Compare commits

...

13 Commits

Author SHA1 Message Date
trunksbomb
5c5c35871d ready for initial release
Some checks failed
Release / publish (release) Has been cancelled
2026-03-23 10:58:48 -04:00
trunksbomb
5d283909db first pass on README
Some checks failed
Build / build (push) Has been cancelled
2026-03-23 05:01:55 -04:00
trunksbomb
8189359e91 add a "not fully supported" tooltip for bags that use the fallback hotbar method 2026-03-23 04:44:38 -04:00
trunksbomb
a90227ff02 remove Bag of Holding references
Some checks failed
Build / build (push) Has been cancelled
2026-03-23 04:02:46 -04:00
trunksbomb
6271417381 add support for non-supported bags/packs. If there is no compat layer yet for a bag/pack item from a mod, then attempt to place that bag into your hotbar and simulate a right click to open it. Prefer empty slots, non-tool slots, non-food slots if it needs to swap for something currently on your hotbar. 2026-03-23 03:43:29 -04:00
trunksbomb
274e4f6dec fullness indicator optional on tabs 2026-03-23 03:04:58 -04:00
trunksbomb
e201c8c674 Transform the dock settings GUI into a proper popup so you can live position the dock and make changes. 2026-03-23 02:45:01 -04:00
trunksbomb
966e199a23 UI polishes
add compact mode for all orientations
2026-03-23 01:55:51 -04:00
trunksbomb
56fcb49f4d UI polishes
Implement scrolling tabs
2026-03-23 00:41:46 -04:00
trunksbomb
638fce4116 tidying up UI and UX.
Add screen dock modes (left/right/top/bottom), better X/Y offset control, replaced tab size with "max tabs"
2026-03-22 23:46:35 -04:00
trunksbomb
b51b41206c tidying up UI and UX
Add dock tab for locking the dock and opening settings, where menu dock location and offset can be moved.
2026-03-22 21:44:01 -04:00
trunksbomb
85a7e1a48b reorder pinned bags 2026-03-22 20:30:54 -04:00
trunksbomb
15be03e055 pin bags 2026-03-22 20:10:32 -04:00
32 changed files with 5375 additions and 131 deletions

View File

@@ -0,0 +1,43 @@
name: Release
on:
release:
types:
- published
jobs:
publish:
runs-on: ubuntu-latest
env:
GITEA_SERVER_URL: ${{ gitea.server_url }}
BAGTABS_GITEA_TOKEN: ${{ secrets.BAGTABS_GITEA_TOKEN }}
MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }}
CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }}
MODRINTH_PROJECT_ID: ${{ vars.MODRINTH_PROJECT_ID }}
CURSEFORGE_PROJECT_ID: ${{ vars.CURSEFORGE_PROJECT_ID }}
MINECRAFT_VERSIONS: ${{ vars.MINECRAFT_VERSIONS }}
MOD_LOADERS: ${{ vars.MOD_LOADERS }}
CURSEFORGE_GAME_VERSION_NAMES: ${{ vars.CURSEFORGE_GAME_VERSION_NAMES }}
CURSEFORGE_GAME_VERSION_IDS: ${{ vars.CURSEFORGE_GAME_VERSION_IDS }}
RELEASE_ARTIFACT_GLOB: ${{ vars.RELEASE_ARTIFACT_GLOB }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Make Gradle wrapper executable
run: chmod +x ./gradlew
- name: Build release jar
run: ./gradlew --no-daemon clean build
- name: Publish release artifacts
run: python3 ./scripts/publish_release.py

109
README.md
View File

@@ -1,25 +1,96 @@
# Bag Tabs
Installation information
=======
Bag Tabs is a NeoForge mod for Minecraft 1.21.1 that adds a tab strip to inventory screens for bags and other portable storage items you are carrying.
This template repository can be directly cloned to get you started with a new
mod. Simply create a new repository cloned from this one, by following the
instructions provided by [GitHub](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template).
The goal is simple: if you are carrying a bag, you should be able to open it directly from your inventory without moving it into your hand first.
Once you have your clone, simply open the repository in the IDE of your choice. The usual recommendation for an IDE is either IntelliJ IDEA or Eclipse.
## What It Does
If at any point you are missing libraries in your IDE, or you've run into problems you can
run `gradlew --refresh-dependencies` to refresh the local cache. `gradlew clean` to reset everything
{this does not affect your code} and then start the process again.
- Adds a tab bar to supported inventory screens.
- Shows one tab for each carried bag or portable storage item the mod recognizes.
- Lets you click a tab to open that bag directly.
- Keeps the tab bar visible while you move between supported bag screens.
- Highlights the currently open bag.
- Supports drag-and-drop insertion onto supported bag tabs when the bag can accept the carried stack.
Mapping Names:
============
By default, the MDK is configured to use the official mapping names from Mojang for methods and fields
in the Minecraft codebase. These names are covered by a specific license. All modders should be aware of this
license. For the latest license text, refer to the mapping file itself, or the reference copy here:
https://github.com/NeoForged/NeoForm/blob/main/Mojang.md
## Included Content
Additional Resources:
==========
Community Documentation: https://docs.neoforged.net/
NeoForged Discord: https://discord.neoforged.net/
- A built-in Bag Tabs bag item.
- 27 inventory slots, like a single chest.
- Dyeable bag colors.
- Crafting recipe.
- Creative tab with the base bag, all dyed variants, and the Bag Namer tool.
- JEI entries for the dyed bag variants.
## Tab Features
- Bag tabs use the bag item icon for normal mode.
- Compact mode replaces the icon with the bag name.
- Dyed bags tint their tab background.
- Pinned tabs always appear first.
- Pinned tabs can be reordered by drag-and-drop.
- Tabs can be locked to disable pinning and reordering.
- Overflow arrows appear automatically when more tabs exist than can be shown.
- Mouse wheel scrolling works over the dock.
- Fullness indicators can be shown on tabs and toggled in dock settings.
## Dock Features
- Dock to the GUI bottom, top, left, or right.
- Dock to the screen bottom, top, left, or right.
- Floating horizontal and floating vertical modes.
- Per-player, per-world saved settings.
- Global default settings plus per-screen overrides.
- Remembered scroll position per screen.
- Adjustable X/Y offset.
- Adjustable max visible tabs.
- Optional compact layout.
- Optional fullness indicator.
- Draggable in-game dock settings popup with live preview.
## Bag Opening Behavior
Bag Tabs supports two kinds of bag opening:
- Direct support for bags with native or compatibility-backed integration.
- A fallback hotbar method for unsupported portable storage items that still look like bags or containers.
When a bag is not fully supported, the tooltip says:
- `Mod not supported: uses hotbar method to open`
If no warning is shown, the bag is using native or compatibility-backed support.
## Current Compatibility
Bag Tabs currently includes compatibility support for:
- Bag Tabs bags
- Traveler's Backpack
- Sophisticated Backpacks
- Dank Storage
Other portable storage items may still appear as tabs and may still open through the fallback hotbar method.
## Bag Namer
Bag Tabs includes a Bag Namer tool that can rename portable storage items.
- Works on supported bags and inventory-like items.
- Uses an in-world style GUI with input and output slots.
- Does not close after each rename, so items can be renamed in batches.
- Drops its contents if you leave the screen.
## Controls
- Left-click tab: open bag
- Right-click tab: pin or unpin
- Drag pinned tab: reorder pinned tabs
- Mouse wheel over dock: scroll visible tabs
- `\` by default: open last bag
## Notes
- Bag Tabs is designed to stay attached to existing inventory screens rather than replacing them.
- Unsupported bags may behave differently depending on how their mod handles right-click opening.
- Some mods may still need dedicated compatibility if they do not expose normal item-use behavior.

View File

@@ -30,9 +30,9 @@ mod_id=bagtabs
# The human-readable display name for the mod.
mod_name=Bag Tabs
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=All Rights Reserved
mod_license=MIT
# The mod version. See https://semver.org/
mod_version=1.0.0
mod_version=0.1.0
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html

View File

@@ -0,0 +1,767 @@
package net.minecraft.client.gui.screens.inventory;
import com.google.common.collect.Sets;
import com.mojang.blaze3d.platform.InputConstants;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.datafixers.util.Pair;
import java.util.List;
import java.util.Set;
import javax.annotation.Nullable;
import net.minecraft.ChatFormatting;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.api.distmarker.OnlyIn;
@OnlyIn(Dist.CLIENT)
public abstract class AbstractContainerScreen<T extends AbstractContainerMenu> extends Screen implements MenuAccess<T> {
/**
* The location of the inventory background texture
*/
public static final ResourceLocation INVENTORY_LOCATION = ResourceLocation.withDefaultNamespace("textures/gui/container/inventory.png");
private static final float SNAPBACK_SPEED = 100.0F;
private static final int QUICKDROP_DELAY = 500;
public static final int SLOT_ITEM_BLIT_OFFSET = 100;
private static final int HOVER_ITEM_BLIT_OFFSET = 200;
/**
* The X size of the inventory window in pixels.
*/
protected int imageWidth = 176;
/**
* The Y size of the inventory window in pixels.
*/
protected int imageHeight = 166;
protected int titleLabelX;
protected int titleLabelY;
protected int inventoryLabelX;
protected int inventoryLabelY;
/**
* A list of the players inventory slots
*/
protected final T menu;
protected final Component playerInventoryTitle;
/**
* Holds the slot currently hovered
*/
@Nullable
protected Slot hoveredSlot;
/**
* Used when touchscreen is enabled
*/
@Nullable
private Slot clickedSlot;
@Nullable
private Slot snapbackEnd;
@Nullable
private Slot quickdropSlot;
@Nullable
private Slot lastClickSlot;
/**
* Starting X position for the Gui. Inconsistent use for Gui backgrounds.
*/
protected int leftPos;
/**
* Starting Y position for the Gui. Inconsistent use for Gui backgrounds.
*/
protected int topPos;
/**
* Used when touchscreen is enabled.
*/
private boolean isSplittingStack;
/**
* Used when touchscreen is enabled
*/
private ItemStack draggingItem = ItemStack.EMPTY;
private int snapbackStartX;
private int snapbackStartY;
private long snapbackTime;
/**
* Used when touchscreen is enabled
*/
private ItemStack snapbackItem = ItemStack.EMPTY;
private long quickdropTime;
protected final Set<Slot> quickCraftSlots = Sets.newHashSet();
protected boolean isQuickCrafting;
private int quickCraftingType;
private int quickCraftingButton;
private boolean skipNextRelease;
private int quickCraftingRemainder;
private long lastClickTime;
private int lastClickButton;
private boolean doubleclick;
private ItemStack lastQuickMoved = ItemStack.EMPTY;
public AbstractContainerScreen(T menu, Inventory playerInventory, Component title) {
super(title);
this.menu = menu;
this.playerInventoryTitle = playerInventory.getDisplayName();
this.skipNextRelease = true;
this.titleLabelX = 8;
this.titleLabelY = 6;
this.inventoryLabelX = 8;
this.inventoryLabelY = this.imageHeight - 94;
}
@Override
protected void init() {
this.leftPos = (this.width - this.imageWidth) / 2;
this.topPos = (this.height - this.imageHeight) / 2;
}
/**
* Renders the graphical user interface (GUI) element.
*
* @param guiGraphics the GuiGraphics object used for rendering.
* @param mouseX the x-coordinate of the mouse cursor.
* @param mouseY the y-coordinate of the mouse cursor.
* @param partialTick the partial tick time.
*/
@Override
public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) {
int i = this.leftPos;
int j = this.topPos;
// Neo: replicate the super method's implementation to insert the event between background and widgets
this.renderBackground(guiGraphics, mouseX, mouseY, partialTick);
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.post(new net.neoforged.neoforge.client.event.ContainerScreenEvent.Render.Background(this, guiGraphics, mouseX, mouseY));
for (net.minecraft.client.gui.components.Renderable renderable : this.renderables) {
renderable.render(guiGraphics, mouseX, mouseY, partialTick);
}
RenderSystem.disableDepthTest();
guiGraphics.pose().pushPose();
guiGraphics.pose().translate((float)i, (float)j, 0.0F);
this.hoveredSlot = null;
for (int k = 0; k < this.menu.slots.size(); k++) {
Slot slot = this.menu.slots.get(k);
if (slot.isActive()) {
this.renderSlot(guiGraphics, slot);
}
if (this.isHovering(slot, (double)mouseX, (double)mouseY) && slot.isActive()) {
this.hoveredSlot = slot;
this.renderSlotHighlight(guiGraphics, slot, mouseX, mouseY, partialTick);
}
}
this.renderLabels(guiGraphics, mouseX, mouseY);
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.post(new net.neoforged.neoforge.client.event.ContainerScreenEvent.Render.Foreground(this, guiGraphics, mouseX, mouseY));
ItemStack itemstack = this.draggingItem.isEmpty() ? this.menu.getCarried() : this.draggingItem;
if (!itemstack.isEmpty()) {
int l1 = 8;
int i2 = this.draggingItem.isEmpty() ? 8 : 16;
String s = null;
if (!this.draggingItem.isEmpty() && this.isSplittingStack) {
itemstack = itemstack.copyWithCount(Mth.ceil((float)itemstack.getCount() / 2.0F));
} else if (this.isQuickCrafting && this.quickCraftSlots.size() > 1) {
itemstack = itemstack.copyWithCount(this.quickCraftingRemainder);
if (itemstack.isEmpty()) {
s = ChatFormatting.YELLOW + "0";
}
}
this.renderFloatingItem(guiGraphics, itemstack, mouseX - i - 8, mouseY - j - i2, s);
}
if (!this.snapbackItem.isEmpty()) {
float f = (float)(Util.getMillis() - this.snapbackTime) / 100.0F;
if (f >= 1.0F) {
f = 1.0F;
this.snapbackItem = ItemStack.EMPTY;
}
int j2 = this.snapbackEnd.x - this.snapbackStartX;
int k2 = this.snapbackEnd.y - this.snapbackStartY;
int j1 = this.snapbackStartX + (int)((float)j2 * f);
int k1 = this.snapbackStartY + (int)((float)k2 * f);
this.renderFloatingItem(guiGraphics, this.snapbackItem, j1, k1, null);
}
guiGraphics.pose().popPose();
RenderSystem.enableDepthTest();
}
@Override
public void renderBackground(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) {
this.renderTransparentBackground(guiGraphics);
this.renderBg(guiGraphics, partialTick, mouseX, mouseY);
}
public static void renderSlotHighlight(GuiGraphics guiGraphics, int x, int y, int blitOffset) {
renderSlotHighlight(guiGraphics, x, y, blitOffset, -2130706433);
}
public static void renderSlotHighlight(GuiGraphics guiGraphics, int x, int y, int blitOffset, int color) {
guiGraphics.fillGradient(RenderType.guiOverlay(), x, y, x + 16, y + 16, color, color, blitOffset);
}
/**
* Renders a highlight for the given slot to indicate the mouse is currently hovering over it.
*/
protected void renderSlotHighlight(GuiGraphics guiGraphics, Slot slot, int mouseX, int mouseY, float partialTick) {
if (slot.isHighlightable()) {
renderSlotHighlight(guiGraphics, slot.x, slot.y, 0, getSlotColor(slot.index));
}
}
protected void renderTooltip(GuiGraphics guiGraphics, int x, int y) {
if (this.menu.getCarried().isEmpty() && this.hoveredSlot != null && this.hoveredSlot.hasItem()) {
ItemStack itemstack = this.hoveredSlot.getItem();
guiGraphics.renderTooltip(this.font, this.getTooltipFromContainerItem(itemstack), itemstack.getTooltipImage(), itemstack, x, y);
}
}
protected List<Component> getTooltipFromContainerItem(ItemStack stack) {
return getTooltipFromItem(this.minecraft, stack);
}
private void renderFloatingItem(GuiGraphics guiGraphics, ItemStack stack, int x, int y, String text) {
guiGraphics.pose().pushPose();
guiGraphics.pose().translate(0.0F, 0.0F, 232.0F);
guiGraphics.renderItem(stack, x, y);
var font = net.neoforged.neoforge.client.extensions.common.IClientItemExtensions.of(stack).getFont(stack, net.neoforged.neoforge.client.extensions.common.IClientItemExtensions.FontContext.ITEM_COUNT);
guiGraphics.renderItemDecorations(font == null ? this.font : font, stack, x, y - (this.draggingItem.isEmpty() ? 0 : 8), text);
guiGraphics.pose().popPose();
}
protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) {
guiGraphics.drawString(this.font, this.title, this.titleLabelX, this.titleLabelY, 4210752, false);
guiGraphics.drawString(this.font, this.playerInventoryTitle, this.inventoryLabelX, this.inventoryLabelY, 4210752, false);
}
protected abstract void renderBg(GuiGraphics guiGraphics, float partialTick, int mouseX, int mouseY);
protected void renderSlot(GuiGraphics guiGraphics, Slot slot) {
int i = slot.x;
int j = slot.y;
ItemStack itemstack = slot.getItem();
boolean flag = false;
boolean flag1 = slot == this.clickedSlot && !this.draggingItem.isEmpty() && !this.isSplittingStack;
ItemStack itemstack1 = this.menu.getCarried();
String s = null;
if (slot == this.clickedSlot && !this.draggingItem.isEmpty() && this.isSplittingStack && !itemstack.isEmpty()) {
itemstack = itemstack.copyWithCount(itemstack.getCount() / 2);
} else if (this.isQuickCrafting && this.quickCraftSlots.contains(slot) && !itemstack1.isEmpty()) {
if (this.quickCraftSlots.size() == 1) {
return;
}
if (AbstractContainerMenu.canItemQuickReplace(slot, itemstack1, true) && this.menu.canDragTo(slot)) {
flag = true;
int k = Math.min(itemstack1.getMaxStackSize(), slot.getMaxStackSize(itemstack1));
int l = slot.getItem().isEmpty() ? 0 : slot.getItem().getCount();
int i1 = AbstractContainerMenu.getQuickCraftPlaceCount(this.quickCraftSlots, this.quickCraftingType, itemstack1) + l;
if (i1 > k) {
i1 = k;
s = ChatFormatting.YELLOW.toString() + k;
}
itemstack = itemstack1.copyWithCount(i1);
} else {
this.quickCraftSlots.remove(slot);
this.recalculateQuickCraftRemaining();
}
}
guiGraphics.pose().pushPose();
guiGraphics.pose().translate(0.0F, 0.0F, 100.0F);
if (itemstack.isEmpty() && slot.isActive()) {
Pair<ResourceLocation, ResourceLocation> pair = slot.getNoItemIcon();
if (pair != null) {
TextureAtlasSprite textureatlassprite = this.minecraft.getTextureAtlas(pair.getFirst()).apply(pair.getSecond());
guiGraphics.blit(i, j, 0, 16, 16, textureatlassprite);
flag1 = true;
}
}
if (!flag1) {
if (flag) {
guiGraphics.fill(i, j, i + 16, j + 16, -2130706433);
}
renderSlotContents(guiGraphics, itemstack, slot, s);
}
guiGraphics.pose().popPose();
}
protected void renderSlotContents(GuiGraphics guiGraphics, ItemStack itemstack, Slot slot, @Nullable String countString) {
GuiGraphics p_281607_ = guiGraphics; Slot p_282613_ = slot; String s = countString; int i = slot.x; int j = slot.y;
int j1 = p_282613_.x + p_282613_.y * this.imageWidth;
if (p_282613_.isFake()) {
p_281607_.renderFakeItem(itemstack, i, j, j1);
} else {
p_281607_.renderItem(itemstack, i, j, j1);
}
p_281607_.renderItemDecorations(this.font, itemstack, i, j, s);
}
private void recalculateQuickCraftRemaining() {
ItemStack itemstack = this.menu.getCarried();
if (!itemstack.isEmpty() && this.isQuickCrafting) {
if (this.quickCraftingType == 2) {
this.quickCraftingRemainder = itemstack.getMaxStackSize();
} else {
this.quickCraftingRemainder = itemstack.getCount();
for (Slot slot : this.quickCraftSlots) {
ItemStack itemstack1 = slot.getItem();
int i = itemstack1.isEmpty() ? 0 : itemstack1.getCount();
int j = Math.min(itemstack.getMaxStackSize(), slot.getMaxStackSize(itemstack));
int k = Math.min(AbstractContainerMenu.getQuickCraftPlaceCount(this.quickCraftSlots, this.quickCraftingType, itemstack) + i, j);
this.quickCraftingRemainder -= k - i;
}
}
}
}
@Nullable
private Slot findSlot(double mouseX, double mouseY) {
for (int i = 0; i < this.menu.slots.size(); i++) {
Slot slot = this.menu.slots.get(i);
if (this.isHovering(slot, mouseX, mouseY) && slot.isActive()) {
return slot;
}
}
return null;
}
/**
* Called when a mouse button is clicked within the GUI element.
* <p>
* @return {@code true} if the event is consumed, {@code false} otherwise.
*
* @param mouseX the X coordinate of the mouse.
* @param mouseY the Y coordinate of the mouse.
* @param button the button that was clicked.
*/
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (super.mouseClicked(mouseX, mouseY, button)) {
return true;
} else {
InputConstants.Key mouseKey = InputConstants.Type.MOUSE.getOrCreate(button);
boolean flag = this.minecraft.options.keyPickItem.isActiveAndMatches(mouseKey);
Slot slot = this.findSlot(mouseX, mouseY);
long i = Util.getMillis();
this.doubleclick = this.lastClickSlot == slot && i - this.lastClickTime < 250L && this.lastClickButton == button;
this.skipNextRelease = false;
if (button != 0 && button != 1 && !flag) {
this.checkHotbarMouseClicked(button);
} else {
int j = this.leftPos;
int k = this.topPos;
boolean flag1 = this.hasClickedOutside(mouseX, mouseY, j, k, button);
if (slot != null) flag1 = false; // Forge, prevent dropping of items through slots outside of GUI boundaries
int l = -1;
if (slot != null) {
l = slot.index;
}
if (flag1) {
l = -999;
}
if (this.minecraft.options.touchscreen().get() && flag1 && this.menu.getCarried().isEmpty()) {
this.onClose();
return true;
}
if (l != -1) {
if (this.minecraft.options.touchscreen().get()) {
if (slot != null && slot.hasItem()) {
this.clickedSlot = slot;
this.draggingItem = ItemStack.EMPTY;
this.isSplittingStack = button == 1;
} else {
this.clickedSlot = null;
}
} else if (!this.isQuickCrafting) {
if (this.menu.getCarried().isEmpty()) {
if (this.minecraft.options.keyPickItem.isActiveAndMatches(mouseKey)) {
this.slotClicked(slot, l, button, ClickType.CLONE);
} else {
boolean flag2 = l != -999
&& (
InputConstants.isKeyDown(Minecraft.getInstance().getWindow().getWindow(), 340)
|| InputConstants.isKeyDown(Minecraft.getInstance().getWindow().getWindow(), 344)
);
ClickType clicktype = ClickType.PICKUP;
if (flag2) {
this.lastQuickMoved = slot != null && slot.hasItem() ? slot.getItem().copy() : ItemStack.EMPTY;
clicktype = ClickType.QUICK_MOVE;
} else if (l == -999) {
clicktype = ClickType.THROW;
}
this.slotClicked(slot, l, button, clicktype);
}
this.skipNextRelease = true;
} else {
this.isQuickCrafting = true;
this.quickCraftingButton = button;
this.quickCraftSlots.clear();
if (button == 0) {
this.quickCraftingType = 0;
} else if (button == 1) {
this.quickCraftingType = 1;
} else if (this.minecraft.options.keyPickItem.isActiveAndMatches(mouseKey)) {
this.quickCraftingType = 2;
}
}
}
}
}
this.lastClickSlot = slot;
this.lastClickTime = i;
this.lastClickButton = button;
return true;
}
}
private void checkHotbarMouseClicked(int keyCode) {
if (this.hoveredSlot != null && this.menu.getCarried().isEmpty()) {
if (this.minecraft.options.keySwapOffhand.matchesMouse(keyCode)) {
this.slotClicked(this.hoveredSlot, this.hoveredSlot.index, 40, ClickType.SWAP);
return;
}
for (int i = 0; i < 9; i++) {
if (this.minecraft.options.keyHotbarSlots[i].matchesMouse(keyCode)) {
this.slotClicked(this.hoveredSlot, this.hoveredSlot.index, i, ClickType.SWAP);
}
}
}
}
protected boolean hasClickedOutside(double mouseX, double mouseY, int guiLeft, int guiTop, int mouseButton) {
return mouseX < (double)guiLeft
|| mouseY < (double)guiTop
|| mouseX >= (double)(guiLeft + this.imageWidth)
|| mouseY >= (double)(guiTop + this.imageHeight);
}
/**
* Called when the mouse is dragged within the GUI element.
* <p>
* @return {@code true} if the event is consumed, {@code false} otherwise.
*
* @param mouseX the X coordinate of the mouse.
* @param mouseY the Y coordinate of the mouse.
* @param button the button that is being dragged.
* @param dragX the X distance of the drag.
* @param dragY the Y distance of the drag.
*/
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) {
Slot slot = this.findSlot(mouseX, mouseY);
ItemStack itemstack = this.menu.getCarried();
if (this.clickedSlot != null && this.minecraft.options.touchscreen().get()) {
if (button == 0 || button == 1) {
if (this.draggingItem.isEmpty()) {
if (slot != this.clickedSlot && !this.clickedSlot.getItem().isEmpty()) {
this.draggingItem = this.clickedSlot.getItem().copy();
}
} else if (this.draggingItem.getCount() > 1 && slot != null && AbstractContainerMenu.canItemQuickReplace(slot, this.draggingItem, false)) {
long i = Util.getMillis();
if (this.quickdropSlot == slot) {
if (i - this.quickdropTime > 500L) {
this.slotClicked(this.clickedSlot, this.clickedSlot.index, 0, ClickType.PICKUP);
this.slotClicked(slot, slot.index, 1, ClickType.PICKUP);
this.slotClicked(this.clickedSlot, this.clickedSlot.index, 0, ClickType.PICKUP);
this.quickdropTime = i + 750L;
this.draggingItem.shrink(1);
}
} else {
this.quickdropSlot = slot;
this.quickdropTime = i;
}
}
}
} else if (this.isQuickCrafting
&& slot != null
&& !itemstack.isEmpty()
&& (itemstack.getCount() > this.quickCraftSlots.size() || this.quickCraftingType == 2)
&& AbstractContainerMenu.canItemQuickReplace(slot, itemstack, true)
&& slot.mayPlace(itemstack)
&& this.menu.canDragTo(slot)) {
this.quickCraftSlots.add(slot);
this.recalculateQuickCraftRemaining();
}
return true;
}
/**
* Called when a mouse button is released within the GUI element.
* <p>
* @return {@code true} if the event is consumed, {@code false} otherwise.
*
* @param mouseX the X coordinate of the mouse.
* @param mouseY the Y coordinate of the mouse.
* @param button the button that was released.
*/
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
super.mouseReleased(mouseX, mouseY, button); //Forge, Call parent to release buttons
Slot slot = this.findSlot(mouseX, mouseY);
int i = this.leftPos;
int j = this.topPos;
boolean flag = this.hasClickedOutside(mouseX, mouseY, i, j, button);
if (slot != null) flag = false; // Forge, prevent dropping of items through slots outside of GUI boundaries
InputConstants.Key mouseKey = InputConstants.Type.MOUSE.getOrCreate(button);
int k = -1;
if (slot != null) {
k = slot.index;
}
if (flag) {
k = -999;
}
if (this.doubleclick && slot != null && button == 0 && this.menu.canTakeItemForPickAll(ItemStack.EMPTY, slot)) {
if (hasShiftDown()) {
if (!this.lastQuickMoved.isEmpty()) {
for (Slot slot2 : this.menu.slots) {
if (slot2 != null
&& slot2.mayPickup(this.minecraft.player)
&& slot2.hasItem()
&& slot2.isSameInventory(slot)
&& AbstractContainerMenu.canItemQuickReplace(slot2, this.lastQuickMoved, true)) {
this.slotClicked(slot2, slot2.index, button, ClickType.QUICK_MOVE);
}
}
}
} else {
this.slotClicked(slot, k, button, ClickType.PICKUP_ALL);
}
this.doubleclick = false;
this.lastClickTime = 0L;
} else {
if (this.isQuickCrafting && this.quickCraftingButton != button) {
this.isQuickCrafting = false;
this.quickCraftSlots.clear();
this.skipNextRelease = true;
return true;
}
if (this.skipNextRelease) {
this.skipNextRelease = false;
return true;
}
if (this.clickedSlot != null && this.minecraft.options.touchscreen().get()) {
if (button == 0 || button == 1) {
if (this.draggingItem.isEmpty() && slot != this.clickedSlot) {
this.draggingItem = this.clickedSlot.getItem();
}
boolean flag2 = AbstractContainerMenu.canItemQuickReplace(slot, this.draggingItem, false);
if (k != -1 && !this.draggingItem.isEmpty() && flag2) {
this.slotClicked(this.clickedSlot, this.clickedSlot.index, button, ClickType.PICKUP);
this.slotClicked(slot, k, 0, ClickType.PICKUP);
if (this.menu.getCarried().isEmpty()) {
this.snapbackItem = ItemStack.EMPTY;
} else {
this.slotClicked(this.clickedSlot, this.clickedSlot.index, button, ClickType.PICKUP);
this.snapbackStartX = Mth.floor(mouseX - (double)i);
this.snapbackStartY = Mth.floor(mouseY - (double)j);
this.snapbackEnd = this.clickedSlot;
this.snapbackItem = this.draggingItem;
this.snapbackTime = Util.getMillis();
}
} else if (!this.draggingItem.isEmpty()) {
this.snapbackStartX = Mth.floor(mouseX - (double)i);
this.snapbackStartY = Mth.floor(mouseY - (double)j);
this.snapbackEnd = this.clickedSlot;
this.snapbackItem = this.draggingItem;
this.snapbackTime = Util.getMillis();
}
this.clearDraggingState();
}
} else if (this.isQuickCrafting && !this.quickCraftSlots.isEmpty()) {
this.slotClicked(null, -999, AbstractContainerMenu.getQuickcraftMask(0, this.quickCraftingType), ClickType.QUICK_CRAFT);
for (Slot slot1 : this.quickCraftSlots) {
this.slotClicked(slot1, slot1.index, AbstractContainerMenu.getQuickcraftMask(1, this.quickCraftingType), ClickType.QUICK_CRAFT);
}
this.slotClicked(null, -999, AbstractContainerMenu.getQuickcraftMask(2, this.quickCraftingType), ClickType.QUICK_CRAFT);
} else if (!this.menu.getCarried().isEmpty()) {
if (this.minecraft.options.keyPickItem.isActiveAndMatches(mouseKey)) {
this.slotClicked(slot, k, button, ClickType.CLONE);
} else {
boolean flag1 = k != -999
&& (
InputConstants.isKeyDown(Minecraft.getInstance().getWindow().getWindow(), 340)
|| InputConstants.isKeyDown(Minecraft.getInstance().getWindow().getWindow(), 344)
);
if (flag1) {
this.lastQuickMoved = slot != null && slot.hasItem() ? slot.getItem().copy() : ItemStack.EMPTY;
}
this.slotClicked(slot, k, button, flag1 ? ClickType.QUICK_MOVE : ClickType.PICKUP);
}
}
}
if (this.menu.getCarried().isEmpty()) {
this.lastClickTime = 0L;
}
this.isQuickCrafting = false;
return true;
}
public void clearDraggingState() {
this.draggingItem = ItemStack.EMPTY;
this.clickedSlot = null;
}
private boolean isHovering(Slot slot, double mouseX, double mouseY) {
return this.isHovering(slot.x, slot.y, 16, 16, mouseX, mouseY);
}
protected boolean isHovering(int x, int y, int width, int height, double mouseX, double mouseY) {
int i = this.leftPos;
int j = this.topPos;
mouseX -= (double)i;
mouseY -= (double)j;
return mouseX >= (double)(x - 1)
&& mouseX < (double)(x + width + 1)
&& mouseY >= (double)(y - 1)
&& mouseY < (double)(y + height + 1);
}
/**
* Called when the mouse is clicked over a slot or outside the gui.
*/
protected void slotClicked(Slot slot, int slotId, int mouseButton, ClickType type) {
if (slot != null) {
slotId = slot.index;
}
this.minecraft.gameMode.handleInventoryMouseClick(this.menu.containerId, slotId, mouseButton, type, this.minecraft.player);
}
protected void handleSlotStateChanged(int slotId, int containerId, boolean newState) {
this.minecraft.gameMode.handleSlotStateChanged(slotId, containerId, newState);
}
/**
* Called when a keyboard key is pressed within the GUI element.
* <p>
* @return {@code true} if the event is consumed, {@code false} otherwise.
*
* @param keyCode the key code of the pressed key.
* @param scanCode the scan code of the pressed key.
* @param modifiers the keyboard modifiers.
*/
@Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
InputConstants.Key mouseKey = InputConstants.getKey(keyCode, scanCode);
if (super.keyPressed(keyCode, scanCode, modifiers)) {
return true;
} else if (this.minecraft.options.keyInventory.isActiveAndMatches(mouseKey)) {
this.onClose();
return true;
} else {
boolean handled = this.checkHotbarKeyPressed(keyCode, scanCode);// Forge MC-146650: Needs to return true when the key is handled
if (this.hoveredSlot != null && this.hoveredSlot.hasItem()) {
if (this.minecraft.options.keyPickItem.isActiveAndMatches(mouseKey)) {
this.slotClicked(this.hoveredSlot, this.hoveredSlot.index, 0, ClickType.CLONE);
handled = true;
} else if (this.minecraft.options.keyDrop.isActiveAndMatches(mouseKey)) {
this.slotClicked(this.hoveredSlot, this.hoveredSlot.index, hasControlDown() ? 1 : 0, ClickType.THROW);
handled = true;
}
} else if (this.minecraft.options.keyDrop.isActiveAndMatches(mouseKey)) {
handled = true; // Forge MC-146650: Emulate MC bug, so we don't drop from hotbar when pressing drop without hovering over a item.
}
return handled;
}
}
protected boolean checkHotbarKeyPressed(int keyCode, int scanCode) {
if (this.menu.getCarried().isEmpty() && this.hoveredSlot != null) {
if (this.minecraft.options.keySwapOffhand.isActiveAndMatches(InputConstants.getKey(keyCode, scanCode))) {
this.slotClicked(this.hoveredSlot, this.hoveredSlot.index, 40, ClickType.SWAP);
return true;
}
for (int i = 0; i < 9; i++) {
if (this.minecraft.options.keyHotbarSlots[i].isActiveAndMatches(InputConstants.getKey(keyCode, scanCode))) {
this.slotClicked(this.hoveredSlot, this.hoveredSlot.index, i, ClickType.SWAP);
return true;
}
}
}
return false;
}
@Override
public void removed() {
if (this.minecraft.player != null) {
this.menu.removed(this.minecraft.player);
}
}
@Override
public boolean isPauseScreen() {
return false;
}
@Override
public final void tick() {
super.tick();
if (this.minecraft.player.isAlive() && !this.minecraft.player.isRemoved()) {
this.containerTick();
} else {
this.minecraft.player.closeContainer();
}
}
protected void containerTick() {
}
@Override
public T getMenu() {
return this.menu;
}
@org.jetbrains.annotations.Nullable
public Slot getSlotUnderMouse() { return this.hoveredSlot; }
public int getGuiLeft() { return leftPos; }
public int getGuiTop() { return topPos; }
public int getXSize() { return imageWidth; }
public int getYSize() { return imageHeight; }
protected int slotColor = -2130706433;
public int getSlotColor(int index) {
return slotColor;
}
@Override
public void onClose() {
this.minecraft.player.closeContainer();
super.onClose();
}
}

View File

@@ -0,0 +1,409 @@
package net.minecraft.server.level;
import com.mojang.logging.LogUtils;
import java.util.Objects;
import javax.annotation.Nullable;
import net.minecraft.advancements.CriteriaTriggers;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.protocol.game.ClientboundBlockUpdatePacket;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
import net.minecraft.network.protocol.game.ServerboundPlayerActionPacket;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.InteractionResultHolder;
import net.minecraft.world.ItemInteractionResult;
import net.minecraft.world.MenuProvider;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.item.enchantment.EnchantmentHelper;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.GameMasterBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.Vec3;
import org.slf4j.Logger;
public class ServerPlayerGameMode {
private static final Logger LOGGER = LogUtils.getLogger();
protected ServerLevel level;
protected final ServerPlayer player;
private GameType gameModeForPlayer = GameType.DEFAULT_MODE;
@Nullable
private GameType previousGameModeForPlayer;
private boolean isDestroyingBlock;
private int destroyProgressStart;
private BlockPos destroyPos = BlockPos.ZERO;
private int gameTicks;
private boolean hasDelayedDestroy;
private BlockPos delayedDestroyPos = BlockPos.ZERO;
private int delayedTickStart;
private int lastSentState = -1;
public ServerPlayerGameMode(ServerPlayer player) {
this.player = player;
this.level = player.serverLevel();
}
public boolean changeGameModeForPlayer(GameType gameModeForPlayer) {
if (gameModeForPlayer == this.gameModeForPlayer) {
return false;
} else {
this.setGameModeForPlayer(gameModeForPlayer, this.previousGameModeForPlayer);
this.player.onUpdateAbilities();
this.player
.server
.getPlayerList()
.broadcastAll(new ClientboundPlayerInfoUpdatePacket(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_GAME_MODE, this.player));
this.level.updateSleepingPlayerList();
if (gameModeForPlayer == GameType.CREATIVE) {
this.player.resetCurrentImpulseContext();
}
return true;
}
}
protected void setGameModeForPlayer(GameType gameModeForPlayer, @Nullable GameType previousGameModeForPlayer) {
this.previousGameModeForPlayer = previousGameModeForPlayer;
this.gameModeForPlayer = gameModeForPlayer;
// Neo: preserve flying state, removed on tick if Attribute or ability no longer applies
boolean wasFlying = this.player.getAbilities().flying;
gameModeForPlayer.updatePlayerAbilities(this.player.getAbilities());
this.player.getAbilities().flying = wasFlying || this.player.getAbilities().flying;
}
public GameType getGameModeForPlayer() {
return this.gameModeForPlayer;
}
@Nullable
public GameType getPreviousGameModeForPlayer() {
return this.previousGameModeForPlayer;
}
public boolean isSurvival() {
return this.gameModeForPlayer.isSurvival();
}
public boolean isCreative() {
return this.gameModeForPlayer.isCreative();
}
public void tick() {
this.gameTicks++;
if (this.hasDelayedDestroy) {
BlockState blockstate = this.level.getBlockState(this.delayedDestroyPos);
if (blockstate.isAir()) {
this.hasDelayedDestroy = false;
} else {
float f = this.incrementDestroyProgress(blockstate, this.delayedDestroyPos, this.delayedTickStart);
if (f >= 1.0F) {
this.hasDelayedDestroy = false;
this.destroyBlock(this.delayedDestroyPos);
}
}
} else if (this.isDestroyingBlock) {
BlockState blockstate1 = this.level.getBlockState(this.destroyPos);
if (blockstate1.isAir()) {
this.level.destroyBlockProgress(this.player.getId(), this.destroyPos, -1);
this.lastSentState = -1;
this.isDestroyingBlock = false;
} else {
this.incrementDestroyProgress(blockstate1, this.destroyPos, this.destroyProgressStart);
}
}
}
private float incrementDestroyProgress(BlockState state, BlockPos pos, int startTick) {
int i = this.gameTicks - startTick;
float f = state.getDestroyProgress(this.player, this.player.level(), pos) * (float)(i + 1);
int j = (int)(f * 10.0F);
if (j != this.lastSentState) {
this.level.destroyBlockProgress(this.player.getId(), pos, j);
this.lastSentState = j;
}
return f;
}
private void debugLogging(BlockPos pos, boolean terminate, int sequence, String message) {
}
public void handleBlockBreakAction(BlockPos pos, ServerboundPlayerActionPacket.Action action, Direction face, int maxBuildHeight, int sequence) {
net.neoforged.neoforge.event.entity.player.PlayerInteractEvent.LeftClickBlock event = net.neoforged.neoforge.common.CommonHooks.onLeftClickBlock(player, pos, face, action);
if (event.isCanceled()) {
return;
}
if (!this.player.canInteractWithBlock(pos, 1.0)) {
this.debugLogging(pos, false, sequence, "too far");
} else if (pos.getY() >= maxBuildHeight) {
this.player.connection.send(new ClientboundBlockUpdatePacket(pos, this.level.getBlockState(pos)));
this.debugLogging(pos, false, sequence, "too high");
} else {
if (action == ServerboundPlayerActionPacket.Action.START_DESTROY_BLOCK) {
if (!this.level.mayInteract(this.player, pos)) {
this.player.connection.send(new ClientboundBlockUpdatePacket(pos, this.level.getBlockState(pos)));
this.debugLogging(pos, false, sequence, "may not interact");
return;
}
if (this.isCreative()) {
this.destroyAndAck(pos, sequence, "creative destroy");
return;
}
if (this.player.blockActionRestricted(this.level, pos, this.gameModeForPlayer)) {
this.player.connection.send(new ClientboundBlockUpdatePacket(pos, this.level.getBlockState(pos)));
this.debugLogging(pos, false, sequence, "block action restricted");
return;
}
this.destroyProgressStart = this.gameTicks;
float f = 1.0F;
BlockState blockstate = this.level.getBlockState(pos);
if (!blockstate.isAir()) {
EnchantmentHelper.onHitBlock(
this.level,
this.player.getMainHandItem(),
this.player,
this.player,
EquipmentSlot.MAINHAND,
Vec3.atCenterOf(pos),
blockstate,
p_348149_ -> this.player.onEquippedItemBroken(p_348149_, EquipmentSlot.MAINHAND)
);
if (event.getUseBlock() != net.neoforged.neoforge.common.util.TriState.FALSE)
blockstate.attack(this.level, pos, this.player);
f = blockstate.getDestroyProgress(this.player, this.player.level(), pos);
}
if (!blockstate.isAir() && f >= 1.0F) {
this.destroyAndAck(pos, sequence, "insta mine");
} else {
if (this.isDestroyingBlock) {
this.player.connection.send(new ClientboundBlockUpdatePacket(this.destroyPos, this.level.getBlockState(this.destroyPos)));
this.debugLogging(pos, false, sequence, "abort destroying since another started (client insta mine, server disagreed)");
}
this.isDestroyingBlock = true;
this.destroyPos = pos.immutable();
int i = (int)(f * 10.0F);
this.level.destroyBlockProgress(this.player.getId(), pos, i);
this.debugLogging(pos, true, sequence, "actual start of destroying");
this.lastSentState = i;
}
} else if (action == ServerboundPlayerActionPacket.Action.STOP_DESTROY_BLOCK) {
if (pos.equals(this.destroyPos)) {
int j = this.gameTicks - this.destroyProgressStart;
BlockState blockstate1 = this.level.getBlockState(pos);
if (!blockstate1.isAir()) {
float f1 = blockstate1.getDestroyProgress(this.player, this.player.level(), pos) * (float)(j + 1);
if (f1 >= 0.7F) {
this.isDestroyingBlock = false;
this.level.destroyBlockProgress(this.player.getId(), pos, -1);
this.destroyAndAck(pos, sequence, "destroyed");
return;
}
if (!this.hasDelayedDestroy) {
this.isDestroyingBlock = false;
this.hasDelayedDestroy = true;
this.delayedDestroyPos = pos;
this.delayedTickStart = this.destroyProgressStart;
}
}
}
this.debugLogging(pos, true, sequence, "stopped destroying");
} else if (action == ServerboundPlayerActionPacket.Action.ABORT_DESTROY_BLOCK) {
this.isDestroyingBlock = false;
if (!Objects.equals(this.destroyPos, pos)) {
LOGGER.warn("Mismatch in destroy block pos: {} {}", this.destroyPos, pos);
this.level.destroyBlockProgress(this.player.getId(), this.destroyPos, -1);
this.debugLogging(pos, true, sequence, "aborted mismatched destroying");
}
this.level.destroyBlockProgress(this.player.getId(), pos, -1);
this.debugLogging(pos, true, sequence, "aborted destroying");
}
}
}
public void destroyAndAck(BlockPos pos, int sequence, String message) {
if (this.destroyBlock(pos)) {
this.debugLogging(pos, true, sequence, message);
} else {
this.player.connection.send(new ClientboundBlockUpdatePacket(pos, this.level.getBlockState(pos)));
this.debugLogging(pos, false, sequence, message);
}
}
/**
* Attempts to harvest a block
*/
public boolean destroyBlock(BlockPos pos) {
BlockState blockstate1 = this.level.getBlockState(pos);
var event = net.neoforged.neoforge.common.CommonHooks.fireBlockBreak(level, gameModeForPlayer, player, pos, blockstate1);
if (event.isCanceled()) {
return false;
} else {
BlockEntity blockentity = this.level.getBlockEntity(pos);
Block block = blockstate1.getBlock();
if (block instanceof GameMasterBlock && !this.player.canUseGameMasterBlocks()) {
this.level.sendBlockUpdated(pos, blockstate1, blockstate1, 3);
return false;
} else if (this.player.blockActionRestricted(this.level, pos, this.gameModeForPlayer)) {
return false;
} else {
BlockState blockstate = block.playerWillDestroy(this.level, pos, blockstate1, this.player);
if (this.isCreative()) {
removeBlock(pos, blockstate, false);
return true;
} else {
ItemStack itemstack = this.player.getMainHandItem();
ItemStack itemstack1 = itemstack.copy();
boolean flag1 = blockstate.canHarvestBlock(this.level, pos, this.player); // previously player.hasCorrectToolForDrops(blockstate)
itemstack.mineBlock(this.level, blockstate, pos, this.player);
boolean flag = removeBlock(pos, blockstate, flag1);
if (flag1 && flag) {
block.playerDestroy(this.level, this.player, pos, blockstate, blockentity, itemstack1);
}
// Neo: Fire the PlayerDestroyItemEvent if the tool was broken at any point during the break process
if (itemstack.isEmpty() && !itemstack1.isEmpty()) {
net.neoforged.neoforge.event.EventHooks.onPlayerDestroyItem(this.player, itemstack1, InteractionHand.MAIN_HAND);
}
return true;
}
}
}
}
/**
* Patched-in method that handles actual removal of blocks for {@link #destroyBlock(BlockPos)}.
*
* @param pos The block pos of the destroyed block
* @param state The state of the destroyed block
* @param canHarvest If the player breaking the block can harvest the drops of the block
* @return If the block was removed, as reported by {@link BlockState#onDestroyedByPlayer}.
*/
private boolean removeBlock(BlockPos pos, BlockState state, boolean canHarvest) {
boolean removed = state.onDestroyedByPlayer(this.level, pos, this.player, canHarvest, this.level.getFluidState(pos));
if (removed)
state.getBlock().destroy(this.level, pos, state);
return removed;
}
public InteractionResult useItem(ServerPlayer player, Level level, ItemStack stack, InteractionHand hand) {
if (this.gameModeForPlayer == GameType.SPECTATOR) {
return InteractionResult.PASS;
} else if (player.getCooldowns().isOnCooldown(stack.getItem())) {
return InteractionResult.PASS;
} else {
InteractionResult cancelResult = net.neoforged.neoforge.common.CommonHooks.onItemRightClick(player, hand);
if (cancelResult != null) return cancelResult;
int i = stack.getCount();
int j = stack.getDamageValue();
InteractionResultHolder<ItemStack> interactionresultholder = stack.use(level, player, hand);
ItemStack itemstack = interactionresultholder.getObject();
if (itemstack == stack && itemstack.getCount() == i && itemstack.getUseDuration(player) <= 0 && itemstack.getDamageValue() == j) {
return interactionresultholder.getResult();
} else if (interactionresultholder.getResult() == InteractionResult.FAIL && itemstack.getUseDuration(player) > 0 && !player.isUsingItem()) {
return interactionresultholder.getResult();
} else {
if (stack != itemstack) {
player.setItemInHand(hand, itemstack);
}
if (itemstack.isEmpty()) {
player.setItemInHand(hand, ItemStack.EMPTY);
}
if (!player.isUsingItem()) {
player.inventoryMenu.sendAllDataToRemote();
}
return interactionresultholder.getResult();
}
}
}
public InteractionResult useItemOn(ServerPlayer player, Level level, ItemStack stack, InteractionHand hand, BlockHitResult hitResult) {
BlockPos blockpos = hitResult.getBlockPos();
BlockState blockstate = level.getBlockState(blockpos);
if (!blockstate.getBlock().isEnabled(level.enabledFeatures())) {
return InteractionResult.FAIL;
}
net.neoforged.neoforge.event.entity.player.PlayerInteractEvent.RightClickBlock event = net.neoforged.neoforge.common.CommonHooks.onRightClickBlock(player, hand, blockpos, hitResult);
if (event.isCanceled()) return event.getCancellationResult();
if (this.gameModeForPlayer == GameType.SPECTATOR) {
MenuProvider menuprovider = blockstate.getMenuProvider(level, blockpos);
if (menuprovider != null) {
player.openMenu(menuprovider);
return InteractionResult.SUCCESS;
} else {
return InteractionResult.PASS;
}
} else {
UseOnContext useoncontext = new UseOnContext(player, hand, hitResult);
if (event.getUseItem() != net.neoforged.neoforge.common.util.TriState.FALSE) {
InteractionResult result = stack.onItemUseFirst(useoncontext);
if (result != InteractionResult.PASS) return result;
}
boolean flag = !player.getMainHandItem().isEmpty() || !player.getOffhandItem().isEmpty();
boolean flag1 = (player.isSecondaryUseActive() && flag) && !(player.getMainHandItem().doesSneakBypassUse(level, blockpos, player) && player.getOffhandItem().doesSneakBypassUse(level, blockpos, player));
ItemStack itemstack = stack.copy();
if (event.getUseBlock().isTrue() || (event.getUseBlock().isDefault() && !flag1)) {
ItemInteractionResult iteminteractionresult = blockstate.useItemOn(player.getItemInHand(hand), level, player, hand, hitResult);
if (iteminteractionresult.consumesAction()) {
CriteriaTriggers.ITEM_USED_ON_BLOCK.trigger(player, blockpos, itemstack);
return iteminteractionresult.result();
}
if (iteminteractionresult == ItemInteractionResult.PASS_TO_DEFAULT_BLOCK_INTERACTION && hand == InteractionHand.MAIN_HAND) {
InteractionResult interactionresult = blockstate.useWithoutItem(level, player, hitResult);
if (interactionresult.consumesAction()) {
CriteriaTriggers.DEFAULT_BLOCK_USE.trigger(player, blockpos);
return interactionresult;
}
}
}
if (event.getUseItem().isTrue() || (!stack.isEmpty() && !player.getCooldowns().isOnCooldown(stack.getItem()))) {
if (event.getUseItem().isFalse()) return InteractionResult.PASS;
InteractionResult interactionresult1;
if (this.isCreative()) {
int i = stack.getCount();
interactionresult1 = stack.useOn(useoncontext);
stack.setCount(i);
} else {
interactionresult1 = stack.useOn(useoncontext);
}
if (interactionresult1.consumesAction()) {
CriteriaTriggers.ITEM_USED_ON_BLOCK.trigger(player, blockpos, itemstack);
}
return interactionresult1;
} else {
return InteractionResult.PASS;
}
}
}
/**
* Sets the world instance.
*/
public void setLevel(ServerLevel serverLevel) {
this.level = serverLevel;
}
}

File diff suppressed because it is too large Load Diff

BIN
project_picture.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

BIN
project_picture_full.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

397
scripts/publish_release.py Normal file
View File

@@ -0,0 +1,397 @@
#!/usr/bin/env python3
import glob
import json
import os
import pathlib
import subprocess
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
ROOT = pathlib.Path(__file__).resolve().parent.parent
GRADLE_PROPERTIES = ROOT / "gradle.properties"
def fail(message: str) -> None:
print(f"ERROR: {message}", file=sys.stderr)
raise SystemExit(1)
def read_gradle_properties() -> dict[str, str]:
values: dict[str, str] = {}
for line in GRADLE_PROPERTIES.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
values[key.strip()] = value.strip()
return values
def load_release_event() -> dict:
event_path = os.environ.get("GITHUB_EVENT_PATH")
if not event_path:
fail("GITHUB_EVENT_PATH is not set. This script must run inside a release workflow.")
with open(event_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
if "release" not in payload:
fail("Workflow event payload does not contain release data.")
return payload
def env_required(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
fail(f"Missing required environment value: {name}")
return value
def env_optional(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()
def first_non_empty(*values: str) -> str:
for value in values:
if value:
return value
return ""
def split_csv(value: str) -> list[str]:
return [part.strip() for part in value.split(",") if part.strip()]
def derive_release_channel(tag_name: str, release_name: str, prerelease: bool) -> str:
text = " ".join([tag_name, release_name]).lower()
if "alpha" in text:
return "alpha"
if prerelease or "beta" in text or "rc" in text or "pre" in text:
return "beta"
return "release"
def select_artifact(mod_id: str, version_hint: str) -> pathlib.Path:
pattern = env_optional("RELEASE_ARTIFACT_GLOB", "build/libs/*.jar")
candidates = [
pathlib.Path(path)
for path in glob.glob(str(ROOT / pattern))
if pathlib.Path(path).is_file()
]
filtered = [
path
for path in candidates
if not any(
marker in path.name
for marker in ("-sources", "-javadoc", "-api", "-dev", "-slim")
)
]
if not filtered:
fail(f"No release jar matched {pattern}")
version_name = version_hint[1:] if version_hint.startswith("v") else version_hint
exact_name = f"{mod_id}-{version_name}.jar"
for path in filtered:
if path.name == exact_name:
return path
filtered.sort(key=lambda path: (path.name.count("-"), len(path.name), path.name))
return filtered[0]
def http_json(method: str, url: str, headers: dict[str, str]) -> tuple[int, object]:
request = urllib.request.Request(url, method=method)
for key, value in headers.items():
request.add_header(key, value)
try:
with urllib.request.urlopen(request) as response:
body = response.read().decode("utf-8")
return response.status, json.loads(body) if body else None
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
return error.code, json.loads(body) if body else None
def curl_request(arguments: list[str], expected_codes: set[int]) -> str:
with tempfile.NamedTemporaryFile(delete=False) as output_file:
output_path = output_file.name
try:
command = [
"curl",
"--silent",
"--show-error",
"--location",
"--output",
output_path,
"--write-out",
"%{http_code}",
*arguments,
]
result = subprocess.run(command, capture_output=True, text=True, check=False)
if result.returncode != 0:
fail(result.stderr.strip() or "curl failed")
status_code = int(result.stdout.strip())
body = pathlib.Path(output_path).read_text(encoding="utf-8", errors="replace")
if status_code not in expected_codes:
fail(f"HTTP {status_code} from remote API:\n{body}")
return body
finally:
pathlib.Path(output_path).unlink(missing_ok=True)
def gitea_headers(token: str) -> dict[str, str]:
return {
"Accept": "application/json",
"Authorization": f"token {token}",
}
def attach_to_gitea_release(
server_url: str,
owner: str,
repo: str,
release_id: int,
artifact: pathlib.Path,
token: str,
) -> None:
asset_name = artifact.name
asset_base = (
f"{server_url.rstrip('/')}/api/v1/repos/"
f"{urllib.parse.quote(owner)}/{urllib.parse.quote(repo)}/releases/{release_id}/assets"
)
headers = gitea_headers(token)
status, payload = http_json("GET", asset_base, headers)
if status == 200 and isinstance(payload, list):
for asset in payload:
if asset.get("name") == asset_name:
delete_url = f"{asset_base}/{asset['id']}"
delete_status, _ = http_json("DELETE", delete_url, headers)
if delete_status not in (204, 404):
fail(f"Failed to delete existing Gitea release asset {asset_name}")
elif status != 404:
fail(f"Could not list Gitea release assets (HTTP {status})")
upload_url = f"{asset_base}?name={urllib.parse.quote(asset_name)}"
print(f"Uploading {asset_name} to Gitea release #{release_id}")
curl_request(
[
"--request",
"POST",
"--header",
f"Authorization: token {token}",
"--header",
"Accept: application/json",
"--form",
f"attachment=@{artifact}",
upload_url,
],
{201},
)
def upload_to_modrinth(
artifact: pathlib.Path,
token: str,
project_id: str,
version_number: str,
version_name: str,
changelog: str,
release_channel: str,
game_versions: list[str],
loaders: list[str],
) -> None:
payload = {
"project_id": project_id,
"version_number": version_number,
"version_title": version_name,
"version_type": release_channel,
"status": "listed",
"featured": False,
"loaders": loaders,
"game_versions": game_versions,
"changelog": changelog,
"file_parts": ["file"],
"primary_file": "file",
}
print(f"Publishing {artifact.name} to Modrinth project {project_id}")
curl_request(
[
"--request",
"POST",
"--header",
f"Authorization: {token}",
"--header",
"User-Agent: bagtabs-release-pipeline/1.0 (self-hosted Gitea)",
"--form",
f"data={json.dumps(payload)}",
"--form",
f"file=@{artifact}",
"https://api.modrinth.com/v2/version",
],
{200, 201},
)
def resolve_curseforge_game_version_ids(
token: str,
game_version_names: list[str],
explicit_ids: list[str],
api_base: str,
) -> list[int]:
if explicit_ids:
return [int(value) for value in explicit_ids]
if not game_version_names:
fail("Set CURSEFORGE_GAME_VERSION_IDS or CURSEFORGE_GAME_VERSION_NAMES for CurseForge publishing.")
url = f"{api_base.rstrip('/')}/api/game/versions"
request = urllib.request.Request(url, method="GET")
request.add_header("X-Api-Token", token)
try:
with urllib.request.urlopen(request) as response:
versions = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
fail(f"Could not read CurseForge game versions (HTTP {error.code}):\n{body}")
matches: list[int] = []
missing: list[str] = []
for name in game_version_names:
matched = next((entry for entry in versions if entry.get("name") == name), None)
if not matched:
missing.append(name)
else:
matches.append(int(matched["id"]))
if missing:
fail(
"Could not resolve CurseForge game version names: "
+ ", ".join(missing)
+ ". Set CURSEFORGE_GAME_VERSION_IDS to bypass lookup."
)
return matches
def upload_to_curseforge(
artifact: pathlib.Path,
token: str,
project_id: str,
display_name: str,
changelog: str,
release_channel: str,
game_version_ids: list[int],
api_base: str,
) -> None:
metadata = {
"changelog": changelog,
"changelogType": "markdown",
"displayName": display_name,
"gameVersions": game_version_ids,
"releaseType": release_channel,
}
url = f"{api_base.rstrip('/')}/api/projects/{project_id}/upload-file"
print(f"Publishing {artifact.name} to CurseForge project {project_id}")
curl_request(
[
"--request",
"POST",
"--header",
f"X-Api-Token: {token}",
"--form",
f"metadata={json.dumps(metadata)}",
"--form",
f"file=@{artifact}",
url,
],
{200, 201},
)
def main() -> None:
gradle_properties = read_gradle_properties()
event = load_release_event()
release = event["release"]
repository = event.get("repository", {})
repository_full_name = repository.get("full_name", "")
repository_parts = repository_full_name.split("/", 1) if "/" in repository_full_name else ["", ""]
mod_id = gradle_properties["mod_id"]
mod_name = gradle_properties["mod_name"]
tag_name = release.get("tag_name")
if not tag_name:
fail("Release tag name missing from event payload.")
version_number = tag_name[1:] if tag_name.startswith("v") else tag_name
version_name = release.get("name") or f"{mod_name} {version_number}"
changelog = release.get("body") or f"Release {version_name}"
release_channel = derive_release_channel(tag_name, version_name, bool(release.get("prerelease")))
artifact = select_artifact(mod_id, tag_name)
print(f"Selected artifact: {artifact}")
server_url = first_non_empty(env_optional("GITEA_SERVER_URL"), env_optional("GITHUB_SERVER_URL"))
if not server_url:
fail("Could not determine the Gitea server URL from workflow environment.")
owner = first_non_empty(
repository.get("owner", {}).get("login")
or repository.get("owner_name"),
repository_parts[0],
env_optional("GITEA_REPOSITORY_OWNER"),
)
repo = first_non_empty(
repository.get("name"),
repository_parts[1],
env_optional("GITEA_REPOSITORY_NAME"),
)
if not owner or not repo:
fail("Could not determine repository owner/name from the release event.")
attach_to_gitea_release(
server_url=server_url,
owner=owner,
repo=repo,
release_id=int(release["id"]),
artifact=artifact,
token=env_required("BAGTABS_GITEA_TOKEN"),
)
game_versions = split_csv(env_required("MINECRAFT_VERSIONS"))
loaders = split_csv(env_required("MOD_LOADERS"))
upload_to_modrinth(
artifact=artifact,
token=env_required("MODRINTH_TOKEN"),
project_id=env_required("MODRINTH_PROJECT_ID"),
version_number=version_number,
version_name=version_name,
changelog=changelog,
release_channel=release_channel,
game_versions=game_versions,
loaders=loaders,
)
curseforge_token = env_required("CURSEFORGE_TOKEN")
curseforge_api_base = env_optional("CURSEFORGE_API_BASE", "https://minecraft.curseforge.com")
curseforge_game_version_ids = resolve_curseforge_game_version_ids(
token=curseforge_token,
game_version_names=split_csv(env_optional("CURSEFORGE_GAME_VERSION_NAMES")),
explicit_ids=split_csv(env_optional("CURSEFORGE_GAME_VERSION_IDS")),
api_base=curseforge_api_base,
)
upload_to_curseforge(
artifact=artifact,
token=curseforge_token,
project_id=env_required("CURSEFORGE_PROJECT_ID"),
display_name=version_name,
changelog=changelog,
release_channel=release_channel,
game_version_ids=curseforge_game_version_ids,
api_base=curseforge_api_base,
)
print("Release publishing complete.")
if __name__ == "__main__":
main()

View File

@@ -3,25 +3,44 @@ package com.trunksbomb.bagtabs;
import com.trunksbomb.bagtabs.client.BagScreen;
import com.trunksbomb.bagtabs.client.BagNamerScreen;
import com.trunksbomb.bagtabs.client.BagTabOverlay;
import com.mojang.blaze3d.platform.InputConstants;
import net.minecraft.client.KeyMapping;
import net.neoforged.bus.api.IEventBus;
import net.minecraft.client.color.item.ItemColor;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.DyedItemColor;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.fml.common.Mod;
import net.neoforged.neoforge.client.event.ClientTickEvent;
import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent;
import net.neoforged.neoforge.client.event.RegisterColorHandlersEvent;
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
import net.neoforged.neoforge.client.event.ScreenEvent;
import net.neoforged.neoforge.common.NeoForge;
import org.lwjgl.glfw.GLFW;
@Mod(value = BagTabs.MODID, dist = Dist.CLIENT)
public class BagTabsClient {
private static final String KEY_CATEGORY = "key.categories.bagtabs";
private static final KeyMapping OPEN_LAST_BAG = new KeyMapping(
"key.bagtabs.open_last_bag",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_BACKSLASH,
KEY_CATEGORY
);
public BagTabsClient(IEventBus modEventBus) {
modEventBus.addListener(BagTabsClient::registerScreens);
modEventBus.addListener(BagTabsClient::registerItemColors);
modEventBus.addListener(BagTabsClient::registerKeyMappings);
NeoForge.EVENT_BUS.addListener(BagTabsClient::renderTabsPre);
NeoForge.EVENT_BUS.addListener(BagTabsClient::renderTabs);
NeoForge.EVENT_BUS.addListener(BagTabsClient::clickTabs);
NeoForge.EVENT_BUS.addListener(BagTabsClient::dragTabs);
NeoForge.EVENT_BUS.addListener(BagTabsClient::releaseTabs);
NeoForge.EVENT_BUS.addListener(BagTabsClient::scrollTabs);
NeoForge.EVENT_BUS.addListener(BagTabsClient::initScreens);
NeoForge.EVENT_BUS.addListener(BagTabsClient::clientTick);
}
private static void registerScreens(RegisterMenuScreensEvent event) {
@@ -34,6 +53,14 @@ public class BagTabsClient {
event.register(bagColor, BagTabs.BAG.get());
}
private static void registerKeyMappings(RegisterKeyMappingsEvent event) {
event.register(OPEN_LAST_BAG);
}
private static void renderTabsPre(ScreenEvent.Render.Pre event) {
BagTabOverlay.renderPre(event);
}
private static void renderTabs(ScreenEvent.Render.Post event) {
BagTabOverlay.render(event);
}
@@ -46,6 +73,24 @@ public class BagTabsClient {
BagTabOverlay.mouseReleased(event);
}
private static void scrollTabs(ScreenEvent.MouseScrolled.Pre event) {
BagTabOverlay.mouseScrolled(event);
}
private static void dragTabs(ScreenEvent.MouseDragged.Pre event) {
BagTabOverlay.mouseDragged(event);
}
private static void initScreens(ScreenEvent.Init.Post event) {
BagTabOverlay.screenInit(event);
}
private static void clientTick(ClientTickEvent.Post event) {
while (OPEN_LAST_BAG.consumeClick()) {
BagTabOverlay.openLastBag();
}
}
private static final class BagItemColor {
private static final int DEFAULT_TINT = 0x9E7B4F;

View File

@@ -21,8 +21,8 @@ public final class BagAccess {
BagIdentityData.ensureBagId(stack);
}
BagCompat.BagHandler handler = BagCompat.findHandler(stack);
if (handler != null) {
bags.add(new BagEntry(slot, stack.copy(), handler));
if (handler != null || BagCompat.canShowInTabs(stack)) {
bags.add(new BagEntry(slot, stack.copy(), handler, BagCompat.getIdentity(stack)));
}
}

View File

@@ -4,6 +4,7 @@ import com.trunksbomb.bagtabs.menu.BagMenu;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import net.minecraft.core.NonNullList;
import java.util.List;
import java.util.Locale;
import net.minecraft.server.level.ServerPlayer;
@@ -14,6 +15,7 @@ import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.item.component.ItemContainerContents;
public final class BagCompat {
private static final List<BagHandler> HANDLERS = List.of(
@@ -48,6 +50,10 @@ public final class BagCompat {
return !stack.isEmpty() && (findHandler(stack) != null || looksLikeInventoryItem(stack));
}
public static boolean canShowInTabs(ItemStack stack) {
return canName(stack);
}
public static BagIdentity getIdentity(ItemStack stack) {
if (!canName(stack)) {
return null;
@@ -83,6 +89,24 @@ public final class BagCompat {
return -1;
}
public static float getFullness(ItemStack stack) {
if (stack.isEmpty()) {
return -1.0F;
}
if (stack.has(DataComponents.CONTAINER)) {
return getContainerFullness(stack.getOrDefault(DataComponents.CONTAINER, ItemContainerContents.EMPTY));
}
BagHandler handler = findHandler(stack);
if (handler == null) {
return -1.0F;
}
float fullness = handler.getFullness(stack);
return fullness < 0.0F ? -1.0F : Math.min(1.0F, fullness);
}
public interface BagHandler {
boolean supports(ItemStack stack);
@@ -99,6 +123,10 @@ public final class BagCompat {
default int getActiveBagSlot(AbstractContainerMenu menu) {
return -1;
}
default float getFullness(ItemStack stack) {
return -1.0F;
}
}
private static final class NativeBagHandler implements BagHandler {
@@ -139,6 +167,11 @@ public final class BagCompat {
return BagContainer.canInsertInto(new BagContainer(player, slot), carriedStack);
}
@Override
public float getFullness(ItemStack stack) {
return getContainerFullness(stack.getOrDefault(DataComponents.CONTAINER, ItemContainerContents.EMPTY));
}
}
private static Integer getDankFrequency(ItemStack stack) {
@@ -227,6 +260,18 @@ public final class BagCompat {
throw new RuntimeException("Failed to access Traveler's Backpack storage", exception);
}
}
@Override
public float getFullness(ItemStack stack) {
try {
Class<?> wrapperClass = Class.forName(WRAPPER_CLASS);
Object wrapper = wrapperClass.getMethod("fromStack", ItemStack.class).invoke(null, stack);
Object storage = wrapper == null ? null : invoke(wrapper, "getStorage");
return storage == null ? -1.0F : getItemHandlerFullness(storage);
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | ClassNotFoundException exception) {
return -1.0F;
}
}
}
private static final class DankStorageHandler implements BagHandler {
@@ -346,6 +391,11 @@ public final class BagCompat {
stack.shrink(inserted);
return true;
}
@Override
public float getFullness(ItemStack stack) {
return -1.0F;
}
}
private static final class SophisticatedBackpacksHandler implements BagHandler {
@@ -425,6 +475,18 @@ public final class BagCompat {
return wrapper == null ? null : invoke(wrapper, "getInventoryHandler");
}
@Override
public float getFullness(ItemStack stack) {
try {
Class<?> wrapperClass = Class.forName("net.p3pp3rf1y.sophisticatedbackpacks.backpack.wrapper.BackpackWrapper");
Object wrapper = wrapperClass.getMethod("fromStack", ItemStack.class).invoke(null, stack);
Object inventoryHandler = wrapper == null ? null : invoke(wrapper, "getInventoryHandler");
return inventoryHandler == null ? -1.0F : getItemHandlerFullness(inventoryHandler);
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | ClassNotFoundException exception) {
return -1.0F;
}
}
}
private static int findMatchingPlayerSlot(AbstractContainerMenu menu, ItemStack bagStack) {
@@ -612,4 +674,35 @@ public final class BagCompat {
throw new RuntimeException("Failed to invoke compatibility method " + className + "#" + methodName, exception);
}
}
private static float getContainerFullness(ItemContainerContents contents) {
NonNullList<ItemStack> items = NonNullList.withSize(27, ItemStack.EMPTY);
contents.copyInto(items);
return calculateFullness(items);
}
private static float getItemHandlerFullness(Object itemHandler) {
int slots = (Integer) invoke(itemHandler, "getSlots");
List<ItemStack> items = new java.util.ArrayList<>(slots);
for (int slot = 0; slot < slots; slot++) {
items.add((ItemStack) invoke(itemHandler, "getStackInSlot", new Class<?>[] {int.class}, slot));
}
return calculateFullness(items);
}
private static float calculateFullness(List<ItemStack> items) {
if (items.isEmpty()) {
return -1.0F;
}
float usedCapacity = 0.0F;
for (ItemStack item : items) {
if (item.isEmpty()) {
continue;
}
usedCapacity += item.getCount() / (float) Math.max(1, item.getMaxStackSize());
}
return Math.min(1.0F, usedCapacity / items.size());
}
}

View File

@@ -2,5 +2,5 @@ package com.trunksbomb.bagtabs.bag;
import net.minecraft.world.item.ItemStack;
public record BagEntry(int slot, ItemStack stack, BagCompat.BagHandler handler) {
public record BagEntry(int slot, ItemStack stack, BagCompat.BagHandler handler, BagIdentity identity) {
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,291 @@
package com.trunksbomb.bagtabs.client;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.trunksbomb.bagtabs.BagTabs;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ServerData;
public final class DockConfigManager {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static SessionSettings cachedSettings = new SessionSettings();
private static Path cachedPath;
private DockConfigManager() {
}
public static DockSettings getEffectiveSettings(String screenKey) {
SessionSettings settings = getSessionSettings();
DockSettings override = settings.screenOverrides.get(screenKey);
return override == null ? settings.globalDefault : override;
}
public static DockSettings getEditableSettings(String screenKey, boolean editOverride) {
SessionSettings settings = getSessionSettings();
if (!editOverride) {
return settings.globalDefault;
}
return settings.screenOverrides.getOrDefault(screenKey, settings.globalDefault.copy());
}
public static boolean hasOverride(String screenKey) {
return getSessionSettings().screenOverrides.containsKey(screenKey);
}
public static void setEditableSettings(String screenKey, boolean editOverride, DockSettings dockSettings) {
SessionSettings settings = getSessionSettings();
if (editOverride) {
settings.screenOverrides.put(screenKey, dockSettings.copy());
} else {
settings.globalDefault = dockSettings.copy();
}
save();
}
public static void setEffectiveOffsets(String screenKey, int xOffset, int yOffset) {
SessionSettings settings = getSessionSettings();
if (settings.screenOverrides.containsKey(screenKey)) {
DockSettings override = settings.screenOverrides.get(screenKey);
settings.screenOverrides.put(screenKey, override.withXOffset(xOffset).withYOffset(yOffset));
} else {
settings.globalDefault = settings.globalDefault.withXOffset(xOffset).withYOffset(yOffset);
}
save();
}
public static void clearOverride(String screenKey) {
SessionSettings settings = getSessionSettings();
settings.screenOverrides.remove(screenKey);
save();
}
public static boolean isInteractionsLocked() {
return getSessionSettings().interactionsLocked;
}
public static void setInteractionsLocked(boolean locked) {
SessionSettings settings = getSessionSettings();
settings.interactionsLocked = locked;
save();
}
public static void toggleInteractionsLocked() {
setInteractionsLocked(!isInteractionsLocked());
}
public static int getRememberedPage(String screenKey) {
return getSessionSettings().rememberedPages.getOrDefault(screenKey, 0);
}
public static void setRememberedPage(String screenKey, int page) {
SessionSettings settings = getSessionSettings();
settings.rememberedPages.put(screenKey, Math.max(0, page));
save();
}
private static SessionSettings getSessionSettings() {
Path path = getConfigPath();
if (!Objects.equals(path, cachedPath)) {
cachedPath = path;
cachedSettings = load(path);
}
return cachedSettings;
}
private static SessionSettings load(Path path) {
if (!Files.exists(path)) {
return new SessionSettings();
}
try (Reader reader = Files.newBufferedReader(path)) {
SessionSettings settings = GSON.fromJson(reader, SessionSettings.class);
return settings == null ? new SessionSettings() : settings.normalize();
} catch (IOException exception) {
BagTabs.LOGGER.warn("Failed to load dock config", exception);
return new SessionSettings();
}
}
private static void save() {
if (cachedPath == null) {
cachedPath = getConfigPath();
}
try {
Files.createDirectories(cachedPath.getParent());
try (Writer writer = Files.newBufferedWriter(cachedPath)) {
GSON.toJson(cachedSettings, writer);
}
} catch (IOException exception) {
BagTabs.LOGGER.warn("Failed to save dock config", exception);
}
}
private static Path getConfigPath() {
Minecraft minecraft = Minecraft.getInstance();
String playerKey = minecraft.player != null ? minecraft.player.getUUID().toString() : "unknown-player";
String worldKey = "default";
if (minecraft.hasSingleplayerServer() && minecraft.getSingleplayerServer() != null) {
worldKey = "singleplayer-" + sanitize(minecraft.getSingleplayerServer().getWorldData().getLevelName());
} else {
ServerData currentServer = minecraft.getCurrentServer();
if (currentServer != null) {
worldKey = "server-" + sanitize(currentServer.ip);
}
}
return minecraft.gameDirectory.toPath()
.resolve("config")
.resolve("bagtabs")
.resolve(playerKey)
.resolve(worldKey + ".json");
}
private static String sanitize(String value) {
return value == null ? "unknown" : value.replaceAll("[^a-zA-Z0-9._-]", "_");
}
public enum DockSide {
BOTTOM,
TOP,
LEFT,
RIGHT,
SCREEN_BOTTOM,
SCREEN_TOP,
SCREEN_LEFT,
SCREEN_RIGHT,
FLOATING_HORIZONTAL,
FLOATING_VERTICAL;
public DockSide next() {
DockSide[] values = values();
return values[(ordinal() + 1) % values.length];
}
public DockSide previous() {
DockSide[] values = values();
return values[(ordinal() - 1 + values.length) % values.length];
}
}
public static final class DockSettings {
private DockSide dockSide = DockSide.BOTTOM;
private int xOffset = 0;
private int yOffset = 0;
private int maxTabs = 8;
private boolean compact = false;
private boolean showFullnessIndicator = false;
public DockSide dockSide() {
return dockSide;
}
public int xOffset() {
return xOffset;
}
public int yOffset() {
return yOffset;
}
public int maxTabs() {
return maxTabs;
}
public boolean compact() {
return compact;
}
public boolean showFullnessIndicator() {
return showFullnessIndicator;
}
public DockSettings withDockSide(DockSide nextDockSide) {
DockSettings copy = copy();
copy.dockSide = nextDockSide;
return copy;
}
public DockSettings withXOffset(int nextXOffset) {
DockSettings copy = copy();
copy.xOffset = nextXOffset;
return copy;
}
public DockSettings withYOffset(int nextYOffset) {
DockSettings copy = copy();
copy.yOffset = nextYOffset;
return copy;
}
public DockSettings withMaxTabs(int nextMaxTabs) {
DockSettings copy = copy();
copy.maxTabs = Math.max(1, Math.min(20, nextMaxTabs));
return copy;
}
public DockSettings withCompact(boolean nextCompact) {
DockSettings copy = copy();
copy.compact = nextCompact;
return copy;
}
public DockSettings withShowFullnessIndicator(boolean nextShowFullnessIndicator) {
DockSettings copy = copy();
copy.showFullnessIndicator = nextShowFullnessIndicator;
return copy;
}
public DockSettings copy() {
DockSettings copy = new DockSettings();
copy.dockSide = this.dockSide;
copy.xOffset = this.xOffset;
copy.yOffset = this.yOffset;
copy.maxTabs = this.maxTabs;
copy.compact = this.compact;
copy.showFullnessIndicator = this.showFullnessIndicator;
return copy;
}
private DockSettings normalize() {
if (dockSide == null) {
dockSide = DockSide.FLOATING_HORIZONTAL;
}
maxTabs = Math.max(1, Math.min(20, maxTabs));
return this;
}
}
private static final class SessionSettings {
private boolean interactionsLocked = false;
private DockSettings globalDefault = new DockSettings();
private Map<String, DockSettings> screenOverrides = new HashMap<>();
private Map<String, Integer> rememberedPages = new HashMap<>();
private SessionSettings normalize() {
if (globalDefault == null) {
globalDefault = new DockSettings();
}
globalDefault = globalDefault.normalize();
if (screenOverrides == null) {
screenOverrides = new HashMap<>();
} else {
screenOverrides.replaceAll((key, value) -> value == null ? new DockSettings() : value.normalize());
}
if (rememberedPages == null) {
rememberedPages = new HashMap<>();
}
return this;
}
}
}

View File

@@ -0,0 +1,242 @@
package com.trunksbomb.bagtabs.client;
import com.trunksbomb.bagtabs.BagTabs;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
public class DockConfigScreen extends Screen {
private static final int PANEL_WIDTH = 196;
private static final int PANEL_HEIGHT = 234;
private static final int TITLE_BAR_HEIGHT = 18;
private final Screen parent;
private final String screenKey;
private boolean editOverride;
private DockConfigManager.DockSettings dockSettings;
private int panelX;
private int panelY;
private boolean draggingPanel;
private int dragOffsetX;
private int dragOffsetY;
private Button targetButton;
private Button sideButton;
private Button xMinusButton;
private Button xPlusButton;
private Button yMinusButton;
private Button yPlusButton;
private Button maxTabsMinusButton;
private Button maxTabsPlusButton;
private Button compactButton;
private Button resetButton;
private Button doneButton;
public DockConfigScreen(Screen parent) {
super(BagTabs.translation("dock.title"));
this.parent = parent;
this.screenKey = parent.getClass().getName();
this.editOverride = DockConfigManager.hasOverride(this.screenKey);
this.dockSettings = DockConfigManager.getEditableSettings(this.screenKey, this.editOverride).copy();
}
@Override
protected void init() {
if (this.panelX == 0 && this.panelY == 0) {
this.panelX = (this.width - PANEL_WIDTH) / 2;
this.panelY = (this.height - PANEL_HEIGHT) / 2;
}
this.targetButton = this.addRenderableWidget(Button.builder(Component.empty(), button -> {
saveCurrent();
this.editOverride = !this.editOverride;
this.dockSettings = DockConfigManager.getEditableSettings(this.screenKey, this.editOverride).copy();
syncLabels();
}).bounds(0, 0, 180, 20).build());
this.sideButton = this.addRenderableWidget(Button.builder(Component.empty(), button -> {
this.dockSettings = this.dockSettings.withDockSide(this.dockSettings.dockSide().next());
saveCurrent();
syncLabels();
}).bounds(0, 0, 180, 20).build());
this.xMinusButton = this.addRenderableWidget(Button.builder(Component.literal("-"), button -> adjustX(-getOffsetStep()))
.bounds(0, 0, 20, 20).build());
this.xPlusButton = this.addRenderableWidget(Button.builder(Component.literal("+"), button -> adjustX(getOffsetStep()))
.bounds(0, 0, 20, 20).build());
this.yMinusButton = this.addRenderableWidget(Button.builder(Component.literal("-"), button -> adjustY(-getOffsetStep()))
.bounds(0, 0, 20, 20).build());
this.yPlusButton = this.addRenderableWidget(Button.builder(Component.literal("+"), button -> adjustY(getOffsetStep()))
.bounds(0, 0, 20, 20).build());
this.maxTabsMinusButton = this.addRenderableWidget(Button.builder(Component.literal("-"), button -> adjustMaxTabs(-1))
.bounds(0, 0, 20, 20).build());
this.maxTabsPlusButton = this.addRenderableWidget(Button.builder(Component.literal("+"), button -> adjustMaxTabs(1))
.bounds(0, 0, 20, 20).build());
this.compactButton = this.addRenderableWidget(Button.builder(Component.empty(), button -> {
this.dockSettings = this.dockSettings.withCompact(!this.dockSettings.compact());
saveCurrent();
syncLabels();
}).bounds(0, 0, 180, 20).build());
this.resetButton = this.addRenderableWidget(Button.builder(BagTabs.translation("dock.reset"), button -> {
DockConfigManager.clearOverride(this.screenKey);
this.editOverride = false;
this.dockSettings = DockConfigManager.getEditableSettings(this.screenKey, false).copy();
syncLabels();
}).bounds(0, 0, 180, 20).build());
this.doneButton = this.addRenderableWidget(Button.builder(BagTabs.translation("dock.done"), button -> onClose())
.bounds(0, 0, 180, 20).build());
syncLabels();
Tooltip offsetTooltip = Tooltip.create(BagTabs.translation("dock.offset_steps"));
this.xMinusButton.setTooltip(offsetTooltip);
this.xPlusButton.setTooltip(offsetTooltip);
this.yMinusButton.setTooltip(offsetTooltip);
this.yPlusButton.setTooltip(offsetTooltip);
updateWidgetPositions();
}
@Override
public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) {
this.parent.render(guiGraphics, mouseX, mouseY, partialTick);
guiGraphics.fill(0, 0, this.width, this.height, 0x5A000000);
guiGraphics.fill(this.panelX, this.panelY, this.panelX + PANEL_WIDTH, this.panelY + PANEL_HEIGHT, 0xE03A3A3A);
guiGraphics.fill(this.panelX + 1, this.panelY + 1, this.panelX + PANEL_WIDTH - 1, this.panelY + PANEL_HEIGHT - 1, 0xE01E1E1E);
guiGraphics.fill(this.panelX + 2, this.panelY + 2, this.panelX + PANEL_WIDTH - 2, this.panelY + TITLE_BAR_HEIGHT, 0xFF5A5A5A);
guiGraphics.fill(this.panelX + 2, this.panelY + TITLE_BAR_HEIGHT, this.panelX + PANEL_WIDTH - 2, this.panelY + TITLE_BAR_HEIGHT + 1, 0xFF000000);
super.render(guiGraphics, mouseX, mouseY, partialTick);
int centerX = this.panelX + (PANEL_WIDTH / 2);
int top = this.panelY + 12;
guiGraphics.drawCenteredString(this.font, this.title, centerX, this.panelY + 6, 0xFFFFFF);
guiGraphics.drawCenteredString(this.font, Component.literal("X Offset: " + this.dockSettings.xOffset()), centerX, top + 62, 0xFFFFFF);
guiGraphics.drawCenteredString(this.font, Component.literal("Y Offset: " + this.dockSettings.yOffset()), centerX, top + 88, 0xFFFFFF);
guiGraphics.drawCenteredString(this.font, Component.literal("Max Tabs: " + this.dockSettings.maxTabs()), centerX, top + 114, 0xFFFFFF);
}
@Override
public void onClose() {
saveCurrent();
Minecraft.getInstance().setScreen(this.parent);
}
@Override
public boolean isPauseScreen() {
return false;
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button == 0 && isOverTitleBar(mouseX, mouseY)) {
this.draggingPanel = true;
this.dragOffsetX = (int) mouseX - this.panelX;
this.dragOffsetY = (int) mouseY - this.panelY;
return true;
}
if (button == 1 && this.sideButton != null && this.sideButton.isMouseOver(mouseX, mouseY)) {
this.dockSettings = this.dockSettings.withDockSide(this.dockSettings.dockSide().previous());
saveCurrent();
syncLabels();
return true;
}
return super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double dragX, double dragY) {
if (button == 0 && this.draggingPanel) {
this.panelX = clamp((int) mouseX - this.dragOffsetX, 4, this.width - PANEL_WIDTH - 4);
this.panelY = clamp((int) mouseY - this.dragOffsetY, 4, this.height - PANEL_HEIGHT - 4);
updateWidgetPositions();
return true;
}
return super.mouseDragged(mouseX, mouseY, button, dragX, dragY);
}
@Override
public boolean mouseReleased(double mouseX, double mouseY, int button) {
if (button == 0) {
this.draggingPanel = false;
}
return super.mouseReleased(mouseX, mouseY, button);
}
private void adjustX(int delta) {
this.dockSettings = this.dockSettings.withXOffset(this.dockSettings.xOffset() + delta);
saveCurrent();
syncLabels();
}
private void adjustY(int delta) {
this.dockSettings = this.dockSettings.withYOffset(this.dockSettings.yOffset() + delta);
saveCurrent();
syncLabels();
}
private void adjustMaxTabs(int delta) {
this.dockSettings = this.dockSettings.withMaxTabs(this.dockSettings.maxTabs() + delta);
saveCurrent();
syncLabels();
}
private int getOffsetStep() {
boolean shift = hasShiftDown();
boolean control = hasControlDown();
if (shift && control) {
return 100;
}
if (control) {
return 25;
}
if (shift) {
return 5;
}
return 1;
}
private void syncLabels() {
this.targetButton.setMessage(this.editOverride
? BagTabs.translation("dock.target.override")
: BagTabs.translation("dock.target.global"));
this.sideButton.setMessage(BagTabs.translation("dock.side." + this.dockSettings.dockSide().name().toLowerCase()));
this.compactButton.setMessage(BagTabs.translation(this.dockSettings.compact() ? "dock.compact.on" : "dock.compact.off"));
this.resetButton.active = this.editOverride || DockConfigManager.hasOverride(this.screenKey);
}
private void saveCurrent() {
DockConfigManager.setEditableSettings(this.screenKey, this.editOverride, this.dockSettings);
}
private void updateWidgetPositions() {
int left = this.panelX + 8;
int top = this.panelY + 26;
this.targetButton.setPosition(left, top);
this.sideButton.setPosition(left, top + 26);
this.xMinusButton.setPosition(left, top + 56);
this.xPlusButton.setPosition(left + 160, top + 56);
this.yMinusButton.setPosition(left, top + 82);
this.yPlusButton.setPosition(left + 160, top + 82);
this.maxTabsMinusButton.setPosition(left, top + 108);
this.maxTabsPlusButton.setPosition(left + 160, top + 108);
this.compactButton.setPosition(left, top + 134);
this.resetButton.setPosition(left, top + 160);
this.doneButton.setPosition(left, top + 186);
}
private boolean isOverTitleBar(double mouseX, double mouseY) {
return mouseX >= this.panelX + 2
&& mouseX < this.panelX + PANEL_WIDTH - 2
&& mouseY >= this.panelY + 2
&& mouseY < this.panelY + TITLE_BAR_HEIGHT;
}
private int clamp(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
}

View File

@@ -0,0 +1,212 @@
package com.trunksbomb.bagtabs.client;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import com.trunksbomb.bagtabs.BagTabs;
import com.trunksbomb.bagtabs.bag.BagEntry;
import com.trunksbomb.bagtabs.bag.BagIdentity;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import net.minecraft.client.Minecraft;
public final class TabPinManager {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Type PINNED_TAB_LIST_TYPE = new TypeToken<List<PinnedTab>>() {}.getType();
private static boolean loaded = false;
private static final List<PinnedTab> PINNED_TABS = new ArrayList<>();
private TabPinManager() {
}
public static List<BagEntry> sortTabs(List<BagEntry> bags) {
ensureLoaded();
return bags.stream()
.sorted(Comparator
.comparingInt((BagEntry bag) -> isPinned(bag, bags) ? 0 : 1)
.thenComparingInt(bag -> getPinnedOrder(bag, bags))
.thenComparingInt(BagEntry::slot))
.toList();
}
public static boolean isPinned(BagEntry bag, List<BagEntry> allBags) {
return resolvePinnedBag(bag.identity(), allBags)
.map(resolved -> resolved.slot() == bag.slot())
.orElse(false);
}
public static String getPinFailureReason(BagEntry bag, List<BagEntry> allBags) {
BagIdentity identity = bag.identity();
if (identity == null || identity.stable()) {
return null;
}
for (PinnedTab pinnedTab : PINNED_TABS) {
if (!pinnedTab.identityKey().equals(identity.key())) {
continue;
}
BagEntry resolved = resolvePinnedBag(identity, allBags).orElse(null);
if (resolved != null && resolved.slot() != bag.slot()) {
return "Only one fallback-identified bag with this name can be pinned.";
}
}
return null;
}
public static ToggleResult togglePin(BagEntry bag, List<BagEntry> allBags) {
ensureLoaded();
BagIdentity identity = bag.identity();
if (identity == null) {
return new ToggleResult(false, "This bag can't be pinned yet.");
}
if (isPinned(bag, allBags)) {
PINNED_TABS.removeIf(pinnedTab -> pinnedTab.identityKey().equals(identity.key()));
save();
return new ToggleResult(true, null);
}
String failureReason = getPinFailureReason(bag, allBags);
if (failureReason != null) {
return new ToggleResult(false, failureReason);
}
PINNED_TABS.add(new PinnedTab(identity.key(), identity.stable(), bag.slot()));
save();
return new ToggleResult(true, null);
}
public static List<String> getPinnedIdentityOrder(List<BagEntry> allBags) {
ensureLoaded();
List<String> order = new ArrayList<>();
for (PinnedTab pinnedTab : PINNED_TABS) {
resolvePinnedBag(pinnedTab.identityKey(), allBags).ifPresent(resolved -> order.add(resolved.identity().key()));
}
return order;
}
public static void applyPinnedOrder(List<String> orderedIdentityKeys, List<BagEntry> allBags) {
ensureLoaded();
List<PinnedTab> reordered = new ArrayList<>();
for (String identityKey : orderedIdentityKeys) {
PinnedTab existing = PINNED_TABS.stream()
.filter(tab -> tab.identityKey().equals(identityKey))
.findFirst()
.orElse(null);
BagEntry resolved = resolvePinnedBag(identityKey, allBags).orElse(null);
if (existing != null && resolved != null) {
reordered.add(new PinnedTab(identityKey, existing.stable(), resolved.slot()));
}
}
for (PinnedTab existing : PINNED_TABS) {
if (reordered.stream().noneMatch(tab -> tab.identityKey().equals(existing.identityKey()))) {
reordered.add(existing);
}
}
PINNED_TABS.clear();
PINNED_TABS.addAll(reordered);
save();
}
private static int getPinnedOrder(BagEntry bag, List<BagEntry> allBags) {
for (int i = 0; i < PINNED_TABS.size(); i++) {
BagEntry resolved = resolvePinnedBag(PINNED_TABS.get(i).identityKey(), allBags).orElse(null);
if (resolved != null && resolved.slot() == bag.slot()) {
return i;
}
}
return Integer.MAX_VALUE;
}
private static java.util.Optional<BagEntry> resolvePinnedBag(BagIdentity identity, List<BagEntry> allBags) {
return identity == null ? java.util.Optional.empty() : resolvePinnedBag(identity.key(), allBags);
}
private static java.util.Optional<BagEntry> resolvePinnedBag(String identityKey, List<BagEntry> allBags) {
PinnedTab pinnedTab = PINNED_TABS.stream()
.filter(tab -> tab.identityKey().equals(identityKey))
.findFirst()
.orElse(null);
if (pinnedTab == null) {
return java.util.Optional.empty();
}
List<BagEntry> matches = allBags.stream()
.filter(bag -> bag.identity() != null && Objects.equals(bag.identity().key(), identityKey))
.sorted(Comparator.comparingInt(BagEntry::slot))
.toList();
if (matches.isEmpty()) {
return java.util.Optional.empty();
}
return matches.stream()
.filter(match -> match.slot() == pinnedTab.preferredSlot())
.findFirst()
.or(() -> java.util.Optional.of(matches.getFirst()));
}
private static void ensureLoaded() {
if (loaded) {
return;
}
loaded = true;
Path path = getConfigPath();
if (!Files.exists(path)) {
return;
}
try (Reader reader = Files.newBufferedReader(path)) {
List<PinnedTab> loadedTabs = GSON.fromJson(reader, PINNED_TAB_LIST_TYPE);
if (loadedTabs != null) {
PINNED_TABS.clear();
Set<String> seenKeys = new HashSet<>();
for (PinnedTab pinnedTab : loadedTabs) {
if (seenKeys.add(pinnedTab.identityKey())) {
PINNED_TABS.add(pinnedTab);
}
}
}
} catch (IOException exception) {
BagTabs.LOGGER.warn("Failed to load pinned bag tab state", exception);
}
}
private static void save() {
Path path = getConfigPath();
try {
Files.createDirectories(path.getParent());
try (Writer writer = Files.newBufferedWriter(path)) {
GSON.toJson(PINNED_TABS, PINNED_TAB_LIST_TYPE, writer);
}
} catch (IOException exception) {
BagTabs.LOGGER.warn("Failed to save pinned bag tab state", exception);
}
}
private static Path getConfigPath() {
return Minecraft.getInstance().gameDirectory.toPath().resolve("config").resolve("bagtabs-pinned-tabs.json");
}
private record PinnedTab(String identityKey, boolean stable, int preferredSlot) {
}
public record ToggleResult(boolean changed, String failureReason) {
}
}

View File

@@ -2,13 +2,27 @@ package com.trunksbomb.bagtabs.network;
import com.trunksbomb.bagtabs.BagTabs;
import com.trunksbomb.bagtabs.bag.BagCompat;
import com.trunksbomb.bagtabs.bag.InventoryBag;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.network.protocol.game.ClientboundSetCarriedItemPacket;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.BowItem;
import net.minecraft.world.item.CrossbowItem;
import net.minecraft.world.item.DiggerItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ProjectileWeaponItem;
import net.minecraft.world.item.ShearsItem;
import net.minecraft.world.item.ShieldItem;
import net.minecraft.world.item.SwordItem;
import net.minecraft.world.item.TridentItem;
import net.neoforged.neoforge.network.handling.IPayloadContext;
public record OpenBagPayload(int slot) implements CustomPacketPayload {
@@ -37,6 +51,99 @@ public record OpenBagPayload(int slot) implements CustomPacketPayload {
BagCompat.BagHandler handler = BagCompat.findHandler(stack);
if (handler != null) {
handler.open(serverPlayer, payload.slot(), stack);
return;
}
if (BagCompat.canShowInTabs(stack)) {
if (!openViaHotbarFallback(serverPlayer, payload.slot())) {
serverPlayer.sendSystemMessage(BagTabs.translation("generic_open_failed"));
BagTabs.LOGGER.debug("Failed to open bag fallback for item {} in slot {}", stack, payload.slot());
}
}
}
private static boolean openViaHotbarFallback(ServerPlayer player, int sourceSlot) {
Inventory inventory = player.getInventory();
ItemStack sourceStack = inventory.getItem(sourceSlot);
if (sourceStack.isEmpty()) {
return false;
}
int targetHotbarSlot = chooseHotbarSlot(inventory, sourceSlot);
if (targetHotbarSlot < 0) {
return false;
}
if (sourceSlot != targetHotbarSlot) {
ItemStack hotbarStack = inventory.getItem(targetHotbarSlot).copy();
inventory.setItem(targetHotbarSlot, sourceStack.copy());
inventory.setItem(sourceSlot, hotbarStack);
}
inventory.selected = targetHotbarSlot;
player.connection.send(new ClientboundSetCarriedItemPacket(targetHotbarSlot));
player.inventoryMenu.broadcastChanges();
ItemStack mainHandStack = player.getItemInHand(InteractionHand.MAIN_HAND);
if (mainHandStack.isEmpty()) {
return false;
}
int previousContainerId = player.containerMenu.containerId;
InteractionResult result = player.gameMode.useItem(player, player.level(), mainHandStack, InteractionHand.MAIN_HAND);
player.inventoryMenu.broadcastChanges();
return result.consumesAction() || player.containerMenu.containerId != previousContainerId;
}
private static int chooseHotbarSlot(Inventory inventory, int sourceSlot) {
if (sourceSlot >= 0 && sourceSlot < Inventory.getSelectionSize()) {
return sourceSlot;
}
for (int hotbarSlot = 0; hotbarSlot < Inventory.getSelectionSize(); hotbarSlot++) {
if (inventory.getItem(hotbarSlot).isEmpty()) {
return hotbarSlot;
}
}
for (int hotbarSlot = 0; hotbarSlot < Inventory.getSelectionSize(); hotbarSlot++) {
if (isBuildingBlock(inventory.getItem(hotbarSlot))) {
return hotbarSlot;
}
}
for (int hotbarSlot = 0; hotbarSlot < Inventory.getSelectionSize(); hotbarSlot++) {
if (isPreferredMiscItem(inventory.getItem(hotbarSlot))) {
return hotbarSlot;
}
}
return -1;
}
private static boolean isBuildingBlock(ItemStack stack) {
return !stack.isEmpty() && stack.getItem() instanceof BlockItem && !isTorch(stack.getItem());
}
private static boolean isPreferredMiscItem(ItemStack stack) {
if (stack.isEmpty()) {
return false;
}
Item item = stack.getItem();
return !stack.has(DataComponents.FOOD)
&& !isTorch(item)
&& !(item instanceof DiggerItem)
&& !(item instanceof SwordItem)
&& !(item instanceof BowItem)
&& !(item instanceof CrossbowItem)
&& !(item instanceof TridentItem)
&& !(item instanceof ShieldItem)
&& !(item instanceof ShearsItem)
&& !(item instanceof ProjectileWeaponItem);
}
private static boolean isTorch(Item item) {
return item instanceof BlockItem blockItem
&& blockItem.getBlock().getDescriptionId().toLowerCase(java.util.Locale.ROOT).contains("torch");
}
}

View File

@@ -5,7 +5,39 @@
"container.bagtabs.bag": "Bag",
"container.bagtabs.bag_namer": "Bag Namer",
"bagtabs.tooltip.click_to_open": "Open from your inventory tabs",
"bagtabs.tooltip.hotbar_fallback": "This container is not fully supported",
"bagtabs.gui.bag_namer.name": "New Name",
"bagtabs.gui.bag_namer.placeholder": "Leave blank to clear",
"bagtabs.gui.bag_namer.rename": "Rename"
"bagtabs.gui.bag_namer.rename": "Rename",
"bagtabs.dock.title": "Tab Dock Settings",
"bagtabs.dock.done": "Done",
"bagtabs.dock.reset": "Reset Screen Override",
"bagtabs.dock.target.global": "Editing: Global Default",
"bagtabs.dock.target.override": "Editing: This Screen Override",
"bagtabs.dock.x_offset": "X Offset",
"bagtabs.dock.y_offset": "Y Offset",
"bagtabs.dock.max_tabs": "Max Tabs",
"bagtabs.dock.compact.on": "Layout: Compact",
"bagtabs.dock.compact.off": "Layout: Normal",
"bagtabs.dock.fullness.on": "Fullness: Shown",
"bagtabs.dock.fullness.off": "Fullness: Hidden",
"bagtabs.dock.open": "Open dock settings",
"bagtabs.dock.lock": "Lock tab interactions",
"bagtabs.dock.unlock": "Unlock tab interactions",
"bagtabs.dock.scroll_prev": "Show previous tabs",
"bagtabs.dock.scroll_next": "Show more tabs",
"bagtabs.dock.offset_steps": "Click: 1, Shift: 5, Ctrl: 25, Ctrl+Shift: 100",
"bagtabs.dock.side.bottom": "Dock: Bottom",
"bagtabs.dock.side.top": "Dock: Top",
"bagtabs.dock.side.left": "Dock: Left",
"bagtabs.dock.side.right": "Dock: Right",
"bagtabs.dock.side.screen_bottom": "Dock: Screen Bottom",
"bagtabs.dock.side.screen_top": "Dock: Screen Top",
"bagtabs.dock.side.screen_left": "Dock: Screen Left",
"bagtabs.dock.side.screen_right": "Dock: Screen Right",
"bagtabs.dock.side.floating_horizontal": "Dock: Floating Horizontal",
"bagtabs.dock.side.floating_vertical": "Dock: Floating Vertical",
"bagtabs.generic_open_failed": "No safe hotbar slot was available to open that bag.",
"key.categories.bagtabs": "Bag Tabs",
"key.bagtabs.open_last_bag": "Open Last Bag"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 B

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 B

After

Width:  |  Height:  |  Size: 452 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 B