aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLinnea Gräf <nea@nea.moe>2025-06-20 15:49:15 +0200
committerLinnea Gräf <nea@nea.moe>2025-06-20 15:49:15 +0200
commit286691c54c87b5e2e70eef7b51469b4ade5a75f6 (patch)
treea4812f2afe10d836b5682c5c957abcff674db676
parent54a76553a7ebda48f223b7a005466345e3c4774d (diff)
downloadFirmament-286691c54c87b5e2e70eef7b51469b4ade5a75f6.tar.gz
Firmament-286691c54c87b5e2e70eef7b51469b4ade5a75f6.tar.bz2
Firmament-286691c54c87b5e2e70eef7b51469b4ade5a75f6.zip
feat: Add screen layout replacement feature for texture packs
-rw-r--r--src/main/kotlin/util/ErrorUtil.kt7
-rw-r--r--src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomScreenLayouts.kt142
-rw-r--r--src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomSkyBlockTextures.kt1
-rw-r--r--src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomTextColors.kt14
-rw-r--r--src/texturePacks/java/moe/nea/firmament/mixins/custommodels/screenlayouts/ReplaceFurnaceBackgrounds.java32
-rw-r--r--src/texturePacks/java/moe/nea/firmament/mixins/custommodels/screenlayouts/ReplaceGenericBackgrounds.java29
-rw-r--r--src/texturePacks/java/moe/nea/firmament/mixins/custommodels/screenlayouts/ReplacePlayerBackgrounds.java34
-rw-r--r--translations/en_us.json2
-rw-r--r--web/src/pages/docs/_texture-pack-format.md78
9 files changed, 338 insertions, 1 deletions
diff --git a/src/main/kotlin/util/ErrorUtil.kt b/src/main/kotlin/util/ErrorUtil.kt
index 2f0acd6..f12bb12 100644
--- a/src/main/kotlin/util/ErrorUtil.kt
+++ b/src/main/kotlin/util/ErrorUtil.kt
@@ -37,6 +37,13 @@ object ErrorUtil {
else Firmament.logger.error(message)
}
+ fun <T> Result<T>.intoCatch(message: String): Catch<T> {
+ return this.map { Catch.succeed(it) }.getOrElse {
+ softError(message, it)
+ Catch.fail(it)
+ }
+ }
+
class Catch<T> private constructor(val value: T?, val exc: Throwable?) {
fun orNull(): T? = value
diff --git a/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomScreenLayouts.kt b/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomScreenLayouts.kt
new file mode 100644
index 0000000..ec696ec
--- /dev/null
+++ b/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomScreenLayouts.kt
@@ -0,0 +1,142 @@
+package moe.nea.firmament.features.texturepack
+
+import kotlinx.serialization.Serializable
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.screen.Screen
+import net.minecraft.client.gui.screen.ingame.HandledScreen
+import net.minecraft.client.render.RenderLayer
+import net.minecraft.resource.ResourceManager
+import net.minecraft.resource.SinglePreparationResourceReloader
+import net.minecraft.screen.slot.Slot
+import net.minecraft.util.Identifier
+import net.minecraft.util.profiler.Profiler
+import moe.nea.firmament.Firmament
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.FinalizeResourceManagerEvent
+import moe.nea.firmament.events.ScreenChangeEvent
+import moe.nea.firmament.mixins.accessor.AccessorHandledScreen
+import moe.nea.firmament.util.ErrorUtil.intoCatch
+import moe.nea.firmament.util.IdentifierSerializer
+
+object CustomScreenLayouts : SinglePreparationResourceReloader<List<CustomScreenLayouts.CustomScreenLayout>>() {
+
+ @Serializable
+ data class CustomScreenLayout(
+ val predicates: Preds,
+ val background: BackgroundReplacer? = null,
+ val slots: List<SlotReplacer> = listOf(),
+ )
+
+ @Serializable
+ data class Preds(
+ val label: StringMatcher,
+ ) {
+ fun matches(screen: Screen): Boolean {
+ // TODO: does this deserve the restriction to handled screen
+ val s = screen as? HandledScreen<*>? ?: return false
+ return label.matches(s.title)
+ }
+ }
+
+ @Serializable
+ data class BackgroundReplacer(
+ @Serializable(with = IdentifierSerializer::class)
+ val texture: Identifier,
+ // TODO: allow selectively still rendering some components (recipe button, trade backgrounds, furnace flame progress, arrows)
+ val x: Int,
+ val y: Int,
+ val width: Int,
+ val height: Int,
+ ) {
+ fun renderGeneric(context: DrawContext, screen: HandledScreen<*>) {
+ screen as AccessorHandledScreen
+ val originalX: Int = (screen.width - screen.backgroundWidth_Firmament) / 2
+ val originalY: Int = (screen.height - screen.backgroundHeight_Firmament) / 2
+ val modifiedX = originalX + this.x
+ val modifiedY = originalY + this.y
+ val textureWidth = this.width
+ val textureHeight = this.height
+ context.drawTexture(
+ RenderLayer::getGuiTextured,
+ this.texture,
+ modifiedX,
+ modifiedY,
+ 0.0f,
+ 0.0f,
+ textureWidth,
+ textureHeight,
+ textureWidth,
+ textureHeight
+ )
+
+ }
+ }
+
+ @Serializable
+ data class SlotReplacer(
+ // TODO: override getRecipeBookButtonPos as well
+ // TODO: is this index or id (i always forget which one is duplicated per inventory)
+ val index: Int,
+ val x: Int,
+ val y: Int,
+ ) {
+ fun move(slots: List<Slot>) {
+ val slot = slots.getOrNull(index) ?: return
+ slot.x = x
+ slot.y = y
+ }
+ }
+
+
+ @Subscribe
+ fun onStart(event: FinalizeResourceManagerEvent) {
+ event.resourceManager.registerReloader(CustomScreenLayouts)
+ }
+
+ override fun prepare(
+ manager: ResourceManager,
+ profiler: Profiler
+ ): List<CustomScreenLayout> {
+ val allScreenLayouts = manager.findResources(
+ "overrides/screen_layout",
+ { it.path.endsWith(".json") && it.namespace == "firmskyblock" })
+ val allParsedLayouts = allScreenLayouts.mapNotNull { (path, stream) ->
+ Firmament.tryDecodeJsonFromStream<CustomScreenLayout>(stream.inputStream)
+ .intoCatch("Could not read custom screen layout from $path").orNull()
+ }
+ return allParsedLayouts
+ }
+
+ var customScreenLayouts = listOf<CustomScreenLayout>()
+
+ override fun apply(
+ prepared: List<CustomScreenLayout>,
+ manager: ResourceManager?,
+ profiler: Profiler?
+ ) {
+ this.customScreenLayouts = prepared
+ }
+
+ @get:JvmStatic
+ var activeScreenOverride = null as CustomScreenLayout?
+
+ @Subscribe
+ fun onScreenOpen(event: ScreenChangeEvent) {
+ if (!CustomSkyBlockTextures.TConfig.allowLayoutChanges) {
+ activeScreenOverride = null
+ return
+ }
+ activeScreenOverride = event.new?.let { screen ->
+ customScreenLayouts.find { it.predicates.matches(screen) }
+ }
+
+ val screen = event.new as? HandledScreen<*> ?: return
+ val handler = screen.screenHandler
+ activeScreenOverride?.let { override ->
+ override.slots.forEach { slotReplacer ->
+ slotReplacer.move(handler.slots)
+ }
+ }
+ }
+
+}
diff --git a/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomSkyBlockTextures.kt b/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomSkyBlockTextures.kt
index cf2a232..18949ff 100644
--- a/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomSkyBlockTextures.kt
+++ b/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomSkyBlockTextures.kt
@@ -36,6 +36,7 @@ object CustomSkyBlockTextures : FirmamentFeature {
val enableLegacyMinecraftCompat by toggle("legacy-minecraft-path-support") { true }
val enableLegacyCIT by toggle("legacy-cit") { true }
val allowRecoloringUiText by toggle("recolor-text") { true }
+ val allowLayoutChanges by toggle("screen-layouts") { true }
}
override val config: ManagedConfig
diff --git a/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomTextColors.kt b/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomTextColors.kt
index 4ca1796..1fcee48 100644
--- a/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomTextColors.kt
+++ b/src/texturePacks/java/moe/nea/firmament/features/texturepack/CustomTextColors.kt
@@ -2,6 +2,7 @@ package moe.nea.firmament.features.texturepack
import java.util.Optional
import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
import kotlin.jvm.optionals.getOrNull
import net.minecraft.resource.ResourceManager
import net.minecraft.resource.SinglePreparationResourceReloader
@@ -18,12 +19,23 @@ object CustomTextColors : SinglePreparationResourceReloader<CustomTextColors.Tex
data class TextOverrides(
val defaultColor: Int,
val overrides: List<TextOverride> = listOf()
- )
+ ) {
+ /**
+ * Stub custom text color to allow always returning a text override
+ */
+ @Transient
+ val baseOverride = TextOverride(
+ StringMatcher.Equals("", false),
+ defaultColor,
+ false
+ )
+ }
@Serializable
data class TextOverride(
val predicate: StringMatcher,
val override: Int,
+ val hidden: Boolean = false,
)
@Subscribe
diff --git a/src/texturePacks/java/moe/nea/firmament/mixins/custommodels/screenlayouts/ReplaceFurnaceBackgrounds.java b/src/texturePacks/java/moe/nea/firmament/mixins/custommodels/screenlayouts/ReplaceFurnaceBackgrounds.java
new file mode 100644
index 0000000..2252a96
--- /dev/null
+++ b/src/texturePacks/java/moe/nea/firmament/mixins/custommodels/screenlayouts/ReplaceFurnaceBackgrounds.java
@@ -0,0 +1,32 @@
+package moe.nea.firmament.mixins.custommodels.screenlayouts;
+
+import com.llamalad7.mixinextras.injector.v2.WrapWithCondition;
+import moe.nea.firmament.features.texturepack.CustomScreenLayouts;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.screen.ingame.AbstractFurnaceScreen;
+import net.minecraft.client.gui.screen.ingame.RecipeBookScreen;
+import net.minecraft.client.gui.screen.recipebook.RecipeBookWidget;
+import net.minecraft.client.render.RenderLayer;
+import net.minecraft.entity.player.PlayerInventory;
+import net.minecraft.screen.AbstractFurnaceScreenHandler;
+import net.minecraft.text.Text;
+import net.minecraft.util.Identifier;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+
+import java.util.function.Function;
+
+@Mixin(AbstractFurnaceScreen.class)
+public abstract class ReplaceFurnaceBackgrounds<T extends AbstractFurnaceScreenHandler> extends RecipeBookScreen<T> {
+ public ReplaceFurnaceBackgrounds(T handler, RecipeBookWidget<?> recipeBook, PlayerInventory inventory, Text title) {
+ super(handler, recipeBook, inventory, title);
+ }
+
+ @WrapWithCondition(method = "drawBackground", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/DrawContext;drawTexture(Ljava/util/function/Function;Lnet/minecraft/util/Identifier;IIFFIIII)V"), allow = 1)
+ private boolean onDrawBackground(DrawContext instance, Function<Identifier, RenderLayer> renderLayers, Identifier sprite, int x, int y, float u, float v, int width, int height, int textureWidth, int textureHeight) {
+ final var override = CustomScreenLayouts.getActiveScreenOverride();
+ if (override == null || override.getBackground() == null) return true;
+ override.getBackground().renderGeneric(instance, this);
+ return false;
+ }
+}
diff --git a/src/texturePacks/java/moe/nea/firmament/mixins/custommodels/screenlayouts/ReplaceGenericBackgrounds.java b/src/texturePacks/java/moe/nea/firmament/mixins/custommodels/screenlayouts/ReplaceGenericBackgrounds.java
new file mode 100644
index 0000000..d33de91
--- /dev/null
+++ b/src/texturePacks/java/moe/nea/firmament/mixins/custommodels/screenlayouts/ReplaceGenericBackgrounds.java
@@ -0,0 +1,29 @@
+package moe.nea.firmament.mixins.custommodels.screenlayouts;
+
+
+import moe.nea.firmament.features.texturepack.CustomScreenLayouts;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.screen.ingame.*;
+import net.minecraft.entity.player.PlayerInventory;
+import net.minecraft.screen.ScreenHandler;
+import net.minecraft.text.Text;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+
+@Mixin({CraftingScreen.class, CrafterScreen.class, Generic3x3ContainerScreen.class, GenericContainerScreen.class, HopperScreen.class, ShulkerBoxScreen.class,})
+public abstract class ReplaceGenericBackgrounds extends HandledScreen<ScreenHandler> {
+ // TODO: split out screens with special background components like flames, arrows, etc. (maybe arrows deserve generic handling tho)
+ public ReplaceGenericBackgrounds(ScreenHandler handler, PlayerInventory inventory, Text title) {
+ super(handler, inventory, title);
+ }
+
+ @Inject(method = "drawBackground", at = @At("HEAD"), cancellable = true)
+ private void replaceDrawBackground(DrawContext context, float deltaTicks, int mouseX, int mouseY, CallbackInfo ci) {
+ final var override = CustomScreenLayouts.getActiveScreenOverride();
+ if (override == null || override.getBackground() == null) return;
+ override.getBackground().renderGeneric(context, this);
+ ci.cancel();
+ }
+}
diff --git a/src/texturePacks/java/moe/nea/firmament/mixins/custommodels/screenlayouts/ReplacePlayerBackgrounds.java b/src/texturePacks/java/moe/nea/firmament/mixins/custommodels/screenlayouts/ReplacePlayerBackgrounds.java
new file mode 100644
index 0000000..a20dc61
--- /dev/null
+++ b/src/texturePacks/java/moe/nea/firmament/mixins/custommodels/screenlayouts/ReplacePlayerBackgrounds.java
@@ -0,0 +1,34 @@
+package moe.nea.firmament.mixins.custommodels.screenlayouts;
+
+
+import com.llamalad7.mixinextras.injector.v2.WrapWithCondition;
+import moe.nea.firmament.features.texturepack.CustomScreenLayouts;
+import net.minecraft.client.gui.DrawContext;
+import net.minecraft.client.gui.screen.ingame.InventoryScreen;
+import net.minecraft.client.gui.screen.ingame.RecipeBookScreen;
+import net.minecraft.client.gui.screen.recipebook.RecipeBookWidget;
+import net.minecraft.client.render.RenderLayer;
+import net.minecraft.entity.player.PlayerInventory;
+import net.minecraft.screen.PlayerScreenHandler;
+import net.minecraft.text.Text;
+import net.minecraft.util.Identifier;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+
+import java.util.function.Function;
+
+@Mixin(InventoryScreen.class)
+public abstract class ReplacePlayerBackgrounds extends RecipeBookScreen<PlayerScreenHandler> {
+ public ReplacePlayerBackgrounds(PlayerScreenHandler handler, RecipeBookWidget<?> recipeBook, PlayerInventory inventory, Text title) {
+ super(handler, recipeBook, inventory, title);
+ }
+
+ @WrapWithCondition(method = "drawBackground", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/DrawContext;drawTexture(Ljava/util/function/Function;Lnet/minecraft/util/Identifier;IIFFIIII)V"))
+ private boolean onDrawBackground(DrawContext instance, Function<Identifier, RenderLayer> renderLayers, Identifier sprite, int x, int y, float u, float v, int width, int height, int textureWidth, int textureHeight) {
+ final var override = CustomScreenLayouts.getActiveScreenOverride();
+ if (override == null || override.getBackground() == null) return true;
+ override.getBackground().renderGeneric(instance, this);
+ return false;
+ }
+ // TODO: allow moving the player
+}
diff --git a/translations/en_us.json b/translations/en_us.json
index ec126f9..33704f7 100644
--- a/translations/en_us.json
+++ b/translations/en_us.json
@@ -96,6 +96,8 @@
"firmament.config.custom-skyblock-textures.model-overrides.description": "Enable Firmament's model predicates. This will apply to vanilla models as well, if that vanilla model has Firmament predicates.",
"firmament.config.custom-skyblock-textures.recolor-text": "Allow packs to recolor text",
"firmament.config.custom-skyblock-textures.recolor-text.description": "Allows texture packs to recolor UI texts.",
+ "firmament.config.custom-skyblock-textures.screen-layouts": "Allow packs screen relayouts",
+ "firmament.config.custom-skyblock-textures.screen-layouts.description": "Allows texture packs to move UI elements like slots around, as well as replace the background of screens.",
"firmament.config.custom-skyblock-textures.skulls-enabled": "Enable Custom Placed Skull Textures",
"firmament.config.custom-skyblock-textures.skulls-enabled.description": "Allow replacing the textures of placed skulls.",
"firmament.config.developer": "Developer Settings",
diff --git a/web/src/pages/docs/_texture-pack-format.md b/web/src/pages/docs/_texture-pack-format.md
index 182c413..a8d4e0e 100644
--- a/web/src/pages/docs/_texture-pack-format.md
+++ b/web/src/pages/docs/_texture-pack-format.md
@@ -575,6 +575,84 @@ not screens from other mods. You can also target specific texts via a [string ma
| `overrides.predicate` | true | This is a [string matcher](#string-matcher) that allows you to match on the text you are replacing |
| `overrides.override` | true | This is the replacement color that will be used if the predicate matches. |
+## Screen Layout Replacement
+
+You can change the layout of an entire screen by using screen layout overrides. These get placed in `firmskyblock:overrides/screen_layout/*.json`, with one file per screen. You can match on the title of a screen, replace the background texture (including extending the background canvas further than vanilla allows you) and move slots around.
+
+### Selecting a screen
+
+```json
+{
+ "predicates": {
+ "label": {
+ "regex": "Hyper Furnace"
+ }
+ }
+}
+```
+
+The `label` property is a regular [string matcher](#string-matcher) and matches against the screens title (typically the chest title, or "Crafting" for the players inventory).
+
+### Changing the background
+
+```json
+{
+ "predicates": {
+ "label": {
+ "regex": "Hyper Furnace"
+ }
+ },
+ "background": {
+ "texture": "firmskyblock:textures/furnace.png",
+ "x": -21,
+ "y": -30,
+ "width": 197,
+ "height": 196
+ }
+}
+```
+
+You need to specify an x and y offset relative to where the regular screen would render. This means you just check where the upper left corner of the UI texture would be in your texture (and turn it into a negative number). You also need to specify a width and height of your texture. This is the width in pixels rendered. If you want a higher or lower resolution texture, you can scale the actual texture up (tho it is expected to meet the same aspect ratio as the one defined here).
+
+### Moving slots around
+
+```json
+{
+ "predicates": {
+ "label": {
+ "regex": "Hyper Furnace"
+ }
+ },
+ "slots": [
+ {
+ "index": 10,
+ "x": -5000,
+ "y": -5000
+ }
+ ]
+}
+```
+
+You can move slots around by a specific index. This is not the index in the inventory, but rather the index in the screen (so if you have a chest screen then all the player inventory slots would be a higher index since the chest slots move them down the list). The x and y are relative to where the regular screen top left would be. Set to large values to effectively "delete" a slot by moving it offscreen.
+
+### All together
+
+| Field | Required | Description |
+|----------------------|----------|--------------------------------------------------------------------------------------------|
+| `predicates` | true | A list of predicates that need to match in order to change the layout of a screen |
+| `predicates.label` | true | A [string matcher](#string-matcher) for the screen title |
+| `background` | false | Allows replacing the background texture |
+| `background.texture` | true | The texture of the background as an identifier |
+| `background.x` | true | The x offset of the background relative to where the regular background would be rendered. |
+| `background.y` | true | The y offset of the background relative to where the regular background would be rendered. |
+| `background.width` | true | The width of the background texture. |
+| `background.height` | true | The height of the background texture. |
+| `slots` | false | An array of slots to move around. |
+| `slots[*].index` | true | The index in the array of all slots on the screen (not inventory). |
+| `slots[*].x` | true | The x coordinate of the slot relative to the top left of the screen |
+| `slots[*].y` | true | The y coordinate of the slot relative to the top left of the screen |
+
+
## Global Item Texture Replacement
Most texture replacement is done based on the SkyBlock id of the item. However, some items you might want to re-texture