diff --git a/build.gradle b/build.gradle index dda01d6..bfa7d4c 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,13 @@ repositories { includeGroup "maven.modrinth" } } + maven { + name = "AzureDoom Mods" + url = "https://maven.azuredoom.com/mods" + content { + includeGroup "mod.azure.azurelib" + } + } // Warium VS is not on Modrinth, it is compiled against the jar in libs/ flatDir { dir 'libs' @@ -73,9 +80,12 @@ dependencies { compileOnly files('libs/vs-core-api-1.1.0.jar', 'libs/vs-core-util-1.1.0.jar', 'libs/joml-primitives-1.10.0.jar') compileOnly 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22' - // Warium VS 1.1.6 and Futurism 0.0.4 (optional dependencies, the flight physics mixins only apply when they are present) - compileOnly fg.deobf("blank:WariumVS:1.1.6") + // Warium VS 1.1.7 and Futurism 0.0.4 (optional dependencies, the flight physics mixins only apply when they are present) + compileOnly fg.deobf("blank:WariumVS:1.1.7") compileOnly fg.deobf("blank:futurism:0.0.4") + + // 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}") } diff --git a/gradle.properties b/gradle.properties index 6d3bc8f..584895d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,6 +13,7 @@ mod_id=improved_aerodynamics mod_name=VS: Improved Aerodynamics mod_license=MIT mod_version=0.3.0 +azurelib_version=3.2.7 mod_group_id=dev.lumareth.improvedaerodynamics mod_authors=Lumareth mod_description=Valkyrien Skies addon that keeps terrain streaming ahead of fast ships and smooths out Warium flight physics, with an optional flight model with real stalls and auto trim. diff --git a/libs/WariumVS-1.1.6.jar b/libs/WariumVS-1.1.7.jar similarity index 85% rename from libs/WariumVS-1.1.6.jar rename to libs/WariumVS-1.1.7.jar index 58e0b44..54c7a11 100644 Binary files a/libs/WariumVS-1.1.6.jar and b/libs/WariumVS-1.1.7.jar differ diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/Config.java b/src/main/java/dev/lumareth/improvedAerodynamics/Config.java index ea9440f..ee2610c 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/Config.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/Config.java @@ -131,12 +131,58 @@ public final class Config { .comment("Auto trim: below this airspeed (blocks per second) the trim holds its current value") .defineInRange("trimMinSpeed", 10.0D, 0.0D, 100.0D); + static { + BUILDER.pop(); + BUILDER.comment("Vectored afterburner nozzle block").push("nozzle"); + } + + public static final ForgeConfigSpec.DoubleValue NOZZLE_MAX_DEFLECTION = BUILDER + .comment("Largest angle in degrees the nozzle can swing to either side") + .defineInRange("maxDeflectionDegrees", 20.0D, 1.0D, 45.0D); + + public static final ForgeConfigSpec.DoubleValue NOZZLE_SLEW_RATE = BUILDER + .comment("Fastest the nozzle swings, in degrees per second, so it never snaps to a new angle") + .defineInRange("slewRateDegreesPerSecond", 90.0D, 5.0D, 720.0D); + + public static final ForgeConfigSpec.DoubleValue AFTERBURNER_MIN_THROTTLE = BUILDER + .comment("Lowest throttle value on the vehicle control node (0 to 10) at which the afterburner key lights the", + "afterburner. It goes out by itself when the throttle falls below it") + .defineInRange("afterburnerMinThrottle", 8.0D, 0.0D, 10.0D); + + public static final ForgeConfigSpec.ConfigValue EXHAUST_STYLE = BUILDER + .comment("How every jet exhaust looks: Warium's jet exhaust and VTOL exhaust, Futurism's vectored afterburner and", + "this mod's nozzle. wariumvs for Warium's own effect entities, improved for the big flame, particles and", + "afterburner sound of this mod. Warium's engine sound is kept in both") + .defineInList("exhaustStyle", "improved", java.util.List.of("wariumvs", "improved")); + + public static final ForgeConfigSpec.DoubleValue NOZZLE_YAW_MIX = BUILDER + .comment("How much of the yaw input goes into the nozzles' differential deflection. The nozzles are canted, so", + "deflecting them against each other pushes the tail sideways as well as rolling the craft, like on an", + "Su-57. 0 turns yaw vectoring off, a negative value turns it around") + .defineInRange("yawMix", 0.6D, -1.0D, 1.0D); + + public static final ForgeConfigSpec.BooleanValue NOZZLE_LIGHT = BUILDER + .comment("The nozzle gives off light while its afterburner is lit") + .define("lightEnabled", true); + static { BUILDER.pop(); } static final ForgeConfigSpec SPEC = BUILDER.build(); + private static final ForgeConfigSpec.Builder CLIENT_BUILDER = new ForgeConfigSpec.Builder(); + + public static final ForgeConfigSpec.BooleanValue NOZZLE_PARTICLES = CLIENT_BUILDER + .comment("Jet exhaust in the improved style: smoke at dry thrust and fire in afterburner") + .define("particlesEnabled", true); + + public static final ForgeConfigSpec.BooleanValue NOZZLE_SOUND = CLIENT_BUILDER + .comment("Vectored afterburner nozzle: afterburner ignition and heavy afterburner sound") + .define("soundEnabled", true); + + static final ForgeConfigSpec CLIENT_SPEC = CLIENT_BUILDER.build(); + private Config() { } } diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/ConfigCommands.java b/src/main/java/dev/lumareth/improvedAerodynamics/ConfigCommands.java index 01ada46..5d95674 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/ConfigCommands.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/ConfigCommands.java @@ -3,10 +3,13 @@ package dev.lumareth.improvedAerodynamics; import com.mojang.brigadier.arguments.ArgumentType; import com.mojang.brigadier.arguments.BoolArgumentType; import com.mojang.brigadier.arguments.DoubleArgumentType; +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 net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; import net.minecraftforge.common.ForgeConfigSpec; @@ -22,7 +25,11 @@ import java.util.List; * All of them are read live, so changes apply immediately. */ public final class ConfigCommands { - private record Option(String name, ForgeConfigSpec.ConfigValue value, ArgumentType argument, Class type) { + private record Option(String name, ForgeConfigSpec.ConfigValue value, ArgumentType argument, Class type, + List choices) { + Option(String name, ForgeConfigSpec.ConfigValue value, ArgumentType argument, Class type) { + this(name, value, argument, type, List.of()); + } } private static final List> OPTIONS = List.of( @@ -44,6 +51,12 @@ public final class ConfigCommands { new Option<>("trim_authority", Config.TRIM_AUTHORITY, 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), + new Option<>("nozzle_slew_rate", Config.NOZZLE_SLEW_RATE, DoubleArgumentType.doubleArg(), Double.class), + new Option<>("afterburner_min_throttle", Config.AFTERBURNER_MIN_THROTTLE, DoubleArgumentType.doubleArg(), Double.class), + new Option<>("exhaust_style", Config.EXHAUST_STYLE, StringArgumentType.word(), String.class, List.of("wariumvs", "improved")), + new Option<>("nozzle_yaw_mix", Config.NOZZLE_YAW_MIX, DoubleArgumentType.doubleArg(), Double.class), + new Option<>("nozzle_light", Config.NOZZLE_LIGHT, BoolArgumentType.bool(), Boolean.class), new Option<>("lookahead_seconds", Config.LOOKAHEAD_SECONDS, DoubleArgumentType.doubleArg(), Double.class)); private ConfigCommands() { @@ -66,6 +79,8 @@ public final class ConfigCommands { for (Option option : OPTIONS) { root.then(optionNode(option)); } + root.then(SeedgenCommands.node()); + root.then(NozzleCommands.node()); LiteralCommandNode node = event.getDispatcher().register(root); event.getDispatcher().register(Commands.literal("aero") .requires(ConfigCommands::mayUse) @@ -76,10 +91,12 @@ public final class ConfigCommands { private static LiteralArgumentBuilder optionNode(Option option) { return Commands.literal(option.name()) .executes(context -> { - context.getSource().sendSuccess(() -> Component.literal(option.name() + " = " + option.value().get()), false); + context.getSource().sendSuccess(() -> Component.literal(option.name() + " = " + option.value().get() + + (option.choices().isEmpty() ? "" : " (" + String.join(" or ", option.choices()) + ")")), false); return 1; }) .then(Commands.argument("value", option.argument()) + .suggests((context, builder) -> SharedSuggestionProvider.suggest(option.choices(), builder)) .executes(context -> set(context.getSource(), option, context.getArgument("value", option.type())))); } @@ -89,7 +106,8 @@ public final class ConfigCommands { ForgeConfigSpec.Range range = spec.getRange(); source.sendFailure(Component.literal(range != null ? option.name() + " must be between " + range.getMin() + " and " + range.getMax() - : "Invalid value for " + option.name())); + : option.choices().isEmpty() ? "Invalid value for " + option.name() + : option.name() + " must be " + String.join(" or ", option.choices()))); return 0; } option.value().set(newValue); diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java index b1ce383..706cb4c 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/ImprovedAerodynamics.java @@ -1,6 +1,8 @@ package dev.lumareth.improvedAerodynamics; import com.mojang.logging.LogUtils; +import dev.lumareth.improvedAerodynamics.exhaust.ExhaustSync; +import dev.lumareth.improvedAerodynamics.exhaust.NozzleContent; import dev.lumareth.improvedAerodynamics.flight.PhysicsBridge; import dev.lumareth.improvedAerodynamics.network.Network; import dev.lumareth.improvedAerodynamics.terrain.LookaheadLoader; @@ -8,6 +10,7 @@ import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.fml.ModLoadingContext; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.config.ModConfig; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; import org.slf4j.Logger; @Mod(ImprovedAerodynamics.MOD_ID) @@ -20,7 +23,10 @@ public class ImprovedAerodynamics { MinecraftForge.EVENT_BUS.register(LookaheadLoader.class); MinecraftForge.EVENT_BUS.register(PhysicsBridge.class); MinecraftForge.EVENT_BUS.register(ConfigCommands.class); + MinecraftForge.EVENT_BUS.register(ExhaustSync.class); ModLoadingContext.get().registerConfig(ModConfig.Type.SERVER, Config.SPEC); + ModLoadingContext.get().registerConfig(ModConfig.Type.CLIENT, Config.CLIENT_SPEC); + NozzleContent.register(FMLJavaModLoadingContext.get().getModEventBus()); Network.register(); } } diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/SeedgenCommands.java b/src/main/java/dev/lumareth/improvedAerodynamics/SeedgenCommands.java new file mode 100644 index 0000000..9d72971 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/SeedgenCommands.java @@ -0,0 +1,95 @@ +package dev.lumareth.improvedAerodynamics; + +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import dev.lumareth.improvedAerodynamics.terrain.AirCeiling; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.coordinates.BlockPosArgument; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; + +import java.util.OptionalInt; + +/** + * {@code /aero seedgen}: measures where the air starts in one real chunk, for worlds where the air placeholder's + * noise based surface estimate does not work (flat worlds and the like). + * {@code set } picks the chunk containing that position in the current dimension, {@code start} reads its + * blocks and saves the height above which only air remains, {@code clear} forgets it again. + * Pick a chunk that is as high as the tallest terrain you want ships to ignore, and has no builds in it. + */ +final class SeedgenCommands { + private record Selection(ResourceKey dimension, ChunkPos chunk) { + } + + private static Selection selection; + + private SeedgenCommands() { + } + + static LiteralArgumentBuilder node() { + return Commands.literal("seedgen") + .executes(context -> status(context.getSource())) + .then(Commands.literal("set") + .then(Commands.argument("pos", BlockPosArgument.blockPos()) + .executes(context -> set(context.getSource(), BlockPosArgument.getBlockPos(context, "pos"))))) + .then(Commands.literal("start").executes(context -> start(context.getSource()))) + .then(Commands.literal("clear").executes(context -> clear(context.getSource()))); + } + + private static int set(CommandSourceStack source, BlockPos pos) { + ChunkPos chunk = new ChunkPos(pos); + selection = new Selection(source.getLevel().dimension(), chunk); + source.sendSuccess(() -> Component.literal("Chunk " + chunk.x + " " + chunk.z + " in " + + source.getLevel().dimension().location() + " selected, run /aero seedgen start to analyze it"), true); + return 1; + } + + private static int start(CommandSourceStack source) { + if (selection == null) { + source.sendFailure(Component.literal("No chunk selected, use /aero seedgen set first")); + return 0; + } + ServerLevel level = source.getServer().getLevel(selection.dimension()); + if (level == null) { + source.sendFailure(Component.literal("Dimension " + selection.dimension().location() + " is not loaded")); + return 0; + } + ChunkPos chunk = selection.chunk(); + AirCeiling.Analysis analysis = AirCeiling.analyze(level, chunk); + if (!analysis.complete()) { + source.sendFailure(Component.literal("Chunk " + chunk.x + " " + chunk.z + " still has blocks at y=" + + analysis.highestSolid() + ", the top of what is read (" + AirCeiling.SCAN_MIN_Y + " to " + + AirCeiling.SCAN_MAX_Y + "), so the air may start higher. Pick a different chunk")); + return 0; + } + AirCeiling.get(level).set(level.dimension(), analysis.airHeight()); + source.sendSuccess(() -> Component.literal("Only air above y=" + analysis.highestSolid() + " in chunk " + chunk.x + " " + + chunk.z + ", saved y=" + analysis.airHeight() + " for " + level.dimension().location() + + ". The air placeholder hands VS air from there up" + (Config.ENABLE_AIR_PLACEHOLDER.get() ? "" + : " (turn it on with /aero air_placeholder true)")), true); + return 1; + } + + private static int clear(CommandSourceStack source) { + ServerLevel level = source.getLevel(); + boolean removed = AirCeiling.get(level).clear(level.dimension()); + source.sendSuccess(() -> Component.literal(removed ? "Measured air height of " + level.dimension().location() + + " removed, the placeholder estimates it from world generation again" + : "No measured air height for " + level.dimension().location()), true); + return removed ? 1 : 0; + } + + private static int status(CommandSourceStack source) { + ServerLevel level = source.getLevel(); + OptionalInt height = AirCeiling.get(level).ceiling(level.dimension()); + String selected = selection != null ? "chunk " + selection.chunk().x + " " + selection.chunk().z + " in " + + selection.dimension().location() : "none"; + source.sendSuccess(() -> Component.literal("Selected: " + selected + "\nMeasured air height in " + + level.dimension().location() + ": " + (height.isPresent() ? "y=" + height.getAsInt() : "none, estimated from world generation")), false); + return 1; + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/client/KeyBindings.java b/src/main/java/dev/lumareth/improvedAerodynamics/client/KeyBindings.java index edeea51..ce39fa0 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/client/KeyBindings.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/client/KeyBindings.java @@ -3,6 +3,7 @@ package dev.lumareth.improvedAerodynamics.client; import com.mojang.blaze3d.platform.InputConstants; import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics; import dev.lumareth.improvedAerodynamics.network.Network; +import dev.lumareth.improvedAerodynamics.network.ToggleAfterburnerPacket; import dev.lumareth.improvedAerodynamics.network.ToggleAutoTrimPacket; import net.minecraft.client.KeyMapping; import net.minecraft.client.Minecraft; @@ -18,6 +19,9 @@ public final class KeyBindings { public static final KeyMapping TOGGLE_AUTO_TRIM = new KeyMapping("key.improved_aerodynamics.toggle_auto_trim", KeyConflictContext.IN_GAME, InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_J, "key.categories.improved_aerodynamics"); + public static final KeyMapping TOGGLE_AFTERBURNER = new KeyMapping("key.improved_aerodynamics.toggle_afterburner", + KeyConflictContext.IN_GAME, InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_K, "key.categories.improved_aerodynamics"); + private KeyBindings() { } @@ -29,6 +33,7 @@ public final class KeyBindings { @SubscribeEvent public static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) { event.register(TOGGLE_AUTO_TRIM); + event.register(TOGGLE_AFTERBURNER); } } @@ -50,6 +55,12 @@ public final class KeyBindings { Network.CHANNEL.sendToServer(new ToggleAutoTrimPacket()); } } + while (TOGGLE_AFTERBURNER.consumeClick()) { + if (minecraft.player != null && minecraft.player.getVehicle() != null && minecraft.getConnection() != null + && Network.CHANNEL.isRemotePresent(minecraft.getConnection().getConnection())) { + Network.CHANNEL.sendToServer(new ToggleAfterburnerPacket()); + } + } } } } diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/AfterburnerToggle.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/AfterburnerToggle.java new file mode 100644 index 0000000..b7d75d9 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/AfterburnerToggle.java @@ -0,0 +1,48 @@ +package dev.lumareth.improvedAerodynamics.exhaust; + +import dev.lumareth.improvedAerodynamics.Config; +import dev.lumareth.improvedAerodynamics.flight.AutoTrimToggle; +import net.mcreator.valkyrienwarium.entity.SeatEntityEntity; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.block.entity.BlockEntity; + +/** + * Server side of the afterburner key. The switch lives in the control node, like auto trim, so it stays with the + * craft and every nozzle linked to the node follows it. It only lights with enough throttle and goes out for good + * once the throttle falls below that, see {@link NozzleBlockEntity}. + */ +public final class AfterburnerToggle { + /** Tag on the control node that holds the switch. */ + public static final String TAG = "improved_aerodynamics_afterburner"; + + private AfterburnerToggle() { + } + + public static void toggle(ServerPlayer player) { + Entity vehicle = player.getVehicle(); + if (!(vehicle instanceof SeatEntityEntity)) { + player.displayClientMessage(Component.translatable("message.improved_aerodynamics.afterburner.no_seat"), true); + return; + } + BlockEntity node = AutoTrimToggle.findNode(vehicle.level(), vehicle.blockPosition()); + if (node == null) { + player.displayClientMessage(Component.translatable("message.improved_aerodynamics.afterburner.no_node"), true); + return; + } + + CompoundTag tag = node.getPersistentData(); + double minimum = Config.AFTERBURNER_MIN_THROTTLE.get(); + if (!tag.getBoolean(TAG) && tag.getDouble("Throttle") < minimum) { + player.displayClientMessage(Component.translatable("message.improved_aerodynamics.afterburner.throttle", (int) Math.ceil(minimum)), true); + return; + } + boolean enabled = !tag.getBoolean(TAG); + tag.putBoolean(TAG, enabled); + node.setChanged(); + player.displayClientMessage(Component.translatable(enabled + ? "message.improved_aerodynamics.afterburner.on" : "message.improved_aerodynamics.afterburner.off"), true); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/ExhaustSync.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/ExhaustSync.java new file mode 100644 index 0000000..9401d49 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/ExhaustSync.java @@ -0,0 +1,77 @@ +package dev.lumareth.improvedAerodynamics.exhaust; + +import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics; +import dev.lumareth.improvedAerodynamics.network.ExhaustPacket; +import dev.lumareth.improvedAerodynamics.network.Network; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.event.server.ServerStoppedEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.network.PacketDistributor; +import org.joml.Vector3d; +import org.valkyrienskies.mod.common.VSGameUtilsKt; + +import java.util.HashMap; +import java.util.Map; + +/** + * Tells the players near a burning jet exhaust about it, for the improved exhaust style. A packet goes out when the + * exhaust changes and otherwise every few ticks, which keeps the flame alive on the clients, see {@code ExhaustFlames}. + */ +public final class ExhaustSync { + private static final int RESEND_TICKS = 4; + private static final double RANGE = 256.0; + private static boolean announced; + + private record Sent(long tick, Vec3 direction, int intensity, boolean afterburner) { + } + + private static final Map, Long2ObjectOpenHashMap> SENT = new HashMap<>(); + + private ExhaustSync() { + } + + /** + * @param key the block the exhaust belongs to, one flame per key + * @param exit where the flame starts, in the block's coordinates + * @param direction unit vector the exhaust moves along + * @param intensity 0 to 1 + */ + public static void emit(ServerLevel level, BlockPos key, Vec3 exit, Vec3 direction, float intensity, boolean afterburner) { + Long2ObjectOpenHashMap sent = SENT.computeIfAbsent(level.dimension(), dimension -> new Long2ObjectOpenHashMap<>()); + long now = level.getGameTime(); + int quantized = Math.round(Math.max(0.0F, Math.min(1.0F, intensity)) * 100.0F); + Sent last = sent.get(key.asLong()); + if (last != null && now - last.tick() < RESEND_TICKS && last.intensity() == quantized && last.afterburner() == afterburner + && last.direction().distanceToSqr(direction) < 1.0E-4) { + return; + } + sent.put(key.asLong(), new Sent(now, direction, quantized, afterburner)); + ExhaustPacket packet = new ExhaustPacket(key, exit.subtract(key.getX(), key.getY(), key.getZ()), direction, quantized, afterburner); + // Near where the exhaust is in the world: on a ship the block itself is far out in the ship's own chunks, which + // nobody is tracking in the usual way + Vec3 world = exit; + var ship = VSGameUtilsKt.getShipManagingPos(level, key); + if (ship != null) { + Vector3d transformed = ship.getShipToWorld().transformPosition(new Vector3d(exit.x, exit.y, exit.z)); + world = new Vec3(transformed.x, transformed.y, transformed.z); + } + Vec3 point = world; + Network.CHANNEL.send(PacketDistributor.NEAR.with(() -> new PacketDistributor.TargetPoint(point.x, point.y, point.z, + RANGE, level.dimension())), packet); + if (!announced) { + announced = true; + ImprovedAerodynamics.LOGGER.info("Sent the first improved exhaust flame, at {} (world {}) to the players within {} blocks", + key.toShortString(), point, RANGE); + } + } + + @SubscribeEvent + public static void onServerStopped(ServerStoppedEvent event) { + SENT.clear(); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/ExhaustThrust.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/ExhaustThrust.java new file mode 100644 index 0000000..8250271 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/ExhaustThrust.java @@ -0,0 +1,46 @@ +package dev.lumareth.improvedAerodynamics.exhaust; + +import net.mcreator.valkyrienwarium.ships.ForceData; +import net.mcreator.valkyrienwarium.ships.ForceDirectionMode; +import net.mcreator.valkyrienwarium.ships.ForceInducedShips; +import net.mcreator.valkyrienwarium.ships.ForceMode; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3d; +import org.valkyrienskies.core.api.ships.LoadedServerShip; +import org.valkyrienskies.core.api.ships.ServerShip; +import org.valkyrienskies.mod.common.VSGameUtilsKt; + +/** + * Thrust of a nozzle, the way Futurism's vectored afterburner makes it: the power comes from the Warium jet exhaust + * directly behind the nozzle, which hands it over through its {@code ExhaustPower} value instead of pushing the ship + * itself because the nozzle is in the {@code warium_vs:blocksjet} tag. The push goes opposite to the way the exhaust + * leaves, so a nozzle deflected upwards pushes the tail down. Only used where Warium VS is installed. + */ +final class ExhaustThrust { + private ExhaustThrust() { + } + + /** The power the jet exhaust directly behind the nozzle hands over, 0 if there is none or it is off. */ + static double power(Level level, BlockPos pos, Direction facing) { + BlockEntity exhaust = level.getBlockEntity(pos.relative(facing.getOpposite())); + return exhaust != null ? exhaust.getPersistentData().getDouble("ExhaustPower") : 0.0; + } + + static void apply(ServerLevel level, BlockPos pos, Direction facing, Vec3 exhaustDirection) { + double power = power(level, pos, facing); + if (power <= 0.0) { + return; + } + LoadedServerShip ship = VSGameUtilsKt.getLoadedShipManagingPos(level, pos); + if (ship == null || ((ServerShip) ship).isStatic()) { + return; + } + Vector3d push = new Vector3d(-exhaustDirection.x, -exhaustDirection.y, -exhaustDirection.z); + ForceInducedShips.getOrCreate(ship).addForce(pos, new ForceData(push, power, ForceMode.POSITION, ForceDirectionMode.SHIP)); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/ImprovedExhaust.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/ImprovedExhaust.java new file mode 100644 index 0000000..49de285 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/ImprovedExhaust.java @@ -0,0 +1,116 @@ +package dev.lumareth.improvedAerodynamics.exhaust; + +import dev.lumareth.improvedAerodynamics.Config; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.TagKey; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.LevelAccessor; +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.phys.Vec3; + +/** + * The improved exhaust style, see {@code exhaustStyle} in the config. It applies to every block that makes a jet exhaust: + * Warium's jet exhaust and VTOL exhaust, Futurism's vectored afterburner behind which a jet exhaust sits, and this mod's + * nozzle. In the other style Warium's own effect entities draw the flame and none of this runs. + *

+ * Warium's exhausts do not spawn their effect entities then. When one would have, it sends a burning flame to the clients instead, from the + * block in front if that one takes over the thrust (the {@code blocksjet} tag), at its exit and along its deflection. The + * engine behind the exhaust says how hard it works ({@code Power}) and whether its afterburner burns. Warium's sound is + * kept. + */ +public final class ImprovedExhaust { + private static final double FULL_POWER = 100.0; + private static final TagKey TAKES_OVER_THRUST = TagKey.create(Registries.BLOCK, new ResourceLocation("warium_vs", "blocksjet")); + + private ImprovedExhaust() { + } + + public static boolean isImproved() { + try { + return "improved".equals(Config.EXHAUST_STYLE.get()); + } catch (IllegalStateException e) { + // The config is not loaded yet + return true; + } + } + + // What the exhaust being ticked would have spawned, it only ticks on the server thread and one at a time + private static boolean jetEffect; + private static boolean afterburnerEffect; + + /** Called when an exhaust starts its tick. */ + public static void begin() { + jetEffect = false; + afterburnerEffect = false; + } + + /** + * Used instead of adding Warium's effect entity. The exhaust only makes one when it burns, so that is the signal to + * send the improved flame; in the improved style the entity itself is not added, in the other it is, as always. + */ + public static boolean addEffectEntity(ServerLevel level, Entity entity) { + if (!isImproved()) { + return level.addFreshEntity(entity); + } + var key = net.minecraftforge.registries.ForgeRegistries.ENTITY_TYPES.getKey(entity.getType()); + if (key != null && key.getPath().contains("after")) { + afterburnerEffect = true; + } else { + jetEffect = true; + } + return false; + } + + /** Sends the flame of the exhaust at this position for this tick. */ + public static void plume(LevelAccessor accessor, double x, double y, double z, BlockState state) { + Direction facing = facingOf(state); + if (!isImproved() || !(accessor instanceof ServerLevel level) || facing == null) { + return; + } + if (!jetEffect && !afterburnerEffect) { + return; + } + BlockPos pos = BlockPos.containing(x, y, z); + boolean afterburner = afterburnerEffect; + // How hard it works, if the engine behind says so, otherwise a flame that is clearly there + BlockEntity engine = level.getBlockEntity(pos.relative(facing.getOpposite())); + double power = engine != null ? engine.getPersistentData().getDouble("Power") : 0.0; + float intensity = power > 0.0 ? (float) Math.min(1.0, 0.3 + power / FULL_POWER) : (afterburner ? 1.0F : 0.8F); + + Vec3 exit = NozzleGeometry.exit(pos, facing); + Vec3 direction = new Vec3(facing.getStepX(), facing.getStepY(), facing.getStepZ()); + BlockPos frontPos = pos.relative(facing); + BlockState front = level.getBlockState(frontPos); + if (front.is(TAKES_OVER_THRUST)) { + if (front.getBlock() instanceof NozzleBlock) { + // The nozzle draws its own flame, swinging with its nozzle + return; + } + // Futurism's vectored afterburner: its flame leaves its exit, tilted by its vector value + Direction frontFacing = facingOf(front) != null ? facingOf(front) : facing; + BlockEntity frontEntity = level.getBlockEntity(frontPos); + double tilt = frontEntity != null ? frontEntity.getPersistentData().getDouble("Vectorpitch") : 0.0; + exit = NozzleGeometry.exit(frontPos, frontFacing); + direction = new Vec3(frontFacing.getStepX(), frontFacing.getStepY() + tilt, frontFacing.getStepZ()).normalize(); + } + ExhaustSync.emit(level, pos, exit, direction, intensity, afterburner); + } + + /** The direction a Warium block faces, from its property called facing, which is not always the same property. */ + public static Direction facingOf(BlockState state) { + for (Property property : state.getProperties()) { + if (property instanceof DirectionProperty direction && property.getName().equals("facing")) { + return state.getValue(direction); + } + } + return null; + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleBlock.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleBlock.java new file mode 100644 index 0000000..50739a9 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleBlock.java @@ -0,0 +1,89 @@ +package dev.lumareth.improvedAerodynamics.exhaust; + +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.DirectionalBlock; +import net.minecraft.world.level.block.Mirror; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.block.SoundType; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityTicker; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.material.MapColor; +import net.minecraft.world.phys.BlockHitResult; +import org.jetbrains.annotations.Nullable; + +/** + * Jet engine nozzle with thrust vectoring. Faces the way its exhaust leaves, like Warium's jet exhausts, and is drawn by + * AzureLib. Sneak-use with an empty hand switches it between left and right engine. + */ +public class NozzleBlock extends BaseEntityBlock { + public static final BooleanProperty LIT = BooleanProperty.create("lit"); + + public NozzleBlock() { + super(Properties.of().mapColor(MapColor.METAL).strength(3.0F, 6.0F).requiresCorrectToolForDrops() + .sound(SoundType.METAL).noOcclusion().lightLevel(state -> state.getValue(LIT) ? 13 : 0)); + registerDefaultState(stateDefinition.any().setValue(DirectionalBlock.FACING, net.minecraft.core.Direction.NORTH) + .setValue(LIT, false)); + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(DirectionalBlock.FACING, LIT); + } + + @Override + public BlockState getStateForPlacement(BlockPlaceContext context) { + return defaultBlockState().setValue(DirectionalBlock.FACING, context.getNearestLookingDirection().getOpposite()); + } + + @Override + public BlockState rotate(BlockState state, Rotation rotation) { + return state.setValue(DirectionalBlock.FACING, rotation.rotate(state.getValue(DirectionalBlock.FACING))); + } + + @Override + public BlockState mirror(BlockState state, Mirror mirror) { + return state.rotate(mirror.getRotation(state.getValue(DirectionalBlock.FACING))); + } + + @Override + public RenderShape getRenderShape(BlockState state) { + return RenderShape.ENTITYBLOCK_ANIMATED; + } + + @Override + public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand, BlockHitResult hit) { + if (!player.isShiftKeyDown() || !player.getItemInHand(hand).isEmpty()) { + return InteractionResult.PASS; + } + if (!level.isClientSide && level.getBlockEntity(pos) instanceof NozzleBlockEntity nozzle) { + nozzle.setRightEngine(!nozzle.isRightEngine()); + player.displayClientMessage(Component.translatable(nozzle.isRightEngine() + ? "message.improved_aerodynamics.nozzle.right" : "message.improved_aerodynamics.nozzle.left"), true); + } + return InteractionResult.sidedSuccess(level.isClientSide); + } + + @Nullable + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new NozzleBlockEntity(pos, state); + } + + @Nullable + @Override + public BlockEntityTicker getTicker(Level level, BlockState state, BlockEntityType type) { + return createTickerHelper(type, NozzleContent.NOZZLE_ENTITY.get(), NozzleBlockEntity::tick); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleBlockEntity.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleBlockEntity.java new file mode 100644 index 0000000..930a5d4 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleBlockEntity.java @@ -0,0 +1,281 @@ +package dev.lumareth.improvedAerodynamics.exhaust; + +import dev.lumareth.improvedAerodynamics.Config; +import dev.lumareth.improvedAerodynamics.exhaust.client.NozzleClientEffects; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.network.Connection; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.DirectionalBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; +import org.jetbrains.annotations.Nullable; + +/** + * State of one vectored afterburner nozzle. The server owns it: whatever drives the nozzle (a debug command now, flight + * logic later) calls {@link #setDeflectionTarget}, {@link #setThrottle} and {@link #setAfterburner}, the rest follows + * from them. Everything the client needs to draw the nozzle is in the update tag, so players that join or reload the + * chunk see the right state. + */ +public class NozzleBlockEntity extends BlockEntity { + // Warium's throttle goes from 0 to 10 + private static final float MAX_NODE_THROTTLE = 10.0F; + private static final boolean WARIUM_LOADED = net.minecraftforge.fml.ModList.get().isLoaded("valkyrien_warium"); + // What changes are worth an update packet: a quarter degree, two percent throttle + private static final float DEFLECTION_STEP = (float) Math.toRadians(0.25); + private static final float THROTTLE_STEP = 0.02F; + + private float deflectionTarget; + private float deflectionPrevious; + private float deflectionCurrent; + private float throttle; + private boolean afterburnerRequested; + private boolean afterburnerAuto; + private boolean afterburner; + private boolean rightEngine; + + // Last values sent to the clients + private int sentDeflection; + private int sentThrottle; + private boolean sentAfterburner; + private boolean sentRightEngine; + + /** The client's sound and particle state, owned by {@link NozzleClientEffects}. */ + @Nullable + private Object clientEffects; + + public NozzleBlockEntity(BlockPos pos, BlockState state) { + super(NozzleContent.NOZZLE_ENTITY.get(), pos, state); + } + + // ---- API ---- + + /** Sets where the nozzle should swing to, in radians, positive turns the nozzle's rotation bone positively. */ + public void setDeflectionTarget(float radians) { + float limit = (float) Math.toRadians(Config.NOZZLE_MAX_DEFLECTION.get()); + deflectionTarget = Math.max(-limit, Math.min(limit, radians)); + changed(); + } + + /** Sets the engine throttle, 0 to 1. Only matters for a nozzle that is not linked to a control node. */ + public void setThrottle(float throttle) { + this.throttle = Math.max(0.0F, Math.min(1.0F, throttle)); + changed(); + } + + /** Forces the afterburner on, independent of the control node. Switching it off leaves it to the afterburner key again. */ + public void setAfterburner(boolean on) { + afterburnerRequested = on; + changed(); + } + + public void setRightEngine(boolean right) { + rightEngine = right; + changed(); + } + + public float getDeflectionTarget() { + return deflectionTarget; + } + + /** The deflection being drawn, eased towards the target, interpolated between ticks. */ + public float getDeflection(float partialTick) { + return deflectionPrevious + (deflectionCurrent - deflectionPrevious) * partialTick; + } + + public float getThrottle() { + return throttle; + } + + public boolean isAfterburner() { + return afterburner; + } + + public boolean isRightEngine() { + return rightEngine; + } + + public Direction getFacing() { + return getBlockState().getValue(DirectionalBlock.FACING); + } + + // ---- Ticking ---- + + public static void tick(Level level, BlockPos pos, BlockState state, NozzleBlockEntity nozzle) { + nozzle.tick(level); + } + + private void tick(Level level) { + if (!level.isClientSide) { + drive(level); + } + float maxStep = (float) Math.toRadians(Config.NOZZLE_SLEW_RATE.get() / 20.0); + deflectionPrevious = deflectionCurrent; + deflectionCurrent += Math.max(-maxStep, Math.min(maxStep, deflectionTarget - deflectionCurrent)); + + if (level.isClientSide) { + NozzleClientEffects.tick(this); + return; + } + boolean lit = afterburnerRequested || afterburnerAuto; + if (lit != afterburner) { + afterburner = lit; + setChanged(); + } + sync(); + boolean light = lit && Config.NOZZLE_LIGHT.get(); + BlockState state = getBlockState(); + if (state.getValue(NozzleBlock.LIT) != light) { + level.setBlock(worldPosition, state.setValue(NozzleBlock.LIT, light), 3); + } + if (WARIUM_LOADED && level instanceof ServerLevel serverLevel) { + ExhaustThrust.apply(serverLevel, worldPosition, getFacing(), + NozzleGeometry.exhaustDirection(getFacing(), deflectionCurrent, rightEngine)); + } + } + + /** + * Takes throttle, deflection and afterburner from the linked control node. A nozzle without a link keeps what the + * API or the debug command gave it. + */ + private void drive(Level level) { + NozzleControl.Inputs inputs = NozzleControl.read(level, this); + if (inputs == null) { + afterburnerAuto = false; + return; + } + // No flame without a running engine behind the nozzle: the jet exhaust there hands over its power + boolean running = !WARIUM_LOADED || ExhaustThrust.power(level, worldPosition, getFacing()) > 0.0; + throttle = running ? Math.min(1.0F, (float) inputs.throttle() / MAX_NODE_THROTTLE) : 0.0F; + float pitch = (float) inputs.pitchUp(); + float roll = (float) inputs.rollRight(); + deflectionTarget = NozzleMixing.deflection(pitch, roll, (float) inputs.yawRight(), rightEngine); + double minimum = Config.AFTERBURNER_MIN_THROTTLE.get(); + if (inputs.afterburnerSwitch() && inputs.throttle() < minimum) { + // Too little throttle: out, and it stays out until the key is pressed again + inputs.node().getPersistentData().putBoolean(AfterburnerToggle.TAG, false); + inputs.node().setChanged(); + } + afterburnerAuto = running && inputs.afterburnerSwitch() && inputs.throttle() >= minimum; + } + + /** Saves the state, and tells the clients if something they can see changed. */ + private void changed() { + setChanged(); + sync(); + } + + private void sync() { + if (level == null || level.isClientSide) { + return; + } + int deflection = Math.round(deflectionTarget / DEFLECTION_STEP); + int throttleStep = Math.round(throttle / THROTTLE_STEP); + if (deflection != sentDeflection || throttleStep != sentThrottle || afterburner != sentAfterburner || rightEngine != sentRightEngine) { + sentDeflection = deflection; + sentThrottle = throttleStep; + sentAfterburner = afterburner; + sentRightEngine = rightEngine; + level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), UPDATE_CLIENTS); + } + } + + private static final int UPDATE_CLIENTS = 2; + + // ---- Saving and syncing ---- + + @Override + protected void saveAdditional(CompoundTag tag) { + super.saveAdditional(tag); + tag.putFloat("Deflection", deflectionTarget); + tag.putFloat("Throttle", throttle); + tag.putBoolean("Afterburner", afterburner); + tag.putBoolean("AfterburnerRequested", afterburnerRequested); + tag.putBoolean("RightEngine", rightEngine); + } + + @Override + public void load(CompoundTag tag) { + super.load(tag); + deflectionTarget = tag.getFloat("Deflection"); + throttle = tag.getFloat("Throttle"); + afterburner = tag.getBoolean("Afterburner"); + afterburnerRequested = tag.getBoolean("AfterburnerRequested"); + afterburnerAuto = afterburner && !afterburnerRequested; + rightEngine = tag.getBoolean("RightEngine"); + if (level == null || !level.isClientSide || deflectionCurrent == 0.0F && deflectionPrevious == 0.0F) { + // Loaded fresh: start at the target instead of swinging in from zero + deflectionCurrent = deflectionTarget; + deflectionPrevious = deflectionTarget; + } + sentDeflection = Math.round(deflectionTarget / DEFLECTION_STEP); + sentThrottle = Math.round(throttle / THROTTLE_STEP); + sentAfterburner = afterburner; + sentRightEngine = rightEngine; + } + + @Override + public CompoundTag getUpdateTag() { + return saveWithoutMetadata(); + } + + @Override + public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } + + @Override + public void onDataPacket(Connection connection, ClientboundBlockEntityDataPacket packet) { + CompoundTag tag = packet.getTag(); + if (tag != null) { + // Warium's key configuration screen reads the keys from the block's persistent data on the client, so that + // has to arrive too. Not load() as a whole: the deflection on screen must keep easing from where it is + super.load(tag); + deflectionTarget = tag.getFloat("Deflection"); + throttle = tag.getFloat("Throttle"); + afterburner = tag.getBoolean("Afterburner"); + rightEngine = tag.getBoolean("RightEngine"); + } + } + + // ---- Client ---- + + @Nullable + public Object getClientEffects() { + return clientEffects; + } + + public void setClientEffects(@Nullable Object clientEffects) { + this.clientEffects = clientEffects; + } + + @Override + public AABB getRenderBoundingBox() { + // The nozzle swings out of the block and the flame trails behind it + return new AABB(worldPosition).inflate(2.0); + } + + @Override + public void setRemoved() { + super.setRemoved(); + stopEffects(); + } + + @Override + public void onChunkUnloaded() { + super.onChunkUnloaded(); + stopEffects(); + } + + private void stopEffects() { + if (level != null && level.isClientSide) { + NozzleClientEffects.stop(this); + } + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleCommands.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleCommands.java new file mode 100644 index 0000000..1e16075 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleCommands.java @@ -0,0 +1,70 @@ +package dev.lumareth.improvedAerodynamics.exhaust; + +import com.mojang.brigadier.arguments.BoolArgumentType; +import com.mojang.brigadier.arguments.FloatArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.coordinates.BlockPosArgument; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; + +import java.util.function.Consumer; + +/** + * {@code /aero nozzle ...}: moves a vectored afterburner nozzle by hand, for testing it without the flight system. + * {@code deflection }, {@code throttle <0..1>}, {@code afterburner } and {@code right }. + */ +public final class NozzleCommands { + private NozzleCommands() { + } + + public static LiteralArgumentBuilder node() { + return Commands.literal("nozzle").then(Commands.argument("pos", BlockPosArgument.blockPos()) + .executes(context -> status(context.getSource(), BlockPosArgument.getLoadedBlockPos(context, "pos"))) + .then(Commands.literal("deflection").then(Commands.argument("degrees", FloatArgumentType.floatArg(-90.0F, 90.0F)) + .executes(context -> { + float degrees = FloatArgumentType.getFloat(context, "degrees"); + return modify(context.getSource(), BlockPosArgument.getLoadedBlockPos(context, "pos"), + nozzle -> nozzle.setDeflectionTarget((float) Math.toRadians(degrees))); + }))) + .then(Commands.literal("throttle").then(Commands.argument("throttle", FloatArgumentType.floatArg(0.0F, 1.0F)) + .executes(context -> { + float throttle = FloatArgumentType.getFloat(context, "throttle"); + return modify(context.getSource(), BlockPosArgument.getLoadedBlockPos(context, "pos"), + nozzle -> nozzle.setThrottle(throttle)); + }))) + .then(Commands.literal("afterburner").then(Commands.argument("on", BoolArgumentType.bool()) + .executes(context -> { + boolean on = BoolArgumentType.getBool(context, "on"); + return modify(context.getSource(), BlockPosArgument.getLoadedBlockPos(context, "pos"), + nozzle -> nozzle.setAfterburner(on)); + }))) + .then(Commands.literal("right").then(Commands.argument("right", BoolArgumentType.bool()) + .executes(context -> { + boolean right = BoolArgumentType.getBool(context, "right"); + return modify(context.getSource(), BlockPosArgument.getLoadedBlockPos(context, "pos"), + nozzle -> nozzle.setRightEngine(right)); + })))); + } + + private static int modify(CommandSourceStack source, BlockPos pos, Consumer change) { + if (!(source.getLevel().getBlockEntity(pos) instanceof NozzleBlockEntity nozzle)) { + source.sendFailure(Component.literal("There is no vectored afterburner nozzle at " + pos.toShortString())); + return 0; + } + change.accept(nozzle); + return status(source, pos); + } + + private static int status(CommandSourceStack source, BlockPos pos) { + if (!(source.getLevel().getBlockEntity(pos) instanceof NozzleBlockEntity nozzle)) { + source.sendFailure(Component.literal("There is no vectored afterburner nozzle at " + pos.toShortString())); + return 0; + } + source.sendSuccess(() -> Component.literal(String.format("Nozzle at %s: deflection %.1f deg, throttle %.2f, afterburner %s, %s engine", + pos.toShortString(), Math.toDegrees(nozzle.getDeflectionTarget()), nozzle.getThrottle(), + nozzle.isAfterburner() ? "on" : "off", nozzle.isRightEngine() ? "right" : "left")), false); + return 1; + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleContent.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleContent.java new file mode 100644 index 0000000..2c97f77 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleContent.java @@ -0,0 +1,46 @@ +package dev.lumareth.improvedAerodynamics.exhaust; + +import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.CreativeModeTab; +import net.minecraft.world.item.Item; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraftforge.eventbus.api.IEventBus; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; + +/** Registry entries of the vectored afterburner nozzle. */ +public final class NozzleContent { + private static final DeferredRegister BLOCKS = DeferredRegister.create(ForgeRegistries.BLOCKS, ImprovedAerodynamics.MOD_ID); + private static final DeferredRegister ITEMS = DeferredRegister.create(ForgeRegistries.ITEMS, ImprovedAerodynamics.MOD_ID); + private static final DeferredRegister> BLOCK_ENTITIES = + DeferredRegister.create(ForgeRegistries.BLOCK_ENTITY_TYPES, ImprovedAerodynamics.MOD_ID); + private static final DeferredRegister TABS = + DeferredRegister.create(Registries.CREATIVE_MODE_TAB, ImprovedAerodynamics.MOD_ID); + + public static final RegistryObject NOZZLE = BLOCKS.register("vectored_afterburner_nozzle", NozzleBlock::new); + public static final RegistryObject NOZZLE_ITEM = ITEMS.register("vectored_afterburner_nozzle", + () -> new BlockItem(NOZZLE.get(), new Item.Properties())); + public static final RegistryObject> NOZZLE_ENTITY = BLOCK_ENTITIES.register("vectored_afterburner_nozzle", + () -> BlockEntityType.Builder.of(NozzleBlockEntity::new, NOZZLE.get()).build(null)); + + public static final RegistryObject TAB = TABS.register("main", () -> CreativeModeTab.builder() + .title(Component.translatable("itemGroup." + ImprovedAerodynamics.MOD_ID)) + .icon(() -> NOZZLE_ITEM.get().getDefaultInstance()) + .displayItems((parameters, output) -> output.accept(NOZZLE_ITEM.get())) + .build()); + + private NozzleContent() { + } + + public static void register(IEventBus modBus) { + BLOCKS.register(modBus); + ITEMS.register(modBus); + BLOCK_ENTITIES.register(modBus); + TABS.register(modBus); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleControl.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleControl.java new file mode 100644 index 0000000..d8987d7 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleControl.java @@ -0,0 +1,68 @@ +package dev.lumareth.improvedAerodynamics.exhaust; + +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraftforge.registries.ForgeRegistries; +import org.jetbrains.annotations.Nullable; + +/** + * What a nozzle reads from the vehicle control node it was linked to with Warium's linker, and how its two keys, set + * 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. + */ +final class NozzleControl { + private static final ResourceLocation NODE_BLOCK = new ResourceLocation("valkyrien_warium", "vehicle_control_node"); + + /** The control node's state in node units: pitch, roll and yaw from -1 to 1, throttle from 0 to 10. Yaw needs no key, D is right. */ + record Inputs(BlockEntity node, double pitchUp, double rollRight, double yawRight, double throttle, boolean afterburnerSwitch) { + } + + private NozzleControl() { + } + + @Nullable + static Inputs read(Level level, NozzleBlockEntity nozzle) { + CompoundTag own = nozzle.getPersistentData(); + if (!own.contains("ControlX")) { + return null; + } + BlockPos nodePos = BlockPos.containing(own.getDouble("ControlX"), own.getDouble("ControlY"), own.getDouble("ControlZ")); + if (!level.isLoaded(nodePos) || !ForgeRegistries.BLOCKS.getKey(level.getBlockState(nodePos).getBlock()).equals(NODE_BLOCK)) { + return null; + } + BlockEntity node = level.getBlockEntity(nodePos); + if (node == null) { + return null; + } + CompoundTag tag = node.getPersistentData(); + 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); + return new Inputs(node, pitch, roll, yaw, Math.max(0.0, tag.getDouble("Throttle")), tag.getBoolean(AfterburnerToggle.TAG)); + } + + /** +1 for axis+, -1 for axis-, from the first key that names the axis, the fallback if neither does. */ + private static double sign(String key, String key2, String axis, double fallback) { + for (String candidate : new String[]{key, key2}) { + if (candidate.equals(axis + "+")) { + return 1.0; + } + if (candidate.equals(axis + "-")) { + return -1.0; + } + } + return fallback; + } + + private static double clamp(double value) { + return Math.max(-1.0, Math.min(1.0, value)); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleGeometry.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleGeometry.java new file mode 100644 index 0000000..23d547f --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleGeometry.java @@ -0,0 +1,55 @@ +package dev.lumareth.improvedAerodynamics.exhaust; + +import net.minecraft.core.Direction; +import net.minecraft.world.phys.Vec3; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +/** + * Where the exhaust leaves the nozzle and which way it goes, in the block's own coordinates (ship coordinates on a + * ship). Matches the model: the nozzle sits in the north half of the block, tilted by the hinge bone and swung by the + * rotation bone, and the block is turned by its facing the way AzureLib turns the model. + */ +public final class NozzleGeometry { + /** Static tilt of the hinge bone around the thrust axis, in degrees, see the model. */ + private static final float HINGE_TILT = 32.5F; + /** The right engine swings about the mirrored axis, so the same deflection turns the opposite way there. */ + public static final float RIGHT_ENGINE_SIGN = -1.0F; + + private NozzleGeometry() { + } + + /** Center of the nozzle exit, on the block face the exhaust leaves through. */ + public static Vec3 exit(net.minecraft.core.BlockPos pos, Direction facing) { + return new Vec3(pos.getX() + 0.5 + facing.getStepX() * 0.5, pos.getY() + 0.5 + facing.getStepY() * 0.5, + pos.getZ() + 0.5 + facing.getStepZ() * 0.5); + } + + /** + * Unit vector the exhaust moves along. At no deflection it is the facing, a deflection swings it around the axis the + * rotation bone turns about: the hinge's tilt applied to X for the left engine, to Y for the right one. + */ + public static Vec3 exhaustDirection(Direction facing, float deflection, boolean rightEngine) { + Vector3f swing = new Vector3f(0.0F, 0.0F, -1.0F); + swing.rotateAxis((rightEngine ? RIGHT_ENGINE_SIGN : 1.0F) * deflection, rightEngine ? 0.0F : 1.0F, rightEngine ? 1.0F : 0.0F, 0.0F); + // The deflection is about an axis turned by the hinge, the counter bone turns the nozzle back + swing.rotateZ((float) Math.toRadians(HINGE_TILT)); + swing.rotate(facingRotation(facing)); + return new Vec3(swing.x, swing.y, swing.z); + } + + /** The turn AzureLib applies to a model for a block facing, from facing north. */ + private static Quaternionf facingRotation(Direction facing) { + Quaternionf rotation = new Quaternionf(); + switch (facing) { + case SOUTH -> rotation.rotateY((float) Math.toRadians(180)); + case WEST -> rotation.rotateY((float) Math.toRadians(90)); + case EAST -> rotation.rotateY((float) Math.toRadians(270)); + case UP -> rotation.rotateX((float) Math.toRadians(90)); + case DOWN -> rotation.rotateX((float) Math.toRadians(-90)); + default -> { + } + } + return rotation; + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleMixing.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleMixing.java new file mode 100644 index 0000000..bbf2671 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/NozzleMixing.java @@ -0,0 +1,29 @@ +package dev.lumareth.improvedAerodynamics.exhaust; + +import dev.lumareth.improvedAerodynamics.Config; + +/** + * Turns pilot inputs into the deflection of one nozzle of a twin engine craft: the same sign on both engines pitches, + * opposite signs roll and yaw. The nozzles hinge on canted axes, so deflecting them against each other turns the exhaust + * sideways as well as up and down: the vertical parts make a rolling moment, the sideways parts push the tail around. + * That coupling is how an Su-57 yaws with its nozzles, so roll and yaw share the one differential channel. Positive + * deflection always means the exhaust swings up, on both sides. Not tied to any input source, whatever drives the + * nozzles calls this and then {@link NozzleBlockEntity#setDeflectionTarget}. + */ +public final class NozzleMixing { + private NozzleMixing() { + } + + /** + * @param pitchUp -1 to 1, positive raises the nose, which takes the exhaust up + * @param rollRight -1 to 1, positive rolls the plane right, which takes the right engine's exhaust up and the left one's down + * @param yawRight -1 to 1, positive yaws the nose right. The left engine's exhaust swings up and the right one's down, + * which for this model's canting turns both sideways to the left and pushes the tail left + * @return target deflection in radians, within the configured limit + */ + public static float deflection(float pitchUp, float rollRight, float yawRight, boolean rightEngine) { + float differential = rollRight - yawRight * Config.NOZZLE_YAW_MIX.get().floatValue(); + float mixed = Math.max(-1.0F, Math.min(1.0F, pitchUp + (rightEngine ? differential : -differential))); + return mixed * (float) Math.toRadians(Config.NOZZLE_MAX_DEFLECTION.get()); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/ExhaustFlames.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/ExhaustFlames.java new file mode 100644 index 0000000..3ece0db --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/ExhaustFlames.java @@ -0,0 +1,144 @@ +package dev.lumareth.improvedAerodynamics.exhaust.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import dev.lumareth.improvedAerodynamics.Config; +import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics; +import dev.lumareth.improvedAerodynamics.exhaust.ImprovedExhaust; +import dev.lumareth.improvedAerodynamics.network.ExhaustPacket; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.RenderLevelStageEvent; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.level.LevelEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import org.joml.Matrix4dc; +import org.joml.Quaternionf; +import org.joml.Vector3d; +import org.joml.Vector3f; +import org.valkyrienskies.core.api.ships.ClientShip; +import org.valkyrienskies.mod.common.VSGameUtilsKt; + +import java.util.Iterator; + +/** + * The flames of Warium's jet exhausts in the improved style. The server says which exhausts burn, see {@code ExhaustSync}, + * and each stays alive on the client for a few ticks after its last message, fading out. They are drawn after the + * particles, in the world, on ships with the ship's interpolated transform, and give off their particles here. + */ +@Mod.EventBusSubscriber(modid = ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT) +public final class ExhaustFlames { + private static final int LIFETIME_TICKS = 10; + private static final int FADE_TICKS = 4; + + private record Flame(BlockPos pos, Vec3 exit, Vec3 direction, float intensity, boolean afterburner, long expires) { + } + + private static final Long2ObjectOpenHashMap FLAMES = new Long2ObjectOpenHashMap<>(); + + private static boolean announced; + + private ExhaustFlames() { + } + + public static void accept(ExhaustPacket packet) { + Level level = Minecraft.getInstance().level; + if (level == null) { + return; + } + if (!announced) { + announced = true; + ImprovedAerodynamics.LOGGER.info("Received the first improved exhaust flame, at {}", packet.pos().toShortString()); + } + Vec3 exit = packet.exitOffset().add(packet.pos().getX(), packet.pos().getY(), packet.pos().getZ()); + FLAMES.put(packet.pos().asLong(), new Flame(packet.pos(), exit, packet.direction(), packet.intensity() / 100.0F, + packet.afterburner(), level.getGameTime() + LIFETIME_TICKS)); + } + + @SubscribeEvent + public static void onClientTick(TickEvent.ClientTickEvent event) { + Level level = Minecraft.getInstance().level; + if (event.phase != TickEvent.Phase.END || level == null || FLAMES.isEmpty()) { + return; + } + if (!ImprovedExhaust.isImproved()) { + FLAMES.clear(); + return; + } + boolean particles = Config.NOZZLE_PARTICLES.get(); + Iterator iterator = FLAMES.values().iterator(); + while (iterator.hasNext()) { + Flame flame = iterator.next(); + if (level.getGameTime() > flame.expires()) { + iterator.remove(); + } else if (particles) { + Vec3 world = transform(level, flame.pos(), flame.exit(), false, 1.0F); + Vec3 direction = transform(level, flame.pos(), flame.direction(), true, 1.0F); + ExhaustParticles.emit(level, world, direction, flame.intensity(), flame.afterburner()); + } + } + } + + @SubscribeEvent + public static void onRenderLevel(RenderLevelStageEvent event) { + Level level = Minecraft.getInstance().level; + if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_PARTICLES || level == null || FLAMES.isEmpty() + || !ImprovedExhaust.isImproved()) { + return; + } + float partialTick = event.getPartialTick(); + Vec3 camera = event.getCamera().getPosition(); + MultiBufferSource.BufferSource buffers = Minecraft.getInstance().renderBuffers().bufferSource(); + VertexConsumer buffer = buffers.getBuffer(FlameRenderer.RENDER_TYPE); + for (Flame flame : FLAMES.values()) { + // Only afterburners burn with the big flame, dry thrust is smoke + if (!flame.afterburner()) { + continue; + } + float fade = Math.min(1.0F, (flame.expires() - level.getGameTime() + partialTick) / FADE_TICKS); + Vec3 exit = transform(level, flame.pos(), flame.exit(), false, partialTick); + Vec3 direction = transform(level, flame.pos(), flame.direction(), true, partialTick); + + PoseStack poseStack = event.getPoseStack(); + poseStack.pushPose(); + poseStack.translate(exit.x - camera.x, exit.y - camera.y, exit.z - camera.z); + poseStack.mulPose(new Quaternionf().rotationTo(new Vector3f(0.0F, 0.0F, -1.0F), + new Vector3f((float) direction.x, (float) direction.y, (float) direction.z))); + PoseStack.Pose pose = poseStack.last(); + FlameRenderer.draw(buffer, pose.pose(), pose.normal(), 0.0F, 0.0F, 0.0F, flame.intensity(), + level.getGameTime() + partialTick, Math.max(0.0F, fade)); + poseStack.popPose(); + } + buffers.endBatch(FlameRenderer.RENDER_TYPE); + } + + @SubscribeEvent + public static void onLevelUnload(LevelEvent.Unload event) { + if (event.getLevel().isClientSide()) { + FLAMES.clear(); + } + } + + /** A position or direction in the block's coordinates as one in the world, differs on a ship. */ + private static Vec3 transform(Level level, BlockPos pos, Vec3 vector, boolean direction, float partialTick) { + var ship = VSGameUtilsKt.getShipManagingPos(level, pos); + if (ship == null) { + return vector; + } + Matrix4dc shipToWorld = ship instanceof ClientShip client && partialTick != 1.0F ? client.getRenderTransform().getShipToWorld() + : ship.getShipToWorld(); + Vector3d result = new Vector3d(vector.x, vector.y, vector.z); + if (direction) { + shipToWorld.transformDirection(result); + } else { + shipToWorld.transformPosition(result); + } + return new Vec3(result.x, result.y, result.z); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/ExhaustParticles.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/ExhaustParticles.java new file mode 100644 index 0000000..ce9d8f9 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/ExhaustParticles.java @@ -0,0 +1,44 @@ +package dev.lumareth.improvedAerodynamics.exhaust.client; + +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; + +/** The particles of a jet exhaust in the improved style, for the nozzle and for Warium's exhausts alike. */ +final class ExhaustParticles { + private ExhaustParticles() { + } + + /** + * @param exit where the exhaust leaves, in the world + * @param direction unit vector the exhaust moves along, in the world + * @param intensity 0 to 1 + */ + static void emit(Level level, Vec3 exit, Vec3 direction, float intensity, boolean afterburner) { + RandomSource random = level.getRandom(); + if (afterburner) { + // A hot plume: fire, a blue core and a heavy smoke trail behind it + for (int i = 0; i < 4; i++) { + spawn(level, random, ParticleTypes.FLAME, exit, direction, 0.8 + 1.4 * intensity, 0.1); + } + for (int i = 0; i < 2; i++) { + spawn(level, random, ParticleTypes.SOUL_FIRE_FLAME, exit, direction, 1.2 + 1.6 * intensity, 0.03); + } + spawn(level, random, ParticleTypes.LARGE_SMOKE, exit, direction, 0.7 + 0.9 * intensity, 0.12); + if (random.nextFloat() < 0.4F) { + spawn(level, random, ParticleTypes.CAMPFIRE_COSY_SMOKE, exit, direction, 0.5, 0.15); + } + } else if (random.nextFloat() < intensity) { + spawn(level, random, ParticleTypes.LARGE_SMOKE, exit, direction, 0.4 + 0.6 * intensity, 0.06); + } + } + + private static void spawn(Level level, RandomSource random, ParticleOptions type, Vec3 position, Vec3 direction, double speed, double spread) { + level.addParticle(type, position.x + (random.nextDouble() - 0.5) * 0.3, position.y + (random.nextDouble() - 0.5) * 0.3, + position.z + (random.nextDouble() - 0.5) * 0.3, + direction.x * speed + (random.nextDouble() - 0.5) * spread, direction.y * speed + (random.nextDouble() - 0.5) * spread, + direction.z * speed + (random.nextDouble() - 0.5) * spread); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/FlameRenderer.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/FlameRenderer.java new file mode 100644 index 0000000..f929191 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/FlameRenderer.java @@ -0,0 +1,90 @@ +package dev.lumareth.improvedAerodynamics.exhaust.client; + +import com.mojang.blaze3d.vertex.VertexConsumer; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.ResourceLocation; +import org.joml.Matrix3f; +import org.joml.Matrix4f; + +/** + * The afterburner flame: glowing cones with a dull red halo, an orange body, a yellow middle and a blue-white core, + * shock diamonds along the axis like a real afterburner and a bright glow at the exit. Length and brightness follow the + * throttle and everything flickers. They use the plain white texture and vertex colors, so no model is needed. The flame + * points along -Z from the origin it is drawn at, the caller turns and places it. + */ +final class FlameRenderer { + static final RenderType RENDER_TYPE = RenderType.entityTranslucentEmissive(new ResourceLocation("textures/misc/white.png")); + // Radius at the base, and length range + private static final float BASE_RADIUS = 0.4F; + private static final float MIN_LENGTH = 1.8F; + private static final float LENGTH_RANGE = 4.5F; + private static final int PLANES = 3; + private static final int DIAMONDS = 5; + // Radius factor, length factor, red, green, blue, alpha at the base + private static final float[][] CONES = { + {1.45F, 1.10F, 1.00F, 0.25F, 0.05F, 0.22F}, + {1.00F, 1.00F, 1.00F, 0.45F, 0.10F, 0.55F}, + {0.70F, 0.82F, 1.00F, 0.75F, 0.30F, 0.75F}, + {0.38F, 0.58F, 0.85F, 0.92F, 1.00F, 0.95F}, + }; + + private FlameRenderer() { + } + + /** + * @param origin where the flame starts, in the space of the matrix + * @param throttle 0 to 1, how long and bright the flame is + * @param time ticks with partial tick, drives the flicker + * @param fade 0 to 1, extra brightness factor for flames that are dying down + */ + static void draw(VertexConsumer buffer, Matrix4f matrix, Matrix3f normal, float originX, float originY, float originZ, + float throttle, float time, float fade) { + float flicker = 0.9F + 0.07F * (float) Math.sin(time * 1.9F) + 0.05F * (float) Math.sin(time * 4.3F + 1.0F) + + 0.03F * (float) Math.sin(time * 9.1F); + float length = (MIN_LENGTH + LENGTH_RANGE * throttle) * flicker * (0.4F + 0.6F * fade); + float brightness = (0.55F + 0.45F * throttle) * fade; + Draw draw = new Draw(buffer, matrix, normal, originX, originY); + + for (float[] cone : CONES) { + float pulse = 1.0F + 0.06F * (float) Math.sin(time * 5.0F + cone[0] * 7.0F); + draw.cone(originZ, originZ - length * cone[1], BASE_RADIUS * cone[0] * pulse, + cone[2], cone[3], cone[4], Math.min(1.0F, cone[5] * brightness)); + } + + // Shock diamonds: two cones back to back, spaced along the flame, shrinking and pulsing + for (int i = 0; i < DIAMONDS; i++) { + float center = originZ - length * (0.14F + 0.15F * i); + float radius = BASE_RADIUS * 0.34F * (1.0F - 0.13F * i) * (0.85F + 0.15F * (float) Math.sin(time * 6.0F + i * 1.7F)); + float half = length * 0.055F; + float alpha = Math.min(1.0F, 0.95F * brightness * (1.0F - 0.1F * i)); + draw.cone(center, center - half, radius, 0.75F, 0.9F, 1.0F, alpha); + draw.cone(center, center + half, radius, 0.75F, 0.9F, 1.0F, alpha); + } + + // Bright glow right at the exit + draw.cone(originZ, originZ - 0.35F, BASE_RADIUS * 0.9F, 1.0F, 0.95F, 0.8F, 0.9F * brightness); + } + + /** The buffer and placement of one flame, so the cones only need to say where they are. */ + private record Draw(VertexConsumer buffer, Matrix4f matrix, Matrix3f normal, float x, float y) { + /** A cone of {@code PLANES} crossed triangles from a base circle at baseZ to a point at tipZ. */ + void cone(float baseZ, float tipZ, float radius, float red, float green, float blue, float baseAlpha) { + for (int plane = 0; plane < PLANES; plane++) { + double angle = Math.PI * plane / PLANES; + float dx = (float) Math.cos(angle) * radius; + float dy = (float) Math.sin(angle) * radius; + vertex(x - dx, y - dy, baseZ, red, green, blue, baseAlpha); + vertex(x + dx, y + dy, baseZ, red, green, blue, baseAlpha); + vertex(x, y, tipZ, red, green, blue, 0.0F); + vertex(x, y, tipZ, red, green, blue, 0.0F); + } + } + + private void vertex(float vx, float vy, float vz, float red, float green, float blue, float alpha) { + buffer.vertex(matrix, vx, vy, vz).color(red, green, blue, alpha).uv(0.5F, 0.5F) + .overlayCoords(OverlayTexture.NO_OVERLAY).uv2(LightTexture.FULL_BRIGHT).normal(normal, 0.0F, 0.0F, -1.0F).endVertex(); + } + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleAnimator.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleAnimator.java new file mode 100644 index 0000000..cb0bda0 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleAnimator.java @@ -0,0 +1,56 @@ +package dev.lumareth.improvedAerodynamics.exhaust.client; + +import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics; +import dev.lumareth.improvedAerodynamics.exhaust.NozzleBlockEntity; +import dev.lumareth.improvedAerodynamics.exhaust.NozzleGeometry; +import mod.azure.azurelib.animation.controller.AzAnimationController; +import mod.azure.azurelib.animation.controller.AzAnimationControllerContainer; +import mod.azure.azurelib.animation.impl.AzBlockAnimator; +import net.minecraft.resources.ResourceLocation; +import org.jetbrains.annotations.NotNull; + +/** + * Swings the nozzle. The model has no keyframes, the animation file is a stub the animator needs anyway; the rotation + * bone is set from code every frame, from its initial pose, so the angle can never drift. + */ +final class NozzleAnimator extends AzBlockAnimator { + static final ResourceLocation ANIMATION = new ResourceLocation(ImprovedAerodynamics.MOD_ID, + "animations/blockentities/nozzle.animation.json"); + private static final String ROTATION_BONE = "rotation"; + /** + * The left engine's nozzle swings about the model's X axis. The right one about Y, the axis that mirrors the hinge's + * tilt, and turns the opposite way for the same deflection, see {@link NozzleGeometry#RIGHT_ENGINE_SIGN}. + */ + private static final float LEFT_SIGN = 1.0F; + + NozzleAnimator() { + super(mod.azure.azurelib.animation.AzAnimatorConfig.defaultConfig()); + } + + @Override + public void registerControllers(AzAnimationControllerContainer container) { + container.add(AzAnimationController.builder(this, "base_controller").build()); + } + + @Override + public @NotNull ResourceLocation getAnimationLocation(NozzleBlockEntity animatable) { + return ANIMATION; + } + + @Override + public void setCustomAnimations(NozzleBlockEntity nozzle, float partialTicks) { + super.setCustomAnimations(nozzle, partialTicks); + var model = context().boneCache().getBakedModel(); + var rotation = model != null ? model.getBoneOrNull(ROTATION_BONE) : null; + if (rotation == null) { + return; + } + float theta = nozzle.getDeflection(partialTicks); + var initial = rotation.getInitialAzSnapshot(); + if (nozzle.isRightEngine()) { + rotation.setRotY(initial.getRotY() + NozzleGeometry.RIGHT_ENGINE_SIGN * theta); + } else { + rotation.setRotX(initial.getRotX() + LEFT_SIGN * theta); + } + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleClientEffects.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleClientEffects.java new file mode 100644 index 0000000..5d79cff --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleClientEffects.java @@ -0,0 +1,113 @@ +package dev.lumareth.improvedAerodynamics.exhaust.client; + +import dev.lumareth.improvedAerodynamics.Config; +import dev.lumareth.improvedAerodynamics.exhaust.ImprovedExhaust; +import dev.lumareth.improvedAerodynamics.exhaust.NozzleBlockEntity; +import dev.lumareth.improvedAerodynamics.exhaust.NozzleGeometry; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.registries.ForgeRegistries; +import org.joml.Vector3d; +import org.valkyrienskies.mod.common.VSGameUtilsKt; + +/** + * Client side sound and particles of the nozzles. Only ever called on the logical client. + *

+ * Sound is Warium's own: its jet engines have no looping sound either, they play a short blast again and again. The jet + * exhaust behind the nozzle does that, the nozzle adds the heavy blast for the afterburner. Nothing here runs in the + * wariumvs exhaust style. + */ +public final class NozzleClientEffects { + private static final SoundSource CATEGORY = SoundSource.BLOCKS; + private static final int HEAVY_BLAST_INTERVAL = 14; + + /** What one nozzle has done so far on this client, kept on the block entity. */ + private static final class State { + boolean wasAfterburner; + } + + private NozzleClientEffects() { + } + + public static void tick(NozzleBlockEntity nozzle) { + Level level = nozzle.getLevel(); + if (level == null) { + return; + } + State state = (State) nozzle.getClientEffects(); + if (state == null) { + state = new State(); + state.wasAfterburner = nozzle.isAfterburner(); + nozzle.setClientEffects(state); + } + + if (!ImprovedExhaust.isImproved()) { + // Warium's own effect, which comes from the exhaust behind the nozzle, is wanted + state.wasAfterburner = nozzle.isAfterburner(); + return; + } + if (Config.NOZZLE_SOUND.get() && nozzle.isAfterburner()) { + playBlasts(level, nozzle, !state.wasAfterburner); + } + state.wasAfterburner = nozzle.isAfterburner(); + + if (Config.NOZZLE_PARTICLES.get() && nozzle.getThrottle() > 0.05F) { + emitParticles(level, nozzle); + } + } + + /** Nothing keeps running on the client, so there is nothing to stop but the remembered state. */ + public static void stop(NozzleBlockEntity nozzle) { + nozzle.setClientEffects(null); + } + + private static void playBlasts(Level level, NozzleBlockEntity nozzle, boolean ignition) { + RandomSource random = level.getRandom(); + Vec3 exit = NozzleGeometry.exit(nozzle.getBlockPos(), nozzle.getFacing()); + Vec3 position = toWorld(level, exit.add(NozzleGeometry.exhaustDirection(nozzle.getFacing(), 0.0F, false).scale(4.0))); + long time = level.getGameTime() + nozzle.getBlockPos().hashCode(); + // Warium's jet exhaust behind the nozzle already plays the engine blast, the afterburner adds the heavy one + if (ignition || time % HEAVY_BLAST_INTERVAL == 0) { + play(level, "heavyjetblast", position, ignition ? 30.0F : 18.0F, (float) (0.95 + random.nextDouble() * 0.1)); + } + } + + /** Plays one of Warium's sounds, nothing if Warium is not installed. */ + private static void play(Level level, String name, Vec3 position, float volume, float pitch) { + SoundEvent sound = ForgeRegistries.SOUND_EVENTS.getValue(new ResourceLocation("valkyrien_warium", name)); + if (sound != null) { + level.playLocalSound(position.x, position.y, position.z, sound, CATEGORY, volume, pitch, false); + } + } + + private static void emitParticles(Level level, NozzleBlockEntity nozzle) { + Vec3 exit = NozzleGeometry.exit(nozzle.getBlockPos(), nozzle.getFacing()); + Vec3 direction = NozzleGeometry.exhaustDirection(nozzle.getFacing(), nozzle.getDeflection(1.0F), nozzle.isRightEngine()); + ExhaustParticles.emit(level, toWorld(level, exit), toWorldDirection(level, nozzle.getBlockPos(), direction), + nozzle.getThrottle(), nozzle.isAfterburner()); + } + + /** A position in the block's coordinates as a position in the world, differs on a ship. */ + public static Vec3 toWorld(Level level, Vec3 position) { + var ship = VSGameUtilsKt.getShipManagingPos(level, position.x, position.y, position.z); + if (ship == null) { + return position; + } + Vector3d world = ship.getShipToWorld().transformPosition(new Vector3d(position.x, position.y, position.z)); + return new Vec3(world.x, world.y, world.z); + } + + private static Vec3 toWorldDirection(Level level, BlockPos pos, Vec3 direction) { + var ship = VSGameUtilsKt.getShipManagingPos(level, pos); + if (ship == null) { + return direction; + } + Vector3d world = ship.getShipToWorld().transformDirection(new Vector3d(direction.x, direction.y, direction.z)); + return new Vec3(world.x, world.y, world.z); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleClientSetup.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleClientSetup.java new file mode 100644 index 0000000..a1032fe --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleClientSetup.java @@ -0,0 +1,25 @@ +package dev.lumareth.improvedAerodynamics.exhaust.client; + +import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics; +import dev.lumareth.improvedAerodynamics.exhaust.NozzleContent; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.EntityRenderersEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.common.Mod; + +/** Registers the nozzle's AzureLib renderer, if AzureLib is installed. */ +@Mod.EventBusSubscriber(modid = ImprovedAerodynamics.MOD_ID, value = Dist.CLIENT, bus = Mod.EventBusSubscriber.Bus.MOD) +public final class NozzleClientSetup { + private NozzleClientSetup() { + } + + @SubscribeEvent + public static void onRegisterRenderers(EntityRenderersEvent.RegisterRenderers event) { + if (!ModList.get().isLoaded("azurelib")) { + ImprovedAerodynamics.LOGGER.warn("AzureLib is not installed, the vectored afterburner nozzle will be invisible"); + return; + } + event.registerBlockEntityRenderer(NozzleContent.NOZZLE_ENTITY.get(), context -> new NozzleRenderer()); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleFlameLayer.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleFlameLayer.java new file mode 100644 index 0000000..30f08cc --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleFlameLayer.java @@ -0,0 +1,47 @@ +package dev.lumareth.improvedAerodynamics.exhaust.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import dev.lumareth.improvedAerodynamics.exhaust.ImprovedExhaust; +import dev.lumareth.improvedAerodynamics.exhaust.NozzleBlockEntity; +import mod.azure.azurelib.model.AzBone; +import mod.azure.azurelib.render.AzRendererPipelineContext; +import mod.azure.azurelib.render.layer.AzRenderLayer; + +/** + * The afterburner flame of the nozzle, drawn in the space of the nozzle's last bone so it swings with the nozzle, see + * {@link FlameRenderer}. + */ +final class NozzleFlameLayer implements AzRenderLayer { + private static final String NOZZLE_BONE = "counter"; + // The middle of the nozzle exit in the model's own coordinates, in blocks + private static final float EXIT_X = 0.5F; + private static final float EXIT_Y = 0.5F; + private static final float EXIT_Z = 0.0F; + + @Override + public void preRender(AzRendererPipelineContext context) { + } + + @Override + public void render(AzRendererPipelineContext context) { + } + + @Override + public void renderForBone(AzRendererPipelineContext context, AzBone bone) { + NozzleBlockEntity nozzle = context.animatable(); + if (!NOZZLE_BONE.equals(bone.getName()) || !nozzle.isAfterburner() || nozzle.getLevel() == null + || !ImprovedExhaust.isImproved()) { + return; + } + PoseStack.Pose pose = context.poseStack().last(); + VertexConsumer buffer = context.multiBufferSource().getBuffer(FlameRenderer.RENDER_TYPE); + FlameRenderer.draw(buffer, pose.pose(), pose.normal(), EXIT_X, EXIT_Y, EXIT_Z, nozzle.getThrottle(), + nozzle.getLevel().getGameTime() + context.partialTick(), 1.0F); + } + + @Override + public boolean sortsTranslucency() { + return false; + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleModelRenderer.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleModelRenderer.java new file mode 100644 index 0000000..9d4b3d7 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleModelRenderer.java @@ -0,0 +1,24 @@ +package dev.lumareth.improvedAerodynamics.exhaust.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import dev.lumareth.improvedAerodynamics.exhaust.NozzleBlockEntity; +import mod.azure.azurelib.render.AzLayerRenderer; +import mod.azure.azurelib.render.block.AzBlockEntityModelRenderer; +import mod.azure.azurelib.render.block.AzBlockEntityRendererPipeline; +import net.minecraft.core.Direction; + +/** + * AzureLib puts the middle of its models on the middle of the block, the nozzle model is built in the 0 to 16 range of + * the block, so it is moved back by half a block after the block is turned to its facing. + */ +final class NozzleModelRenderer extends AzBlockEntityModelRenderer { + NozzleModelRenderer(AzBlockEntityRendererPipeline pipeline, AzLayerRenderer layerRenderer) { + super(pipeline, layerRenderer); + } + + @Override + protected void rotateBlock(Direction facing, PoseStack poseStack) { + super.rotateBlock(facing, poseStack); + poseStack.translate(-0.5, 0.0, -0.5); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleRenderer.java b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleRenderer.java new file mode 100644 index 0000000..c89487f --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/exhaust/client/NozzleRenderer.java @@ -0,0 +1,25 @@ +package dev.lumareth.improvedAerodynamics.exhaust.client; + +import dev.lumareth.improvedAerodynamics.ImprovedAerodynamics; +import dev.lumareth.improvedAerodynamics.exhaust.NozzleBlockEntity; +import mod.azure.azurelib.render.block.AzBlockEntityRenderer; +import mod.azure.azurelib.render.block.AzBlockEntityRendererConfig; +import mod.azure.azurelib.render.block.AzBlockEntityRendererPipeline; +import net.minecraft.resources.ResourceLocation; + +/** Draws the vectored afterburner nozzle with AzureLib. */ +final class NozzleRenderer extends AzBlockEntityRenderer { + private static final ResourceLocation MODEL = new ResourceLocation(ImprovedAerodynamics.MOD_ID, + "geo/blockentities/vectored_afterburner_nozzle.geo.json"); + private static final ResourceLocation TEXTURE = new ResourceLocation(ImprovedAerodynamics.MOD_ID, + "textures/blockentities/jetexausttexture.png"); + + NozzleRenderer() { + super(AzBlockEntityRendererConfig.builder(MODEL, TEXTURE) + .setAnimatorProvider(NozzleAnimator::new) + .setModelRenderer((pipeline, layerRenderer) -> + new NozzleModelRenderer((AzBlockEntityRendererPipeline) pipeline, layerRenderer)) + .addRenderLayer(new NozzleFlameLayer()) + .build()); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AutoTrimToggle.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AutoTrimToggle.java index 58da24c..f2b4c84 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/AutoTrimToggle.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/AutoTrimToggle.java @@ -46,7 +46,7 @@ public final class AutoTrimToggle { * The control node the seat under the player is linked to. Warium's control seat block finds its seat entity * within half a block of itself, so the seat block is at the entity's block or just below it. */ - private static BlockEntity findNode(Level level, BlockPos seatPos) { + public static BlockEntity findNode(Level level, BlockPos seatPos) { for (BlockPos pos : new BlockPos[]{seatPos, seatPos.below()}) { BlockEntity seat = level.getBlockEntity(pos); if (seat == null || !seat.getPersistentData().contains("ControlX")) { diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/MixinPlugin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/MixinPlugin.java index d5f11d4..9dce857 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/MixinPlugin.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/MixinPlugin.java @@ -18,7 +18,7 @@ import java.util.Set; */ public final class MixinPlugin implements IMixinConfigPlugin { private static final Logger LOGGER = LoggerFactory.getLogger("improved_aerodynamics"); - private static final String WARIUM_VERSION = "1.1.6"; + private static final String WARIUM_VERSION = "1.1.7"; private static final String FUTURISM_VERSION = "0.0.4"; private boolean warium; diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumAero.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumAero.java index 312dbf0..5931b49 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumAero.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/WariumAero.java @@ -1,7 +1,7 @@ package dev.lumareth.improvedAerodynamics.flight; /** - * Warium VS 1.1.6's aerodynamic forces, term for term as its block procedures compute them, but evaluated from the + * Warium VS 1.1.7's aerodynamic forces, term for term as its block procedures compute them, but evaluated from the * ship's current motion. Like in Warium every block uses the velocity of the ship's center of mass. The procedures' * conditions (air around the block, minimum speed, linked control node) were already checked on the server thread. * Quirks are kept on purpose so craft fly exactly as before: vertical wings have no drag (Warium never sets the @@ -31,7 +31,8 @@ final class WariumAero { switch (s.type()) { case WING -> { out.addForce(r, 0.0, v[1] * Math.abs(v[1]) * -45.0 * p / m, 0.0); - out.addForce(r, 0.0, Math.abs(fx * v[0] + fz * v[2]) * 160.0 * p, 0.0); + // Lift follows the horizontal speed whichever way the wing faces + out.addForce(r, 0.0, Math.hypot(v[0], v[2]) * 160.0 * p, 0.0); double damping = speed * 600.0 * p / m; out.addTorque(-w[0] * damping, -0.1 * w[1] * damping, -w[2] * damping); double drag = -speed * p / m; diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/BypassedCompressorScriptProcedureMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/BypassedCompressorScriptProcedureMixin.java new file mode 100644 index 0000000..c90de52 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/BypassedCompressorScriptProcedureMixin.java @@ -0,0 +1,33 @@ +package dev.lumareth.improvedAerodynamics.flight.mixin.warium; + +import dev.lumareth.improvedAerodynamics.exhaust.ImprovedExhaust; +import net.mcreator.valkyrienwarium.procedures.BypassedCompressorScriptProcedure; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.LevelAccessor; +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.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** The improved exhaust style for the medium bypassed compressor, whose jet effect is an exhaust too: no effect entity, the flame of this mod, see {@link ImprovedExhaust}. */ +@Mixin(value = BypassedCompressorScriptProcedure.class, remap = false) +public abstract class BypassedCompressorScriptProcedureMixin { + @Inject(method = "execute", at = @At("HEAD")) + private static void improvedAerodynamics$begin(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) { + ImprovedExhaust.begin(); + } + + @Redirect(method = "execute", at = @At(value = "INVOKE", + target = "Lnet/minecraft/server/level/ServerLevel;m_7967_(Lnet/minecraft/world/entity/Entity;)Z")) + private static boolean improvedAerodynamics$addEffect(ServerLevel level, Entity entity) { + return ImprovedExhaust.addEffectEntity(level, entity); + } + + @Inject(method = "execute", at = @At("TAIL")) + private static void improvedAerodynamics$plume(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) { + ImprovedExhaust.plume(world, x, y, z, blockstate); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/JetExhaustOnTickUpdateProcedureMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/JetExhaustOnTickUpdateProcedureMixin.java new file mode 100644 index 0000000..3d46317 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/JetExhaustOnTickUpdateProcedureMixin.java @@ -0,0 +1,33 @@ +package dev.lumareth.improvedAerodynamics.flight.mixin.warium; + +import dev.lumareth.improvedAerodynamics.exhaust.ImprovedExhaust; +import net.mcreator.valkyrienwarium.procedures.JetExhaustOnTickUpdateProcedure; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.LevelAccessor; +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.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** The improved exhaust style: no effect entities, a bigger particle plume, see {@link ImprovedExhaust}. */ +@Mixin(value = JetExhaustOnTickUpdateProcedure.class, remap = false) +public abstract class JetExhaustOnTickUpdateProcedureMixin { + @Inject(method = "execute", at = @At("HEAD")) + private static void improvedAerodynamics$begin(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) { + ImprovedExhaust.begin(); + } + + @Redirect(method = "execute", at = @At(value = "INVOKE", + target = "Lnet/minecraft/server/level/ServerLevel;m_7967_(Lnet/minecraft/world/entity/Entity;)Z")) + private static boolean improvedAerodynamics$addEffect(ServerLevel level, Entity entity) { + return ImprovedExhaust.addEffectEntity(level, entity); + } + + @Inject(method = "execute", at = @At("TAIL")) + private static void improvedAerodynamics$plume(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) { + ImprovedExhaust.plume(world, x, y, z, blockstate); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/VTOLExhaustTickProcedureMixin.java b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/VTOLExhaustTickProcedureMixin.java new file mode 100644 index 0000000..dfdc34d --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/flight/mixin/warium/VTOLExhaustTickProcedureMixin.java @@ -0,0 +1,33 @@ +package dev.lumareth.improvedAerodynamics.flight.mixin.warium; + +import dev.lumareth.improvedAerodynamics.exhaust.ImprovedExhaust; +import net.mcreator.valkyrienwarium.procedures.VTOLExhaustTickProcedure; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.LevelAccessor; +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.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** The improved exhaust style for the VTOL exhaust: no effect entity, the flame of this mod, see {@link ImprovedExhaust}. */ +@Mixin(value = VTOLExhaustTickProcedure.class, remap = false) +public abstract class VTOLExhaustTickProcedureMixin { + @Inject(method = "execute", at = @At("HEAD")) + private static void improvedAerodynamics$begin(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) { + ImprovedExhaust.begin(); + } + + @Redirect(method = "execute", at = @At(value = "INVOKE", + target = "Lnet/minecraft/server/level/ServerLevel;m_7967_(Lnet/minecraft/world/entity/Entity;)Z")) + private static boolean improvedAerodynamics$addEffect(ServerLevel level, Entity entity) { + return ImprovedExhaust.addEffectEntity(level, entity); + } + + @Inject(method = "execute", at = @At("TAIL")) + private static void improvedAerodynamics$plume(LevelAccessor world, double x, double y, double z, BlockState blockstate, CallbackInfo ci) { + ImprovedExhaust.plume(world, x, y, z, blockstate); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/network/ExhaustPacket.java b/src/main/java/dev/lumareth/improvedAerodynamics/network/ExhaustPacket.java new file mode 100644 index 0000000..553e58d --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/network/ExhaustPacket.java @@ -0,0 +1,39 @@ +package dev.lumareth.improvedAerodynamics.network; + +import dev.lumareth.improvedAerodynamics.exhaust.client.ExhaustFlames; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.fml.DistExecutor; +import net.minecraftforge.network.NetworkEvent; + +import java.util.function.Supplier; + +/** + * One jet exhaust that burns right now, sent to the players near it every few ticks: where the flame starts and which + * way it points, in the coordinates of the block, which are ship coordinates on a ship. The offset is relative to the + * block because ship coordinates are far out, where floats are too coarse. + * + * @param intensity 0 to 100 + */ +public record ExhaustPacket(BlockPos pos, Vec3 exitOffset, Vec3 direction, int intensity, boolean afterburner) { + public void encode(FriendlyByteBuf buffer) { + buffer.writeBlockPos(pos); + buffer.writeFloat((float) exitOffset.x).writeFloat((float) exitOffset.y).writeFloat((float) exitOffset.z); + buffer.writeFloat((float) direction.x).writeFloat((float) direction.y).writeFloat((float) direction.z); + buffer.writeByte(intensity); + buffer.writeBoolean(afterburner); + } + + public static ExhaustPacket decode(FriendlyByteBuf buffer) { + BlockPos pos = buffer.readBlockPos(); + Vec3 exit = new Vec3(buffer.readFloat(), buffer.readFloat(), buffer.readFloat()); + Vec3 direction = new Vec3(buffer.readFloat(), buffer.readFloat(), buffer.readFloat()); + return new ExhaustPacket(pos, exit, direction, buffer.readByte(), buffer.readBoolean()); + } + + public void handle(Supplier context) { + DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> () -> ExhaustFlames.accept(this)); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java b/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java index 5249949..7ab3755 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/network/Network.java @@ -23,5 +23,15 @@ public final class Network { .decoder(ToggleAutoTrimPacket::decode) .consumerMainThread(ToggleAutoTrimPacket::handle) .add(); + CHANNEL.messageBuilder(ToggleAfterburnerPacket.class, 1, NetworkDirection.PLAY_TO_SERVER) + .encoder(ToggleAfterburnerPacket::encode) + .decoder(ToggleAfterburnerPacket::decode) + .consumerMainThread(ToggleAfterburnerPacket::handle) + .add(); + CHANNEL.messageBuilder(ExhaustPacket.class, 2, NetworkDirection.PLAY_TO_CLIENT) + .encoder(ExhaustPacket::encode) + .decoder(ExhaustPacket::decode) + .consumerMainThread(ExhaustPacket::handle) + .add(); } } diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/network/ToggleAfterburnerPacket.java b/src/main/java/dev/lumareth/improvedAerodynamics/network/ToggleAfterburnerPacket.java new file mode 100644 index 0000000..aba0319 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/network/ToggleAfterburnerPacket.java @@ -0,0 +1,26 @@ +package dev.lumareth.improvedAerodynamics.network; + +import dev.lumareth.improvedAerodynamics.exhaust.AfterburnerToggle; +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 when a player presses the afterburner key, toggles the afterburner on the control node of the seat they sit in. */ +public final class ToggleAfterburnerPacket { + public void encode(FriendlyByteBuf buffer) { + } + + public static ToggleAfterburnerPacket decode(FriendlyByteBuf buffer) { + return new ToggleAfterburnerPacket(); + } + + public void handle(Supplier context) { + ServerPlayer player = context.get().getSender(); + if (player != null && ModList.get().isLoaded("valkyrien_warium")) { + AfterburnerToggle.toggle(player); + } + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/terrain/AirCeiling.java b/src/main/java/dev/lumareth/improvedAerodynamics/terrain/AirCeiling.java new file mode 100644 index 0000000..8af8934 --- /dev/null +++ b/src/main/java/dev/lumareth/improvedAerodynamics/terrain/AirCeiling.java @@ -0,0 +1,95 @@ +package dev.lumareth.improvedAerodynamics.terrain; + +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.saveddata.SavedData; + +import java.util.HashMap; +import java.util.Map; +import java.util.OptionalInt; + +/** + * Per dimension, the height above which terrain is known to be air, measured from one real chunk with + * {@code /aero seedgen}. The air placeholder hands VS only air above it, which beats the noise based estimate in + * worlds where that does not work, like flat or void worlds. Saved with the world. + */ +public final class AirCeiling extends SavedData { + private static final String NAME = "improved_aerodynamics_air_ceilings"; + /** The blocks that are read, clamped to the dimension's build height. */ + public static final int SCAN_MIN_Y = -64; + public static final int SCAN_MAX_Y = 100; + + /** Outcome of reading a chunk. */ + public record Analysis(boolean complete, int highestSolid, int airHeight) { + } + + private final Map ceilings = new HashMap<>(); + + public static AirCeiling get(ServerLevel level) { + return level.getServer().overworld().getDataStorage().computeIfAbsent(AirCeiling::load, AirCeiling::new, NAME); + } + + private static AirCeiling load(CompoundTag tag) { + AirCeiling ceiling = new AirCeiling(); + CompoundTag saved = tag.getCompound("ceilings"); + for (String key : saved.getAllKeys()) { + ceiling.ceilings.put(new ResourceLocation(key), saved.getInt(key)); + } + return ceiling; + } + + @Override + public CompoundTag save(CompoundTag tag) { + CompoundTag saved = new CompoundTag(); + ceilings.forEach((dimension, height) -> saved.putInt(dimension.toString(), height)); + tag.put("ceilings", saved); + return tag; + } + + /** Lowest Y at or above which this dimension has only air, if it was measured. */ + public OptionalInt ceiling(ResourceKey dimension) { + Integer height = ceilings.get(dimension.location()); + return height != null ? OptionalInt.of(height) : OptionalInt.empty(); + } + + public void set(ResourceKey dimension, int height) { + ceilings.put(dimension.location(), height); + setDirty(); + } + + public boolean clear(ResourceKey dimension) { + boolean removed = ceilings.remove(dimension.location()) != null; + if (removed) { + setDirty(); + } + return removed; + } + + /** + * Reads the chunk's blocks (generating the chunk if it does not exist yet) from the top of the scan range down + * and finds the first layer with anything but air in it. Not complete if that layer is the top of the range, + * since then the air may start higher up than what was read. + */ + public static Analysis analyze(ServerLevel level, ChunkPos pos) { + ChunkAccess chunk = level.getChunk(pos.x, pos.z); + int bottom = Math.max(SCAN_MIN_Y, level.getMinBuildHeight()); + int top = Math.min(SCAN_MAX_Y, level.getMaxBuildHeight() - 1); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + for (int y = top; y >= bottom; y--) { + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + if (!chunk.getBlockState(cursor.set(pos.getMinBlockX() + x, y, pos.getMinBlockZ() + z)).isAir()) { + return new Analysis(y < top, y, y + 1); + } + } + } + } + return new Analysis(true, bottom - 1, bottom); + } +} diff --git a/src/main/java/dev/lumareth/improvedAerodynamics/terrain/AirPlaceholder.java b/src/main/java/dev/lumareth/improvedAerodynamics/terrain/AirPlaceholder.java index 5f0f2c9..3e0d756 100644 --- a/src/main/java/dev/lumareth/improvedAerodynamics/terrain/AirPlaceholder.java +++ b/src/main/java/dev/lumareth/improvedAerodynamics/terrain/AirPlaceholder.java @@ -22,11 +22,13 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.OptionalInt; /** * Optional: hands VS empty sections for not yet loaded chunks on a ship's path, so physics sees air there instead of * its solid "unloaded" fallback. Only sections fully above the generator's estimated surface (plus a margin) are * replaced, everything lower stays solid so a ship can never end up inside terrain that has not loaded yet. + * If a height was measured with /aero seedgen, every section from there up is replaced instead. * Once the real chunk loads, VS overwrites every section of it with the actual terrain. *

* This uses VS internals (its terrain update API and the mixin field listing its loaded chunks), so it switches @@ -76,6 +78,8 @@ final class AirPlaceholder { if (enabled) { int budget = Config.MAX_NEW_CHUNKS_PER_TICK.get(); int surfaceMargin = Config.PLACEHOLDER_SURFACE_MARGIN.get(); + // Measured with /aero seedgen, replaces the estimate (and its margin) for this dimension + OptionalInt airHeight = AirCeiling.get(level).ceiling(level.dimension()); for (long chunk : corridor) { Placeholder placeholder = placeholders.get(chunk); if (placeholder != null) { @@ -90,7 +94,8 @@ final class AirPlaceholder { if (budget-- <= 0) { break; } - int minSectionY = Math.floorDiv(estimateSurface(level, pos) + surfaceMargin, 16) + 1; + int minSectionY = airHeight.isPresent() ? Math.floorDiv(airHeight.getAsInt() + 15, 16) + : Math.floorDiv(estimateSurface(level, pos) + surfaceMargin, 16) + 1; int maxSectionY = level.getMaxSection() - 1; if (minSectionY > maxSectionY) { continue; diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml index 9248f23..6595a9c 100644 --- a/src/main/resources/META-INF/mods.toml +++ b/src/main/resources/META-INF/mods.toml @@ -45,3 +45,11 @@ mandatory = false versionRange = "[0.0.4,)" ordering = "AFTER" side = "BOTH" + +# Optional, animated block models +[[dependencies."${mod_id}"]] +modId = "azurelib" +mandatory = false +versionRange = "[3.2.7,)" +ordering = "AFTER" +side = "BOTH" diff --git a/src/main/resources/assets/improved_aerodynamics/animations/blockentities/nozzle.animation.json b/src/main/resources/assets/improved_aerodynamics/animations/blockentities/nozzle.animation.json new file mode 100644 index 0000000..bd99bff --- /dev/null +++ b/src/main/resources/assets/improved_aerodynamics/animations/blockentities/nozzle.animation.json @@ -0,0 +1,8 @@ +{ + "format_version": "1.8.0", + "animations": { + "animation.model.nozzle": { + "loop": true + } + } +} \ No newline at end of file diff --git a/src/main/resources/assets/improved_aerodynamics/blockstates/vectored_afterburner_nozzle.json b/src/main/resources/assets/improved_aerodynamics/blockstates/vectored_afterburner_nozzle.json new file mode 100644 index 0000000..e24f172 --- /dev/null +++ b/src/main/resources/assets/improved_aerodynamics/blockstates/vectored_afterburner_nozzle.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "improved_aerodynamics:block/vectored_afterburner_nozzle" + } + } +} diff --git a/src/main/resources/assets/improved_aerodynamics/geo/blockentities/vectored_afterburner_nozzle.geo.json b/src/main/resources/assets/improved_aerodynamics/geo/blockentities/vectored_afterburner_nozzle.geo.json new file mode 100644 index 0000000..5f9be6b --- /dev/null +++ b/src/main/resources/assets/improved_aerodynamics/geo/blockentities/vectored_afterburner_nozzle.geo.json @@ -0,0 +1,113 @@ +{ + "format_version": "1.21.20", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.unknown", + "texture_width": 16, + "texture_height": 16, + "visible_bounds_width": 3, + "visible_bounds_height": 2.5, + "visible_bounds_offset": [0, 0.75, 0] + }, + "bones": [ + { + "name": "bb_main", + "pivot": [0, 0, 0], + "cubes": [ + { + "origin": [-16, 0, 10], + "size": [16, 16, 6], + "uv": { + "north": {"uv": [1.5, 1.5], "uv_size": [4, 4]}, + "east": {"uv": [0, 1.5], "uv_size": [1.5, 4]}, + "south": {"uv": [7, 1.5], "uv_size": [4, 4]}, + "west": {"uv": [5.5, 1.5], "uv_size": [1.5, 4]}, + "up": {"uv": [1.5, 0], "uv_size": [4, 1.5]}, + "down": {"uv": [5.5, 1.5], "uv_size": [4, -1.5]} + } + } + ] + }, + { + "name": "hinge", + "pivot": [-8, 8, 10], + "rotation": [0, 0, 32.5] + }, + { + "name": "rotation", + "parent": "hinge", + "pivot": [-8, 8, 10] + }, + { + "name": "counter", + "parent": "rotation", + "pivot": [-8, 8, 10], + "rotation": [0, 0, -32.5], + "cubes": [ + { + "origin": [-15, 1, 0], + "size": [14, 2, 10], + "uv": { + "north": {"uv": [2.5, 8], "uv_size": [3.5, 0.5]}, + "east": {"uv": [0, 8], "uv_size": [2.5, 0.5]}, + "south": {"uv": [8.5, 8], "uv_size": [3.5, 0.5]}, + "west": {"uv": [6, 8], "uv_size": [2.5, 0.5]}, + "up": {"uv": [2.5, 5.5], "uv_size": [3.5, 2.5]}, + "down": {"uv": [6, 8], "uv_size": [3.5, -2.5]} + } + }, + { + "origin": [-15, 13, 0], + "size": [14, 2, 10], + "uv": { + "north": {"uv": [6.5, 15.5], "uv_size": [3.5, 0.5]}, + "east": {"uv": [4, 15.5], "uv_size": [2.5, 0.5]}, + "south": {"uv": [12.5, 15.5], "uv_size": [3.5, 0.5]}, + "west": {"uv": [10, 15.5], "uv_size": [2.5, 0.5]}, + "up": {"uv": [6.5, 13], "uv_size": [3.5, 2.5]}, + "down": {"uv": [10, 15.5], "uv_size": [3.5, -2.5]} + } + }, + { + "origin": [-15, 3, 0], + "size": [2, 10, 10], + "uv": { + "north": {"uv": [2.5, 11], "uv_size": [0.5, 2.5]}, + "east": {"uv": [0, 11], "uv_size": [2.5, 2.5]}, + "south": {"uv": [5.5, 11], "uv_size": [0.5, 2.5]}, + "west": {"uv": [3, 11], "uv_size": [2.5, 2.5]}, + "up": {"uv": [2.5, 8.5], "uv_size": [0.5, 2.5]}, + "down": {"uv": [3, 11], "uv_size": [0.5, -2.5]} + } + }, + { + "origin": [-3, 3, 0], + "size": [2, 10, 10], + "uv": { + "north": {"uv": [3, 11], "uv_size": [-0.5, 2.5]}, + "east": {"uv": [5.5, 11], "uv_size": [-2.5, 2.5]}, + "south": {"uv": [6, 11], "uv_size": [-0.5, 2.5]}, + "west": {"uv": [2.5, 11], "uv_size": [-2.5, 2.5]}, + "up": {"uv": [3, 8.5], "uv_size": [-0.5, 2.5]}, + "down": {"uv": [3.5, 11], "uv_size": [-0.5, -2.5]} + } + }, + { + "origin": [-11, 5, 4], + "size": [6, 6, 6], + "uv": { + "north": {"uv": [7.5, 10], "uv_size": [1.5, 1.5]}, + "east": {"uv": [6, 10], "uv_size": [1.5, 1.5]}, + "south": {"uv": [10.5, 10], "uv_size": [1.5, 1.5]}, + "west": {"uv": [9, 10], "uv_size": [1.5, 1.5]}, + "up": {"uv": [7.5, 8.5], "uv_size": [1.5, 1.5]}, + "down": {"uv": [9, 10], "uv_size": [1.5, -1.5]} + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/main/resources/assets/improved_aerodynamics/lang/de_de.json b/src/main/resources/assets/improved_aerodynamics/lang/de_de.json index e2274bb..2453ead 100644 --- a/src/main/resources/assets/improved_aerodynamics/lang/de_de.json +++ b/src/main/resources/assets/improved_aerodynamics/lang/de_de.json @@ -12,5 +12,15 @@ "config.improved_aerodynamics.supersonicDrag": "Multiplikator für den Nullauftriebswiderstand bei vollem Überschall, ab Mach 1,3", "config.improved_aerodynamics.vortexLift": "Wirbelauftrieb: Flügel mit kleiner Streckung (Deltaflügel) überziehen später und liefern bei großen Anstellwinkeln mehr Auftrieb (verbessertes Flugmodell)", "config.improved_aerodynamics.vortexStallBonus": "Zusätzlicher Überziehwinkel der Flügel mit der kleinsten Streckung, als Anteil des normalen Überziehwinkels", - "config.improved_aerodynamics.vortexLiftStrength": "Multiplikator für den zusätzlichen Auftrieb der Vorderkantenwirbel" + "config.improved_aerodynamics.vortexLiftStrength": "Multiplikator für den zusätzlichen Auftrieb der Vorderkantenwirbel", + "block.improved_aerodynamics.vectored_afterburner_nozzle": "Schubvektor-Nachbrennerdüse", + "itemGroup.improved_aerodynamics": "Improved Aerodynamics", + "message.improved_aerodynamics.nozzle.left": "Düse auf linkes Triebwerk gestellt", + "message.improved_aerodynamics.nozzle.right": "Düse auf rechtes Triebwerk gestellt", + "key.improved_aerodynamics.toggle_afterburner": "Nachbrenner umschalten", + "message.improved_aerodynamics.afterburner.on": "Nachbrenner an", + "message.improved_aerodynamics.afterburner.off": "Nachbrenner aus", + "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" } diff --git a/src/main/resources/assets/improved_aerodynamics/lang/en_us.json b/src/main/resources/assets/improved_aerodynamics/lang/en_us.json index 12dbf29..11bd43c 100644 --- a/src/main/resources/assets/improved_aerodynamics/lang/en_us.json +++ b/src/main/resources/assets/improved_aerodynamics/lang/en_us.json @@ -12,5 +12,15 @@ "config.improved_aerodynamics.supersonicDrag": "Zero-lift drag multiplier once fully supersonic, from Mach 1.3", "config.improved_aerodynamics.vortexLift": "Vortex lift: low aspect ratio (delta) wings stall later and lift more at high angles of attack (improved flight model)", "config.improved_aerodynamics.vortexStallBonus": "Extra stall angle of the lowest aspect ratio wings, as a fraction of the normal stall angle", - "config.improved_aerodynamics.vortexLiftStrength": "Multiplier for the extra lift the leading-edge vortices make" + "config.improved_aerodynamics.vortexLiftStrength": "Multiplier for the extra lift the leading-edge vortices make", + "block.improved_aerodynamics.vectored_afterburner_nozzle": "Vectored Afterburner Nozzle", + "itemGroup.improved_aerodynamics": "Improved Aerodynamics", + "message.improved_aerodynamics.nozzle.left": "Nozzle set to left engine", + "message.improved_aerodynamics.nozzle.right": "Nozzle set to right engine", + "key.improved_aerodynamics.toggle_afterburner": "Toggle afterburner", + "message.improved_aerodynamics.afterburner.on": "Afterburner on", + "message.improved_aerodynamics.afterburner.off": "Afterburner off", + "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" } diff --git a/src/main/resources/assets/improved_aerodynamics/models/block/vectored_afterburner_nozzle.json b/src/main/resources/assets/improved_aerodynamics/models/block/vectored_afterburner_nozzle.json new file mode 100644 index 0000000..0d3e268 --- /dev/null +++ b/src/main/resources/assets/improved_aerodynamics/models/block/vectored_afterburner_nozzle.json @@ -0,0 +1,5 @@ +{ + "textures": { + "particle": "minecraft:block/iron_block" + } +} diff --git a/src/main/resources/assets/improved_aerodynamics/models/item/vectored_afterburner_nozzle.json b/src/main/resources/assets/improved_aerodynamics/models/item/vectored_afterburner_nozzle.json new file mode 100644 index 0000000..9c23dd0 --- /dev/null +++ b/src/main/resources/assets/improved_aerodynamics/models/item/vectored_afterburner_nozzle.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "minecraft:block/iron_block" + } +} diff --git a/src/main/resources/assets/improved_aerodynamics/textures/blockentities/jetexausttexture.png b/src/main/resources/assets/improved_aerodynamics/textures/blockentities/jetexausttexture.png new file mode 100644 index 0000000..2706762 Binary files /dev/null and b/src/main/resources/assets/improved_aerodynamics/textures/blockentities/jetexausttexture.png differ diff --git a/src/main/resources/data/improved_aerodynamics/loot_tables/blocks/vectored_afterburner_nozzle.json b/src/main/resources/data/improved_aerodynamics/loot_tables/blocks/vectored_afterburner_nozzle.json new file mode 100644 index 0000000..61df602 --- /dev/null +++ b/src/main/resources/data/improved_aerodynamics/loot_tables/blocks/vectored_afterburner_nozzle.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1.0, + "bonus_rolls": 0.0, + "entries": [ + { + "type": "minecraft:item", + "name": "improved_aerodynamics:vectored_afterburner_nozzle" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/improved_aerodynamics/vs_mass/improved_aerodynamics.json b/src/main/resources/data/improved_aerodynamics/vs_mass/improved_aerodynamics.json new file mode 100644 index 0000000..4f78135 --- /dev/null +++ b/src/main/resources/data/improved_aerodynamics/vs_mass/improved_aerodynamics.json @@ -0,0 +1,7 @@ +[ + { + "block": "improved_aerodynamics:vectored_afterburner_nozzle", + "mass": 500.0, + "priority": 101 + } +] diff --git a/src/main/resources/data/minecraft/tags/blocks/mineable/pickaxe.json b/src/main/resources/data/minecraft/tags/blocks/mineable/pickaxe.json new file mode 100644 index 0000000..68b724f --- /dev/null +++ b/src/main/resources/data/minecraft/tags/blocks/mineable/pickaxe.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "improved_aerodynamics:vectored_afterburner_nozzle" + ] +} diff --git a/src/main/resources/data/warium_vs/tags/blocks/blocksjet.json b/src/main/resources/data/warium_vs/tags/blocks/blocksjet.json new file mode 100644 index 0000000..68b724f --- /dev/null +++ b/src/main/resources/data/warium_vs/tags/blocks/blocksjet.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "improved_aerodynamics:vectored_afterburner_nozzle" + ] +} diff --git a/src/main/resources/data/warium_vs/tags/blocks/controllable.json b/src/main/resources/data/warium_vs/tags/blocks/controllable.json new file mode 100644 index 0000000..68b724f --- /dev/null +++ b/src/main/resources/data/warium_vs/tags/blocks/controllable.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "improved_aerodynamics:vectored_afterburner_nozzle" + ] +} diff --git a/src/main/resources/data/warium_vs/tags/blocks/doublecontrol.json b/src/main/resources/data/warium_vs/tags/blocks/doublecontrol.json new file mode 100644 index 0000000..68b724f --- /dev/null +++ b/src/main/resources/data/warium_vs/tags/blocks/doublecontrol.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "improved_aerodynamics:vectored_afterburner_nozzle" + ] +} diff --git a/src/main/resources/data/warium_vs/tags/blocks/unconfigurable.json b/src/main/resources/data/warium_vs/tags/blocks/unconfigurable.json new file mode 100644 index 0000000..8a7df1b --- /dev/null +++ b/src/main/resources/data/warium_vs/tags/blocks/unconfigurable.json @@ -0,0 +1,9 @@ +{ + "replace": false, + "values": [ + { "id": "valkyrien_warium:slim_landing_gear_bay", "required": false }, + { "id": "valkyrien_warium:open_slim_landing_gear_bay", "required": false }, + { "id": "warframes:airframe_slim_landing_gear_bay", "required": false }, + { "id": "warframes:airframe_slab_landing_gear_bay", "required": false } + ] +} diff --git a/src/main/resources/improved_aerodynamics.mixins.json b/src/main/resources/improved_aerodynamics.mixins.json index 2607e64..b56432d 100644 --- a/src/main/resources/improved_aerodynamics.mixins.json +++ b/src/main/resources/improved_aerodynamics.mixins.json @@ -7,10 +7,13 @@ "mixins": [ "futurism.ForceInducedShipsMixin", "warium.AirBrakeTickProcedureMixin", + "warium.BypassedCompressorScriptProcedureMixin", "warium.ControlSurfaceTickProcedureMixin", "warium.ForceInducedShipsMixin", "warium.HorizontalStabilizerScriptProcedureMixin", "warium.HorizontalWingScriptProcedureMixin", + "warium.JetExhaustOnTickUpdateProcedureMixin", + "warium.VTOLExhaustTickProcedureMixin", "warium.VerticalAirBrakeTickProcedureMixin", "warium.VerticalControlSurfaceTickProcedureMixin", "warium.VerticalWingScriptProcedureMixin"