aboutsummaryrefslogtreecommitdiff
path: root/src/main/kotlin/features/inventory
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/kotlin/features/inventory')
-rw-r--r--src/main/kotlin/features/inventory/CraftingOverlay.kt66
-rw-r--r--src/main/kotlin/features/inventory/ItemRarityCosmetics.kt85
-rw-r--r--src/main/kotlin/features/inventory/PriceData.kt51
-rw-r--r--src/main/kotlin/features/inventory/SaveCursorPosition.kt66
-rw-r--r--src/main/kotlin/features/inventory/SlotLocking.kt203
-rw-r--r--src/main/kotlin/features/inventory/buttons/InventoryButton.kt85
-rw-r--r--src/main/kotlin/features/inventory/buttons/InventoryButtonEditor.kt184
-rw-r--r--src/main/kotlin/features/inventory/buttons/InventoryButtonTemplates.kt35
-rw-r--r--src/main/kotlin/features/inventory/buttons/InventoryButtons.kt88
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StorageBackingHandle.kt53
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StorageData.kt21
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StorageOverlay.kt154
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StorageOverlayCustom.kt98
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StorageOverlayScreen.kt296
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StorageOverviewScreen.kt123
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StoragePageSlot.kt66
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/VirtualInventory.kt65
17 files changed, 1739 insertions, 0 deletions
diff --git a/src/main/kotlin/features/inventory/CraftingOverlay.kt b/src/main/kotlin/features/inventory/CraftingOverlay.kt
new file mode 100644
index 0000000..031ef78
--- /dev/null
+++ b/src/main/kotlin/features/inventory/CraftingOverlay.kt
@@ -0,0 +1,66 @@
+
+
+package moe.nea.firmament.features.inventory
+
+import net.minecraft.client.gui.screen.ingame.GenericContainerScreen
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Formatting
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.SlotRenderEvents
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.rei.FirmamentReiPlugin.Companion.asItemEntry
+import moe.nea.firmament.rei.SBItemEntryDefinition
+import moe.nea.firmament.rei.recipes.SBCraftingRecipe
+import moe.nea.firmament.util.MC
+
+object CraftingOverlay : FirmamentFeature {
+
+ private var screen: GenericContainerScreen? = null
+ private var recipe: SBCraftingRecipe? = null
+ private val craftingOverlayIndices = listOf(
+ 10, 11, 12,
+ 19, 20, 21,
+ 28, 29, 30,
+ )
+
+
+ fun setOverlay(screen: GenericContainerScreen, recipe: SBCraftingRecipe) {
+ this.screen = screen
+ this.recipe = recipe
+ }
+
+ override val identifier: String
+ get() = "crafting-overlay"
+
+ @Subscribe
+ fun onSlotRender(event: SlotRenderEvents.After) {
+ val slot = event.slot
+ val recipe = this.recipe ?: return
+ if (slot.inventory != screen?.screenHandler?.inventory) return
+ val recipeIndex = craftingOverlayIndices.indexOf(slot.index)
+ if (recipeIndex < 0) return
+ val expectedItem = recipe.neuRecipe.inputs[recipeIndex]
+ val actualStack = slot.stack ?: ItemStack.EMPTY!!
+ val actualEntry = SBItemEntryDefinition.getEntry(actualStack).value
+ if ((actualEntry.skyblockId.neuItem != expectedItem.itemId || actualEntry.getStackSize() < expectedItem.amount) && expectedItem.amount.toInt() != 0) {
+ event.context.fill(
+ event.slot.x,
+ event.slot.y,
+ event.slot.x + 16,
+ event.slot.y + 16,
+ 0x80FF0000.toInt()
+ )
+ }
+ if (!slot.hasStack()) {
+ val itemStack = SBItemEntryDefinition.getEntry(expectedItem).asItemEntry().value
+ event.context.drawItem(itemStack, event.slot.x, event.slot.y)
+ event.context.drawItemInSlot(
+ MC.font,
+ itemStack,
+ event.slot.x,
+ event.slot.y,
+ "${Formatting.RED}${expectedItem.amount.toInt()}"
+ )
+ }
+ }
+}
diff --git a/src/main/kotlin/features/inventory/ItemRarityCosmetics.kt b/src/main/kotlin/features/inventory/ItemRarityCosmetics.kt
new file mode 100644
index 0000000..566a813
--- /dev/null
+++ b/src/main/kotlin/features/inventory/ItemRarityCosmetics.kt
@@ -0,0 +1,85 @@
+
+
+package moe.nea.firmament.features.inventory
+
+import java.awt.Color
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Formatting
+import net.minecraft.util.Identifier
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.HotbarItemRenderEvent
+import moe.nea.firmament.events.SlotRenderEvents
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.item.loreAccordingToNbt
+import moe.nea.firmament.util.lastNotNullOfOrNull
+import moe.nea.firmament.util.memoize
+import moe.nea.firmament.util.memoizeIdentity
+import moe.nea.firmament.util.unformattedString
+
+object ItemRarityCosmetics : FirmamentFeature {
+ override val identifier: String
+ get() = "item-rarity-cosmetics"
+
+ object TConfig : ManagedConfig(identifier) {
+ val showItemRarityBackground by toggle("background") { false }
+ val showItemRarityInHotbar by toggle("background-hotbar") { false }
+ }
+
+ override val config: ManagedConfig
+ get() = TConfig
+
+ private val rarityToColor = mapOf(
+ "UNCOMMON" to Formatting.GREEN,
+ "COMMON" to Formatting.WHITE,
+ "RARE" to Formatting.DARK_BLUE,
+ "EPIC" to Formatting.DARK_PURPLE,
+ "LEGENDARY" to Formatting.GOLD,
+ "LEGENJERRY" to Formatting.GOLD,
+ "MYTHIC" to Formatting.LIGHT_PURPLE,
+ "DIVINE" to Formatting.BLUE,
+ "SPECIAL" to Formatting.DARK_RED,
+ "SUPREME" to Formatting.DARK_RED,
+ ).mapValues {
+ val c = Color(it.value.colorValue!!)
+ Triple(c.red / 255F, c.green / 255F, c.blue / 255F)
+ }
+
+ private fun getSkyblockRarity0(itemStack: ItemStack): Triple<Float, Float, Float>? {
+ return itemStack.loreAccordingToNbt.lastNotNullOfOrNull {
+ val entry = it.unformattedString
+ rarityToColor.entries.find { (k, v) -> k in entry }?.value
+ }
+ }
+
+ val getSkyblockRarity = ::getSkyblockRarity0.memoizeIdentity(100)
+
+
+ fun drawItemStackRarity(drawContext: DrawContext, x: Int, y: Int, item: ItemStack) {
+ val (r, g, b) = getSkyblockRarity(item) ?: return
+ drawContext.drawSprite(
+ x, y,
+ 0,
+ 16, 16,
+ MC.guiAtlasManager.getSprite(Identifier.of("firmament:item_rarity_background")),
+ r, g, b, 1F
+ )
+ }
+
+
+ @Subscribe
+ fun onRenderSlot(it: SlotRenderEvents.Before) {
+ if (!TConfig.showItemRarityBackground) return
+ val stack = it.slot.stack ?: return
+ drawItemStackRarity(it.context, it.slot.x, it.slot.y, stack)
+ }
+
+ @Subscribe
+ fun onRenderHotbarItem(it: HotbarItemRenderEvent) {
+ if (!TConfig.showItemRarityInHotbar) return
+ val stack = it.item
+ drawItemStackRarity(it.context, it.x, it.y, stack)
+ }
+}
diff --git a/src/main/kotlin/features/inventory/PriceData.kt b/src/main/kotlin/features/inventory/PriceData.kt
new file mode 100644
index 0000000..c61f8e8
--- /dev/null
+++ b/src/main/kotlin/features/inventory/PriceData.kt
@@ -0,0 +1,51 @@
+
+
+package moe.nea.firmament.features.inventory
+
+import net.minecraft.text.Text
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.ItemTooltipEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.repo.HypixelStaticData
+import moe.nea.firmament.util.FirmFormatters
+import moe.nea.firmament.util.skyBlockId
+
+object PriceData : FirmamentFeature {
+ override val identifier: String
+ get() = "price-data"
+
+ object TConfig : ManagedConfig(identifier) {
+ val tooltipEnabled by toggle("enable-always") { true }
+ val enableKeybinding by keyBindingWithDefaultUnbound("enable-keybind")
+ }
+
+ override val config get() = TConfig
+
+ @Subscribe
+ fun onItemTooltip(it: ItemTooltipEvent) {
+ if (!TConfig.tooltipEnabled && !TConfig.enableKeybinding.isPressed()) {
+ return
+ }
+ val sbId = it.stack.skyBlockId
+ val bazaarData = HypixelStaticData.bazaarData[sbId]
+ val lowestBin = HypixelStaticData.lowestBin[sbId]
+ if (bazaarData != null) {
+ it.lines.add(Text.literal(""))
+ it.lines.add(
+ Text.stringifiedTranslatable("firmament.tooltip.bazaar.sell-order",
+ FirmFormatters.formatCommas(bazaarData.quickStatus.sellPrice, 1))
+ )
+ it.lines.add(
+ Text.stringifiedTranslatable("firmament.tooltip.bazaar.buy-order",
+ FirmFormatters.formatCommas(bazaarData.quickStatus.buyPrice, 1))
+ )
+ } else if (lowestBin != null) {
+ it.lines.add(Text.literal(""))
+ it.lines.add(
+ Text.stringifiedTranslatable("firmament.tooltip.ah.lowestbin",
+ FirmFormatters.formatCommas(lowestBin, 1))
+ )
+ }
+ }
+}
diff --git a/src/main/kotlin/features/inventory/SaveCursorPosition.kt b/src/main/kotlin/features/inventory/SaveCursorPosition.kt
new file mode 100644
index 0000000..1c55753
--- /dev/null
+++ b/src/main/kotlin/features/inventory/SaveCursorPosition.kt
@@ -0,0 +1,66 @@
+
+
+package moe.nea.firmament.features.inventory
+
+import kotlin.math.absoluteValue
+import kotlin.time.Duration.Companion.milliseconds
+import net.minecraft.client.util.InputUtil
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.TimeMark
+import moe.nea.firmament.util.assertNotNullOr
+
+object SaveCursorPosition : FirmamentFeature {
+ override val identifier: String
+ get() = "save-cursor-position"
+
+ object TConfig : ManagedConfig(identifier) {
+ val enable by toggle("enable") { true }
+ val tolerance by duration("tolerance", 10.milliseconds, 5000.milliseconds) { 500.milliseconds }
+ }
+
+ override val config: TConfig
+ get() = TConfig
+
+ var savedPositionedP1: Pair<Double, Double>? = null
+ var savedPosition: SavedPosition? = null
+
+ data class SavedPosition(
+ val middle: Pair<Double, Double>,
+ val cursor: Pair<Double, Double>,
+ val savedAt: TimeMark = TimeMark.now()
+ )
+
+ @JvmStatic
+ fun saveCursorOriginal(positionedX: Double, positionedY: Double) {
+ savedPositionedP1 = Pair(positionedX, positionedY)
+ }
+
+ @JvmStatic
+ fun loadCursor(middleX: Double, middleY: Double): Pair<Double, Double>? {
+ if (!TConfig.enable) return null
+ val lastPosition = savedPosition?.takeIf { it.savedAt.passedTime() < TConfig.tolerance }
+ savedPosition = null
+ if (lastPosition != null &&
+ (lastPosition.middle.first - middleX).absoluteValue < 1 &&
+ (lastPosition.middle.second - middleY).absoluteValue < 1
+ ) {
+ InputUtil.setCursorParameters(
+ MC.window.handle,
+ InputUtil.GLFW_CURSOR_NORMAL,
+ lastPosition.cursor.first,
+ lastPosition.cursor.second
+ )
+ return lastPosition.cursor
+ }
+ return null
+ }
+
+ @JvmStatic
+ fun saveCursorMiddle(middleX: Double, middleY: Double) {
+ if (!TConfig.enable) return
+ val cursorPos = assertNotNullOr(savedPositionedP1) { return }
+ savedPosition = SavedPosition(Pair(middleX, middleY), cursorPos)
+ }
+}
diff --git a/src/main/kotlin/features/inventory/SlotLocking.kt b/src/main/kotlin/features/inventory/SlotLocking.kt
new file mode 100644
index 0000000..a50d8fb
--- /dev/null
+++ b/src/main/kotlin/features/inventory/SlotLocking.kt
@@ -0,0 +1,203 @@
+
+
+@file:UseSerializers(DashlessUUIDSerializer::class)
+
+package moe.nea.firmament.features.inventory
+
+import com.mojang.blaze3d.systems.RenderSystem
+import java.util.UUID
+import org.lwjgl.glfw.GLFW
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import kotlinx.serialization.serializer
+import net.minecraft.client.gui.screen.ingame.HandledScreen
+import net.minecraft.entity.player.PlayerInventory
+import net.minecraft.screen.GenericContainerScreenHandler
+import net.minecraft.screen.slot.SlotActionType
+import net.minecraft.util.Identifier
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.HandledScreenKeyPressedEvent
+import moe.nea.firmament.events.IsSlotProtectedEvent
+import moe.nea.firmament.events.SlotRenderEvents
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.keybindings.SavedKeyBinding
+import moe.nea.firmament.mixins.accessor.AccessorHandledScreen
+import moe.nea.firmament.util.CommonSoundEffects
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.SBData
+import moe.nea.firmament.util.SkyBlockIsland
+import moe.nea.firmament.util.data.ProfileSpecificDataHolder
+import moe.nea.firmament.util.item.displayNameAccordingToNbt
+import moe.nea.firmament.util.item.loreAccordingToNbt
+import moe.nea.firmament.util.json.DashlessUUIDSerializer
+import moe.nea.firmament.util.skyblockUUID
+import moe.nea.firmament.util.unformattedString
+
+object SlotLocking : FirmamentFeature {
+ override val identifier: String
+ get() = "slot-locking"
+
+ @Serializable
+ data class Data(
+ val lockedSlots: MutableSet<Int> = mutableSetOf(),
+ val lockedSlotsRift: MutableSet<Int> = mutableSetOf(),
+
+ val lockedUUIDs: MutableSet<UUID> = mutableSetOf(),
+ )
+
+ object TConfig : ManagedConfig(identifier) {
+ val lockSlot by keyBinding("lock") { GLFW.GLFW_KEY_L }
+ val lockUUID by keyBindingWithOutDefaultModifiers("lock-uuid") {
+ SavedKeyBinding(GLFW.GLFW_KEY_L, shift = true)
+ }
+ }
+
+ override val config: TConfig
+ get() = TConfig
+
+ object DConfig : ProfileSpecificDataHolder<Data>(serializer(), "locked-slots", ::Data)
+
+ val lockedUUIDs get() = DConfig.data?.lockedUUIDs
+
+ val lockedSlots
+ get() = when (SBData.skyblockLocation) {
+ SkyBlockIsland.RIFT -> DConfig.data?.lockedSlotsRift
+ null -> null
+ else -> DConfig.data?.lockedSlots
+ }
+
+ fun isSalvageScreen(screen: HandledScreen<*>?): Boolean {
+ if (screen == null) return false
+ return screen.title.unformattedString.contains("Salvage Item")
+ }
+
+ fun isTradeScreen(screen: HandledScreen<*>?): Boolean {
+ if (screen == null) return false
+ val handler = screen.screenHandler as? GenericContainerScreenHandler ?: return false
+ if (handler.inventory.size() < 9) return false
+ val middlePane = handler.inventory.getStack(handler.inventory.size() - 5)
+ if (middlePane == null) return false
+ return middlePane.displayNameAccordingToNbt?.unformattedString == "⇦ Your stuff"
+ }
+
+ fun isNpcShop(screen: HandledScreen<*>?): Boolean {
+ if (screen == null) return false
+ val handler = screen.screenHandler as? GenericContainerScreenHandler ?: return false
+ if (handler.inventory.size() < 9) return false
+ val sellItem = handler.inventory.getStack(handler.inventory.size() - 5)
+ if (sellItem == null) return false
+ if (sellItem.displayNameAccordingToNbt?.unformattedString == "Sell Item") return true
+ val lore = sellItem.loreAccordingToNbt
+ return (lore.lastOrNull() ?: return false).unformattedString == "Click to buyback!"
+ }
+
+ @Subscribe
+ fun onSalvageProtect(event: IsSlotProtectedEvent) {
+ if (event.slot == null) return
+ if (!event.slot.hasStack()) return
+ if (event.slot.stack.displayNameAccordingToNbt?.unformattedString != "Salvage Items") return
+ val inv = event.slot.inventory
+ var anyBlocked = false
+ for (i in 0 until event.slot.index) {
+ val stack = inv.getStack(i)
+ if (IsSlotProtectedEvent.shouldBlockInteraction(null, SlotActionType.THROW, stack))
+ anyBlocked = true
+ }
+ if (anyBlocked) {
+ event.protectSilent()
+ }
+ }
+
+ @Subscribe
+ fun onProtectUuidItems(event: IsSlotProtectedEvent) {
+ val doesNotDeleteItem = event.actionType == SlotActionType.SWAP
+ || event.actionType == SlotActionType.PICKUP
+ || event.actionType == SlotActionType.QUICK_MOVE
+ || event.actionType == SlotActionType.QUICK_CRAFT
+ || event.actionType == SlotActionType.CLONE
+ || event.actionType == SlotActionType.PICKUP_ALL
+ val isSellOrTradeScreen =
+ isNpcShop(MC.handledScreen) || isTradeScreen(MC.handledScreen) || isSalvageScreen(MC.handledScreen)
+ if ((!isSellOrTradeScreen || event.slot?.inventory !is PlayerInventory)
+ && doesNotDeleteItem
+ ) return
+ val stack = event.itemStack ?: return
+ val uuid = stack.skyblockUUID ?: return
+ if (uuid in (lockedUUIDs ?: return)) {
+ event.protect()
+ }
+ }
+
+ @Subscribe
+ fun onProtectSlot(it: IsSlotProtectedEvent) {
+ if (it.slot != null && it.slot.inventory is PlayerInventory && it.slot.index in (lockedSlots ?: setOf())) {
+ it.protect()
+ }
+ }
+
+ @Subscribe
+ fun onLockUUID(it: HandledScreenKeyPressedEvent) {
+ if (!it.matches(TConfig.lockUUID)) return
+ val inventory = MC.handledScreen ?: return
+ inventory as AccessorHandledScreen
+
+ val slot = inventory.focusedSlot_Firmament ?: return
+ val stack = slot.stack ?: return
+ val uuid = stack.skyblockUUID ?: return
+ val lockedUUIDs = lockedUUIDs ?: return
+ if (uuid in lockedUUIDs) {
+ lockedUUIDs.remove(uuid)
+ } else {
+ lockedUUIDs.add(uuid)
+ }
+ DConfig.markDirty()
+ CommonSoundEffects.playSuccess()
+ it.cancel()
+ }
+
+ @Subscribe
+ fun onLockSlot(it: HandledScreenKeyPressedEvent) {
+ if (!it.matches(TConfig.lockSlot)) return
+ val inventory = MC.handledScreen ?: return
+ inventory as AccessorHandledScreen
+
+ val slot = inventory.focusedSlot_Firmament ?: return
+ val lockedSlots = lockedSlots ?: return
+ if (slot.inventory is PlayerInventory) {
+ if (slot.index in lockedSlots) {
+ lockedSlots.remove(slot.index)
+ } else {
+ lockedSlots.add(slot.index)
+ }
+ DConfig.markDirty()
+ CommonSoundEffects.playSuccess()
+ }
+ }
+
+ @Subscribe
+ fun onRenderSlotOverlay(it: SlotRenderEvents.After) {
+ val isSlotLocked = it.slot.inventory is PlayerInventory && it.slot.index in (lockedSlots ?: setOf())
+ val isUUIDLocked = (it.slot.stack?.skyblockUUID) in (lockedUUIDs ?: setOf())
+ if (isSlotLocked || isUUIDLocked) {
+ RenderSystem.disableDepthTest()
+ it.context.drawSprite(
+ it.slot.x, it.slot.y, 0,
+ 16, 16,
+ MC.guiAtlasManager.getSprite(
+ when {
+ isSlotLocked ->
+ (Identifier.of("firmament:slot_locked"))
+
+ isUUIDLocked ->
+ (Identifier.of("firmament:uuid_locked"))
+
+ else ->
+ error("unreachable")
+ }
+ )
+ )
+ RenderSystem.enableDepthTest()
+ }
+ }
+}
diff --git a/src/main/kotlin/features/inventory/buttons/InventoryButton.kt b/src/main/kotlin/features/inventory/buttons/InventoryButton.kt
new file mode 100644
index 0000000..539edf2
--- /dev/null
+++ b/src/main/kotlin/features/inventory/buttons/InventoryButton.kt
@@ -0,0 +1,85 @@
+
+
+package moe.nea.firmament.features.inventory.buttons
+
+import com.mojang.brigadier.StringReader
+import me.shedaniel.math.Dimension
+import me.shedaniel.math.Point
+import me.shedaniel.math.Rectangle
+import kotlinx.serialization.Serializable
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.command.CommandRegistryAccess
+import net.minecraft.command.argument.ItemStackArgumentType
+import net.minecraft.item.ItemStack
+import net.minecraft.resource.featuretoggle.FeatureFlags
+import net.minecraft.util.Identifier
+import moe.nea.firmament.repo.ItemCache.asItemStack
+import moe.nea.firmament.repo.RepoManager
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.SkyblockId
+import moe.nea.firmament.util.memoize
+
+@Serializable
+data class InventoryButton(
+ var x: Int,
+ var y: Int,
+ var anchorRight: Boolean,
+ var anchorBottom: Boolean,
+ var icon: String? = "",
+ var command: String? = "",
+) {
+ companion object {
+ val itemStackParser by lazy {
+ ItemStackArgumentType.itemStack(CommandRegistryAccess.of(MC.defaultRegistries,
+ FeatureFlags.VANILLA_FEATURES))
+ }
+ val dimensions = Dimension(18, 18)
+ val getItemForName = ::getItemForName0.memoize(1024)
+ fun getItemForName0(icon: String): ItemStack {
+ val repoItem = RepoManager.getNEUItem(SkyblockId(icon))
+ var itemStack = repoItem.asItemStack(idHint = SkyblockId(icon))
+ if (repoItem == null) {
+ val giveSyntaxItem = if (icon.startsWith("/give") || icon.startsWith("give"))
+ icon.split(" ", limit = 3).getOrNull(2) ?: icon
+ else icon
+ val componentItem =
+ runCatching {
+ itemStackParser.parse(StringReader(giveSyntaxItem)).createStack(1, false)
+ }.getOrNull()
+ if (componentItem != null)
+ itemStack = componentItem
+ }
+ return itemStack
+ }
+ }
+
+ fun render(context: DrawContext) {
+ context.drawSprite(
+ 0,
+ 0,
+ 0,
+ dimensions.width,
+ dimensions.height,
+ MC.guiAtlasManager.getSprite(Identifier.of("firmament:inventory_button_background"))
+ )
+ context.drawItem(getItem(), 1, 1)
+ }
+
+ fun isValid() = !icon.isNullOrBlank() && !command.isNullOrBlank()
+
+ fun getPosition(guiRect: Rectangle): Point {
+ return Point(
+ (if (anchorRight) guiRect.maxX else guiRect.minX) + x,
+ (if (anchorBottom) guiRect.maxY else guiRect.minY) + y,
+ )
+ }
+
+ fun getBounds(guiRect: Rectangle): Rectangle {
+ return Rectangle(getPosition(guiRect), dimensions)
+ }
+
+ fun getItem(): ItemStack {
+ return getItemForName(icon ?: "")
+ }
+
+}
diff --git a/src/main/kotlin/features/inventory/buttons/InventoryButtonEditor.kt b/src/main/kotlin/features/inventory/buttons/InventoryButtonEditor.kt
new file mode 100644
index 0000000..c57563e
--- /dev/null
+++ b/src/main/kotlin/features/inventory/buttons/InventoryButtonEditor.kt
@@ -0,0 +1,184 @@
+
+
+package moe.nea.firmament.features.inventory.buttons
+
+import io.github.notenoughupdates.moulconfig.common.IItemStack
+import io.github.notenoughupdates.moulconfig.platform.ModernItemStack
+import io.github.notenoughupdates.moulconfig.xml.Bind
+import me.shedaniel.math.Point
+import me.shedaniel.math.Rectangle
+import org.lwjgl.glfw.GLFW
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.widget.ButtonWidget
+import net.minecraft.client.util.InputUtil
+import net.minecraft.text.Text
+import net.minecraft.util.math.MathHelper
+import net.minecraft.util.math.Vec2f
+import moe.nea.firmament.util.ClipboardUtils
+import moe.nea.firmament.util.FragmentGuiScreen
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.MoulConfigUtils
+
+class InventoryButtonEditor(
+ val lastGuiRect: Rectangle,
+) : FragmentGuiScreen() {
+ inner class Editor(val originalButton: InventoryButton) {
+ @field:Bind
+ var command: String = originalButton.command ?: ""
+
+ @field:Bind
+ var icon: String = originalButton.icon ?: ""
+
+ @Bind
+ fun getItemIcon(): IItemStack {
+ save()
+ return ModernItemStack.of(InventoryButton.getItemForName(icon))
+ }
+
+ @Bind
+ fun delete() {
+ buttons.removeIf { it === originalButton }
+ popup = null
+ }
+
+ fun save() {
+ originalButton.icon = icon
+ originalButton.command = command
+ }
+ }
+
+ var buttons: MutableList<InventoryButton> =
+ InventoryButtons.DConfig.data.buttons.map { it.copy() }.toMutableList()
+
+ override fun close() {
+ InventoryButtons.DConfig.data.buttons = buttons
+ InventoryButtons.DConfig.markDirty()
+ super.close()
+ }
+
+ override fun init() {
+ super.init()
+ addDrawableChild(
+ ButtonWidget.builder(Text.translatable("firmament.inventory-buttons.load-preset")) {
+ val t = ClipboardUtils.getTextContents()
+ val newButtons = InventoryButtonTemplates.loadTemplate(t)
+ if (newButtons != null)
+ buttons = newButtons.toMutableList()
+ }
+ .position(lastGuiRect.minX + 10, lastGuiRect.minY + 35)
+ .width(lastGuiRect.width - 20)
+ .build()
+ )
+ addDrawableChild(
+ ButtonWidget.builder(Text.translatable("firmament.inventory-buttons.save-preset")) {
+ ClipboardUtils.setTextContent(InventoryButtonTemplates.saveTemplate(buttons))
+ }
+ .position(lastGuiRect.minX + 10, lastGuiRect.minY + 60)
+ .width(lastGuiRect.width - 20)
+ .build()
+ )
+ }
+
+ override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+ super.render(context, mouseX, mouseY, delta)
+ context.matrices.push()
+ context.matrices.translate(0f, 0f, -10f)
+ context.fill(lastGuiRect.minX, lastGuiRect.minY, lastGuiRect.maxX, lastGuiRect.maxY, -1)
+ context.setShaderColor(1f, 1f, 1f, 1f)
+ context.matrices.pop()
+ for (button in buttons) {
+ val buttonPosition = button.getBounds(lastGuiRect)
+ context.matrices.push()
+ context.matrices.translate(buttonPosition.minX.toFloat(), buttonPosition.minY.toFloat(), 0F)
+ button.render(context)
+ context.matrices.pop()
+ }
+ }
+
+ override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean {
+ if (super.keyPressed(keyCode, scanCode, modifiers)) return true
+ if (keyCode == GLFW.GLFW_KEY_ESCAPE) {
+ close()
+ return true
+ }
+ return false
+ }
+
+ override fun mouseReleased(mouseX: Double, mouseY: Double, button: Int): Boolean {
+ if (super.mouseReleased(mouseX, mouseY, button)) return true
+ val clickedButton = buttons.firstOrNull { it.getBounds(lastGuiRect).contains(Point(mouseX, mouseY)) }
+ if (clickedButton != null && !justPerformedAClickAction) {
+ createPopup(MoulConfigUtils.loadGui("button_editor_fragment", Editor(clickedButton)), Point(mouseX, mouseY))
+ return true
+ }
+ justPerformedAClickAction = false
+ lastDraggedButton = null
+ return false
+ }
+
+ override fun mouseDragged(mouseX: Double, mouseY: Double, button: Int, deltaX: Double, deltaY: Double): Boolean {
+ if (super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) return true
+
+ if (initialDragMousePosition.distanceSquared(Vec2f(mouseX.toFloat(), mouseY.toFloat())) >= 4 * 4) {
+ initialDragMousePosition = Vec2f(-10F, -10F)
+ lastDraggedButton?.let { dragging ->
+ justPerformedAClickAction = true
+ val (anchorRight, anchorBottom, offsetX, offsetY) = getCoordsForMouse(mouseX.toInt(), mouseY.toInt())
+ ?: return true
+ dragging.x = offsetX
+ dragging.y = offsetY
+ dragging.anchorRight = anchorRight
+ dragging.anchorBottom = anchorBottom
+ }
+ }
+ return false
+ }
+
+ var lastDraggedButton: InventoryButton? = null
+ var justPerformedAClickAction = false
+ var initialDragMousePosition = Vec2f(-10F, -10F)
+
+ data class AnchoredCoords(
+ val anchorRight: Boolean,
+ val anchorBottom: Boolean,
+ val offsetX: Int,
+ val offsetY: Int,
+ )
+
+ fun getCoordsForMouse(mx: Int, my: Int): AnchoredCoords? {
+ if (lastGuiRect.contains(mx, my) || lastGuiRect.contains(
+ Point(
+ mx + InventoryButton.dimensions.width,
+ my + InventoryButton.dimensions.height,
+ )
+ )
+ ) return null
+
+ val anchorRight = mx > lastGuiRect.maxX
+ val anchorBottom = my > lastGuiRect.maxY
+ var offsetX = mx - if (anchorRight) lastGuiRect.maxX else lastGuiRect.minX
+ var offsetY = my - if (anchorBottom) lastGuiRect.maxY else lastGuiRect.minY
+ if (InputUtil.isKeyPressed(MC.window.handle, InputUtil.GLFW_KEY_LEFT_SHIFT)) {
+ offsetX = MathHelper.floor(offsetX / 20F) * 20
+ offsetY = MathHelper.floor(offsetY / 20F) * 20
+ }
+ return AnchoredCoords(anchorRight, anchorBottom, offsetX, offsetY)
+ }
+
+ override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean {
+ if (super.mouseClicked(mouseX, mouseY, button)) return true
+ val clickedButton = buttons.firstOrNull { it.getBounds(lastGuiRect).contains(Point(mouseX, mouseY)) }
+ if (clickedButton != null) {
+ lastDraggedButton = clickedButton
+ initialDragMousePosition = Vec2f(mouseX.toFloat(), mouseY.toFloat())
+ return true
+ }
+ val mx = mouseX.toInt()
+ val my = mouseY.toInt()
+ val (anchorRight, anchorBottom, offsetX, offsetY) = getCoordsForMouse(mx, my) ?: return true
+ buttons.add(InventoryButton(offsetX, offsetY, anchorRight, anchorBottom, null, null))
+ justPerformedAClickAction = true
+ return true
+ }
+
+}
diff --git a/src/main/kotlin/features/inventory/buttons/InventoryButtonTemplates.kt b/src/main/kotlin/features/inventory/buttons/InventoryButtonTemplates.kt
new file mode 100644
index 0000000..99b544b
--- /dev/null
+++ b/src/main/kotlin/features/inventory/buttons/InventoryButtonTemplates.kt
@@ -0,0 +1,35 @@
+
+
+package moe.nea.firmament.features.inventory.buttons
+
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import net.minecraft.text.Text
+import moe.nea.firmament.Firmament
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.TemplateUtil
+
+object InventoryButtonTemplates {
+
+ val legacyPrefix = "NEUBUTTONS/"
+ val modernPrefix = "MAYBEONEDAYIWILLHAVEMYOWNFORMAT"
+
+ fun loadTemplate(t: String): List<InventoryButton>? {
+ val buttons = TemplateUtil.maybeDecodeTemplate<List<String>>(legacyPrefix, t) ?: return null
+ return buttons.mapNotNull {
+ try {
+ Firmament.json.decodeFromString<InventoryButton>(it).also {
+ if (it.icon?.startsWith("extra:") == true || it.command?.any { it.isLowerCase() } == true) {
+ MC.sendChat(Text.translatable("firmament.inventory-buttons.import-failed"))
+ }
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+ }
+
+ fun saveTemplate(buttons: List<InventoryButton>): String {
+ return TemplateUtil.encodeTemplate(legacyPrefix, buttons.map { Firmament.json.encodeToString(it) })
+ }
+}
diff --git a/src/main/kotlin/features/inventory/buttons/InventoryButtons.kt b/src/main/kotlin/features/inventory/buttons/InventoryButtons.kt
new file mode 100644
index 0000000..fa90d21
--- /dev/null
+++ b/src/main/kotlin/features/inventory/buttons/InventoryButtons.kt
@@ -0,0 +1,88 @@
+
+
+package moe.nea.firmament.features.inventory.buttons
+
+import me.shedaniel.math.Rectangle
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.serializer
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.HandledScreenClickEvent
+import moe.nea.firmament.events.HandledScreenForegroundEvent
+import moe.nea.firmament.events.HandledScreenPushREIEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.ScreenUtil
+import moe.nea.firmament.util.data.DataHolder
+import moe.nea.firmament.util.getRectangle
+
+object InventoryButtons : FirmamentFeature {
+ override val identifier: String
+ get() = "inventory-buttons"
+
+ object TConfig : ManagedConfig(identifier) {
+ val _openEditor by button("open-editor") {
+ openEditor()
+ }
+ }
+
+ object DConfig : DataHolder<Data>(serializer(), identifier, ::Data)
+
+ @Serializable
+ data class Data(
+ var buttons: MutableList<InventoryButton> = mutableListOf()
+ )
+
+
+ override val config: ManagedConfig
+ get() = TConfig
+
+ fun getValidButtons() = DConfig.data.buttons.asSequence().filter { it.isValid() }
+
+ @Subscribe
+ fun onRectangles(it: HandledScreenPushREIEvent) {
+ val bounds = it.screen.getRectangle()
+ for (button in getValidButtons()) {
+ val buttonBounds = button.getBounds(bounds)
+ it.block(buttonBounds)
+ }
+ }
+
+ @Subscribe
+ fun onClickScreen(it: HandledScreenClickEvent) {
+ val bounds = it.screen.getRectangle()
+ for (button in getValidButtons()) {
+ val buttonBounds = button.getBounds(bounds)
+ if (buttonBounds.contains(it.mouseX, it.mouseY)) {
+ MC.sendCommand(button.command!! /* non null invariant covered by getValidButtons */)
+ break
+ }
+ }
+ }
+
+ @Subscribe
+ fun onRenderForeground(it: HandledScreenForegroundEvent) {
+ val bounds = it.screen.getRectangle()
+ for (button in getValidButtons()) {
+ val buttonBounds = button.getBounds(bounds)
+ it.context.matrices.push()
+ it.context.matrices.translate(buttonBounds.minX.toFloat(), buttonBounds.minY.toFloat(), 0F)
+ button.render(it.context)
+ it.context.matrices.pop()
+ }
+ lastRectangle = bounds
+ }
+
+ var lastRectangle: Rectangle? = null
+ fun openEditor() {
+ ScreenUtil.setScreenLater(
+ InventoryButtonEditor(
+ lastRectangle ?: Rectangle(
+ MC.window.scaledWidth / 2 - 100,
+ MC.window.scaledHeight / 2 - 100,
+ 200, 200,
+ )
+ )
+ )
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageBackingHandle.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageBackingHandle.kt
new file mode 100644
index 0000000..1015578
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StorageBackingHandle.kt
@@ -0,0 +1,53 @@
+
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import net.minecraft.client.gui.screen.Screen
+import net.minecraft.client.gui.screen.ingame.GenericContainerScreen
+import net.minecraft.screen.GenericContainerScreenHandler
+import moe.nea.firmament.util.ifMatches
+import moe.nea.firmament.util.unformattedString
+
+/**
+ * A handle representing the state of the "server side" screens.
+ */
+sealed interface StorageBackingHandle {
+
+ sealed interface HasBackingScreen {
+ val handler: GenericContainerScreenHandler
+ }
+
+ /**
+ * The main storage overview is open. Clicking on a slot will open that page. This page is accessible via `/storage`
+ */
+ data class Overview(override val handler: GenericContainerScreenHandler) : StorageBackingHandle, HasBackingScreen
+
+ /**
+ * An individual storage page is open. This may be a backpack or an enderchest page. This page is accessible via
+ * the [Overview] or via `/ec <index + 1>` for enderchest pages.
+ */
+ data class Page(override val handler: GenericContainerScreenHandler, val storagePageSlot: StoragePageSlot) :
+ StorageBackingHandle, HasBackingScreen
+
+ companion object {
+ private val enderChestName = "^Ender Chest \\(([1-9])/[1-9]\\)$".toRegex()
+ private val backPackName = "^.+Backpack \\(Slot #([0-9]+)\\)$".toRegex()
+
+ /**
+ * Parse a screen into a [StorageBackingHandle]. If this returns null it means that the screen is not
+ * representable as a [StorageBackingHandle], meaning another screen is open, for example the enderchest icon
+ * selection screen.
+ */
+ fun fromScreen(screen: Screen?): StorageBackingHandle? {
+ if (screen == null) return null
+ if (screen !is GenericContainerScreen) return null
+ val title = screen.title.unformattedString
+ if (title == "Storage") return Overview(screen.screenHandler)
+ return title.ifMatches(enderChestName) {
+ Page(screen.screenHandler, StoragePageSlot.ofEnderChestPage(it.groupValues[1].toInt()))
+ } ?: title.ifMatches(backPackName) {
+ Page(screen.screenHandler, StoragePageSlot.ofBackPackPage(it.groupValues[1].toInt()))
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageData.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageData.kt
new file mode 100644
index 0000000..7555c56
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StorageData.kt
@@ -0,0 +1,21 @@
+
+
+@file:UseSerializers(SortedMapSerializer::class)
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import java.util.SortedMap
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import moe.nea.firmament.util.SortedMapSerializer
+
+@Serializable
+data class StorageData(
+ val storageInventories: SortedMap<StoragePageSlot, StorageInventory> = sortedMapOf()
+) {
+ @Serializable
+ data class StorageInventory(
+ var title: String,
+ val slot: StoragePageSlot,
+ var inventory: VirtualInventory?,
+ )
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageOverlay.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlay.kt
new file mode 100644
index 0000000..b615c73
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlay.kt
@@ -0,0 +1,154 @@
+
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import java.util.SortedMap
+import kotlinx.serialization.serializer
+import net.minecraft.client.gui.screen.ingame.GenericContainerScreen
+import net.minecraft.client.gui.screen.ingame.HandledScreen
+import net.minecraft.entity.player.PlayerInventory
+import net.minecraft.item.Items
+import net.minecraft.network.packet.c2s.play.CloseHandledScreenC2SPacket
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.ScreenChangeEvent
+import moe.nea.firmament.events.SlotClickEvent
+import moe.nea.firmament.events.TickEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.customgui.customGui
+import moe.nea.firmament.util.data.ProfileSpecificDataHolder
+
+object StorageOverlay : FirmamentFeature {
+
+
+ object Data : ProfileSpecificDataHolder<StorageData>(serializer(), "storage-data", ::StorageData)
+
+ override val identifier: String
+ get() = "storage-overlay"
+
+ object TConfig : ManagedConfig(identifier) {
+ val alwaysReplace by toggle("always-replace") { true }
+ val columns by integer("rows", 1, 10) { 3 }
+ val scrollSpeed by integer("scroll-speed", 1, 50) { 10 }
+ val inverseScroll by toggle("inverse-scroll") { false }
+ val padding by integer("padding", 1, 20) { 5 }
+ val margin by integer("margin", 1, 60) { 20 }
+ }
+
+ fun adjustScrollSpeed(amount: Double): Double {
+ return amount * TConfig.scrollSpeed * (if (TConfig.inverseScroll) 1 else -1)
+ }
+
+ override val config: TConfig
+ get() = TConfig
+
+ var lastStorageOverlay: StorageOverviewScreen? = null
+ var skipNextStorageOverlayBackflip = false
+ var currentHandler: StorageBackingHandle? = null
+
+ @Subscribe
+ fun onTick(event: TickEvent) {
+ rememberContent(currentHandler ?: return)
+ }
+
+ @Subscribe
+ fun onClick(event: SlotClickEvent) {
+ if (lastStorageOverlay != null && event.slot.inventory !is PlayerInventory && event.slot.index < 9
+ && event.stack.item != Items.BLACK_STAINED_GLASS_PANE
+ ) {
+ skipNextStorageOverlayBackflip = true
+ }
+ }
+
+ @Subscribe
+ fun onScreenChange(it: ScreenChangeEvent) {
+ if (it.old == null && it.new == null) return
+ val storageOverlayScreen = it.old as? StorageOverlayScreen
+ ?: ((it.old as? HandledScreen<*>)?.customGui as? StorageOverlayCustom)?.overview
+ var storageOverviewScreen = it.old as? StorageOverviewScreen
+ val screen = it.new as? GenericContainerScreen
+ val oldHandler = currentHandler
+ currentHandler = StorageBackingHandle.fromScreen(screen)
+ rememberContent(currentHandler)
+ if (storageOverviewScreen != null && oldHandler is StorageBackingHandle.HasBackingScreen) {
+ val player = MC.player
+ assert(player != null)
+ player?.networkHandler?.sendPacket(CloseHandledScreenC2SPacket(oldHandler.handler.syncId))
+ if (player?.currentScreenHandler === oldHandler.handler) {
+ player.currentScreenHandler = player.playerScreenHandler
+ }
+ }
+ storageOverviewScreen = storageOverviewScreen ?: lastStorageOverlay
+ if (it.new == null && storageOverlayScreen != null && !storageOverlayScreen.isExiting) {
+ it.overrideScreen = storageOverlayScreen
+ return
+ }
+ if (storageOverviewScreen != null
+ && !storageOverviewScreen.isClosing
+ && (currentHandler is StorageBackingHandle.Overview || currentHandler == null)
+ ) {
+ if (skipNextStorageOverlayBackflip) {
+ skipNextStorageOverlayBackflip = false
+ } else {
+ it.overrideScreen = storageOverviewScreen
+ lastStorageOverlay = null
+ }
+ return
+ }
+ screen ?: return
+ screen.customGui = StorageOverlayCustom(
+ currentHandler ?: return,
+ screen,
+ storageOverlayScreen ?: (if (TConfig.alwaysReplace) StorageOverlayScreen() else return))
+ }
+
+ fun rememberContent(handler: StorageBackingHandle?) {
+ handler ?: return
+ // TODO: Make all of these functions work on deltas / updates instead of the entire contents
+ val data = Data.data?.storageInventories ?: return
+ when (handler) {
+ is StorageBackingHandle.Overview -> rememberStorageOverview(handler, data)
+ is StorageBackingHandle.Page -> rememberPage(handler, data)
+ }
+ Data.markDirty()
+ }
+
+ private fun rememberStorageOverview(
+ handler: StorageBackingHandle.Overview,
+ data: SortedMap<StoragePageSlot, StorageData.StorageInventory>
+ ) {
+ for ((index, stack) in handler.handler.stacks.withIndex()) {
+ // Ignore unloaded item stacks
+ if (stack.isEmpty) continue
+ val slot = StoragePageSlot.fromOverviewSlotIndex(index) ?: continue
+ val isEmpty = stack.item in StorageOverviewScreen.emptyStorageSlotItems
+ if (slot in data) {
+ if (isEmpty)
+ data.remove(slot)
+ continue
+ }
+ if (!isEmpty) {
+ data[slot] = StorageData.StorageInventory(slot.defaultName(), slot, null)
+ }
+ }
+ }
+
+ private fun rememberPage(
+ handler: StorageBackingHandle.Page,
+ data: SortedMap<StoragePageSlot, StorageData.StorageInventory>
+ ) {
+ // TODO: FIXME: FIXME NOW: Definitely don't copy all of this every tick into persistence
+ val newStacks =
+ VirtualInventory(handler.handler.stacks.take(handler.handler.rows * 9).drop(9).map { it.copy() })
+ data.compute(handler.storagePageSlot) { slot, existingInventory ->
+ (existingInventory ?: StorageData.StorageInventory(
+ slot.defaultName(),
+ slot,
+ null
+ )).also {
+ it.inventory = newStacks
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayCustom.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayCustom.kt
new file mode 100644
index 0000000..d0d9114
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayCustom.kt
@@ -0,0 +1,98 @@
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import me.shedaniel.math.Point
+import me.shedaniel.math.Rectangle
+import net.minecraft.client.MinecraftClient
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.screen.ingame.GenericContainerScreen
+import net.minecraft.entity.player.PlayerInventory
+import net.minecraft.screen.slot.Slot
+import moe.nea.firmament.mixins.accessor.AccessorHandledScreen
+import moe.nea.firmament.util.customgui.CustomGui
+
+class StorageOverlayCustom(
+ val handler: StorageBackingHandle,
+ val screen: GenericContainerScreen,
+ val overview: StorageOverlayScreen,
+) : CustomGui() {
+ override fun onVoluntaryExit(): Boolean {
+ overview.isExiting = true
+ return super.onVoluntaryExit()
+ }
+
+ override fun getBounds(): List<Rectangle> {
+ return overview.getBounds()
+ }
+
+ override fun afterSlotRender(context: DrawContext, slot: Slot) {
+ if (slot.inventory !is PlayerInventory)
+ context.disableScissor()
+ }
+
+ override fun beforeSlotRender(context: DrawContext, slot: Slot) {
+ if (slot.inventory !is PlayerInventory)
+ overview.createScissors(context)
+ }
+
+ override fun onInit() {
+ overview.init(MinecraftClient.getInstance(), screen.width, screen.height)
+ overview.init()
+ screen as AccessorHandledScreen
+ screen.x_Firmament = overview.measurements.x
+ screen.y_Firmament = overview.measurements.y
+ screen.backgroundWidth_Firmament = overview.measurements.totalWidth
+ screen.backgroundHeight_Firmament = overview.measurements.totalHeight
+ }
+
+ override fun isPointOverSlot(slot: Slot, xOffset: Int, yOffset: Int, pointX: Double, pointY: Double): Boolean {
+ if (!super.isPointOverSlot(slot, xOffset, yOffset, pointX, pointY))
+ return false
+ if (slot.inventory !is PlayerInventory) {
+ if (!overview.getScrollPanelInner().contains(pointX, pointY))
+ return false
+ }
+ return true
+ }
+
+ override fun shouldDrawForeground(): Boolean {
+ return false
+ }
+
+ override fun mouseClick(mouseX: Double, mouseY: Double, button: Int): Boolean {
+ return overview.mouseClicked(mouseX, mouseY, button, (handler as? StorageBackingHandle.Page)?.storagePageSlot)
+ }
+
+ override fun render(drawContext: DrawContext, delta: Float, mouseX: Int, mouseY: Int) {
+ overview.drawBackgrounds(drawContext)
+ overview.drawPages(drawContext,
+ mouseX,
+ mouseY,
+ delta,
+ (handler as? StorageBackingHandle.Page)?.storagePageSlot,
+ screen.screenHandler.slots.take(screen.screenHandler.rows * 9).drop(9),
+ Point((screen as AccessorHandledScreen).x_Firmament, screen.y_Firmament))
+ overview.drawScrollBar(drawContext)
+ }
+
+ override fun moveSlot(slot: Slot) {
+ val index = slot.index
+ if (index in 0..<36) {
+ val (x, y) = overview.getPlayerInventorySlotPosition(index)
+ slot.x = x - (screen as AccessorHandledScreen).x_Firmament
+ slot.y = y - screen.y_Firmament
+ } else {
+ slot.x = -100000
+ slot.y = -100000
+ }
+ }
+
+ override fun mouseScrolled(
+ mouseX: Double,
+ mouseY: Double,
+ horizontalAmount: Double,
+ verticalAmount: Double
+ ): Boolean {
+ return overview.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount)
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayScreen.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayScreen.kt
new file mode 100644
index 0000000..13c6974
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayScreen.kt
@@ -0,0 +1,296 @@
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import me.shedaniel.math.Point
+import me.shedaniel.math.Rectangle
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.screen.Screen
+import net.minecraft.screen.slot.Slot
+import net.minecraft.text.Text
+import net.minecraft.util.Identifier
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.CommandEvent
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.ScreenUtil
+import moe.nea.firmament.util.assertTrueOr
+
+class StorageOverlayScreen : Screen(Text.literal("")) {
+
+ companion object {
+ val PLAYER_WIDTH = 184
+ val PLAYER_HEIGHT = 91
+ val PLAYER_Y_INSET = 3
+ val SLOT_SIZE = 18
+ val PADDING = 10
+ val PAGE_WIDTH = SLOT_SIZE * 9
+ val HOTBAR_X = 12
+ val HOTBAR_Y = 67
+ val MAIN_INVENTORY_Y = 9
+ val SCROLL_BAR_WIDTH = 8
+ val SCROLL_BAR_HEIGHT = 16
+ }
+
+ var isExiting: Boolean = false
+ var scroll: Float = 0F
+ var pageWidthCount = StorageOverlay.TConfig.columns
+
+ inner class Measurements {
+ val innerScrollPanelWidth = PAGE_WIDTH * pageWidthCount + (pageWidthCount - 1) * PADDING
+ val overviewWidth = innerScrollPanelWidth + 3 * PADDING + SCROLL_BAR_WIDTH
+ val x = width / 2 - overviewWidth / 2
+ val overviewHeight = minOf(3 * 18 * 6, height - PLAYER_HEIGHT - minOf(80, height / 10))
+ val innerScrollPanelHeight = overviewHeight - PADDING * 2
+ val y = height / 2 - (overviewHeight + PLAYER_HEIGHT) / 2
+ val playerX = width / 2 - PLAYER_WIDTH / 2
+ val playerY = y + overviewHeight - PLAYER_Y_INSET
+ val totalWidth = overviewWidth
+ val totalHeight = overviewHeight - PLAYER_Y_INSET + PLAYER_HEIGHT
+ }
+
+ var measurements = Measurements()
+
+ var lastRenderedInnerHeight = 0
+ public override fun init() {
+ super.init()
+ pageWidthCount = StorageOverlay.TConfig.columns
+ .coerceAtMost((width - PADDING) / (PAGE_WIDTH + PADDING))
+ .coerceAtLeast(1)
+ measurements = Measurements()
+ }
+
+ override fun mouseScrolled(
+ mouseX: Double,
+ mouseY: Double,
+ horizontalAmount: Double,
+ verticalAmount: Double
+ ): Boolean {
+ scroll = (scroll + StorageOverlay.adjustScrollSpeed(verticalAmount)).toFloat()
+ .coerceAtMost(getMaxScroll())
+ .coerceAtLeast(0F)
+ return true
+ }
+
+ fun getMaxScroll() = lastRenderedInnerHeight.toFloat() - getScrollPanelInner().height
+
+ val playerInventorySprite = Identifier.of("firmament:storageoverlay/player_inventory")
+ val upperBackgroundSprite = Identifier.of("firmament:storageoverlay/upper_background")
+ val slotRowSprite = Identifier.of("firmament:storageoverlay/storage_row")
+ val scrollbarBackground = Identifier.of("firmament:storageoverlay/scroll_bar_background")
+ val scrollbarKnob = Identifier.of("firmament:storageoverlay/scroll_bar_knob")
+
+ override fun close() {
+ isExiting = true
+ super.close()
+ }
+
+ override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+ super.render(context, mouseX, mouseY, delta)
+ drawBackgrounds(context)
+ drawPages(context, mouseX, mouseY, delta, null, null, Point())
+ drawScrollBar(context)
+ drawPlayerInventory(context, mouseX, mouseY, delta)
+ }
+
+ fun getScrollbarPercentage(): Float {
+ return scroll / getMaxScroll()
+ }
+
+ fun drawScrollBar(context: DrawContext) {
+ val sbRect = getScrollBarRect()
+ context.drawGuiTexture(
+ scrollbarBackground,
+ sbRect.minX, sbRect.minY,
+ sbRect.width, sbRect.height,
+ )
+ context.drawGuiTexture(
+ scrollbarKnob,
+ sbRect.minX, sbRect.minY + (getScrollbarPercentage() * (sbRect.height - SCROLL_BAR_HEIGHT)).toInt(),
+ SCROLL_BAR_WIDTH, SCROLL_BAR_HEIGHT
+ )
+ }
+
+ fun drawBackgrounds(context: DrawContext) {
+ context.drawGuiTexture(upperBackgroundSprite,
+ measurements.x,
+ measurements.y,
+ 0,
+ measurements.overviewWidth,
+ measurements.overviewHeight)
+ context.drawGuiTexture(playerInventorySprite,
+ measurements.playerX,
+ measurements.playerY,
+ 0,
+ PLAYER_WIDTH,
+ PLAYER_HEIGHT)
+ }
+
+ fun getPlayerInventorySlotPosition(int: Int): Pair<Int, Int> {
+ if (int < 9) {
+ return Pair(measurements.playerX + int * SLOT_SIZE + HOTBAR_X, HOTBAR_Y + measurements.playerY)
+ }
+ return Pair(
+ measurements.playerX + (int % 9) * SLOT_SIZE + HOTBAR_X,
+ measurements.playerY + (int / 9 - 1) * SLOT_SIZE + MAIN_INVENTORY_Y
+ )
+ }
+
+ fun drawPlayerInventory(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+ val items = MC.player?.inventory?.main ?: return
+ items.withIndex().forEach { (index, item) ->
+ val (x, y) = getPlayerInventorySlotPosition(index)
+ context.drawItem(item, x, y, 0)
+ context.drawItemInSlot(textRenderer, item, x, y)
+ }
+ }
+
+ fun getScrollBarRect(): Rectangle {
+ return Rectangle(measurements.x + PADDING + measurements.innerScrollPanelWidth + PADDING,
+ measurements.y + PADDING,
+ SCROLL_BAR_WIDTH,
+ measurements.innerScrollPanelHeight)
+ }
+
+ fun getScrollPanelInner(): Rectangle {
+ return Rectangle(measurements.x + PADDING,
+ measurements.y + PADDING,
+ measurements.innerScrollPanelWidth,
+ measurements.innerScrollPanelHeight)
+ }
+
+ fun createScissors(context: DrawContext) {
+ val rect = getScrollPanelInner()
+ context.enableScissor(
+ rect.minX, rect.minY,
+ rect.maxX, rect.maxY
+ )
+ }
+
+ fun drawPages(
+ context: DrawContext, mouseX: Int, mouseY: Int, delta: Float,
+ excluding: StoragePageSlot?,
+ slots: List<Slot>?,
+ slotOffset: Point
+ ) {
+ createScissors(context)
+ val data = StorageOverlay.Data.data ?: StorageData()
+ layoutedForEach(data) { rect, page, inventory ->
+ drawPage(context,
+ rect.x,
+ rect.y,
+ page, inventory,
+ if (excluding == page) slots else null,
+ slotOffset
+ )
+ }
+ context.disableScissor()
+ }
+
+ override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean {
+ return mouseClicked(mouseX, mouseY, button, null)
+ }
+
+ fun mouseClicked(mouseX: Double, mouseY: Double, button: Int, activePage: StoragePageSlot?): Boolean {
+ if (getScrollPanelInner().contains(mouseX, mouseY)) {
+ val data = StorageOverlay.Data.data ?: StorageData()
+ layoutedForEach(data) { rect, page, _ ->
+ if (rect.contains(mouseX, mouseY) && activePage != page && button == 0) {
+ page.navigateTo()
+ return true
+ }
+ }
+ return false
+ }
+ val sbRect = getScrollBarRect()
+ if (sbRect.contains(mouseX, mouseY)) {
+ // TODO: support dragging of the mouse and such
+ val percentage = (mouseY - sbRect.getY()) / sbRect.getHeight()
+ scroll = (getMaxScroll() * percentage).toFloat()
+ mouseScrolled(0.0, 0.0, 0.0, 0.0)
+ return true
+ }
+ return false
+ }
+
+ private inline fun layoutedForEach(
+ data: StorageData,
+ func: (
+ rectangle: Rectangle,
+ page: StoragePageSlot, inventory: StorageData.StorageInventory,
+ ) -> Unit
+ ) {
+ var yOffset = -scroll.toInt()
+ var xOffset = 0
+ var maxHeight = 0
+ for ((page, inventory) in data.storageInventories.entries) {
+ val currentHeight = inventory.inventory?.let { it.rows * SLOT_SIZE + 4 + textRenderer.fontHeight }
+ ?: 18
+ maxHeight = maxOf(maxHeight, currentHeight)
+ val rect = Rectangle(
+ measurements.x + PADDING + (PAGE_WIDTH + PADDING) * xOffset,
+ yOffset + measurements.y + PADDING,
+ PAGE_WIDTH,
+ currentHeight
+ )
+ func(rect, page, inventory)
+ xOffset++
+ if (xOffset >= pageWidthCount) {
+ yOffset += maxHeight
+ xOffset = 0
+ maxHeight = 0
+ }
+ }
+ lastRenderedInnerHeight = maxHeight + yOffset + scroll.toInt()
+ }
+
+ fun drawPage(
+ context: DrawContext,
+ x: Int,
+ y: Int,
+ page: StoragePageSlot,
+ inventory: StorageData.StorageInventory,
+ slots: List<Slot>?,
+ slotOffset: Point,
+ ): Int {
+ val inv = inventory.inventory
+ if (inv == null) {
+ context.drawGuiTexture(upperBackgroundSprite, x, y, PAGE_WIDTH, 18)
+ context.drawText(textRenderer,
+ Text.literal("TODO: open this page"),
+ x + 4,
+ y + 4,
+ -1,
+ true)
+ return 18
+ }
+ assertTrueOr(slots == null || slots.size == inv.stacks.size) { return 0 }
+ val name = page.defaultName()
+ context.drawText(textRenderer, Text.literal(name), x + 4, y + 2,
+ if (slots == null) 0xFFFFFFFF.toInt() else 0xFFFFFF00.toInt(), true)
+ context.drawGuiTexture(slotRowSprite, x, y + 4 + textRenderer.fontHeight, PAGE_WIDTH, inv.rows * SLOT_SIZE)
+ inv.stacks.forEachIndexed { index, stack ->
+ val slotX = (index % 9) * SLOT_SIZE + x + 1
+ val slotY = (index / 9) * SLOT_SIZE + y + 4 + textRenderer.fontHeight + 1
+ if (slots == null) {
+ context.drawItem(stack, slotX, slotY)
+ context.drawItemInSlot(textRenderer, stack, slotX, slotY)
+ } else {
+ val slot = slots[index]
+ slot.x = slotX - slotOffset.x
+ slot.y = slotY - slotOffset.y
+ }
+ }
+ return inv.rows * SLOT_SIZE + 4 + textRenderer.fontHeight
+ }
+
+ fun getBounds(): List<Rectangle> {
+ return listOf(
+ Rectangle(measurements.x,
+ measurements.y,
+ measurements.overviewWidth,
+ measurements.overviewHeight),
+ Rectangle(measurements.playerX,
+ measurements.playerY,
+ PLAYER_WIDTH,
+ PLAYER_HEIGHT))
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageOverviewScreen.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageOverviewScreen.kt
new file mode 100644
index 0000000..2cbd54e
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StorageOverviewScreen.kt
@@ -0,0 +1,123 @@
+
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import org.lwjgl.glfw.GLFW
+import kotlin.math.max
+import net.minecraft.block.Blocks
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.screen.Screen
+import net.minecraft.item.Item
+import net.minecraft.item.Items
+import net.minecraft.text.Text
+import net.minecraft.util.DyeColor
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.toShedaniel
+
+class StorageOverviewScreen() : Screen(Text.empty()) {
+ companion object {
+ val emptyStorageSlotItems = listOf<Item>(
+ Blocks.RED_STAINED_GLASS_PANE.asItem(),
+ Blocks.BROWN_STAINED_GLASS_PANE.asItem(),
+ Items.GRAY_DYE
+ )
+ val pageWidth get() = 19 * 9
+ }
+
+ val content = StorageOverlay.Data.data ?: StorageData()
+ var isClosing = false
+
+ var scroll = 0
+ var lastRenderedHeight = 0
+
+ override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+ super.render(context, mouseX, mouseY, delta)
+ context.fill(0, 0, width, height, 0x90000000.toInt())
+ layoutedForEach { (key, value), offsetX, offsetY ->
+ context.matrices.push()
+ context.matrices.translate(offsetX.toFloat(), offsetY.toFloat(), 0F)
+ renderStoragePage(context, value, mouseX - offsetX, mouseY - offsetY)
+ context.matrices.pop()
+ }
+ }
+
+ inline fun layoutedForEach(onEach: (data: Pair<StoragePageSlot, StorageData.StorageInventory>, offsetX: Int, offsetY: Int) -> Unit) {
+ var offsetY = 0
+ var currentMaxHeight = StorageOverlay.config.margin - StorageOverlay.config.padding - scroll
+ var totalHeight = -currentMaxHeight
+ content.storageInventories.onEachIndexed { index, (key, value) ->
+ val pageX = (index % StorageOverlay.config.columns)
+ if (pageX == 0) {
+ currentMaxHeight += StorageOverlay.config.padding
+ offsetY += currentMaxHeight
+ totalHeight += currentMaxHeight
+ currentMaxHeight = 0
+ }
+ val xPosition =
+ width / 2 - (StorageOverlay.config.columns * (pageWidth + StorageOverlay.config.padding) - StorageOverlay.config.padding) / 2 + pageX * (pageWidth + StorageOverlay.config.padding)
+ onEach(Pair(key, value), xPosition, offsetY)
+ val height = getStorePageHeight(value)
+ currentMaxHeight = max(currentMaxHeight, height)
+ }
+ lastRenderedHeight = totalHeight + currentMaxHeight
+ }
+
+ override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean {
+ layoutedForEach { (k, p), x, y ->
+ val rx = mouseX - x
+ val ry = mouseY - y
+ if (rx in (0.0..pageWidth.toDouble()) && ry in (0.0..getStorePageHeight(p).toDouble())) {
+ close()
+ StorageOverlay.lastStorageOverlay = this
+ k.navigateTo()
+ return true
+ }
+ }
+ return super.mouseClicked(mouseX, mouseY, button)
+ }
+
+ fun getStorePageHeight(page: StorageData.StorageInventory): Int {
+ return page.inventory?.rows?.let { it * 19 + MC.font.fontHeight + 2 } ?: 60
+ }
+
+ override fun mouseScrolled(
+ mouseX: Double,
+ mouseY: Double,
+ horizontalAmount: Double,
+ verticalAmount: Double
+ ): Boolean {
+ scroll =
+ (scroll + StorageOverlay.adjustScrollSpeed(verticalAmount)).toInt()
+ .coerceAtMost(lastRenderedHeight - height + 2 * StorageOverlay.config.margin).coerceAtLeast(0)
+ return true
+ }
+
+ private fun renderStoragePage(context: DrawContext, page: StorageData.StorageInventory, mouseX: Int, mouseY: Int) {
+ context.drawText(MC.font, page.title, 2, 2, -1, true)
+ val inventory = page.inventory
+ if (inventory == null) {
+ // TODO: Missing texture
+ context.fill(0, 0, pageWidth, 60, DyeColor.RED.toShedaniel().darker(4.0).color)
+ context.drawCenteredTextWithShadow(MC.font, Text.literal("Not loaded yet"), pageWidth / 2, 30, -1)
+ return
+ }
+
+ for ((index, stack) in inventory.stacks.withIndex()) {
+ val x = (index % 9) * 19
+ val y = (index / 9) * 19 + MC.font.fontHeight + 2
+ if (((mouseX - x) in 0 until 18) && ((mouseY - y) in 0 until 18)) {
+ context.fill(x, y, x + 18, y + 18, 0x80808080.toInt())
+ } else {
+ context.fill(x, y, x + 18, y + 18, 0x40808080.toInt())
+ }
+ context.drawItem(stack, x + 1, y + 1)
+ context.drawItemInSlot(MC.font, stack, x + 1, y + 1)
+ }
+ }
+
+ override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean {
+ if (keyCode == GLFW.GLFW_KEY_ESCAPE)
+ isClosing = true
+ return super.keyPressed(keyCode, scanCode, modifiers)
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StoragePageSlot.kt b/src/main/kotlin/features/inventory/storageoverlay/StoragePageSlot.kt
new file mode 100644
index 0000000..9259415
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StoragePageSlot.kt
@@ -0,0 +1,66 @@
+
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import moe.nea.firmament.util.MC
+
+@Serializable(with = StoragePageSlot.Serializer::class)
+data class StoragePageSlot(val index: Int) : Comparable<StoragePageSlot> {
+ object Serializer : KSerializer<StoragePageSlot> {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("StoragePageSlot", PrimitiveKind.INT)
+
+ override fun deserialize(decoder: Decoder): StoragePageSlot {
+ return StoragePageSlot(decoder.decodeInt())
+ }
+
+ override fun serialize(encoder: Encoder, value: StoragePageSlot) {
+ encoder.encodeInt(value.index)
+ }
+ }
+
+ init {
+ assert(index in 0 until (3 * 9))
+ }
+
+ val isEnderChest get() = index < 9
+ val isBackPack get() = !isEnderChest
+ val slotIndexInOverviewPage get() = if (isEnderChest) index + 9 else index + 18
+ fun defaultName(): String = if (isEnderChest) "Ender Chest #${index + 1}" else "Backpack #${index - 9 + 1}"
+
+ fun navigateTo() {
+ if (isBackPack) {
+ MC.sendCommand("backpack ${index - 9 + 1}")
+ } else {
+ MC.sendCommand("enderchest ${index + 1}")
+ }
+ }
+
+ companion object {
+ fun fromOverviewSlotIndex(slot: Int): StoragePageSlot? {
+ if (slot in 9 until 18) return StoragePageSlot(slot - 9)
+ if (slot in 27 until 45) return StoragePageSlot(slot - 27 + 9)
+ return null
+ }
+
+ fun ofEnderChestPage(slot: Int): StoragePageSlot {
+ assert(slot in 1..9)
+ return StoragePageSlot(slot - 1)
+ }
+
+ fun ofBackPackPage(slot: Int): StoragePageSlot {
+ assert(slot in 1..18)
+ return StoragePageSlot(slot - 1 + 9)
+ }
+ }
+
+ override fun compareTo(other: StoragePageSlot): Int {
+ return this.index - other.index
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/VirtualInventory.kt b/src/main/kotlin/features/inventory/storageoverlay/VirtualInventory.kt
new file mode 100644
index 0000000..e07df8a
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/VirtualInventory.kt
@@ -0,0 +1,65 @@
+
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import io.ktor.util.decodeBase64Bytes
+import io.ktor.util.encodeBase64
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import net.minecraft.item.ItemStack
+import net.minecraft.nbt.NbtCompound
+import net.minecraft.nbt.NbtIo
+import net.minecraft.nbt.NbtList
+import net.minecraft.nbt.NbtOps
+import net.minecraft.nbt.NbtSizeTracker
+
+@Serializable(with = VirtualInventory.Serializer::class)
+data class VirtualInventory(
+ val stacks: List<ItemStack>
+) {
+ val rows = stacks.size / 9
+
+ init {
+ assert(stacks.size % 9 == 0)
+ assert(stacks.size / 9 in 1..5)
+ }
+
+
+ object Serializer : KSerializer<VirtualInventory> {
+ const val INVENTORY = "INVENTORY"
+ override val descriptor: SerialDescriptor
+ get() = PrimitiveSerialDescriptor("VirtualInventory", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): VirtualInventory {
+ val s = decoder.decodeString()
+ val n = NbtIo.readCompressed(ByteArrayInputStream(s.decodeBase64Bytes()), NbtSizeTracker.of(100_000_000))
+ val items = n.getList(INVENTORY, NbtCompound.COMPOUND_TYPE.toInt())
+ return VirtualInventory(items.map {
+ it as NbtCompound
+ if (it.isEmpty) ItemStack.EMPTY
+ else runCatching {
+ ItemStack.CODEC.parse(NbtOps.INSTANCE, it).orThrow
+ }.getOrElse { ItemStack.EMPTY }
+ })
+ }
+
+ override fun serialize(encoder: Encoder, value: VirtualInventory) {
+ val list = NbtList()
+ value.stacks.forEach {
+ if (it.isEmpty) list.add(NbtCompound())
+ else list.add(runCatching { ItemStack.CODEC.encode(it, NbtOps.INSTANCE, NbtCompound()).orThrow }
+ .getOrElse { NbtCompound() })
+ }
+ val baos = ByteArrayOutputStream()
+ NbtIo.writeCompressed(NbtCompound().also { it.put(INVENTORY, list) }, baos)
+ encoder.encodeString(baos.toByteArray().encodeBase64())
+ }
+ }
+}