diff --git a/net/neoforged/neoforge/client/event/ClientTickEvent.java b/net/neoforged/neoforge/client/event/ClientTickEvent.java
new file mode 100644
index 0000000..78c3394
--- /dev/null
+++ b/net/neoforged/neoforge/client/event/ClientTickEvent.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) NeoForged and contributors
+ * SPDX-License-Identifier: LGPL-2.1-only
+ */
+
+package net.neoforged.neoforge.client.event;
+
+import net.neoforged.bus.api.Event;
+
+/**
+ * Base class of the two client tick events.
+ *
+ * For the event that fires once per frame (instead of per tick), see {@link RenderFrameEvent}.
+ *
+ * @see ClientTickEvent.Pre
+ * @see ClientTickEvent.Post
+ */
+public abstract class ClientTickEvent extends Event {
+ /**
+ * {@link ClientTickEvent.Pre} is fired once per client tick, before the client performs work for the current tick.
+ *
+ * This event only fires on the physical client.
+ */
+ public static class Pre extends ClientTickEvent {
+ public Pre() {}
+ }
+
+ /**
+ * {@link ClientTickEvent.Post} is fired once per client tick, after the client performs work for the current tick.
+ *
+ * This event only fires on the physical client.
+ */
+ public static class Post extends ClientTickEvent {
+ public Post() {}
+ }
+}
diff --git a/net/neoforged/neoforge/client/event/RegisterKeyMappingsEvent.java b/net/neoforged/neoforge/client/event/RegisterKeyMappingsEvent.java
new file mode 100644
index 0000000..217e613
--- /dev/null
+++ b/net/neoforged/neoforge/client/event/RegisterKeyMappingsEvent.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) Forge Development LLC and contributors
+ * SPDX-License-Identifier: LGPL-2.1-only
+ */
+
+package net.neoforged.neoforge.client.event;
+
+import net.minecraft.client.KeyMapping;
+import net.minecraft.client.Options;
+import net.neoforged.bus.api.Event;
+import net.neoforged.bus.api.ICancellableEvent;
+import net.neoforged.fml.LogicalSide;
+import net.neoforged.fml.event.IModBusEvent;
+import org.apache.commons.lang3.ArrayUtils;
+import org.jetbrains.annotations.ApiStatus;
+
+/**
+ * Allows users to register custom {@link net.minecraft.client.KeyMapping key mappings}.
+ *
+ *
This event is not {@linkplain ICancellableEvent cancellable}, and does not {@linkplain HasResult have a result}.
+ *
+ *
This event is fired on the mod-specific event bus, only on the {@linkplain LogicalSide#CLIENT logical client}.
+ */
+public class RegisterKeyMappingsEvent extends Event implements IModBusEvent {
+ private final Options options;
+
+ @ApiStatus.Internal
+ public RegisterKeyMappingsEvent(Options options) {
+ this.options = options;
+ }
+
+ /**
+ * Registers a new key mapping.
+ */
+ public void register(KeyMapping key) {
+ options.keyMappings = ArrayUtils.add(options.keyMappings, key);
+ }
+}
diff --git a/net/neoforged/neoforge/event/entity/player/PlayerInteractEvent.java b/net/neoforged/neoforge/event/entity/player/PlayerInteractEvent.java
new file mode 100644
index 0000000..388b231
--- /dev/null
+++ b/net/neoforged/neoforge/event/entity/player/PlayerInteractEvent.java
@@ -0,0 +1,437 @@
+/*
+ * Copyright (c) Forge Development LLC and contributors
+ * SPDX-License-Identifier: LGPL-2.1-only
+ */
+
+package net.neoforged.neoforge.event.entity.player;
+
+import com.google.common.base.Preconditions;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.network.protocol.game.ServerboundPlayerActionPacket;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.InteractionResult;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.context.UseOnContext;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.LevelAccessor;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.phys.BlockHitResult;
+import net.minecraft.world.phys.Vec3;
+import net.neoforged.bus.api.ICancellableEvent;
+import net.neoforged.fml.LogicalSide;
+import net.neoforged.neoforge.common.NeoForge;
+import net.neoforged.neoforge.common.util.TriState;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * PlayerInteractEvent is fired when a player interacts in some way.
+ * All subclasses are fired on {@link NeoForge#EVENT_BUS}.
+ * See the individual documentation on each subevent for more details.
+ **/
+public abstract class PlayerInteractEvent extends PlayerEvent {
+ private final InteractionHand hand;
+ private final BlockPos pos;
+ @Nullable
+ private final Direction face;
+
+ protected PlayerInteractEvent(Player player, InteractionHand hand, BlockPos pos, @Nullable Direction face) {
+ super(Preconditions.checkNotNull(player, "Null player in PlayerInteractEvent!"));
+ this.hand = Preconditions.checkNotNull(hand, "Null hand in PlayerInteractEvent!");
+ this.pos = Preconditions.checkNotNull(pos, "Null position in PlayerInteractEvent!");
+ this.face = face;
+ }
+
+ /**
+ * This event is fired on both sides whenever a player right clicks an entity.
+ *
+ * "Interact at" is an interact where the local vector (which part of the entity you clicked) is known.
+ * The state of this event affects whether {@link Entity#interactAt(Player, Vec3, InteractionHand)} is called.
+ *
+ * Let result be the return value of {@link Entity#interactAt(Player, Vec3, InteractionHand)}, or {@link #cancellationResult} if the event is cancelled.
+ * If we are on the client and result is not {@link InteractionResult#SUCCESS}, the client will then try {@link EntityInteract}.
+ */
+ public static class EntityInteractSpecific extends PlayerInteractEvent implements ICancellableEvent {
+ private InteractionResult cancellationResult = InteractionResult.PASS;
+
+ private final Vec3 localPos;
+ private final Entity target;
+
+ public EntityInteractSpecific(Player player, InteractionHand hand, Entity target, Vec3 localPos) {
+ super(player, hand, target.blockPosition(), null);
+ this.localPos = localPos;
+ this.target = target;
+ }
+
+ /**
+ * Returns the local interaction position. This is a 3D vector, where (0, 0, 0) is centered exactly at the
+ * center of the entity's bounding box at their feet. This means the X and Z values will be in the range
+ * [-width / 2, width / 2] while Y values will be in the range [0, height]
+ *
+ * @return The local position
+ */
+ public Vec3 getLocalPos() {
+ return localPos;
+ }
+
+ public Entity getTarget() {
+ return target;
+ }
+
+ /**
+ * @return The InteractionResult that will be returned to vanilla if the event is cancelled, instead of calling the relevant
+ * method of the event. By default, this is {@link InteractionResult#PASS}, meaning cancelled events will cause
+ * the client to keep trying more interactions until something works.
+ */
+ public InteractionResult getCancellationResult() {
+ return cancellationResult;
+ }
+
+ /**
+ * Set the InteractionResult that will be returned to vanilla if the event is cancelled, instead of calling the relevant
+ * method of the event.
+ */
+ public void setCancellationResult(InteractionResult result) {
+ this.cancellationResult = result;
+ }
+ }
+
+ /**
+ * This event is fired on both sides when the player right clicks an entity.
+ * It is responsible for all general entity interactions.
+ *
+ * This event is fired only if the result of the above {@link EntityInteractSpecific} is not {@link InteractionResult#SUCCESS}.
+ * This event's state affects whether {@link Entity#interact(Player, InteractionHand)} and
+ * {@link Item#interactLivingEntity(ItemStack, Player, LivingEntity, InteractionHand)} are called.
+ *
+ * Let result be {@link InteractionResult#SUCCESS} if {@link Entity#interact(Player, InteractionHand)} or
+ * {@link Item#interactLivingEntity(ItemStack, Player, LivingEntity, InteractionHand)} return true,
+ * or {@link #cancellationResult} if the event is cancelled.
+ * If we are on the client and result is not {@link InteractionResult#SUCCESS}, the client will then try {@link RightClickItem}.
+ */
+ public static class EntityInteract extends PlayerInteractEvent implements ICancellableEvent {
+ private InteractionResult cancellationResult = InteractionResult.PASS;
+
+ private final Entity target;
+
+ public EntityInteract(Player player, InteractionHand hand, Entity target) {
+ super(player, hand, target.blockPosition(), null);
+ this.target = target;
+ }
+
+ public Entity getTarget() {
+ return target;
+ }
+
+ /**
+ * @return The InteractionResult that will be returned to vanilla if the event is cancelled, instead of calling the relevant
+ * method of the event. By default, this is {@link InteractionResult#PASS}, meaning cancelled events will cause
+ * the client to keep trying more interactions until something works.
+ */
+ public InteractionResult getCancellationResult() {
+ return cancellationResult;
+ }
+
+ /**
+ * Set the InteractionResult that will be returned to vanilla if the event is cancelled, instead of calling the relevant
+ * method of the event.
+ */
+ public void setCancellationResult(InteractionResult result) {
+ this.cancellationResult = result;
+ }
+ }
+
+ /**
+ * This event is fired on both sides whenever the player right clicks while targeting a block.
+ * This event controls which of {@link Item#onItemUseFirst}, {@link Block#use(BlockState, Level, BlockPos, Player, InteractionHand, BlockHitResult)},
+ * and {@link Item#useOn(UseOnContext)} will be called.
+ * Canceling the event will cause none of the above three to be called.
+ *
+ * Let result be the first non-pass return value of the above three methods, or pass, if they all pass.
+ * Or {@link #cancellationResult} if the event is cancelled.
+ * If result equals {@link InteractionResult#PASS}, we proceed to {@link RightClickItem}.
+ *
+ * There are various results to this event, see the getters below.
+ * Note that handling things differently on the client vs server may cause desynchronizations!
+ */
+ public static class RightClickBlock extends PlayerInteractEvent implements ICancellableEvent {
+ private InteractionResult cancellationResult = InteractionResult.PASS;
+
+ private TriState useBlock = TriState.DEFAULT;
+ private TriState useItem = TriState.DEFAULT;
+ private BlockHitResult hitVec;
+
+ public RightClickBlock(Player player, InteractionHand hand, BlockPos pos, BlockHitResult hitVec) {
+ super(player, hand, pos, hitVec.getDirection());
+ this.hitVec = hitVec;
+ }
+
+ /**
+ * @return If {@link Block#use(BlockState, Level, BlockPos, Player, InteractionHand, BlockHitResult)} should be called
+ */
+ public TriState getUseBlock() {
+ return useBlock;
+ }
+
+ /**
+ * @return If {@link Item#onItemUseFirst} and {@link Item#useOn(UseOnContext)} should be called
+ */
+ public TriState getUseItem() {
+ return useItem;
+ }
+
+ /**
+ * @return The ray trace result targeting the block.
+ */
+ public BlockHitResult getHitVec() {
+ return hitVec;
+ }
+
+ /**
+ * FALSE: {@link Block#use(BlockState, Level, BlockPos, Player, InteractionHand, BlockHitResult)} will never be called.
+ * DEFAULT: {@link Block#use(BlockState, Level, BlockPos, Player, InteractionHand, BlockHitResult)} will be called if {@link Item#onItemUseFirst} passes.
+ * Note that default activation can be blocked if the user is sneaking and holding an item that does not return true to {@link Item#doesSneakBypassUse}.
+ * TRUE: {@link Block#updateOrDestroy(BlockState, BlockState, LevelAccessor, BlockPos, int, int)} will always be called, unless {@link Item#onItemUseFirst} does not pass.
+ */
+ public void setUseBlock(TriState triggerBlock) {
+ this.useBlock = triggerBlock;
+ }
+
+ /**
+ * FALSE: Neither {@link Item#useOn(UseOnContext)} or {@link Item#onItemUseFirst} will be called.
+ * DEFAULT: {@link Item#onItemUseFirst} will always be called, and {@link Item#useOn(UseOnContext)} will be called if the block passes.
+ * TRUE: {@link Item#onItemUseFirst} will always be called, and {@link Item#useOn(UseOnContext)} will be called if the block passes, regardless of cooldowns or emptiness.
+ */
+ public void setUseItem(TriState triggerItem) {
+ this.useItem = triggerItem;
+ }
+
+ @Override
+ public void setCanceled(boolean canceled) {
+ ICancellableEvent.super.setCanceled(canceled);
+ if (canceled) {
+ useBlock = TriState.FALSE;
+ useItem = TriState.FALSE;
+ }
+ }
+
+ /**
+ * @return The InteractionResult that will be returned to vanilla if the event is cancelled, instead of calling the relevant
+ * method of the event. By default, this is {@link InteractionResult#PASS}, meaning cancelled events will cause
+ * the client to keep trying more interactions until something works.
+ */
+ public InteractionResult getCancellationResult() {
+ return cancellationResult;
+ }
+
+ /**
+ * Set the InteractionResult that will be returned to vanilla if the event is cancelled, instead of calling the relevant
+ * method of the event.
+ */
+ public void setCancellationResult(InteractionResult result) {
+ this.cancellationResult = result;
+ }
+ }
+
+ /**
+ * This event is fired on both sides before the player triggers {@link Item#use(Level, Player, InteractionHand)}.
+ * Note that this is NOT fired if the player is targeting a block {@link RightClickBlock} or entity {@link EntityInteract} {@link EntityInteractSpecific}.
+ *
+ * Let result be the return value of {@link Item#use(Level, Player, InteractionHand)}, or {@link #cancellationResult} if the event is cancelled.
+ * If we are on the client and result is not {@link InteractionResult#SUCCESS}, the client will then continue to other hands.
+ */
+ public static class RightClickItem extends PlayerInteractEvent implements ICancellableEvent {
+ private InteractionResult cancellationResult = InteractionResult.PASS;
+
+ public RightClickItem(Player player, InteractionHand hand) {
+ super(player, hand, player.blockPosition(), null);
+ }
+
+ /**
+ * @return The InteractionResult that will be returned to vanilla if the event is cancelled, instead of calling the relevant
+ * method of the event. By default, this is {@link InteractionResult#PASS}, meaning cancelled events will cause
+ * the client to keep trying more interactions until something works.
+ */
+ public InteractionResult getCancellationResult() {
+ return cancellationResult;
+ }
+
+ /**
+ * Set the InteractionResult that will be returned to vanilla if the event is cancelled, instead of calling the relevant
+ * method of the event.
+ */
+ public void setCancellationResult(InteractionResult result) {
+ this.cancellationResult = result;
+ }
+ }
+
+ /**
+ * This event is fired on the client side when the player right clicks empty space with an empty hand.
+ * The server is not aware of when the client right clicks empty space with an empty hand, you will need to tell the server yourself.
+ * This event cannot be canceled.
+ */
+ public static class RightClickEmpty extends PlayerInteractEvent {
+ public RightClickEmpty(Player player, InteractionHand hand) {
+ super(player, hand, player.blockPosition(), null);
+ }
+ }
+
+ /**
+ * This event is fired when a player left clicks while targeting a block.
+ * This event controls which of {@link Block#attack(BlockState, Level, BlockPos, Player)} and/or the item harvesting methods will be called
+ * Canceling the event will cause none of the above noted methods to be called.
+ * There are various results to this event, see the getters below.
+ *
+ * This event is fired at various points during left clicking on blocks, at both the start and end on the server, and at the start and while held down on the client.
+ * Use {@link #getAction()} to check which type of action triggered this event.
+ *
+ * Note that if the event is canceled and the player holds down left mouse, the event will continue to fire.
+ * This is due to how vanilla calls the left click handler methods.
+ *
+ * Also note that creative mode directly breaks the block without running any other logic.
+ * Therefore, in creative mode, {@link #setUseBlock} and {@link #setUseItem} have no effect.
+ */
+ public static class LeftClickBlock extends PlayerInteractEvent implements ICancellableEvent {
+ private TriState useBlock = TriState.DEFAULT;
+ private TriState useItem = TriState.DEFAULT;
+ private final Action action;
+
+ @ApiStatus.Internal
+ public LeftClickBlock(Player player, BlockPos pos, Direction face, Action action) {
+ super(player, InteractionHand.MAIN_HAND, pos, face);
+ this.action = action;
+ }
+
+ /**
+ * @return If {@link Block#attack(BlockState, Level, BlockPos, Player)} should be called. Changing this has no effect in creative mode
+ */
+ public TriState getUseBlock() {
+ return useBlock;
+ }
+
+ /**
+ * @return If the block should be attempted to be mined with the current item. Changing this has no effect in creative mode
+ */
+ public TriState getUseItem() {
+ return useItem;
+ }
+
+ /**
+ * @return The action type for this interaction. Will never be null.
+ */
+ public Action getAction() {
+ return this.action;
+ }
+
+ public void setUseBlock(TriState triggerBlock) {
+ this.useBlock = triggerBlock;
+ }
+
+ public void setUseItem(TriState triggerItem) {
+ this.useItem = triggerItem;
+ }
+
+ @Override
+ public void setCanceled(boolean canceled) {
+ ICancellableEvent.super.setCanceled(canceled);
+ if (canceled) {
+ useBlock = TriState.FALSE;
+ useItem = TriState.FALSE;
+ }
+ }
+
+ public enum Action {
+ /**
+ * When the player first left clicks a block
+ */
+ START,
+ /**
+ * When the player stops left clicking a block by completely breaking it
+ */
+ STOP,
+ /**
+ * When the player stops left clicking a block by releasing the button, or no longer targeting the same block before it breaks.
+ */
+ ABORT,
+ /**
+ * When the player is actively mining a block on the client side
+ * Warning: The event is fired every tick on the client
+ */
+ CLIENT_HOLD;
+
+ public static Action convert(ServerboundPlayerActionPacket.Action action) {
+ return switch (action) {
+ default -> START;
+ case START_DESTROY_BLOCK -> START;
+ case STOP_DESTROY_BLOCK -> STOP;
+ case ABORT_DESTROY_BLOCK -> ABORT;
+ };
+ }
+ }
+ }
+
+ /**
+ * This event is fired on the client side when the player left clicks empty space with any ItemStack.
+ * The server is not aware of when the client left clicks empty space, you will need to tell the server yourself.
+ * This event cannot be canceled.
+ */
+ public static class LeftClickEmpty extends PlayerInteractEvent {
+ public LeftClickEmpty(Player player) {
+ super(player, InteractionHand.MAIN_HAND, player.blockPosition(), null);
+ }
+ }
+
+ /**
+ * @return The hand involved in this interaction. Will never be null.
+ */
+ public InteractionHand getHand() {
+ return hand;
+ }
+
+ /**
+ * @return The itemstack involved in this interaction, {@code ItemStack.EMPTY} if the hand was empty.
+ */
+ public ItemStack getItemStack() {
+ return getEntity().getItemInHand(hand);
+ }
+
+ /**
+ * If the interaction was on an entity, will be a BlockPos centered on the entity.
+ * If the interaction was on a block, will be the position of that block.
+ * Otherwise, will be a BlockPos centered on the player.
+ * Will never be null.
+ *
+ * @return The position involved in this interaction.
+ */
+ public BlockPos getPos() {
+ return pos;
+ }
+
+ /**
+ * @return The face involved in this interaction. For all non-block interactions, this will return null.
+ */
+ @Nullable
+ public Direction getFace() {
+ return face;
+ }
+
+ /**
+ * @return Convenience method to get the level of this interaction.
+ */
+ public Level getLevel() {
+ return getEntity().level();
+ }
+
+ /**
+ * @return The effective, i.e. logical, side of this interaction. This will be {@link LogicalSide#CLIENT} on the client thread, and {@link LogicalSide#SERVER} on the server thread.
+ */
+ public LogicalSide getSide() {
+ return getLevel().isClientSide ? LogicalSide.CLIENT : LogicalSide.SERVER;
+ }
+}
diff --git a/net/neoforged/neoforge/network/PacketDistributor.java b/net/neoforged/neoforge/network/PacketDistributor.java
new file mode 100644
index 0000000..ea23ac4
--- /dev/null
+++ b/net/neoforged/neoforge/network/PacketDistributor.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (c) Forge Development LLC and contributors
+ * SPDX-License-Identifier: LGPL-2.1-only
+ */
+
+package net.neoforged.neoforge.network;
+
+import com.google.common.base.Preconditions;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.multiplayer.ClientPacketListener;
+import net.minecraft.network.protocol.Packet;
+import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket;
+import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
+import net.minecraft.network.protocol.game.ClientGamePacketListener;
+import net.minecraft.network.protocol.game.ClientboundBundlePacket;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerChunkCache;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.level.ChunkPos;
+import net.neoforged.fml.loading.FMLEnvironment;
+import net.neoforged.neoforge.server.ServerLifecycleHooks;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Means to distribute packets in various ways
+ */
+public final class PacketDistributor {
+ private PacketDistributor() {}
+
+ /**
+ * Send the given payload(s) to the server
+ */
+ public static void sendToServer(CustomPacketPayload payload, CustomPacketPayload... payloads) {
+ Preconditions.checkState(FMLEnvironment.dist.isClient(), "Cannot send serverbound payloads on the server");
+ ClientPacketListener listener = Objects.requireNonNull(Minecraft.getInstance().getConnection());
+ listener.send(payload);
+ for (CustomPacketPayload otherPayload : payloads) {
+ listener.send(otherPayload);
+ }
+ }
+
+ /**
+ * Send the given payload(s) to the given player
+ */
+ public static void sendToPlayer(ServerPlayer player, CustomPacketPayload payload, CustomPacketPayload... payloads) {
+ player.connection.send(makeClientboundPacket(payload, payloads));
+ }
+
+ /**
+ * Send the given payload(s) to all players in the given dimension
+ */
+ public static void sendToPlayersInDimension(ServerLevel level, CustomPacketPayload payload, CustomPacketPayload... payloads) {
+ level.getServer().getPlayerList().broadcastAll(makeClientboundPacket(payload, payloads), level.dimension());
+ }
+
+ /**
+ * Send the given payload(s) to all players in the area covered by the given radius around the given coordinates
+ * in the given dimension, except the given excluded player if present
+ */
+ public static void sendToPlayersNear(
+ ServerLevel level,
+ @Nullable ServerPlayer excluded,
+ double x,
+ double y,
+ double z,
+ double radius,
+ CustomPacketPayload payload,
+ CustomPacketPayload... payloads) {
+ Packet> packet = makeClientboundPacket(payload, payloads);
+ level.getServer().getPlayerList().broadcast(excluded, x, y, z, radius, level.dimension(), packet);
+ }
+
+ /**
+ * Send the given payload(s) to all players on the server
+ */
+ public static void sendToAllPlayers(CustomPacketPayload payload, CustomPacketPayload... payloads) {
+ MinecraftServer server = Objects.requireNonNull(ServerLifecycleHooks.getCurrentServer(), "Cannot send clientbound payloads on the client");
+ server.getPlayerList().broadcastAll(makeClientboundPacket(payload, payloads));
+ }
+
+ /**
+ * Send the given payload(s) to all players tracking the given entity
+ */
+ public static void sendToPlayersTrackingEntity(Entity entity, CustomPacketPayload payload, CustomPacketPayload... payloads) {
+ if (entity.level().isClientSide()) {
+ throw new IllegalStateException("Cannot send clientbound payloads on the client");
+ } else if (entity.level().getChunkSource() instanceof ServerChunkCache chunkCache) {
+ chunkCache.broadcast(entity, makeClientboundPacket(payload, payloads));
+ }
+ // Silently ignore custom Level implementations which may not return ServerChunkCache.
+ }
+
+ /**
+ * Send the given payload(s) to all players tracking the given entity and the entity itself if it is a player
+ */
+ public static void sendToPlayersTrackingEntityAndSelf(Entity entity, CustomPacketPayload payload, CustomPacketPayload... payloads) {
+ if (entity.level().isClientSide()) {
+ throw new IllegalStateException("Cannot send clientbound payloads on the client");
+ } else if (entity.level().getChunkSource() instanceof ServerChunkCache chunkCache) {
+ chunkCache.broadcastAndSend(entity, makeClientboundPacket(payload, payloads));
+ }
+ // Silently ignore custom Level implementations which may not return ServerChunkCache.
+ }
+
+ /**
+ * Send the given payload(s) to all players tracking the chunk at the given position in the given level
+ */
+ public static void sendToPlayersTrackingChunk(ServerLevel level, ChunkPos chunkPos, CustomPacketPayload payload, CustomPacketPayload... payloads) {
+ Packet> packet = makeClientboundPacket(payload, payloads);
+ for (ServerPlayer player : level.getChunkSource().chunkMap.getPlayers(chunkPos, false)) {
+ player.connection.send(packet);
+ }
+ }
+
+ private static Packet> makeClientboundPacket(CustomPacketPayload payload, CustomPacketPayload... payloads) {
+ if (payloads.length > 0) {
+ final List> packets = new ArrayList<>();
+ packets.add(new ClientboundCustomPayloadPacket(payload));
+ for (CustomPacketPayload otherPayload : payloads) {
+ packets.add(new ClientboundCustomPayloadPacket(otherPayload));
+ }
+ return new ClientboundBundlePacket(packets);
+ } else {
+ return new ClientboundCustomPayloadPacket(payload);
+ }
+ }
+}
diff --git a/src/main/java/dev/devce/rocketnautics/RocketNautics.java b/src/main/java/dev/devce/rocketnautics/RocketNautics.java
index 7cdee2a..c373b8e 100644
--- a/src/main/java/dev/devce/rocketnautics/RocketNautics.java
+++ b/src/main/java/dev/devce/rocketnautics/RocketNautics.java
@@ -1,10 +1,10 @@
package dev.devce.rocketnautics;
-import dev.devce.rocketnautics.registry.RocketBlocks;
-import dev.devce.rocketnautics.registry.RocketBlockEntities;
-import dev.devce.rocketnautics.registry.RocketParticles;
-import dev.devce.rocketnautics.registry.RocketSounds;
-import dev.devce.rocketnautics.registry.RocketTabs;
+import dev.devce.rocketnautics.content.mobs.Starved;
+import dev.devce.rocketnautics.content.screens.ModMenuTypes;
+import dev.devce.rocketnautics.event.ClientModEvents;
+import dev.devce.rocketnautics.event.RopeHandler;
+import dev.devce.rocketnautics.registry.*;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.bus.api.SubscribeEvent;
@@ -25,14 +25,21 @@ public RocketNautics(IEventBus modEventBus) {
LOGGER.info("Initializing RocketNautics!");
RocketBlocks.register(modEventBus);
+ RocketItems.register(modEventBus);
+ RocketEntities.register(modEventBus);
+ ArmorMaterials.register(modEventBus);
RocketBlockEntities.register(modEventBus);
RocketParticles.register(modEventBus);
RocketTabs.register(modEventBus);
RocketSounds.register(modEventBus);
+ ModMenuTypes.register(modEventBus);
modEventBus.addListener(this::setup);
NeoForge.EVENT_BUS.register(this);
GlobalSpacePhysicsHandler.init();
+ RopeHandler.init(modEventBus);
+ ClientModEvents.init(modEventBus);
+
}
private void setup(final FMLCommonSetupEvent event) {
diff --git a/src/main/java/dev/devce/rocketnautics/client/TetherClientRenderer.java b/src/main/java/dev/devce/rocketnautics/client/TetherClientRenderer.java
new file mode 100644
index 0000000..b81cd99
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/client/TetherClientRenderer.java
@@ -0,0 +1,103 @@
+package dev.devce.rocketnautics.client;
+
+import com.mojang.blaze3d.systems.RenderSystem;
+import com.mojang.blaze3d.vertex.BufferBuilder;
+import com.mojang.blaze3d.vertex.BufferUploader;
+import com.mojang.blaze3d.vertex.DefaultVertexFormat;
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.mojang.blaze3d.vertex.Tesselator;
+import com.mojang.blaze3d.vertex.VertexFormat;
+import dev.devce.rocketnautics.RocketNautics;
+import dev.devce.rocketnautics.event.RopeHandler;
+import dev.devce.rocketnautics.registry.RocketItems;
+import dev.ryanhcode.sable.companion.SableCompanion;
+import net.minecraft.client.Camera;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.renderer.GameRenderer;
+import net.minecraft.util.Mth;
+import net.minecraft.world.entity.EquipmentSlot;
+import net.minecraft.world.phys.Vec3;
+import net.neoforged.api.distmarker.Dist;
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.fml.common.EventBusSubscriber;
+import net.neoforged.neoforge.client.event.RenderLevelStageEvent;
+import org.joml.Matrix4f;
+
+@EventBusSubscriber(modid = RocketNautics.MODID, bus = EventBusSubscriber.Bus.GAME, value = Dist.CLIENT)
+public class TetherClientRenderer {
+
+ @SubscribeEvent
+ public static void onRenderLevelStage(RenderLevelStageEvent event) {
+ if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_PARTICLES) {
+ return;
+ }
+
+ Minecraft mc = Minecraft.getInstance();
+ if (mc.level == null || mc.player == null) {
+ return;
+ }
+
+ if (!mc.player.getItemBySlot(EquipmentSlot.CHEST).is(RocketItems.SPACE_CHESTPLATE.get())) {
+ return;
+ }
+
+ Vec3 anchor = RopeHandler.getProjectedAnchor(mc.player);
+ if (anchor == null) {
+ return;
+ }
+
+ Vec3 chest = mc.player.getEyePosition().add(0.0, -0.35, 0.0);
+ chest = SableCompanion.INSTANCE.projectOutOfSubLevel(mc.level, chest);
+
+ if (chest.distanceToSqr(anchor) < 0.0001) {
+ return;
+ }
+
+ PoseStack poseStack = event.getPoseStack();
+ Camera camera = event.getCamera();
+ Vec3 camPos = camera.getPosition();
+
+ poseStack.pushPose();
+ poseStack.translate(-camPos.x, -camPos.y, -camPos.z);
+ Matrix4f matrix = poseStack.last().pose();
+
+ RenderSystem.enableBlend();
+ RenderSystem.defaultBlendFunc();
+ RenderSystem.disableCull();
+ RenderSystem.setShader(GameRenderer::getPositionColorShader);
+ RenderSystem.lineWidth(2.0f);
+
+ Tesselator tesselator = Tesselator.getInstance();
+ BufferBuilder builder = tesselator.begin(VertexFormat.Mode.LINES, DefaultVertexFormat.POSITION_COLOR);
+
+ int segments = Mth.clamp((int) (Math.sqrt(chest.distanceToSqr(anchor)) * 3.0), 8, 32);
+ Vec3 previous = curvePoint(chest, anchor, 0.0);
+
+ for (int i = 1; i <= segments; i++) {
+ double t = (double) i / segments;
+ Vec3 current = curvePoint(chest, anchor, t);
+ float shade = 1.0f - (float) t * 0.15f;
+
+ builder.addVertex(matrix, (float) previous.x, (float) previous.y, (float) previous.z)
+ .setColor(0.86f * shade, 0.86f * shade, 0.90f * shade, 1.0f);
+ builder.addVertex(matrix, (float) current.x, (float) current.y, (float) current.z)
+ .setColor(0.74f * shade, 0.74f * shade, 0.78f * shade, 1.0f);
+
+ previous = current;
+ }
+
+ BufferUploader.drawWithShader(builder.buildOrThrow());
+
+ RenderSystem.lineWidth(1.0f);
+ RenderSystem.enableCull();
+ RenderSystem.disableBlend();
+ poseStack.popPose();
+ }
+
+ private static Vec3 curvePoint(Vec3 start, Vec3 end, double t) {
+ Vec3 point = start.lerp(end, t);
+ double distance = start.distanceTo(end);
+ double sag = Math.sin(Math.PI * t) * Math.min(0.20 + distance * 0.02, 1.10);
+ return point.add(0.0, -sag, 0.0);
+ }
+}
diff --git a/src/main/java/dev/devce/rocketnautics/client/TetherKeybindHandler.java b/src/main/java/dev/devce/rocketnautics/client/TetherKeybindHandler.java
new file mode 100644
index 0000000..b0384dd
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/client/TetherKeybindHandler.java
@@ -0,0 +1,51 @@
+package dev.devce.rocketnautics.client;
+
+import com.mojang.blaze3d.platform.InputConstants;
+import dev.devce.rocketnautics.RocketNautics;
+import dev.devce.rocketnautics.network.TetherDetachPayload;
+import net.minecraft.client.KeyMapping;
+import net.minecraft.client.Minecraft;
+import net.neoforged.api.distmarker.Dist;
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.fml.common.EventBusSubscriber;
+import net.neoforged.neoforge.client.event.ClientTickEvent;
+import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent;
+import net.neoforged.neoforge.network.PacketDistributor;
+import org.lwjgl.glfw.GLFW;
+
+@EventBusSubscriber(modid = RocketNautics.MODID, bus = EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
+public final class TetherKeybindHandler {
+
+ private static final KeyMapping DETACH_TETHER = new KeyMapping(
+ "key.rocketnautics.detach_tether",
+ InputConstants.Type.KEYSYM,
+ GLFW.GLFW_KEY_Z,
+ "key.categories.rocketnautics"
+ );
+
+ private TetherKeybindHandler() {
+ }
+
+ @SubscribeEvent
+ public static void registerKeyMappings(RegisterKeyMappingsEvent event) {
+ event.register(DETACH_TETHER);
+ }
+
+ @EventBusSubscriber(modid = RocketNautics.MODID, bus = EventBusSubscriber.Bus.GAME, value = Dist.CLIENT)
+ public static final class RuntimeEvents {
+ private RuntimeEvents() {
+ }
+
+ @SubscribeEvent
+ public static void onClientTick(ClientTickEvent.Post event) {
+ Minecraft mc = Minecraft.getInstance();
+ if (mc.player == null) {
+ return;
+ }
+
+ while (DETACH_TETHER.consumeClick()) {
+ PacketDistributor.sendToServer(new TetherDetachPayload());
+ }
+ }
+ }
+}
diff --git a/src/main/java/dev/devce/rocketnautics/content/blocks/AstralEngineeringTable.java b/src/main/java/dev/devce/rocketnautics/content/blocks/AstralEngineeringTable.java
new file mode 100644
index 0000000..62b9b3d
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/content/blocks/AstralEngineeringTable.java
@@ -0,0 +1,82 @@
+package dev.devce.rocketnautics.content.blocks;
+
+import com.mojang.serialization.MapCodec;
+import dev.devce.rocketnautics.registry.RocketBlockEntities;
+import net.minecraft.core.BlockPos;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.ItemInteractionResult;
+import net.minecraft.world.SimpleMenuProvider;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.BaseEntityBlock;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.LeavesBlock;
+import net.minecraft.world.level.block.RenderShape;
+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.phys.BlockHitResult;
+import org.jetbrains.annotations.Nullable;
+
+public class AstralEngineeringTable extends BaseEntityBlock {
+ public static final MapCodec CODEC = simpleCodec(AstralEngineeringTable::new);
+
+ public AstralEngineeringTable(Properties properties) {
+ super(properties);
+ }
+
+ @Override
+ protected MapCodec extends BaseEntityBlock> codec() {
+ return CODEC;
+ }
+
+ @Override
+ public @Nullable BlockEntity newBlockEntity(BlockPos blockPos, BlockState blockState) {
+ return new AstralEngineeringTableBlockEntity(blockPos, blockState);
+ }
+
+ @Override
+ protected RenderShape getRenderShape(BlockState p_49232_) {
+ return RenderShape.MODEL;
+ }
+
+ public void onRemove(BlockState pState, Level pLevel, BlockPos pPos, BlockState pNewState, boolean pIsMoving) {
+ if(pState.getBlock() != pNewState.getBlock()){
+ BlockEntity blockEntity = pLevel.getBlockEntity(pPos);
+ if (blockEntity instanceof AstralEngineeringTableBlockEntity astralengineeringtableblockentity) {
+ astralengineeringtableblockentity.drops();
+ }
+ }
+
+ super.onRemove(pState, pLevel, pPos, pNewState, pIsMoving);
+ }
+
+ @Override
+ protected ItemInteractionResult useItemOn(ItemStack pStack, BlockState pState, Level pLevel, BlockPos pPos,
+ Player pPlayer, InteractionHand pHand, BlockHitResult pHitResult) {
+ if (!pLevel.isClientSide()) {
+ BlockEntity entity = pLevel.getBlockEntity(pPos);
+ if(entity instanceof AstralEngineeringTableBlockEntity astralengineeringtableblockentity) {
+ ((ServerPlayer) pPlayer).openMenu(new SimpleMenuProvider(astralengineeringtableblockentity, Component.literal("Astral Engineering Table")), pPos);
+ } else {
+ throw new IllegalStateException("Our Container provider is missing!");
+ }
+ }
+
+ return ItemInteractionResult.sidedSuccess(pLevel.isClientSide());
+ }
+
+ @Override
+ public @Nullable BlockEntityTicker getTicker(Level level, BlockState state, BlockEntityType blockEntityType) {
+ if(level.isClientSide()){
+ return null;
+ }
+
+ return createTickerHelper(blockEntityType, RocketBlockEntities.ASTRAL_ENGINEERING_TABLE_BLOCK_ENTITY.get(),
+ (Level, blockPos, blockState, blockEntity) -> blockEntity.tick(level, blockPos, blockState));
+ }
+}
diff --git a/src/main/java/dev/devce/rocketnautics/content/blocks/AstralEngineeringTableBlockEntity.java b/src/main/java/dev/devce/rocketnautics/content/blocks/AstralEngineeringTableBlockEntity.java
new file mode 100644
index 0000000..df442a9
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/content/blocks/AstralEngineeringTableBlockEntity.java
@@ -0,0 +1,150 @@
+package dev.devce.rocketnautics.content.blocks;
+
+import dev.devce.rocketnautics.content.screens.AstralEngineeringTableMenu;
+import dev.devce.rocketnautics.registry.RocketBlockEntities;
+import dev.devce.rocketnautics.registry.RocketItems;
+import dev.devce.rocketnautics.util.JetpackData;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.HolderLookup;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.protocol.Packet;
+import net.minecraft.network.protocol.game.ClientGamePacketListener;
+import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
+import net.minecraft.world.Containers;
+import net.minecraft.world.MenuProvider;
+import net.minecraft.world.SimpleContainer;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.inventory.AbstractContainerMenu;
+import net.minecraft.world.inventory.ContainerData;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import net.minecraft.world.level.block.state.BlockState;
+import net.neoforged.neoforge.items.ItemStackHandler;
+import org.jetbrains.annotations.Nullable;
+
+public class AstralEngineeringTableBlockEntity extends BlockEntity implements MenuProvider {
+
+ public final ItemStackHandler itemHandler = new ItemStackHandler(3) {
+ @Override
+ protected void onContentsChanged(int slot) {
+ setChanged();
+ if (!level.isClientSide()) {
+ level.sendBlockUpdated(getBlockPos(), getBlockState(), getBlockState(), 3);
+ }
+ }
+ };
+
+ private static final int INPUT_SLOT1 = 0;
+ private static final int INPUT_SLOT2 = 1;
+ private static final int OUTPUT_SLOT = 2;
+
+ protected final ContainerData data;
+
+ public AstralEngineeringTableBlockEntity(BlockPos pos, BlockState state) {
+ super(RocketBlockEntities.ASTRAL_ENGINEERING_TABLE_BLOCK_ENTITY.get(), pos, state);
+
+ this.data = new ContainerData() {
+ public int get(int i) { return 0; }
+ public void set(int i, int v) {}
+ public int getCount() { return 0; }
+ };
+ }
+
+ public void tick(Level level, BlockPos pos, BlockState state) {
+ if (level.isClientSide) return;
+
+ ItemStack result = getRecipeResult();
+ ItemStack output = itemHandler.getStackInSlot(OUTPUT_SLOT);
+
+ if (!result.isEmpty()) {
+ if (output.isEmpty()) {
+ itemHandler.setStackInSlot(OUTPUT_SLOT, result);
+ setChanged(level, pos, state);
+ }
+ }
+ }
+
+ private ItemStack getRecipeResult() {
+ ItemStack slot1 = itemHandler.getStackInSlot(INPUT_SLOT1);
+ ItemStack slot2 = itemHandler.getStackInSlot(INPUT_SLOT2);
+
+ if (slot1.is(RocketItems.SPACE_CHESTPLATE.get()) &&
+ slot2.is(RocketItems.JETPACK_UPGRADE.get())) {
+ return createJetpackChestplate(slot1);
+ }
+
+ if (slot2.is(RocketItems.SPACE_CHESTPLATE.get()) &&
+ slot1.is(RocketItems.JETPACK_UPGRADE.get())) {
+ return createJetpackChestplate(slot2);
+ }
+
+ return ItemStack.EMPTY;
+ }
+
+ private ItemStack createJetpackChestplate(ItemStack input) {
+ ItemStack result = input.copy();
+ result.setCount(1);
+
+ JetpackData.enable(result);
+
+ return result;
+ }
+
+ public void consumeRecipeInputs() {
+ if (!level.isClientSide && !getRecipeResult().isEmpty()) {
+ itemHandler.extractItem(INPUT_SLOT1, 1, false);
+ itemHandler.extractItem(INPUT_SLOT2, 1, false);
+ itemHandler.setStackInSlot(OUTPUT_SLOT, ItemStack.EMPTY);
+ setChanged();
+ }
+ }
+
+ @Override
+ public Component getDisplayName() {
+ return Component.translatable("block.rocketnautics.astral_engineering_table");
+ }
+
+ @Override
+ public @Nullable AbstractContainerMenu createMenu(int id, Inventory inv, Player player) {
+ return new AstralEngineeringTableMenu(id, inv, this, this.data);
+ }
+
+ // =========================
+ // INVENTORY DROP
+ // =========================
+ public void drops() {
+ SimpleContainer inv = new SimpleContainer(itemHandler.getSlots());
+ for (int i = 0; i < itemHandler.getSlots(); i++) {
+ inv.setItem(i, itemHandler.getStackInSlot(i));
+ }
+ Containers.dropContents(this.level, this.worldPosition, inv);
+ }
+
+ // =========================
+ // NBT SAVE/LOAD
+ // =========================
+ @Override
+ protected void saveAdditional(CompoundTag tag, HolderLookup.Provider provider) {
+ tag.put("inventory", itemHandler.serializeNBT(provider));
+ super.saveAdditional(tag, provider);
+ }
+
+ @Override
+ protected void loadAdditional(CompoundTag tag, HolderLookup.Provider provider) {
+ super.loadAdditional(tag, provider);
+ itemHandler.deserializeNBT(provider, tag.getCompound("inventory"));
+ }
+
+ @Override
+ public CompoundTag getUpdateTag(HolderLookup.Provider provider) {
+ return saveWithoutMetadata(provider);
+ }
+
+ @Override
+ public @Nullable Packet getUpdatePacket() {
+ return ClientboundBlockEntityDataPacket.create(this);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/devce/rocketnautics/content/mobs/Starved.java b/src/main/java/dev/devce/rocketnautics/content/mobs/Starved.java
new file mode 100644
index 0000000..4055374
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/content/mobs/Starved.java
@@ -0,0 +1,29 @@
+package dev.devce.rocketnautics.content.mobs;
+
+import dev.devce.rocketnautics.content.mobs.ai.StarvedHelmetSnatchGoal;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.entity.ai.attributes.AttributeSupplier;
+import net.minecraft.world.entity.ai.attributes.Attributes;
+import net.minecraft.world.entity.monster.Monster;
+import net.minecraft.world.entity.monster.Zombie;
+import net.minecraft.world.level.Level;
+
+public class Starved extends Zombie {
+ public Starved(EntityType extends Zombie> p_34271_, Level p_34272_) {
+ super(p_34271_, p_34272_);
+ }
+
+ @Override
+ protected void registerGoals() {
+ super.registerGoals();
+ this.goalSelector.addGoal(2, new StarvedHelmetSnatchGoal(this, 1.15D));
+ }
+
+ public static AttributeSupplier.Builder createAttributes() {
+ return Monster.createMonsterAttributes().add(Attributes.FOLLOW_RANGE, (double)35.0F).add(Attributes.MOVEMENT_SPEED, (double)0.23F).add(Attributes.ATTACK_DAMAGE, (double)3.0F).add(Attributes.ARMOR, (double)0.0F).add(Attributes.SPAWN_REINFORCEMENTS_CHANCE);
+ }
+
+ protected boolean convertsInWater() {
+ return false;
+ }
+}
diff --git a/src/main/java/dev/devce/rocketnautics/content/mobs/StarvedRenderer.java b/src/main/java/dev/devce/rocketnautics/content/mobs/StarvedRenderer.java
new file mode 100644
index 0000000..31ea16d
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/content/mobs/StarvedRenderer.java
@@ -0,0 +1,36 @@
+package dev.devce.rocketnautics.content.mobs;
+
+import com.mojang.blaze3d.vertex.PoseStack;
+import dev.devce.rocketnautics.content.mobs.Starved;
+import net.minecraft.client.model.ZombieModel;
+import net.minecraft.client.model.geom.ModelLayers;
+import net.minecraft.client.renderer.MultiBufferSource;
+import net.minecraft.client.renderer.entity.EntityRendererProvider;
+import net.minecraft.client.renderer.entity.MobRenderer;
+import net.minecraft.client.renderer.entity.ZombieRenderer;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.entity.monster.Zombie;
+
+public class StarvedRenderer extends MobRenderer> {
+
+ public StarvedRenderer(EntityRendererProvider.Context context) {
+ super(context, new ZombieModel<>(context.bakeLayer(ModelLayers.ZOMBIE)), 0.5f);
+ }
+
+ @Override
+ public ResourceLocation getTextureLocation(Starved entity) {
+ return ResourceLocation.fromNamespaceAndPath("rocketnautics", "textures/entity/starved.png");
+ }
+
+
+ @Override
+ public void render(Starved entity, float entityYaw, float partialTicks, PoseStack poseStack, MultiBufferSource buffer, int packedLight) {
+ if(entity.isBaby()) {
+ poseStack.scale(0.45f, 0.45f, 0.45f);
+ } else {
+ poseStack.scale(1f, 1f, 1f);
+ }
+
+ super.render(entity, entityYaw, partialTicks, poseStack, buffer, packedLight);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/devce/rocketnautics/content/mobs/ai/StarvedHelmetSnatchGoal.java b/src/main/java/dev/devce/rocketnautics/content/mobs/ai/StarvedHelmetSnatchGoal.java
new file mode 100644
index 0000000..bbf9fc8
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/content/mobs/ai/StarvedHelmetSnatchGoal.java
@@ -0,0 +1,89 @@
+package dev.devce.rocketnautics.content.mobs.ai;
+
+import dev.devce.rocketnautics.registry.RocketItems;
+import net.minecraft.world.entity.EquipmentSlot;
+import net.minecraft.world.entity.ai.goal.Goal;
+import net.minecraft.world.entity.monster.Zombie;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+
+import java.util.EnumSet;
+
+public class StarvedHelmetSnatchGoal extends Goal {
+
+ private final Zombie mob;
+ private final double speed;
+ private Player target;
+
+ private int grabCooldown = 0;
+
+ public StarvedHelmetSnatchGoal(Zombie mob, double speed) {
+ this.mob = mob;
+ this.speed = speed;
+ this.setFlags(EnumSet.of(Flag.MOVE, Flag.LOOK));
+ }
+
+ @Override
+ public boolean canUse() {
+ if (!(mob.getTarget() instanceof Player player)) return false;
+
+ ItemStack head = player.getItemBySlot(EquipmentSlot.HEAD);
+ return head.is(RocketItems.SPACE_HELMET.get());
+ }
+
+ @Override
+ public boolean canContinueToUse() {
+ if (!(mob.getTarget() instanceof Player player)) return false;
+
+ ItemStack head = player.getItemBySlot(EquipmentSlot.HEAD);
+ return player.isAlive() && head.is(RocketItems.SPACE_HELMET.get());
+ }
+
+ @Override
+ public void start() {
+ this.target = (Player) mob.getTarget();
+ }
+
+ @Override
+ public void stop() {
+ this.target = null;
+ }
+
+ @Override
+ public void tick() {
+ if (target == null) return;
+
+ // countdown happens regardless
+ if (grabCooldown > 0) {
+ grabCooldown--;
+ }
+
+ mob.getLookControl().setLookAt(target, 30.0F, 30.0F);
+ mob.getNavigation().moveTo(target, speed);
+
+ double distSq = mob.distanceToSqr(target);
+
+ if (distSq <= 2.5D * 2.5D && grabCooldown <= 0) {
+ tryStripHelmet(target);
+ }
+ }
+
+ private void tryStripHelmet(Player player) {
+ ItemStack helmet = player.getItemBySlot(EquipmentSlot.HEAD);
+
+ if (!helmet.is(RocketItems.SPACE_HELMET.get())) return;
+
+ player.setItemSlot(EquipmentSlot.HEAD, ItemStack.EMPTY);
+
+ player.spawnAtLocation(helmet.copy());
+
+ mob.swing(mob.getUsedItemHand());
+
+ grabCooldown = 40;
+ }
+
+ @Override
+ public boolean requiresUpdateEveryTick() {
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/devce/rocketnautics/content/screens/AstralEngineeringTableMenu.java b/src/main/java/dev/devce/rocketnautics/content/screens/AstralEngineeringTableMenu.java
new file mode 100644
index 0000000..7f05b04
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/content/screens/AstralEngineeringTableMenu.java
@@ -0,0 +1,123 @@
+package dev.devce.rocketnautics.content.screens;
+
+import dev.devce.rocketnautics.content.blocks.AstralEngineeringTableBlockEntity;
+import dev.devce.rocketnautics.registry.RocketBlocks;
+import net.minecraft.core.BlockPos;
+import net.minecraft.network.FriendlyByteBuf;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.inventory.*;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.entity.BlockEntity;
+import net.neoforged.neoforge.items.SlotItemHandler;
+import org.jetbrains.annotations.Nullable;
+
+public class AstralEngineeringTableMenu extends AbstractContainerMenu {
+
+ private final AstralEngineeringTableBlockEntity blockEntity;
+ private final Level level;
+ private final ContainerData data;
+ private final BlockPos blockPos;
+
+ public AstralEngineeringTableMenu(int containerId, Inventory inv, FriendlyByteBuf extraData) {
+ this(containerId, inv, inv.player.level(), extraData.readBlockPos());
+ }
+
+ public AstralEngineeringTableMenu(int containerId, Inventory inv, Level level, BlockPos pos) {
+ this(containerId, inv, level, pos, level.getBlockEntity(pos), new SimpleContainerData(2));
+ }
+
+ public AstralEngineeringTableMenu(int containerId, Inventory inv, BlockEntity entity, ContainerData data) {
+ this(containerId, inv, inv.player.level(), entity.getBlockPos(), entity, data);
+ }
+
+ private AstralEngineeringTableMenu(int containerId, Inventory inv, Level level, BlockPos pos, @Nullable BlockEntity entity, ContainerData data) {
+ super(ModMenuTypes.ASTRAL_ENGINEERING_TABLE_MENU.get(), containerId);
+ this.level = level;
+ this.blockPos = pos;
+ this.data = data;
+
+ if (!(entity instanceof AstralEngineeringTableBlockEntity table)) {
+ throw new IllegalStateException("Astral Engineering Table menu opened without a valid block entity at " + pos);
+ }
+
+ this.blockEntity = table;
+
+ addPlayerInventory(inv);
+ addPlayerHotbar(inv);
+
+ this.addSlot(new SlotItemHandler(blockEntity.itemHandler, 0, 54, 34)); // input left
+ this.addSlot(new SlotItemHandler(blockEntity.itemHandler, 1, 80, 34)); // input middle
+ this.addSlot(new SlotItemHandler(blockEntity.itemHandler, 2, 104, 34) { // output right
+ @Override
+ public boolean mayPlace(ItemStack stack) {
+ return false;
+ }
+
+ @Override
+ public void onTake(Player player, ItemStack stack) {
+ super.onTake(player, stack);
+ blockEntity.consumeRecipeInputs();
+ }
+ });
+
+ addDataSlots(data);
+ }
+
+ public boolean isCrafting() {
+ return data.get(0) > 0;
+ }
+
+ @Override
+ public ItemStack quickMoveStack(Player playerIn, int index) {
+ Slot sourceSlot = slots.get(index);
+ if (sourceSlot == null || !sourceSlot.hasItem()) return ItemStack.EMPTY;
+
+ ItemStack sourceStack = sourceSlot.getItem();
+ ItemStack copyOfSourceStack = sourceStack.copy();
+
+ int vanillaSlotCount = 36;
+
+ if (index < vanillaSlotCount) {
+ if (!moveItemStackTo(sourceStack, vanillaSlotCount, vanillaSlotCount + 2, false)) {
+ return ItemStack.EMPTY;
+ }
+ } else if (index < vanillaSlotCount + 3) {
+ if (!moveItemStackTo(sourceStack, 0, vanillaSlotCount, false)) {
+ return ItemStack.EMPTY;
+ }
+ } else {
+ return ItemStack.EMPTY;
+ }
+
+ if (sourceStack.isEmpty()) {
+ sourceSlot.set(ItemStack.EMPTY);
+ } else {
+ sourceSlot.setChanged();
+ }
+
+ sourceSlot.onTake(playerIn, sourceStack);
+ return copyOfSourceStack;
+ }
+
+ @Override
+ public boolean stillValid(Player player) {
+ return stillValid(ContainerLevelAccess.create(level, blockPos),
+ player, RocketBlocks.ASTRAL_ENGINEERING_TABLE.get());
+ }
+
+ private void addPlayerInventory(Inventory playerInventory) {
+ for (int row = 0; row < 3; ++row) {
+ for (int col = 0; col < 9; ++col) {
+ this.addSlot(new Slot(playerInventory, col + row * 9 + 9, 8 + col * 18, 84 + row * 18));
+ }
+ }
+ }
+
+ private void addPlayerHotbar(Inventory playerInventory) {
+ for (int i = 0; i < 9; ++i) {
+ this.addSlot(new Slot(playerInventory, i, 8 + i * 18, 142));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/devce/rocketnautics/content/screens/AstralEngineeringTableScreen.java b/src/main/java/dev/devce/rocketnautics/content/screens/AstralEngineeringTableScreen.java
new file mode 100644
index 0000000..5071bfb
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/content/screens/AstralEngineeringTableScreen.java
@@ -0,0 +1,38 @@
+package dev.devce.rocketnautics.content.screens;
+
+
+import com.mojang.blaze3d.systems.RenderSystem;
+import dev.devce.rocketnautics.RocketNautics;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
+import net.minecraft.client.renderer.GameRenderer;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.entity.player.Inventory;
+
+public class AstralEngineeringTableScreen extends AbstractContainerScreen {
+ private static final ResourceLocation GUI_TEXTURE =
+ ResourceLocation.fromNamespaceAndPath(RocketNautics.MODID,"textures/gui/astral_engineering_table/astral_engineering_table_gui.png");
+
+ public AstralEngineeringTableScreen(AstralEngineeringTableMenu menu, Inventory playerInventory, Component title) {
+ super(menu, playerInventory, title);
+ }
+
+ @Override
+ protected void renderBg(GuiGraphics guiGraphics, float v, int i, int i1) {
+ RenderSystem.setShader(GameRenderer::getPositionTexShader);
+ RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F);
+ RenderSystem.setShaderTexture(0, GUI_TEXTURE);
+
+ int x = (width - imageWidth) / 2;
+ int y = (height - imageHeight) / 2;
+
+ guiGraphics.blit(GUI_TEXTURE, x, y, 0, 0, imageWidth, imageHeight);
+ }
+
+ @Override
+ public void render(GuiGraphics pGuiGraphics, int pMouseX, int pMouseY, float pPartialTick) {
+ super.render(pGuiGraphics, pMouseX, pMouseY, pPartialTick);
+ this.renderTooltip(pGuiGraphics, pMouseX, pMouseY);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/devce/rocketnautics/content/screens/ModMenuTypes.java b/src/main/java/dev/devce/rocketnautics/content/screens/ModMenuTypes.java
new file mode 100644
index 0000000..6bfd6a7
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/content/screens/ModMenuTypes.java
@@ -0,0 +1,28 @@
+package dev.devce.rocketnautics.content.screens;
+
+import dev.devce.rocketnautics.RocketNautics;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.world.inventory.AbstractContainerMenu;
+import net.minecraft.world.inventory.MenuType;
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.neoforge.common.extensions.IMenuTypeExtension;
+import net.neoforged.neoforge.network.IContainerFactory;
+import net.neoforged.neoforge.registries.DeferredHolder;
+import net.neoforged.neoforge.registries.DeferredRegister;
+
+public class ModMenuTypes {
+ public static final DeferredRegister> MENUS =
+ DeferredRegister.create(Registries.MENU, RocketNautics.MODID);
+
+ public static final DeferredHolder, MenuType> ASTRAL_ENGINEERING_TABLE_MENU =
+ registerMenuType("astral_engineering_table_menu", AstralEngineeringTableMenu::new);
+
+ private static DeferredHolder, MenuType> registerMenuType(
+ String name, IContainerFactory factory) {
+ return MENUS.register(name, () -> IMenuTypeExtension.create(factory));
+ }
+
+ public static void register(IEventBus eventBus) {
+ MENUS.register(eventBus);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/devce/rocketnautics/event/ClientModEvents.java b/src/main/java/dev/devce/rocketnautics/event/ClientModEvents.java
new file mode 100644
index 0000000..d86fe2e
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/event/ClientModEvents.java
@@ -0,0 +1,28 @@
+package dev.devce.rocketnautics.event;
+
+import dev.devce.rocketnautics.content.mobs.StarvedRenderer;
+import dev.devce.rocketnautics.content.screens.AstralEngineeringTableScreen;
+import dev.devce.rocketnautics.content.screens.ModMenuTypes;
+import dev.devce.rocketnautics.registry.RocketEntities;
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.neoforge.client.event.EntityRenderersEvent;
+import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
+
+public class ClientModEvents {
+
+ public static void init(IEventBus modBus) {
+ modBus.addListener(ClientModEvents::registerScreens);
+ modBus.addListener(ClientModEvents::registerRenderers);
+ }
+
+ private static void registerScreens(RegisterMenuScreensEvent event) {
+ event.register(
+ ModMenuTypes.ASTRAL_ENGINEERING_TABLE_MENU.get(),
+ AstralEngineeringTableScreen::new
+ );
+ }
+
+ private static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
+ event.registerEntityRenderer(RocketEntities.STARVED.get(), StarvedRenderer::new);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/devce/rocketnautics/event/RopeHandler.java b/src/main/java/dev/devce/rocketnautics/event/RopeHandler.java
new file mode 100644
index 0000000..619f306
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/event/RopeHandler.java
@@ -0,0 +1,303 @@
+package dev.devce.rocketnautics.event;
+
+import dev.devce.rocketnautics.registry.RocketItems;
+import dev.ryanhcode.sable.companion.SableCompanion;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.core.particles.ParticleTypes;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.InteractionResult;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.entity.EquipmentSlot;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.phys.Vec3;
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.neoforge.common.NeoForge;
+import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
+import net.neoforged.neoforge.event.tick.PlayerTickEvent;
+import org.joml.Vector3d;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+public final class RopeHandler {
+
+ private static final Map ROPE_STATES = new HashMap<>();
+ private static final ResourceLocation SIMULATED_ROPE_CONNECTOR_ID =
+ ResourceLocation.fromNamespaceAndPath("simulated", "rope_connector");
+
+ private static final double DEFAULT_ROPE_LENGTH = 8.0;
+ private static final double MIN_ROPE_LENGTH = 1.25;
+ private static final double BASE_PULL_STRENGTH = 0.16;
+ private static final double STRETCH_PULL_STRENGTH = 0.18;
+ private static final double MAX_PULL_STRENGTH = 0.90;
+ private static final double MAX_RELATIVE_SPEED = 7.20;
+ private static final double DAMPING = 0.992;
+ private static final double TAUT_THRESHOLD = 0.98;
+ private static final double CLIMB_ACCELERATION = 0.28;
+ private static final double REEL_SPEED = 0.45;
+ private static final double INPUT_DEADZONE = 0.15;
+
+ private RopeHandler() {
+ }
+
+ private static final class RopeState {
+ private final BlockPos anchorPos;
+ private Vector3d anchor;
+ private double ropeLength;
+ private final double maxRopeLength;
+
+ private RopeState(BlockPos anchorPos, Vector3d anchor, double ropeLength, double maxRopeLength) {
+ this.anchorPos = anchorPos;
+ this.anchor = anchor;
+ this.ropeLength = ropeLength;
+ this.maxRopeLength = maxRopeLength;
+ }
+ }
+
+ public static void init(IEventBus modBus) {
+ NeoForge.EVENT_BUS.addListener(RopeHandler::onPlayerTick);
+ NeoForge.EVENT_BUS.addListener(RopeHandler::onRightClickBlock);
+ }
+
+ public static void onPlayerTick(PlayerTickEvent.Post event) {
+ Player player = event.getEntity();
+
+ UUID id = player.getUUID();
+ RopeState state = ROPE_STATES.get(id);
+
+ boolean wearing = player.getItemBySlot(EquipmentSlot.CHEST).is(RocketItems.SPACE_CHESTPLATE.get());
+
+ if (!wearing) {
+ ROPE_STATES.remove(id);
+ return;
+ }
+
+ if (state == null || state.anchor == null) {
+ return;
+ }
+
+ if (player.level().isClientSide) {
+ return;
+ }
+
+ if (!isRopeConnector(player.level(), state.anchorPos)) {
+ detachIfPresent(player);
+ return;
+ }
+
+
+ double climbInput = getClimbInput(player);
+ if (climbInput != 0.0 && isInZeroG(player)) {
+ state.ropeLength = clamp(state.ropeLength - climbInput * REEL_SPEED, MIN_ROPE_LENGTH, state.maxRopeLength);
+ }
+
+ applyGrapple(player, state, climbInput);
+
+ if (player.level() instanceof ServerLevel serverLevel) {
+ Vec3 projectedAnchor = projectAnchor(player.level(), state.anchor);
+ spawnRopeParticles(serverLevel, player.position().add(0, 1.5, 0), projectedAnchor);
+ }
+ }
+
+ private static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) {
+ Player player = event.getEntity();
+ Level level = player.level();
+ if (event.getHand() != InteractionHand.MAIN_HAND) {
+ return;
+ }
+
+ if (level.isClientSide) {
+ return;
+ }
+
+ if (!player.isShiftKeyDown()) {
+ return;
+ }
+
+ if (!player.getItemBySlot(EquipmentSlot.CHEST).is(RocketItems.SPACE_CHESTPLATE.get())) {
+ return;
+ }
+
+ if (!player.getItemBySlot(EquipmentSlot.MAINHAND).is(RocketItems.TETHER.get())) {
+ return;
+ }
+
+ BlockPos pos = event.getPos();
+ if (!isRopeConnector(level, pos)) {
+ showStatus(player, "message.rocketnautics.tether_invalid_anchor");
+ event.setCancellationResult(InteractionResult.SUCCESS);
+ event.setCanceled(true);
+ return;
+ }
+
+ Vec3 anchor = blockCenter(pos);
+
+ UUID id = player.getUUID();
+ double ropeLengthSqr = SableCompanion.INSTANCE.distanceSquaredWithSubLevels(level, player.position(), anchor);
+ double ropeLength = Math.sqrt(Math.max(0.0, ropeLengthSqr)) * 1.5;
+ if (!Double.isFinite(ropeLength)) {
+ ropeLength = DEFAULT_ROPE_LENGTH;
+ }
+
+ double clampedLength = Math.max(MIN_ROPE_LENGTH, ropeLength);
+ ROPE_STATES.put(id, new RopeState(
+ pos.immutable(),
+ new Vector3d(anchor.x, anchor.y, anchor.z),
+ clampedLength,
+ clampedLength
+ ));
+
+ showStatus(player, "message.rocketnautics.tether_attached");
+ event.setCancellationResult(InteractionResult.SUCCESS);
+ event.setCanceled(true);
+ }
+
+ public static Vec3 getProjectedAnchor(Player player) {
+ RopeState state = ROPE_STATES.get(player.getUUID());
+ if (state == null || state.anchor == null) {
+ return null;
+ }
+ return projectAnchor(player.level(), state.anchor);
+ }
+
+ private static void applyGrapple(Player player, RopeState state, double climbInput) {
+ Level level = player.level();
+ Vector3d anchor = state.anchor;
+ double ropeLength = state.ropeLength;
+ Vec3 anchorRaw = new Vec3(anchor.x, anchor.y, anchor.z);
+ Vec3 playerPos = SableCompanion.INSTANCE.projectOutOfSubLevel(level, player.position());
+ Vec3 target = SableCompanion.INSTANCE.projectOutOfSubLevel(level, anchorRaw);
+ Vec3 toAnchor = target.subtract(playerPos);
+ double distance = toAnchor.length();
+
+ if (distance < 0.0001) {
+ return;
+ }
+
+ Vec3 dir = toAnchor.normalize();
+ Vec3 anchorVelocity = SableCompanion.INSTANCE.getVelocity(level, anchorRaw);
+ Vec3 relativeVel = player.getDeltaMovement().subtract(anchorVelocity);
+ double radialSpeed = relativeVel.dot(dir);
+
+ Vec3 newRelativeVel = relativeVel;
+
+ if (climbInput != 0.0 && isInZeroG(player)) {
+ newRelativeVel = newRelativeVel.add(dir.scale(climbInput * CLIMB_ACCELERATION));
+ }
+
+ if (distance >= ropeLength * TAUT_THRESHOLD) {
+ // radialSpeed < 0 means moving away from anchor.
+ if (radialSpeed < 0.0) {
+ newRelativeVel = newRelativeVel.subtract(dir.scale(radialSpeed));
+ }
+
+ if (distance > ropeLength) {
+ double stretch = distance - ropeLength;
+ double pullStrength = Math.min(MAX_PULL_STRENGTH, BASE_PULL_STRENGTH + stretch * STRETCH_PULL_STRENGTH);
+ newRelativeVel = newRelativeVel.add(dir.scale(pullStrength));
+ }
+ }
+
+ newRelativeVel = newRelativeVel.scale(DAMPING);
+
+ double relativeSpeed = newRelativeVel.length();
+ if (relativeSpeed > MAX_RELATIVE_SPEED) {
+ newRelativeVel = newRelativeVel.scale(MAX_RELATIVE_SPEED / relativeSpeed);
+ }
+
+ Vec3 newVel = newRelativeVel.add(anchorVelocity);
+
+ player.setDeltaMovement(newVel);
+ player.hurtMarked = true;
+ }
+
+ private static double getClimbInput(Player player) {
+ double forward = player.zza;
+ if (Math.abs(forward) < INPUT_DEADZONE) {
+ return 0.0;
+ }
+ return clamp(forward, -1.0, 1.0);
+ }
+
+ private static boolean isInZeroG(Player player) {
+ return !player.onGround() || player.getY() > 1000.0;
+ }
+
+ private static double clamp(double value, double min, double max) {
+ return Math.max(min, Math.min(max, value));
+ }
+
+ private static boolean isRopeConnector(Level level, BlockPos pos) {
+ Block ropeConnector = BuiltInRegistries.BLOCK.get(SIMULATED_ROPE_CONNECTOR_ID);
+ Block block = level.getBlockState(pos).getBlock();
+ if (ropeConnector != null && block == ropeConnector) {
+ return true;
+ }
+
+ ResourceLocation blockId = BuiltInRegistries.BLOCK.getKey(block);
+ if (blockId != null && "rope_connector".equals(blockId.getPath())) {
+ return true;
+ }
+
+ String descriptionId = block.getDescriptionId();
+ if ("block.simulated.rope_connector".equals(descriptionId)) {
+ return true;
+ }
+
+ String className = block.getClass().getName();
+ return className.endsWith(".RopeConnectorBlock")
+ || className.contains(".rope.rope_connector.");
+ }
+
+ private static void detach(Player player) {
+ ROPE_STATES.remove(player.getUUID());
+ }
+
+ public static void detachIfPresent(Player player) {
+ if (!ROPE_STATES.containsKey(player.getUUID())) {
+ return;
+ }
+
+ detach(player);
+ showStatus(player, "message.rocketnautics.tether_detached");
+ }
+
+
+ private static void showStatus(Player player, String translationKey) {
+ if (!player.level().isClientSide) {
+ player.displayClientMessage(Component.translatable(translationKey), true);
+ }
+ }
+
+ private static Vec3 projectAnchor(Level level, Vector3d anchor) {
+ return SableCompanion.INSTANCE.projectOutOfSubLevel(level, new Vec3(anchor.x, anchor.y, anchor.z));
+ }
+
+ private static Vec3 blockCenter(BlockPos pos) {
+ return new Vec3(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
+ }
+
+ private static void spawnRopeParticles(ServerLevel level, Vec3 from, Vec3 to) {
+ int segments = 12;
+ for (int i = 0; i <= segments; i++) {
+ double t = (double) i / segments;
+ double x = from.x + (to.x - from.x) * t;
+ double y = from.y + (to.y - from.y) * t;
+ double z = from.z + (to.z - from.z) * t;
+
+ level.sendParticles(
+ ParticleTypes.END_ROD,
+ x, y, z,
+ 1,
+ 0.0, 0.0, 0.0,
+ 0.0
+ );
+ }
+ }
+}
diff --git a/src/main/java/dev/devce/rocketnautics/network/NetworkHandler.java b/src/main/java/dev/devce/rocketnautics/network/NetworkHandler.java
index 20cca44..8cba821 100644
--- a/src/main/java/dev/devce/rocketnautics/network/NetworkHandler.java
+++ b/src/main/java/dev/devce/rocketnautics/network/NetworkHandler.java
@@ -2,6 +2,7 @@
import dev.devce.rocketnautics.RocketNautics;
import dev.devce.rocketnautics.client.SkyHandler;
+import dev.devce.rocketnautics.event.RopeHandler;
import net.minecraft.core.Holder;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.level.ServerLevel;
@@ -30,6 +31,12 @@ public static void register(RegisterPayloadHandlersEvent event) {
(payload, context) -> context.enqueueWork(() -> handleMapRequest(context.player()))
);
+ registrar.playToServer(
+ TetherDetachPayload.TYPE,
+ TetherDetachPayload.CODEC,
+ (payload, context) -> context.enqueueWork(() -> RopeHandler.detachIfPresent(context.player()))
+ );
+
registrar.playToClient(
PlanetMapPayload.TYPE,
PlanetMapPayload.CODEC,
diff --git a/src/main/java/dev/devce/rocketnautics/network/TetherDetachPayload.java b/src/main/java/dev/devce/rocketnautics/network/TetherDetachPayload.java
new file mode 100644
index 0000000..0ec76b0
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/network/TetherDetachPayload.java
@@ -0,0 +1,20 @@
+package dev.devce.rocketnautics.network;
+
+import dev.devce.rocketnautics.RocketNautics;
+import io.netty.buffer.ByteBuf;
+import net.minecraft.network.codec.StreamCodec;
+import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
+import net.minecraft.resources.ResourceLocation;
+
+public record TetherDetachPayload() implements CustomPacketPayload {
+ public static final Type TYPE =
+ new Type<>(ResourceLocation.fromNamespaceAndPath(RocketNautics.MODID, "tether_detach"));
+
+ public static final StreamCodec CODEC =
+ StreamCodec.unit(new TetherDetachPayload());
+
+ @Override
+ public Type extends CustomPacketPayload> type() {
+ return TYPE;
+ }
+}
diff --git a/src/main/java/dev/devce/rocketnautics/registry/ArmorMaterials.java b/src/main/java/dev/devce/rocketnautics/registry/ArmorMaterials.java
new file mode 100644
index 0000000..e17b7ab
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/registry/ArmorMaterials.java
@@ -0,0 +1,61 @@
+package dev.devce.rocketnautics.registry;
+
+import dev.devce.rocketnautics.RocketNautics;
+import net.minecraft.Util;
+import net.minecraft.core.Holder;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.sounds.SoundEvent;
+import net.minecraft.sounds.SoundEvents;
+import net.minecraft.world.item.ArmorItem;
+import net.minecraft.world.item.ArmorMaterial;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.item.crafting.Ingredient;
+import net.minecraft.world.level.ItemLike;
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.neoforge.common.Tags;
+import net.neoforged.neoforge.registries.DeferredRegister;
+
+import java.util.EnumMap;
+import java.util.List;
+
+public class ArmorMaterials {
+
+ public static final DeferredRegister ARMOR_MATERIAL = DeferredRegister.create(
+ BuiltInRegistries.ARMOR_MATERIAL,
+
+ RocketNautics.MODID
+ );
+
+ public static final Holder SPACESUIT_MATERIAL =
+ ARMOR_MATERIAL.register("spacesuit", () -> new ArmorMaterial(
+ Util.make(new EnumMap<>(ArmorItem.Type.class), map -> {
+ map.put(ArmorItem.Type.BOOTS, 2);
+ map.put(ArmorItem.Type.LEGGINGS, 5);
+ map.put(ArmorItem.Type.CHESTPLATE, 6);
+ map.put(ArmorItem.Type.HELMET, 2);
+ map.put(ArmorItem.Type.BODY, 5);
+ }),
+ 9,
+ SoundEvents.ARMOR_EQUIP_LEATHER,
+
+ () -> Ingredient.of(Tags.Items.INGOTS_IRON),
+
+ List.of(
+ new ArmorMaterial.Layer(
+ ResourceLocation.fromNamespaceAndPath(RocketNautics.MODID, "spacesuit")
+ ),
+
+ new ArmorMaterial.Layer(
+ ResourceLocation.fromNamespaceAndPath(RocketNautics.MODID, "copper"), "_overlay", true
+ )
+ ),
+
+ 0,
+ 0
+ ));
+
+ public static void register(IEventBus modEventBus) {
+ ARMOR_MATERIAL.register(modEventBus);
+ }
+}
diff --git a/src/main/java/dev/devce/rocketnautics/registry/RocketBlockEntities.java b/src/main/java/dev/devce/rocketnautics/registry/RocketBlockEntities.java
index 00df577..c26449b 100644
--- a/src/main/java/dev/devce/rocketnautics/registry/RocketBlockEntities.java
+++ b/src/main/java/dev/devce/rocketnautics/registry/RocketBlockEntities.java
@@ -1,10 +1,7 @@
package dev.devce.rocketnautics.registry;
import dev.devce.rocketnautics.RocketNautics;
-import dev.devce.rocketnautics.content.blocks.BoosterThrusterBlockEntity;
-import dev.devce.rocketnautics.content.blocks.RCSThrusterBlockEntity;
-import dev.devce.rocketnautics.content.blocks.RocketThrusterBlockEntity;
-import dev.devce.rocketnautics.content.blocks.VectorThrusterBlockEntity;
+import dev.devce.rocketnautics.content.blocks.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.neoforged.bus.api.IEventBus;
@@ -34,6 +31,10 @@ public class RocketBlockEntities {
BLOCK_ENTITIES.register("rcs_thruster",
() -> BlockEntityType.Builder.of(RCSThrusterBlockEntity::new, RocketBlocks.RCS_THRUSTER.get()).build(null));
+ public static final Supplier> ASTRAL_ENGINEERING_TABLE_BLOCK_ENTITY =
+ BLOCK_ENTITIES.register("astral_engineering_table",
+ () -> BlockEntityType.Builder.of(AstralEngineeringTableBlockEntity::new, RocketBlocks.ASTRAL_ENGINEERING_TABLE.get()).build(null));
+
public static void register(IEventBus eventBus) {
BLOCK_ENTITIES.register(eventBus);
eventBus.addListener(RocketBlockEntities::registerCapabilities);
diff --git a/src/main/java/dev/devce/rocketnautics/registry/RocketBlocks.java b/src/main/java/dev/devce/rocketnautics/registry/RocketBlocks.java
index e3a2cd8..fe250ac 100644
--- a/src/main/java/dev/devce/rocketnautics/registry/RocketBlocks.java
+++ b/src/main/java/dev/devce/rocketnautics/registry/RocketBlocks.java
@@ -1,11 +1,7 @@
package dev.devce.rocketnautics.registry;
import dev.devce.rocketnautics.RocketNautics;
-import dev.devce.rocketnautics.content.blocks.RocketThrusterBlock;
-import dev.devce.rocketnautics.content.blocks.VectorThrusterBlock;
-import dev.devce.rocketnautics.content.blocks.BoosterThrusterBlock;
-import dev.devce.rocketnautics.content.blocks.RCSThrusterBlock;
-import dev.devce.rocketnautics.content.blocks.SeparatorBlock;
+import dev.devce.rocketnautics.content.blocks.*;
import dev.devce.rocketnautics.content.items.RocketItem;
import dev.devce.rocketnautics.content.items.RocketBlockItem;
import net.minecraft.world.item.Item;
@@ -42,6 +38,9 @@ public class RocketBlocks {
public static final DeferredBlock SEPARATOR = registerBlock("separator",
() -> new SeparatorBlock(BlockBehaviour.Properties.ofFullCopy(Blocks.IRON_BLOCK).noOcclusion()));
+ public static final DeferredBlock ASTRAL_ENGINEERING_TABLE = registerBlock("astral_engineering_table",
+ () -> new AstralEngineeringTable(BlockBehaviour.Properties.ofFullCopy(Blocks.IRON_BLOCK).noOcclusion()));
+
public static final DeferredItem MUSIC_DISC_SPACE = ITEMS.register("music_disc_space",
() -> new RocketItem(new Item.Properties().stacksTo(1).rarity(Rarity.RARE)
.jukeboxPlayable(ResourceKey.create(Registries.JUKEBOX_SONG,
diff --git a/src/main/java/dev/devce/rocketnautics/registry/RocketEntities.java b/src/main/java/dev/devce/rocketnautics/registry/RocketEntities.java
new file mode 100644
index 0000000..ce2a009
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/registry/RocketEntities.java
@@ -0,0 +1,35 @@
+package dev.devce.rocketnautics.registry;
+import dev.devce.rocketnautics.RocketNautics;
+import dev.devce.rocketnautics.content.mobs.Starved;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.entity.MobCategory;
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.neoforge.event.entity.EntityAttributeCreationEvent;
+import net.neoforged.neoforge.registries.DeferredRegister;
+
+import java.util.function.Supplier;
+
+public class RocketEntities {
+ public static final DeferredRegister> ENTITY_TYPES =
+ DeferredRegister.create(BuiltInRegistries.ENTITY_TYPE, RocketNautics.MODID);
+
+ public static final Supplier> STARVED =
+ ENTITY_TYPES.register("the_starved",
+ () -> EntityType.Builder.of(Starved::new, MobCategory.MONSTER)
+ .sized(0.6F, 1.95F)
+ .build("the_starved"));
+
+
+ public static void register(IEventBus eventBus) {
+ ENTITY_TYPES.register(eventBus);
+
+ eventBus.addListener(RocketEntities::registerAttributes);
+ }
+
+ @SubscribeEvent
+ public static void registerAttributes(EntityAttributeCreationEvent event) {
+ event.put(RocketEntities.STARVED.get(), Starved.createAttributes().build());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/devce/rocketnautics/registry/RocketItems.java b/src/main/java/dev/devce/rocketnautics/registry/RocketItems.java
new file mode 100644
index 0000000..621a09b
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/registry/RocketItems.java
@@ -0,0 +1,62 @@
+package dev.devce.rocketnautics.registry;
+
+import dev.devce.rocketnautics.RocketNautics;
+import dev.devce.rocketnautics.content.items.RocketItem;
+import net.minecraft.core.component.DataComponentMap;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.item.*;
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.neoforge.registries.DeferredItem;
+import net.neoforged.neoforge.registries.DeferredRegister;
+
+import java.util.List;
+import java.util.function.Supplier;
+
+import static net.minecraft.core.component.DataComponents.MAX_STACK_SIZE;
+
+public class RocketItems {
+
+ public static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(RocketNautics.MODID);
+
+ public static final Supplier SPACE_HELMET = ITEMS.register("space_helmet", () -> new ArmorItem(
+ ArmorMaterials.SPACESUIT_MATERIAL,
+ ArmorItem.Type.HELMET,
+ new Item.Properties().stacksTo(1)
+ ));
+ public static final Supplier SPACE_CHESTPLATE = ITEMS.register("space_chestplate", () -> new ArmorItem(
+ ArmorMaterials.SPACESUIT_MATERIAL,
+ ArmorItem.Type.CHESTPLATE,
+ new Item.Properties().stacksTo(1)
+ ));
+
+
+
+ public static final Supplier SPACE_LEGGINGS = ITEMS.register("space_leggings", () -> new ArmorItem(
+ ArmorMaterials.SPACESUIT_MATERIAL,
+ ArmorItem.Type.LEGGINGS,
+ new Item.Properties().stacksTo(1)
+ ));
+ public static final Supplier SPACE_BOOTS = ITEMS.register("space_boots", () -> new ArmorItem(
+ ArmorMaterials.SPACESUIT_MATERIAL,
+ ArmorItem.Type.BOOTS,
+ new Item.Properties().stacksTo(1)
+ ));
+
+ public static final DeferredItem JETPACK_UPGRADE = ITEMS.register("jetpack_upgrade",
+ () -> new RocketItem(new Item.Properties().stacksTo(1)));
+
+ public static final DeferredItem TETHER = ITEMS.register("tether",
+ () -> new RocketItem(new Item.Properties()));
+
+
+
+
+
+
+ public static void register(IEventBus modEventBus) {
+ ITEMS.register(modEventBus);
+ }
+}
diff --git a/src/main/java/dev/devce/rocketnautics/registry/RocketTabs.java b/src/main/java/dev/devce/rocketnautics/registry/RocketTabs.java
index fc4dfb1..65e7de3 100644
--- a/src/main/java/dev/devce/rocketnautics/registry/RocketTabs.java
+++ b/src/main/java/dev/devce/rocketnautics/registry/RocketTabs.java
@@ -24,6 +24,13 @@ public class RocketTabs {
output.accept(RocketBlocks.BOOSTER_THRUSTER.get());
output.accept(RocketBlocks.RCS_THRUSTER.get());
output.accept(RocketBlocks.SEPARATOR.get());
+ output.accept(RocketBlocks.ASTRAL_ENGINEERING_TABLE.get());
+ output.accept(RocketItems.SPACE_HELMET.get());
+ output.accept(RocketItems.SPACE_CHESTPLATE.get());
+ output.accept(RocketItems.SPACE_LEGGINGS.get());
+ output.accept(RocketItems.SPACE_BOOTS.get());
+ output.accept(RocketItems.TETHER.get());
+ output.accept(RocketItems.JETPACK_UPGRADE.get());
output.accept(RocketBlocks.MUSIC_DISC_SPACE.get());
ItemStack credits = new ItemStack(net.minecraft.world.item.Items.WRITTEN_BOOK);
credits.set(net.minecraft.core.component.DataComponents.ITEM_NAME, net.minecraft.network.chat.Component.translatable("item.rocketnautics.credits_book").withStyle(net.minecraft.ChatFormatting.GOLD));
diff --git a/src/main/java/dev/devce/rocketnautics/util/JetpackData.java b/src/main/java/dev/devce/rocketnautics/util/JetpackData.java
new file mode 100644
index 0000000..843e35f
--- /dev/null
+++ b/src/main/java/dev/devce/rocketnautics/util/JetpackData.java
@@ -0,0 +1,52 @@
+package dev.devce.rocketnautics.util;
+
+import net.minecraft.core.component.DataComponents;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.component.CustomData;
+
+public final class JetpackData {
+ private static final String ROOT = "jetpack";
+
+ private JetpackData() {
+ }
+
+ public static void enable(ItemStack stack) {
+ CustomData.update(DataComponents.CUSTOM_DATA, stack, tag -> {
+ CompoundTag jetpack = new CompoundTag();
+ jetpack.putBoolean("enabled", true);
+ jetpack.putInt("fuel", 1000);
+ jetpack.putInt("maxFuel", 1000);
+ tag.put(ROOT, jetpack);
+ });
+ }
+
+ public static boolean isEnabled(ItemStack stack) {
+ CustomData data = stack.get(DataComponents.CUSTOM_DATA);
+ if (data == null) {
+ return false;
+ }
+
+ CompoundTag tag = data.copyTag();
+ if (!tag.contains(ROOT)) {
+ return false;
+ }
+
+ return tag.getCompound(ROOT).getBoolean("enabled");
+ }
+
+ public static int getFuel(ItemStack stack) {
+ CustomData data = stack.get(DataComponents.CUSTOM_DATA);
+ if (data == null) {
+ return 0;
+ }
+
+ CompoundTag tag = data.copyTag();
+ if (!tag.contains(ROOT)) {
+ return 0;
+ }
+
+ return tag.getCompound(ROOT).getInt("fuel");
+ }
+}
+
diff --git a/src/main/resources/assets/rocketnautics/lang/en_us.json b/src/main/resources/assets/rocketnautics/lang/en_us.json
index 8e89907..b68afea 100644
--- a/src/main/resources/assets/rocketnautics/lang/en_us.json
+++ b/src/main/resources/assets/rocketnautics/lang/en_us.json
@@ -25,6 +25,11 @@
"item.rocketnautics.music_disc_space.desc": "Kevin MacLeod - Brittle Rille",
"item.rocketnautics.music_disc_space.tooltip.shift": "We noticed our employee playing some game. We fired him, but we liked the music.",
"item.rocketnautics.credits_book": "Credits",
+ "key.categories.rocketnautics": "Rocketnautics",
+ "key.rocketnautics.detach_tether": "Detach Tether",
+ "message.rocketnautics.tether_attached": "Tether attached",
+ "message.rocketnautics.tether_detached": "Tether detached",
+ "message.rocketnautics.tether_invalid_anchor": "Tethers can only attach to Rope Connectors",
"rocketnautics.goggles.status": "Status",
"rocketnautics.goggles.active": "Active",
"rocketnautics.goggles.inactive": "Inactive",
diff --git a/src/main/resources/assets/rocketnautics/lang/ru_ru.json b/src/main/resources/assets/rocketnautics/lang/ru_ru.json
index 1119892..3ad7897 100644
--- a/src/main/resources/assets/rocketnautics/lang/ru_ru.json
+++ b/src/main/resources/assets/rocketnautics/lang/ru_ru.json
@@ -5,6 +5,7 @@
"block.rocketnautics.rcs_thruster": "Двигатель Ориентации (RCS)",
"block.rocketnautics.gyroscope": "Гироскоп (SAS)",
"block.rocketnautics.separator": "Отделитель ступеней",
+ "block.rocketnautics.rope_connector": "Тросовый Коннектор",
"block.rocketnautics.separator.tooltip": "«Мы знаем, что это работает, потому что оно взрывается». — Инженерный отдел RocketNautics.",
"block.rocketnautics.rcs_thruster.tooltip.shift": "Пуляет воздухом. Инженеры говорят, что это сжатый газ, но мы-то знаем, что он просто выкачивает его из кабины.",
"rocketnautics.tooltip.rcs_thruster.extra": "Не требует топлива. Тяга 100Н. Активируется редстоуном. Высокая точность, низкая мощность.",
@@ -14,6 +15,9 @@
"gui.rocketnautics.min_thrust": "Мин. тяга (Сигнал 0)",
"gui.rocketnautics.max_thrust": "Макс. тяга (Сигнал 15)",
"gui.rocketnautics.thrust_power": "Мощность тяги",
+ "message.rocketnautics.tether_attached": "Трос закреплен",
+ "message.rocketnautics.tether_detached": "Трос отсоединен",
+ "message.rocketnautics.tether_invalid_anchor": "Трос можно крепить только к коннектору",
"rocketnautics.goggles.status": "Статус",
"rocketnautics.goggles.active": "Активен",
"rocketnautics.goggles.inactive": "Неактивен",
diff --git a/src/main/resources/assets/rocketnautics/textures/entity/starved.png b/src/main/resources/assets/rocketnautics/textures/entity/starved.png
new file mode 100644
index 0000000..b7c6f53
Binary files /dev/null and b/src/main/resources/assets/rocketnautics/textures/entity/starved.png differ
diff --git a/src/main/resources/assets/rocketnautics/textures/gui/astral_engineering_table/astral_engineering_table_gui.png b/src/main/resources/assets/rocketnautics/textures/gui/astral_engineering_table/astral_engineering_table_gui.png
new file mode 100644
index 0000000..6bc566d
Binary files /dev/null and b/src/main/resources/assets/rocketnautics/textures/gui/astral_engineering_table/astral_engineering_table_gui.png differ