From f8008cf0687b6ec127aa0d39c3e7ef766bebefd3 Mon Sep 17 00:00:00 2001 From: RCUTANF <110910565+RCUTANF@users.noreply.github.com> Date: Sun, 15 Feb 2026 12:19:03 +0800 Subject: [PATCH 1/6] feat: implement inventory and container synchronization for spectators --- .../client/gui/screens/DummyContainer.java | 19 +- .../gui/screens/SyncedInventoryScreen.java | 23 +- .../fabric/client/mixin/MinecraftMixin.java | 4 +- .../screen/AbstractContainerScreenMixin.java | 48 +++- .../client/mixin/screen/MenuScreensMixin.java | 26 +- .../client/mixin/screen/ScreenMixin.java | 26 ++ .../client/sync/ClientSyncController.java | 32 +-- .../fabric/client/sync/ClientSyncData.java | 2 +- .../sync/screen/ScreenSyncController.java | 255 ++++++++++++++---- .../client/sync/screen/ScreenSyncData.java | 135 ++++++++++ .../fabric/client/util/EffectUtil.java | 7 +- .../spectatorplus.client.mixins.json | 3 +- .../ServerGamePacketListenerImplMixin.java | 8 + .../fabric/mixin/ServerPlayerMixin.java | 35 ++- .../fabric/sync/ServerSyncController.java | 7 + .../fabric/sync/SyncPackets.java | 2 + .../sync/handler/ContainerSyncHandler.java | 113 ++++++++ .../sync/handler/CursorSyncHandler.java | 75 ++++++ .../sync/handler/InventorySyncHandler.java | 128 +++++++++ .../sync/handler/ScreenSyncHandler.java | 100 +++++++ .../ClientboundContainerSyncPacket.java | 58 ++++ .../packet/ClientboundHotbarSyncPacket.java | 1 + .../ClientboundScreenCursorSyncPacket.java | 4 +- .../packet/ClientboundScreenSyncPacket.java | 8 +- .../ServerboundOpenedInventorySyncPacket.java | 15 +- 25 files changed, 1027 insertions(+), 107 deletions(-) create mode 100644 fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/ScreenMixin.java create mode 100644 fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ContainerSyncHandler.java create mode 100644 fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/CursorSyncHandler.java create mode 100644 fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/InventorySyncHandler.java create mode 100644 fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundContainerSyncPacket.java diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/gui/screens/DummyContainer.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/gui/screens/DummyContainer.java index 48a6400..16a9a12 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/gui/screens/DummyContainer.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/gui/screens/DummyContainer.java @@ -1,29 +1,38 @@ package com.hpfxd.spectatorplus.fabric.client.gui.screens; +import net.minecraft.core.NonNullList; import net.minecraft.world.Container; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; import org.jetbrains.annotations.NotNull; public class DummyContainer implements Container { - private final int size; + private final NonNullList items; public DummyContainer(int size) { - this.size = size; + this.items = NonNullList.withSize(size, ItemStack.EMPTY); } @Override public int getContainerSize() { - return this.size; + return this.items.size(); } @Override public boolean isEmpty() { + for (ItemStack item : this.items) { + if (!item.isEmpty()) { + return false; + } + } return true; } @Override public @NotNull ItemStack getItem(int slot) { + if (slot >= 0 && slot < this.items.size()) { + return this.items.get(slot); + } return ItemStack.EMPTY; } @@ -39,6 +48,9 @@ public boolean isEmpty() { @Override public void setItem(int slot, ItemStack stack) { + if (slot >= 0 && slot < this.items.size()) { + this.items.set(slot, stack); + } } @Override @@ -52,5 +64,6 @@ public boolean stillValid(Player player) { @Override public void clearContent() { + this.items.clear(); } } diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/gui/screens/SyncedInventoryScreen.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/gui/screens/SyncedInventoryScreen.java index 0ca8ea0..e0ae3db 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/gui/screens/SyncedInventoryScreen.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/gui/screens/SyncedInventoryScreen.java @@ -1,8 +1,8 @@ package com.hpfxd.spectatorplus.fabric.client.gui.screens; -import com.hpfxd.spectatorplus.fabric.client.mixin.InventoryAccessor; import com.hpfxd.spectatorplus.fabric.client.mixin.screen.AbstractRecipeBookScreenAccessor; import com.hpfxd.spectatorplus.fabric.client.mixin.screen.ImageButtonAccessor; +import com.hpfxd.spectatorplus.fabric.client.sync.ClientSyncController; import net.minecraft.client.gui.components.ImageButton; import net.minecraft.client.gui.components.Renderable; import net.minecraft.client.gui.components.WidgetSprites; @@ -10,7 +10,6 @@ import net.minecraft.client.gui.narration.NarratableEntry; import net.minecraft.client.gui.screens.inventory.InventoryScreen; import net.minecraft.client.gui.screens.recipebook.RecipeBookComponent; -import net.minecraft.world.entity.EntityEquipment; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; @@ -33,15 +32,17 @@ public void containerTick() { } private void syncOtherItems() { - final SyncedInventoryMenu menu = (SyncedInventoryMenu) this.menu; - final Inventory fakeInventory = menu.getInventory(); - - // Use synced inventory data for all slots (main, armor, offhand) - var syncData = com.hpfxd.spectatorplus.fabric.client.sync.ClientSyncController.syncData; - if (syncData != null && syncData.screen != null && syncData.screen.inventoryItems != null) { - var items = syncData.screen.inventoryItems; - for (int i = 0; i < items.size(); i++) { - fakeInventory.setItem(i, items.get(i)); + // If the mixin worked correctly, this.menu should be a SyncedInventoryMenu + if (this.menu instanceof SyncedInventoryMenu syncedMenu) { + final Inventory inventory = syncedMenu.getInventory(); + + // Use synced inventory data for all slots (main, armor, offhand) + var syncData = ClientSyncController.syncData; + if (syncData != null && syncData.screen != null && syncData.screen.inventoryItems != null) { + var items = syncData.screen.inventoryItems; + for (int i = 0; i < items.size() && i < 41; i++) { + inventory.setItem(i, items.get(i)); + } } } } diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/MinecraftMixin.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/MinecraftMixin.java index e0cdcc2..b243423 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/MinecraftMixin.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/MinecraftMixin.java @@ -24,7 +24,7 @@ public abstract class MinecraftMixin { @Inject(method = "setCameraEntity(Lnet/minecraft/world/entity/Entity;)V", at = @At(value = "TAIL")) private void spectatorplus$resetSyncDataOnCameraSwitch(Entity viewingEntity, CallbackInfo ci) { if (ClientSyncController.syncData != null && (viewingEntity == null || !ClientSyncController.syncData.playerId.equals(viewingEntity.getUUID()))) { - ClientSyncController.setSyncData(null); + ClientSyncController.createSyncDataIfNull(null); } } @@ -64,7 +64,7 @@ public abstract class MinecraftMixin { if (ClientPlayNetworking.canSend(ServerboundOpenedInventorySyncPacket.TYPE)) { // just let the server we've opened our inventory. this is used to sync with other users spectating this client - ClientPlayNetworking.send(new ServerboundOpenedInventorySyncPacket()); + ClientPlayNetworking.send(new ServerboundOpenedInventorySyncPacket(true)); } return true; } diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/AbstractContainerScreenMixin.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/AbstractContainerScreenMixin.java index 248ceb6..52cae8e 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/AbstractContainerScreenMixin.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/AbstractContainerScreenMixin.java @@ -5,6 +5,7 @@ import com.hpfxd.spectatorplus.fabric.client.sync.ClientSyncController; import com.hpfxd.spectatorplus.fabric.client.sync.screen.ScreenSyncController; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.renderer.RenderPipelines; @@ -73,7 +74,7 @@ public abstract class AbstractContainerScreenMixin { ) ) private void spectatorplus$renderSyncedCursorItem(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick, CallbackInfo ci) { - if (!this.spectatorplus$isSyncedScreen()) { + if (!this.spectatorplus$isSyncedScreen() || ClientSyncController.syncData == null || ClientSyncController.syncData.screen == null) { return; } @@ -143,6 +144,51 @@ public abstract class AbstractContainerScreenMixin { this.animations.removeIf(animation -> ++animation.tick >= MOVE_ANIMATION_TICKS); } + @Inject(method = "containerTick", at = @At("TAIL")) + private void spectatorplus$syncContainerItems(CallbackInfo ci) { + if (!this.spectatorplus$isSyncedScreen()) { + return; + } + + this.spectatorplus$syncContainerItems(); + } + + + @Unique + private void spectatorplus$syncContainerItems() { + AbstractContainerScreen self = (AbstractContainerScreen) (Object) this; + var minecraft = Minecraft.getInstance(); + + if (minecraft == null || minecraft.player == null) { + return; + } + + var syncData = ClientSyncController.syncData; + if (syncData == null || syncData.screen == null || syncData.screen.containerItems == null) { + return; + } + + var containerItems = syncData.screen.containerItems; + + // Find container slots and sync items + int containerItemIndex = 0; + for (int slotIndex = 0; slotIndex < self.getMenu().slots.size() && containerItemIndex < containerItems.size(); slotIndex++) { + Slot slot = self.getMenu().slots.get(slotIndex); + + // Only sync container slots, not player inventory + if (slot.container != minecraft.player.getInventory()) { + ItemStack syncedItem = containerItems.get(containerItemIndex); + slot.set(syncedItem); + containerItemIndex++; + } + } + + // Sync cursor item + if (syncData.screen.cursorItem != null) { + minecraft.player.containerMenu.setCarried(syncData.screen.cursorItem); + } + } + @ModifyExpressionValue( method = "renderTooltip(Lnet/minecraft/client/gui/GuiGraphics;II)V", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/inventory/Slot;hasItem()Z") diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/MenuScreensMixin.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/MenuScreensMixin.java index ddf08d7..dbb8c20 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/MenuScreensMixin.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/MenuScreensMixin.java @@ -50,18 +50,26 @@ public abstract class MenuScreensMixin { // if no inventory could be created, we close the inventory } else { - final Inventory inventory; - if (hasInventory) { - inventory = ScreenSyncController.syncedInventory; + // Handle container screens + if (ClientSyncController.syncData.screen.containerType != null) { + // Open container screen with proper type and size + ScreenSyncController.openContainerScreen(mc, + ClientSyncController.syncData.screen.containerType, + ClientSyncController.syncData.screen.containerSize); } else { - inventory = mc.player.getInventory(); - } + // Fallback to original screen creation for unknown container types + final Inventory inventory; + if (hasInventory) { + inventory = ScreenSyncController.syncedInventory; + } else { + inventory = mc.player.getInventory(); + } - final M menu = type.create(windowId, inventory); - final S screen = screenConstructor.create(menu, inventory, title); + final M menu = type.create(windowId, inventory); + final S screen = screenConstructor.create(menu, inventory, title); - ScreenSyncController.handleNewSyncedScreen(mc, screen); - return; + ScreenSyncController.handleNewSyncedScreen(mc, screen); + } } } } diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/ScreenMixin.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/ScreenMixin.java new file mode 100644 index 0000000..57236ee --- /dev/null +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/ScreenMixin.java @@ -0,0 +1,26 @@ +package com.hpfxd.spectatorplus.fabric.client.mixin.screen; + +import com.hpfxd.spectatorplus.fabric.sync.packet.ServerboundOpenedInventorySyncPacket; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.gui.screens.inventory.InventoryScreen; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Screen.class) +public class ScreenMixin { + + @Inject(method = "onClose()V", at = @At("HEAD")) + private void spectatorplus$onScreenClose(CallbackInfo ci) { + // Check if this is an inventory or container screen being closed + if ((Object) this instanceof InventoryScreen || (Object) this instanceof AbstractContainerScreen) { + // Notify server that we closed our inventory/container + if (ClientPlayNetworking.canSend(ServerboundOpenedInventorySyncPacket.TYPE)) { + ClientPlayNetworking.send(new ServerboundOpenedInventorySyncPacket(false)); + } + } + } +} diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/ClientSyncController.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/ClientSyncController.java index 7b1b89c..56a3081 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/ClientSyncController.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/ClientSyncController.java @@ -2,19 +2,12 @@ import com.hpfxd.spectatorplus.fabric.client.sync.screen.ScreenSyncController; import com.hpfxd.spectatorplus.fabric.client.util.EffectUtil; +import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundContainerSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundExperienceSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundFoodSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundHotbarSyncPacket; -import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundInventorySyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundSelectedSlotSyncPacket; -import java.util.List; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundEffectsSyncPacket; -import com.hpfxd.spectatorplus.fabric.sync.SyncedEffect; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.core.Holder; -import net.minecraft.resources.Identifier; -import net.minecraft.world.effect.MobEffect; -import net.minecraft.world.effect.MobEffectInstance; import net.fabricmc.fabric.api.client.networking.v1.ClientLoginConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; @@ -22,7 +15,6 @@ import net.minecraft.world.food.FoodData; import net.minecraft.world.item.ItemStack; -import java.util.ArrayList; import java.util.UUID; public class ClientSyncController { @@ -33,27 +25,25 @@ public static void init() { ClientPlayNetworking.registerGlobalReceiver(ClientboundExperienceSyncPacket.TYPE, ClientSyncController::handle); ClientPlayNetworking.registerGlobalReceiver(ClientboundFoodSyncPacket.TYPE, ClientSyncController::handle); ClientPlayNetworking.registerGlobalReceiver(ClientboundHotbarSyncPacket.TYPE, ClientSyncController::handle); - ClientPlayNetworking.registerGlobalReceiver(ClientboundSelectedSlotSyncPacket.TYPE, - ClientSyncController::handle); + ClientPlayNetworking.registerGlobalReceiver(ClientboundSelectedSlotSyncPacket.TYPE, ClientSyncController::handle); ClientPlayNetworking.registerGlobalReceiver(ClientboundEffectsSyncPacket.TYPE, ClientSyncController::handle); - ClientLoginConnectionEvents.INIT.register((handler, client) -> setSyncData(null)); - ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> setSyncData(null)); + ClientLoginConnectionEvents.INIT.register((handler, client) -> createSyncDataIfNull(null)); + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> createSyncDataIfNull(null)); ScreenSyncController.init(); } private static void handle(ClientboundEffectsSyncPacket packet, ClientPlayNetworking.Context context) { - setSyncData(packet.playerId()); + createSyncDataIfNull(packet.playerId()); syncData.effects = packet.effects(); EffectUtil.updateEffectInstances(packet.effects()); } private static void handle(ClientboundExperienceSyncPacket packet, ClientPlayNetworking.Context context) { - setSyncData(packet.playerId()); + createSyncDataIfNull(packet.playerId()); var player = minecraft.player; - if (player != null - && (packet.progress() != player.experienceProgress || packet.level() != player.experienceLevel)) + if (player != null && (packet.progress() != player.experienceProgress || packet.level() != player.experienceLevel)) player.experienceDisplayStartTick = player.tickCount; syncData.experienceProgress = packet.progress(); @@ -62,7 +52,7 @@ private static void handle(ClientboundExperienceSyncPacket packet, ClientPlayNet } private static void handle(ClientboundFoodSyncPacket packet, ClientPlayNetworking.Context context) { - setSyncData(packet.playerId()); + createSyncDataIfNull(packet.playerId()); if (syncData.foodData == null) { syncData.foodData = new FoodData(); @@ -72,7 +62,7 @@ private static void handle(ClientboundFoodSyncPacket packet, ClientPlayNetworkin } private static void handle(ClientboundHotbarSyncPacket packet, ClientPlayNetworking.Context context) { - setSyncData(packet.playerId()); + createSyncDataIfNull(packet.playerId()); final ItemStack[] items = packet.items(); for (int slot = 0; slot < items.length; slot++) { @@ -85,12 +75,12 @@ private static void handle(ClientboundHotbarSyncPacket packet, ClientPlayNetwork } private static void handle(ClientboundSelectedSlotSyncPacket packet, ClientPlayNetworking.Context context) { - setSyncData(packet.playerId()); + createSyncDataIfNull(packet.playerId()); syncData.selectedHotbarSlot = packet.selectedSlot(); } - public static void setSyncData(UUID playerId) { + public static void createSyncDataIfNull(UUID playerId) { if (playerId == null) { syncData = null; } else if (syncData == null || !syncData.playerId.equals(playerId)) { diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/ClientSyncData.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/ClientSyncData.java index ec15e27..ecb009f 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/ClientSyncData.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/ClientSyncData.java @@ -28,7 +28,7 @@ public ClientSyncData(UUID playerId) { this.playerId = playerId; } - public void setScreen() { + public void createScreenIfNull() { if (this.screen == null) { this.screen = new ScreenSyncData(); } diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncController.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncController.java index 19fc69d..d78a6b7 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncController.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncController.java @@ -1,33 +1,35 @@ package com.hpfxd.spectatorplus.fabric.client.sync.screen; import com.hpfxd.spectatorplus.fabric.client.gui.screens.SyncedInventoryScreen; +import com.hpfxd.spectatorplus.fabric.client.gui.screens.DummyContainer; import com.hpfxd.spectatorplus.fabric.client.mixin.InventoryAccessor; +import com.hpfxd.spectatorplus.fabric.client.sync.ClientSyncController; import com.hpfxd.spectatorplus.fabric.client.util.SpecUtil; +import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundContainerSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundInventorySyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundScreenCursorSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundScreenSyncPacket; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientEntityEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; -import net.fabricmc.fabric.api.networking.v1.PacketSender; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; -import net.minecraft.client.gui.screens.inventory.MenuAccess; -import net.minecraft.client.player.LocalPlayer; -import net.minecraft.core.NonNullList; +import net.minecraft.client.gui.screens.inventory.*; +import net.minecraft.network.chat.Component; import net.minecraft.world.entity.EntityEquipment; 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 static com.hpfxd.spectatorplus.fabric.client.sync.ClientSyncController.setSyncData; +import static com.hpfxd.spectatorplus.fabric.client.sync.ClientSyncController.createSyncDataIfNull; import static com.hpfxd.spectatorplus.fabric.client.sync.ClientSyncController.syncData; public class ScreenSyncController { public static boolean isPendingOpen = false; public static int syncedWindowId = Integer.MIN_VALUE; - public static Inventory syncedInventory; + public static Inventory syncedInventory;//dummy inventory public static Screen syncedScreen; public static void init() { @@ -37,77 +39,239 @@ public static void init() { } }); - ClientPlayNetworking.registerGlobalReceiver(ClientboundScreenSyncPacket.TYPE, ScreenSyncController::handle); ClientPlayNetworking.registerGlobalReceiver(ClientboundInventorySyncPacket.TYPE, ScreenSyncController::handle); + ClientPlayNetworking.registerGlobalReceiver(ClientboundContainerSyncPacket.TYPE, ScreenSyncController::handle); + ClientPlayNetworking.registerGlobalReceiver(ClientboundScreenSyncPacket.TYPE, ScreenSyncController::handle); ClientPlayNetworking.registerGlobalReceiver(ClientboundScreenCursorSyncPacket.TYPE, ScreenSyncController::handle); } + private static void handle(ClientboundContainerSyncPacket packet, ClientPlayNetworking.Context context) { + createSyncDataIfNull(packet.playerId()); + syncData.createScreenIfNull(); + + // Set container data + syncData.screen.containerType = packet.containerType(); + syncData.screen.containerSize = packet.containerSize(); + + // Initialize container items if not already done + syncData.screen.initializeContainerItems(packet.containerSize()); + + // Update container items + // Support incremental updates: null items in the array mean "don't update this slot" + syncData.screen.updateContainerItems(packet.items()); + } + private static void handle(ClientboundScreenSyncPacket packet, ClientPlayNetworking.Context context) { - setSyncData(packet.playerId()); - syncData.setScreen(); + createSyncDataIfNull(packet.playerId()); + syncData.createScreenIfNull(); + + // If the packet indicates the screen is closed, close it on the client + if (packet.isScreenClosed()) { + final Minecraft mc = Minecraft.getInstance(); + mc.execute(ScreenSyncController::closeSyncedInventory); + return; + } isPendingOpen = true; syncData.screen.isSurvivalInventory = packet.isSurvivalInventory(); syncData.screen.isClientRequested = packet.isClientRequested(); syncData.screen.hasDummySlots = packet.hasDummySlots(); + + // If this is a survival inventory screen sync, try to open it immediately + if (syncData.screen.isSurvivalInventory) { + final Minecraft mc = Minecraft.getInstance(); + mc.execute(() -> { + if (isPendingOpen) { + // Create the synced inventory first, so the mixin can use it + final Player spectated = SpecUtil.getCameraPlayer(mc); + if (spectated != null && createInventory(spectated)) { + openPlayerInventory(mc); + } + } + }); + } } private static void handle(ClientboundInventorySyncPacket packet, ClientPlayNetworking.Context context) { - setSyncData(packet.playerId()); - syncData.setScreen(); + createSyncDataIfNull(packet.playerId()); + syncData.createScreenIfNull(); - if (syncData.screen.inventoryItems == null || syncData.screen.inventoryItems.size() != ClientboundInventorySyncPacket.ITEMS_LENGTH) { - syncData.screen.inventoryItems = NonNullList.withSize(ClientboundInventorySyncPacket.ITEMS_LENGTH, ItemStack.EMPTY); - } + syncData.screen.initializeInventoryItems(ClientboundInventorySyncPacket.ITEMS_LENGTH); final ItemStack[] items = packet.items(); - for (int slot = 0; slot < items.length; slot++) { - final ItemStack item = items[slot]; - if (item != null) { - syncData.screen.inventoryItems.set(slot, item); - if (syncedInventory != null) { - syncedInventory.setItem(slot, item); - } - } - } - // Also update global armorItems for convenience - if (syncData.armorItems != null && items.length >= 40) { - for (int slot = 36; slot < 40; slot++) { - final ItemStack item = items[slot]; - if (item != null) { - syncData.armorItems.set(slot - 36, item); - } - } - } + syncData.screen.updateInventoryItems(items); } private static void handle(ClientboundScreenCursorSyncPacket packet, ClientPlayNetworking.Context context) { - setSyncData(packet.playerId()); - syncData.setScreen(); + createSyncDataIfNull(packet.playerId()); + syncData.createScreenIfNull(); syncData.screen.cursorItem = packet.cursor(); syncData.screen.cursorItemSlot = packet.originSlot(); } public static void closeSyncedInventory() { - if (syncedScreen != null) { - syncedScreen.onClose(); - syncedInventory = null; - } + final Minecraft mc = Minecraft.getInstance(); + mc.setScreen(null); + syncedScreen = null; + + syncedInventory = null; + syncedWindowId = Integer.MIN_VALUE; + isPendingOpen = false; + syncData.screen = null; } public static void openPlayerInventory(Minecraft mc) { - final Player player = SpecUtil.getCameraPlayer(mc); - final SyncedInventoryScreen screen = new SyncedInventoryScreen(player); - + final Player spectated = SpecUtil.getCameraPlayer(mc); + final SyncedInventoryScreen screen = new SyncedInventoryScreen(spectated); handleNewSyncedScreen(mc, screen); } + public static void openContainerScreen(Minecraft mc, MenuType containerType, int containerSize) { + final Player spectated = SpecUtil.getCameraPlayer(mc); + if (spectated != null && mc.player != null && syncData != null && syncData.screen != null) { + // Create dummy container for the synced screen + DummyContainer containerInventory = new DummyContainer(containerSize); + + // Populate the container with synced items using helper method + syncData.screen.populateContainer(containerInventory); + + Inventory playerInventory = syncedInventory != null ? syncedInventory : mc.player.getInventory(); + Screen screen = createVanillaContainerScreen(containerType, syncedWindowId, playerInventory, containerInventory); + + if (screen instanceof MenuAccess) { + handleNewSyncedScreen(mc, (Screen & MenuAccess) screen); + } + } + } + + private static Screen createVanillaContainerScreen(MenuType type, int windowId, Inventory playerInventory, DummyContainer containerInventory) { + Component title = Component.literal("Container"); + + // For furnace-like containers + if (type == MenuType.FURNACE) { + FurnaceMenu menu = new FurnaceMenu(windowId, playerInventory, containerInventory, new SimpleContainerData(4)); + return new FurnaceScreen(menu, playerInventory, title); + } else if (type == MenuType.BLAST_FURNACE) { + BlastFurnaceMenu menu = new BlastFurnaceMenu(windowId, playerInventory, containerInventory, new SimpleContainerData(4)); + return new BlastFurnaceScreen(menu, playerInventory, title); + } else if (type == MenuType.SMOKER) { + SmokerMenu menu = new SmokerMenu(windowId, playerInventory, containerInventory, new SimpleContainerData(4)); + return new SmokerScreen(menu, playerInventory, title); + } + + // For brewing stand + else if (type == MenuType.BREWING_STAND) { + BrewingStandMenu menu = new BrewingStandMenu(windowId, playerInventory, containerInventory, new SimpleContainerData(2)); + return new BrewingStandScreen(menu, playerInventory, title); + } + + // For anvil + else if (type == MenuType.ANVIL) { + AnvilMenu menu = new AnvilMenu(windowId, playerInventory, ContainerLevelAccess.NULL); + return new AnvilScreen(menu, playerInventory, title); + } + + // For enchanting table + else if (type == MenuType.ENCHANTMENT) { + EnchantmentMenu menu = new EnchantmentMenu(windowId, playerInventory, ContainerLevelAccess.NULL); + return new EnchantmentScreen(menu, playerInventory, title); + } + + // For crafting table + else if (type == MenuType.CRAFTING) { + CraftingMenu menu = new CraftingMenu(windowId, playerInventory, ContainerLevelAccess.NULL); + return new CraftingScreen(menu, playerInventory, title); + } + + // For grindstone + else if (type == MenuType.GRINDSTONE) { + GrindstoneMenu menu = new GrindstoneMenu(windowId, playerInventory, ContainerLevelAccess.NULL); + return new GrindstoneScreen(menu, playerInventory, title); + } + + // For loom + else if (type == MenuType.LOOM) { + LoomMenu menu = new LoomMenu(windowId, playerInventory, ContainerLevelAccess.NULL); + return new LoomScreen(menu, playerInventory, title); + } + + // For stonecutter + else if (type == MenuType.STONECUTTER) { + StonecutterMenu menu = new StonecutterMenu(windowId, playerInventory, ContainerLevelAccess.NULL); + return new StonecutterScreen(menu, playerInventory, title); + } + + // For cartography table + else if (type == MenuType.CARTOGRAPHY_TABLE) { + CartographyTableMenu menu = new CartographyTableMenu(windowId, playerInventory, ContainerLevelAccess.NULL); + return new CartographyTableScreen(menu, playerInventory, title); + } + + // For smithing table + else if (type == MenuType.SMITHING) { + SmithingMenu menu = new SmithingMenu(windowId, playerInventory, ContainerLevelAccess.NULL); + return new SmithingScreen(menu, playerInventory, title); + } + + // For generic containers (chests, etc.) + else if (type == MenuType.GENERIC_9x1) { + ChestMenu menu = new ChestMenu(MenuType.GENERIC_9x1, windowId, playerInventory, containerInventory, 1); + return new ContainerScreen(menu, playerInventory, title); + } else if (type == MenuType.GENERIC_9x2) { + ChestMenu menu = new ChestMenu(MenuType.GENERIC_9x2, windowId, playerInventory, containerInventory, 2); + return new ContainerScreen(menu, playerInventory, title); + } else if (type == MenuType.GENERIC_9x3) { + ChestMenu menu = new ChestMenu(MenuType.GENERIC_9x3, windowId, playerInventory, containerInventory, 3); + return new ContainerScreen(menu, playerInventory, title); + } else if (type == MenuType.GENERIC_9x4) { + ChestMenu menu = new ChestMenu(MenuType.GENERIC_9x4, windowId, playerInventory, containerInventory, 4); + return new ContainerScreen(menu, playerInventory, title); + } else if (type == MenuType.GENERIC_9x5) { + ChestMenu menu = new ChestMenu(MenuType.GENERIC_9x5, windowId, playerInventory, containerInventory, 5); + return new ContainerScreen(menu, playerInventory, title); + } else if (type == MenuType.GENERIC_9x6) { + ChestMenu menu = new ChestMenu(MenuType.GENERIC_9x6, windowId, playerInventory, containerInventory, 6); + return new ContainerScreen(menu, playerInventory, title); + } + + // For dispenser and dropper + else if (type == MenuType.GENERIC_3x3) { + DispenserMenu menu = new DispenserMenu(windowId, playerInventory, containerInventory); + return new DispenserScreen(menu, playerInventory, title); + } + + // For hopper + else if (type == MenuType.HOPPER) { + HopperMenu menu = new HopperMenu(windowId, playerInventory, containerInventory); + return new HopperScreen(menu, playerInventory, title); + } + + // For shulker box + else if (type == MenuType.SHULKER_BOX) { + ShulkerBoxMenu menu = new ShulkerBoxMenu(windowId, playerInventory, containerInventory); + return new ShulkerBoxScreen(menu, playerInventory, title); + } + + // For beacon + else if (type == MenuType.BEACON) { + BeaconMenu menu = new BeaconMenu(windowId, containerInventory, new SimpleContainerData(3), ContainerLevelAccess.NULL); + return new BeaconScreen(menu, playerInventory, title); + } + + // Fallback to generic 3x9 chest for unknown types (including lectern which has no GUI) + else { + ChestMenu menu = new ChestMenu(MenuType.GENERIC_9x3, windowId, playerInventory, containerInventory, 3); + return new ContainerScreen(menu, playerInventory, title); + } + } + public static > void handleNewSyncedScreen(Minecraft mc, S screen) { isPendingOpen = false; mc.player.containerMenu = screen.getMenu(); mc.setScreen(screen); + // sanity check to avoid registering events on the wrong screen if (mc.screen != screen) { syncedInventory = null; syncData.screen = null; @@ -125,18 +289,13 @@ public static > void handleNewSyncedScreen(Mine } public static boolean createInventory(Player spectated) { - if (syncData.screen.inventoryItems == null) { + if (!syncData.screen.hasInventoryItems()) { return false; } EntityEquipment equipment = ((InventoryAccessor)spectated.getInventory()).getEquipment(); syncedInventory = new Inventory(spectated, equipment); - - // Set all inventory slots (0-39) - for (int i = 0; i < syncData.screen.inventoryItems.size(); i++) { - syncedInventory.setItem(i, syncData.screen.inventoryItems.get(i)); - } - // No direct access to armor list; setting slots 36-39 is sufficient for GUI + syncData.screen.populateInventory(syncedInventory); return true; } } diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncData.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncData.java index 8e69bac..3cf3d65 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncData.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncData.java @@ -1,7 +1,13 @@ package com.hpfxd.spectatorplus.fabric.client.sync.screen; import net.minecraft.core.NonNullList; +import net.minecraft.world.Container; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.MenuType; import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.NotNull; + +import static com.hpfxd.spectatorplus.fabric.client.sync.ClientSyncController.syncData; public class ScreenSyncData { /** @@ -11,10 +17,139 @@ public class ScreenSyncData { */ public NonNullList inventoryItems; + /** + * Container items for non-inventory screens + */ + public NonNullList containerItems; + + /** + * Container type for determining proper GUI rendering + */ + public MenuType containerType; + + /** + * Container size for dynamic containers like chests + */ + public int containerSize; + public boolean isSurvivalInventory; public boolean isClientRequested; public boolean hasDummySlots; public ItemStack cursorItem = ItemStack.EMPTY; public int cursorItemSlot = -1; + + /** + * Initialize or resize container items to the specified size + * @param size the desired size of the container + */ + public void initializeContainerItems(int size) { + if (containerItems == null || containerItems.size() != size) { + containerItems = NonNullList.withSize(size, ItemStack.EMPTY); + } + } + + /** + * Update container item at the specified slot + * @param slot the slot index + * @param item the item to set (null means skip this slot) + */ + public void updateContainerItem(int slot, ItemStack item) { + if (containerItems != null && slot >= 0 && slot < containerItems.size() && item != null) { + containerItems.set(slot, item.isEmpty() ? ItemStack.EMPTY : item); + } + } + + /** + * Update multiple container items from an array + * @param items array of items to update (null items are skipped) + */ + public void updateContainerItems(ItemStack[] items) { + if (containerItems == null || items == null) { + return; + } + for (int i = 0; i < items.length && i < containerItems.size(); i++) { + updateContainerItem(i, items[i]); + } + } + + /** + * Populate a Container with the synced container items + * @param container the container to populate + */ + public void populateContainer(Container container) { + if (containerItems == null || container == null) { + return; + } + for (int i = 0; i < containerItems.size() && i < container.getContainerSize(); i++) { + container.setItem(i, containerItems.get(i)); + } + } + + /** + * Check if container items are initialized + * @return true if containerItems is not null + */ + public boolean hasContainerItems() { + return containerItems != null; + } + + /** + * Initialize or resize inventory items to the specified size + * @param size the desired size of the inventory + */ + public void initializeInventoryItems(int size) { + if (inventoryItems == null || inventoryItems.size() != size) { + inventoryItems = NonNullList.withSize(size, ItemStack.EMPTY); + } + } + + /** + * Update inventory item at the specified slot + * @param slot the slot index + * @param item the item to set + */ + public void updateInventoryItem(int slot, ItemStack item) { + if (inventoryItems != null && slot >= 0 && slot < inventoryItems.size() && item != null) { + inventoryItems.set(slot, item); + } + } + + public void updateInventoryItems(ItemStack[] items) { + if (inventoryItems == null) return; + + for (int i = 0; i < items.length && i < inventoryItems.size(); i++) { + updateInventoryItem(i, items[i]); + } + //update armor items + if (syncData != null) { + for (int i = 36; i < 40 && i < items.length; i++) { + final ItemStack item = items[i]; + if (item != null) { + syncData.armorItems.set(i - 36, item); + } + } + } + } + + /** + * Populate an Inventory with the synced inventory items + * @param inventory the inventory to populate + */ + public void populateInventory(Inventory inventory) { + if (inventoryItems == null || inventory == null) { + return; + } + for (int i = 0; i < inventoryItems.size(); i++) { + inventory.setItem(i, inventoryItems.get(i)); + } + } + + /** + * Check if inventory items are initialized + * @return true if inventoryItems is not null + */ + public boolean hasInventoryItems() { + return inventoryItems != null; + } } diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/util/EffectUtil.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/util/EffectUtil.java index 3119807..ebcb05d 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/util/EffectUtil.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/util/EffectUtil.java @@ -16,7 +16,7 @@ public class EffectUtil { private static final Map, MobEffectInstance> activeEffects = new HashMap<>(); public static void updateEffectInstances(List effects) { - // 收集新的效果 + // collecting new effects Set> newEffects = new HashSet<>(); for (SyncedEffect syncedEffect : effects) { @@ -25,10 +25,10 @@ public static void updateEffectInstances(List effects) { newEffects.add(effect); } - // 移除不再存在的效果 + // remove effects that are no longer present activeEffects.entrySet().removeIf(entry -> !newEffects.contains(entry.getKey())); - // 添加新效果(保持现有实例的BlendState) + // add new effects (keep existing instances' BlendState) for (SyncedEffect syncedEffect : effects) { Holder effect = BuiltInRegistries.MOB_EFFECT.get(Identifier.parse(syncedEffect.effectKey)) .orElseThrow(() -> new IllegalArgumentException("Unknown effect: " + syncedEffect.effectKey)); @@ -53,7 +53,6 @@ public static boolean shouldUseSpectatorData() { hasValidSyncData(); } - // 直接返回原版格式的activeEffects public static Map, MobEffectInstance> getActiveEffectsMap() { return activeEffects; } diff --git a/fabric/src/client/resources/spectatorplus.client.mixins.json b/fabric/src/client/resources/spectatorplus.client.mixins.json index d302c21..b650ae5 100644 --- a/fabric/src/client/resources/spectatorplus.client.mixins.json +++ b/fabric/src/client/resources/spectatorplus.client.mixins.json @@ -31,7 +31,8 @@ "screen.ImageButtonAccessor", "screen.InventoryMenuMixin", "screen.InventoryScreenMixin", - "screen.MenuScreensMixin" + "screen.MenuScreensMixin", + "screen.ScreenMixin" ], "injectors": { "defaultRequire": 1 diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/mixin/ServerGamePacketListenerImplMixin.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/mixin/ServerGamePacketListenerImplMixin.java index 15d8e13..316ad4c 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/mixin/ServerGamePacketListenerImplMixin.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/mixin/ServerGamePacketListenerImplMixin.java @@ -1,7 +1,9 @@ package com.hpfxd.spectatorplus.fabric.mixin; import com.hpfxd.spectatorplus.fabric.sync.ServerSyncController; +import com.hpfxd.spectatorplus.fabric.sync.handler.CursorSyncHandler; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundSelectedSlotSyncPacket; +import net.minecraft.network.protocol.game.ServerboundContainerClickPacket; import net.minecraft.network.protocol.game.ServerboundSetCarriedItemPacket; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.network.ServerGamePacketListenerImpl; @@ -20,4 +22,10 @@ public abstract class ServerGamePacketListenerImplMixin { private void spectatorplus$syncSelectedSlot(ServerboundSetCarriedItemPacket packet, CallbackInfo ci) { ServerSyncController.broadcastPacketToSpectators(this.player, new ClientboundSelectedSlotSyncPacket(this.player.getUUID(), packet.getSlot())); } + + @Inject(method = "handleContainerClick(Lnet/minecraft/network/protocol/game/ServerboundContainerClickPacket;)V", at = @At("TAIL")) + private void spectatorplus$syncCursorAfterClick(ServerboundContainerClickPacket packet, CallbackInfo ci) { + // After handling container click, check if cursor item changed + CursorSyncHandler.onCursorChanged(this.player, this.player.containerMenu.getCarried(), packet.slotNum()); + } } diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/mixin/ServerPlayerMixin.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/mixin/ServerPlayerMixin.java index 15c7b1f..a4d07ef 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/mixin/ServerPlayerMixin.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/mixin/ServerPlayerMixin.java @@ -3,14 +3,13 @@ import com.google.common.collect.Lists; import com.hpfxd.spectatorplus.fabric.SpectatorMod; import com.hpfxd.spectatorplus.fabric.sync.ServerSyncController; -import com.hpfxd.spectatorplus.fabric.sync.handler.EffectsSyncHandler; +import com.hpfxd.spectatorplus.fabric.sync.handler.*; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundExperienceSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundFoodSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundHotbarSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundSelectedSlotSyncPacket; import com.llamalad7.mixinextras.sugar.Local; import com.mojang.authlib.GameProfile; -import net.minecraft.core.BlockPos; import net.minecraft.core.component.DataComponents; import net.minecraft.network.protocol.Packet; import net.minecraft.network.protocol.game.ClientboundMapItemDataPacket; @@ -35,7 +34,9 @@ import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import java.util.OptionalInt; import java.util.Set; @Mixin(ServerPlayer.class) @@ -58,6 +59,16 @@ public ServerPlayerMixin(Level level, GameProfile gameProfile) { ServerSyncController.broadcastPacketToSpectators((ServerPlayer) (Object) this, new ClientboundExperienceSyncPacket(this.getUUID(), this.experienceProgress, this.getXpNeededForNextLevel(), this.experienceLevel)); } + @Inject(method = "setCamera(Lnet/minecraft/world/entity/Entity;)V", at = @At("HEAD")) + private void spectatorplus$beforeCameraChange(Entity entityToSpectate, CallbackInfo ci) { + final ServerPlayer spectator = (ServerPlayer) (Object) this; + + // If we're changing from spectating a player to something else, clean up + if (spectator.getCamera() instanceof ServerPlayer oldTarget && spectator.getCamera() != entityToSpectate) { + // Clean up any container listeners when stopping spectating a player + ContainerSyncHandler.unsubscribeFromContainer(spectator, oldTarget); + } + } @Inject(method = "setCamera(Lnet/minecraft/world/entity/Entity;)V", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/network/ServerGamePacketListenerImpl;send(Lnet/minecraft/network/protocol/Packet;)V")) private void spectatorplus$syncToNewSpectator(Entity entityToSpectate, CallbackInfo ci) { if (entityToSpectate instanceof final ServerPlayer target) { @@ -67,6 +78,7 @@ public ServerPlayerMixin(Level level, GameProfile gameProfile) { ServerSyncController.sendPacket(spectator, ClientboundFoodSyncPacket.initializing(target)); ServerSyncController.sendPacket(spectator, ClientboundHotbarSyncPacket.initializing(target)); ServerSyncController.sendPacket(spectator, ClientboundSelectedSlotSyncPacket.initializing(target)); + InventorySyncHandler.sendPacket(spectator, target); EffectsSyncHandler.onStartSpectating(spectator, target); // Send initial map data patch packet if the target has a map in inventory @@ -130,4 +142,23 @@ private static ClientboundMapItemDataPacket getInitialMapDataPacket(MapId mapId, this.setCamera(entity); } } + + @Inject(method = "openMenu(Lnet/minecraft/world/MenuProvider;)Ljava/util/OptionalInt;", at = @At("RETURN")) + private void spectatorplus$onOpenMenu(CallbackInfoReturnable cir) { + + final ServerPlayer player = (ServerPlayer) (Object) this; + + if (cir.getReturnValue().isPresent() && player.containerMenu != player.inventoryMenu) { + ScreenSyncHandler.syncScreenOpened(player); + } + } + + @Inject(method = "doCloseContainer()V", at = @At("HEAD")) + private void spectatorplus$onCloseContainer(CallbackInfo ci) { + final ServerPlayer player = (ServerPlayer) (Object) this; + + if (player.containerMenu != player.inventoryMenu) { + ScreenSyncHandler.syncScreenClosed(player); + } + } } diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/ServerSyncController.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/ServerSyncController.java index 587735e..80ca8d2 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/ServerSyncController.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/ServerSyncController.java @@ -1,8 +1,12 @@ package com.hpfxd.spectatorplus.fabric.sync; +import com.hpfxd.spectatorplus.fabric.sync.handler.ContainerSyncHandler; +import com.hpfxd.spectatorplus.fabric.sync.handler.CursorSyncHandler; import com.hpfxd.spectatorplus.fabric.sync.handler.EffectsSyncHandler; import com.hpfxd.spectatorplus.fabric.sync.handler.HotbarSyncHandler; +import com.hpfxd.spectatorplus.fabric.sync.handler.InventorySyncHandler; import com.hpfxd.spectatorplus.fabric.sync.handler.ScreenSyncHandler; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; @@ -16,7 +20,10 @@ public static void init() { HotbarSyncHandler.init(); ScreenSyncHandler.init(); + ContainerSyncHandler.init(); EffectsSyncHandler.init(); + InventorySyncHandler.init(); + CursorSyncHandler.init(); } public static void sendPacket(ServerPlayer serverPlayer, ClientboundSyncPacket packet) { diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/SyncPackets.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/SyncPackets.java index eee14b5..c66eea4 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/SyncPackets.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/SyncPackets.java @@ -1,5 +1,6 @@ package com.hpfxd.spectatorplus.fabric.sync; +import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundContainerSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundEffectsSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundExperienceSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundFoodSyncPacket; @@ -17,6 +18,7 @@ public static void registerAll() { PayloadTypeRegistry.playC2S().register(ServerboundOpenedInventorySyncPacket.TYPE, ServerboundOpenedInventorySyncPacket.STREAM_CODEC); PayloadTypeRegistry.playC2S().register(ServerboundRequestInventoryOpenPacket.TYPE, ServerboundRequestInventoryOpenPacket.STREAM_CODEC); + PayloadTypeRegistry.playS2C().register(ClientboundContainerSyncPacket.TYPE, ClientboundContainerSyncPacket.STREAM_CODEC); PayloadTypeRegistry.playS2C().register(ClientboundExperienceSyncPacket.TYPE, ClientboundExperienceSyncPacket.STREAM_CODEC); PayloadTypeRegistry.playS2C().register(ClientboundFoodSyncPacket.TYPE, ClientboundFoodSyncPacket.STREAM_CODEC); PayloadTypeRegistry.playS2C().register(ClientboundHotbarSyncPacket.TYPE, ClientboundHotbarSyncPacket.STREAM_CODEC); diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ContainerSyncHandler.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ContainerSyncHandler.java new file mode 100644 index 0000000..c30b14a --- /dev/null +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ContainerSyncHandler.java @@ -0,0 +1,113 @@ +package com.hpfxd.spectatorplus.fabric.sync.handler; + +import com.hpfxd.spectatorplus.fabric.sync.ServerSyncController; +import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundContainerSyncPacket; +import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundScreenSyncPacket; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.network.protocol.game.ClientboundOpenScreenPacket; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ContainerListener; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class ContainerSyncHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(ContainerSyncHandler.class); + + // Map to track container listeners for each spectator + // Key: spectator UUID, Value: listener instance + private static final Map containerListeners = new HashMap<>(); + + public static void init() { + // Clean up listeners when a player disconnects + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> { + containerListeners.remove(handler.getPlayer().getUUID()); + }); + } + + /** + * Unsubscribe a spectator from a target player's container. + * This removes the container listener. + */ + public static void unsubscribeFromContainer(ServerPlayer spectator, ServerPlayer target) { + target.containerMenu.removeSlotListener(containerListeners.get(spectator.getUUID())); + + } + + public static void sendPacket(ServerPlayer spectator, ServerPlayer target) { + InventorySyncHandler.sendPacket(spectator, target); + var containerMenu = target.containerMenu; + if (containerMenu == target.inventoryMenu) { + return; // No container open, just player inventory + } + ItemStack[] containerItems = extractContainerItems(containerMenu); + ServerSyncController.broadcastPacketToSpectators(target, new ClientboundContainerSyncPacket( + target.getUUID(), + containerMenu.getType(), + containerItems.length, + containerItems + )); + + int flags = 0; // Container screen (not survival inventory) + flags |= (1 << 2); // Has dummy slots flag for container screens + ServerSyncController.broadcastPacketToSpectators(target, new ClientboundScreenSyncPacket(target.getUUID(), flags)); + + containerMenu.addSlotListener(createContainerListener(spectator, target)); + + spectator.connection.send(new ClientboundOpenScreenPacket( + containerMenu.containerId, + containerMenu.getType(), + target.getDisplayName() + )); + } + + private static ItemStack[] extractContainerItems(AbstractContainerMenu menu) { + int containerSize = menu.slots.size() - 36; // Subtract player inventory slots + ItemStack[] containerItems = new ItemStack[containerSize]; + + for (int i = 0; i < containerSize; i++) { + var slot = menu.getSlot(i); + containerItems[i] = slot.getItem().copy(); + } + + return containerItems; + } + + public static ContainerListener createContainerListener(ServerPlayer spectator, ServerPlayer target) { + return new ContainerListener() { + @Override + public void slotChanged(@NotNull AbstractContainerMenu menu, int slotIndex, @NotNull ItemStack stack) { + // Only sync container slots, not player inventory slots + if (slotIndex < menu.slots.size() - 36 && ServerSyncController.getSpectators(target).contains(spectator)) { + ItemStack[] update = new ItemStack[menu.slots.size() - 36]; + for (int i = 0; i < update.length; i++) { + if (i == slotIndex) { + update[i] = stack.copy(); + } else { + // For efficiency, only send changed slot + update[i] = null; + } + } + ServerSyncController.broadcastPacketToSpectators(target, new ClientboundContainerSyncPacket( + target.getUUID(), + menu.getType(), + update.length, + update + )); + } + } + + @Override + public void dataChanged(@NotNull AbstractContainerMenu menu, int dataSlot, int value) { + // Handle furnace progress, brewing stand progress, etc. + // For now, we don't need to sync this separately + } + }; + } +} diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/CursorSyncHandler.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/CursorSyncHandler.java new file mode 100644 index 0000000..370cac3 --- /dev/null +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/CursorSyncHandler.java @@ -0,0 +1,75 @@ +package com.hpfxd.spectatorplus.fabric.sync.handler; + +import com.hpfxd.spectatorplus.fabric.sync.ServerSyncController; +import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundScreenCursorSyncPacket; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Handles synchronization of cursor items (items being dragged) to spectators + */ +public class CursorSyncHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(CursorSyncHandler.class); + private static final Map playerCursors = new HashMap<>(); + + public static void init() { + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> + playerCursors.remove(handler.getPlayer().getUUID())); + } + + /** + * Called when a player's cursor item changes + */ + public static void onCursorChanged(ServerPlayer player, ItemStack newCursor) { + onCursorChanged(player, newCursor, -1); + } + + /** + * Called when a player's cursor item changes + */ + public static void onCursorChanged(ServerPlayer player, ItemStack newCursor, int originSlot) { + ItemStack oldCursor = playerCursors.get(player.getUUID()); + if (oldCursor == null) { + oldCursor = ItemStack.EMPTY; + } + if (newCursor == null) { + newCursor = ItemStack.EMPTY; + } + + if (!ItemStack.isSameItem(oldCursor, newCursor)) { + playerCursors.put(player.getUUID(), newCursor.copy()); + + ClientboundScreenCursorSyncPacket packet = new ClientboundScreenCursorSyncPacket( + player.getUUID(), + newCursor, + originSlot + ); + + ServerSyncController.broadcastPacketToSpectators(player, packet); + } + } + + /** + * Send initial cursor data to a spectator + */ + public static void sendPacket(ServerPlayer spectator, ServerPlayer target) { + ItemStack cursor = playerCursors.getOrDefault(target.getUUID(), ItemStack.EMPTY); + + if (!cursor.isEmpty()) { + ClientboundScreenCursorSyncPacket packet = new ClientboundScreenCursorSyncPacket( + target.getUUID(), + cursor, + -1 + ); + + ServerSyncController.sendPacket(spectator, packet); + } + } +} diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/InventorySyncHandler.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/InventorySyncHandler.java new file mode 100644 index 0000000..432c3b5 --- /dev/null +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/InventorySyncHandler.java @@ -0,0 +1,128 @@ +package com.hpfxd.spectatorplus.fabric.sync.handler; + +import com.hpfxd.spectatorplus.fabric.sync.ServerSyncController; +import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundHotbarSyncPacket; +import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundInventorySyncPacket; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.stream.IntStream; + +public class InventorySyncHandler { + private static final Map playerInventories = new HashMap<>(); + private static int tickCounter = 0; + private static final int SYNC_INTERVAL = 5; // Sync every 5 ticks (4 times per second) + + public static void init() { + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> + playerInventories.remove(handler.getPlayer().getUUID())); + ServerTickEvents.END_SERVER_TICK.register(InventorySyncHandler::tick); + } + + public static void tick(MinecraftServer server) { + tickCounter++; + if (tickCounter % SYNC_INTERVAL != 0) { + return; // Skip this tick + } + + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + if (ServerSyncController.getSpectators(player).isEmpty()) { + continue; + } + syncPlayerInventory(player); + } + } + + private static void syncPlayerInventory(ServerPlayer player) { + final ItemStack[] slots = playerInventories.computeIfAbsent(player.getUUID(), k -> { + final ItemStack[] arr = new ItemStack[ClientboundInventorySyncPacket.ITEMS_LENGTH]; + Arrays.fill(arr, ItemStack.EMPTY); + return arr; + }); + + //as hotbar will change more than rest of inventory, we track it separately to avoid sending full inventory when only hotbar changes + final ItemStack[] currentSlots = extractPlayerInventory(player); + final ItemStack[] inventorySendSlots = new ItemStack[ClientboundInventorySyncPacket.ITEMS_LENGTH]; + final ItemStack[] hotbarSendSlots = new ItemStack[ClientboundHotbarSyncPacket.ITEMS_LENGTH]; + + boolean updatedHotbar = false; + boolean updatedInventory = false; + + final Inventory inventory = player.getInventory(); + + // Main inventory (0-35) including hotbar (0-8) + for (int i = 0; i < currentSlots.length; i++) { + if (!ItemStack.matches(currentSlots[i], slots[i])) { + slots[i] = currentSlots[i].copy(); + inventorySendSlots[i] = currentSlots[i]; + updatedInventory = true; + + // 热键栏是槽位 0-8 + if (i < ClientboundHotbarSyncPacket.ITEMS_LENGTH) { + hotbarSendSlots[i] = currentSlots[i]; + updatedHotbar = true; + } + } + } + + if (updatedInventory) { + ScreenSyncHandler.updatePlayerInventory(player, inventorySendSlots); + } + + if (updatedHotbar) { + ServerSyncController.broadcastPacketToSpectators(player, + new ClientboundHotbarSyncPacket(player.getUUID(), hotbarSendSlots)); + } + } + + //this for send full inventory packet immediately + public static void sendPacket(ServerPlayer spectator, ServerPlayer target) { + final ItemStack[] slots = extractPlayerInventory(target); + ServerSyncController.broadcastPacketToSpectators(target, new ClientboundInventorySyncPacket(target.getUUID(), slots)); + } + + private static ItemStack[] extractPlayerInventory(ServerPlayer player) { + final Inventory inventory = player.getInventory(); + final ItemStack[] slots = new ItemStack[ClientboundInventorySyncPacket.ITEMS_LENGTH]; + + // Main inventory (0-35) + IntStream.range(0, 36).forEach(i -> { + ItemStack item = inventory.getItem(i); + slots[i] = item != null ? item : ItemStack.EMPTY; + }); + + // Armor (36-39) + IntStream.range(36, 40).forEach(i -> { + ItemStack item = inventory.getItem(i); + slots[i] = item != null ? item : ItemStack.EMPTY; + }); + + // Offhand slot (40) + ItemStack offhand = inventory.getItem(40); + slots[40] = offhand != null ? offhand : ItemStack.EMPTY; + + return slots; + } + + public static void onPlayerDisconnect(UUID playerId) { + playerInventories.remove(playerId); + } + + /** + * Force an immediate inventory sync for a player, bypassing the tick counter + * Used for critical inventory changes that need immediate synchronization + */ + public static void forceSyncPlayer(ServerPlayer player) { + if (!ServerSyncController.getSpectators(player).isEmpty()) { + syncPlayerInventory(player); + } + } +} diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ScreenSyncHandler.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ScreenSyncHandler.java index 4fa0e46..714b106 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ScreenSyncHandler.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ScreenSyncHandler.java @@ -1,18 +1,118 @@ package com.hpfxd.spectatorplus.fabric.sync.handler; +import com.hpfxd.spectatorplus.fabric.sync.ServerSyncController; +import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundInventorySyncPacket; +import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundScreenSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ServerboundOpenedInventorySyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ServerboundRequestInventoryOpenPacket; +import me.lucko.fabric.api.permissions.v0.Permissions; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collection; public class ScreenSyncHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(ScreenSyncHandler.class); + public static void init() { ServerPlayNetworking.registerGlobalReceiver(ServerboundRequestInventoryOpenPacket.TYPE, ScreenSyncHandler::handle); ServerPlayNetworking.registerGlobalReceiver(ServerboundOpenedInventorySyncPacket.TYPE, ScreenSyncHandler::handle); } private static void handle(ServerboundRequestInventoryOpenPacket packet, ServerPlayNetworking.Context ctx) { + final var spectator = ctx.player(); + final var target = ctx.server().getPlayerList().getPlayer(packet.playerId()); + if (target == null) { + return; + } + // Send screen sync packet to indicate survival inventory + int flags = 1; // Survival inventory flag + flags |= (1 << 1); // Client requested flag + // Send full inventory data + InventorySyncHandler.sendPacket(spectator, target); + + ServerSyncController.broadcastPacketToSpectators(target, new ClientboundScreenSyncPacket(target.getUUID(), flags)); + } private static void handle(ServerboundOpenedInventorySyncPacket packet, ServerPlayNetworking.Context ctx) { + final var player = ctx.player(); + if (packet.isOpened()) { + syncScreenOpened(player); + } else { + syncScreenClosed(player); + } + } + + /** + * Called when a player opens their inventory or any container screen. + * This method syncs the appropriate screen to all spectators who are viewing this player. + * + * @param target The player who opened their inventory/container + */ + public static void syncScreenOpened(ServerPlayer target) { + var spectators = ServerSyncController.getSpectators(target); + if (spectators.isEmpty()) { + return; + } + + for (ServerPlayer spectator : spectators) { + // Determine what type of screen the target player has open + if (target.containerMenu != target.inventoryMenu) { + // Player has a container open (chest, crafting table, furnace, etc.) + ContainerSyncHandler.sendPacket(spectator, target); + + } else { + // Player has their regular inventory open (survival/creative inventory) + syncPlayerInventoryScreen(spectator, target); + } + } + } + + /** + * Called when a player closes their inventory or any container screen. + * This method closes the synced screen for all spectators who are viewing this player. + * + * @param target The player who closed their inventory/container + */ + public static void syncScreenClosed(ServerPlayer target) { + var spectators = ServerSyncController.getSpectators(target); + if (spectators.isEmpty()) { + return; + } + + for (ServerPlayer spectator : spectators) { + ContainerSyncHandler.unsubscribeFromContainer(spectator, target); + + // Send screen sync packet to indicate inventory/container was closed + int flags = 0; // No flags means close screen + flags |= (1 << 3); // Screen closed flag + ServerSyncController.broadcastPacketToSpectators(target, new ClientboundScreenSyncPacket(target.getUUID(), flags)); + } + } + + public static void updatePlayerInventory(ServerPlayer player, ItemStack[] inventorySendSlots) { + for (ServerPlayer spectator : ServerSyncController.getSpectators(player)) { + ServerSyncController.broadcastPacketToSpectators(player, new ClientboundInventorySyncPacket(player.getUUID(), inventorySendSlots)); + } + } + + /** + * Syncs a player's regular inventory screen (survival/creative inventory) to a spectator. + * This is used when the target player has opened their own inventory. + * + * @param spectator The spectator to sync the screen to + * @param target The player whose inventory screen should be synced + */ + private static void syncPlayerInventoryScreen(ServerPlayer spectator, ServerPlayer target) { + // Send screen sync packet for player inventory (survival inventory) + int flags = 1; // Survival inventory flag + // Send the target's inventory data to the spectator + InventorySyncHandler.sendPacket(spectator, target); + + ServerSyncController.broadcastPacketToSpectators(target, new ClientboundScreenSyncPacket(target.getUUID(), flags)); } } diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundContainerSyncPacket.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundContainerSyncPacket.java new file mode 100644 index 0000000..f28225d --- /dev/null +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundContainerSyncPacket.java @@ -0,0 +1,58 @@ +package com.hpfxd.spectatorplus.fabric.sync.packet; + +import com.hpfxd.spectatorplus.fabric.sync.ClientboundSyncPacket; +import com.hpfxd.spectatorplus.fabric.sync.CustomPacketCodecs; +import me.lucko.fabric.api.permissions.v0.Permissions; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public record ClientboundContainerSyncPacket( + UUID playerId, + MenuType containerType, + int containerSize, + ItemStack[] items +) implements ClientboundSyncPacket { + public static final StreamCodec STREAM_CODEC = + CustomPacketPayload.codec(ClientboundContainerSyncPacket::write, ClientboundContainerSyncPacket::new); + public static final CustomPacketPayload.Type TYPE = + new CustomPacketPayload.Type<>(Identifier.parse("spectatorplus:container_sync")); + private static final String PERMISSION = "spectatorplus.sync.screen"; + + public ClientboundContainerSyncPacket(RegistryFriendlyByteBuf buf) { + this( + buf.readUUID(), + buf.readById(id -> { + MenuType type = BuiltInRegistries.MENU.byId(id); + return type != null ? type : MenuType.GENERIC_9x3; + }), + buf.readVarInt(), + CustomPacketCodecs.readItems(buf) + ); + } + + public void write(RegistryFriendlyByteBuf buf) { + buf.writeUUID(this.playerId); + buf.writeById(BuiltInRegistries.MENU::getId, this.containerType); + buf.writeVarInt(this.containerSize); + CustomPacketCodecs.writeItems(buf, this.items); + } + + @Override + public @NotNull Type type() { + return TYPE; + } + + @Override + public boolean canSend(ServerPlayer receiver) { + return Permissions.check(receiver, PERMISSION, true); + } +} diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundHotbarSyncPacket.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundHotbarSyncPacket.java index e39b7d0..db3a2d1 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundHotbarSyncPacket.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundHotbarSyncPacket.java @@ -20,6 +20,7 @@ public record ClientboundHotbarSyncPacket( public static final StreamCodec STREAM_CODEC = CustomPacketPayload.codec(ClientboundHotbarSyncPacket::write, ClientboundHotbarSyncPacket::new); public static final CustomPacketPayload.Type TYPE = new CustomPacketPayload.Type<>(Identifier.parse("spectatorplus:hotbar_sync")); static final String PERMISSION = "spectatorplus.sync.hotbar"; + public static final int ITEMS_LENGTH = 9; public static ClientboundHotbarSyncPacket initializing(ServerPlayer target) { final ItemStack[] items = new ItemStack[9]; diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundScreenCursorSyncPacket.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundScreenCursorSyncPacket.java index 157a972..958ad88 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundScreenCursorSyncPacket.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundScreenCursorSyncPacket.java @@ -2,6 +2,7 @@ import com.hpfxd.spectatorplus.fabric.sync.ClientboundSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.CustomPacketCodecs; +import me.lucko.fabric.api.permissions.v0.Permissions; import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; @@ -19,6 +20,7 @@ public record ClientboundScreenCursorSyncPacket( ) implements ClientboundSyncPacket { public static final StreamCodec STREAM_CODEC = CustomPacketPayload.codec(ClientboundScreenCursorSyncPacket::write, ClientboundScreenCursorSyncPacket::new); public static final CustomPacketPayload.Type TYPE = new CustomPacketPayload.Type<>(Identifier.parse("spectatorplus:screen_cursor_sync")); + private static final String PERMISSION = "spectatorplus.sync.screen"; public ClientboundScreenCursorSyncPacket(RegistryFriendlyByteBuf buf) { this(buf.readUUID(), CustomPacketCodecs.readItem(buf), buf.readByte()); @@ -37,6 +39,6 @@ public void write(RegistryFriendlyByteBuf buf) { @Override public boolean canSend(ServerPlayer receiver) { - return true; + return Permissions.check(receiver, PERMISSION, true); } } diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundScreenSyncPacket.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundScreenSyncPacket.java index cb0464d..be7f51b 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundScreenSyncPacket.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ClientboundScreenSyncPacket.java @@ -1,6 +1,7 @@ package com.hpfxd.spectatorplus.fabric.sync.packet; import com.hpfxd.spectatorplus.fabric.sync.ClientboundSyncPacket; +import me.lucko.fabric.api.permissions.v0.Permissions; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; @@ -16,6 +17,7 @@ public record ClientboundScreenSyncPacket( ) implements ClientboundSyncPacket { public static final StreamCodec STREAM_CODEC = CustomPacketPayload.codec(ClientboundScreenSyncPacket::write, ClientboundScreenSyncPacket::new); public static final CustomPacketPayload.Type TYPE = new CustomPacketPayload.Type<>(Identifier.parse("spectatorplus:screen_sync")); + private static final String PERMISSION = "spectatorplus.sync.screen"; public ClientboundScreenSyncPacket(FriendlyByteBuf buf) { this(buf.readUUID(), buf.readByte()); @@ -38,6 +40,10 @@ public boolean hasDummySlots() { return (this.flags >> 2 & 1) == 1; } + public boolean isScreenClosed() { + return (this.flags >> 3 & 1) == 1; + } + @Override public @NotNull Type type() { return TYPE; @@ -45,6 +51,6 @@ public boolean hasDummySlots() { @Override public boolean canSend(ServerPlayer receiver) { - return true; + return Permissions.check(receiver, PERMISSION, true); } } diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ServerboundOpenedInventorySyncPacket.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ServerboundOpenedInventorySyncPacket.java index e6aea33..087d6e6 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ServerboundOpenedInventorySyncPacket.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/packet/ServerboundOpenedInventorySyncPacket.java @@ -5,18 +5,29 @@ import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import net.minecraft.resources.Identifier; - +/* + * Sent by the client to the server to synchronize the opened inventory state of the player. + */ public final class ServerboundOpenedInventorySyncPacket implements ServerboundSyncPacket { public static final StreamCodec STREAM_CODEC = CustomPacketPayload.codec(ServerboundOpenedInventorySyncPacket::write, ServerboundOpenedInventorySyncPacket::new); public static final CustomPacketPayload.Type TYPE = new CustomPacketPayload.Type<>(Identifier.parse("spectatorplus:opened_inventory_sync")); - public ServerboundOpenedInventorySyncPacket() { + private final boolean isOpened; + + public ServerboundOpenedInventorySyncPacket(boolean isOpened) { + this.isOpened = isOpened; } public ServerboundOpenedInventorySyncPacket(FriendlyByteBuf buf) { + this.isOpened = buf.readBoolean(); } public void write(FriendlyByteBuf buf) { + buf.writeBoolean(this.isOpened); + } + + public boolean isOpened() { + return this.isOpened; } @Override From 33ae64b089525c79f92562269631b7ac4dbe4141 Mon Sep 17 00:00:00 2001 From: RCUTANF <110910565+RCUTANF@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:44:06 +0800 Subject: [PATCH 2/6] fix: containerMenu.addSlotListener should be remove manually --- .../sync/handler/ContainerSyncHandler.java | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ContainerSyncHandler.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ContainerSyncHandler.java index c30b14a..24b45c7 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ContainerSyncHandler.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ContainerSyncHandler.java @@ -25,7 +25,7 @@ public class ContainerSyncHandler { private static final Map containerListeners = new HashMap<>(); public static void init() { - // Clean up listeners when a player disconnects + // Clean up listeners when a player disconnects. ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> { containerListeners.remove(handler.getPlayer().getUUID()); }); @@ -33,11 +33,13 @@ public static void init() { /** * Unsubscribe a spectator from a target player's container. - * This removes the container listener. + * This removes the container listener to prevent memory leaks and stale updates. */ public static void unsubscribeFromContainer(ServerPlayer spectator, ServerPlayer target) { - target.containerMenu.removeSlotListener(containerListeners.get(spectator.getUUID())); - + ContainerListener oldListener = containerListeners.remove(spectator.getUUID()); + if (oldListener != null) { + target.containerMenu.removeSlotListener(oldListener); + } } public static void sendPacket(ServerPlayer spectator, ServerPlayer target) { @@ -58,7 +60,7 @@ public static void sendPacket(ServerPlayer spectator, ServerPlayer target) { flags |= (1 << 2); // Has dummy slots flag for container screens ServerSyncController.broadcastPacketToSpectators(target, new ClientboundScreenSyncPacket(target.getUUID(), flags)); - containerMenu.addSlotListener(createContainerListener(spectator, target)); + containerMenu.addSlotListener(createContainerListener(spectator, target, containerListeners)); spectator.connection.send(new ClientboundOpenScreenPacket( containerMenu.containerId, @@ -79,8 +81,8 @@ private static ItemStack[] extractContainerItems(AbstractContainerMenu menu) { return containerItems; } - public static ContainerListener createContainerListener(ServerPlayer spectator, ServerPlayer target) { - return new ContainerListener() { + public static ContainerListener createContainerListener(ServerPlayer spectator, ServerPlayer target, Map listenerMap) { + ContainerListener listener = new ContainerListener() { @Override public void slotChanged(@NotNull AbstractContainerMenu menu, int slotIndex, @NotNull ItemStack stack) { // Only sync container slots, not player inventory slots @@ -109,5 +111,7 @@ public void dataChanged(@NotNull AbstractContainerMenu menu, int dataSlot, int v // For now, we don't need to sync this separately } }; + listenerMap.put(spectator.getUUID(), listener); + return listener; } } From 45be02425b8bb0eb70479f3281b2d7a16fc7b41d Mon Sep 17 00:00:00 2001 From: RCUTANF <110910565+RCUTANF@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:46:38 +0800 Subject: [PATCH 3/6] refactor: clean up inventory synchronization logic and improve null handling --- .../fabric/client/sync/ClientSyncController.java | 3 +-- .../fabric/client/sync/screen/ScreenSyncController.java | 4 ++-- .../fabric/client/sync/screen/ScreenSyncData.java | 1 - .../fabric/sync/handler/InventorySyncHandler.java | 8 +++----- .../fabric/sync/handler/ScreenSyncHandler.java | 7 +------ 5 files changed, 7 insertions(+), 16 deletions(-) diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/ClientSyncController.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/ClientSyncController.java index 56a3081..97aa86d 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/ClientSyncController.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/ClientSyncController.java @@ -2,7 +2,6 @@ import com.hpfxd.spectatorplus.fabric.client.sync.screen.ScreenSyncController; import com.hpfxd.spectatorplus.fabric.client.util.EffectUtil; -import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundContainerSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundExperienceSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundFoodSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundHotbarSyncPacket; @@ -19,7 +18,7 @@ public class ClientSyncController { public static ClientSyncData syncData; - private static Minecraft minecraft = Minecraft.getInstance(); + private static final Minecraft minecraft = Minecraft.getInstance(); public static void init() { ClientPlayNetworking.registerGlobalReceiver(ClientboundExperienceSyncPacket.TYPE, ClientSyncController::handle); diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncController.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncController.java index d78a6b7..c1cf393 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncController.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncController.java @@ -1,9 +1,8 @@ package com.hpfxd.spectatorplus.fabric.client.sync.screen; -import com.hpfxd.spectatorplus.fabric.client.gui.screens.SyncedInventoryScreen; import com.hpfxd.spectatorplus.fabric.client.gui.screens.DummyContainer; +import com.hpfxd.spectatorplus.fabric.client.gui.screens.SyncedInventoryScreen; import com.hpfxd.spectatorplus.fabric.client.mixin.InventoryAccessor; -import com.hpfxd.spectatorplus.fabric.client.sync.ClientSyncController; import com.hpfxd.spectatorplus.fabric.client.util.SpecUtil; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundContainerSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundInventorySyncPacket; @@ -268,6 +267,7 @@ else if (type == MenuType.BEACON) { public static > void handleNewSyncedScreen(Minecraft mc, S screen) { isPendingOpen = false; + if(mc.player == null) return; mc.player.containerMenu = screen.getMenu(); mc.setScreen(screen); diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncData.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncData.java index 3cf3d65..933f88d 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncData.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncData.java @@ -5,7 +5,6 @@ import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.inventory.MenuType; import net.minecraft.world.item.ItemStack; -import org.jetbrains.annotations.NotNull; import static com.hpfxd.spectatorplus.fabric.client.sync.ClientSyncController.syncData; diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/InventorySyncHandler.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/InventorySyncHandler.java index 432c3b5..52081b8 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/InventorySyncHandler.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/InventorySyncHandler.java @@ -56,8 +56,6 @@ private static void syncPlayerInventory(ServerPlayer player) { boolean updatedHotbar = false; boolean updatedInventory = false; - final Inventory inventory = player.getInventory(); - // Main inventory (0-35) including hotbar (0-8) for (int i = 0; i < currentSlots.length; i++) { if (!ItemStack.matches(currentSlots[i], slots[i])) { @@ -96,18 +94,18 @@ private static ItemStack[] extractPlayerInventory(ServerPlayer player) { // Main inventory (0-35) IntStream.range(0, 36).forEach(i -> { ItemStack item = inventory.getItem(i); - slots[i] = item != null ? item : ItemStack.EMPTY; + slots[i] = item; }); // Armor (36-39) IntStream.range(36, 40).forEach(i -> { ItemStack item = inventory.getItem(i); - slots[i] = item != null ? item : ItemStack.EMPTY; + slots[i] = item; }); // Offhand slot (40) ItemStack offhand = inventory.getItem(40); - slots[40] = offhand != null ? offhand : ItemStack.EMPTY; + slots[40] = offhand; return slots; } diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ScreenSyncHandler.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ScreenSyncHandler.java index 714b106..cd3f162 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ScreenSyncHandler.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ScreenSyncHandler.java @@ -5,15 +5,12 @@ import com.hpfxd.spectatorplus.fabric.sync.packet.ClientboundScreenSyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ServerboundOpenedInventorySyncPacket; import com.hpfxd.spectatorplus.fabric.sync.packet.ServerboundRequestInventoryOpenPacket; -import me.lucko.fabric.api.permissions.v0.Permissions; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.item.ItemStack; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Collection; - public class ScreenSyncHandler { private static final Logger LOGGER = LoggerFactory.getLogger(ScreenSyncHandler.class); @@ -95,9 +92,7 @@ public static void syncScreenClosed(ServerPlayer target) { } public static void updatePlayerInventory(ServerPlayer player, ItemStack[] inventorySendSlots) { - for (ServerPlayer spectator : ServerSyncController.getSpectators(player)) { - ServerSyncController.broadcastPacketToSpectators(player, new ClientboundInventorySyncPacket(player.getUUID(), inventorySendSlots)); - } + ServerSyncController.broadcastPacketToSpectators(player, new ClientboundInventorySyncPacket(player.getUUID(), inventorySendSlots)); } /** From b92ce718dd0760d752fe212d566242c7e751c26f Mon Sep 17 00:00:00 2001 From: RCUTANF <110910565+RCUTANF@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:16:36 +0800 Subject: [PATCH 4/6] fix: send container slot updates only to the owning spectator --- .../spectatorplus/fabric/sync/handler/ContainerSyncHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ContainerSyncHandler.java b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ContainerSyncHandler.java index 24b45c7..2adee86 100644 --- a/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ContainerSyncHandler.java +++ b/fabric/src/main/java/com/hpfxd/spectatorplus/fabric/sync/handler/ContainerSyncHandler.java @@ -96,7 +96,7 @@ public void slotChanged(@NotNull AbstractContainerMenu menu, int slotIndex, @Not update[i] = null; } } - ServerSyncController.broadcastPacketToSpectators(target, new ClientboundContainerSyncPacket( + ServerSyncController.sendPacket(spectator, new ClientboundContainerSyncPacket( target.getUUID(), menu.getType(), update.length, From e3efcbc29f88b4d03294806e8f03e72492ae5d33 Mon Sep 17 00:00:00 2001 From: RCUTANF <110910565+RCUTANF@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:54:25 +0800 Subject: [PATCH 5/6] fix: prevent fallback close packet from being sent after successful screen open Add early returns in the survival inventory and container screen branches to avoid incorrectly executing the "unable to open" fallback logic (sending ServerboundContainerClosePacket) after a screen has already been successfully opened. --- .../fabric/client/mixin/screen/MenuScreensMixin.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/MenuScreensMixin.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/MenuScreensMixin.java index dbb8c20..e3624cd 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/MenuScreensMixin.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/mixin/screen/MenuScreensMixin.java @@ -56,6 +56,7 @@ public abstract class MenuScreensMixin { ScreenSyncController.openContainerScreen(mc, ClientSyncController.syncData.screen.containerType, ClientSyncController.syncData.screen.containerSize); + return; } else { // Fallback to original screen creation for unknown container types final Inventory inventory; @@ -69,6 +70,7 @@ public abstract class MenuScreensMixin { final S screen = screenConstructor.create(menu, inventory, title); ScreenSyncController.handleNewSyncedScreen(mc, screen); + return; } } } From 70d045d356c68e75bd9a0880a28c2336ee28fe0e Mon Sep 17 00:00:00 2001 From: RCUTANF <110910565+RCUTANF@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:16:12 +0800 Subject: [PATCH 6/6] fix: prevent NullPointerException when resetting syncData.screen in ScreenSyncController --- .../fabric/client/sync/screen/ScreenSyncController.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncController.java b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncController.java index c1cf393..e4b53b2 100644 --- a/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncController.java +++ b/fabric/src/client/java/com/hpfxd/spectatorplus/fabric/client/sync/screen/ScreenSyncController.java @@ -117,7 +117,9 @@ public static void closeSyncedInventory() { syncedInventory = null; syncedWindowId = Integer.MIN_VALUE; isPendingOpen = false; - syncData.screen = null; + if (syncData != null) { + syncData.screen = null; + } } public static void openPlayerInventory(Minecraft mc) {