From 921c31b85f3df927febf927900d6908ec7bef52b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:44:22 +0000 Subject: [PATCH 1/9] Add the Display wall A functional wall that shows one item type, its count across the chest, a fill bar and the upgrades affecting it, honouring the chest's visual settings. Drawer style clicks: left-click takes a stack or one while sneaking, right-click inserts, a double right-click inserts all. Only a tool that can harvest it mines it, so taking items never breaks the chest. Chest settings are now synced to clients for rendering. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RSz5r6eowhjNuANBjZE2v6 --- .../block/BlockChestFunctionalWall.java | 29 ++++ .../block/DisplayWallInteractions.java | 164 ++++++++++++++++++ .../colossalchests2/block/WallType.java | 13 +- .../blockentity/BlockEntityChestCore.java | 8 +- .../blockentity/BlockEntityChestWall.java | 107 +++++++++++- .../BlockEntityChestWallConfig.java | 4 +- .../client/render/ChestOverlayHelpers.java | 47 ++++- .../client/render/DisplayWallOverlay.java | 96 ++++++++++ .../gametest/GameTestsCommon.java | 134 +++++++++++++- .../packet/ServerboundDisplayTakePacket.java | 65 +++++++ .../colossalchests2/storage/DisplayStats.java | 66 +++++++ .../blockstates/chest_wall_display.json | 10 ++ .../assets/colossalchests2/lang/en_us.json | 2 + .../models/block/chest_wall_display.json | 6 + .../block/chest_wall_display_formed.json | 6 + .../models/item/chest_wall_display.json | 3 + .../textures/block/chest_wall_display.png | Bin 0 -> 366 bytes .../block/chest_wall_display_icon.png | Bin 0 -> 180 bytes .../textures/block/display_bar.png | Bin 0 -> 86 bytes .../loot_table/blocks/chest_wall_display.json | 19 ++ .../tags/block/mineable/pickaxe.json | 3 +- .../storage/TestDisplayStats.java | 83 +++++++++ .../colossalchests2/ColossalChestsFabric.java | 17 +- .../BlockEntityChestWallConfigFabric.java | 2 +- .../proxy/CommonProxyFabric.java | 2 + .../colossalchests2/ColossalChestsForge.java | 17 +- .../proxy/CommonProxyForge.java | 2 + .../colossalchests2/ColossalChests.java | 19 +- .../CommonCapabilitiesModCompat.java | 2 +- .../colossalchests2/proxy/CommonProxy.java | 2 + 30 files changed, 892 insertions(+), 36 deletions(-) create mode 100644 loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java create mode 100644 loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java create mode 100644 loader-common/src/main/java/org/cyclops/colossalchests2/network/packet/ServerboundDisplayTakePacket.java create mode 100644 loader-common/src/main/java/org/cyclops/colossalchests2/storage/DisplayStats.java create mode 100644 loader-common/src/main/resources/assets/colossalchests2/blockstates/chest_wall_display.json create mode 100644 loader-common/src/main/resources/assets/colossalchests2/models/block/chest_wall_display.json create mode 100644 loader-common/src/main/resources/assets/colossalchests2/models/block/chest_wall_display_formed.json create mode 100644 loader-common/src/main/resources/assets/colossalchests2/models/item/chest_wall_display.json create mode 100644 loader-common/src/main/resources/assets/colossalchests2/textures/block/chest_wall_display.png create mode 100644 loader-common/src/main/resources/assets/colossalchests2/textures/block/chest_wall_display_icon.png create mode 100644 loader-common/src/main/resources/assets/colossalchests2/textures/block/display_bar.png create mode 100644 loader-common/src/main/resources/data/colossalchests2/loot_table/blocks/chest_wall_display.json create mode 100644 loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestDisplayStats.java diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/block/BlockChestFunctionalWall.java b/loader-common/src/main/java/org/cyclops/colossalchests2/block/BlockChestFunctionalWall.java index 7d04f52e..f2f0adab 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/block/BlockChestFunctionalWall.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/block/BlockChestFunctionalWall.java @@ -3,11 +3,16 @@ import com.google.common.collect.Lists; import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; +import net.minecraft.world.ItemInteractionResult; 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.EntityBlock; 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.cyclops.colossalchests2.RegistryEntries; @@ -57,6 +62,30 @@ protected InteractionResult useWithoutItem(BlockState state, Level level, BlockP } return InteractionResult.sidedSuccess(level.isClientSide); } + if (type == WallType.DISPLAY && level.getBlockEntity(pos) instanceof BlockEntityChestWall wall) { + InteractionResult result = DisplayWallInteractions.useWithoutItem(player, wall); + if (result != null) { + return result; + } + } return super.useWithoutItem(state, level, pos, player, hit); } + + @Override + protected ItemInteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand, BlockHitResult hit) { + if (type == WallType.DISPLAY && level.getBlockEntity(pos) instanceof BlockEntityChestWall wall) { + ItemInteractionResult result = DisplayWallInteractions.useItemOn(stack, player, wall); + if (result != null) { + return result; + } + } + return super.useItemOn(stack, state, level, pos, player, hand, hit); + } + + @Nullable + @Override + public BlockEntityTicker getTicker(Level level, BlockState state, BlockEntityType blockEntityType) { + return type == WallType.DISPLAY && !level.isClientSide && blockEntityType == RegistryEntries.BLOCK_ENTITY_CHEST_WALL.value() + ? (l, p, s, be) -> BlockEntityChestWall.serverTick(l, p, s, (BlockEntityChestWall) be) : null; + } } diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java b/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java new file mode 100644 index 00000000..e225970b --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java @@ -0,0 +1,164 @@ +package org.cyclops.colossalchests2.block; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.ItemInteractionResult; +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.state.BlockState; +import org.cyclops.colossalchests2.ColossalChestsInstance; +import org.cyclops.colossalchests2.blockentity.BlockEntityChestCore; +import org.cyclops.colossalchests2.blockentity.BlockEntityChestWall; +import org.cyclops.colossalchests2.network.packet.ServerboundDisplayTakePacket; +import org.cyclops.colossalchests2.storage.ChestStorage; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; + +/** + * Drawer style clicks on Display walls: left-click takes, right-click inserts, a double right-click inserts all. + * @author rubensworks + */ +public final class DisplayWallInteractions { + + private static final int HELD_CLICK_TICKS = 6; + + private static long lastAttackTick = -HELD_CLICK_TICKS - 1; + + private DisplayWallInteractions() { + } + + public static boolean isDisplayWall(BlockState state) { + return state.getBlock() instanceof BlockChestFunctionalWall wall && wall.getType() == WallType.DISPLAY; + } + + /** + * A Display wall is only mined with a tool that can harvest it, so taking items never breaks the chest. + * @param state A block state. + * @param player A player. + * @return If a left-click by the player must not mine the block. + */ + public static boolean isProtectedFromMining(BlockState state, Player player) { + return isDisplayWall(state) && !player.getMainHandItem().isCorrectToolForDrops(state); + } + + /** + * Handle a left-click on a block, called on both sides by the loaders before mining starts. + * The client asks the server to take items, once per click and not while the button is held. + * @param player The player. + * @param level The level. + * @param pos The clicked position. + * @return If mining must be cancelled. + */ + public static boolean onAttack(Player player, Level level, BlockPos pos) { + if (!isProtectedFromMining(level.getBlockState(pos), player)) { + return false; + } + if (level.isClientSide) { + long tick = level.getGameTime(); + // A held click repeats every tick, or every 6 ticks in creative. A new click comes after a longer gap. + // Game time can also restart in another world. + if (tick - lastAttackTick > HELD_CLICK_TICKS || tick < lastAttackTick) { + ColossalChestsInstance.MOD.getPacketHandlerCommon().sendToServer(new ServerboundDisplayTakePacket(pos, player.isShiftKeyDown())); + } + lastAttackTick = tick; + } + return true; + } + + private static Optional getStorage(BlockEntityChestWall wall) { + return wall.getCore().map(BlockEntityChestCore::getStorage); + } + + /** + * Take the shown type into the player's inventory, dropping what does not fit. + * @param player The player. + * @param wall A Display wall. + * @param single If one item is taken, otherwise a stack. + * @return How many were taken. + */ + public static long take(Player player, BlockEntityChestWall wall, boolean single) { + ItemStack type = wall.getDisplayed(); + Optional storage = getStorage(wall); + if (type.isEmpty() || storage.isEmpty()) { + return 0; + } + long taken = storage.get().extract(type, single ? 1 : type.getMaxStackSize(), false); + if (taken > 0) { + ItemStack stack = type.copyWithCount((int) taken); + if (!player.getInventory().add(stack)) { + player.drop(stack, false); + } + wall.updateDisplayStats(false); + } + return taken; + } + + /** + * A right-click with an item: an empty display starts showing it, and the shown type is inserted. + * @return The result, or null to fall back to the default wall behaviour. + */ + @Nullable + public static ItemInteractionResult useItemOn(ItemStack stack, Player player, BlockEntityChestWall wall) { + Optional storage = getStorage(wall); + if (storage.isEmpty()) { + return null; + } + if (!player.level().isClientSide) { + if (wall.getDisplayed().isEmpty()) { + wall.setDisplayed(stack); + } + if (ItemStack.isSameItemSameComponents(stack, wall.getDisplayed())) { + if (wall.recordInsertClick(player, player.level().getGameTime())) { + insertAll(player, storage.get(), wall.getDisplayed()); + } else { + stack.shrink((int) storage.get().insert(stack, stack.getCount(), false)); + } + wall.updateDisplayStats(false); + } + } + return ItemInteractionResult.sidedSuccess(player.level().isClientSide); + } + + /** + * A right-click with an empty hand: sneaking clears the display, a double click inserts all of the shown type. + * @return The result, or null to fall back to opening the chest. + */ + @Nullable + public static InteractionResult useWithoutItem(Player player, BlockEntityChestWall wall) { + if (getStorage(wall).isEmpty()) { + return null; + } + if (player.isSecondaryUseActive()) { + if (!player.level().isClientSide) { + wall.setDisplayed(ItemStack.EMPTY); + } + return InteractionResult.sidedSuccess(player.level().isClientSide); + } + // Only the server knows about the previous click, the client lets it decide. + if (!player.level().isClientSide && !wall.getDisplayed().isEmpty() && wall.recordInsertClick(player, player.level().getGameTime())) { + insertAll(player, getStorage(wall).get(), wall.getDisplayed()); + wall.updateDisplayStats(false); + return InteractionResult.CONSUME; + } + return null; + } + + /** + * Insert every stack of the type from the player's inventory. + * @return How many were inserted. + */ + public static long insertAll(Player player, ChestStorage storage, ItemStack type) { + long inserted = 0; + for (ItemStack stack : player.getInventory().items) { + if (ItemStack.isSameItemSameComponents(stack, type)) { + long count = storage.insert(stack, stack.getCount(), false); + stack.shrink((int) count); + inserted += count; + } + } + player.getInventory().setChanged(); + return inserted; + } +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/block/WallType.java b/loader-common/src/main/java/org/cyclops/colossalchests2/block/WallType.java index a66828c3..0cbf2eef 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/block/WallType.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/block/WallType.java @@ -14,7 +14,11 @@ public enum WallType { /** * Item I/O where inserts of held types that do not fit are destroyed. */ - VOID; + VOID, + /** + * Shows one item type and its count on its faces, and lets players take and insert it by clicking. + */ + DISPLAY; public static final WallType[] VALUES = values(); @@ -25,6 +29,13 @@ public String getName() { return name().toLowerCase(Locale.ROOT); } + /** + * @return If automation can move items through this wall. + */ + public boolean exposesItems() { + return this != DISPLAY; + } + public String getRegistryName() { return "chest_wall_" + getName(); } diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestCore.java b/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestCore.java index 67f0d40d..ea1baa22 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestCore.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestCore.java @@ -261,11 +261,14 @@ public ChestSettings getSettings() { } /** - * Change the GUI settings, and show them to everyone viewing the chest. + * Change the GUI settings, and show them to everyone viewing the chest and to clients rendering Display walls. */ public void setSettings(ChestSettings settings) { this.settings = settings; setChanged(); + if (level != null && !level.isClientSide) { + syncToClients(); + } for (ServerPlayer viewer : viewers) { if (viewer.containerMenu instanceof ContainerChest menu && menu.isFor(this)) { menu.onSettingsChanged(settings); @@ -586,12 +589,13 @@ private void saveStructure(CompoundTag tag) { } /** - * Clients only receive what they render: the structure, not the contents. + * Clients only receive what they render: the structure and the visual settings, not the contents. */ @Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { CompoundTag tag = new CompoundTag(); saveStructure(tag); + tag.put("settings", ChestSettings.CODEC.encodeStart(NbtOps.INSTANCE, settings).getOrThrow()); return tag; } diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWall.java b/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWall.java index b9384798..773c0544 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWall.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWall.java @@ -1,10 +1,14 @@ package org.cyclops.colossalchests2.blockentity; +import com.google.common.collect.Maps; import net.minecraft.core.BlockPos; import net.minecraft.core.HolderLookup; import net.minecraft.core.NonNullList; 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.Container; import net.minecraft.world.ContainerHelper; import net.minecraft.world.MenuProvider; @@ -13,6 +17,8 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.state.BlockState; @@ -24,19 +30,28 @@ import org.cyclops.colossalchests2.capability.WallAccess; import org.cyclops.colossalchests2.inventory.ContainerInterface; import org.cyclops.colossalchests2.multiblock.ChestCoreIndex; +import org.cyclops.colossalchests2.storage.DisplayStats; import org.jetbrains.annotations.Nullable; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.UUID; /** * A functional wall, giving automation access to its chest under the wall's rules. - * Only an Interface has settings: its filter and direction. + * Only an Interface has settings: its filter and direction. A Display wall keeps the type it shows, and its stats + * for clients. * @author rubensworks */ public class BlockEntityChestWall extends BlockEntity implements MenuProvider { public static final int FILTER_SLOTS = 9; + /** + * The longest time between two clicks on a Display wall that make a double click. + */ + public static final int DOUBLE_CLICK_TICKS = 10; + private static final int DISPLAY_UPDATE_TICKS = 10; private final SimpleContainer settings = new SimpleContainer(FILTER_SLOTS) { @Override @@ -51,6 +66,9 @@ public int getMaxStackSize() { } }; private WallAccess.Mode mode = WallAccess.Mode.BOTH; + private ItemStack displayed = ItemStack.EMPTY; + private DisplayStats displayStats = DisplayStats.EMPTY; + private final Map lastInserts = Maps.newHashMap(); public BlockEntityChestWall(BlockEntityType type, BlockPos pos, BlockState state) { super(type, pos, state); @@ -89,6 +107,7 @@ public WallAccess getAccess() { return switch (getWallType()) { case INTERFACE -> new WallAccess(mode, settings.getItems(), false); case VOID -> new WallAccess(WallAccess.Mode.BOTH, List.of(), true); + case DISPLAY -> WallAccess.OPEN; }; } @@ -102,11 +121,71 @@ public Optional getCore() { return ChestCoreIndex.findFormedCore(level, worldPosition); } + /** + * @return The formed chest this wall gives automation access to, empty for walls without item access. + */ + public Optional getExposedCore() { + return getWallType().exposesItems() ? getCore() : Optional.empty(); + } + /** * @return Item handler semantics for automation through this wall, while its chest is formed. */ public Optional getItemHandlerLogic() { - return getCore().map(core -> new ItemHandlerLogic(core.getStorage(), getAccess())); + return getExposedCore().map(core -> new ItemHandlerLogic(core.getStorage(), getAccess())); + } + + /** + * @return The type a Display wall shows, empty if none. + */ + public ItemStack getDisplayed() { + return displayed; + } + + public void setDisplayed(ItemStack type) { + displayed = type.isEmpty() ? ItemStack.EMPTY : type.copyWithCount(1); + lastInserts.clear(); + setChanged(); + updateDisplayStats(true); + } + + /** + * @return What a Display wall shows about its type, as last synced on clients. + */ + public DisplayStats getDisplayStats() { + return displayStats; + } + + /** + * @param player A player. + * @param gameTime The current game time. + * @return If the player's previous insert was recent enough to make this click a double click. Records this click. + */ + public boolean recordInsertClick(Player player, long gameTime) { + Long previous = lastInserts.put(player.getUUID(), gameTime); + return previous != null && gameTime - previous <= DOUBLE_CLICK_TICKS; + } + + /** + * Recompute a Display wall's stats and sync them to clients when they changed. + * @param force If clients are updated even without changes. + */ + public void updateDisplayStats(boolean force) { + if (level == null || level.isClientSide) { + return; + } + DisplayStats stats = getCore().map(core -> DisplayStats.of(core.getStorage(), displayed)).orElse(DisplayStats.EMPTY); + if (force || !stats.equals(displayStats)) { + displayStats = stats; + BlockState state = getBlockState(); + level.sendBlockUpdated(worldPosition, state, state, Block.UPDATE_CLIENTS); + } + } + + public static void serverTick(Level level, BlockPos pos, BlockState state, BlockEntityChestWall wall) { + if (level.getGameTime() % DISPLAY_UPDATE_TICKS == 0 && wall.getWallType() == WallType.DISPLAY) { + wall.updateDisplayStats(false); + } } private void onSettingsChanged() { @@ -128,6 +207,9 @@ protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) super.saveAdditional(tag, registries); tag.putString("mode", mode.name()); ContainerHelper.saveAllItems(tag, settings.getItems(), registries); + if (!displayed.isEmpty()) { + tag.put("displayed", displayed.save(registries)); + } } @Override @@ -143,6 +225,27 @@ protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) for (int i = 0; i < items.size(); i++) { settings.getItems().set(i, items.get(i)); } + displayed = tag.contains("displayed") ? ItemStack.parseOptional(registries, tag.getCompound("displayed")) : ItemStack.EMPTY; + if (tag.contains("display_stats")) { + displayStats = DisplayStats.fromTag(tag.getCompound("display_stats")); + } + } + + @Override + public CompoundTag getUpdateTag(HolderLookup.Provider registries) { + // Clients only need what a Display wall shows. + CompoundTag tag = new CompoundTag(); + if (!displayed.isEmpty()) { + tag.put("displayed", displayed.save(registries)); + } + tag.put("display_stats", displayStats.toTag()); + return tag; + } + + @Nullable + @Override + public Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); } @Override diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWallConfig.java b/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWallConfig.java index 9c37f9c7..65267fe7 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWallConfig.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWallConfig.java @@ -2,7 +2,9 @@ import net.minecraft.world.level.block.entity.BlockEntityType; import org.cyclops.colossalchests2.block.BlockChestFunctionalWall; +import org.cyclops.colossalchests2.block.WallType; import org.cyclops.colossalchests2.client.render.ChestOverlays; +import org.cyclops.colossalchests2.client.render.DisplayWallOverlay; import org.cyclops.colossalchests2.client.render.FunctionalWallOverlay; import org.cyclops.cyclopscore.config.extendedconfig.BlockEntityConfigCommon; import org.cyclops.cyclopscore.init.IModBase; @@ -28,7 +30,7 @@ public void onForgeRegistered() { super.onForgeRegistered(); if (getMod().getModHelpers().getMinecraftHelpers().isClientSide()) { for (BlockChestFunctionalWall wall : BlockChestFunctionalWall.getFunctionalInstances()) { - ChestOverlays.register(wall, new FunctionalWallOverlay(wall.getType())); + ChestOverlays.register(wall, wall.getType() == WallType.DISPLAY ? new DisplayWallOverlay() : new FunctionalWallOverlay(wall.getType())); } } } diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/ChestOverlayHelpers.java b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/ChestOverlayHelpers.java index e728a07b..0a36791b 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/ChestOverlayHelpers.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/ChestOverlayHelpers.java @@ -3,6 +3,7 @@ import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.blaze3d.vertex.VertexConsumer; import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.client.renderer.RenderType; import net.minecraft.client.renderer.texture.OverlayTexture; @@ -35,6 +36,16 @@ private ChestOverlayHelpers() { */ public static void renderSprite(PoseStack poseStack, MultiBufferSource buffers, TextureAtlasSprite sprite, float x0, float y0, float x1, float y1, int light, int overlay) { + renderSprite(poseStack, buffers, sprite, x0, y0, x1, y1, light, overlay, 0xFFFFFFFF); + } + + /** + * {@link #renderSprite(PoseStack, MultiBufferSource, TextureAtlasSprite, float, float, float, float, int, int)}, + * tinted. + * @param color The ARGB tint. + */ + public static void renderSprite(PoseStack poseStack, MultiBufferSource buffers, TextureAtlasSprite sprite, + float x0, float y0, float x1, float y1, int light, int overlay, int color) { VertexConsumer buffer = buffers.getBuffer(RenderType.entityCutout(TextureAtlas.LOCATION_BLOCKS)); PoseStack.Pose pose = poseStack.last(); float u0 = sprite.getU0(); @@ -42,15 +53,15 @@ public static void renderSprite(PoseStack poseStack, MultiBufferSource buffers, // Texture v grows downwards, face y grows upwards. float v0 = sprite.getV1(); float v1 = sprite.getV0(); - vertex(buffer, pose, x0, y0, u0, v0, light, overlay); - vertex(buffer, pose, x1, y0, u1, v0, light, overlay); - vertex(buffer, pose, x1, y1, u1, v1, light, overlay); - vertex(buffer, pose, x0, y1, u0, v1, light, overlay); + vertex(buffer, pose, x0, y0, u0, v0, light, overlay, color); + vertex(buffer, pose, x1, y0, u1, v0, light, overlay, color); + vertex(buffer, pose, x1, y1, u1, v1, light, overlay, color); + vertex(buffer, pose, x0, y1, u0, v1, light, overlay, color); } - private static void vertex(VertexConsumer buffer, PoseStack.Pose pose, float x, float y, float u, float v, int light, int overlay) { + private static void vertex(VertexConsumer buffer, PoseStack.Pose pose, float x, float y, float u, float v, int light, int overlay, int color) { buffer.addVertex(pose, x, y, 0) - .setColor(255, 255, 255, 255) + .setColor(color) .setUv(u, v) .setOverlay(overlay) .setLight(light) @@ -86,4 +97,28 @@ public static boolean isRevealingMembers() { return Minecraft.getInstance().player != null && Minecraft.getInstance().player.isCrouching(); } + /** + * Draw a line of text on the face, centered horizontally. + * @param poseStack The pose stack. + * @param buffers The buffers. + * @param text The text. + * @param centerX Horizontal center. + * @param y0 Bottom edge. + * @param height Height of the text. + * @param color The RGB color. + * @param light The packed light. + */ + public static void renderText(PoseStack poseStack, MultiBufferSource buffers, String text, float centerX, float y0, float height, + int color, int light) { + Font font = Minecraft.getInstance().font; + float scale = height / font.lineHeight; + poseStack.pushPose(); + // Font y grows downwards, face y upwards. + poseStack.translate(centerX, y0 + height, 0); + poseStack.scale(scale, -scale, scale); + font.drawInBatch(text, -font.width(text) / 2F, 0, color, false, poseStack.last().pose(), buffers, + Font.DisplayMode.POLYGON_OFFSET, 0, light); + poseStack.popPose(); + } + } diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java new file mode 100644 index 00000000..a53665ac --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java @@ -0,0 +1,96 @@ +package org.cyclops.colossalchests2.client.render; + +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import org.cyclops.colossalchests2.Reference; +import org.cyclops.colossalchests2.block.WallType; +import org.cyclops.colossalchests2.blockentity.BlockEntityChestCore; +import org.cyclops.colossalchests2.blockentity.BlockEntityChestWall; +import org.cyclops.colossalchests2.inventory.ChestSettings; +import org.cyclops.colossalchests2.storage.DisplayStats; +import org.cyclops.colossalchests2.upgrade.ChestUpgrade; +import org.cyclops.colossalchests2.upgrade.ChestUpgrades; +import org.cyclops.cyclopscore.helper.IModHelpers; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shows a Display wall's item, count, fill level and upgrade indicators on its outer faces, honouring the chest's + * visual settings. An empty Display wall shows its icon while the player sneaks, like other functional walls. + * @author rubensworks + */ +public class DisplayWallOverlay implements IChestOverlay { + + private static final ResourceLocation BAR_TEXTURE = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "block/display_bar"); + private static final int COLOR_TEXT = 0xFFFFFF; + private static final int COLOR_BAR_BACKGROUND = 0xFF202020; + private static final int COLOR_BAR = 0xFF40C040; + private static final int COLOR_BAR_FULL = 0xFFD04030; + private static final float BAR_X0 = 3F / 16F; + private static final float BAR_X1 = 13F / 16F; + private static final float BAR_Y0 = 1F / 16F; + private static final float BAR_Y1 = 2F / 16F; + private static final float INDICATOR_SIZE = 3F / 16F; + + private final FunctionalWallOverlay icon = new FunctionalWallOverlay(WallType.DISPLAY); + + @Override + public void render(BlockEntityChestCore core, BlockPos pos, Direction face, float partialTick, + PoseStack poseStack, MultiBufferSource buffers, int light, int overlay) { + Level level = core.getLevel(); + if (!(level.getBlockEntity(pos) instanceof BlockEntityChestWall wall) || wall.getDisplayed().isEmpty()) { + icon.render(core, pos, face, partialTick, poseStack, buffers, light, overlay); + return; + } + ChestSettings settings = core.getSettings(); + DisplayStats stats = wall.getDisplayStats(); + ChestOverlayHelpers.renderItem(poseStack, buffers, level, wall.getDisplayed(), 0.5F, 9F / 16F, 8F / 16F, light); + if (settings.showCounts()) { + String count = IModHelpers.get().getGuiHelpers().quantityToScaledString(stats.count()); + ChestOverlayHelpers.renderText(poseStack, buffers, count, 0.5F, 2.5F / 16F, 2.5F / 16F, COLOR_TEXT, light); + } + if (settings.showFillLevels()) { + TextureAtlasSprite bar = Minecraft.getInstance().getTextureAtlas(TextureAtlas.LOCATION_BLOCKS).apply(BAR_TEXTURE); + float fill = stats.getFillLevel(); + ChestOverlayHelpers.renderSprite(poseStack, buffers, bar, BAR_X0, BAR_Y0, BAR_X1, BAR_Y1, light, overlay, COLOR_BAR_BACKGROUND); + if (fill > 0) { + poseStack.pushPose(); + poseStack.translate(0, 0, 0.001F); + ChestOverlayHelpers.renderSprite(poseStack, buffers, bar, BAR_X0, BAR_Y0, BAR_X0 + (BAR_X1 - BAR_X0) * fill, BAR_Y1, + light, overlay, fill >= 1 ? COLOR_BAR_FULL : COLOR_BAR); + poseStack.popPose(); + } + } + if (settings.showUpgradeIndicators()) { + // Upgrades that affect the shown item, from the top right corner leftwards. + List indicators = new ArrayList<>(); + if (stats.locked()) { + indicators.add(ChestUpgrades.LOCK); + } + if (stats.voided()) { + indicators.add(ChestUpgrades.VOID); + } + if (stats.compressed()) { + indicators.add(ChestUpgrades.COMPRESSION); + } + float x = 1F - 1F / 16F - INDICATOR_SIZE / 2; + for (ChestUpgrade upgrade : indicators) { + ItemStack stack = new ItemStack(BuiltInRegistries.ITEM.get( + ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "upgrade_" + upgrade.getId().getPath()))); + ChestOverlayHelpers.renderItem(poseStack, buffers, level, stack, x, 1F - 1F / 16F - INDICATOR_SIZE / 2, INDICATOR_SIZE, light); + x -= INDICATOR_SIZE; + } + } + } + +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/gametest/GameTestsCommon.java b/loader-common/src/main/java/org/cyclops/colossalchests2/gametest/GameTestsCommon.java index 5f92287f..f96a3794 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/gametest/GameTestsCommon.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/gametest/GameTestsCommon.java @@ -5,7 +5,6 @@ import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.world.inventory.ClickType; import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTestAssertException; import net.minecraft.gametest.framework.GameTestHelper; @@ -20,6 +19,7 @@ import net.minecraft.world.ItemInteractionResult; import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ClickType; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; @@ -43,11 +43,11 @@ import org.cyclops.colossalchests2.block.BlockChestWall; import org.cyclops.colossalchests2.block.ChestInteractions; import org.cyclops.colossalchests2.block.ChestMaterial; +import org.cyclops.colossalchests2.block.DisplayWallInteractions; import org.cyclops.colossalchests2.block.WallType; import org.cyclops.colossalchests2.blockentity.BlockEntityChestCore; -import org.cyclops.colossalchests2.inventory.ContainerInterface; -import org.cyclops.colossalchests2.capability.WallAccess; import org.cyclops.colossalchests2.blockentity.BlockEntityChestWall; +import org.cyclops.colossalchests2.capability.WallAccess; import org.cyclops.colossalchests2.config.ChestTables; import org.cyclops.colossalchests2.config.ChestTablesLoader; import org.cyclops.colossalchests2.inventory.ChestClickAction; @@ -55,21 +55,24 @@ import org.cyclops.colossalchests2.inventory.ChestSearch; import org.cyclops.colossalchests2.inventory.ChestSettings; import org.cyclops.colossalchests2.inventory.ContainerChest; +import org.cyclops.colossalchests2.inventory.ContainerInterface; import org.cyclops.colossalchests2.multiblock.ChestCoreIndex; import org.cyclops.colossalchests2.multiblock.ChestStructure; import org.cyclops.colossalchests2.multiblock.StructureDiagnosis; import org.cyclops.colossalchests2.network.packet.ClientboundChestSlotsPacket; import org.cyclops.colossalchests2.network.packet.ClientboundChestStatePacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestClickPacket; -import org.cyclops.colossalchests2.network.packet.ServerboundChestFormPacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestDragPacket; +import org.cyclops.colossalchests2.network.packet.ServerboundChestFormPacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestSettingsPacket; +import org.cyclops.colossalchests2.network.packet.ServerboundDisplayTakePacket; import org.cyclops.colossalchests2.storage.CapacityProfile; import org.cyclops.colossalchests2.storage.ChestStorage; import org.cyclops.colossalchests2.storage.CompressionFamilies; import org.cyclops.colossalchests2.storage.CompressionFamiliesCache; import org.cyclops.colossalchests2.storage.CompressionFamily; import org.cyclops.colossalchests2.storage.DeepSlot; +import org.cyclops.colossalchests2.storage.DisplayStats; import org.cyclops.colossalchests2.upgrade.ChestUpgrade; import org.cyclops.colossalchests2.upgrade.ChestUpgradeInventory; import org.cyclops.colossalchests2.upgrade.ChestUpgradeRules; @@ -419,6 +422,8 @@ public void testUpdateTagCarriesStructureButNoContents(GameTestHelper helper) { BlockEntityChestCore core = getCore(helper, corePos); core.getStorage().insert(STONE, 10, false); helper.assertValueEqual(core.getDecoratedPositions(), List.of(helper.absolutePos(corePos)), "decorated positions"); + // Display walls render with the chest's visual settings. + core.setSettings(core.getSettings().withShowCounts(false)); CompoundTag tag = core.getUpdateTag(helper.getLevel().registryAccess()); helper.assertFalse(tag.contains("storage"), "Expected no contents in the update tag"); @@ -426,6 +431,7 @@ public void testUpdateTagCarriesStructureButNoContents(GameTestHelper helper) { client.loadWithComponents(tag, helper.getLevel().registryAccess()); helper.assertValueEqual(client.getStructure(), core.getStructure(), "synced structure"); helper.assertValueEqual(client.getDecoratedPositions(), core.getDecoratedPositions(), "synced decorated positions"); + helper.assertValueEqual(client.getSettings(), core.getSettings(), "synced settings"); helper.assertValueEqual(client.getFacing(), Direction.NORTH, "synced facing"); helper.assertTrue(client.getStorage().getSlot(0).isEmpty(), "Expected no synced contents"); }) @@ -1734,4 +1740,124 @@ public void testVoidWallDestroysHeldOverflow(GameTestHelper helper) { .thenSucceed(); } + // Display walls + + private static ItemInteractionResult useWithItem(GameTestHelper helper, ServerPlayer player, BlockPos pos, ItemStack stack) { + player.setItemInHand(InteractionHand.MAIN_HAND, stack); + BlockPos absolute = helper.absolutePos(pos); + return helper.getLevel().getBlockState(absolute).useItemOn(stack, helper.getLevel(), player, InteractionHand.MAIN_HAND, + new BlockHitResult(Vec3.atCenterOf(absolute), Direction.UP, absolute, false)); + } + + private static int countInInventory(ServerPlayer player, Item item) { + int count = 0; + for (ItemStack stack : player.getInventory().items) { + if (stack.is(item)) { + count += stack.getCount(); + } + } + return count; + } + + @GameTest(template = TEMPLATE_EMPTY) + public void testDisplayWallInsertsAndTakes(GameTestHelper helper) { + BlockPos corePos = buildChest(helper, MIN_A, 3, ChestMaterial.WOOD); + BlockPos display = placeWall(helper, MIN_A.offset(1, 2, 1), WallType.DISPLAY); + helper.startSequence() + .thenWaitUntil(() -> assertFormed(helper, corePos, MIN_A, 3)) + .thenExecute(() -> { + ChestStorage storage = getCore(helper, corePos).getStorage(); + BlockEntityChestWall wall = getWall(helper, display); + ServerPlayer player = makeViewer(helper); + // The first item clicked in becomes the shown item and is inserted. + ItemStack held = STONE.copyWithCount(10); + useWithItem(helper, player, display, held); + helper.assertTrue(wall.getDisplayed().is(Items.STONE), "Expected stone to be shown"); + helper.assertTrue(held.isEmpty(), "Expected the held stone to be inserted"); + helper.assertValueEqual(DisplayStats.of(storage, STONE).count(), 10L, "stored stone"); + // Other items are not inserted. + ItemStack dirt = new ItemStack(Items.DIRT, 5); + useWithItem(helper, player, display, dirt); + helper.assertValueEqual(dirt.getCount(), 5, "dirt kept"); + // A quick second right-click inserts all stone from the inventory. + player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + player.getInventory().setItem(3, STONE.copyWithCount(20)); + player.getInventory().setItem(4, STONE.copyWithCount(30)); + useWithItem(helper, player, display, STONE.copyWithCount(1)); + player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + use(helper, player, display); + helper.assertValueEqual(countInInventory(player, Items.STONE), 0, "stone left in the inventory"); + helper.assertValueEqual(DisplayStats.of(storage, STONE).count(), 61L, "stored stone after inserting all"); + // Left-clicks take a stack or one, through the packet the client sends. + roundTrip(helper, new ServerboundDisplayTakePacket(helper.absolutePos(display), false), ServerboundDisplayTakePacket.CODEC) + .actionServer(helper.getLevel(), player); + helper.assertValueEqual(countInInventory(player, Items.STONE), 61, "stone taken as one stack"); + player.getInventory().clearContent(); + storage.insert(STONE, 100, false); + roundTrip(helper, new ServerboundDisplayTakePacket(helper.absolutePos(display), true), ServerboundDisplayTakePacket.CODEC) + .actionServer(helper.getLevel(), player); + helper.assertValueEqual(countInInventory(player, Items.STONE), 1, "single stone taken"); + // Sneaking with an empty hand clears the display. + player.setShiftKeyDown(true); + use(helper, player, display); + helper.assertTrue(wall.getDisplayed().isEmpty(), "Expected the display to be cleared"); + }) + .thenSucceed(); + } + + @GameTest(template = TEMPLATE_EMPTY) + public void testDisplayWallSyncsAndHasNoItemAccess(GameTestHelper helper) { + BlockPos corePos = buildChest(helper, MIN_A, 3, ChestMaterial.WOOD); + BlockPos display = placeWall(helper, MIN_A.offset(1, 2, 1), WallType.DISPLAY); + helper.startSequence() + .thenWaitUntil(() -> assertFormed(helper, corePos, MIN_A, 3)) + .thenExecute(() -> { + BlockEntityChestWall wall = getWall(helper, display); + helper.assertTrue(wall.getItemHandlerLogic().isEmpty(), "Expected no item access through a Display wall"); + wall.setDisplayed(STONE); + getCore(helper, corePos).getStorage().insert(STONE, 40, false); + }) + // The stats follow the chest on their own. + .thenWaitUntil(() -> helper.assertValueEqual(getWall(helper, display).getDisplayStats().count(), 40L, "shown count")) + .thenExecute(() -> { + BlockEntityChestWall wall = getWall(helper, display); + // What clients get. + CompoundTag tag = wall.getUpdateTag(helper.getLevel().registryAccess()); + BlockEntityChestWall client = new BlockEntityChestWall(wall.getBlockPos(), wall.getBlockState()); + client.loadWithComponents(tag, helper.getLevel().registryAccess()); + helper.assertTrue(client.getDisplayed().is(Items.STONE), "Expected the shown item on the client"); + helper.assertValueEqual(client.getDisplayStats(), wall.getDisplayStats(), "stats on the client"); + // The shown item survives saving and loading. + BlockEntityChestWall loaded = new BlockEntityChestWall(wall.getBlockPos(), wall.getBlockState()); + loaded.loadWithComponents(wall.saveWithoutMetadata(helper.getLevel().registryAccess()), helper.getLevel().registryAccess()); + helper.assertTrue(loaded.getDisplayed().is(Items.STONE), "Expected the shown item after loading"); + }) + .thenSucceed(); + } + + @GameTest(template = TEMPLATE_EMPTY) + public void testDisplayWallOnlyMinedWithAPickaxe(GameTestHelper helper) { + BlockPos corePos = buildChest(helper, MIN_A, 3, ChestMaterial.WOOD); + BlockPos display = placeWall(helper, MIN_A.offset(1, 2, 1), WallType.DISPLAY); + BlockPos interfacePos = placeWall(helper, MIN_A.offset(1, 0, 1), WallType.INTERFACE); + helper.startSequence() + .thenWaitUntil(() -> assertFormed(helper, corePos, MIN_A, 3)) + .thenExecute(() -> { + ServerPlayer player = makeViewer(helper); + BlockPos absolute = helper.absolutePos(display); + for (ItemStack held : List.of(ItemStack.EMPTY, STONE.copy(), new ItemStack(Items.WOODEN_SWORD))) { + player.setItemInHand(InteractionHand.MAIN_HAND, held); + helper.assertTrue(DisplayWallInteractions.onAttack(player, helper.getLevel(), absolute), + "Expected mining to be cancelled with " + held); + } + player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.IRON_PICKAXE)); + helper.assertFalse(DisplayWallInteractions.onAttack(player, helper.getLevel(), absolute), "Expected a pickaxe to mine"); + // Only Display walls are protected. + player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + helper.assertFalse(DisplayWallInteractions.onAttack(player, helper.getLevel(), helper.absolutePos(interfacePos)), + "Expected an Interface to mine normally"); + }) + .thenSucceed(); + } + } diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/network/packet/ServerboundDisplayTakePacket.java b/loader-common/src/main/java/org/cyclops/colossalchests2/network/packet/ServerboundDisplayTakePacket.java new file mode 100644 index 00000000..ffb01780 --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/network/packet/ServerboundDisplayTakePacket.java @@ -0,0 +1,65 @@ +package org.cyclops.colossalchests2.network.packet; + +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import org.cyclops.colossalchests2.Reference; +import org.cyclops.colossalchests2.block.DisplayWallInteractions; +import org.cyclops.colossalchests2.blockentity.BlockEntityChestWall; +import org.cyclops.cyclopscore.network.PacketBase; + +/** + * A left-click on a Display wall, taking a stack or one item of what it shows. + * @author rubensworks + */ +public class ServerboundDisplayTakePacket extends PacketBase { + + public static final Type TYPE = new Type<>(ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "display_take")); + public static final StreamCodec CODEC = getCodec(ServerboundDisplayTakePacket::new); + + private BlockPos pos; + private boolean single; + + public ServerboundDisplayTakePacket() { + super(TYPE); + } + + public ServerboundDisplayTakePacket(BlockPos pos, boolean single) { + super(TYPE); + this.pos = pos; + this.single = single; + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public void encode(RegistryFriendlyByteBuf buf) { + buf.writeBlockPos(pos); + buf.writeBoolean(single); + } + + @Override + public void decode(RegistryFriendlyByteBuf buf) { + pos = buf.readBlockPos(); + single = buf.readBoolean(); + } + + @Override + public void actionClient(Level level, Player player) { + } + + @Override + public void actionServer(Level level, ServerPlayer player) { + if (player.canInteractWithBlock(pos, 1.0) && DisplayWallInteractions.isDisplayWall(level.getBlockState(pos)) + && level.getBlockEntity(pos) instanceof BlockEntityChestWall wall) { + DisplayWallInteractions.take(player, wall, single); + } + } +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/storage/DisplayStats.java b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/DisplayStats.java new file mode 100644 index 00000000..ed3af7e8 --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/storage/DisplayStats.java @@ -0,0 +1,66 @@ +package org.cyclops.colossalchests2.storage; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.item.ItemStack; + +/** + * What a Display wall shows about one item type in a chest. + * @param count How many of the type the chest holds, across all slots and compression forms. + * @param capacity How many of the type the slots holding it can hold, 0 if none hold it. + * @param locked If a slot holding the type is locked. + * @param voided If a slot holding the type is voiding. + * @param compressed If the type is stored compressed. + * @author rubensworks + */ +public record DisplayStats(long count, long capacity, boolean locked, boolean voided, boolean compressed) { + + public static final DisplayStats EMPTY = new DisplayStats(0, 0, false, false, false); + + /** + * @param storage A storage. + * @param type An item type, empty for none. + * @return The stats of the type in the storage. + */ + public static DisplayStats of(ChestStorage storage, ItemStack type) { + if (type.isEmpty()) { + return EMPTY; + } + ItemStack stored = storage.getStoredType(type); + long count = 0; + long capacity = 0; + boolean locked = false; + boolean voided = false; + for (int slot = 0; slot < storage.getSlotCount(); slot++) { + DeepSlot deepSlot = storage.getSlot(slot); + if (deepSlot.matches(stored)) { + count = CapacityProfile.saturatedAdd(count, storage.getAvailable(slot, type)); + capacity = CapacityProfile.saturatedAdd(capacity, storage.getCapacity(type)); + locked |= deepSlot.isLocked(); + voided |= deepSlot.isVoiding(); + } + } + return new DisplayStats(count, capacity, locked, voided, storage.getFamily(type).isPresent()); + } + + /** + * @return How full the slots holding the type are, from 0 to 1. + */ + public float getFillLevel() { + return capacity <= 0 ? 0 : (float) Math.min(1D, (double) count / capacity); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putLong("count", count); + tag.putLong("capacity", capacity); + tag.putBoolean("locked", locked); + tag.putBoolean("voided", voided); + tag.putBoolean("compressed", compressed); + return tag; + } + + public static DisplayStats fromTag(CompoundTag tag) { + return new DisplayStats(tag.getLong("count"), tag.getLong("capacity"), tag.getBoolean("locked"), + tag.getBoolean("voided"), tag.getBoolean("compressed")); + } +} diff --git a/loader-common/src/main/resources/assets/colossalchests2/blockstates/chest_wall_display.json b/loader-common/src/main/resources/assets/colossalchests2/blockstates/chest_wall_display.json new file mode 100644 index 00000000..aea01593 --- /dev/null +++ b/loader-common/src/main/resources/assets/colossalchests2/blockstates/chest_wall_display.json @@ -0,0 +1,10 @@ +{ + "variants": { + "formed=false": { + "model": "colossalchests2:block/chest_wall_display" + }, + "formed=true": { + "model": "colossalchests2:block/chest_wall_display_formed" + } + } +} diff --git a/loader-common/src/main/resources/assets/colossalchests2/lang/en_us.json b/loader-common/src/main/resources/assets/colossalchests2/lang/en_us.json index 5afc5f9a..a03359a2 100644 --- a/loader-common/src/main/resources/assets/colossalchests2/lang/en_us.json +++ b/loader-common/src/main/resources/assets/colossalchests2/lang/en_us.json @@ -11,6 +11,8 @@ "block.colossalchests2.chest_wall_interface.info": "A wall for chest external access. Sneak-right-click with empty hand to set filters.", "block.colossalchests2.chest_wall_void": "Void Chest Interface", "block.colossalchests2.chest_wall_void.info": "An interface that destroys overflowing items.", + "block.colossalchests2.chest_wall_display": "Chest Display", + "block.colossalchests2.chest_wall_display.info": "Shows an item and its count. Left-click takes, right-click inserts, double right-click inserts all. Sneak-right-click with empty hand to clear. Only a pickaxe breaks it.", "block.colossalchests2.chest_wall_gold": "Golden Chest Wall", "block.colossalchests2.chest_core_gold": "Golden Chest Core", "block.colossalchests2.chest_wall_diamond": "Diamond Chest Wall", diff --git a/loader-common/src/main/resources/assets/colossalchests2/models/block/chest_wall_display.json b/loader-common/src/main/resources/assets/colossalchests2/models/block/chest_wall_display.json new file mode 100644 index 00000000..62e74e33 --- /dev/null +++ b/loader-common/src/main/resources/assets/colossalchests2/models/block/chest_wall_display.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "colossalchests2:block/chest_wall_display" + } +} diff --git a/loader-common/src/main/resources/assets/colossalchests2/models/block/chest_wall_display_formed.json b/loader-common/src/main/resources/assets/colossalchests2/models/block/chest_wall_display_formed.json new file mode 100644 index 00000000..8df6de8b --- /dev/null +++ b/loader-common/src/main/resources/assets/colossalchests2/models/block/chest_wall_display_formed.json @@ -0,0 +1,6 @@ +{ + "parent": "colossalchests2:block/formed_member", + "textures": { + "particle": "colossalchests2:block/chest_wall_display" + } +} diff --git a/loader-common/src/main/resources/assets/colossalchests2/models/item/chest_wall_display.json b/loader-common/src/main/resources/assets/colossalchests2/models/item/chest_wall_display.json new file mode 100644 index 00000000..a4c5e1bf --- /dev/null +++ b/loader-common/src/main/resources/assets/colossalchests2/models/item/chest_wall_display.json @@ -0,0 +1,3 @@ +{ + "parent": "colossalchests2:block/chest_wall_display" +} diff --git a/loader-common/src/main/resources/assets/colossalchests2/textures/block/chest_wall_display.png b/loader-common/src/main/resources/assets/colossalchests2/textures/block/chest_wall_display.png new file mode 100644 index 0000000000000000000000000000000000000000..c44cb5620d846e6a9b6580de588b353fd9142e7b GIT binary patch literal 366 zcmV-!0g?WRP){fTQDimkR?F) zdELnNH~wT>rqik4ZnuaCs!F+BMpY3Jy!Qk|gfvYN5sGNNUS~3y0AP%vR4Sbm?{+(` zF`v)TJkQzh_W%qAgR99!mdoXD2T78UWf`N4?5*EV-s|4SMM2}7yC`srIF3ov6l1NW)9E}c5JeHzT1=Sl zvuGU0WLb6=xaK(!teYr`Xt&!B|7@+LTCE-|BuVnH0D$)%-E1}|QwVduMX;`oMuTp* z%WAcv-|yd599Fqfsi5BbqlMy*-;3sX&TKY23wZAj9{-$n@j69>ACKOH8<~#sBme*a M07*qoM6N<$f@aa66#xJL literal 0 HcmV?d00001 diff --git a/loader-common/src/main/resources/assets/colossalchests2/textures/block/chest_wall_display_icon.png b/loader-common/src/main/resources/assets/colossalchests2/textures/block/chest_wall_display_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..41efc8b5ad36661b12915168f416b6b8c5862792 GIT binary patch literal 180 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`)t)YnAr*6y6BY;<9Fiz1D*Eqv z@yCmc%5FOtL7e2|WY0~NA0Hj%>6f!jN^ul3;q6uP`uy^$_rn~qn64{%OBa5~UwQV> zs=iJQ?T-zK2@K~Pj)-P#DL>f4A1Tolm$Ii}vvt^-!}$)42TXjj!vtP3JrQx!Szuut b$iuKnO=0FsJJl^fXE1oW`njxgN@xNAa;ijb literal 0 HcmV?d00001 diff --git a/loader-common/src/main/resources/assets/colossalchests2/textures/block/display_bar.png b/loader-common/src/main/resources/assets/colossalchests2/textures/block/display_bar.png new file mode 100644 index 0000000000000000000000000000000000000000..89bea2f3559e16c1c31250707aadc1710395b9fd GIT binary patch literal 86 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`vYsxEAr*6y|NQ^|zn+bYL0EN` jJFkRk!j*=DDGUtRr&wf8?@IRsDrE3<^>bP0l+XkKV>TCr literal 0 HcmV?d00001 diff --git a/loader-common/src/main/resources/data/colossalchests2/loot_table/blocks/chest_wall_display.json b/loader-common/src/main/resources/data/colossalchests2/loot_table/blocks/chest_wall_display.json new file mode 100644 index 00000000..75eb8c6a --- /dev/null +++ b/loader-common/src/main/resources/data/colossalchests2/loot_table/blocks/chest_wall_display.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "entries": [ + { + "type": "minecraft:item", + "name": "colossalchests2:chest_wall_display" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/loader-common/src/main/resources/data/minecraft/tags/block/mineable/pickaxe.json b/loader-common/src/main/resources/data/minecraft/tags/block/mineable/pickaxe.json index 6935d0ed..bc5686f6 100644 --- a/loader-common/src/main/resources/data/minecraft/tags/block/mineable/pickaxe.json +++ b/loader-common/src/main/resources/data/minecraft/tags/block/mineable/pickaxe.json @@ -13,6 +13,7 @@ "colossalchests2:chest_wall_netherite", "colossalchests2:chest_core_netherite", "colossalchests2:chest_wall_interface", - "colossalchests2:chest_wall_void" + "colossalchests2:chest_wall_void", + "colossalchests2:chest_wall_display" ] } diff --git a/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestDisplayStats.java b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestDisplayStats.java new file mode 100644 index 00000000..61a562d9 --- /dev/null +++ b/loader-common/src/test/java/org/cyclops/colossalchests2/storage/TestDisplayStats.java @@ -0,0 +1,83 @@ +package org.cyclops.colossalchests2.storage; + +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * @author rubensworks + */ +public class TestDisplayStats extends BootstrapTest { + + private static final ItemStack STONE = new ItemStack(Items.STONE); + + private ChestStorage storage; + + @Before + public void setUp() { + // 4 stacks per slot. + storage = new ChestStorage(4, CapacityProfile.ofDepth(4)); + } + + @Test + public void testEmptyType() { + assertEquals(DisplayStats.EMPTY, DisplayStats.of(storage, ItemStack.EMPTY)); + assertEquals(0, DisplayStats.EMPTY.getFillLevel(), 0); + } + + @Test + public void testNotStored() { + DisplayStats stats = DisplayStats.of(storage, STONE); + assertEquals(0, stats.count()); + assertEquals(0, stats.capacity()); + assertEquals(0, stats.getFillLevel(), 0); + } + + @Test + public void testCountsAcrossSlots() { + storage.insert(0, STONE, 256, false); + storage.insert(2, STONE, 64, false); + storage.insert(1, new ItemStack(Items.DIRT), 10, false); + DisplayStats stats = DisplayStats.of(storage, STONE); + assertEquals(320, stats.count()); + assertEquals(512, stats.capacity()); + assertEquals(0.625F, stats.getFillLevel(), 0.0001F); + assertFalse(stats.locked()); + assertFalse(stats.voided()); + assertFalse(stats.compressed()); + } + + @Test + public void testLockedAndVoided() { + storage.lockTo(1, STONE); + assertTrue(DisplayStats.of(storage, STONE).locked()); + assertEquals(0, DisplayStats.of(storage, STONE).count()); + storage.insert(1, STONE, 5, false); + storage.setVoiding(1, true); + DisplayStats stats = DisplayStats.of(storage, STONE); + assertTrue(stats.voided()); + assertEquals(5, stats.count()); + } + + @Test + public void testCompressedFormsCount() { + CompressionFamilies families = new CompressionFamilies(); + families.register(TestCompressionFamily.iron()); + storage.setCompression(() -> families); + storage.insert(new ItemStack(Items.IRON_BLOCK), 2, false); + storage.insert(new ItemStack(Items.IRON_NUGGET), 5, false); + DisplayStats ingots = DisplayStats.of(storage, new ItemStack(Items.IRON_INGOT)); + assertTrue(ingots.compressed()); + assertEquals(18, ingots.count()); + assertEquals(167, DisplayStats.of(storage, new ItemStack(Items.IRON_NUGGET)).count()); + } + + @Test + public void testTagRoundTrip() { + DisplayStats stats = new DisplayStats(123456789012L, 99, true, false, true); + assertEquals(stats, DisplayStats.fromTag(stats.toTag())); + } +} diff --git a/loader-fabric/src/main/java/org/cyclops/colossalchests2/ColossalChestsFabric.java b/loader-fabric/src/main/java/org/cyclops/colossalchests2/ColossalChestsFabric.java index 6e297390..18bcc8ca 100644 --- a/loader-fabric/src/main/java/org/cyclops/colossalchests2/ColossalChestsFabric.java +++ b/loader-fabric/src/main/java/org/cyclops/colossalchests2/ColossalChestsFabric.java @@ -1,28 +1,31 @@ package org.cyclops.colossalchests2; import net.fabricmc.api.ModInitializer; -import net.minecraft.world.item.CreativeModeTab; -import net.minecraft.world.item.ItemStack; +import net.fabricmc.fabric.api.event.player.AttackBlockCallback; import net.fabricmc.fabric.api.resource.ResourceManagerHelper; import net.minecraft.server.packs.PackType; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.item.CreativeModeTab; +import net.minecraft.world.item.ItemStack; import org.cyclops.colossalchests2.block.BlockChestCoreConfig; import org.cyclops.colossalchests2.block.BlockChestFunctionalWallConfig; import org.cyclops.colossalchests2.block.BlockChestWallConfig; -import org.cyclops.colossalchests2.block.WallType; import org.cyclops.colossalchests2.block.ChestMaterial; +import org.cyclops.colossalchests2.block.DisplayWallInteractions; +import org.cyclops.colossalchests2.block.WallType; import org.cyclops.colossalchests2.blockentity.BlockEntityChestCoreConfigFabric; import org.cyclops.colossalchests2.blockentity.BlockEntityChestWallConfigFabric; import org.cyclops.colossalchests2.component.DataComponentChestContentsConfig; import org.cyclops.colossalchests2.component.DataComponentChestSettingsConfig; import org.cyclops.colossalchests2.component.DataComponentChestUpgradesConfig; +import org.cyclops.colossalchests2.config.ChestTablesReloadListenerFabric; import org.cyclops.colossalchests2.inventory.ContainerChestConfig; import org.cyclops.colossalchests2.inventory.ContainerInterfaceConfig; +import org.cyclops.colossalchests2.proxy.ClientProxyFabric; +import org.cyclops.colossalchests2.proxy.CommonProxyFabric; import org.cyclops.colossalchests2.upgrade.ChestUpgrade; import org.cyclops.colossalchests2.upgrade.ChestUpgrades; import org.cyclops.colossalchests2.upgrade.ItemChestUpgradeConfig; -import org.cyclops.colossalchests2.config.ChestTablesReloadListenerFabric; -import org.cyclops.colossalchests2.proxy.ClientProxyFabric; -import org.cyclops.colossalchests2.proxy.CommonProxyFabric; import org.cyclops.cyclopscore.config.ConfigHandlerCommon; import org.cyclops.cyclopscore.init.ModBaseFabric; import org.cyclops.cyclopscore.proxy.IClientProxyCommon; @@ -45,6 +48,8 @@ public ColossalChestsFabric() { _instance = instance; }); ResourceManagerHelper.get(PackType.SERVER_DATA).registerReloadListener(new ChestTablesReloadListenerFabric()); + AttackBlockCallback.EVENT.register((player, level, hand, pos, direction) -> + DisplayWallInteractions.onAttack(player, level, pos) ? InteractionResult.FAIL : InteractionResult.PASS); } @Override diff --git a/loader-fabric/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWallConfigFabric.java b/loader-fabric/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWallConfigFabric.java index a7b19241..dd4aceb0 100644 --- a/loader-fabric/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWallConfigFabric.java +++ b/loader-fabric/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWallConfigFabric.java @@ -16,7 +16,7 @@ public BlockEntityChestWallConfigFabric(M mod) { @Override public void onForgeRegistered() { super.onForgeRegistered(); - ItemStorage.SIDED.registerForBlockEntity((wall, side) -> wall.getCore() + ItemStorage.SIDED.registerForBlockEntity((wall, side) -> wall.getExposedCore() .map(core -> ((BlockEntityChestCoreFabric) core).getFabricStorage().withAccess(wall.getAccess())) .orElse(null), getInstance()); } diff --git a/loader-fabric/src/main/java/org/cyclops/colossalchests2/proxy/CommonProxyFabric.java b/loader-fabric/src/main/java/org/cyclops/colossalchests2/proxy/CommonProxyFabric.java index 265979d4..2f86cf59 100644 --- a/loader-fabric/src/main/java/org/cyclops/colossalchests2/proxy/CommonProxyFabric.java +++ b/loader-fabric/src/main/java/org/cyclops/colossalchests2/proxy/CommonProxyFabric.java @@ -5,6 +5,7 @@ import org.cyclops.colossalchests2.network.packet.ClientboundChestStatePacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestClickPacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestFormPacket; +import org.cyclops.colossalchests2.network.packet.ServerboundDisplayTakePacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestDragPacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestSettingsPacket; import org.cyclops.cyclopscore.init.ModBaseFabric; @@ -30,6 +31,7 @@ public void registerPackets(IPacketHandler packetHandler) { packetHandler.register(ClientboundChestStatePacket.class, ClientboundChestStatePacket.TYPE, ClientboundChestStatePacket.CODEC); packetHandler.register(ServerboundChestClickPacket.class, ServerboundChestClickPacket.TYPE, ServerboundChestClickPacket.CODEC); packetHandler.register(ServerboundChestFormPacket.class, ServerboundChestFormPacket.TYPE, ServerboundChestFormPacket.CODEC); + packetHandler.register(ServerboundDisplayTakePacket.class, ServerboundDisplayTakePacket.TYPE, ServerboundDisplayTakePacket.CODEC); packetHandler.register(ServerboundChestDragPacket.class, ServerboundChestDragPacket.TYPE, ServerboundChestDragPacket.CODEC); packetHandler.register(ServerboundChestSettingsPacket.class, ServerboundChestSettingsPacket.TYPE, ServerboundChestSettingsPacket.CODEC); } diff --git a/loader-forge/src/main/java/org/cyclops/colossalchests2/ColossalChestsForge.java b/loader-forge/src/main/java/org/cyclops/colossalchests2/ColossalChestsForge.java index be315f01..b9e1c1b6 100644 --- a/loader-forge/src/main/java/org/cyclops/colossalchests2/ColossalChestsForge.java +++ b/loader-forge/src/main/java/org/cyclops/colossalchests2/ColossalChestsForge.java @@ -2,27 +2,29 @@ import net.minecraft.world.item.CreativeModeTab; import net.minecraft.world.item.ItemStack; -import net.minecraftforge.fml.common.Mod; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.AddReloadListenerEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.fml.common.Mod; import org.cyclops.colossalchests2.block.BlockChestCoreConfig; import org.cyclops.colossalchests2.block.BlockChestFunctionalWallConfig; import org.cyclops.colossalchests2.block.BlockChestWallConfig; -import org.cyclops.colossalchests2.block.WallType; import org.cyclops.colossalchests2.block.ChestMaterial; +import org.cyclops.colossalchests2.block.DisplayWallInteractions; +import org.cyclops.colossalchests2.block.WallType; import org.cyclops.colossalchests2.blockentity.BlockEntityChestCoreConfigForge; import org.cyclops.colossalchests2.blockentity.BlockEntityChestWallConfigForge; import org.cyclops.colossalchests2.component.DataComponentChestContentsConfig; import org.cyclops.colossalchests2.component.DataComponentChestSettingsConfig; import org.cyclops.colossalchests2.component.DataComponentChestUpgradesConfig; +import org.cyclops.colossalchests2.config.ChestTablesReloadListener; import org.cyclops.colossalchests2.inventory.ContainerChestConfig; import org.cyclops.colossalchests2.inventory.ContainerInterfaceConfig; +import org.cyclops.colossalchests2.proxy.ClientProxyForge; +import org.cyclops.colossalchests2.proxy.CommonProxyForge; import org.cyclops.colossalchests2.upgrade.ChestUpgrade; import org.cyclops.colossalchests2.upgrade.ChestUpgrades; import org.cyclops.colossalchests2.upgrade.ItemChestUpgradeConfig; -import org.cyclops.colossalchests2.config.ChestTablesReloadListener; -import org.cyclops.colossalchests2.proxy.ClientProxyForge; -import org.cyclops.colossalchests2.proxy.CommonProxyForge; import org.cyclops.cyclopscore.config.ConfigHandlerCommon; import org.cyclops.cyclopscore.init.ModBaseForge; import org.cyclops.cyclopscore.proxy.IClientProxyCommon; @@ -47,6 +49,11 @@ public ColossalChestsForge() { ColossalChestsInstance.MOD = instance; }); MinecraftForge.EVENT_BUS.addListener((AddReloadListenerEvent event) -> event.addListener(new ChestTablesReloadListener())); + MinecraftForge.EVENT_BUS.addListener((PlayerInteractEvent.LeftClickBlock event) -> { + if (DisplayWallInteractions.onAttack(event.getEntity(), event.getLevel(), event.getPos())) { + event.setCanceled(true); + } + }); } @Override diff --git a/loader-forge/src/main/java/org/cyclops/colossalchests2/proxy/CommonProxyForge.java b/loader-forge/src/main/java/org/cyclops/colossalchests2/proxy/CommonProxyForge.java index dab32c47..c0e40ba3 100644 --- a/loader-forge/src/main/java/org/cyclops/colossalchests2/proxy/CommonProxyForge.java +++ b/loader-forge/src/main/java/org/cyclops/colossalchests2/proxy/CommonProxyForge.java @@ -5,6 +5,7 @@ import org.cyclops.colossalchests2.network.packet.ClientboundChestStatePacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestClickPacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestFormPacket; +import org.cyclops.colossalchests2.network.packet.ServerboundDisplayTakePacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestDragPacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestSettingsPacket; import org.cyclops.cyclopscore.init.ModBaseForge; @@ -30,6 +31,7 @@ public void registerPackets(IPacketHandler packetHandler) { packetHandler.register(ClientboundChestStatePacket.class, ClientboundChestStatePacket.TYPE, ClientboundChestStatePacket.CODEC); packetHandler.register(ServerboundChestClickPacket.class, ServerboundChestClickPacket.TYPE, ServerboundChestClickPacket.CODEC); packetHandler.register(ServerboundChestFormPacket.class, ServerboundChestFormPacket.TYPE, ServerboundChestFormPacket.CODEC); + packetHandler.register(ServerboundDisplayTakePacket.class, ServerboundDisplayTakePacket.TYPE, ServerboundDisplayTakePacket.CODEC); packetHandler.register(ServerboundChestDragPacket.class, ServerboundChestDragPacket.TYPE, ServerboundChestDragPacket.CODEC); packetHandler.register(ServerboundChestSettingsPacket.class, ServerboundChestSettingsPacket.TYPE, ServerboundChestSettingsPacket.CODEC); } diff --git a/loader-neoforge/src/main/java/org/cyclops/colossalchests2/ColossalChests.java b/loader-neoforge/src/main/java/org/cyclops/colossalchests2/ColossalChests.java index d6673a8b..ffef614a 100644 --- a/loader-neoforge/src/main/java/org/cyclops/colossalchests2/ColossalChests.java +++ b/loader-neoforge/src/main/java/org/cyclops/colossalchests2/ColossalChests.java @@ -6,28 +6,30 @@ import net.neoforged.api.distmarker.OnlyIn; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.common.Mod; -import org.apache.logging.log4j.Level; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.AddReloadListenerEvent; +import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; +import org.apache.logging.log4j.Level; import org.cyclops.colossalchests2.block.BlockChestCoreConfig; import org.cyclops.colossalchests2.block.BlockChestFunctionalWallConfig; import org.cyclops.colossalchests2.block.BlockChestWallConfig; -import org.cyclops.colossalchests2.block.WallType; import org.cyclops.colossalchests2.block.ChestMaterial; +import org.cyclops.colossalchests2.block.DisplayWallInteractions; +import org.cyclops.colossalchests2.block.WallType; import org.cyclops.colossalchests2.blockentity.BlockEntityChestCoreConfigNeoForge; import org.cyclops.colossalchests2.blockentity.BlockEntityChestWallConfigNeoForge; import org.cyclops.colossalchests2.component.DataComponentChestContentsConfig; import org.cyclops.colossalchests2.component.DataComponentChestSettingsConfig; import org.cyclops.colossalchests2.component.DataComponentChestUpgradesConfig; +import org.cyclops.colossalchests2.config.ChestTablesReloadListener; import org.cyclops.colossalchests2.inventory.ContainerChestConfig; import org.cyclops.colossalchests2.inventory.ContainerInterfaceConfig; -import org.cyclops.colossalchests2.upgrade.ChestUpgrade; -import org.cyclops.colossalchests2.upgrade.ChestUpgrades; -import org.cyclops.colossalchests2.upgrade.ItemChestUpgradeConfig; -import org.cyclops.colossalchests2.config.ChestTablesReloadListener; import org.cyclops.colossalchests2.modcompat.CommonCapabilitiesModCompat; import org.cyclops.colossalchests2.proxy.ClientProxy; import org.cyclops.colossalchests2.proxy.CommonProxy; +import org.cyclops.colossalchests2.upgrade.ChestUpgrade; +import org.cyclops.colossalchests2.upgrade.ChestUpgrades; +import org.cyclops.colossalchests2.upgrade.ItemChestUpgradeConfig; import org.cyclops.cyclopscore.config.ConfigHandlerCommon; import org.cyclops.cyclopscore.init.ModBaseVersionable; import org.cyclops.cyclopscore.modcompat.ModCompatLoader; @@ -53,6 +55,11 @@ public ColossalChests(IEventBus modEventBus) { _instance = instance; }, modEventBus); NeoForge.EVENT_BUS.addListener((AddReloadListenerEvent event) -> event.addListener(new ChestTablesReloadListener())); + NeoForge.EVENT_BUS.addListener((PlayerInteractEvent.LeftClickBlock event) -> { + if (DisplayWallInteractions.onAttack(event.getEntity(), event.getLevel(), event.getPos())) { + event.setCanceled(true); + } + }); } @Override diff --git a/loader-neoforge/src/main/java/org/cyclops/colossalchests2/modcompat/CommonCapabilitiesModCompat.java b/loader-neoforge/src/main/java/org/cyclops/colossalchests2/modcompat/CommonCapabilitiesModCompat.java index 7ae0c3cf..c97f3a60 100644 --- a/loader-neoforge/src/main/java/org/cyclops/colossalchests2/modcompat/CommonCapabilitiesModCompat.java +++ b/loader-neoforge/src/main/java/org/cyclops/colossalchests2/modcompat/CommonCapabilitiesModCompat.java @@ -39,6 +39,6 @@ private static void registerCapabilities(RegisterCapabilitiesEvent event) { event.registerBlockEntity(Capabilities.InventoryState.BLOCK, RegistryEntries.BLOCK_ENTITY_CHEST_CORE.value(), (core, side) -> core.isFormed() ? new InventoryStateChestStorage(core.getStorage()) : null); event.registerBlockEntity(Capabilities.InventoryState.BLOCK, RegistryEntries.BLOCK_ENTITY_CHEST_WALL.value(), - (wall, side) -> wall.getCore().map(core -> (IInventoryState) new InventoryStateChestStorage(core.getStorage())).orElse(null)); + (wall, side) -> wall.getExposedCore().map(core -> (IInventoryState) new InventoryStateChestStorage(core.getStorage())).orElse(null)); } } diff --git a/loader-neoforge/src/main/java/org/cyclops/colossalchests2/proxy/CommonProxy.java b/loader-neoforge/src/main/java/org/cyclops/colossalchests2/proxy/CommonProxy.java index 0963aaee..bc40a901 100644 --- a/loader-neoforge/src/main/java/org/cyclops/colossalchests2/proxy/CommonProxy.java +++ b/loader-neoforge/src/main/java/org/cyclops/colossalchests2/proxy/CommonProxy.java @@ -5,6 +5,7 @@ import org.cyclops.colossalchests2.network.packet.ClientboundChestStatePacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestClickPacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestFormPacket; +import org.cyclops.colossalchests2.network.packet.ServerboundDisplayTakePacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestDragPacket; import org.cyclops.colossalchests2.network.packet.ServerboundChestSettingsPacket; import org.cyclops.cyclopscore.init.ModBase; @@ -30,6 +31,7 @@ public void registerPacketHandlers(PacketHandler packetHandler) { packetHandler.register(ClientboundChestStatePacket.class, ClientboundChestStatePacket.TYPE, ClientboundChestStatePacket.CODEC); packetHandler.register(ServerboundChestClickPacket.class, ServerboundChestClickPacket.TYPE, ServerboundChestClickPacket.CODEC); packetHandler.register(ServerboundChestFormPacket.class, ServerboundChestFormPacket.TYPE, ServerboundChestFormPacket.CODEC); + packetHandler.register(ServerboundDisplayTakePacket.class, ServerboundDisplayTakePacket.TYPE, ServerboundDisplayTakePacket.CODEC); packetHandler.register(ServerboundChestDragPacket.class, ServerboundChestDragPacket.TYPE, ServerboundChestDragPacket.CODEC); packetHandler.register(ServerboundChestSettingsPacket.class, ServerboundChestSettingsPacket.TYPE, ServerboundChestSettingsPacket.CODEC); } From 13ef0b9ab68d0cf4cf2ef69009a7f4c7d1fdade7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:55:54 +0000 Subject: [PATCH 2/9] Render Display walls as a framed, recessed panel (wip) The item sits in a dark panel with the count, fill bar and flat upgrade icons inside it. The frame style is still switchable while the look is chosen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RSz5r6eowhjNuANBjZE2v6 --- .../client/render/ChestOverlayHelpers.java | 4 +- .../client/render/DisplayWallOverlay.java | 160 +++++++++++++----- .../textures/block/display_panel.png | Bin 0 -> 132 bytes 3 files changed, 115 insertions(+), 49 deletions(-) create mode 100644 loader-common/src/main/resources/assets/colossalchests2/textures/block/display_panel.png diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/ChestOverlayHelpers.java b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/ChestOverlayHelpers.java index 0a36791b..53608fa0 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/ChestOverlayHelpers.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/ChestOverlayHelpers.java @@ -98,7 +98,7 @@ public static boolean isRevealingMembers() { } /** - * Draw a line of text on the face, centered horizontally. + * Draw a line of text with a shadow on the face, centered horizontally. * @param poseStack The pose stack. * @param buffers The buffers. * @param text The text. @@ -116,7 +116,7 @@ public static void renderText(PoseStack poseStack, MultiBufferSource buffers, St // Font y grows downwards, face y upwards. poseStack.translate(centerX, y0 + height, 0); poseStack.scale(scale, -scale, scale); - font.drawInBatch(text, -font.width(text) / 2F, 0, color, false, poseStack.last().pose(), buffers, + font.drawInBatch(text, -font.width(text) / 2F, 0, color, true, poseStack.last().pose(), buffers, Font.DisplayMode.POLYGON_OFFSET, 0, light); poseStack.popPose(); } diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java index a53665ac..7fa23e4f 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java @@ -3,16 +3,17 @@ import com.mojang.blaze3d.vertex.PoseStack; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.texture.OverlayTexture; import net.minecraft.client.renderer.texture.TextureAtlas; import net.minecraft.client.renderer.texture.TextureAtlasSprite; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; -import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemDisplayContext; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.Level; import org.cyclops.colossalchests2.Reference; -import org.cyclops.colossalchests2.block.WallType; +import org.cyclops.colossalchests2.block.BlockChestCore; import org.cyclops.colossalchests2.blockentity.BlockEntityChestCore; import org.cyclops.colossalchests2.blockentity.BlockEntityChestWall; import org.cyclops.colossalchests2.inventory.ChestSettings; @@ -20,76 +21,141 @@ import org.cyclops.colossalchests2.upgrade.ChestUpgrade; import org.cyclops.colossalchests2.upgrade.ChestUpgrades; import org.cyclops.cyclopscore.helper.IModHelpers; +import org.joml.Matrix3f; import java.util.ArrayList; import java.util.List; /** - * Shows a Display wall's item, count, fill level and upgrade indicators on its outer faces, honouring the chest's - * visual settings. An empty Display wall shows its icon while the player sneaks, like other functional walls. + * Shows a Display wall as a recessed panel in a frame of the chest's material, with its item, count, fill level and + * the upgrades affecting it, honouring the chest's visual settings. * @author rubensworks */ public class DisplayWallOverlay implements IChestOverlay { + private static final ResourceLocation PANEL_TEXTURE = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "block/display_panel"); private static final ResourceLocation BAR_TEXTURE = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "block/display_bar"); + private static final ResourceLocation NEUTRAL_FRAME_TEXTURE = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "block/chest_wall_iron"); private static final int COLOR_TEXT = 0xFFFFFF; - private static final int COLOR_BAR_BACKGROUND = 0xFF202020; + private static final int COLOR_BAR_BACKGROUND = 0xFF101010; private static final int COLOR_BAR = 0xFF40C040; private static final int COLOR_BAR_FULL = 0xFFD04030; + private static final float PANEL_MIN = 1F / 16F; + private static final float PANEL_MAX = 15F / 16F; private static final float BAR_X0 = 3F / 16F; private static final float BAR_X1 = 13F / 16F; - private static final float BAR_Y0 = 1F / 16F; - private static final float BAR_Y1 = 2F / 16F; - private static final float INDICATOR_SIZE = 3F / 16F; + private static final float BAR_Y0 = 2F / 16F; + private static final float BAR_Y1 = 2.75F / 16F; + private static final float TEXT_Y0 = 3.25F / 16F; + private static final float TEXT_HEIGHT = 2.25F / 16F; + private static final float ITEM_Y = 9.25F / 16F; + private static final float ITEM_SIZE = 7.5F / 16F; + private static final float INDICATOR_SIZE = 2.5F / 16F; + private static final float LAYER = 0.0005F; - private final FunctionalWallOverlay icon = new FunctionalWallOverlay(WallType.DISPLAY); + /** + * Temporary switches to compare looks in-game. 0: material frame, 1: neutral frame, 2: no frame. + */ + public static int frameStyle = 0; + /** + * 0: flat inventory icon, 1: inventory icon shaded by the world, 2: item frame style. + */ + public static int itemStyle = 1; @Override public void render(BlockEntityChestCore core, BlockPos pos, Direction face, float partialTick, PoseStack poseStack, MultiBufferSource buffers, int light, int overlay) { Level level = core.getLevel(); - if (!(level.getBlockEntity(pos) instanceof BlockEntityChestWall wall) || wall.getDisplayed().isEmpty()) { - icon.render(core, pos, face, partialTick, poseStack, buffers, light, overlay); - return; - } - ChestSettings settings = core.getSettings(); - DisplayStats stats = wall.getDisplayStats(); - ChestOverlayHelpers.renderItem(poseStack, buffers, level, wall.getDisplayed(), 0.5F, 9F / 16F, 8F / 16F, light); - if (settings.showCounts()) { - String count = IModHelpers.get().getGuiHelpers().quantityToScaledString(stats.count()); - ChestOverlayHelpers.renderText(poseStack, buffers, count, 0.5F, 2.5F / 16F, 2.5F / 16F, COLOR_TEXT, light); - } - if (settings.showFillLevels()) { - TextureAtlasSprite bar = Minecraft.getInstance().getTextureAtlas(TextureAtlas.LOCATION_BLOCKS).apply(BAR_TEXTURE); - float fill = stats.getFillLevel(); - ChestOverlayHelpers.renderSprite(poseStack, buffers, bar, BAR_X0, BAR_Y0, BAR_X1, BAR_Y1, light, overlay, COLOR_BAR_BACKGROUND); - if (fill > 0) { - poseStack.pushPose(); - poseStack.translate(0, 0, 0.001F); - ChestOverlayHelpers.renderSprite(poseStack, buffers, bar, BAR_X0, BAR_Y0, BAR_X0 + (BAR_X1 - BAR_X0) * fill, BAR_Y1, - light, overlay, fill >= 1 ? COLOR_BAR_FULL : COLOR_BAR); - poseStack.popPose(); + TextureAtlas atlas = Minecraft.getInstance().getModelManager().getAtlas(TextureAtlas.LOCATION_BLOCKS); + poseStack.pushPose(); + renderPanel(core, atlas, poseStack, buffers, light, overlay); + if (level.getBlockEntity(pos) instanceof BlockEntityChestWall wall && !wall.getDisplayed().isEmpty()) { + ChestSettings settings = core.getSettings(); + DisplayStats stats = wall.getDisplayStats(); + poseStack.translate(0, 0, LAYER); + renderDisplayedItem(poseStack, buffers, level, wall.getDisplayed(), light); + poseStack.translate(0, 0, LAYER); + if (settings.showCounts()) { + String count = IModHelpers.get().getGuiHelpers().quantityToScaledString(stats.count()); + ChestOverlayHelpers.renderText(poseStack, buffers, count, 0.5F, TEXT_Y0, TEXT_HEIGHT, COLOR_TEXT, light); } - } - if (settings.showUpgradeIndicators()) { - // Upgrades that affect the shown item, from the top right corner leftwards. - List indicators = new ArrayList<>(); - if (stats.locked()) { - indicators.add(ChestUpgrades.LOCK); + if (settings.showFillLevels()) { + renderFillBar(atlas.getSprite(BAR_TEXTURE), stats.getFillLevel(), poseStack, buffers, light, overlay); } - if (stats.voided()) { - indicators.add(ChestUpgrades.VOID); + if (settings.showUpgradeIndicators()) { + renderIndicators(atlas, stats, poseStack, buffers, light, overlay); } - if (stats.compressed()) { - indicators.add(ChestUpgrades.COMPRESSION); - } - float x = 1F - 1F / 16F - INDICATOR_SIZE / 2; - for (ChestUpgrade upgrade : indicators) { - ItemStack stack = new ItemStack(BuiltInRegistries.ITEM.get( - ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "upgrade_" + upgrade.getId().getPath()))); - ChestOverlayHelpers.renderItem(poseStack, buffers, level, stack, x, 1F - 1F / 16F - INDICATOR_SIZE / 2, INDICATOR_SIZE, light); - x -= INDICATOR_SIZE; + } + poseStack.popPose(); + } + + private void renderPanel(BlockEntityChestCore core, TextureAtlas atlas, PoseStack poseStack, MultiBufferSource buffers, + int light, int overlay) { + if (frameStyle != 2) { + ResourceLocation frame = frameStyle == 0 && core.getBlockState().getBlock() instanceof BlockChestCore block + ? ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "block/chest_wall_" + block.getMaterial().getName()) + : NEUTRAL_FRAME_TEXTURE; + ChestOverlayHelpers.renderSprite(poseStack, buffers, atlas.getSprite(frame), 0, 0, 1, 1, light, overlay); + poseStack.translate(0, 0, LAYER); + } + // The panel texture has a transparent rim, so the frame shows around it. + float inset = frameStyle == 2 ? PANEL_MIN : 0; + ChestOverlayHelpers.renderSprite(poseStack, buffers, atlas.getSprite(PANEL_TEXTURE), inset, inset, 1 - inset, 1 - inset, light, overlay); + } + + private void renderDisplayedItem(PoseStack poseStack, MultiBufferSource buffers, Level level, ItemStack stack, int light) { + poseStack.pushPose(); + poseStack.translate(0.5F, ITEM_Y, 0); + if (itemStyle == 2) { + // Like an item frame: blocks show their front face with some depth. + poseStack.scale(ITEM_SIZE * 1.6F, ITEM_SIZE * 1.6F, ITEM_SIZE * 0.4F); + Minecraft.getInstance().getItemRenderer().renderStatic(stack, ItemDisplayContext.FIXED, light, + OverlayTexture.NO_OVERLAY, poseStack, buffers, level, 0); + } else { + Matrix3f normal = new Matrix3f(poseStack.last().normal()); + // Flatten towards the face, so blocks look like their inventory icon without sticking out. + poseStack.scale(ITEM_SIZE, ITEM_SIZE, 0.001F); + if (itemStyle == 1) { + // Keep the normals of the unflattened icon, so its faces are shaded like a block in the world. + poseStack.last().normal().set(normal); } + Minecraft.getInstance().getItemRenderer().renderStatic(stack, ItemDisplayContext.GUI, light, + OverlayTexture.NO_OVERLAY, poseStack, buffers, level, 0); + } + poseStack.popPose(); + } + + private void renderFillBar(TextureAtlasSprite bar, float fill, PoseStack poseStack, MultiBufferSource buffers, int light, int overlay) { + ChestOverlayHelpers.renderSprite(poseStack, buffers, bar, BAR_X0, BAR_Y0, BAR_X1, BAR_Y1, light, overlay, COLOR_BAR_BACKGROUND); + if (fill > 0) { + poseStack.pushPose(); + poseStack.translate(0, 0, LAYER); + ChestOverlayHelpers.renderSprite(poseStack, buffers, bar, BAR_X0, BAR_Y0, BAR_X0 + (BAR_X1 - BAR_X0) * fill, BAR_Y1, + light, overlay, fill >= 1 ? COLOR_BAR_FULL : COLOR_BAR); + poseStack.popPose(); + } + } + + private void renderIndicators(TextureAtlas atlas, DisplayStats stats, PoseStack poseStack, MultiBufferSource buffers, + int light, int overlay) { + // Flat icons of the upgrades that affect the shown item, in the top right corner of the panel. + List indicators = new ArrayList<>(); + if (stats.locked()) { + indicators.add(ChestUpgrades.LOCK); + } + if (stats.voided()) { + indicators.add(ChestUpgrades.VOID); + } + if (stats.compressed()) { + indicators.add(ChestUpgrades.COMPRESSION); + } + float x1 = PANEL_MAX - 0.75F / 16F; + float y1 = PANEL_MAX - 0.75F / 16F; + for (ChestUpgrade upgrade : indicators) { + TextureAtlasSprite sprite = atlas.getSprite(ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, + "item/upgrade_" + upgrade.getId().getPath())); + ChestOverlayHelpers.renderSprite(poseStack, buffers, sprite, x1 - INDICATOR_SIZE, y1 - INDICATOR_SIZE, x1, y1, light, overlay); + x1 -= INDICATOR_SIZE; } } diff --git a/loader-common/src/main/resources/assets/colossalchests2/textures/block/display_panel.png b/loader-common/src/main/resources/assets/colossalchests2/textures/block/display_panel.png new file mode 100644 index 0000000000000000000000000000000000000000..47070abe8d26c2ddb0e264c743373cbdf106ba77 GIT binary patch literal 132 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`-kvUwAr*6y6BY;<7#SECaBy<| zRd@fqyZpUGWP*&LA?GBM*)t>zbs{&lG Date: Sun, 4 Oct 2026 18:09:26 +0000 Subject: [PATCH 3/9] Finish the Display wall look Frames use the chest's material, and items show like an inventory icon by default. A client config option shows blocks and other 3D items like an item frame instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RSz5r6eowhjNuANBjZE2v6 --- .../colossalchests2/GeneralConfig.java | 3 ++ .../client/render/DisplayWallOverlay.java | 44 ++++++------------- 2 files changed, 17 insertions(+), 30 deletions(-) diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/GeneralConfig.java b/loader-common/src/main/java/org/cyclops/colossalchests2/GeneralConfig.java index 7f13b45e..faf7d434 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/GeneralConfig.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/GeneralConfig.java @@ -45,6 +45,9 @@ public class GeneralConfig extends DummyConfigCommon { @ConfigurablePropertyCommon(category = "chest", comment = "If items that do not stack, such as tools, can be stored.", isCommandable = true, configLocation = ModConfigLocation.SERVER) public static boolean acceptNonStackables = true; + @ConfigurablePropertyCommon(category = "display", comment = "If Display walls show blocks and other 3D items like an item frame, front-on, instead of like an inventory icon.", configLocation = ModConfigLocation.CLIENT) + public static boolean displayItemFrameStyle = false; + public GeneralConfig(M mod) { super(mod, "general"); } diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java index 7fa23e4f..cac11ec2 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java @@ -4,6 +4,7 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.client.resources.model.BakedModel; import net.minecraft.client.renderer.texture.TextureAtlas; import net.minecraft.client.renderer.texture.TextureAtlasSprite; import net.minecraft.core.BlockPos; @@ -12,6 +13,7 @@ import net.minecraft.world.item.ItemDisplayContext; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.Level; +import org.cyclops.colossalchests2.GeneralConfig; import org.cyclops.colossalchests2.Reference; import org.cyclops.colossalchests2.block.BlockChestCore; import org.cyclops.colossalchests2.blockentity.BlockEntityChestCore; @@ -21,7 +23,6 @@ import org.cyclops.colossalchests2.upgrade.ChestUpgrade; import org.cyclops.colossalchests2.upgrade.ChestUpgrades; import org.cyclops.cyclopscore.helper.IModHelpers; -import org.joml.Matrix3f; import java.util.ArrayList; import java.util.List; @@ -35,12 +36,10 @@ public class DisplayWallOverlay implements IChestOverlay { private static final ResourceLocation PANEL_TEXTURE = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "block/display_panel"); private static final ResourceLocation BAR_TEXTURE = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "block/display_bar"); - private static final ResourceLocation NEUTRAL_FRAME_TEXTURE = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "block/chest_wall_iron"); private static final int COLOR_TEXT = 0xFFFFFF; private static final int COLOR_BAR_BACKGROUND = 0xFF101010; private static final int COLOR_BAR = 0xFF40C040; private static final int COLOR_BAR_FULL = 0xFFD04030; - private static final float PANEL_MIN = 1F / 16F; private static final float PANEL_MAX = 15F / 16F; private static final float BAR_X0 = 3F / 16F; private static final float BAR_X1 = 13F / 16F; @@ -53,15 +52,6 @@ public class DisplayWallOverlay implements IChestOverlay { private static final float INDICATOR_SIZE = 2.5F / 16F; private static final float LAYER = 0.0005F; - /** - * Temporary switches to compare looks in-game. 0: material frame, 1: neutral frame, 2: no frame. - */ - public static int frameStyle = 0; - /** - * 0: flat inventory icon, 1: inventory icon shaded by the world, 2: item frame style. - */ - public static int itemStyle = 1; - @Override public void render(BlockEntityChestCore core, BlockPos pos, Direction face, float partialTick, PoseStack poseStack, MultiBufferSource buffers, int light, int overlay) { @@ -91,36 +81,30 @@ public void render(BlockEntityChestCore core, BlockPos pos, Direction face, floa private void renderPanel(BlockEntityChestCore core, TextureAtlas atlas, PoseStack poseStack, MultiBufferSource buffers, int light, int overlay) { - if (frameStyle != 2) { - ResourceLocation frame = frameStyle == 0 && core.getBlockState().getBlock() instanceof BlockChestCore block - ? ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "block/chest_wall_" + block.getMaterial().getName()) - : NEUTRAL_FRAME_TEXTURE; + // A frame of the chest's material, so the panel looks built into it. + if (core.getBlockState().getBlock() instanceof BlockChestCore block) { + ResourceLocation frame = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "block/chest_wall_" + block.getMaterial().getName()); ChestOverlayHelpers.renderSprite(poseStack, buffers, atlas.getSprite(frame), 0, 0, 1, 1, light, overlay); poseStack.translate(0, 0, LAYER); } // The panel texture has a transparent rim, so the frame shows around it. - float inset = frameStyle == 2 ? PANEL_MIN : 0; - ChestOverlayHelpers.renderSprite(poseStack, buffers, atlas.getSprite(PANEL_TEXTURE), inset, inset, 1 - inset, 1 - inset, light, overlay); + ChestOverlayHelpers.renderSprite(poseStack, buffers, atlas.getSprite(PANEL_TEXTURE), 0, 0, 1, 1, light, overlay); } private void renderDisplayedItem(PoseStack poseStack, MultiBufferSource buffers, Level level, ItemStack stack, int light) { + BakedModel model = Minecraft.getInstance().getItemRenderer().getModel(stack, level, null, 0); poseStack.pushPose(); poseStack.translate(0.5F, ITEM_Y, 0); - if (itemStyle == 2) { - // Like an item frame: blocks show their front face with some depth. + if (GeneralConfig.displayItemFrameStyle && model.isGui3d()) { + // Like an item frame: blocks show their front face, with some depth. poseStack.scale(ITEM_SIZE * 1.6F, ITEM_SIZE * 1.6F, ITEM_SIZE * 0.4F); - Minecraft.getInstance().getItemRenderer().renderStatic(stack, ItemDisplayContext.FIXED, light, - OverlayTexture.NO_OVERLAY, poseStack, buffers, level, 0); + Minecraft.getInstance().getItemRenderer().render(stack, ItemDisplayContext.FIXED, false, poseStack, buffers, light, + OverlayTexture.NO_OVERLAY, model); } else { - Matrix3f normal = new Matrix3f(poseStack.last().normal()); - // Flatten towards the face, so blocks look like their inventory icon without sticking out. + // Like drawers: the inventory icon, flattened towards the face so blocks do not stick out. poseStack.scale(ITEM_SIZE, ITEM_SIZE, 0.001F); - if (itemStyle == 1) { - // Keep the normals of the unflattened icon, so its faces are shaded like a block in the world. - poseStack.last().normal().set(normal); - } - Minecraft.getInstance().getItemRenderer().renderStatic(stack, ItemDisplayContext.GUI, light, - OverlayTexture.NO_OVERLAY, poseStack, buffers, level, 0); + Minecraft.getInstance().getItemRenderer().render(stack, ItemDisplayContext.GUI, false, poseStack, buffers, light, + OverlayTexture.NO_OVERLAY, model); } poseStack.popPose(); } From 97fd1d8562753691912e67426cd0393eace9e577 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:12:47 +0000 Subject: [PATCH 4/9] Display wall: settings GUI, fixed empty-hand clicks and fast takes - Right-click with an empty hand opens a GUI with a ghost slot to choose the displayed item. Sneak-right-click with an empty hand does nothing. - Fix double right-click: empty-hand clicks were consumed by useItemOn, so they never reached the insert-all logic. - Fix fast sneak-left-clicks being merged: a take now needs the attack button to be released since the last one, tracked with a client tick hook per loader, instead of a 6 tick debounce. - Render the sides of the display plate, so it no longer floats in front of the chest. - Share the ghost slot menu logic between the Interface and Display menus. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RSz5r6eowhjNuANBjZE2v6 --- .../colossalchests2/RegistryEntries.java | 3 + .../block/BlockChestFunctionalWall.java | 7 +- .../block/DisplayWallInteractions.java | 61 +++++---- .../blockentity/BlockEntityChestWall.java | 68 +++++++++- .../client/gui/ContainerScreenDisplay.java | 50 +++++++ .../client/gui/ContainerScreenInterface.java | 2 +- .../client/render/ChestOverlayHelpers.java | 38 ++++++ .../client/render/DisplayWallOverlay.java | 6 +- .../gametest/GameTestsCommon.java | 60 ++++++++- .../inventory/ContainerDisplay.java | 43 ++++++ .../inventory/ContainerDisplayConfig.java | 23 ++++ ...nerDisplayConfigScreenFactoryProvider.java | 19 +++ .../inventory/ContainerGhostSettings.java | 124 ++++++++++++++++++ .../inventory/ContainerInterface.java | 103 +-------------- .../assets/colossalchests2/lang/en_us.json | 4 +- .../colossalchests2/ColossalChestsFabric.java | 2 + .../proxy/ClientProxyFabric.java | 9 ++ .../colossalchests2/ColossalChestsForge.java | 2 + .../proxy/ClientProxyForge.java | 11 ++ .../colossalchests2/ColossalChests.java | 2 + .../colossalchests2/proxy/ClientProxy.java | 11 ++ 21 files changed, 507 insertions(+), 141 deletions(-) create mode 100644 loader-common/src/main/java/org/cyclops/colossalchests2/client/gui/ContainerScreenDisplay.java create mode 100644 loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerDisplay.java create mode 100644 loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerDisplayConfig.java create mode 100644 loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerDisplayConfigScreenFactoryProvider.java create mode 100644 loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerGhostSettings.java diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/RegistryEntries.java b/loader-common/src/main/java/org/cyclops/colossalchests2/RegistryEntries.java index 59172791..11412f51 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/RegistryEntries.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/RegistryEntries.java @@ -11,6 +11,7 @@ import org.cyclops.colossalchests2.blockentity.BlockEntityChestWall; import org.cyclops.colossalchests2.inventory.ChestSettings; import org.cyclops.colossalchests2.inventory.ContainerChest; +import org.cyclops.colossalchests2.inventory.ContainerDisplay; import org.cyclops.colossalchests2.inventory.ContainerInterface; import org.cyclops.colossalchests2.storage.ChestStorage; import org.cyclops.cyclopscore.config.DeferredHolderCommon; @@ -29,6 +30,8 @@ public class RegistryEntries { public static final DeferredHolderCommon, MenuType> MENU_INTERFACE = DeferredHolderCommon.create(Registries.MENU, ResourceLocation.parse("colossalchests2:interface")); + public static final DeferredHolderCommon, MenuType> MENU_DISPLAY = DeferredHolderCommon.create(Registries.MENU, ResourceLocation.parse("colossalchests2:display")); + public static final DeferredHolderCommon, DataComponentType> COMPONENT_CHEST_SETTINGS = DeferredHolderCommon.create(Registries.DATA_COMPONENT_TYPE, ResourceLocation.parse("colossalchests2:chest_settings")); public static final DeferredHolderCommon, MenuType> MENU_CHEST = DeferredHolderCommon.create(Registries.MENU, ResourceLocation.parse("colossalchests2:chest")); diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/block/BlockChestFunctionalWall.java b/loader-common/src/main/java/org/cyclops/colossalchests2/block/BlockChestFunctionalWall.java index f2f0adab..bdf70f34 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/block/BlockChestFunctionalWall.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/block/BlockChestFunctionalWall.java @@ -55,7 +55,7 @@ public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { @Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) { - // Like other walls a click opens the chest, sneaking opens an interface's own settings. + // Like other walls a click opens the chest, sneaking opens an interface's own settings. A display has its own. if (type == WallType.INTERFACE && player.isSecondaryUseActive()) { if (player instanceof ServerPlayer serverPlayer && level.getBlockEntity(pos) instanceof BlockEntityChestWall wall) { IModHelpers.get().getMinecraftHelpers().openMenu(serverPlayer, wall, buf -> buf.writeBlockPos(pos)); @@ -63,10 +63,7 @@ protected InteractionResult useWithoutItem(BlockState state, Level level, BlockP return InteractionResult.sidedSuccess(level.isClientSide); } if (type == WallType.DISPLAY && level.getBlockEntity(pos) instanceof BlockEntityChestWall wall) { - InteractionResult result = DisplayWallInteractions.useWithoutItem(player, wall); - if (result != null) { - return result; - } + return DisplayWallInteractions.useWithoutItem(player, wall); } return super.useWithoutItem(state, level, pos, player, hit); } diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java b/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java index e225970b..0dcc062f 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java @@ -1,6 +1,7 @@ package org.cyclops.colossalchests2.block; import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionResult; import net.minecraft.world.ItemInteractionResult; import net.minecraft.world.entity.player.Player; @@ -12,23 +13,33 @@ import org.cyclops.colossalchests2.blockentity.BlockEntityChestWall; import org.cyclops.colossalchests2.network.packet.ServerboundDisplayTakePacket; import org.cyclops.colossalchests2.storage.ChestStorage; +import org.cyclops.cyclopscore.helper.IModHelpers; import org.jetbrains.annotations.Nullable; import java.util.Optional; /** * Drawer style clicks on Display walls: left-click takes, right-click inserts, a double right-click inserts all. + * An empty right-click opens the settings. * @author rubensworks */ public final class DisplayWallInteractions { - private static final int HELD_CLICK_TICKS = 6; - - private static long lastAttackTick = -HELD_CLICK_TICKS - 1; + private static boolean attackReleased = true; private DisplayWallInteractions() { } + /** + * Called on the client at the end of each tick. + * @param attackDown If the attack button is held. + */ + public static void onClientTick(boolean attackDown) { + if (!attackDown) { + attackReleased = true; + } + } + public static boolean isDisplayWall(BlockState state) { return state.getBlock() instanceof BlockChestFunctionalWall wall && wall.getType() == WallType.DISPLAY; } @@ -45,7 +56,7 @@ public static boolean isProtectedFromMining(BlockState state, Player player) { /** * Handle a left-click on a block, called on both sides by the loaders before mining starts. - * The client asks the server to take items, once per click and not while the button is held. + * The client asks the server to take items, once per click. * @param player The player. * @param level The level. * @param pos The clicked position. @@ -55,14 +66,10 @@ public static boolean onAttack(Player player, Level level, BlockPos pos) { if (!isProtectedFromMining(level.getBlockState(pos), player)) { return false; } - if (level.isClientSide) { - long tick = level.getGameTime(); - // A held click repeats every tick, or every 6 ticks in creative. A new click comes after a longer gap. - // Game time can also restart in another world. - if (tick - lastAttackTick > HELD_CLICK_TICKS || tick < lastAttackTick) { - ColossalChestsInstance.MOD.getPacketHandlerCommon().sendToServer(new ServerboundDisplayTakePacket(pos, player.isShiftKeyDown())); - } - lastAttackTick = tick; + // Holding the button repeats this, only a new click takes again. + if (level.isClientSide && attackReleased) { + attackReleased = false; + ColossalChestsInstance.MOD.getPacketHandlerCommon().sendToServer(new ServerboundDisplayTakePacket(pos, player.isShiftKeyDown())); } return true; } @@ -102,7 +109,8 @@ public static long take(Player player, BlockEntityChestWall wall, boolean single @Nullable public static ItemInteractionResult useItemOn(ItemStack stack, Player player, BlockEntityChestWall wall) { Optional storage = getStorage(wall); - if (storage.isEmpty()) { + // An empty hand is handled by useWithoutItem. + if (stack.isEmpty() || storage.isEmpty()) { return null; } if (!player.level().isClientSide) { @@ -122,27 +130,24 @@ public static ItemInteractionResult useItemOn(ItemStack stack, Player player, Bl } /** - * A right-click with an empty hand: sneaking clears the display, a double click inserts all of the shown type. - * @return The result, or null to fall back to opening the chest. + * A right-click with an empty hand: right after an insert it inserts all of the shown type, otherwise it opens the + * settings. Sneaking does nothing. */ - @Nullable public static InteractionResult useWithoutItem(Player player, BlockEntityChestWall wall) { - if (getStorage(wall).isEmpty()) { - return null; - } if (player.isSecondaryUseActive()) { - if (!player.level().isClientSide) { - wall.setDisplayed(ItemStack.EMPTY); - } - return InteractionResult.sidedSuccess(player.level().isClientSide); + return InteractionResult.PASS; } // Only the server knows about the previous click, the client lets it decide. - if (!player.level().isClientSide && !wall.getDisplayed().isEmpty() && wall.recordInsertClick(player, player.level().getGameTime())) { - insertAll(player, getStorage(wall).get(), wall.getDisplayed()); - wall.updateDisplayStats(false); - return InteractionResult.CONSUME; + if (player instanceof ServerPlayer serverPlayer) { + Optional storage = getStorage(wall); + if (storage.isPresent() && !wall.getDisplayed().isEmpty() && wall.recordInsertClick(player, player.level().getGameTime())) { + insertAll(player, storage.get(), wall.getDisplayed()); + wall.updateDisplayStats(false); + } else { + IModHelpers.get().getMinecraftHelpers().openMenu(serverPlayer, wall, buf -> buf.writeBlockPos(wall.getBlockPos())); + } } - return null; + return InteractionResult.sidedSuccess(player.level().isClientSide); } /** diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWall.java b/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWall.java index 773c0544..ae28a2a6 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWall.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWall.java @@ -28,6 +28,7 @@ import org.cyclops.colossalchests2.block.WallType; import org.cyclops.colossalchests2.capability.ItemHandlerLogic; import org.cyclops.colossalchests2.capability.WallAccess; +import org.cyclops.colossalchests2.inventory.ContainerDisplay; import org.cyclops.colossalchests2.inventory.ContainerInterface; import org.cyclops.colossalchests2.multiblock.ChestCoreIndex; import org.cyclops.colossalchests2.storage.DisplayStats; @@ -69,6 +70,7 @@ public int getMaxStackSize() { private ItemStack displayed = ItemStack.EMPTY; private DisplayStats displayStats = DisplayStats.EMPTY; private final Map lastInserts = Maps.newHashMap(); + private final Container displayedContainer = new DisplayedContainer(); public BlockEntityChestWall(BlockEntityType type, BlockPos pos, BlockState state) { super(type, pos, state); @@ -149,6 +151,13 @@ public void setDisplayed(ItemStack type) { updateDisplayStats(true); } + /** + * @return A single slot container of the type a Display wall shows, for its menu. + */ + public Container getDisplayedContainer() { + return displayedContainer; + } + /** * @return What a Display wall shows about its type, as last synced on clients. */ @@ -256,6 +265,63 @@ public Component getDisplayName() { @Nullable @Override public AbstractContainerMenu createMenu(int id, Inventory inventory, Player player) { - return new ContainerInterface(id, inventory, this); + return getWallType() == WallType.DISPLAY ? new ContainerDisplay(id, inventory, this) : new ContainerInterface(id, inventory, this); + } + + private class DisplayedContainer implements Container { + + @Override + public int getContainerSize() { + return 1; + } + + @Override + public boolean isEmpty() { + return displayed.isEmpty(); + } + + @Override + public ItemStack getItem(int slot) { + return slot == 0 ? displayed : ItemStack.EMPTY; + } + + @Override + public ItemStack removeItem(int slot, int amount) { + return removeItemNoUpdate(slot); + } + + @Override + public ItemStack removeItemNoUpdate(int slot) { + ItemStack previous = getItem(slot); + setItem(slot, ItemStack.EMPTY); + return previous; + } + + @Override + public void setItem(int slot, ItemStack stack) { + if (slot == 0) { + setDisplayed(stack); + } + } + + @Override + public int getMaxStackSize() { + return 1; + } + + @Override + public void setChanged() { + BlockEntityChestWall.this.setChanged(); + } + + @Override + public boolean stillValid(Player player) { + return Container.stillValidBlockEntity(BlockEntityChestWall.this, player); + } + + @Override + public void clearContent() { + setDisplayed(ItemStack.EMPTY); + } } } diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/client/gui/ContainerScreenDisplay.java b/loader-common/src/main/java/org/cyclops/colossalchests2/client/gui/ContainerScreenDisplay.java new file mode 100644 index 00000000..8e4742bd --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/client/gui/ContainerScreenDisplay.java @@ -0,0 +1,50 @@ +package org.cyclops.colossalchests2.client.gui; + +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.Slot; +import org.cyclops.colossalchests2.inventory.ContainerDisplay; + +import java.util.List; + +/** + * Settings of a Display wall. + * @author rubensworks + */ +public class ContainerScreenDisplay extends AbstractContainerScreen { + + public ContainerScreenDisplay(ContainerDisplay menu, Inventory inventory, Component title) { + super(menu, inventory, title); + this.imageWidth = ContainerDisplay.WIDTH; + this.imageHeight = ContainerDisplay.HEIGHT; + this.inventoryLabelY = ContainerDisplay.INVENTORY_Y - 11; + } + + @Override + protected void renderBg(GuiGraphics guiGraphics, float partialTick, int mouseX, int mouseY) { + GuiPanels.drawPanel(guiGraphics, leftPos, topPos, imageWidth, imageHeight); + for (Slot slot : menu.slots) { + GuiPanels.drawSlot(guiGraphics, leftPos + slot.x, topPos + slot.y); + } + } + + @Override + public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + super.render(guiGraphics, mouseX, mouseY, partialTick); + renderTooltip(guiGraphics, mouseX, mouseY); + } + + @Override + protected void renderTooltip(GuiGraphics guiGraphics, int mouseX, int mouseY) { + if (menu.getCarried().isEmpty() && hoveredSlot != null && menu.isGhostSlot(hoveredSlot.index) && !hoveredSlot.hasItem()) { + String key = "gui.colossalchests2.wall.displayed"; + guiGraphics.renderComponentTooltip(font, List.of(Component.translatable(key), + Component.translatable(key + ".info").withStyle(ChatFormatting.GRAY)), mouseX, mouseY); + return; + } + super.renderTooltip(guiGraphics, mouseX, mouseY); + } +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/client/gui/ContainerScreenInterface.java b/loader-common/src/main/java/org/cyclops/colossalchests2/client/gui/ContainerScreenInterface.java index 4b4e1b5a..0fc4218a 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/client/gui/ContainerScreenInterface.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/client/gui/ContainerScreenInterface.java @@ -71,7 +71,7 @@ public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partia @Override protected void renderTooltip(GuiGraphics guiGraphics, int mouseX, int mouseY) { - if (menu.getCarried().isEmpty() && hoveredSlot != null && ContainerInterface.isSettingsSlot(hoveredSlot.index) && !hoveredSlot.hasItem()) { + if (menu.getCarried().isEmpty() && hoveredSlot != null && menu.isGhostSlot(hoveredSlot.index) && !hoveredSlot.hasItem()) { String key = "gui.colossalchests2.wall.filter"; guiGraphics.renderComponentTooltip(font, List.of(Component.translatable(key), Component.translatable(key + ".info").withStyle(ChatFormatting.GRAY)), mouseX, mouseY); diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/ChestOverlayHelpers.java b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/ChestOverlayHelpers.java index 53608fa0..d4bdbc5b 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/ChestOverlayHelpers.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/ChestOverlayHelpers.java @@ -68,6 +68,44 @@ private static void vertex(VertexConsumer buffer, PoseStack.Pose pose, float x, .setNormal(pose, 0, 0, 1); } + /** + * Draw the four sides of the face's unit square back to the chest surface, so a full face overlay looks like a + * plate on the chest instead of floating in front of it. + * @param poseStack The pose stack. + * @param buffers The buffers. + * @param sprite A sprite on the block atlas, whose outer rows texture the sides. + * @param depth How far the face lies in front of the chest surface. + * @param light The packed light. + * @param overlay The packed overlay. + */ + public static void renderSides(PoseStack poseStack, MultiBufferSource buffers, TextureAtlasSprite sprite, float depth, + int light, int overlay) { + VertexConsumer buffer = buffers.getBuffer(RenderType.entityCutout(TextureAtlas.LOCATION_BLOCKS)); + PoseStack.Pose pose = poseStack.last(); + float u0 = sprite.getU0(); + float u1 = sprite.getU1(); + float v0 = sprite.getV0(); + float v1 = v0 + (sprite.getV1() - v0) * Math.min(1F, depth); + float z = -depth; + // Bottom, top, left, right, each counter-clockwise seen from outside. + quad(buffer, pose, 0, -1, 0, light, overlay, 0, 0, z, u0, v1, 1, 0, z, u1, v1, 1, 0, 0, u1, v0, 0, 0, 0, u0, v0); + quad(buffer, pose, 0, 1, 0, light, overlay, 0, 1, 0, u0, v0, 1, 1, 0, u1, v0, 1, 1, z, u1, v1, 0, 1, z, u0, v1); + quad(buffer, pose, -1, 0, 0, light, overlay, 0, 0, 0, u0, v0, 0, 1, 0, u1, v0, 0, 1, z, u1, v1, 0, 0, z, u0, v1); + quad(buffer, pose, 1, 0, 0, light, overlay, 1, 0, z, u0, v1, 1, 1, z, u1, v1, 1, 1, 0, u1, v0, 1, 0, 0, u0, v0); + } + + private static void quad(VertexConsumer buffer, PoseStack.Pose pose, float nx, float ny, float nz, int light, int overlay, + float... vertices) { + for (int i = 0; i < 20; i += 5) { + buffer.addVertex(pose, vertices[i], vertices[i + 1], vertices[i + 2]) + .setColor(0xFFFFFFFF) + .setUv(vertices[i + 3], vertices[i + 4]) + .setOverlay(overlay) + .setLight(light) + .setNormal(pose, nx, ny, nz); + } + } + /** * Draw an item flat on the face, like in a GUI slot. * @param poseStack The pose stack. diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java index cac11ec2..1b399982 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/client/render/DisplayWallOverlay.java @@ -4,9 +4,9 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.client.renderer.texture.OverlayTexture; -import net.minecraft.client.resources.model.BakedModel; import net.minecraft.client.renderer.texture.TextureAtlas; import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.resources.model.BakedModel; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.resources.ResourceLocation; @@ -84,7 +84,9 @@ private void renderPanel(BlockEntityChestCore core, TextureAtlas atlas, PoseStac // A frame of the chest's material, so the panel looks built into it. if (core.getBlockState().getBlock() instanceof BlockChestCore block) { ResourceLocation frame = ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "block/chest_wall_" + block.getMaterial().getName()); - ChestOverlayHelpers.renderSprite(poseStack, buffers, atlas.getSprite(frame), 0, 0, 1, 1, light, overlay); + TextureAtlasSprite frameSprite = atlas.getSprite(frame); + ChestOverlayHelpers.renderSprite(poseStack, buffers, frameSprite, 0, 0, 1, 1, light, overlay); + ChestOverlayHelpers.renderSides(poseStack, buffers, frameSprite, RenderChestCore.OVERLAY_OFFSET, light, overlay); poseStack.translate(0, 0, LAYER); } // The panel texture has a transparent rim, so the frame shows around it. diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/gametest/GameTestsCommon.java b/loader-common/src/main/java/org/cyclops/colossalchests2/gametest/GameTestsCommon.java index f96a3794..3532cdb6 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/gametest/GameTestsCommon.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/gametest/GameTestsCommon.java @@ -16,6 +16,7 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; import net.minecraft.world.ItemInteractionResult; import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.entity.player.Player; @@ -55,6 +56,7 @@ import org.cyclops.colossalchests2.inventory.ChestSearch; import org.cyclops.colossalchests2.inventory.ChestSettings; import org.cyclops.colossalchests2.inventory.ContainerChest; +import org.cyclops.colossalchests2.inventory.ContainerDisplay; import org.cyclops.colossalchests2.inventory.ContainerInterface; import org.cyclops.colossalchests2.multiblock.ChestCoreIndex; import org.cyclops.colossalchests2.multiblock.ChestStructure; @@ -1749,6 +1751,20 @@ private static ItemInteractionResult useWithItem(GameTestHelper helper, ServerPl new BlockHitResult(Vec3.atCenterOf(absolute), Direction.UP, absolute, false)); } + /** + * Right-click with the held item like the server does: an item use that passes falls back to an empty hand use. + */ + private static InteractionResult click(GameTestHelper helper, ServerPlayer player, BlockPos pos) { + BlockPos absolute = helper.absolutePos(pos); + BlockState state = helper.getLevel().getBlockState(absolute); + BlockHitResult hit = new BlockHitResult(Vec3.atCenterOf(absolute), Direction.UP, absolute, false); + ItemInteractionResult result = state.useItemOn(player.getMainHandItem(), helper.getLevel(), player, InteractionHand.MAIN_HAND, hit); + if (result == ItemInteractionResult.PASS_TO_DEFAULT_BLOCK_INTERACTION) { + return state.useWithoutItem(helper.getLevel(), player, hit); + } + return result.result(); + } + private static int countInInventory(ServerPlayer player, Item item) { int count = 0; for (ItemStack stack : player.getInventory().items) { @@ -1784,8 +1800,7 @@ public void testDisplayWallInsertsAndTakes(GameTestHelper helper) { player.getInventory().setItem(3, STONE.copyWithCount(20)); player.getInventory().setItem(4, STONE.copyWithCount(30)); useWithItem(helper, player, display, STONE.copyWithCount(1)); - player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); - use(helper, player, display); + helper.assertValueEqual(click(helper, player, display), InteractionResult.CONSUME, "double click"); helper.assertValueEqual(countInInventory(player, Items.STONE), 0, "stone left in the inventory"); helper.assertValueEqual(DisplayStats.of(storage, STONE).count(), 61L, "stored stone after inserting all"); // Left-clicks take a stack or one, through the packet the client sends. @@ -1797,10 +1812,11 @@ public void testDisplayWallInsertsAndTakes(GameTestHelper helper) { roundTrip(helper, new ServerboundDisplayTakePacket(helper.absolutePos(display), true), ServerboundDisplayTakePacket.CODEC) .actionServer(helper.getLevel(), player); helper.assertValueEqual(countInInventory(player, Items.STONE), 1, "single stone taken"); - // Sneaking with an empty hand clears the display. + // Sneaking with an empty hand does nothing. + player.getInventory().clearContent(); player.setShiftKeyDown(true); - use(helper, player, display); - helper.assertTrue(wall.getDisplayed().isEmpty(), "Expected the display to be cleared"); + helper.assertValueEqual(click(helper, player, display), InteractionResult.PASS, "sneaking click"); + helper.assertTrue(wall.getDisplayed().is(Items.STONE), "Expected stone to stay shown"); }) .thenSucceed(); } @@ -1835,6 +1851,40 @@ public void testDisplayWallSyncsAndHasNoItemAccess(GameTestHelper helper) { .thenSucceed(); } + @GameTest(template = TEMPLATE_EMPTY) + public void testDisplayWallSettings(GameTestHelper helper) { + BlockPos corePos = buildChest(helper, MIN_A, 3, ChestMaterial.WOOD); + BlockPos display = placeWall(helper, MIN_A.offset(1, 2, 1), WallType.DISPLAY); + helper.startSequence() + .thenWaitUntil(() -> assertFormed(helper, corePos, MIN_A, 3)) + .thenExecute(() -> { + BlockEntityChestWall wall = getWall(helper, display); + ServerPlayer player = makeViewer(helper); + getCore(helper, corePos).getStorage().insert(STONE, 5, false); + wall.setDisplayed(STONE); + player.getInventory().setItem(3, STONE.copyWithCount(20)); + // The settings choose the shown item through a ghost slot. Created directly, like openChest. + ContainerDisplay menu = new ContainerDisplay(102, player.getInventory(), wall); + player.containerMenu = menu; + helper.assertTrue(menu.getSlot(0).getItem().is(Items.STONE), "Expected the shown item in the slot"); + menu.setCarried(new ItemStack(Items.DIRT, 5)); + menu.clicked(0, 0, ClickType.PICKUP, player); + helper.assertValueEqual(menu.getCarried().getCount(), 5, "cursor kept"); + helper.assertTrue(wall.getDisplayed().is(Items.DIRT), "Expected dirt to be shown"); + menu.setCarried(ItemStack.EMPTY); + menu.clicked(0, 0, ClickType.PICKUP, player); + helper.assertTrue(wall.getDisplayed().isEmpty(), "Expected the display to be cleared"); + // Shift-clicking an inventory item shows it, and leaves it in the inventory. + int stoneSlot = menu.slots.indexOf(menu.slots.stream() + .filter(slot -> slot.container == player.getInventory() && slot.getContainerSlot() == 3).findFirst().orElseThrow()); + menu.quickMoveStack(player, stoneSlot); + helper.assertTrue(wall.getDisplayed().is(Items.STONE), "Expected stone to be shown"); + helper.assertValueEqual(countInInventory(player, Items.STONE), 20, "stone kept after shift-click"); + helper.assertValueEqual(wall.getDisplayStats().count(), 5L, "shown count"); + }) + .thenSucceed(); + } + @GameTest(template = TEMPLATE_EMPTY) public void testDisplayWallOnlyMinedWithAPickaxe(GameTestHelper helper) { BlockPos corePos = buildChest(helper, MIN_A, 3, ChestMaterial.WOOD); diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerDisplay.java b/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerDisplay.java new file mode 100644 index 00000000..4207333b --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerDisplay.java @@ -0,0 +1,43 @@ +package org.cyclops.colossalchests2.inventory; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import org.cyclops.colossalchests2.RegistryEntries; +import org.cyclops.colossalchests2.blockentity.BlockEntityChestWall; +import org.jetbrains.annotations.Nullable; + +/** + * The settings of a Display wall: the item it shows. + * @author rubensworks + */ +public class ContainerDisplay extends ContainerGhostSettings { + + public static final int WIDTH = 176; + public static final int HEIGHT = 136; + public static final int DISPLAYED_X = 80; + public static final int DISPLAYED_Y = 20; + public static final int INVENTORY_Y = 54; + + /** + * Client-side constructor. + */ + public ContainerDisplay(int id, Inventory inventory, FriendlyByteBuf data) { + this(id, inventory, new SimpleContainer(1), null); + data.readBlockPos(); + } + + /** + * Server-side constructor. + */ + public ContainerDisplay(int id, Inventory inventory, BlockEntityChestWall wall) { + this(id, inventory, wall.getDisplayedContainer(), wall); + } + + private ContainerDisplay(int id, Inventory inventory, Container displayed, @Nullable BlockEntityChestWall wall) { + super(RegistryEntries.MENU_DISPLAY.value(), id, wall); + addGhostSlot(displayed, 0, DISPLAYED_X, DISPLAYED_Y); + addPlayerInventory(inventory, INVENTORY_Y); + } +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerDisplayConfig.java b/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerDisplayConfig.java new file mode 100644 index 00000000..b179af5b --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerDisplayConfig.java @@ -0,0 +1,23 @@ +package org.cyclops.colossalchests2.inventory; + +import net.minecraft.world.flag.FeatureFlags; +import org.cyclops.cyclopscore.config.extendedconfig.GuiConfigCommon; +import org.cyclops.cyclopscore.config.extendedconfig.GuiConfigScreenFactoryProvider; +import org.cyclops.cyclopscore.init.IModBase; +import org.cyclops.cyclopscore.inventory.container.ContainerTypeDataCommon; + +/** + * Config for {@link ContainerDisplay}. + * @author rubensworks + */ +public class ContainerDisplayConfig extends GuiConfigCommon { + + public ContainerDisplayConfig(M mod) { + super(mod, "display", eConfig -> new ContainerTypeDataCommon<>(ContainerDisplay::new, FeatureFlags.VANILLA_SET)); + } + + @Override + public GuiConfigScreenFactoryProvider getScreenFactoryProvider() { + return new ContainerDisplayConfigScreenFactoryProvider(); + } +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerDisplayConfigScreenFactoryProvider.java b/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerDisplayConfigScreenFactoryProvider.java new file mode 100644 index 00000000..3cd23403 --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerDisplayConfigScreenFactoryProvider.java @@ -0,0 +1,19 @@ +package org.cyclops.colossalchests2.inventory; + +import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.MenuAccess; +import org.cyclops.colossalchests2.client.gui.ContainerScreenDisplay; +import org.cyclops.cyclopscore.client.gui.ScreenFactorySafe; +import org.cyclops.cyclopscore.config.extendedconfig.GuiConfigScreenFactoryProvider; + +/** + * Only loaded on the client. + * @author rubensworks + */ +public class ContainerDisplayConfigScreenFactoryProvider extends GuiConfigScreenFactoryProvider { + @Override + public > MenuScreens.ScreenConstructor getScreenFactory() { + return new ScreenFactorySafe<>(ContainerScreenDisplay::new); + } +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerGhostSettings.java b/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerGhostSettings.java new file mode 100644 index 00000000..fcfc883f --- /dev/null +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerGhostSettings.java @@ -0,0 +1,124 @@ +package org.cyclops.colossalchests2.inventory; + +import net.minecraft.world.Container; +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.ClickType; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.cyclops.colossalchests2.blockentity.BlockEntityChestWall; +import org.jetbrains.annotations.Nullable; + +/** + * Settings of a wall in ghost slots, followed by the player inventory. + * Ghost slots hold copies of what is clicked into them: the cursor is never taken. + * @author rubensworks + */ +public abstract class ContainerGhostSettings extends AbstractContainerMenu { + + @Nullable + protected final BlockEntityChestWall wall; + private int ghostSlots; + + protected ContainerGhostSettings(@Nullable MenuType type, int id, @Nullable BlockEntityChestWall wall) { + super(type, id); + this.wall = wall; + } + + protected void addGhostSlot(Container container, int slot, int x, int y) { + addSlot(new GhostSlot(container, slot, x, y)); + ghostSlots++; + } + + protected void addPlayerInventory(Inventory inventory, int y) { + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column + row * 9 + 9, 8 + column * 18, y + row * 18)); + } + } + for (int column = 0; column < 9; column++) { + addSlot(new Slot(inventory, column, 8 + column * 18, y + 58)); + } + } + + /** + * @param slot A slot index. + * @return If it is one of the ghost slots. + */ + public boolean isGhostSlot(int slot) { + return slot >= 0 && slot < ghostSlots; + } + + @Override + public void clicked(int slotId, int button, ClickType clickType, Player player) { + if (isGhostSlot(slotId)) { + // Clicking copies one of the cursor in, or clears with an empty cursor. + if (clickType == ClickType.PICKUP || clickType == ClickType.QUICK_MOVE) { + ItemStack carried = getCarried(); + getSlot(slotId).set(clickType == ClickType.PICKUP && !carried.isEmpty() ? carried.copyWithCount(1) : ItemStack.EMPTY); + } + return; + } + super.clicked(slotId, button, clickType, player); + } + + @Override + public ItemStack quickMoveStack(Player player, int index) { + if (isGhostSlot(index)) { + return ItemStack.EMPTY; + } + // Shift-clicking an item copies it into the first free ghost slot, or the only one, and leaves it where it is. + ItemStack stack = getSlot(index).getItem(); + if (!stack.isEmpty()) { + int free = -1; + for (int i = 0; i < ghostSlots; i++) { + ItemStack entry = getSlot(i).getItem(); + if (ItemStack.isSameItemSameComponents(entry, stack)) { + return ItemStack.EMPTY; + } + if (entry.isEmpty() && free < 0) { + free = i; + } + } + if (free < 0 && ghostSlots == 1) { + free = 0; + } + if (free >= 0) { + getSlot(free).set(stack.copyWithCount(1)); + } + } + return ItemStack.EMPTY; + } + + @Override + public boolean canDragTo(Slot slot) { + return !(slot instanceof GhostSlot); + } + + @Override + public boolean stillValid(Player player) { + return wall == null || (!wall.isRemoved() && Container.stillValidBlockEntity(wall, player)); + } + + /** + * A settings slot, only changed through {@link #clicked(int, int, ClickType, Player)}. + */ + public static class GhostSlot extends Slot { + + public GhostSlot(Container container, int slot, int x, int y) { + super(container, slot, x, y); + } + + @Override + public boolean mayPlace(ItemStack stack) { + return false; + } + + @Override + public boolean mayPickup(Player player) { + return false; + } + } +} diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerInterface.java b/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerInterface.java index f208b715..a6e4cf8c 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerInterface.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/inventory/ContainerInterface.java @@ -6,11 +6,7 @@ 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.ClickType; import net.minecraft.world.inventory.DataSlot; -import net.minecraft.world.inventory.Slot; -import net.minecraft.world.item.ItemStack; import org.cyclops.colossalchests2.RegistryEntries; import org.cyclops.colossalchests2.blockentity.BlockEntityChestWall; import org.cyclops.colossalchests2.capability.WallAccess; @@ -18,10 +14,9 @@ /** * The settings of an Interface: filter slots and direction. - * Filter slots hold copies of what is clicked into them, like ghost slots. * @author rubensworks */ -public class ContainerInterface extends AbstractContainerMenu { +public class ContainerInterface extends ContainerGhostSettings { public static final int WIDTH = 176; public static final int HEIGHT = 154; @@ -30,18 +25,14 @@ public class ContainerInterface extends AbstractContainerMenu { public static final int INVENTORY_Y = 72; public static final int BUTTON_MODE = 0; - private static final int SETTINGS_SLOTS = BlockEntityChestWall.FILTER_SLOTS; - private final BlockPos pos; - @Nullable - private final BlockEntityChestWall wall; private final DataSlot mode; /** * Client-side constructor. */ public ContainerInterface(int id, Inventory inventory, FriendlyByteBuf data) { - this(id, inventory, data.readBlockPos(), new SimpleContainer(SETTINGS_SLOTS), null); + this(id, inventory, data.readBlockPos(), new SimpleContainer(BlockEntityChestWall.FILTER_SLOTS), null); } /** @@ -52,20 +43,12 @@ public ContainerInterface(int id, Inventory inventory, BlockEntityChestWall wall } private ContainerInterface(int id, Inventory inventory, BlockPos pos, Container settings, @Nullable BlockEntityChestWall wall) { - super(RegistryEntries.MENU_INTERFACE.value(), id); + super(RegistryEntries.MENU_INTERFACE.value(), id, wall); this.pos = pos; - this.wall = wall; for (int i = 0; i < BlockEntityChestWall.FILTER_SLOTS; i++) { - addSlot(new GhostSlot(settings, i, 8 + i * 18, FILTER_Y)); - } - for (int row = 0; row < 3; row++) { - for (int column = 0; column < 9; column++) { - addSlot(new Slot(inventory, column + row * 9 + 9, 8 + column * 18, INVENTORY_Y + row * 18)); - } - } - for (int column = 0; column < 9; column++) { - addSlot(new Slot(inventory, column, 8 + column * 18, INVENTORY_Y + 58)); + addGhostSlot(settings, i, 8 + i * 18, FILTER_Y); } + addPlayerInventory(inventory, INVENTORY_Y); this.mode = wall == null ? DataSlot.standalone() : new DataSlot() { @Override public int get() { @@ -89,14 +72,6 @@ public WallAccess.Mode getMode() { return modes[Math.floorMod(mode.get(), modes.length)]; } - /** - * @param slot A slot index. - * @return If it is one of the filter slots. - */ - public static boolean isSettingsSlot(int slot) { - return slot >= 0 && slot < SETTINGS_SLOTS; - } - @Override public boolean clickMenuButton(Player player, int id) { if (id == BUTTON_MODE && wall != null) { @@ -105,72 +80,4 @@ public boolean clickMenuButton(Player player, int id) { } return false; } - - @Override - public void clicked(int slotId, int button, ClickType clickType, Player player) { - if (isSettingsSlot(slotId)) { - // Ghost slots: the cursor is never taken, clicking copies one of it in, or clears with an empty cursor. - if (clickType == ClickType.PICKUP || clickType == ClickType.QUICK_MOVE) { - ItemStack carried = getCarried(); - getSlot(slotId).set(clickType == ClickType.PICKUP && !carried.isEmpty() ? carried.copyWithCount(1) : ItemStack.EMPTY); - } - return; - } - super.clicked(slotId, button, clickType, player); - } - - @Override - public ItemStack quickMoveStack(Player player, int index) { - if (isSettingsSlot(index)) { - return ItemStack.EMPTY; - } - // Shift-clicking an item adds it to the filter, the item stays where it is. - ItemStack stack = getSlot(index).getItem(); - if (!stack.isEmpty()) { - int free = -1; - for (int i = 0; i < SETTINGS_SLOTS; i++) { - ItemStack entry = getSlot(i).getItem(); - if (ItemStack.isSameItemSameComponents(entry, stack)) { - return ItemStack.EMPTY; - } - if (entry.isEmpty() && free < 0) { - free = i; - } - } - if (free >= 0) { - getSlot(free).set(stack.copyWithCount(1)); - } - } - return ItemStack.EMPTY; - } - - @Override - public boolean canDragTo(Slot slot) { - return !(slot instanceof GhostSlot); - } - - @Override - public boolean stillValid(Player player) { - return wall == null || (!wall.isRemoved() && Container.stillValidBlockEntity(wall, player)); - } - - /** - * A filter slot, only changed through {@link #clicked(int, int, ClickType, Player)}. - */ - public static class GhostSlot extends Slot { - - public GhostSlot(Container container, int slot, int x, int y) { - super(container, slot, x, y); - } - - @Override - public boolean mayPlace(ItemStack stack) { - return false; - } - - @Override - public boolean mayPickup(Player player) { - return false; - } - } } diff --git a/loader-common/src/main/resources/assets/colossalchests2/lang/en_us.json b/loader-common/src/main/resources/assets/colossalchests2/lang/en_us.json index a03359a2..1f1d2445 100644 --- a/loader-common/src/main/resources/assets/colossalchests2/lang/en_us.json +++ b/loader-common/src/main/resources/assets/colossalchests2/lang/en_us.json @@ -12,7 +12,7 @@ "block.colossalchests2.chest_wall_void": "Void Chest Interface", "block.colossalchests2.chest_wall_void.info": "An interface that destroys overflowing items.", "block.colossalchests2.chest_wall_display": "Chest Display", - "block.colossalchests2.chest_wall_display.info": "Shows an item and its count. Left-click takes, right-click inserts, double right-click inserts all. Sneak-right-click with empty hand to clear. Only a pickaxe breaks it.", + "block.colossalchests2.chest_wall_display.info": "Shows an item and its count. Left-click takes a stack, sneak-left-click takes one, right-click inserts, double right-click inserts all. Right-click with empty hand to choose the item. Only a pickaxe breaks it.", "block.colossalchests2.chest_wall_gold": "Golden Chest Wall", "block.colossalchests2.chest_core_gold": "Golden Chest Core", "block.colossalchests2.chest_wall_diamond": "Diamond Chest Wall", @@ -67,6 +67,8 @@ "gui.colossalchests2.lock_all": "Lock all", "gui.colossalchests2.wall.filter": "Filter", "gui.colossalchests2.wall.filter.info": "Click with item to configure.", + "gui.colossalchests2.wall.displayed": "Displayed item", + "gui.colossalchests2.wall.displayed.info": "Click with item to choose, click with empty hand to clear.", "gui.colossalchests2.wall.mode": "Direction", "gui.colossalchests2.wall.mode.both": "In and out", "gui.colossalchests2.wall.mode.both.info": "Items can be inserted and extracted.", diff --git a/loader-fabric/src/main/java/org/cyclops/colossalchests2/ColossalChestsFabric.java b/loader-fabric/src/main/java/org/cyclops/colossalchests2/ColossalChestsFabric.java index 18bcc8ca..52635af1 100644 --- a/loader-fabric/src/main/java/org/cyclops/colossalchests2/ColossalChestsFabric.java +++ b/loader-fabric/src/main/java/org/cyclops/colossalchests2/ColossalChestsFabric.java @@ -20,6 +20,7 @@ import org.cyclops.colossalchests2.component.DataComponentChestUpgradesConfig; import org.cyclops.colossalchests2.config.ChestTablesReloadListenerFabric; import org.cyclops.colossalchests2.inventory.ContainerChestConfig; +import org.cyclops.colossalchests2.inventory.ContainerDisplayConfig; import org.cyclops.colossalchests2.inventory.ContainerInterfaceConfig; import org.cyclops.colossalchests2.proxy.ClientProxyFabric; import org.cyclops.colossalchests2.proxy.CommonProxyFabric; @@ -96,5 +97,6 @@ protected void onConfigsRegister(ConfigHandlerCommon configHandler) { configHandler.addConfigurable(new BlockEntityChestCoreConfigFabric<>(this)); configHandler.addConfigurable(new BlockEntityChestWallConfigFabric<>(this)); configHandler.addConfigurable(new ContainerInterfaceConfig<>(this)); + configHandler.addConfigurable(new ContainerDisplayConfig<>(this)); } } diff --git a/loader-fabric/src/main/java/org/cyclops/colossalchests2/proxy/ClientProxyFabric.java b/loader-fabric/src/main/java/org/cyclops/colossalchests2/proxy/ClientProxyFabric.java index 35b1668a..0b27bd66 100644 --- a/loader-fabric/src/main/java/org/cyclops/colossalchests2/proxy/ClientProxyFabric.java +++ b/loader-fabric/src/main/java/org/cyclops/colossalchests2/proxy/ClientProxyFabric.java @@ -1,6 +1,8 @@ package org.cyclops.colossalchests2.proxy; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import org.cyclops.colossalchests2.ColossalChestsFabric; +import org.cyclops.colossalchests2.block.DisplayWallInteractions; import org.cyclops.cyclopscore.init.ModBaseFabric; import org.cyclops.cyclopscore.proxy.ClientProxyComponentFabric; @@ -16,6 +18,13 @@ public ClientProxyFabric() { super(new CommonProxyFabric()); } + @Override + public void registerEventHooks() { + super.registerEventHooks(); + ClientTickEvents.END_CLIENT_TICK.register(minecraft -> + DisplayWallInteractions.onClientTick(minecraft.options.keyAttack.isDown())); + } + @Override public ModBaseFabric getMod() { return ColossalChestsFabric._instance; diff --git a/loader-forge/src/main/java/org/cyclops/colossalchests2/ColossalChestsForge.java b/loader-forge/src/main/java/org/cyclops/colossalchests2/ColossalChestsForge.java index b9e1c1b6..f3dc35a3 100644 --- a/loader-forge/src/main/java/org/cyclops/colossalchests2/ColossalChestsForge.java +++ b/loader-forge/src/main/java/org/cyclops/colossalchests2/ColossalChestsForge.java @@ -19,6 +19,7 @@ import org.cyclops.colossalchests2.component.DataComponentChestUpgradesConfig; import org.cyclops.colossalchests2.config.ChestTablesReloadListener; import org.cyclops.colossalchests2.inventory.ContainerChestConfig; +import org.cyclops.colossalchests2.inventory.ContainerDisplayConfig; import org.cyclops.colossalchests2.inventory.ContainerInterfaceConfig; import org.cyclops.colossalchests2.proxy.ClientProxyForge; import org.cyclops.colossalchests2.proxy.CommonProxyForge; @@ -100,5 +101,6 @@ protected void onConfigsRegister(ConfigHandlerCommon configHandler) { configHandler.addConfigurable(new BlockEntityChestCoreConfigForge<>(this)); configHandler.addConfigurable(new BlockEntityChestWallConfigForge<>(this)); configHandler.addConfigurable(new ContainerInterfaceConfig<>(this)); + configHandler.addConfigurable(new ContainerDisplayConfig<>(this)); } } diff --git a/loader-forge/src/main/java/org/cyclops/colossalchests2/proxy/ClientProxyForge.java b/loader-forge/src/main/java/org/cyclops/colossalchests2/proxy/ClientProxyForge.java index a64ff48e..4b4fd9bd 100644 --- a/loader-forge/src/main/java/org/cyclops/colossalchests2/proxy/ClientProxyForge.java +++ b/loader-forge/src/main/java/org/cyclops/colossalchests2/proxy/ClientProxyForge.java @@ -1,6 +1,10 @@ package org.cyclops.colossalchests2.proxy; +import net.minecraft.client.Minecraft; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.TickEvent; import org.cyclops.colossalchests2.ColossalChestsForge; +import org.cyclops.colossalchests2.block.DisplayWallInteractions; import org.cyclops.cyclopscore.init.ModBaseForge; import org.cyclops.cyclopscore.proxy.ClientProxyComponentForge; @@ -16,6 +20,13 @@ public ClientProxyForge() { super(new CommonProxyForge()); } + @Override + public void registerEventHooks() { + super.registerEventHooks(); + MinecraftForge.EVENT_BUS.addListener((TickEvent.ClientTickEvent.Post event) -> + DisplayWallInteractions.onClientTick(Minecraft.getInstance().options.keyAttack.isDown())); + } + @Override public ModBaseForge getMod() { return ColossalChestsForge._instance; diff --git a/loader-neoforge/src/main/java/org/cyclops/colossalchests2/ColossalChests.java b/loader-neoforge/src/main/java/org/cyclops/colossalchests2/ColossalChests.java index ffef614a..e2f7d559 100644 --- a/loader-neoforge/src/main/java/org/cyclops/colossalchests2/ColossalChests.java +++ b/loader-neoforge/src/main/java/org/cyclops/colossalchests2/ColossalChests.java @@ -23,6 +23,7 @@ import org.cyclops.colossalchests2.component.DataComponentChestUpgradesConfig; import org.cyclops.colossalchests2.config.ChestTablesReloadListener; import org.cyclops.colossalchests2.inventory.ContainerChestConfig; +import org.cyclops.colossalchests2.inventory.ContainerDisplayConfig; import org.cyclops.colossalchests2.inventory.ContainerInterfaceConfig; import org.cyclops.colossalchests2.modcompat.CommonCapabilitiesModCompat; import org.cyclops.colossalchests2.proxy.ClientProxy; @@ -107,6 +108,7 @@ protected void onConfigsRegister(ConfigHandlerCommon configHandler) { configHandler.addConfigurable(new BlockEntityChestCoreConfigNeoForge<>(this)); configHandler.addConfigurable(new BlockEntityChestWallConfigNeoForge<>(this)); configHandler.addConfigurable(new ContainerInterfaceConfig<>(this)); + configHandler.addConfigurable(new ContainerDisplayConfig<>(this)); } /** diff --git a/loader-neoforge/src/main/java/org/cyclops/colossalchests2/proxy/ClientProxy.java b/loader-neoforge/src/main/java/org/cyclops/colossalchests2/proxy/ClientProxy.java index 5cc9fe9a..e4f11a37 100644 --- a/loader-neoforge/src/main/java/org/cyclops/colossalchests2/proxy/ClientProxy.java +++ b/loader-neoforge/src/main/java/org/cyclops/colossalchests2/proxy/ClientProxy.java @@ -1,6 +1,10 @@ package org.cyclops.colossalchests2.proxy; +import net.minecraft.client.Minecraft; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.common.NeoForge; import org.cyclops.colossalchests2.ColossalChests; +import org.cyclops.colossalchests2.block.DisplayWallInteractions; import org.cyclops.cyclopscore.init.ModBase; import org.cyclops.cyclopscore.proxy.ClientProxyComponent; @@ -16,6 +20,13 @@ public ClientProxy() { super(new CommonProxy()); } + @Override + public void registerEventHooks() { + super.registerEventHooks(); + NeoForge.EVENT_BUS.addListener((ClientTickEvent.Post event) -> + DisplayWallInteractions.onClientTick(Minecraft.getInstance().options.keyAttack.isDown())); + } + @Override public ModBase getMod() { return ColossalChests._instance; From 7d4dc2d52499314afa107c11be12b9b16a6b9aa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:21:06 +0000 Subject: [PATCH 5/9] Display wall: put taken items in the held slot first Left-clicks fill the player's selected slot when it is empty or holds the same item, and only put the rest elsewhere in the inventory. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RSz5r6eowhjNuANBjZE2v6 --- .../block/DisplayWallInteractions.java | 13 ++++++++++-- .../gametest/GameTestsCommon.java | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java b/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java index 0dcc062f..571c5cc2 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java @@ -4,6 +4,7 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionResult; import net.minecraft.world.ItemInteractionResult; +import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.Level; @@ -79,7 +80,7 @@ private static Optional getStorage(BlockEntityChestWall wall) { } /** - * Take the shown type into the player's inventory, dropping what does not fit. + * Take the shown type into the player's held slot, then the rest of the inventory, dropping what does not fit. * @param player The player. * @param wall A Display wall. * @param single If one item is taken, otherwise a stack. @@ -94,7 +95,15 @@ public static long take(Player player, BlockEntityChestWall wall, boolean single long taken = storage.get().extract(type, single ? 1 : type.getMaxStackSize(), false); if (taken > 0) { ItemStack stack = type.copyWithCount((int) taken); - if (!player.getInventory().add(stack)) { + // Fill the held slot first. + Inventory inventory = player.getInventory(); + ItemStack held = inventory.getSelected(); + if (held.isEmpty()) { + inventory.setItem(inventory.selected, stack.split(stack.getMaxStackSize())); + } else if (ItemStack.isSameItemSameComponents(held, stack)) { + held.grow(stack.split(Math.max(0, held.getMaxStackSize() - held.getCount())).getCount()); + } + if (!stack.isEmpty() && !inventory.add(stack)) { player.drop(stack, false); } wall.updateDisplayStats(false); diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/gametest/GameTestsCommon.java b/loader-common/src/main/java/org/cyclops/colossalchests2/gametest/GameTestsCommon.java index 3532cdb6..893dbe50 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/gametest/GameTestsCommon.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/gametest/GameTestsCommon.java @@ -1812,6 +1812,26 @@ public void testDisplayWallInsertsAndTakes(GameTestHelper helper) { roundTrip(helper, new ServerboundDisplayTakePacket(helper.absolutePos(display), true), ServerboundDisplayTakePacket.CODEC) .actionServer(helper.getLevel(), player); helper.assertValueEqual(countInInventory(player, Items.STONE), 1, "single stone taken"); + // Taken items go to the held slot first, even when an earlier slot is free. + player.getInventory().clearContent(); + player.getInventory().selected = 4; + roundTrip(helper, new ServerboundDisplayTakePacket(helper.absolutePos(display), true), ServerboundDisplayTakePacket.CODEC) + .actionServer(helper.getLevel(), player); + helper.assertValueEqual(player.getInventory().getItem(4).getCount(), 1, "stone in the held slot"); + // A held stack is topped up, the rest goes elsewhere. + player.getInventory().setItem(4, STONE.copyWithCount(60)); + roundTrip(helper, new ServerboundDisplayTakePacket(helper.absolutePos(display), false), ServerboundDisplayTakePacket.CODEC) + .actionServer(helper.getLevel(), player); + helper.assertValueEqual(player.getInventory().getItem(4).getCount(), 64, "held stone topped up"); + helper.assertValueEqual(countInInventory(player, Items.STONE), 124, "stone after taking a stack"); + // Another held item stays, the stone goes elsewhere. + player.getInventory().clearContent(); + player.getInventory().setItem(4, new ItemStack(Items.DIRT)); + roundTrip(helper, new ServerboundDisplayTakePacket(helper.absolutePos(display), true), ServerboundDisplayTakePacket.CODEC) + .actionServer(helper.getLevel(), player); + helper.assertTrue(player.getInventory().getItem(4).is(Items.DIRT), "Expected the held dirt to stay"); + helper.assertValueEqual(countInInventory(player, Items.STONE), 1, "stone next to the held dirt"); + player.getInventory().selected = 0; // Sneaking with an empty hand does nothing. player.getInventory().clearContent(); player.setShiftKeyDown(true); From 0fd8990965220e43ca8ce526b7f736c802450834 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 15:29:35 +0000 Subject: [PATCH 6/9] Display wall: own item and visibility per side Each outer face of a Display wall shows its own item and stats. Clicks act on the clicked face, and the take packet carries it. The Display settings have a column per face, named relative to the chest's front, with a ghost slot and a Shown/Hidden toggle. A hidden face renders and acts like a plain wall; the last shown face can not be hidden. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RSz5r6eowhjNuANBjZE2v6 --- .../block/BlockChestFunctionalWall.java | 7 +- .../block/DisplayWallInteractions.java | 74 +++++--- .../blockentity/BlockEntityChestWall.java | 170 ++++++++++++++---- .../client/gui/ContainerScreenDisplay.java | 63 ++++++- .../client/render/DisplayWallOverlay.java | 14 +- .../gametest/GameTestsCommon.java | 98 +++++++--- .../inventory/ContainerDisplay.java | 127 +++++++++++-- .../packet/ServerboundDisplayTakePacket.java | 15 +- .../assets/colossalchests2/lang/en_us.json | 12 +- .../colossalchests2/ColossalChestsFabric.java | 2 +- .../colossalchests2/ColossalChestsForge.java | 2 +- .../colossalchests2/ColossalChests.java | 2 +- 12 files changed, 474 insertions(+), 112 deletions(-) diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/block/BlockChestFunctionalWall.java b/loader-common/src/main/java/org/cyclops/colossalchests2/block/BlockChestFunctionalWall.java index bdf70f34..9fe3cef8 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/block/BlockChestFunctionalWall.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/block/BlockChestFunctionalWall.java @@ -63,7 +63,10 @@ protected InteractionResult useWithoutItem(BlockState state, Level level, BlockP return InteractionResult.sidedSuccess(level.isClientSide); } if (type == WallType.DISPLAY && level.getBlockEntity(pos) instanceof BlockEntityChestWall wall) { - return DisplayWallInteractions.useWithoutItem(player, wall); + InteractionResult result = DisplayWallInteractions.useWithoutItem(player, wall, hit.getDirection()); + if (result != null) { + return result; + } } return super.useWithoutItem(state, level, pos, player, hit); } @@ -71,7 +74,7 @@ protected InteractionResult useWithoutItem(BlockState state, Level level, BlockP @Override protected ItemInteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand, BlockHitResult hit) { if (type == WallType.DISPLAY && level.getBlockEntity(pos) instanceof BlockEntityChestWall wall) { - ItemInteractionResult result = DisplayWallInteractions.useItemOn(stack, player, wall); + ItemInteractionResult result = DisplayWallInteractions.useItemOn(stack, player, wall, hit.getDirection()); if (result != null) { return result; } diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java b/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java index 571c5cc2..886ffa30 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/block/DisplayWallInteractions.java @@ -1,6 +1,7 @@ package org.cyclops.colossalchests2.block; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionResult; import net.minecraft.world.ItemInteractionResult; @@ -12,6 +13,7 @@ import org.cyclops.colossalchests2.ColossalChestsInstance; import org.cyclops.colossalchests2.blockentity.BlockEntityChestCore; import org.cyclops.colossalchests2.blockentity.BlockEntityChestWall; +import org.cyclops.colossalchests2.inventory.ContainerDisplay; import org.cyclops.colossalchests2.network.packet.ServerboundDisplayTakePacket; import org.cyclops.colossalchests2.storage.ChestStorage; import org.cyclops.cyclopscore.helper.IModHelpers; @@ -21,7 +23,7 @@ /** * Drawer style clicks on Display walls: left-click takes, right-click inserts, a double right-click inserts all. - * An empty right-click opens the settings. + * An empty right-click opens the settings. Each face shows its own type, or acts like a plain wall when hidden. * @author rubensworks */ public final class DisplayWallInteractions { @@ -45,14 +47,28 @@ public static boolean isDisplayWall(BlockState state) { return state.getBlock() instanceof BlockChestFunctionalWall wall && wall.getType() == WallType.DISPLAY; } + /** + * @param level The level. + * @param pos A position. + * @param face A face of it, null if unknown. + * @return The Display wall at the position, if the face is not hidden. + */ + public static Optional getShownWall(Level level, BlockPos pos, @Nullable Direction face) { + return isDisplayWall(level.getBlockState(pos)) && level.getBlockEntity(pos) instanceof BlockEntityChestWall wall + && (face == null || !wall.isFaceHidden(face)) ? Optional.of(wall) : Optional.empty(); + } + /** * A Display wall is only mined with a tool that can harvest it, so taking items never breaks the chest. - * @param state A block state. + * Hidden faces act like a plain wall. + * @param level The level. + * @param pos A position. + * @param face The clicked face, null if unknown. * @param player A player. * @return If a left-click by the player must not mine the block. */ - public static boolean isProtectedFromMining(BlockState state, Player player) { - return isDisplayWall(state) && !player.getMainHandItem().isCorrectToolForDrops(state); + public static boolean isProtectedFromMining(Level level, BlockPos pos, @Nullable Direction face, Player player) { + return getShownWall(level, pos, face).isPresent() && !player.getMainHandItem().isCorrectToolForDrops(level.getBlockState(pos)); } /** @@ -61,16 +77,17 @@ public static boolean isProtectedFromMining(BlockState state, Player player) { * @param player The player. * @param level The level. * @param pos The clicked position. + * @param face The clicked face, null if unknown. * @return If mining must be cancelled. */ - public static boolean onAttack(Player player, Level level, BlockPos pos) { - if (!isProtectedFromMining(level.getBlockState(pos), player)) { + public static boolean onAttack(Player player, Level level, BlockPos pos, @Nullable Direction face) { + if (!isProtectedFromMining(level, pos, face, player)) { return false; } // Holding the button repeats this, only a new click takes again. - if (level.isClientSide && attackReleased) { + if (level.isClientSide && attackReleased && face != null) { attackReleased = false; - ColossalChestsInstance.MOD.getPacketHandlerCommon().sendToServer(new ServerboundDisplayTakePacket(pos, player.isShiftKeyDown())); + ColossalChestsInstance.MOD.getPacketHandlerCommon().sendToServer(new ServerboundDisplayTakePacket(pos, face, player.isShiftKeyDown())); } return true; } @@ -80,14 +97,16 @@ private static Optional getStorage(BlockEntityChestWall wall) { } /** - * Take the shown type into the player's held slot, then the rest of the inventory, dropping what does not fit. + * Take the type shown on a face into the player's held slot, then the rest of the inventory, dropping what does + * not fit. * @param player The player. * @param wall A Display wall. + * @param face The clicked face. * @param single If one item is taken, otherwise a stack. * @return How many were taken. */ - public static long take(Player player, BlockEntityChestWall wall, boolean single) { - ItemStack type = wall.getDisplayed(); + public static long take(Player player, BlockEntityChestWall wall, Direction face, boolean single) { + ItemStack type = wall.getDisplayed(face); Optional storage = getStorage(wall); if (type.isEmpty() || storage.isEmpty()) { return 0; @@ -112,23 +131,23 @@ public static long take(Player player, BlockEntityChestWall wall, boolean single } /** - * A right-click with an item: an empty display starts showing it, and the shown type is inserted. + * A right-click with an item on a face: an empty face starts showing it, and the type it shows is inserted. * @return The result, or null to fall back to the default wall behaviour. */ @Nullable - public static ItemInteractionResult useItemOn(ItemStack stack, Player player, BlockEntityChestWall wall) { + public static ItemInteractionResult useItemOn(ItemStack stack, Player player, BlockEntityChestWall wall, Direction face) { Optional storage = getStorage(wall); // An empty hand is handled by useWithoutItem. - if (stack.isEmpty() || storage.isEmpty()) { + if (stack.isEmpty() || storage.isEmpty() || wall.isFaceHidden(face)) { return null; } if (!player.level().isClientSide) { - if (wall.getDisplayed().isEmpty()) { - wall.setDisplayed(stack); + if (wall.getDisplayed(face).isEmpty()) { + wall.setDisplayed(face, stack); } - if (ItemStack.isSameItemSameComponents(stack, wall.getDisplayed())) { - if (wall.recordInsertClick(player, player.level().getGameTime())) { - insertAll(player, storage.get(), wall.getDisplayed()); + if (ItemStack.isSameItemSameComponents(stack, wall.getDisplayed(face))) { + if (wall.recordInsertClick(player, face, player.level().getGameTime())) { + insertAll(player, storage.get(), wall.getDisplayed(face)); } else { stack.shrink((int) storage.get().insert(stack, stack.getCount(), false)); } @@ -139,21 +158,26 @@ public static ItemInteractionResult useItemOn(ItemStack stack, Player player, Bl } /** - * A right-click with an empty hand: right after an insert it inserts all of the shown type, otherwise it opens the - * settings. Sneaking does nothing. + * A right-click with an empty hand on a face: right after an insert there it inserts all of the type it shows, + * otherwise it opens the settings. Sneaking does nothing. + * @return The result, or null to fall back to the default wall behaviour. */ - public static InteractionResult useWithoutItem(Player player, BlockEntityChestWall wall) { + @Nullable + public static InteractionResult useWithoutItem(Player player, BlockEntityChestWall wall, Direction face) { + if (wall.isFaceHidden(face)) { + return null; + } if (player.isSecondaryUseActive()) { return InteractionResult.PASS; } // Only the server knows about the previous click, the client lets it decide. if (player instanceof ServerPlayer serverPlayer) { Optional storage = getStorage(wall); - if (storage.isPresent() && !wall.getDisplayed().isEmpty() && wall.recordInsertClick(player, player.level().getGameTime())) { - insertAll(player, storage.get(), wall.getDisplayed()); + if (storage.isPresent() && !wall.getDisplayed(face).isEmpty() && wall.recordInsertClick(player, face, player.level().getGameTime())) { + insertAll(player, storage.get(), wall.getDisplayed(face)); wall.updateDisplayStats(false); } else { - IModHelpers.get().getMinecraftHelpers().openMenu(serverPlayer, wall, buf -> buf.writeBlockPos(wall.getBlockPos())); + IModHelpers.get().getMinecraftHelpers().openMenu(serverPlayer, wall, buf -> ContainerDisplay.writeOpenData(buf, wall)); } } return InteractionResult.sidedSuccess(player.level().isClientSide); diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWall.java b/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWall.java index ae28a2a6..27816d3f 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWall.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/blockentity/BlockEntityChestWall.java @@ -2,9 +2,12 @@ import com.google.common.collect.Maps; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; import net.minecraft.core.HolderLookup; import net.minecraft.core.NonNullList; import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; import net.minecraft.network.chat.Component; import net.minecraft.network.protocol.Packet; import net.minecraft.network.protocol.game.ClientGamePacketListener; @@ -31,9 +34,11 @@ import org.cyclops.colossalchests2.inventory.ContainerDisplay; import org.cyclops.colossalchests2.inventory.ContainerInterface; import org.cyclops.colossalchests2.multiblock.ChestCoreIndex; +import org.cyclops.colossalchests2.multiblock.ChestShape; import org.cyclops.colossalchests2.storage.DisplayStats; import org.jetbrains.annotations.Nullable; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Optional; @@ -41,13 +46,14 @@ /** * A functional wall, giving automation access to its chest under the wall's rules. - * Only an Interface has settings: its filter and direction. A Display wall keeps the type it shows, and its stats - * for clients. + * Only an Interface has settings: its filter and direction. A Display wall keeps the type it shows on each face, + * which faces are hidden, and its stats for clients. * @author rubensworks */ public class BlockEntityChestWall extends BlockEntity implements MenuProvider { public static final int FILTER_SLOTS = 9; + public static final int FACES = Direction.values().length; /** * The longest time between two clicks on a Display wall that make a double click. */ @@ -67,13 +73,16 @@ public int getMaxStackSize() { } }; private WallAccess.Mode mode = WallAccess.Mode.BOTH; - private ItemStack displayed = ItemStack.EMPTY; - private DisplayStats displayStats = DisplayStats.EMPTY; - private final Map lastInserts = Maps.newHashMap(); + private final ItemStack[] displayed = new ItemStack[FACES]; + private final DisplayStats[] displayStats = new DisplayStats[FACES]; + private int hiddenFaces; + private final Map lastInserts = Maps.newHashMap(); private final Container displayedContainer = new DisplayedContainer(); public BlockEntityChestWall(BlockEntityType type, BlockPos pos, BlockState state) { super(type, pos, state); + Arrays.fill(displayed, ItemStack.EMPTY); + Arrays.fill(displayStats, DisplayStats.EMPTY); } public BlockEntityChestWall(BlockPos pos, BlockState state) { @@ -138,41 +147,86 @@ public Optional getItemHandlerLogic() { } /** - * @return The type a Display wall shows, empty if none. + * @return The faces of a Display wall on the outside of its formed chest, which can show an item. */ - public ItemStack getDisplayed() { - return displayed; + public List getDisplayFaces() { + return getCore().map(core -> ChestShape.getOuterFaces(core.getStructure(), worldPosition)).orElse(List.of()); } - public void setDisplayed(ItemStack type) { - displayed = type.isEmpty() ? ItemStack.EMPTY : type.copyWithCount(1); + /** + * @param face A face. + * @return The type a Display wall shows on the face, empty if none. + */ + public ItemStack getDisplayed(Direction face) { + return displayed[face.ordinal()]; + } + + public void setDisplayed(Direction face, ItemStack type) { + displayed[face.ordinal()] = type.isEmpty() ? ItemStack.EMPTY : type.copyWithCount(1); lastInserts.clear(); setChanged(); updateDisplayStats(true); } /** - * @return A single slot container of the type a Display wall shows, for its menu. + * @param face A face. + * @return If a Display wall shows nothing on the face, and acts like a plain wall there. + */ + public boolean isFaceHidden(Direction face) { + return (hiddenFaces & (1 << face.ordinal())) != 0; + } + + /** + * Show or hide a face of a Display wall. The last shown face of a formed chest can not be hidden, so its settings + * stay reachable. + * @param face A face. + * @param hidden If it is hidden. + * @return If it changed. + */ + public boolean setFaceHidden(Direction face, boolean hidden) { + if (isFaceHidden(face) == hidden + || (hidden && getDisplayFaces().stream().noneMatch(other -> other != face && !isFaceHidden(other)))) { + return false; + } + setHiddenFaces(hiddenFaces ^ (1 << face.ordinal())); + return true; + } + + public int getHiddenFaces() { + return hiddenFaces; + } + + private void setHiddenFaces(int hiddenFaces) { + this.hiddenFaces = hiddenFaces; + setChanged(); + updateDisplayStats(true); + } + + /** + * @return A container with the type a Display wall shows on each face, by face ordinal, for its menu. */ public Container getDisplayedContainer() { return displayedContainer; } /** - * @return What a Display wall shows about its type, as last synced on clients. + * @param face A face. + * @return What a Display wall shows about the type on the face, as last synced on clients. */ - public DisplayStats getDisplayStats() { - return displayStats; + public DisplayStats getDisplayStats(Direction face) { + return displayStats[face.ordinal()]; } /** * @param player A player. + * @param face The clicked face. * @param gameTime The current game time. - * @return If the player's previous insert was recent enough to make this click a double click. Records this click. + * @return If the player's previous insert was on the same face and recent enough to make this click a double + * click. Records this click. */ - public boolean recordInsertClick(Player player, long gameTime) { - Long previous = lastInserts.put(player.getUUID(), gameTime); - return previous != null && gameTime - previous <= DOUBLE_CLICK_TICKS; + public boolean recordInsertClick(Player player, Direction face, long gameTime) { + InsertClick previous = lastInserts.put(player.getUUID(), new InsertClick(face, gameTime)); + return previous != null && previous.face() == face && gameTime - previous.gameTime() <= DOUBLE_CLICK_TICKS; } /** @@ -183,9 +237,17 @@ public void updateDisplayStats(boolean force) { if (level == null || level.isClientSide) { return; } - DisplayStats stats = getCore().map(core -> DisplayStats.of(core.getStorage(), displayed)).orElse(DisplayStats.EMPTY); - if (force || !stats.equals(displayStats)) { - displayStats = stats; + Optional core = getCore(); + boolean changed = force; + for (Direction face : Direction.values()) { + ItemStack type = displayed[face.ordinal()]; + DisplayStats stats = core.map(c -> DisplayStats.of(c.getStorage(), type)).orElse(DisplayStats.EMPTY); + if (!stats.equals(displayStats[face.ordinal()])) { + displayStats[face.ordinal()] = stats; + changed = true; + } + } + if (changed) { BlockState state = getBlockState(); level.sendBlockUpdated(worldPosition, state, state, Block.UPDATE_CLIENTS); } @@ -216,9 +278,44 @@ protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) super.saveAdditional(tag, registries); tag.putString("mode", mode.name()); ContainerHelper.saveAllItems(tag, settings.getItems(), registries); - if (!displayed.isEmpty()) { - tag.put("displayed", displayed.save(registries)); + saveDisplay(tag, registries, false); + } + + private void saveDisplay(CompoundTag tag, HolderLookup.Provider registries, boolean withStats) { + ListTag faces = new ListTag(); + for (Direction face : Direction.values()) { + ItemStack type = displayed[face.ordinal()]; + if (!type.isEmpty() || (withStats && !displayStats[face.ordinal()].equals(DisplayStats.EMPTY))) { + CompoundTag faceTag = new CompoundTag(); + faceTag.putString("face", face.getSerializedName()); + if (!type.isEmpty()) { + faceTag.put("item", type.save(registries)); + } + if (withStats) { + faceTag.put("stats", displayStats[face.ordinal()].toTag()); + } + faces.add(faceTag); + } } + tag.put("display", faces); + tag.putInt("hidden_faces", hiddenFaces); + } + + private void loadDisplay(CompoundTag tag, HolderLookup.Provider registries) { + Arrays.fill(displayed, ItemStack.EMPTY); + Arrays.fill(displayStats, DisplayStats.EMPTY); + for (Tag entry : tag.getList("display", Tag.TAG_COMPOUND)) { + CompoundTag faceTag = (CompoundTag) entry; + Direction face = Direction.byName(faceTag.getString("face")); + if (face != null) { + displayed[face.ordinal()] = faceTag.contains("item") + ? ItemStack.parseOptional(registries, faceTag.getCompound("item")) : ItemStack.EMPTY; + if (faceTag.contains("stats")) { + displayStats[face.ordinal()] = DisplayStats.fromTag(faceTag.getCompound("stats")); + } + } + } + hiddenFaces = tag.getInt("hidden_faces"); } @Override @@ -234,20 +331,14 @@ protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) for (int i = 0; i < items.size(); i++) { settings.getItems().set(i, items.get(i)); } - displayed = tag.contains("displayed") ? ItemStack.parseOptional(registries, tag.getCompound("displayed")) : ItemStack.EMPTY; - if (tag.contains("display_stats")) { - displayStats = DisplayStats.fromTag(tag.getCompound("display_stats")); - } + loadDisplay(tag, registries); } @Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { // Clients only need what a Display wall shows. CompoundTag tag = new CompoundTag(); - if (!displayed.isEmpty()) { - tag.put("displayed", displayed.save(registries)); - } - tag.put("display_stats", displayStats.toTag()); + saveDisplay(tag, registries, true); return tag; } @@ -268,21 +359,24 @@ public AbstractContainerMenu createMenu(int id, Inventory inventory, Player play return getWallType() == WallType.DISPLAY ? new ContainerDisplay(id, inventory, this) : new ContainerInterface(id, inventory, this); } + private record InsertClick(Direction face, long gameTime) { + } + private class DisplayedContainer implements Container { @Override public int getContainerSize() { - return 1; + return FACES; } @Override public boolean isEmpty() { - return displayed.isEmpty(); + return Arrays.stream(displayed).allMatch(ItemStack::isEmpty); } @Override public ItemStack getItem(int slot) { - return slot == 0 ? displayed : ItemStack.EMPTY; + return displayed[slot]; } @Override @@ -299,9 +393,7 @@ public ItemStack removeItemNoUpdate(int slot) { @Override public void setItem(int slot, ItemStack stack) { - if (slot == 0) { - setDisplayed(stack); - } + setDisplayed(Direction.from3DDataValue(slot), stack); } @Override @@ -321,7 +413,9 @@ public boolean stillValid(Player player) { @Override public void clearContent() { - setDisplayed(ItemStack.EMPTY); + for (Direction face : Direction.values()) { + setDisplayed(face, ItemStack.EMPTY); + } } } } diff --git a/loader-common/src/main/java/org/cyclops/colossalchests2/client/gui/ContainerScreenDisplay.java b/loader-common/src/main/java/org/cyclops/colossalchests2/client/gui/ContainerScreenDisplay.java index 8e4742bd..810b818c 100644 --- a/loader-common/src/main/java/org/cyclops/colossalchests2/client/gui/ContainerScreenDisplay.java +++ b/loader-common/src/main/java/org/cyclops/colossalchests2/client/gui/ContainerScreenDisplay.java @@ -1,8 +1,11 @@ package org.cyclops.colossalchests2.client.gui; +import com.google.common.collect.Lists; import net.minecraft.ChatFormatting; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.core.Direction; import net.minecraft.network.chat.Component; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.inventory.Slot; @@ -11,11 +14,16 @@ import java.util.List; /** - * Settings of a Display wall. + * Settings of a Display wall, a column per face. * @author rubensworks */ public class ContainerScreenDisplay extends AbstractContainerScreen { + private static final int COLOR_LABEL = 0x404040; + + private final List