diff --git a/.timetracker b/.timetracker index a35cccf..dc91c0e 100644 --- a/.timetracker +++ b/.timetracker @@ -1,5 +1,5 @@ { - "total": 66247, + "total": 73071, "sessions": [ { "begin": "2026-03-11T23:50:47+01:00", @@ -65,6 +65,11 @@ "begin": "2026-03-15T20:33:08+01:00", "end": "2026-03-15T20:53:09+01:00", "duration": 1201 + }, + { + "begin": "2026-03-15T21:00:39+01:00", + "end": "2026-03-15T23:04:35+01:00", + "duration": 7435 } ] } \ No newline at end of file diff --git a/ChestPreview/ChestPreview/Configs/Config.cs b/ChestPreview/ChestPreview/Configs/Config.cs index 779b1d5..b927491 100644 --- a/ChestPreview/ChestPreview/Configs/Config.cs +++ b/ChestPreview/ChestPreview/Configs/Config.cs @@ -1,20 +1,22 @@ -namespace ChestPreview.Configs; - -public sealed class Config -{ - public string Mode { get; set; } = PreviewModes.UNDER_CURSOR; - - public bool HoldKey { get; set; } = true; - - public int BillboardColumnsPerBlock { get; set; } = 4; - - public int ColumnsUnderCursor { get; set; } = 10; - - public int PreviewNearbyRadius { get; set; } = 8; - - public bool WhitelistedContainersOnly { get; set; } = true; - - public string WhitelistedContainers { get; set; } = "barrel, crate, chest-*, trunk-*, storagevessel-*, stationarybasket-*, labeledchest-*"; - - public string BlacklistedContainers { get; set; } = ""; +namespace ChestPreview.Configs; + +public sealed class Config +{ + public string Mode { get; set; } = PreviewModes.UNDER_CURSOR; + + public bool HoldKey { get; set; } = true; + + public int BillboardColumnsPerBlock { get; set; } = 4; + + public int ColumnsUnderCursor { get; set; } = 10; + + public int PreviewNearbyRadius { get; set; } = 8; + + public bool WhitelistedContainersOnly { get; set; } = true; + + public string WhitelistedContainers { get; set; } = "barrel, crate, chest-*, trunk-*, storagevessel-*, stationarybasket-*, labeledchest-*, groundstorage"; + + public string BlacklistedContainers { get; set; } = ""; + + public bool GroundStorageOnlyContainers { get; set; } = true; } \ No newline at end of file diff --git a/ChestPreview/ChestPreview/PreviewTargetProvider.cs b/ChestPreview/ChestPreview/PreviewTargetProvider.cs index acd9dd0..65e6ae0 100644 --- a/ChestPreview/ChestPreview/PreviewTargetProvider.cs +++ b/ChestPreview/ChestPreview/PreviewTargetProvider.cs @@ -1,284 +1,336 @@ -using ChestPreview.Configs; -using ChestPreview.Core; -using ChestPreview.Models; -using ChestPreview.Utils; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using System.Text.RegularExpressions; -using Vintagestory.API.Client; -using Vintagestory.API.Common; -using Vintagestory.API.MathTools; -using Vintagestory.API.Util; - -namespace ChestPreview; - -internal partial class PreviewTargetProvider(ICoreClientAPI api, Config config) : IDisposable -{ - private const float nearby_scan_interval_seconds = 0.1f; - private static readonly Regex list_separator_regex = ListSeparatorRegex(); - - private readonly ICoreClientAPI api = api; - private readonly Config config = config; - private readonly List nearbyContainerEntities = []; - private float nearbyScanAccumulator; - - public void CollectTargets(float deltaTime, List targets) - { - string mode = GetActiveMode(); - if (mode == PreviewModes.NONE) - return; - - if (mode == PreviewModes.ON_NEARBY_CONTAINERS) - { - CollectNearbyTargets(deltaTime, targets); - return; - } - - if (!TryGetHoveredContainer(out Block hoveredBlock, out BlockEntity hoveredBlockEntity)) - return; - - if (!CanAccessContainer(hoveredBlockEntity)) - return; - - if (!IsContainerAllowed(hoveredBlock)) - return; - - targets.Add(new PreviewTarget(hoveredBlock, hoveredBlockEntity, mode, GetContainerAnchor(hoveredBlock, hoveredBlockEntity.Pos))); - } - - public void Dispose() - { - nearbyContainerEntities.Clear(); - } - - private void CollectNearbyTargets(float deltaTime, List targets) - { - nearbyScanAccumulator += deltaTime; - bool shouldRefresh = nearbyContainerEntities.Count == 0 || nearbyScanAccumulator >= nearby_scan_interval_seconds; - - if (shouldRefresh) - { - RefreshNearbyContainers(); - nearbyScanAccumulator = 0f; - } - - foreach (BlockEntity blockEntity in nearbyContainerEntities) - { - Block block = api.World.BlockAccessor.GetBlock(blockEntity.Pos); - if (block.Id == 0) - continue; - - if (!CanAccessContainer(blockEntity)) - continue; - - if (!IsContainerAllowed(block)) - continue; - - targets.Add(new PreviewTarget(block, blockEntity, PreviewModes.ON_NEARBY_CONTAINERS, GetContainerAnchor(block, blockEntity.Pos))); - } - } - - private string GetActiveMode() - { - if (IsHotkeyHeld(ChestPreviewModSystem.PREVIEW_CONTAINERS_NEARBY_HOTKEY_CODE)) - return PreviewModes.ON_NEARBY_CONTAINERS; - - if (config.HoldKey && !IsHotkeyHeld(ChestPreviewModSystem.PREVIEW_CONTAINERS_HOTKEY_CODE)) - return PreviewModes.NONE; - - return PreviewModes.Normalize(config.Mode); - } - - private bool IsHotkeyHeld(string hotkeyCode) - { - HotKey? hotKey = api.Input.GetHotKeyByCode(hotkeyCode); - if (hotKey?.CurrentMapping == null) - return false; - - KeyCombination mapping = hotKey.CurrentMapping; - if (!IsKeyHeld(mapping.KeyCode)) - return false; - - if (mapping.SecondKeyCode.HasValue && !IsKeyHeld(mapping.SecondKeyCode.Value)) - return false; - - EntityControls? controls = api.World.Player?.Entity?.Controls; - if (mapping.Ctrl && controls?.CtrlKey != true) - return false; - - if (mapping.Shift && controls?.ShiftKey != true) - return false; - - return !mapping.Alt || IsAltHeld(); - } - - private bool IsKeyHeld(int keyCode) - { - bool[] keyStates = api.Input.KeyboardKeyStateRaw; - return keyCode >= 0 && keyCode < keyStates.Length && keyStates[keyCode]; - } - - private bool IsAltHeld() - { - return IsKeyHeld((int)GlKeys.AltLeft) || IsKeyHeld((int)GlKeys.AltRight); - } - - private bool TryGetHoveredContainer(out Block block, out BlockEntity blockEntity) - { - block = null!; - blockEntity = null!; - - BlockSelection? currentBlockSelection = api.World.Player?.CurrentBlockSelection; - if (currentBlockSelection == null) - return false; - - BlockEntity? currentBlockEntity = api.World.BlockAccessor.GetBlockEntity(currentBlockSelection.Position); - if (currentBlockEntity is IBlockEntityContainer) - { - block = currentBlockSelection.Block; - blockEntity = currentBlockEntity; - return true; - } - - if (!TryResolveMultiblockControllerContainer(currentBlockSelection, out Block controllerBlock, out BlockEntity controllerBlockEntity)) - return false; - - block = controllerBlock; - blockEntity = controllerBlockEntity; - return true; - } - - private bool TryResolveMultiblockControllerContainer(BlockSelection blockSelection, out Block controllerBlock, out BlockEntity controllerBlockEntity) - { - controllerBlock = null!; - controllerBlockEntity = null!; - - if (blockSelection.Block is not IMultiblockOffset multiblockOffset) - return false; - - BlockPos controllerPos = multiblockOffset.GetControlBlockPos(blockSelection.Position); - BlockEntity? blockEntity = api.World.BlockAccessor.GetBlockEntity(controllerPos); - if (blockEntity is not IBlockEntityContainer) - return false; - - controllerBlock = api.World.BlockAccessor.GetBlock(controllerPos); - if (controllerBlock.Id == 0) - return false; - - controllerBlockEntity = blockEntity; - return true; - } - - private void RefreshNearbyContainers() - { - nearbyContainerEntities.Clear(); - - EntityPlayer? playerEntity = api.World.Player?.Entity; - if (playerEntity?.CameraPos == null) - return; - - float nearbyRadius = config.PreviewNearbyRadius; - float nearbyRadiusSquared = nearbyRadius * nearbyRadius; - - Vec3d nearbyRadiusOffset = new(nearbyRadius, nearbyRadius, nearbyRadius); - BlockPos minPos = (playerEntity.CameraPos - nearbyRadiusOffset).AsBlockPos; - BlockPos maxPos = (playerEntity.CameraPos + nearbyRadiusOffset + new Vec3d(1d, 1d, 1d)).AsBlockPos; - - api.World.BlockAccessor.WalkBlocks(minPos, maxPos, (block, x, y, z) => - { - double horizontalDx = x + 0.5d - playerEntity.CameraPos.X; - double horizontalDz = z + 0.5d - playerEntity.CameraPos.Z; - if (horizontalDx * horizontalDx + horizontalDz * horizontalDz > nearbyRadiusSquared) - return; - - if (Math.Abs(y + 0.5d - playerEntity.CameraPos.Y) > nearbyRadius) - return; - - BlockEntity? blockEntity = api.World.BlockAccessor.GetBlockEntity(new BlockPos(x, y, z)); - if (blockEntity is not IBlockEntityContainer) - return; - - if (block.Id == 0) - return; - - nearbyContainerEntities.Add(blockEntity); - }); - } - - private Vec3d GetContainerAnchor(Block block, BlockPos blockPos) - { - GetContainerBounds(block, blockPos, out Vector3 min, out Vector3 max); - - Vector3 anchor = blockPos.ToVector3() + new Vector3((min.X + max.X) * 0.5f, max.Y + 0.1f, (min.Z + max.Z) * 0.5f); - return anchor.ToVec3d(); - } - - private void GetContainerBounds(Block block, BlockPos blockPos, out Vector3 min, out Vector3 max) - { - Cuboidf[]? selectionBoxes = block.GetSelectionBoxes(api.World.BlockAccessor, blockPos); - - min = new Vector3(0f, 0f, 0f); - max = new Vector3(1f, 1f, 1f); - - if (selectionBoxes is not { Length: > 0 }) - return; - - foreach (Cuboidf selectionBox in selectionBoxes) - { - min = Vector3.Min(min, selectionBox.Start.ToVector3()); - max = Vector3.Max(max, selectionBox.End.ToVector3()); - } - } - - private bool CanAccessContainer(BlockEntity blockEntity) - { - if (blockEntity is not IBlockEntityContainer container || container.Inventory is not InventoryBase inventory) - return false; - - IPlayer? player = api.World.Player; - EntityPlayer? playerEntity = player?.Entity; - if (player == null || playerEntity == null) - return false; - - return inventory.CanPlayerAccess(player, playerEntity.GetPos()); - } - - private bool IsContainerAllowed(Block block) - { - string? code = block.Code?.Path; - if (string.IsNullOrWhiteSpace(code)) - return true; - - HashSet blacklist = ParseContainerCodes(config.BlacklistedContainers); - if (blacklist.Any(x => WildcardUtil.Match(x, code))) - return false; - - if (!config.WhitelistedContainersOnly) - return true; - - HashSet whitelist = ParseContainerCodes(config.WhitelistedContainers); - return whitelist.Any(x => WildcardUtil.Match(x, code)); - } - - private static HashSet ParseContainerCodes(string rawList) - { - if (string.IsNullOrWhiteSpace(rawList)) - return []; - - string[] entries = list_separator_regex.Split(rawList.Trim()); - HashSet result = new(StringComparer.OrdinalIgnoreCase); - - foreach (string entry in entries) - { - if (!string.IsNullOrWhiteSpace(entry)) - result.Add(entry); - } - - return result; - } - - [GeneratedRegex("[,;\\s]+", RegexOptions.Compiled)] - private static partial Regex ListSeparatorRegex(); +using ChestPreview.Configs; +using ChestPreview.Core; +using ChestPreview.Models; +using ChestPreview.Utils; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text.RegularExpressions; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.API.MathTools; +using Vintagestory.API.Util; +using Vintagestory.GameContent; + +namespace ChestPreview; + +internal partial class PreviewTargetProvider(ICoreClientAPI api, Config config) : IDisposable +{ + private const float nearby_scan_interval_seconds = 0.1f; + private static readonly Regex list_separator_regex = ListSeparatorRegex(); + + private readonly ICoreClientAPI api = api; + private readonly Config config = config; + private readonly List nearbyContainerEntities = []; + private float nearbyScanAccumulator; + + public void CollectTargets(float deltaTime, List targets) + { + string mode = GetActiveMode(); + if (mode == PreviewModes.NONE) + return; + + if (mode == PreviewModes.ON_NEARBY_CONTAINERS) + { + CollectNearbyTargets(deltaTime, targets); + return; + } + + if (!TryGetHoveredContainer(out Block hoveredBlock, out BlockEntity hoveredBlockEntity)) + return; + + if (!CanAccessContainer(hoveredBlock, hoveredBlockEntity)) + return; + + if (!IsContainerAllowed(hoveredBlock)) + return; + + targets.Add(new PreviewTarget(hoveredBlock, hoveredBlockEntity, mode, GetContainerAnchor(hoveredBlock, hoveredBlockEntity.Pos))); + } + + public void Dispose() + { + nearbyContainerEntities.Clear(); + } + + private void CollectNearbyTargets(float deltaTime, List targets) + { + nearbyScanAccumulator += deltaTime; + bool shouldRefresh = nearbyContainerEntities.Count == 0 || nearbyScanAccumulator >= nearby_scan_interval_seconds; + + if (shouldRefresh) + { + RefreshNearbyContainers(); + nearbyScanAccumulator = 0f; + } + + foreach (BlockEntity blockEntity in nearbyContainerEntities) + { + Block block = api.World.BlockAccessor.GetBlock(blockEntity.Pos); + if (block.Id == 0) + continue; + + if (!CanAccessContainer(block, blockEntity)) + continue; + + if (!IsContainerAllowed(block)) + continue; + + targets.Add(new PreviewTarget(block, blockEntity, PreviewModes.ON_NEARBY_CONTAINERS, GetContainerAnchor(block, blockEntity.Pos))); + } + } + + private string GetActiveMode() + { + if (IsHotkeyHeld(ChestPreviewModSystem.PREVIEW_CONTAINERS_NEARBY_HOTKEY_CODE)) + return PreviewModes.ON_NEARBY_CONTAINERS; + + if (config.HoldKey && !IsHotkeyHeld(ChestPreviewModSystem.PREVIEW_CONTAINERS_HOTKEY_CODE)) + return PreviewModes.NONE; + + return PreviewModes.Normalize(config.Mode); + } + + private bool IsHotkeyHeld(string hotkeyCode) + { + HotKey? hotKey = api.Input.GetHotKeyByCode(hotkeyCode); + if (hotKey?.CurrentMapping == null) + return false; + + KeyCombination mapping = hotKey.CurrentMapping; + if (!IsKeyHeld(mapping.KeyCode)) + return false; + + if (mapping.SecondKeyCode.HasValue && !IsKeyHeld(mapping.SecondKeyCode.Value)) + return false; + + EntityControls? controls = api.World.Player?.Entity?.Controls; + if (mapping.Ctrl && controls?.CtrlKey != true) + return false; + + if (mapping.Shift && controls?.ShiftKey != true) + return false; + + return !mapping.Alt || IsAltHeld(); + } + + private bool IsKeyHeld(int keyCode) + { + bool[] keyStates = api.Input.KeyboardKeyStateRaw; + return keyCode >= 0 && keyCode < keyStates.Length && keyStates[keyCode]; + } + + private bool IsAltHeld() + { + return IsKeyHeld((int)GlKeys.AltLeft) || IsKeyHeld((int)GlKeys.AltRight); + } + + private bool TryGetHoveredContainer(out Block block, out BlockEntity blockEntity) + { + block = null!; + blockEntity = null!; + + BlockSelection? currentBlockSelection = api.World.Player?.CurrentBlockSelection; + if (currentBlockSelection == null) + return false; + + BlockPos hoveredPos = currentBlockSelection.Position; + BlockEntity? currentBlockEntity = api.World.BlockAccessor.GetBlockEntity(hoveredPos); + if (HasPreviewableStorage(currentBlockSelection.Block, hoveredPos, currentBlockEntity)) + { + block = currentBlockSelection.Block; + if (currentBlockEntity == null) + return false; + + blockEntity = currentBlockEntity; + return true; + } + + if (!TryResolveMultiblockControllerContainer(currentBlockSelection, out Block controllerBlock, out BlockEntity controllerBlockEntity)) + return false; + + block = controllerBlock; + blockEntity = controllerBlockEntity; + return true; + } + + private bool TryResolveMultiblockControllerContainer(BlockSelection blockSelection, out Block controllerBlock, out BlockEntity controllerBlockEntity) + { + controllerBlock = null!; + controllerBlockEntity = null!; + + if (blockSelection.Block is not IMultiblockOffset multiblockOffset) + return false; + + BlockPos controllerPos = multiblockOffset.GetControlBlockPos(blockSelection.Position); + controllerBlock = api.World.BlockAccessor.GetBlock(controllerPos); + if (controllerBlock.Id == 0) + return false; + + BlockEntity? blockEntity = api.World.BlockAccessor.GetBlockEntity(controllerPos); + if (!HasPreviewableStorage(controllerBlock, controllerPos, blockEntity)) + return false; + + if (blockEntity == null) + return false; + + controllerBlockEntity = blockEntity; + return true; + } + + private void RefreshNearbyContainers() + { + nearbyContainerEntities.Clear(); + + EntityPlayer? playerEntity = api.World.Player?.Entity; + if (playerEntity?.CameraPos == null) + return; + + float nearbyRadius = config.PreviewNearbyRadius; + float nearbyRadiusSquared = nearbyRadius * nearbyRadius; + + Vec3d nearbyRadiusOffset = new(nearbyRadius, nearbyRadius, nearbyRadius); + BlockPos minPos = (playerEntity.CameraPos - nearbyRadiusOffset).AsBlockPos; + BlockPos maxPos = (playerEntity.CameraPos + nearbyRadiusOffset + new Vec3d(1d, 1d, 1d)).AsBlockPos; + + api.World.BlockAccessor.WalkBlocks(minPos, maxPos, (block, x, y, z) => + { + double horizontalDx = x + 0.5d - playerEntity.CameraPos.X; + double horizontalDz = z + 0.5d - playerEntity.CameraPos.Z; + if (horizontalDx * horizontalDx + horizontalDz * horizontalDz > nearbyRadiusSquared) + return; + + if (Math.Abs(y + 0.5d - playerEntity.CameraPos.Y) > nearbyRadius) + return; + + if (block.Id == 0) + return; + + BlockPos blockPos = new(x, y, z); + BlockEntity? blockEntity = api.World.BlockAccessor.GetBlockEntity(blockPos); + if (!HasPreviewableStorage(block, blockPos, blockEntity)) + return; + + if (blockEntity == null) + return; + + nearbyContainerEntities.Add(blockEntity); + }); + } + + private Vec3d GetContainerAnchor(Block block, BlockPos blockPos) + { + GetContainerBounds(block, blockPos, out Vector3 min, out Vector3 max); + + Vector3 anchor = blockPos.ToVector3() + new Vector3((min.X + max.X) * 0.5f, max.Y + 0.1f, (min.Z + max.Z) * 0.5f); + return anchor.ToVec3d(); + } + + private void GetContainerBounds(Block block, BlockPos blockPos, out Vector3 min, out Vector3 max) + { + Cuboidf[]? selectionBoxes = block.GetSelectionBoxes(api.World.BlockAccessor, blockPos); + + min = new Vector3(0f, 0f, 0f); + max = new Vector3(1f, 1f, 1f); + + if (selectionBoxes is not { Length: > 0 }) + return; + + foreach (Cuboidf selectionBox in selectionBoxes) + { + min = Vector3.Min(min, selectionBox.Start.ToVector3()); + max = Vector3.Max(max, selectionBox.End.ToVector3()); + } + } + + private bool CanAccessContainer(Block block, BlockEntity blockEntity) + { + if (config.GroundStorageOnlyContainers && blockEntity is BlockEntityGroundStorage && !HasHeldBagInGroundStorage(blockEntity)) + return false; + + BlockPos blockPos = blockEntity.Pos; + if (block.GetInterface(api.World, blockPos) is { } container && container.Inventory is InventoryBase inventory) + { + IPlayer? player = api.World.Player; + EntityPlayer? playerEntity = player?.Entity; + if (player == null || playerEntity == null) + return false; + + return inventory.CanPlayerAccess(player, playerEntity.GetPos()); + } + + if (blockEntity is BlockEntityBloomery) + return true; + + return HasHeldBagInGroundStorage(blockEntity); + } + + private bool HasPreviewableStorage(Block block, BlockPos blockPos, BlockEntity? blockEntity) + { + if (config.GroundStorageOnlyContainers && blockEntity is BlockEntityGroundStorage) + return HasHeldBagInGroundStorage(blockEntity); + + return block.GetInterface(api.World, blockPos) != null || HasHeldBagInGroundStorage(blockEntity) || HasBloomeryInventory(blockEntity); + } + + private static bool HasBloomeryInventory(BlockEntity? blockEntity) + { + return blockEntity is BlockEntityBloomery; + } + + private static bool HasHeldBagInGroundStorage(BlockEntity? blockEntity) + { + if (blockEntity is not BlockEntityGroundStorage groundStorage) + return false; + + foreach (ItemSlot? slot in groundStorage.Inventory) + { + if (slot?.Itemstack?.Collectible is not { } collectible) + continue; + + IHeldBag? heldBag = collectible.GetCollectibleInterface(); + if (heldBag != null) + return true; + } + + return false; + } + + private bool IsContainerAllowed(Block block) + { + string? code = block.Code?.Path; + if (string.IsNullOrWhiteSpace(code)) + return true; + + HashSet blacklist = ParseContainerCodes(config.BlacklistedContainers); + if (blacklist.Any(x => WildcardUtil.Match(x, code))) + return false; + + if (!config.WhitelistedContainersOnly) + return true; + + HashSet whitelist = ParseContainerCodes(config.WhitelistedContainers); + return whitelist.Any(x => WildcardUtil.Match(x, code)); + } + + private static HashSet ParseContainerCodes(string rawList) + { + if (string.IsNullOrWhiteSpace(rawList)) + return []; + + string[] entries = list_separator_regex.Split(rawList.Trim()); + HashSet result = new(StringComparer.OrdinalIgnoreCase); + + foreach (string entry in entries) + { + if (!string.IsNullOrWhiteSpace(entry)) + result.Add(entry); + } + + return result; + } + + [GeneratedRegex("[,;\\s]+", RegexOptions.Compiled)] + private static partial Regex ListSeparatorRegex(); } \ No newline at end of file diff --git a/ChestPreview/ChestPreview/Rendering/CardRenderer.cs b/ChestPreview/ChestPreview/Rendering/CardRenderer.cs index 3f557a7..f2af050 100644 --- a/ChestPreview/ChestPreview/Rendering/CardRenderer.cs +++ b/ChestPreview/ChestPreview/Rendering/CardRenderer.cs @@ -1,526 +1,635 @@ -using Cairo; -using ChestPreview.Configs; -using ChestPreview.Models; -using ChestPreview.Utils; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Text; -using Vintagestory.API.Client; -using Vintagestory.API.Common; -using Vintagestory.API.Config; -using Vintagestory.API.MathTools; - -namespace ChestPreview.Rendering; - -internal class CardRenderer(ICoreClientAPI api, Config config) : IDisposable -{ - private static readonly int rendered_icon_size = 64; - private static readonly float hud_z = 0f; - private static readonly double hud_outer_alpha = 0.36d; - private static readonly float cell_padding = 10f; - private static readonly float cell_gap = 6f; - private static readonly float cell_width = 72f; - private static readonly float cell_height = 86f; - private static readonly float icon_size = 64f; - private static readonly double count_font_size = 20d; - private static readonly float count_anchor_inset = 2f; - - private readonly ICoreClientAPI api = api; - private readonly Config config = config; - private readonly Dictionary cardTextureByTarget = []; - private readonly Dictionary atlasTextureById = []; - private readonly Dictionary renderedIconByStack = []; - private int iconRenderVersion; - - public static float Width => GetCardWidth(1); - - public static float Height => GetCardHeight(1); - - public bool TryGetOrCreateCardTexture(PreviewTarget target, out LoadedTexture texture) - { - return TryGetOrCreateCardTexture(target, GetDefaultMaxColumns(target), out texture); - } - - public bool TryGetOrCreateCardTexture(PreviewTarget target, int maxColumns, out LoadedTexture texture) - { - texture = null!; - - if (target.BlockEntity is not IBlockEntityContainer container || container.Inventory is not InventoryBase inventory) - return false; - - IPlayer? player = api.World.Player; - EntityPlayer? playerEntity = player?.Entity; - if (player == null || playerEntity == null || !inventory.CanPlayerAccess(player, playerEntity.GetPos())) - return false; - - string targetKey = CreateTargetKey(target.BlockEntity.Pos); - if (!cardTextureByTarget.TryGetValue(targetKey, out CachedCardTexture? cachedTexture)) - { - cachedTexture = new CachedCardTexture(new LoadedTexture(api), string.Empty); - cardTextureByTarget[targetKey] = cachedTexture; - } - - BuildLayout(inventory, maxColumns, out List cells, out int usedColumns, out int usedRows, out string fingerprint); - if (cachedTexture.Fingerprint != fingerprint) - { - ComposeTexture(cachedTexture.Texture, cells, usedColumns, usedRows); - cachedTexture.Fingerprint = fingerprint; - } - - texture = cachedTexture.Texture; - return texture.TextureId != 0; - } - - public void DrawAtScreen(float x, float y, LoadedTexture texture) - { - if (texture.TextureId == 0) - return; - - api.Render.RenderTexture(texture.TextureId, x, y, texture.Width, texture.Height, hud_z, new Vec4f(1f, 1f, 1f, 1f)); - } - - public void ClampToScreen(ref float x, ref float y) - { - ClampToScreen(ref x, ref y, Width, Height); - } - - public void ClampToScreen(ref float x, ref float y, float cardWidth, float cardHeight) - { - float max_x = api.Render.FrameWidth - cardWidth; - float max_y = api.Render.FrameHeight - cardHeight; - - x = Math.Clamp(x, 0f, max_x); - y = Math.Clamp(y, 0f, max_y); - } - - public void Dispose() - { - foreach (CachedCardTexture cardTexture in cardTextureByTarget.Values) - cardTexture.Texture.Dispose(); - - cardTextureByTarget.Clear(); - atlasTextureById.Clear(); - renderedIconByStack.Clear(); - } - - private void BuildLayout(IInventory inventory, int maxColumns, out List cells, out int usedColumns, out int usedRows, out string fingerprint) - { - List groupedItems = []; - int naturalOrder = 0; - - foreach (ItemSlot? slot in inventory) - { - if (slot == null || slot.Empty || slot.Itemstack == null) - continue; - - ItemStack stack = slot.Itemstack; - int existingIndex = FindMatchingItemIndex(groupedItems, stack); - if (existingIndex >= 0) - { - GroupedItemCell existing = groupedItems[existingIndex]; - existing.Count += stack.StackSize; - continue; - } - - ItemStack representativeStack = stack.Clone(); - representativeStack.StackSize = 1; - groupedItems.Add(new GroupedItemCell(representativeStack, stack.StackSize, naturalOrder)); - naturalOrder++; - } - - groupedItems.Sort(static (left, right) => - { - int countComparison = right.Count.CompareTo(left.Count); - return countComparison != 0 ? countComparison : left.NaturalOrder.CompareTo(right.NaturalOrder); - }); - - maxColumns = Math.Max(1, maxColumns); - usedColumns = GetUsedColumnCount(groupedItems.Count, maxColumns); - usedRows = GetRowCount(groupedItems.Count, maxColumns); - - cells = groupedItems; - fingerprint = BuildFingerprint(cells, maxColumns, usedColumns, usedRows); - } - - private int FindMatchingItemIndex(List groupedItems, ItemStack stack) - { - for (int i = 0; i < groupedItems.Count; i++) - { - if (AreStacksEquivalent(groupedItems[i].Stack, stack)) - return i; - } - - return -1; - } - - private bool AreStacksEquivalent(ItemStack left, ItemStack right) - { - if (left.Class != right.Class || left.Id != right.Id) - return false; - - ItemStack leftComparable = left.Clone(); - ItemStack rightComparable = right.Clone(); - leftComparable.StackSize = 1; - rightComparable.StackSize = 1; - return leftComparable.Equals(api.World, rightComparable, GlobalConstants.IgnoredStackAttributes); - } - - private string BuildFingerprint(List cells, int maxColumns, int usedColumns, int usedRows) - { - StringBuilder builder = new(); - builder.Append('m'); - builder.Append(maxColumns); - builder.Append('|'); - builder.Append('w'); - builder.Append(GetCardWidth(usedColumns).ToString(CultureInfo.InvariantCulture)); - builder.Append('h'); - builder.Append(GetCardHeight(usedRows).ToString(CultureInfo.InvariantCulture)); - builder.Append('|'); - builder.Append("iv"); - builder.Append(iconRenderVersion); - builder.Append('|'); - - foreach (GroupedItemCell cell in cells) - { - builder.Append((int)cell.Stack.Class); - builder.Append(':'); - builder.Append(cell.Stack.Id); - builder.Append(':'); - builder.Append(cell.Count); - builder.Append(':'); - builder.Append(HashBytes(cell.Stack.ToBytes())); - builder.Append('|'); - } - - return builder.ToString(); - } - - private static uint HashBytes(byte[] bytes) - { - const uint offset_basis = 2166136261; - const uint prime = 16777619; - - uint hash = offset_basis; - for (int i = 0; i < bytes.Length; i++) - hash = (hash ^ bytes[i]) * prime; - - return hash; - } - - private void ComposeTexture(LoadedTexture targetTexture, List cells, int usedColumns, int usedRows) - { - float hudWidth = GetCardWidth(usedColumns); - float hudHeight = GetCardHeight(usedRows); - - using ImageSurface surface = new(Format.Argb32, (int)hudWidth, (int)hudHeight); - using Context context = new(surface); - - context.Operator = Operator.Source; - context.SetSourceRGBA(0d, 0d, 0d, 0d); - context.Paint(); - - context.SetSourceRGBA(0.28d, 0.28d, 0.28d, hud_outer_alpha); - context.Rectangle(0d, 0d, hudWidth, hudHeight); - context.Fill(); - - context.SelectFontFace("sans", FontSlant.Normal, FontWeight.Bold); - context.SetFontSize(count_font_size); - - api.Gui.LoadOrUpdateCairoTexture(surface, true, ref targetTexture); - - for (int i = 0; i < cells.Count; i++) - { - GroupedItemCell cell = cells[i]; - if (!TryGetCellBounds(i, usedColumns, hudWidth, hudHeight, out float cellX, out float cellY)) - continue; - - if (!TryGetIconTexture(cell.Stack, out LoadedTexture atlasTexture, out TextureAtlasPosition textureAtlasPosition)) - continue; - - float sourceX = textureAtlasPosition.x1 * atlasTexture.Width; - float sourceY = textureAtlasPosition.y1 * atlasTexture.Height; - float sourceWidth = (textureAtlasPosition.x2 - textureAtlasPosition.x1) * atlasTexture.Width; - float sourceHeight = (textureAtlasPosition.y2 - textureAtlasPosition.y1) * atlasTexture.Height; - - float iconX = cellX + (cell_width - icon_size) / 2f; - float iconY = cellY + 1f; - - api.Render.RenderTextureIntoTexture(atlasTexture, sourceX, sourceY, sourceWidth, sourceHeight, targetTexture, iconX, iconY, 0.01f); - } - - using ImageSurface labelsSurface = new(Format.Argb32, (int)hudWidth, (int)hudHeight); - using Context labelsContext = new(labelsSurface); - LoadedTexture labelsTexture = new(api); - - labelsContext.Operator = Operator.Source; - labelsContext.SetSourceRGBA(0d, 0d, 0d, 0d); - labelsContext.Paint(); - labelsContext.SelectFontFace("sans", FontSlant.Normal, FontWeight.Bold); - labelsContext.SetFontSize(count_font_size); - - for (int i = 0; i < cells.Count; i++) - { - GroupedItemCell cell = cells[i]; - if (!TryGetCellBounds(i, usedColumns, hudWidth, hudHeight, out float cellX, out float cellY)) - continue; - - float iconX = cellX + (cell_width - icon_size) / 2f; - float iconY = cellY + 1f; - float iconRight = iconX + icon_size - count_anchor_inset; - float iconBottom = iconY + icon_size - count_anchor_inset; - - string countText = cell.Count.ToString(CultureInfo.InvariantCulture); - TextExtents textExtents = labelsContext.TextExtents(countText); - - double textX = iconRight - (textExtents.Width + textExtents.XBearing); - double textY = iconBottom - (textExtents.Height + textExtents.YBearing); - - labelsContext.SetSourceRGBA(0d, 0d, 0d, 0.95d); - labelsContext.MoveTo(textX + 1d, textY + 1d); - labelsContext.ShowText(countText); - - labelsContext.SetSourceRGBA(1d, 1d, 1d, 1d); - labelsContext.MoveTo(textX, textY); - labelsContext.ShowText(countText); - } - - try - { - api.Gui.LoadOrUpdateCairoTexture(labelsSurface, true, ref labelsTexture); - api.Render.RenderTextureIntoTexture(labelsTexture, 0f, 0f, labelsTexture.Width, labelsTexture.Height, targetTexture, 0f, 0f, 0.01f); - } - finally - { - labelsTexture.Dispose(); - } - - api.Render.BindTexture2d(targetTexture.TextureId); - } - - private bool TryGetIconTexture(ItemStack stack, out LoadedTexture atlasTexture, out TextureAtlasPosition textureAtlasPosition) - { - if (TryGetRenderedItemIcon(stack, out atlasTexture, out textureAtlasPosition)) - return true; - - TextureAtlasPosition fallbackTextureAtlasPosition = api.Render.GetTextureAtlasPosition(stack); - if (fallbackTextureAtlasPosition == null || !TryGetAtlasTexture(fallbackTextureAtlasPosition, out atlasTexture)) - return false; - - textureAtlasPosition = fallbackTextureAtlasPosition; - return true; - } - - private bool TryGetRenderedItemIcon(ItemStack stack, out LoadedTexture atlasTexture, out TextureAtlasPosition textureAtlasPosition) - { - atlasTexture = null!; - textureAtlasPosition = null!; - - string key = BuildStackKey(stack); - if (!renderedIconByStack.TryGetValue(key, out RenderedIconEntry? entry)) - { - entry = new RenderedIconEntry(stack.Clone()); - renderedIconByStack[key] = entry; - } - - if (!entry.Requested) - { - entry.Requested = true; - api.Render.RenderItemStackToAtlas( - entry.Stack, - api.ItemTextureAtlas, - rendered_icon_size, - textureSubId => - { - entry.TextureSubId = textureSubId; - entry.Ready = true; - iconRenderVersion++; - }, - ColorUtil.WhiteArgb, - 0f, - 1f - ); - } - - if (!entry.Ready) - return false; - - if (!TryGetAtlasPosition(api.ItemTextureAtlas, entry.TextureSubId, out TextureAtlasPosition texPos)) - return false; - - if (!TryGetAtlasTexture(api.ItemTextureAtlas, texPos.atlasNumber, out LoadedTexture tex)) - return false; - - atlasTexture = tex; - textureAtlasPosition = texPos; - return true; - } - - private bool TryGetAtlasTexture(TextureAtlasPosition textureAtlasPosition, out LoadedTexture atlasTexture) - { - atlasTexture = null!; - - int textureId = textureAtlasPosition.atlasTextureId; - if (textureId <= 0) - return false; - - if (atlasTextureById.TryGetValue(textureId, out LoadedTexture? cachedAtlasTexture)) - { - atlasTexture = cachedAtlasTexture; - return true; - } - - int atlasSize = ResolveAtlasSize(textureId); - if (atlasSize <= 0) - return false; - - atlasTexture = new LoadedTexture(api, textureId, atlasSize, atlasSize) - { - IgnoreUndisposed = true - }; - - atlasTextureById[textureId] = atlasTexture; - return true; - } - - private int ResolveAtlasSize(int textureId) - { - if (TryGetAtlasSize(api.ItemTextureAtlas, textureId, out int itemAtlasSize)) - return itemAtlasSize; - - if (TryGetAtlasSize(api.BlockTextureAtlas, textureId, out int blockAtlasSize)) - return blockAtlasSize; - - if (TryGetAtlasSize(api.EntityTextureAtlas, textureId, out int entityAtlasSize)) - return entityAtlasSize; - - return 0; - } - - private static bool TryGetAtlasPosition(ITextureAtlasAPI atlas, int textureSubId, out TextureAtlasPosition textureAtlasPosition) - { - textureAtlasPosition = null!; - if (atlas.Positions == null || textureSubId < 0 || textureSubId >= atlas.Positions.Length) - return false; - - textureAtlasPosition = atlas.Positions[textureSubId]; - return textureAtlasPosition != null; - } - - private static bool TryGetAtlasTexture(ITextureAtlasAPI? atlas, int atlasNumber, out LoadedTexture texture) - { - texture = null!; - if (atlas == null || atlas.AtlasTextures == null || atlasNumber < 0 || atlasNumber >= atlas.AtlasTextures.Count) - return false; - - LoadedTexture atlasTexture = atlas.AtlasTextures[atlasNumber]; - if (atlasTexture.TextureId == 0) - return false; - - texture = atlasTexture; - return true; - } - - private static bool TryGetAtlasSize(ITextureAtlasAPI? atlas, int textureId, out int size) - { - size = 0; - if (atlas == null || atlas.AtlasTextures == null) - return false; - - foreach (LoadedTexture atlasTexture in atlas.AtlasTextures) - { - if (atlasTexture.TextureId != textureId) - continue; - - size = atlasTexture.Width; - if (size > 0) - return true; - } - - return false; - } - - private static string CreateTargetKey(BlockPos blockPos) - { - return $"{blockPos.X}:{blockPos.Y}:{blockPos.Z}"; - } - - private static string BuildStackKey(ItemStack stack) - { - return $"{(int)stack.Class}:{stack.Id}:{HashBytes(stack.ToBytes())}"; - } - - private static bool TryGetCellBounds(int index, int maxColumns, float hudWidth, float hudHeight, out float x, out float y) - { - int row = index / maxColumns; - int column = index % maxColumns; - - x = cell_padding + column * (cell_width + cell_gap); - y = cell_padding + row * (cell_height + cell_gap); - if (x + cell_width > hudWidth - cell_padding || y + cell_height > hudHeight - cell_padding) - return false; - - return true; - } - - private static float GetCardWidth(int maxColumns) - { - return cell_padding * 2f + maxColumns * cell_width + (maxColumns - 1) * cell_gap; - } - - private static float GetCardHeight(int rows) - { - return cell_padding * 2f + rows * cell_height + (rows - 1) * cell_gap; - } - - private static int GetRowCount(int itemCount, int maxColumns) - { - int safeMaxColumns = Math.Max(1, maxColumns); - int safeItemCount = Math.Max(1, itemCount); - return (safeItemCount + safeMaxColumns - 1) / safeMaxColumns; - } - - private static int GetUsedColumnCount(int itemCount, int maxColumns) - { - int safeMaxColumns = Math.Max(1, maxColumns); - if (itemCount <= 0) - return 1; - - return Math.Min(itemCount, safeMaxColumns); - } - - private int GetDefaultMaxColumns(PreviewTarget target) - { - if (target.Mode == PreviewModes.UNDER_CURSOR) - return Math.Max(1, config.ColumnsUnderCursor <= 0 ? 1 : config.ColumnsUnderCursor); - - return Math.Max(1, config.BillboardColumnsPerBlock <= 0 ? 1 : config.BillboardColumnsPerBlock); - } - - private class CachedCardTexture(LoadedTexture texture, string fingerprint) - { - public LoadedTexture Texture { get; } = texture; - - public string Fingerprint { get; set; } = fingerprint; - } - - private class GroupedItemCell(ItemStack stack, int count, int naturalOrder) - { - public ItemStack Stack { get; } = stack; - - public int Count { get; set; } = count; - - public int NaturalOrder { get; } = naturalOrder; - } - - private class RenderedIconEntry(ItemStack stack) - { - public ItemStack Stack { get; } = stack; - - public bool Requested { get; set; } - - public bool Ready { get; set; } - - public int TextureSubId { get; set; } = -1; - } -} +using Cairo; +using ChestPreview.Configs; +using ChestPreview.Models; +using ChestPreview.Utils; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.API.Config; +using Vintagestory.API.Datastructures; +using Vintagestory.API.MathTools; +using Vintagestory.GameContent; + +namespace ChestPreview.Rendering; + +internal class CardRenderer(ICoreClientAPI api, Config config) : IDisposable +{ + private static readonly int rendered_icon_size = 64; + private static readonly float hud_z = 0f; + private static readonly double hud_outer_alpha = 0.36d; + private static readonly float cell_padding = 10f; + private static readonly float cell_gap = 6f; + private static readonly float cell_width = 72f; + private static readonly float cell_height = 86f; + private static readonly float icon_size = 64f; + private static readonly double count_font_size = 20d; + private static readonly float count_anchor_inset = 2f; + + private readonly ICoreClientAPI api = api; + private readonly Config config = config; + private readonly TreeAttribute tempTree = new(); + private readonly Dictionary cardTextureByTarget = []; + private readonly Dictionary atlasTextureById = []; + private readonly Dictionary renderedIconByStack = []; + private int iconRenderVersion; + + public static float Width => GetCardWidth(1); + + public static float Height => GetCardHeight(1); + + public bool TryGetOrCreateCardTexture(PreviewTarget target, out LoadedTexture texture) + { + return TryGetOrCreateCardTexture(target, GetDefaultMaxColumns(target), out texture); + } + + public bool TryGetOrCreateCardTexture(PreviewTarget target, int maxColumns, out LoadedTexture texture) + { + texture = null!; + + if (TryResolveHeldBagContents(target.BlockEntity, out List? bagContents)) + { + string bagTargetKey = CreateTargetKey(target.BlockEntity.Pos); + if (!cardTextureByTarget.TryGetValue(bagTargetKey, out CachedCardTexture? bagCachedTexture)) + { + bagCachedTexture = new CachedCardTexture(new LoadedTexture(api), string.Empty); + cardTextureByTarget[bagTargetKey] = bagCachedTexture; + } + + BuildLayoutFromStacks(bagContents, maxColumns, out List bagCells, out int bagUsedColumns, out int bagUsedRows, out string bagFingerprint); + if (bagCachedTexture.Fingerprint != bagFingerprint) + { + ComposeTexture(bagCachedTexture.Texture, bagCells, bagUsedColumns, bagUsedRows); + bagCachedTexture.Fingerprint = bagFingerprint; + } + + texture = bagCachedTexture.Texture; + return texture.TextureId != 0; + } + + if (config.GroundStorageOnlyContainers && target.BlockEntity is BlockEntityGroundStorage) + return false; + + if (!TryResolveInventory(target, out InventoryBase? resolvedInventory)) + return false; + + InventoryBase inventory = resolvedInventory; + IPlayer? player = api.World.Player; + EntityPlayer? playerEntity = player?.Entity; + if (player == null || playerEntity == null || !inventory.CanPlayerAccess(player, playerEntity.GetPos())) + return false; + + string targetKey = CreateTargetKey(target.BlockEntity.Pos); + if (!cardTextureByTarget.TryGetValue(targetKey, out CachedCardTexture? cachedTexture)) + { + cachedTexture = new CachedCardTexture(new LoadedTexture(api), string.Empty); + cardTextureByTarget[targetKey] = cachedTexture; + } + + BuildLayout(inventory, maxColumns, out List cellsFromInventory, out int usedColumnsFromInventory, out int usedRowsFromInventory, out string fingerprintFromInventory); + if (cachedTexture.Fingerprint != fingerprintFromInventory) + { + ComposeTexture(cachedTexture.Texture, cellsFromInventory, usedColumnsFromInventory, usedRowsFromInventory); + cachedTexture.Fingerprint = fingerprintFromInventory; + } + + texture = cachedTexture.Texture; + return texture.TextureId != 0; + } + + private bool TryResolveHeldBagContents(BlockEntity blockEntity, [NotNullWhen(true)] out List? contents) + { + contents = null; + if (blockEntity is not BlockEntityGroundStorage groundStorage) + return false; + + foreach (ItemSlot? slot in groundStorage.Inventory) + { + ItemStack? bagStack = slot?.Itemstack; + if (bagStack?.Collectible is not { } collectible) + continue; + + IHeldBag? heldBag = collectible.GetCollectibleInterface(); + if (heldBag == null) + continue; + + contents = []; + ItemStack[] bagContents = heldBag.GetContents(bagStack, api.World); + foreach (ItemStack contentStack in bagContents) + { + if (contentStack == null || contentStack.StackSize <= 0) + continue; + + contents.Add(contentStack); + } + + return true; + } + + return false; + } + + private bool TryResolveInventory(PreviewTarget target, [NotNullWhen(true)] out InventoryBase? inventory) + { + inventory = null; + + if (TryResolveBloomeryInventory(target.BlockEntity, out InventoryBase? bloomeryInventory)) + { + inventory = bloomeryInventory; + return true; + } + + BlockPos pos = target.BlockEntity.Pos; + if (target.Block.GetInterface(api.World, pos) is { } container && container.Inventory is InventoryBase blockInventory) + { + inventory = blockInventory; + return true; + } + + return false; + } + + private bool TryResolveBloomeryInventory(BlockEntity blockEntity, [NotNullWhen(true)] out InventoryBase? inventory) + { + inventory = null; + if (blockEntity is not BlockEntityBloomery) + return false; + + tempTree.Clear(); + blockEntity.ToTreeAttributes(tempTree); + + // bloomery inventory has 3 slots: fuel, ore, output + InventoryGeneric bloomeryInventory = new(3, "bloomery-preview", null, null); + bloomeryInventory.FromTreeAttributes(tempTree); + inventory = bloomeryInventory; + return true; + } + + public void DrawAtScreen(float x, float y, LoadedTexture texture) + { + if (texture.TextureId == 0) + return; + + api.Render.RenderTexture(texture.TextureId, x, y, texture.Width, texture.Height, hud_z, new Vec4f(1f, 1f, 1f, 1f)); + } + + public void ClampToScreen(ref float x, ref float y) + { + ClampToScreen(ref x, ref y, Width, Height); + } + + public void ClampToScreen(ref float x, ref float y, float cardWidth, float cardHeight) + { + float max_x = api.Render.FrameWidth - cardWidth; + float max_y = api.Render.FrameHeight - cardHeight; + + x = Math.Clamp(x, 0f, max_x); + y = Math.Clamp(y, 0f, max_y); + } + + public void Dispose() + { + foreach (CachedCardTexture cardTexture in cardTextureByTarget.Values) + cardTexture.Texture.Dispose(); + + cardTextureByTarget.Clear(); + atlasTextureById.Clear(); + renderedIconByStack.Clear(); + } + + private void BuildLayout(IInventory inventory, int maxColumns, out List cells, out int usedColumns, out int usedRows, out string fingerprint) + { + List stacks = []; + foreach (ItemSlot? slot in inventory) + { + if (slot?.Itemstack == null || slot.Empty) + continue; + + stacks.Add(slot.Itemstack); + } + + BuildLayoutFromStacks(stacks, maxColumns, out cells, out usedColumns, out usedRows, out fingerprint); + } + + private void BuildLayoutFromStacks(IEnumerable stacks, int maxColumns, out List cells, out int usedColumns, out int usedRows, out string fingerprint) + { + List groupedItems = []; + int naturalOrder = 0; + + foreach (ItemStack stack in stacks) + { + if (stack == null || stack.StackSize <= 0) + continue; + + int existingIndex = FindMatchingItemIndex(groupedItems, stack); + if (existingIndex >= 0) + { + GroupedItemCell existing = groupedItems[existingIndex]; + existing.Count += stack.StackSize; + continue; + } + + ItemStack representativeStack = stack.Clone(); + representativeStack.StackSize = 1; + groupedItems.Add(new GroupedItemCell(representativeStack, stack.StackSize, naturalOrder)); + naturalOrder++; + } + + groupedItems.Sort(static (left, right) => + { + int countComparison = right.Count.CompareTo(left.Count); + return countComparison != 0 ? countComparison : left.NaturalOrder.CompareTo(right.NaturalOrder); + }); + + maxColumns = Math.Max(1, maxColumns); + usedColumns = GetUsedColumnCount(groupedItems.Count, maxColumns); + usedRows = GetRowCount(groupedItems.Count, maxColumns); + + cells = groupedItems; + fingerprint = BuildFingerprint(cells, maxColumns, usedColumns, usedRows); + } + + private int FindMatchingItemIndex(List groupedItems, ItemStack stack) + { + for (int i = 0; i < groupedItems.Count; i++) + { + if (AreStacksEquivalent(groupedItems[i].Stack, stack)) + return i; + } + + return -1; + } + + private bool AreStacksEquivalent(ItemStack left, ItemStack right) + { + if (left.Class != right.Class || left.Id != right.Id) + return false; + + ItemStack leftComparable = left.Clone(); + ItemStack rightComparable = right.Clone(); + leftComparable.StackSize = 1; + rightComparable.StackSize = 1; + return leftComparable.Equals(api.World, rightComparable, GlobalConstants.IgnoredStackAttributes); + } + + private string BuildFingerprint(List cells, int maxColumns, int usedColumns, int usedRows) + { + StringBuilder builder = new(); + builder.Append('m'); + builder.Append(maxColumns); + builder.Append('|'); + builder.Append('w'); + builder.Append(GetCardWidth(usedColumns).ToString(CultureInfo.InvariantCulture)); + builder.Append('h'); + builder.Append(GetCardHeight(usedRows).ToString(CultureInfo.InvariantCulture)); + builder.Append('|'); + builder.Append("iv"); + builder.Append(iconRenderVersion); + builder.Append('|'); + + foreach (GroupedItemCell cell in cells) + { + builder.Append((int)cell.Stack.Class); + builder.Append(':'); + builder.Append(cell.Stack.Id); + builder.Append(':'); + builder.Append(cell.Count); + builder.Append(':'); + builder.Append(HashBytes(cell.Stack.ToBytes())); + builder.Append('|'); + } + + return builder.ToString(); + } + + private static uint HashBytes(byte[] bytes) + { + const uint offset_basis = 2166136261; + const uint prime = 16777619; + + uint hash = offset_basis; + for (int i = 0; i < bytes.Length; i++) + hash = (hash ^ bytes[i]) * prime; + + return hash; + } + + private void ComposeTexture(LoadedTexture targetTexture, List cells, int usedColumns, int usedRows) + { + float hudWidth = GetCardWidth(usedColumns); + float hudHeight = GetCardHeight(usedRows); + + using ImageSurface surface = new(Format.Argb32, (int)hudWidth, (int)hudHeight); + using Context context = new(surface); + + context.Operator = Operator.Source; + context.SetSourceRGBA(0d, 0d, 0d, 0d); + context.Paint(); + + context.SetSourceRGBA(0.28d, 0.28d, 0.28d, hud_outer_alpha); + context.Rectangle(0d, 0d, hudWidth, hudHeight); + context.Fill(); + + context.SelectFontFace("sans", FontSlant.Normal, FontWeight.Bold); + context.SetFontSize(count_font_size); + + api.Gui.LoadOrUpdateCairoTexture(surface, true, ref targetTexture); + + for (int i = 0; i < cells.Count; i++) + { + GroupedItemCell cell = cells[i]; + if (!TryGetCellBounds(i, usedColumns, hudWidth, hudHeight, out float cellX, out float cellY)) + continue; + + if (!TryGetIconTexture(cell.Stack, out LoadedTexture atlasTexture, out TextureAtlasPosition textureAtlasPosition)) + continue; + + float sourceX = textureAtlasPosition.x1 * atlasTexture.Width; + float sourceY = textureAtlasPosition.y1 * atlasTexture.Height; + float sourceWidth = (textureAtlasPosition.x2 - textureAtlasPosition.x1) * atlasTexture.Width; + float sourceHeight = (textureAtlasPosition.y2 - textureAtlasPosition.y1) * atlasTexture.Height; + + float iconX = cellX + (cell_width - icon_size) / 2f; + float iconY = cellY + 1f; + + api.Render.RenderTextureIntoTexture(atlasTexture, sourceX, sourceY, sourceWidth, sourceHeight, targetTexture, iconX, iconY, 0.01f); + } + + using ImageSurface labelsSurface = new(Format.Argb32, (int)hudWidth, (int)hudHeight); + using Context labelsContext = new(labelsSurface); + LoadedTexture labelsTexture = new(api); + + labelsContext.Operator = Operator.Source; + labelsContext.SetSourceRGBA(0d, 0d, 0d, 0d); + labelsContext.Paint(); + labelsContext.SelectFontFace("sans", FontSlant.Normal, FontWeight.Bold); + labelsContext.SetFontSize(count_font_size); + + for (int i = 0; i < cells.Count; i++) + { + GroupedItemCell cell = cells[i]; + if (!TryGetCellBounds(i, usedColumns, hudWidth, hudHeight, out float cellX, out float cellY)) + continue; + + float iconX = cellX + (cell_width - icon_size) / 2f; + float iconY = cellY + 1f; + float iconRight = iconX + icon_size - count_anchor_inset; + float iconBottom = iconY + icon_size - count_anchor_inset; + + string countText = cell.Count.ToString(CultureInfo.InvariantCulture); + TextExtents textExtents = labelsContext.TextExtents(countText); + + double textX = iconRight - (textExtents.Width + textExtents.XBearing); + double textY = iconBottom - (textExtents.Height + textExtents.YBearing); + + labelsContext.SetSourceRGBA(0d, 0d, 0d, 0.95d); + labelsContext.MoveTo(textX + 1d, textY + 1d); + labelsContext.ShowText(countText); + + labelsContext.SetSourceRGBA(1d, 1d, 1d, 1d); + labelsContext.MoveTo(textX, textY); + labelsContext.ShowText(countText); + } + + try + { + api.Gui.LoadOrUpdateCairoTexture(labelsSurface, true, ref labelsTexture); + api.Render.RenderTextureIntoTexture(labelsTexture, 0f, 0f, labelsTexture.Width, labelsTexture.Height, targetTexture, 0f, 0f, 0.01f); + } + finally + { + labelsTexture.Dispose(); + } + + api.Render.BindTexture2d(targetTexture.TextureId); + } + + private bool TryGetIconTexture(ItemStack stack, out LoadedTexture atlasTexture, out TextureAtlasPosition textureAtlasPosition) + { + if (TryGetRenderedItemIcon(stack, out atlasTexture, out textureAtlasPosition)) + return true; + + TextureAtlasPosition fallbackTextureAtlasPosition = api.Render.GetTextureAtlasPosition(stack); + if (fallbackTextureAtlasPosition == null || !TryGetAtlasTexture(fallbackTextureAtlasPosition, out atlasTexture)) + return false; + + textureAtlasPosition = fallbackTextureAtlasPosition; + return true; + } + + private bool TryGetRenderedItemIcon(ItemStack stack, out LoadedTexture atlasTexture, out TextureAtlasPosition textureAtlasPosition) + { + atlasTexture = null!; + textureAtlasPosition = null!; + + string key = BuildStackKey(stack); + if (!renderedIconByStack.TryGetValue(key, out RenderedIconEntry? entry)) + { + entry = new RenderedIconEntry(stack.Clone()); + renderedIconByStack[key] = entry; + } + + if (!entry.Requested) + { + entry.Requested = true; + api.Render.RenderItemStackToAtlas( + entry.Stack, + api.ItemTextureAtlas, + rendered_icon_size, + textureSubId => + { + entry.TextureSubId = textureSubId; + entry.Ready = true; + iconRenderVersion++; + }, + ColorUtil.WhiteArgb, + 0f, + 1f + ); + } + + if (!entry.Ready) + return false; + + if (!TryGetAtlasPosition(api.ItemTextureAtlas, entry.TextureSubId, out TextureAtlasPosition texPos)) + return false; + + if (!TryGetAtlasTexture(api.ItemTextureAtlas, texPos.atlasNumber, out LoadedTexture tex)) + return false; + + atlasTexture = tex; + textureAtlasPosition = texPos; + return true; + } + + private bool TryGetAtlasTexture(TextureAtlasPosition textureAtlasPosition, out LoadedTexture atlasTexture) + { + atlasTexture = null!; + + int textureId = textureAtlasPosition.atlasTextureId; + if (textureId <= 0) + return false; + + if (atlasTextureById.TryGetValue(textureId, out LoadedTexture? cachedAtlasTexture)) + { + atlasTexture = cachedAtlasTexture; + return true; + } + + int atlasSize = ResolveAtlasSize(textureId); + if (atlasSize <= 0) + return false; + + atlasTexture = new LoadedTexture(api, textureId, atlasSize, atlasSize) + { + IgnoreUndisposed = true + }; + + atlasTextureById[textureId] = atlasTexture; + return true; + } + + private int ResolveAtlasSize(int textureId) + { + if (TryGetAtlasSize(api.ItemTextureAtlas, textureId, out int itemAtlasSize)) + return itemAtlasSize; + + if (TryGetAtlasSize(api.BlockTextureAtlas, textureId, out int blockAtlasSize)) + return blockAtlasSize; + + if (TryGetAtlasSize(api.EntityTextureAtlas, textureId, out int entityAtlasSize)) + return entityAtlasSize; + + return 0; + } + + private static bool TryGetAtlasPosition(ITextureAtlasAPI atlas, int textureSubId, out TextureAtlasPosition textureAtlasPosition) + { + textureAtlasPosition = null!; + if (atlas.Positions == null || textureSubId < 0 || textureSubId >= atlas.Positions.Length) + return false; + + textureAtlasPosition = atlas.Positions[textureSubId]; + return textureAtlasPosition != null; + } + + private static bool TryGetAtlasTexture(ITextureAtlasAPI? atlas, int atlasNumber, out LoadedTexture texture) + { + texture = null!; + if (atlas == null || atlas.AtlasTextures == null || atlasNumber < 0 || atlasNumber >= atlas.AtlasTextures.Count) + return false; + + LoadedTexture atlasTexture = atlas.AtlasTextures[atlasNumber]; + if (atlasTexture.TextureId == 0) + return false; + + texture = atlasTexture; + return true; + } + + private static bool TryGetAtlasSize(ITextureAtlasAPI? atlas, int textureId, out int size) + { + size = 0; + if (atlas == null || atlas.AtlasTextures == null) + return false; + + foreach (LoadedTexture atlasTexture in atlas.AtlasTextures) + { + if (atlasTexture.TextureId != textureId) + continue; + + size = atlasTexture.Width; + if (size > 0) + return true; + } + + return false; + } + + private static string CreateTargetKey(BlockPos blockPos) + { + return $"{blockPos.X}:{blockPos.Y}:{blockPos.Z}"; + } + + private static string BuildStackKey(ItemStack stack) + { + return $"{(int)stack.Class}:{stack.Id}:{HashBytes(stack.ToBytes())}"; + } + + private static bool TryGetCellBounds(int index, int maxColumns, float hudWidth, float hudHeight, out float x, out float y) + { + int row = index / maxColumns; + int column = index % maxColumns; + + x = cell_padding + column * (cell_width + cell_gap); + y = cell_padding + row * (cell_height + cell_gap); + if (x + cell_width > hudWidth - cell_padding || y + cell_height > hudHeight - cell_padding) + return false; + + return true; + } + + private static float GetCardWidth(int maxColumns) + { + return cell_padding * 2f + maxColumns * cell_width + (maxColumns - 1) * cell_gap; + } + + private static float GetCardHeight(int rows) + { + return cell_padding * 2f + rows * cell_height + (rows - 1) * cell_gap; + } + + private static int GetRowCount(int itemCount, int maxColumns) + { + int safeMaxColumns = Math.Max(1, maxColumns); + int safeItemCount = Math.Max(1, itemCount); + return (safeItemCount + safeMaxColumns - 1) / safeMaxColumns; + } + + private static int GetUsedColumnCount(int itemCount, int maxColumns) + { + int safeMaxColumns = Math.Max(1, maxColumns); + if (itemCount <= 0) + return 1; + + return Math.Min(itemCount, safeMaxColumns); + } + + private int GetDefaultMaxColumns(PreviewTarget target) + { + if (target.Mode == PreviewModes.UNDER_CURSOR) + return Math.Max(1, config.ColumnsUnderCursor <= 0 ? 1 : config.ColumnsUnderCursor); + + return Math.Max(1, config.BillboardColumnsPerBlock <= 0 ? 1 : config.BillboardColumnsPerBlock); + } + + private class CachedCardTexture(LoadedTexture texture, string fingerprint) + { + public LoadedTexture Texture { get; } = texture; + + public string Fingerprint { get; set; } = fingerprint; + } + + private class GroupedItemCell(ItemStack stack, int count, int naturalOrder) + { + public ItemStack Stack { get; } = stack; + + public int Count { get; set; } = count; + + public int NaturalOrder { get; } = naturalOrder; + } + + private class RenderedIconEntry(ItemStack stack) + { + public ItemStack Stack { get; } = stack; + + public bool Requested { get; set; } + + public bool Ready { get; set; } + + public int TextureSubId { get; set; } = -1; + } +} diff --git a/ChestPreview/ChestPreview/assets/chestpreview/config/configlib-patches.json b/ChestPreview/ChestPreview/assets/chestpreview/config/configlib-patches.json index f9f7665..dbc6deb 100644 --- a/ChestPreview/ChestPreview/assets/chestpreview/config/configlib-patches.json +++ b/ChestPreview/ChestPreview/assets/chestpreview/config/configlib-patches.json @@ -72,7 +72,7 @@ "code": "WhitelistedContainers", "comment": "config-desc-WhitelistedContainers", "type": "string", - "default": "barrel, crate, chest-*, trunk-*, storagevessel-*, stationarybasket-*, labeledchest-*", + "default": "barrel, crate, chest-*, trunk-*, storagevessel-*, stationarybasket-*, labeledchest-*, labeledtrunk-*, groundstorage, beehive-*", "clientSide": true }, { @@ -81,6 +81,13 @@ "type": "string", "default": "", "clientSide": true + }, + { + "code": "GroundStorageOnlyContainers", + "comment": "config-desc-GroundStorageOnlyContainers", + "type": "boolean", + "default": true, + "clientSide": true } ] } \ No newline at end of file diff --git a/ChestPreview/ChestPreview/assets/chestpreview/lang/en.json b/ChestPreview/ChestPreview/assets/chestpreview/lang/en.json index d33bc72..5cfc5ba 100644 --- a/ChestPreview/ChestPreview/assets/chestpreview/lang/en.json +++ b/ChestPreview/ChestPreview/assets/chestpreview/lang/en.json @@ -7,6 +7,7 @@ "config-desc-ColumnsUnderCursor": "Columns in the UnderCursor mode.", "config-desc-PreviewNearbyRadius": "Radius for \"Preview containers nearby\".", "config-desc-WhitelistedContainersOnly": "If true, only container codes in WhitelistedContainers are handled.", + "config-desc-GroundStorageOnlyContainers": "If true, groundstorage previews only appear when the stored item has its own inventory (for example bags/backpacks).", "config-desc-WhitelistedContainers": "Allowed container codes, separated by comma, semicolon, or spaces. No effectif WhitelistedContainersOnly==false.", "config-desc-BlacklistedContainers": "Blocked container codes, separated by comma, semicolon, or spaces." } \ No newline at end of file diff --git a/ChestPreview/ChestPreview/modinfo.json b/ChestPreview/ChestPreview/modinfo.json index 0d6a93e..27f60fe 100644 --- a/ChestPreview/ChestPreview/modinfo.json +++ b/ChestPreview/ChestPreview/modinfo.json @@ -7,7 +7,7 @@ "OrekiWoof" ], "description": "see containers' contents without having to open them", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "game": "1.21.0" },