diff --git a/src/main/java/com/ruling_0/materiallib/CommandMatInfo.java b/src/main/java/com/ruling_0/materiallib/CommandMatInfo.java index 23cc6272..b5585746 100644 --- a/src/main/java/com/ruling_0/materiallib/CommandMatInfo.java +++ b/src/main/java/com/ruling_0/materiallib/CommandMatInfo.java @@ -119,9 +119,21 @@ private static String formatValue(Material material, Property property) { if (TINT_PROPERTIES.contains(property)) { return String.format("0x%08X", value); } + if (property == StandardProperties.LAYER_TINTS) { + return hexList((List) value); + } return String.valueOf(value); } + /// The layer tints as AARRGGBB hex, in layer order. + private static String hexList(List tints) { + List hex = new ArrayList<>(tints.size()); + for (Object tint : tints) { + hex.add(String.format("0x%08X", tint)); + } + return hex.toString(); + } + private static String type(Shape shape) { if (shape instanceof ShapeFluidInContainer) return "fluid container"; if (shape instanceof ShapeBlock) return "block"; diff --git a/src/main/java/com/ruling_0/materiallib/api/IconSet.java b/src/main/java/com/ruling_0/materiallib/api/IconSet.java index 84829443..bcb9c750 100644 --- a/src/main/java/com/ruling_0/materiallib/api/IconSet.java +++ b/src/main/java/com/ruling_0/materiallib/api/IconSet.java @@ -9,10 +9,10 @@ import cpw.mods.fml.relauncher.SideOnly; /// A named per-material icon binding on one texture atlas, with no backing item or block: the icon resolution of a -/// shape (resource-pack overrides, texture-set chain, fallbacks, unification alternatives, `_OVERLAY` convention) for -/// art a mod composites in its own renderers, e.g. tool parts drawn by the material read from a stack's NBT, or an -/// extra face icon of a block the mod already owns. The set's name is the `` its art is filed under inside -/// each texture set, i.e. the `` of `materials//.png`. +/// shape (resource-pack overrides, texture-set chain, fallbacks, unification alternatives, layer stack) for art a +/// mod composites in its own renderers, e.g. tool parts drawn by the material read from a stack's NBT, or an extra +/// face icon of a block the mod already owns. The set's name is the `` its art is filed under inside each +/// texture set, i.e. the `` of `materials//.png`. /// /// Created through [MaterialLibClient#newIconSet] before the first texture stitch. Icons bind for every registered /// material -- an icon set has no served set, since no material generates one -- when the chosen atlas stitches, and @@ -47,15 +47,25 @@ public enum Atlas { this.icons = new ShapeIcons(atlas == Atlas.ITEMS); } - /// The icon bound for `material`, or the transparent placeholder when none resolved. Valid only once the - /// atlas has stitched. + /// The first layer of `material`'s icon stack, or the transparent placeholder when none resolved. Valid only + /// once the atlas has stitched. public IIcon getIcon(Material material) { return icons.get(material.getIndex()); } - /// The `_OVERLAY` icon bound for `material`, or null when its resolved texture set has none. - public IIcon getOverlayIcon(Material material) { - return icons.getOverlayOrNull(material.getIndex()); + /// The number of icon layers bound for `material`; see [TextureSet]. + public int getLayerCount(Material material) { + return icons.layerCount(material.getIndex()); + } + + /// The icon at `layer` of `material`'s stack, or the transparent placeholder outside the stack's bounds. + public IIcon getLayerIcon(Material material, int layer) { + return icons.layer(material.getIndex(), layer); + } + + /// The ARGB tint `material`'s layer `layer` takes; see [ShapeItem#getMaterialLayerColor]. + public int getLayerColor(Material material, int layer) { + return icons.layerColor(material, layer); } /// Whether `material`'s icon bound from the resource-pack override location; see [ShapeItem#hasOverrideIcon]. @@ -67,6 +77,10 @@ void bind(IIconRegister register, Material[] materials) { icons.bind(register, materials, name); } + void bindPlaceholder(IIconRegister register) { + icons.bindPlaceholder(register); + } + int atlasType() { return atlas.textureType; } diff --git a/src/main/java/com/ruling_0/materiallib/api/IconSetBinder.java b/src/main/java/com/ruling_0/materiallib/api/IconSetBinder.java index d3bc4ca6..2586f54b 100644 --- a/src/main/java/com/ruling_0/materiallib/api/IconSetBinder.java +++ b/src/main/java/com/ruling_0/materiallib/api/IconSetBinder.java @@ -21,9 +21,14 @@ public final class IconSetBinder { @SubscribeEvent public void onTextureStitch(TextureStitchEvent.Pre event) { int textureType = event.map.getTextureType(); + boolean defer = MaterialLibClient.deferIconBinding(); Material[] materials = null; for (IconSet set : MaterialLibClient.getIconSets()) { if (set.atlasType() != textureType) continue; + if (defer) { + set.bindPlaceholder(event.map); + continue; + } if (materials == null) materials = registeredMaterials(); set.bind(event.map, materials); } diff --git a/src/main/java/com/ruling_0/materiallib/api/MaterialBuilder.java b/src/main/java/com/ruling_0/materiallib/api/MaterialBuilder.java index d8b0b489..710c760e 100644 --- a/src/main/java/com/ruling_0/materiallib/api/MaterialBuilder.java +++ b/src/main/java/com/ruling_0/materiallib/api/MaterialBuilder.java @@ -37,6 +37,16 @@ public MaterialBuilder setTint(int tint) { return setProperty(StandardProperties.TINT, tint); } + /// Sets [StandardProperties#LAYER_TINTS], the ARGB tints of the shape textures' numbered icon layers, the + /// first tinting `_LAYER1`. + public MaterialBuilder setLayerTints(int... tints) { + Integer[] boxed = new Integer[tints.length]; + for (int i = 0; i < tints.length; i++) { + boxed[i] = tints[i]; + } + return setProperty(StandardProperties.LAYER_TINTS, List.of(boxed)); + } + /// Sets [StandardProperties#FLUID_TINT], the ARGB tint applied to a fluid shape's fill icon in place of /// [StandardProperties#TINT]. public MaterialBuilder setFluidTint(int fluidTint) { diff --git a/src/main/java/com/ruling_0/materiallib/api/MaterialLibClient.java b/src/main/java/com/ruling_0/materiallib/api/MaterialLibClient.java index 7df2604a..06e36458 100644 --- a/src/main/java/com/ruling_0/materiallib/api/MaterialLibClient.java +++ b/src/main/java/com/ruling_0/materiallib/api/MaterialLibClient.java @@ -5,6 +5,8 @@ import net.minecraftforge.client.IItemRenderer; +import cpw.mods.fml.common.Loader; +import cpw.mods.fml.common.LoaderState; import cpw.mods.fml.relauncher.Side; import cpw.mods.fml.relauncher.SideOnly; import it.unimi.dsi.fastutil.objects.ObjectArrayList; @@ -36,6 +38,12 @@ public static IconSet newIconSet(String modid, String name, IconSet.Atlas atlas) static List getIconSets() { return iconSets; } + /// Whether atlas stitches bind only the placeholder icon: true until FML finishes mod loading, since the + /// mid-startup resource refresh stitches a temporary atlas that the load-complete reload replaces. + static boolean deferIconBinding() { + return !Loader.instance().hasReachedState(LoaderState.AVAILABLE); + } + /// Renders every item shape of `material` through `renderer`. Call from a mod's client proxy. public static void setItemRenderer(Material material, IItemRenderer renderer) { Objects.requireNonNull(material, "material must not be null"); diff --git a/src/main/java/com/ruling_0/materiallib/api/MaterialTints.java b/src/main/java/com/ruling_0/materiallib/api/MaterialTints.java index c48600f4..4dc58ba3 100644 --- a/src/main/java/com/ruling_0/materiallib/api/MaterialTints.java +++ b/src/main/java/com/ruling_0/materiallib/api/MaterialTints.java @@ -1,5 +1,6 @@ package com.ruling_0.materiallib.api; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -9,13 +10,15 @@ /// Reads the standard ARGB tint properties through [ColorResource], so a resource pack retints one material with a /// lang entry; see [StandardProperties] for the pack-facing key and value format. /// -/// One [ColorResource] per unified material and property is cached here for the life of the game. GTNHLib tracks its -/// instances weakly and refreshes only the reachable ones when resource packs reload. Angelica meshes chunks on -/// several threads at once (see [ShapeBlockRenderingHandler]), so the cache is concurrent. A dedicated server carries -/// no lang files, so every read there returns the value the material declares. +/// One [ColorResource] per unified material and property, and one per unified material and numbered icon layer, is +/// cached here for the life of the game. GTNHLib tracks its instances weakly and refreshes only the reachable ones +/// when resource packs reload. Angelica meshes chunks on several threads at once (see [ShapeBlockRenderingHandler]), +/// so the cache is concurrent. A dedicated server carries no lang files, so every read there returns the value the +/// material declares. final class MaterialTints { private static final Map, Map> RESOURCES = new ConcurrentHashMap<>(); + private static final Map> LAYER_RESOURCES = new ConcurrentHashMap<>(); private MaterialTints() {} @@ -25,8 +28,7 @@ static int color(Material material, Property property) { return resourceFor(material, property).getColor(); } - /// The [ColorResource] backing `property` for `material`, created on first read and shared by every declaration - /// unified onto the same material. + /// The [ColorResource] backing `property` for `material`. static ColorResource resourceFor(Material material, Property property) { Map byMaterial = RESOURCES.computeIfAbsent(property, ignored -> new ConcurrentHashMap<>()); @@ -34,4 +36,31 @@ static ColorResource resourceFor(Material material, Property property) canonical -> new ColorResource(MaterialLib.MODID, canonical.getName() + "." + property.getName(), String.format("0x%08X", canonical.getProperty(property)), true)); } + + /// The ARGB tint `material` resolves for its `_LAYER` icon, or opaque white where + /// [StandardProperties#LAYER_TINTS] codes no element for that layer. + static int layerColor(Material material, int layer) { + ColorResource resource = layerResourceFor(material, layer); + return resource != null ? resource.getColor() : 0xFFFFFFFF; + } + + /// The [ColorResource] backing `material`'s `_LAYER` tint, or null where + /// [StandardProperties#LAYER_TINTS] codes no element for that layer. + static ColorResource layerResourceFor(Material material, int layer) { + Material canonical = material.canonical(); + Integer coded = codedLayerTint(canonical, layer); + if (coded == null) return null; + Map byLayer = LAYER_RESOURCES.computeIfAbsent(canonical, + ignored -> new ConcurrentHashMap<>()); + return byLayer.computeIfAbsent(layer, + number -> new ColorResource(MaterialLib.MODID, canonical.getName() + ".tint." + number, + String.format("0x%08X", coded), true)); + } + + /// The tint `canonical` codes for its `_LAYER` icon, or null where the list stops short of it. + private static Integer codedLayerTint(Material canonical, int layer) { + List tints = canonical.getProperty(StandardProperties.LAYER_TINTS); + if (tints == null || layer < 1 || layer > tints.size()) return null; + return tints.get(layer - 1); + } } diff --git a/src/main/java/com/ruling_0/materiallib/api/ShapeBlock.java b/src/main/java/com/ruling_0/materiallib/api/ShapeBlock.java index 8fb378d3..654f97f6 100644 --- a/src/main/java/com/ruling_0/materiallib/api/ShapeBlock.java +++ b/src/main/java/com/ruling_0/materiallib/api/ShapeBlock.java @@ -40,6 +40,10 @@ /// Drops, hardness, resistance, and harvest level may be overridden per material and variant, and the harvest tool /// class per shape, through [BlockShapeBuilder]'s behavior hooks; a hook left unset preserves the vanilla default /// it replaces. +/// +/// [ShapeBlockRenderingHandler] also composites a material whose art binds more than one icon layer (see +/// [TextureSet]). A stack's depth is known only once the atlas stitches, so [#registerBlockIcons] switches such a +/// block to that handler's render type there and never switches back. public class ShapeBlock extends Block implements BackedShape { private final String modid; @@ -182,6 +186,11 @@ protected String iconName() { @Override @SideOnly(Side.CLIENT) public void registerBlockIcons(IIconRegister register) { + if (MaterialLibClient.deferIconBinding()) { + icons.bindPlaceholder(register); + if (baseTexture != null) baseIcon = register.registerIcon(ShapeIcons.EMPTY_ICON); + return; + } String iconName = iconName(); List candidates = groupName != null ? List.of(ShapeNaming.variantBlockName(iconName, variant), iconName) : List.of(iconName); @@ -189,6 +198,18 @@ public void registerBlockIcons(IIconRegister register) { if (baseTexture != null) { baseIcon = registerBaseIcon(register); } + if (renderType == 0 && hasLayeredMaterial()) { + setRenderType(ShapeBlockRenderingHandler.RENDER_ID); + } + } + + /// Whether any served material bound more than one icon layer. + @SideOnly(Side.CLIENT) + private boolean hasLayeredMaterial() { + for (Material material : served.get()) { + if (icons.layerCount(material.getIndex()) > 1) return true; + } + return false; } /// Registers [#baseTexture] if it names an existing file, or the [ShapeIcons#EMPTY_ICON] placeholder if it @@ -213,8 +234,8 @@ public boolean hasBaseTexture() { return baseTexture != null; } - /// Sets the render type [#getRenderType] reports: [ShapeBlockRenderingHandler]'s render ID for a - /// [#hasBaseTexture] composite, or the vanilla full-cube default (0). + /// Sets the render type [#getRenderType] reports: [ShapeBlockRenderingHandler]'s render ID for a composite, + /// or the vanilla full-cube default (0). @SideOnly(Side.CLIENT) public void setRenderType(int renderType) { this.renderType = renderType; } @@ -227,10 +248,16 @@ IIcon baseIcon() { return baseIcon; } - /// The material icon bound at the given metadata; see [ShapeIcons#get]. + /// The number of material icon layers bound at the given metadata; see [ShapeIcons#layerCount]. @SideOnly(Side.CLIENT) - IIcon materialIcon(int meta) { - return icons.get(meta); + int materialLayerCount(int meta) { + return icons.layerCount(meta); + } + + /// The material icon layer bound at the given metadata; see [ShapeIcons#layer]. + @SideOnly(Side.CLIENT) + IIcon materialLayer(int meta, int layer) { + return icons.layer(meta, layer); } /// The icon path to try for `material` before this shape's texture-set candidates, or null to skip straight @@ -249,18 +276,31 @@ public IIcon getIcon(int side, int meta) { return icons.get(meta); } - /// The icon bound for `material` on this shape, or the transparent placeholder when none resolved. Valid only - /// after the block atlas has stitched; the caching contract of [ShapeItem#getMaterialIcon] applies. + /// The first layer of `material`'s icon stack on this shape, or the transparent placeholder when none resolved. + /// Valid only after the block atlas has stitched; the caching contract of [ShapeItem#getMaterialIcon] applies. @SideOnly(Side.CLIENT) public IIcon getMaterialIcon(Material material) { return icons.get(material.getIndex()); } - /// The `_OVERLAY` icon bound for `material` on this shape, or null when its resolved texture set has none; - /// see [#getMaterialIcon] for the caching contract. + /// The number of icon layers bound for `material` on this shape; see [TextureSet]. + @SideOnly(Side.CLIENT) + public int getMaterialLayerCount(Material material) { + return materialLayerCount(material.getIndex()); + } + + /// The icon at `layer` of `material`'s stack on this shape, or the transparent placeholder outside the stack's + /// bounds; see [#getMaterialIcon] for the caching contract. + @SideOnly(Side.CLIENT) + public IIcon getMaterialLayerIcon(Material material, int layer) { + return materialLayer(material.getIndex(), layer); + } + + /// The ARGB tint `material`'s layer `layer` takes on this shape; see [ShapeItem#getMaterialLayerColor]. Layer 0 + /// reports the material's [StandardProperties#TINT], not [StandardProperties#BLOCK_TINT]. @SideOnly(Side.CLIENT) - public IIcon getMaterialOverlayIcon(Material material) { - return icons.getOverlayOrNull(material.getIndex()); + public int getMaterialLayerColor(Material material, int layer) { + return icons.layerColor(material, layer); } /// Whether `material`'s icon bound from the resource-pack override location; see [ShapeItem#hasOverrideIcon]. @@ -272,7 +312,7 @@ public boolean hasOverrideIcon(Material material) { @Override @SideOnly(Side.CLIENT) public int getRenderColor(int meta) { - if (baseTexture != null) { + if (rendersComposite()) { return 0xFFFFFF; } return tintFor(meta); @@ -281,12 +321,18 @@ public int getRenderColor(int meta) { @Override @SideOnly(Side.CLIENT) public int colorMultiplier(IBlockAccess world, int x, int y, int z) { - if (baseTexture != null) { + if (rendersComposite()) { return 0xFFFFFF; } return tintFor(world.getBlockMetadata(x, y, z)); } + /// Whether [ShapeBlockRenderingHandler] draws this block and supplies each layer's color itself. + @SideOnly(Side.CLIENT) + private boolean rendersComposite() { + return baseTexture != null || renderType != 0; + } + /// The RGB tint of the material at the given metadata, or white when the metadata maps to no live material: /// [StandardProperties#BLOCK_OVERLAY_TINT] for a [#hasBaseTexture] composite's overlay layer, /// [StandardProperties#BLOCK_TINT] for a plain block, [StandardProperties#TINT] when the specific property is @@ -303,6 +349,16 @@ int tintFor(int meta) { return MaterialTints.color(material, tint) & 0xFFFFFF; } + /// The RGB tint of a material icon layer above the first at the given metadata, or white when the metadata maps + /// to no live material; see [ShapeItem#getMaterialLayerColor] for the layer colors. Masked to 24 bits like + /// [#tintFor]. + @SideOnly(Side.CLIENT) + int layerTint(int meta, int layer) { + Material material = MaterialRegistry.instance().getMaterialByIndex(meta); + if (material == null) return 0xFFFFFF; + return icons.layerColor(material, layer) & 0xFFFFFF; + } + @Override public float getBlockHardness(World world, int x, int y, int z) { Material material = behavior.hardness() != null ? materialAt(world, x, y, z) : null; diff --git a/src/main/java/com/ruling_0/materiallib/api/ShapeBlockRenderingHandler.java b/src/main/java/com/ruling_0/materiallib/api/ShapeBlockRenderingHandler.java index 7a10772e..ff609175 100644 --- a/src/main/java/com/ruling_0/materiallib/api/ShapeBlockRenderingHandler.java +++ b/src/main/java/com/ruling_0/materiallib/api/ShapeBlockRenderingHandler.java @@ -16,15 +16,16 @@ import cpw.mods.fml.relauncher.SideOnly; import org.lwjgl.opengl.GL11; -/// Renders a [ShapeBlock#hasBaseTexture] composite -- an untinted base texture under a tinted material icon -- in -/// world and in every item form (GUI slot, hotbar, held, and dropped). A block with no base texture keeps the -/// vanilla full-cube render type and never reaches this handler; see [ShapeBlock#setRenderType]. +/// Renders a [ShapeBlock] composite -- an untinted base texture, where the variant declares one, under the +/// material's icon layer stack -- in world and in every item form (GUI slot, hotbar, held, and dropped). A block +/// with neither a base texture nor layered material art keeps the vanilla full-cube render type and never reaches +/// this handler; see [ShapeBlock#setRenderType]. /// /// Each layer is drawn with its icon and color passed in explicitly, under a [RenderBlocks] override texture, so /// the handler holds no state and one shared instance serves every thread of Angelica's off-thread chunk meshing. -/// [#renderInventoryBlock] draws both layers back-to-back into the same [Tessellator] batch; [#renderWorldBlock] -/// makes two standard-block draws with explicit colors. Submitting the coplanar quads with identical vertex data -/// lets the depth test resolve the tie in submission order instead of z-fighting. +/// [#renderInventoryBlock] draws the layers back-to-back into the same [Tessellator] batch; [#renderWorldBlock] +/// makes one standard-block draw per layer with explicit colors. Submitting the coplanar quads with identical +/// vertex data lets the depth test resolve the tie in submission order instead of z-fighting. /// /// The whole composite draws in the solid chunk pass (the vanilla render-pass defaults), where the alpha test cuts /// out the overlay's transparent pixels; the overlay icons are cutout textures, not translucent ones, matching @@ -33,7 +34,7 @@ @ThreadSafeISBRH(perThread = false) public final class ShapeBlockRenderingHandler implements ISimpleBlockRenderingHandler { - private static final int RENDER_ID = RenderingRegistry.getNextAvailableRenderId(); + static final int RENDER_ID = RenderingRegistry.getNextAvailableRenderId(); @Override public int getRenderId() { return RENDER_ID; } @@ -54,13 +55,23 @@ public void renderInventoryBlock(Block block, int metadata, int modelId, RenderB Tessellator tessellator = Tessellator.instance; tessellator.startDrawingQuads(); - drawInventoryLayer(renderer, tessellator, shape, shape.baseIcon(), 0xFFFFFF); - drawInventoryLayer(renderer, tessellator, shape, shape.materialIcon(metadata), shape.tintFor(metadata)); + IIcon base = shape.baseIcon(); + if (base != null) { + drawInventoryLayer(renderer, tessellator, shape, base, 0xFFFFFF); + } + for (int layer = 0, layers = shape.materialLayerCount(metadata); layer < layers; layer++) { + drawInventoryLayer(renderer, tessellator, shape, shape.materialLayer(metadata, layer), + layerColor(shape, metadata, layer)); + } tessellator.draw(); GL11.glTranslatef(0.5F, 0.5F, 0.5F); } + private static int layerColor(ShapeBlock shape, int meta, int layer) { + return layer == 0 ? shape.tintFor(meta) : shape.layerTint(meta, layer); + } + private static void drawInventoryLayer(RenderBlocks renderer, Tessellator tessellator, ShapeBlock shape, IIcon icon, int color) { tessellator.setColorOpaque_F((color >> 16 & 255) / 255.0F, (color >> 8 & 255) / 255.0F, (color & 255) / 255.0F); @@ -93,8 +104,13 @@ public boolean renderWorldBlock(IBlockAccess world, int x, int y, int z, Block b // override texture; a single draw stamps that texture once. if (renderer.hasOverrideBlockTexture()) return renderer.renderStandardBlock(shape, x, y, z); int meta = world.getBlockMetadata(x, y, z); - boolean rendered = drawWorldLayer(renderer, shape, x, y, z, shape.baseIcon(), 0xFFFFFF); - drawWorldLayer(renderer, shape, x, y, z, shape.materialIcon(meta), shape.tintFor(meta)); + IIcon base = shape.baseIcon(); + boolean rendered = base != null && drawWorldLayer(renderer, shape, x, y, z, base, 0xFFFFFF); + for (int layer = 0, layers = shape.materialLayerCount(meta); layer < layers; layer++) { + boolean drawn = drawWorldLayer(renderer, shape, x, y, z, shape.materialLayer(meta, layer), + layerColor(shape, meta, layer)); + if (base == null && layer == 0) rendered = drawn; + } return rendered; } diff --git a/src/main/java/com/ruling_0/materiallib/api/ShapeFluid.java b/src/main/java/com/ruling_0/materiallib/api/ShapeFluid.java index 2c338c24..8703416f 100644 --- a/src/main/java/com/ruling_0/materiallib/api/ShapeFluid.java +++ b/src/main/java/com/ruling_0/materiallib/api/ShapeFluid.java @@ -5,6 +5,7 @@ import java.util.function.Predicate; import net.minecraft.client.renderer.texture.IIconRegister; +import net.minecraft.util.IIcon; import net.minecraftforge.fluids.Fluid; import net.minecraftforge.fluids.FluidRegistry; @@ -201,6 +202,16 @@ void registerIcons(IIconRegister register) { } } + /// Binds the placeholder to every material's fluid; see [MaterialLibClient#deferIconBinding]. + @SideOnly(Side.CLIENT) + void registerPlaceholderIcons(IIconRegister register) { + IIcon empty = register.registerIcon(ShapeIcons.EMPTY_ICON); + for (Material material : served.get()) { + Fluid fluid = fluidsByIndex.get(material.getIndex()); + if (fluid instanceof MaterialFluid) fluid.setIcons(empty); + } + } + /// The icon path to register for `material`'s fluid, or the [ShapeIcons#EMPTY_ICON] placeholder when no source /// carries one; see [ShapeIcons#resolvePath] for the order. Never null. String resolveIconPath(Material material) { @@ -250,9 +261,7 @@ private final class MaterialFluid extends Fluid { } @Override - public String getLocalizedName(FluidStack stack) { - return ShapeText.displayName(ShapeFluid.this, displayNameFormat, material); - } + public String getLocalizedName() { return ShapeText.displayName(ShapeFluid.this, displayNameFormat, material); } @Override public int getColor() { diff --git a/src/main/java/com/ruling_0/materiallib/api/ShapeFluidIcons.java b/src/main/java/com/ruling_0/materiallib/api/ShapeFluidIcons.java index d3ec0fe9..3f27f156 100644 --- a/src/main/java/com/ruling_0/materiallib/api/ShapeFluidIcons.java +++ b/src/main/java/com/ruling_0/materiallib/api/ShapeFluidIcons.java @@ -17,8 +17,10 @@ public final class ShapeFluidIcons { @SubscribeEvent public void onTextureStitch(TextureStitchEvent.Pre event) { if (event.map.getTextureType() != 0) return; + boolean defer = MaterialLibClient.deferIconBinding(); for (ShapeFluid fluid : ShapeRegistry.instance().getFluidShapes()) { - fluid.registerIcons(event.map); + if (defer) fluid.registerPlaceholderIcons(event.map); + else fluid.registerIcons(event.map); } } } diff --git a/src/main/java/com/ruling_0/materiallib/api/ShapeFluidInContainer.java b/src/main/java/com/ruling_0/materiallib/api/ShapeFluidInContainer.java index baec18c5..36da64d1 100644 --- a/src/main/java/com/ruling_0/materiallib/api/ShapeFluidInContainer.java +++ b/src/main/java/com/ruling_0/materiallib/api/ShapeFluidInContainer.java @@ -21,12 +21,14 @@ /// The container holds one fluid shape. A material that generates this shape must also generate that fluid shape; /// the registry enforces that at resolve. /// -/// Renders in two passes: an untinted empty-container texture underneath the texture set's texture for this -/// shape, which supplies the fluid fill and is tinted with [StandardProperties#CELL_TINT] when the material sets -/// it, or with the fluid's fill tint (see [ShapeFluid#tintOf]) otherwise. The container looks the same for every +/// Renders the untinted container base first, then the fluid fill over it -- the texture set's texture for this +/// shape, one pass per icon layer as [ShapeItem] draws it. The fill's first layer takes +/// [StandardProperties#CELL_TINT] when the material sets it, or the fluid's fill tint (see [ShapeFluid#tintOf]) +/// otherwise. Every later fill layer takes the color [ShapeItem] gives it. The container looks the same for every /// material, so the empty texture is a property of the shape rather than of a texture set: it defaults to -/// `:materials/_empty` in the shape's own domain, or the path [FluidInContainerShapeBuilder#emptyIcon] -/// sets. For a path naming no existing texture file, see [#registerIcons]. +/// `:materials/_empty` in the shape's own domain, or the path +/// [FluidInContainerShapeBuilder#emptyIcon] sets. For a path naming no existing texture file, see +/// [#registerIcons]. public class ShapeFluidInContainer extends ShapeItem { private final Shape fluidShape; @@ -122,27 +124,37 @@ public void registerIcons(IIconRegister register) { emptyIcon = register.registerIcon(ShapeIcons.EMPTY_ICON); } - /// The untinted container base for pass 0, and the material's fill icon -- [ShapeItem]'s pass-0 icon -- for - /// every later pass. + /// The container base's first pass, plus one pass per fill layer. + @Override + @SideOnly(Side.CLIENT) + public int getRenderPasses(int meta) { + return super.getRenderPasses(meta) + 1; + } + + /// The untinted container base for pass 0, and the fill's layer at `pass - 1` for every later pass. @Override @SideOnly(Side.CLIENT) public IIcon getIconFromDamageForRenderPass(int damage, int pass) { - return pass == 0 ? emptyIcon : super.getIconFromDamageForRenderPass(damage, 0); + if (pass == 0) return emptyIcon; + return super.getIconFromDamageForRenderPass(damage, pass - 1); } - /// The container base, for callers that ask for a single icon; see [ShapeItem#getIconFromDamage]. + /// The container base, not the fill, for callers that ask for a single icon; see [ShapeItem#getIconFromDamage]. @Override @SideOnly(Side.CLIENT) public IIcon getIconFromDamage(int damage) { return emptyIcon; } - /// White for the untinted container base in pass 0, the fill tint (see the class doc) for every later pass. An - /// override-bound fill icon is white as well; see [ShapeItem#hasOverrideIcon]. + /// White for the container base's first pass, the fill tint (see the class doc) for the pass over it, and each + /// later fill layer's own color (see [ShapeItem#getColorFromItemStack]) for the passes after that. The fill's + /// first pass is white for a damage value carrying no live material and for an override-bound fill icon; see + /// [ShapeItem#hasOverrideIcon]. @Override @SideOnly(Side.CLIENT) public int getColorFromItemStack(ItemStack stack, int renderPass) { if (renderPass == 0) return 0xFFFFFFFF; + if (renderPass > 1) return super.getColorFromItemStack(stack, renderPass - 1); Material material = ShapeText.materialFor(stack); if (material == null || hasOverrideIcon(material)) return 0xFFFFFFFF; if (material.getProperty(StandardProperties.CELL_TINT) == null) return ShapeFluid.tintOf(material); diff --git a/src/main/java/com/ruling_0/materiallib/api/ShapeIcons.java b/src/main/java/com/ruling_0/materiallib/api/ShapeIcons.java index a367c803..31fa8c1e 100644 --- a/src/main/java/com/ruling_0/materiallib/api/ShapeIcons.java +++ b/src/main/java/com/ruling_0/materiallib/api/ShapeIcons.java @@ -15,12 +15,12 @@ import it.unimi.dsi.fastutil.ints.IntOpenHashSet; import it.unimi.dsi.fastutil.objects.ObjectArrayList; -/// The per-material icons of an item or block shape, keyed by material index. Once [#bind] has run, [#get] and -/// [#getOverlay] never return null: an index that bound no icon resolves to the transparent [#EMPTY_ICON] -/// placeholder. A material with a null [StandardProperties#TEXTURE_SET] or [StandardProperties#FALLBACK_TEXTURE_SETS] -/// -- or a null entry inside the list -- is treated like one whose texture files do not exist. A resource-pack file -/// at [#overridePath] reskins a single material, outranks every other source, and draws untinted ([#isOverride]); -/// see [#resolvePath]. +/// The per-material icon layer stacks of an item or block shape, keyed by material index. Once [#bind] has run, +/// [#get] and [#layer] never return null: an index that bound no icon resolves to the transparent [#EMPTY_ICON] +/// placeholder. See [TextureSet] for the files a stack is built from and the tints its layers take. A material with a +/// null [StandardProperties#TEXTURE_SET] or [StandardProperties#FALLBACK_TEXTURE_SETS] -- or a null entry inside the +/// list -- is treated like one whose texture files do not exist. A resource-pack file at [#overridePath] reskins a +/// single material, outranks every other source, and draws untinted ([#isOverride]); see [#resolvePath]. final class ShapeIcons { /// The transparent placeholder icon path, present on both the item and block atlases. @@ -29,11 +29,15 @@ final class ShapeIcons { /// The suffix marking a shape texture's companion overlay layer, appended to the base icon path. static final String OVERLAY_SUFFIX = "_OVERLAY"; + /// The suffix marking one numbered layer of a shape texture, appended to the base icon path together with the + /// layer's 1-based number. + static final String LAYER_SUFFIX = "_LAYER"; + /// The resource-pack override root; see [#resolvePath]. static final String OVERRIDE_ROOT = MaterialLib.MODID + ":mloverrides/"; - private final Int2ObjectMap iconsByIndex = new Int2ObjectOpenHashMap<>(); - private final Int2ObjectMap overlaysByIndex = new Int2ObjectOpenHashMap<>(); + private final Int2ObjectMap layersByIndex = new Int2ObjectOpenHashMap<>(); + private final IntOpenHashSet overlayIndices = new IntOpenHashSet(); private final IntOpenHashSet overrideIndices = new IntOpenHashSet(); private final boolean isItem; private final Predicate exists; @@ -66,23 +70,39 @@ void bind(IIconRegister register, Material[] materials, List shapeNameCa /// ahead of the texture-set candidates; see [#resolvePath]. A null `perMaterialIconPath` skips that source. void bind(IIconRegister register, Material[] materials, List shapeNameCandidates, Function perMaterialIconPath) { - iconsByIndex.clear(); - overlaysByIndex.clear(); - overrideIndices.clear(); - emptyIcon = register.registerIcon(EMPTY_ICON); + bindPlaceholder(register); List unbound = null; + int generating = 0; for (Material material : materials) { + boolean marker = isMarker(material); + if (!marker) generating++; String path = resolvePath(material, shapeNameCandidates, perMaterialIconPath, this::checkResLoc); if (path == null) { - if (unbound == null) unbound = new ObjectArrayList<>(); - unbound.add(material.getKey()); + if (!marker) { + if (unbound == null) unbound = new ObjectArrayList<>(); + unbound.add(material.getKey()); + } continue; } if (path.startsWith(OVERRIDE_ROOT)) overrideIndices.add(material.getIndex()); - iconsByIndex.put(material.getIndex(), register.registerIcon(path)); - putOverlay(register, material, path + OVERLAY_SUFFIX); + layersByIndex.put(material.getIndex(), registerStack(register, material.getIndex(), path)); } - warnUnbound(unbound, materials.length, shapeNameCandidates); + warnUnbound(unbound, generating, shapeNameCandidates); + } + + /// Binds only the transparent placeholder, dropping any per-material stacks. Every lookup then resolves it. + /// See [MaterialLibClient#deferIconBinding]. + void bindPlaceholder(IIconRegister register) { + layersByIndex.clear(); + overlayIndices.clear(); + overrideIndices.clear(); + emptyIcon = register.registerIcon(EMPTY_ICON); + } + + /// Whether `material` generates no shape at all: an ore-dictionary marker pseudo-material, which backs a name + /// without ever drawing. + private static boolean isMarker(Material material) { + return material.getShapes().isEmpty(); } private void warnUnbound(List unbound, int total, List shapeNameCandidates) { @@ -98,22 +118,27 @@ private void warnUnbound(List unbound, int total, List shapeName String.join(", ", unbound.subList(0, examples))); } - /// The icon for a material index, or the empty placeholder if none resolved. + /// The first layer's icon for a material index, or the empty placeholder if none resolved. IIcon get(int index) { - IIcon icon = iconsByIndex.get(index); - return icon != null ? icon : emptyIcon; + return layer(index, 0); + } + + /// The icon at `layer` of a material index's stack, or the empty placeholder outside the stack's bounds. + IIcon layer(int index, int layer) { + IIcon[] layers = layersByIndex.get(index); + return layers != null && layer >= 0 && layer < layers.length ? layers[layer] : emptyIcon; } - /// The overlay icon for a material index, or the empty placeholder if none resolved. - IIcon getOverlay(int index) { - IIcon icon = overlaysByIndex.get(index); - return icon != null ? icon : emptyIcon; + /// The number of layers bound for a material index, at least one: an index that bound no icon reports the + /// single placeholder layer [#get] resolves. + int layerCount(int index) { + IIcon[] layers = layersByIndex.get(index); + return layers != null ? layers.length : 1; } - /// The overlay icon for a material index, or null if none resolved, for a caller that composites the overlay - /// itself and needs to distinguish "no overlay" from the transparent placeholder. - IIcon getOverlayOrNull(int index) { - return overlaysByIndex.get(index); + /// Whether `layer` is the trailing `_OVERLAY` layer of a material index's stack. + boolean isOverlayLayer(int index, int layer) { + return overlayIndices.contains(index) && layer == layerCount(index) - 1; } /// Whether the icon bound for a material index came from the resource-pack override location. @@ -121,6 +146,15 @@ boolean isOverride(int index) { return overrideIndices.contains(index); } + /// The ARGB tint `material`'s stack takes at `layer`; see [ShapeItem#getMaterialLayerColor]. + int layerColor(Material material, int layer) { + int index = material.getIndex(); + if (isOverride(index)) return 0xFFFFFFFF; + if (layer == 0) return MaterialTints.color(material, StandardProperties.TINT); + if (isOverlayLayer(index, layer)) return 0xFFFFFFFF; + return MaterialTints.layerColor(material, layer); + } + /// The resource-pack override icon path for `material`'s art filed under `shapeName`. static String overridePath(Material material, String shapeName) { return OVERRIDE_ROOT + material.getName() + "/" + shapeName; @@ -185,10 +219,22 @@ private static String resolveSet(TextureSet textureSet, List shapeNameCa return null; } - /// Registers `material`'s overlay from `path` when that file exists. - private void putOverlay(IIconRegister register, Material material, String path) { - if (!checkResLoc(path)) return; - overlaysByIndex.put(material.getIndex(), register.registerIcon(path)); + /// Registers the layer stack rooted at `path` for a material index and returns it in draw order; see + /// [TextureSet] for the files it is built from. + private IIcon[] registerStack(IIconRegister register, int index, String path) { + List layers = new ObjectArrayList<>(); + layers.add(register.registerIcon(path)); + for (int number = 1;; number++) { + String layerPath = path + LAYER_SUFFIX + number; + if (!checkResLoc(layerPath)) break; + layers.add(register.registerIcon(layerPath)); + } + String overlay = path + OVERLAY_SUFFIX; + if (checkResLoc(overlay)) { + layers.add(register.registerIcon(overlay)); + overlayIndices.add(index); + } + return layers.toArray(new IIcon[0]); } private boolean checkResLoc(String path) { diff --git a/src/main/java/com/ruling_0/materiallib/api/ShapeItem.java b/src/main/java/com/ruling_0/materiallib/api/ShapeItem.java index d1c26167..d9ed5fc0 100644 --- a/src/main/java/com/ruling_0/materiallib/api/ShapeItem.java +++ b/src/main/java/com/ruling_0/materiallib/api/ShapeItem.java @@ -18,10 +18,11 @@ /// /// Simple shapes are created through [MaterialLibAPI#newItemShape]. A mod that wants custom item behavior (right click /// logic, NBT) subclasses this and registers the instance through [MaterialLibAPI#registerItemShape]. The base handles -/// subtypes, textures from each material's [TextureSet], the [StandardProperties#TINT] color, display names, and -/// oredict. Create and register the item inside the owning mod's [MaterialRegistrationEvent] handler. MaterialLib -/// registers the chosen owner's item under its own domain; see [ShapeOwnerStore]. An advanced tooltip names the mod -/// that owns the shape and the mod that added the material. +/// subtypes, textures from each material's [TextureSet] -- one render pass per icon layer -- the +/// [StandardProperties#TINT] and [StandardProperties#LAYER_TINTS] colors, display names, and oredict. Create and +/// register the item inside the owning mod's [MaterialRegistrationEvent] handler. MaterialLib registers the chosen +/// owner's item under its own domain; see [ShapeOwnerStore]. An advanced tooltip names the mod that owns the shape +/// and the mod that added the material. public class ShapeItem extends Item implements BackedShape { private final String modid; @@ -117,6 +118,10 @@ protected String iconName() { @Override @SideOnly(Side.CLIENT) public void registerIcons(IIconRegister register) { + if (MaterialLibClient.deferIconBinding()) { + icons.bindPlaceholder(register); + return; + } icons.bind(register, served.get(), iconName()); } @@ -126,10 +131,16 @@ public boolean requiresMultipleRenderPasses() { return true; } + @Override + @SideOnly(Side.CLIENT) + public int getRenderPasses(int meta) { + return icons.layerCount(meta); + } + @Override @SideOnly(Side.CLIENT) public IIcon getIconFromDamageForRenderPass(int damage, int renderPass) { - return renderPass == 0 ? icons.get(damage) : icons.getOverlay(damage); + return icons.layer(damage, renderPass); } /// The material's icon, for callers that ask for a single icon. Vanilla's implementation returns the @@ -140,19 +151,34 @@ public IIcon getIconFromDamage(int damage) { return icons.get(damage); } - /// The icon bound for `material` on this shape, or the transparent placeholder when none resolved. Valid only - /// after the item atlas has stitched. Icons re-bind on every resource reload, so a caller compositing this icon - /// itself must hold the shape and read the icon per use, never caching the returned [IIcon]. + /// The first layer of `material`'s icon stack on this shape, or the transparent placeholder when none resolved. + /// Valid only after the item atlas has stitched. Icons re-bind on every resource reload, so a caller compositing + /// this icon itself must hold the shape and read the icon per use, never caching the returned [IIcon]. @SideOnly(Side.CLIENT) public IIcon getMaterialIcon(Material material) { return icons.get(material.getIndex()); } - /// The `_OVERLAY` icon bound for `material` on this shape, or null when its resolved texture set has none; - /// see [#getMaterialIcon] for the caching contract. + /// The number of icon layers bound for `material` on this shape; see [TextureSet]. + @SideOnly(Side.CLIENT) + public int getMaterialLayerCount(Material material) { + return icons.layerCount(material.getIndex()); + } + + /// The icon at `layer` of `material`'s stack on this shape, or the transparent placeholder outside the stack's + /// bounds; see [#getMaterialIcon] for the caching contract. + @SideOnly(Side.CLIENT) + public IIcon getMaterialLayerIcon(Material material, int layer) { + return icons.layer(material.getIndex(), layer); + } + + /// The ARGB tint `material`'s layer `layer` takes on this shape: [StandardProperties#TINT] for layer 0 and the + /// [StandardProperties#LAYER_TINTS] element for a later one. White for the trailing `_OVERLAY` layer, for a + /// layer the list codes no element for, and for an override-bound stack. A caller compositing the icon itself + /// applies this per layer. @SideOnly(Side.CLIENT) - public IIcon getMaterialOverlayIcon(Material material) { - return icons.getOverlayOrNull(material.getIndex()); + public int getMaterialLayerColor(Material material, int layer) { + return icons.layerColor(material, layer); } /// Whether `material`'s icon bound from the resource-pack override location (see [TextureSet]). Override art @@ -163,12 +189,12 @@ public boolean hasOverrideIcon(Material material) { return icons.isOverride(material.getIndex()); } + /// The color of the stack's material at layer `renderPass`; see [#getMaterialLayerColor]. White for a damage + /// value carrying no live material. @Override @SideOnly(Side.CLIENT) public int getColorFromItemStack(ItemStack stack, int renderPass) { - if (renderPass != 0) return 0xFFFFFFFF; Material material = ShapeText.materialFor(stack); - if (material == null || hasOverrideIcon(material)) return 0xFFFFFFFF; - return MaterialTints.color(material, StandardProperties.TINT); + return material != null ? icons.layerColor(material, renderPass) : 0xFFFFFFFF; } } diff --git a/src/main/java/com/ruling_0/materiallib/api/ShapeRegistry.java b/src/main/java/com/ruling_0/materiallib/api/ShapeRegistry.java index 6193b828..614e6a20 100644 --- a/src/main/java/com/ruling_0/materiallib/api/ShapeRegistry.java +++ b/src/main/java/com/ruling_0/materiallib/api/ShapeRegistry.java @@ -434,7 +434,7 @@ private void requireRegistration(String what) { } } - private void requireResolved(String what) { + void requireResolved(String what) { if (!resolved) { throw new IllegalStateException( "Cannot " + what + ": shapes have not resolved yet. They are available once MaterialLib's " + diff --git a/src/main/java/com/ruling_0/materiallib/api/StackResolver.java b/src/main/java/com/ruling_0/materiallib/api/StackResolver.java new file mode 100644 index 00000000..fa9f3408 --- /dev/null +++ b/src/main/java/com/ruling_0/materiallib/api/StackResolver.java @@ -0,0 +1,114 @@ +package com.ruling_0.materiallib.api; + +import java.util.List; +import java.util.Map; + +import net.minecraft.item.ItemStack; + +import com.ruling_0.materiallib.MaterialLib; + +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; + +/// Resolves MaterialLib stacks from the material name and shape token a config entry names, so a config entry keeps +/// its target across sessions that renumber item metadata. +/// +/// A material is named by its bare name, with no modid: declarations sharing a name unify into one material (see +/// [Material]). A shape token is matched exactly and case-sensitively against the registered names, which are an +/// item shape's name, a variant-less block shape's name, or one variant block's `_` (see +/// [ShapeNaming#variantBlockName]). Fluid shapes are not among them. A lookup matching nothing returns null or an +/// empty list and logs an error. +/// +/// The lookups read the resolved registries through caches built on first call. They run from a consuming mod's +/// preInit only when its `@Mod` dependencies order it after materiallib, from init onwards in any case, and never +/// from inside a [MaterialRegistrationEvent] handler. +public final class StackResolver { + + private static Map materialsByName; + private static Map shapesByToken; + + private StackResolver() {} + + /// The material registered under the bare name `materialName`, or null when no material carries it. + public static Material getMaterial(String materialName) { + Material material = materialIndex().get(materialName); + if (material == null) { + MaterialLib.LOG.error("No material is registered under the name \"{}\"", materialName); + } + return material; + } + + /// The item or block shape registered under `shapeToken`, or null when no shape carries it. + public static Shape getShape(String shapeToken) { + BackedShape shape = shapeIndex().get(shapeToken); + if (shape == null) { + MaterialLib.LOG.error("No item or block shape is registered under the name \"{}\"", shapeToken); + } + return shape; + } + + /// The itemstack of the named material in the named shape, with the given stack size. Null when either name + /// matches nothing, or when the material does not generate the shape. + public static ItemStack getStack(String materialName, String shapeToken, int amount) { + Material material = materialIndex().get(materialName); + if (material == null) { + MaterialLib.LOG.error("Cannot resolve {}:{}: no such material", materialName, shapeToken); + return null; + } + BackedShape shape = shapeIndex().get(shapeToken); + if (shape == null) { + MaterialLib.LOG.error("Cannot resolve {}:{}: no such item or block shape", materialName, shapeToken); + return null; + } + if (!shape.serves(material)) { + MaterialLib.LOG + .error("Cannot resolve {}:{}: that material does not generate that shape", materialName, shapeToken); + return null; + } + return shape.getStack(material, amount); + } + + /// The block shapes `shapeToken` names: the one registered under it, or every variant block registered as + /// `_`. Empty when neither matches. + public static List getBlockShapes(String shapeToken) { + if (shapeIndex().get(shapeToken) instanceof ShapeBlock block) return List.of(block); + List variants = new ObjectArrayList<>(); + String prefix = shapeToken + "_"; + for (ShapeBlock block : ShapeRegistry.instance().getBlockShapes()) { + if (block.getName().startsWith(prefix)) variants.add(block); + } + if (variants.isEmpty()) { + MaterialLib.LOG.error("No block shape is registered as \"{}\" or as one of its variants", shapeToken); + } + return variants; + } + + private static Map materialIndex() { + if (materialsByName == null) { + Map index = new Object2ObjectOpenHashMap<>(); + for (Material material : MaterialLibAPI.getMaterials()) { + index.put(material.getName(), material); + } + materialsByName = index; + } + return materialsByName; + } + + /// Indexes every backed shape under the name its backing object registered with. A [ShapeBlockVariants] group is + /// not among them, only its per-variant blocks; see [#getBlockShapes]. + private static Map shapeIndex() { + if (shapesByToken == null) { + ShapeRegistry registry = ShapeRegistry.instance(); + registry.requireResolved("resolve a shape by name"); + Map index = new Object2ObjectOpenHashMap<>(); + for (ShapeItem item : registry.getItemShapes()) { + index.put(item.getName(), item); + } + for (ShapeBlock block : registry.getBlockShapes()) { + index.put(block.getName(), block); + } + shapesByToken = index; + } + return shapesByToken; + } +} diff --git a/src/main/java/com/ruling_0/materiallib/api/StandardProperties.java b/src/main/java/com/ruling_0/materiallib/api/StandardProperties.java index 22ca2847..df0f5606 100644 --- a/src/main/java/com/ruling_0/materiallib/api/StandardProperties.java +++ b/src/main/java/com/ruling_0/materiallib/api/StandardProperties.java @@ -12,7 +12,9 @@ /// `color.resource.materiallib..`, where `` is the material's registry /// name in its exact case and `` is one of `tint`, `fluidTint`, `blockTint`, `blockOverlayTint`, or /// `cellTint`. The value is AARRGGBB hex. An entry changes what renders, not what [Material#getProperty] returns. -/// Every material reads `tint`; the other four keys are read only for a material that sets the matching property. +/// Every material reads `tint`. The other four keys are read only for a material that sets the matching property. +/// A numbered icon layer is keyed `color.resource.materiallib..tint.`, `` being the layer's +/// number, and is read only for a material whose [#LAYER_TINTS] codes element `n - 1`. public final class StandardProperties { private StandardProperties() {} @@ -37,6 +39,12 @@ private StandardProperties() {} /// ARGB tint applied to the material's textures. public static final Property TINT = Property.of(MaterialLib.MODID, "tint", 0xFFFFFFFF); + /// ARGB tints of a shape's numbered icon layers (see [TextureSet]), element 0 tinting `_LAYER1`. Null when + /// unset. A layer the list codes no element for draws untinted. A material-level list replaces a family-level + /// one entirely (standard property shadowing). Store an immutable list: the value is shared, never defensively + /// copied. + public static final Property> LAYER_TINTS = Property.of(MaterialLib.MODID, "layerTints"); + /// ARGB tint applied to a fluid shape's fill icon in place of [#TINT], for fluid art that already encodes its /// color. Null when unset, falling back to [#TINT]. Applies to a fluid's fill layer and to the fill layer of a /// [ShapeFluidInContainer] holding it; every other shape uses [#TINT]. diff --git a/src/main/java/com/ruling_0/materiallib/api/TextureSet.java b/src/main/java/com/ruling_0/materiallib/api/TextureSet.java index a70ee972..2f2f9a9f 100644 --- a/src/main/java/com/ruling_0/materiallib/api/TextureSet.java +++ b/src/main/java/com/ruling_0/materiallib/api/TextureSet.java @@ -5,10 +5,17 @@ /// The `name` of this corresponds to the folder name holding the textures, either `textures/items/materials//` /// for items or `textures/blocks/materials//` for blocks and fluids. /// +/// A shape's art may be a stack of layers drawn back to front: `.png`, then `_LAYER1.png`, +/// `_LAYER2.png` and upward until a number is absent, then `_OVERLAY.png` last where it exists. The +/// first layer takes the material's tint for the shape being drawn. `_LAYER.png` takes element `n - 1` of +/// [StandardProperties#LAYER_TINTS], or no tint where the list codes none. `_OVERLAY` art carries its own colors and +/// always draws untinted. The whole stack comes from the one texture source that wins the shape: a set carrying +/// only `_LAYER1.png` contributes nothing. +/// /// A resource pack reskins one material alone, outranking every texture set, with a file at -/// `assets/materiallib/textures//mloverrides//[_OVERLAY].png`. `` is -/// the material's registry name in its exact case, and `` is the name the art is filed under inside a texture -/// set. The winning file supplies its own `_OVERLAY` layer or none. Override art carries its own colors, so it draws +/// `assets/materiallib/textures//mloverrides//.png`. `` is the +/// material's registry name in its exact case, and `` is the name the art is filed under inside a texture +/// set. Its `_LAYER` and `_OVERLAY` siblings are read from the override location too, and the whole stack draws /// untinted. A pack recolors art it does not replace through the tint lang keys instead; see [StandardProperties]. public final class TextureSet { diff --git a/src/main/java/com/ruling_0/materiallib/examples/ExampleContent.java b/src/main/java/com/ruling_0/materiallib/examples/ExampleContent.java index 89fe0ad0..32ad402f 100644 --- a/src/main/java/com/ruling_0/materiallib/examples/ExampleContent.java +++ b/src/main/java/com/ruling_0/materiallib/examples/ExampleContent.java @@ -49,6 +49,7 @@ private static void register() { .build(); Shape ingot = MaterialLibAPI.newItemShape(MaterialLib.MODID, "ingot").displayName("%s Ingot").build(); + Shape ingot2 = MaterialLibAPI.newItemShape(MaterialLib.MODID, "ingot2").displayName("%s Special Ingot").build(); Shape block = MaterialLibAPI.newBlockShape(MaterialLib.MODID, "block").displayName("%s Block").build(); @@ -83,14 +84,15 @@ private static void register() { Family testFamily = MaterialLibAPI.newFamily(MaterialLib.MODID, "Test").generateShape(testFrame).build(); MaterialLibAPI.newMaterial(MaterialLib.MODID, "TestIron", test) - .generateShapes(testGear, ingot, block, testFluid, testBucket, testOre) + .generateShapes(testGear, ingot, block, testFluid, testBucket, testOre, ingot2) .addToFamily(testFamily) .addTooltip("Iron strong") .build(); MaterialLibAPI.newMaterial(MaterialLib.MODID, "TestGold", test) .setTint(0xFFFFD700) - .generateShapes(testGear, ingot, block, testFluid, testBucket, testOre) + .setLayerTints(0xFF00D0D3, 0xFF6F00D1) + .generateShapes(testGear, ingot, block, testFluid, testBucket, testOre, ingot2) .addToFamily(testFamily) .addTooltip("Shiny gold", "so shiny") .build(); diff --git a/src/main/resources/assets/materiallib/textures/items/materials/test/ingot2.png b/src/main/resources/assets/materiallib/textures/items/materials/test/ingot2.png new file mode 100644 index 00000000..9e0fa1ec Binary files /dev/null and b/src/main/resources/assets/materiallib/textures/items/materials/test/ingot2.png differ diff --git a/src/main/resources/assets/materiallib/textures/items/materials/test/ingot2_LAYER1.png b/src/main/resources/assets/materiallib/textures/items/materials/test/ingot2_LAYER1.png new file mode 100644 index 00000000..69c3bd88 Binary files /dev/null and b/src/main/resources/assets/materiallib/textures/items/materials/test/ingot2_LAYER1.png differ diff --git a/src/main/resources/assets/materiallib/textures/items/materials/test/ingot2_LAYER2.png b/src/main/resources/assets/materiallib/textures/items/materials/test/ingot2_LAYER2.png new file mode 100644 index 00000000..f990a39e Binary files /dev/null and b/src/main/resources/assets/materiallib/textures/items/materials/test/ingot2_LAYER2.png differ diff --git a/src/main/resources/assets/materiallib/textures/items/materials/test/ingot2_OVERLAY.png b/src/main/resources/assets/materiallib/textures/items/materials/test/ingot2_OVERLAY.png new file mode 100644 index 00000000..59ce9601 Binary files /dev/null and b/src/main/resources/assets/materiallib/textures/items/materials/test/ingot2_OVERLAY.png differ diff --git a/src/test/java/com/ruling_0/materiallib/api/MaterialTintsTest.java b/src/test/java/com/ruling_0/materiallib/api/MaterialTintsTest.java index 9d02e300..30c2897b 100644 --- a/src/test/java/com/ruling_0/materiallib/api/MaterialTintsTest.java +++ b/src/test/java/com/ruling_0/materiallib/api/MaterialTintsTest.java @@ -5,8 +5,8 @@ import org.junit.jupiter.api.Test; -/// Pins the pack-facing lang key the standard tint properties read, and the coded value a read falls back to with -/// no lang entry present. +/// Pins the pack-facing lang keys the standard tint properties and the numbered icon layers read, and the coded +/// value a read falls back to with no lang entry present. class MaterialTintsTest { private final MaterialRegistry registry = new MaterialRegistry(); @@ -22,6 +22,19 @@ void aTintReadsTheLangKeyNamedAfterItsMaterialAndProperty() { assertEquals(0xFFFFCC00, MaterialTints.color(material, StandardProperties.TINT)); } + @Test + void aLayerTintReadsTheLangKeyNumberedAfterItsLayer() { + Material material = registry.newMaterial("testmod", "TestBronze", texture) + .setLayerTints(0xFF00FF00, 0xFF0000FF).build(); + registry.resolve(); + + assertEquals("color.resource.materiallib.TestBronze.tint.1", + MaterialTints.layerResourceFor(material, 1).getLangKey()); + assertEquals(0xFF00FF00, MaterialTints.layerColor(material, 1)); + assertEquals(0xFF0000FF, MaterialTints.layerColor(material, 2)); + assertEquals(0xFFFFFFFF, MaterialTints.layerColor(material, 3)); + } + @Test void aUnifiedAlternativeReadsTheCanonicalMaterialsKey() { Material owner = registry.newMaterial("amod", "TestSteel", texture) diff --git a/src/test/java/com/ruling_0/materiallib/api/ShapeIconsTest.java b/src/test/java/com/ruling_0/materiallib/api/ShapeIconsTest.java index 6907e0ec..65eb6fcd 100644 --- a/src/test/java/com/ruling_0/materiallib/api/ShapeIconsTest.java +++ b/src/test/java/com/ruling_0/materiallib/api/ShapeIconsTest.java @@ -4,7 +4,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -17,13 +16,17 @@ import org.junit.jupiter.api.Test; -/// Pins [ShapeIcons]' placeholder fallbacks and the precedence of its texture sources. [ShapeIcons#resolvePath] -/// and [ShapeIcons#ShapeIcons(boolean, Predicate)] take an existence predicate, so both run headless. +/// Pins [ShapeIcons]' placeholder fallbacks, the precedence of its texture sources, the shape of the layer stack it +/// binds, and the color each layer takes. [ShapeIcons#resolvePath] and [ShapeIcons#ShapeIcons(boolean, Predicate)] +/// take an existence predicate, so both run headless. class ShapeIconsTest { /// Accepts every path outside the resource-pack override root, so a chain test runs with that source missing. private static final Predicate NO_OVERRIDE = path -> !path.startsWith(ShapeIcons.OVERRIDE_ROOT); + /// Accepts no numbered layer sibling, bounding the layer probe of a test that otherwise accepts every path. + private static final Predicate NO_LAYERS = path -> !path.contains(ShapeIcons.LAYER_SUFFIX); + private final MaterialRegistry registry = new MaterialRegistry(); private final RecordingRegister register = new RecordingRegister(); private final TextureSet setA = TextureSet.of("testmod", "setA"); @@ -31,7 +34,7 @@ class ShapeIconsTest { private final TextureSet setC = TextureSet.of("testmod", "setC"); /// Constructs a [Material] directly -- [MaterialBuilder] rejects a missing [StandardProperties#TEXTURE_SET] -- - /// to pin that binding it resolves the placeholder from both accessors instead of crashing. + /// to pin that binding it resolves the placeholder instead of crashing. @Test void materialWithoutTextureSetBindsPlaceholderInsteadOfCrashing() { Map, Object> properties = Map.of(StandardProperties.NAME, "Broken"); @@ -45,8 +48,8 @@ void materialWithoutTextureSetBindsPlaceholderInsteadOfCrashing() { IIcon placeholder = register.registered.get(ShapeIcons.EMPTY_ICON); assertNotNull(placeholder); assertSame(placeholder, icons.get(material.getIndex())); - assertSame(placeholder, icons.getOverlay(material.getIndex())); - assertNull(icons.getOverlayOrNull(material.getIndex())); + assertEquals(1, icons.layerCount(material.getIndex())); + assertSame(placeholder, icons.layer(material.getIndex(), 1)); } /// An empty fallback list is equivalent to no fallback at all: the chain runs out and the material binds the @@ -67,8 +70,8 @@ void anEmptyFallbackListBindsThePlaceholderWithoutCrashing() { assertSame(placeholder, icons.get(material.getIndex())); } - /// An index no material bound resolves to the empty placeholder from both placeholder accessors, and to null - /// from the nullable overlay accessor. + /// An index no material bound reports one layer and resolves to the empty placeholder inside and outside the + /// stack's bounds. @Test void unboundIndexFallsBackToPlaceholder() { ShapeIcons icons = new ShapeIcons(false, path -> false); @@ -77,8 +80,8 @@ void unboundIndexFallsBackToPlaceholder() { IIcon placeholder = register.registered.get(ShapeIcons.EMPTY_ICON); assertNotNull(placeholder); assertSame(placeholder, icons.get(7)); - assertSame(placeholder, icons.getOverlay(7)); - assertNull(icons.getOverlayOrNull(7)); + assertEquals(1, icons.layerCount(7)); + assertSame(placeholder, icons.layer(7, 1)); } /// A material's own texture set outranks its fallback sets. @@ -164,17 +167,19 @@ void anOverrideSuppliesItsOwnOverlayOrNone() { registry.resolve(); Material[] materials = { material }; String override = ShapeIcons.overridePath(material, "gear"); + int index = material.getIndex(); - ShapeIcons withOverlay = new ShapeIcons(true, path -> true); + ShapeIcons withOverlay = new ShapeIcons(true, NO_LAYERS); withOverlay.bind(register, materials, "gear"); - IIcon overlay = withOverlay.getOverlayOrNull(material.getIndex()); - assertNotNull(overlay); - assertSame(register.registered.get(override + ShapeIcons.OVERLAY_SUFFIX), overlay); + int last = withOverlay.layerCount(index) - 1; + assertTrue(withOverlay.isOverlayLayer(index, last)); + assertSame(register.registered.get(override + ShapeIcons.OVERLAY_SUFFIX), withOverlay.layer(index, last)); - ShapeIcons withoutOverlay = new ShapeIcons(true, path -> !path.equals(override + ShapeIcons.OVERLAY_SUFFIX)); + ShapeIcons withoutOverlay = new ShapeIcons(true, + NO_LAYERS.and(path -> !path.equals(override + ShapeIcons.OVERLAY_SUFFIX))); withoutOverlay.bind(register, materials, "gear"); - assertSame(register.registered.get(override), withoutOverlay.get(material.getIndex())); - assertNull(withoutOverlay.getOverlayOrNull(material.getIndex())); + assertSame(register.registered.get(override), withoutOverlay.get(index)); + assertFalse(withoutOverlay.isOverlayLayer(index, withoutOverlay.layerCount(index) - 1)); } /// Only the override tier marks a material untinted, and re-binding without the pack clears that mark. @@ -184,7 +189,7 @@ void onlyAnOverrideBoundIconIsFlaggedUntinted() { registry.resolve(); Material[] materials = { material }; boolean[] packLoaded = { true }; - ShapeIcons icons = new ShapeIcons(true, path -> packLoaded[0] || NO_OVERRIDE.test(path)); + ShapeIcons icons = new ShapeIcons(true, NO_LAYERS.and(path -> packLoaded[0] || NO_OVERRIDE.test(path))); icons.bind(register, materials, "gear"); assertTrue(icons.isOverride(material.getIndex())); @@ -197,6 +202,87 @@ void onlyAnOverrideBoundIconIsFlaggedUntinted() { assertFalse(icons.isOverride(material.getIndex())); } + /// The numbered layers are probed upward from 1 and stop at the first absent number, so art skipping a number + /// leaves everything above it out of the stack. + @Test + void aGapInTheLayerNumbersEndsTheStack() { + Material material = declareMaterial("testmod", "Testiron", setA, List.of()); + registry.resolve(); + String base = setA.iconPath("gear"); + Set art = Set.of(base, base + ShapeIcons.LAYER_SUFFIX + 1, base + ShapeIcons.LAYER_SUFFIX + 3); + int index = material.getIndex(); + + ShapeIcons icons = new ShapeIcons(true, art::contains); + icons.bind(register, new Material[] { material }, "gear"); + + assertEquals(2, icons.layerCount(index)); + assertSame(register.registered.get(base), icons.layer(index, 0)); + assertSame(register.registered.get(base + ShapeIcons.LAYER_SUFFIX + 1), icons.layer(index, 1)); + assertFalse(icons.isOverlayLayer(index, 1)); + assertSame(register.registered.get(ShapeIcons.EMPTY_ICON), icons.layer(index, 2)); + } + + /// `_OVERLAY` binds as the stack's last layer. + @Test + void anOverlayBindsAsTheStacksLastLayer() { + Material material = declareMaterial("testmod", "Testiron", setA, List.of()); + registry.resolve(); + String base = setA.iconPath("gear"); + Set art = Set.of(base, base + ShapeIcons.OVERLAY_SUFFIX); + int index = material.getIndex(); + + ShapeIcons icons = new ShapeIcons(true, art::contains); + icons.bind(register, new Material[] { material }, "gear"); + + assertEquals(2, icons.layerCount(index)); + assertSame(register.registered.get(base), icons.get(index)); + assertSame(register.registered.get(base + ShapeIcons.OVERLAY_SUFFIX), icons.layer(index, 1)); + assertTrue(icons.isOverlayLayer(index, 1)); + } + + /// A stack resolves at one location: an override's numbered layers come from the override root, not from the + /// texture set the override outranks. + @Test + void anOverrideStacksLayersComeFromTheOverrideRoot() { + Material material = declareMaterial("testmod", "Testiron", setA, List.of()); + registry.resolve(); + String override = ShapeIcons.overridePath(material, "gear"); + String base = setA.iconPath("gear"); + Set art = Set.of(override, override + ShapeIcons.LAYER_SUFFIX + 1, base, + base + ShapeIcons.LAYER_SUFFIX + 1, base + ShapeIcons.LAYER_SUFFIX + 2); + int index = material.getIndex(); + + ShapeIcons icons = new ShapeIcons(true, art::contains); + icons.bind(register, new Material[] { material }, "gear"); + + assertTrue(icons.isOverride(index)); + assertEquals(2, icons.layerCount(index)); + assertSame(register.registered.get(override), icons.layer(index, 0)); + assertSame(register.registered.get(override + ShapeIcons.LAYER_SUFFIX + 1), icons.layer(index, 1)); + } + + /// The trailing `_OVERLAY` layer draws untinted even where [StandardProperties#LAYER_TINTS] codes an element at + /// its index, while a numbered layer takes its coded element and layer 0 takes [StandardProperties#TINT]. + @Test + void anOverlayLayerReportsWhiteWhileANumberedLayerReportsItsCodedTint() { + Map, Object> properties = Map.of(StandardProperties.NAME, "Testlayered", + StandardProperties.TEXTURE_SET, setA, StandardProperties.TINT, 0xFFFFCC00, + StandardProperties.LAYER_TINTS, List.of(0xFF00FF00, 0xFF0000FF)); + Material material = new Material(registry, "testmod", "Testlayered", properties, Set.of(), List.of()); + registry.register(material); + registry.resolve(); + String base = setA.iconPath("gear"); + Set art = Set.of(base, base + ShapeIcons.LAYER_SUFFIX + 1, base + ShapeIcons.OVERLAY_SUFFIX); + + ShapeIcons icons = new ShapeIcons(true, art::contains); + icons.bind(register, new Material[] { material }, "gear"); + + assertTrue(icons.isOverlayLayer(material.getIndex(), 2)); + assertEquals(0xFFFFCC00, icons.layerColor(material, 0)); + assertEquals(0xFF00FF00, icons.layerColor(material, 1)); + assertEquals(0xFFFFFFFF, icons.layerColor(material, 2)); + } + private Material declareMaterial(String modid, String name, TextureSet textureSet, List fallbacks) { Map, Object> properties = Map.of(StandardProperties.NAME, name, StandardProperties.TEXTURE_SET, textureSet, StandardProperties.FALLBACK_TEXTURE_SETS, fallbacks);