From 6f1ab0aac9dabe42c990ebf25ce1c92252c04ca4 Mon Sep 17 00:00:00 2001 From: James Hall Date: Wed, 7 Jan 2026 04:54:46 +0000 Subject: [PATCH 01/37] json defined guis --- .../animation/client/AnimationMetadata.java | 2 +- .../dev/amble/lib/client/AmbleKitClient.java | 6 +- .../bedrock/BedrockAnimationAdapter.java | 2 +- .../bedrock/BedrockAnimationRegistry.java | 8 +- .../client/bedrock/BedrockModelRegistry.java | 8 +- .../dev/amble/lib/client/gui/AmbleButton.java | 77 +++++++ .../amble/lib/client/gui/AmbleContainer.java | 163 +++++++++++++++ .../lib/client/gui/AmbleDisplayType.java | 64 ++++++ .../amble/lib/client/gui/AmbleElement.java | 193 ++++++++++++++++++ .../dev/amble/lib/client/gui/AmbleText.java | 79 +++++++ .../dev/amble/lib/client/gui/UIAlign.java | 8 + .../client/gui/registry/AmbleGuiRegistry.java | 184 +++++++++++++++++ .../datagen/lang/AmbleLanguageProvider.java | 2 +- .../lib/datagen/sound/AmbleSoundProvider.java | 4 +- .../datapack/SimpleDatapackRegistry.java | 4 +- .../java/dev/amble/lib/skin/SkinTracker.java | 6 +- .../dev/amble/lib/skin/client/SkinCache.java | 4 +- .../dev/amble/litmus/client/LitmusClient.java | 5 + .../litmus/commands/TestScreenCommand.java | 65 ++++++ .../resources/assets/litmus/gui/test.json | 71 +++++++ .../litmus/textures/gui/test_screen.png | Bin 0 -> 6311 bytes 21 files changed, 933 insertions(+), 22 deletions(-) create mode 100644 src/main/java/dev/amble/lib/client/gui/AmbleButton.java create mode 100644 src/main/java/dev/amble/lib/client/gui/AmbleContainer.java create mode 100644 src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java create mode 100644 src/main/java/dev/amble/lib/client/gui/AmbleElement.java create mode 100644 src/main/java/dev/amble/lib/client/gui/AmbleText.java create mode 100644 src/main/java/dev/amble/lib/client/gui/UIAlign.java create mode 100644 src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java create mode 100644 src/test/java/dev/amble/litmus/commands/TestScreenCommand.java create mode 100644 src/test/resources/assets/litmus/gui/test.json create mode 100644 src/test/resources/assets/litmus/textures/gui/test_screen.png diff --git a/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java b/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java index 59a71a7..046ca8d 100644 --- a/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java +++ b/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java @@ -13,7 +13,7 @@ /** * Metadata for animations, controlling how they behave in certain situations. - * in "filename.metadata.json" + * in "filename.metadata.registry" * @param movement Whether the animation should allow player movement. Default: true * @param perspective The perspective the animation should play in. Default: null (all perspectives) * @param fpsCamera Whether the animation should have FPS camera controls. Default: true diff --git a/src/main/java/dev/amble/lib/client/AmbleKitClient.java b/src/main/java/dev/amble/lib/client/AmbleKitClient.java index 0206b6a..01362d0 100644 --- a/src/main/java/dev/amble/lib/client/AmbleKitClient.java +++ b/src/main/java/dev/amble/lib/client/AmbleKitClient.java @@ -3,10 +3,12 @@ import dev.amble.lib.client.bedrock.BedrockAnimationRegistry; import dev.amble.lib.client.bedrock.BedrockModel; import dev.amble.lib.client.bedrock.BedrockModelRegistry; +import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; import dev.amble.lib.register.AmbleRegistries; import dev.amble.lib.skin.client.SkinGrabber; import dev.drtheo.scheduler.client.SchedulerClientMod; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.loader.api.FabricLoader; @@ -25,13 +27,13 @@ public void onInitializeClient() { AmbleRegistries.getInstance().registerAll( BedrockModelRegistry.getInstance(), - BedrockAnimationRegistry.getInstance() + BedrockAnimationRegistry.getInstance(), + AmbleGuiRegistry.getInstance() ); ClientTickEvents.END_CLIENT_TICK.register((client) -> { SkinGrabber.INSTANCE.tick(); }); - } } diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java index ce0b9bf..e0075a4 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java @@ -34,7 +34,7 @@ public BedrockAnimationAdapter() {} @Override public BedrockAnimation deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { if (!json.isJsonObject()) { - throw new IllegalStateException("animation json could not be parsed"); + throw new IllegalStateException("animation registry could not be parsed"); } JsonObject jsonObj = json.getAsJsonObject(); diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationRegistry.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationRegistry.java index dbec2cf..f34b7f1 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationRegistry.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationRegistry.java @@ -61,12 +61,12 @@ public void reload(ResourceManager manager) { int animationCount = 0; groups.clear(); - for (Identifier rawId : manager.findResources("bedrock", filename -> filename.getPath().endsWith(".animation.json")).keySet()) { + for (Identifier rawId : manager.findResources("bedrock", filename -> filename.getPath().endsWith(".animation.registry")).keySet()) { try (InputStream stream = manager.getResource(rawId).get().getInputStream()) { JsonObject json = JsonParser.parseReader(new InputStreamReader(stream)).getAsJsonObject(); @Nullable JsonObject metadata = null; - Identifier metadataId = Identifier.of(rawId.getNamespace(), rawId.getPath().replaceFirst("\\.animation\\.json$", ".metadata.json")); + Identifier metadataId = Identifier.of(rawId.getNamespace(), rawId.getPath().replaceFirst("\\.animation\\.json$", ".metadata.registry")); if (manager.getResource(metadataId).isPresent()) { try (InputStream metaStream = manager.getResource(metadataId).get().getInputStream()) { metadata = JsonParser.parseReader(new InputStreamReader(metaStream)).getAsJsonObject(); @@ -88,11 +88,11 @@ public void reload(ResourceManager manager) { group.animations.forEach((name, animation) -> animation.name = name); - String groupName = rawId.getPath().substring(rawId.getPath().lastIndexOf("/") + 1).replace(".animation.json", ""); + String groupName = rawId.getPath().substring(rawId.getPath().lastIndexOf("/") + 1).replace(".animation.registry", ""); groups.put(groupName, group); animationCount += group.animations.size(); } catch (Exception e) { - AmbleKit.LOGGER.error("Error occurred while loading resource json {}", rawId.toString(), e); + AmbleKit.LOGGER.error("Error occurred while loading resource registry {}", rawId.toString(), e); } } diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockModelRegistry.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockModelRegistry.java index 229610c..b2f2add 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockModelRegistry.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockModelRegistry.java @@ -40,11 +40,11 @@ public Identifier getFabricId() { public void reload(ResourceManager manager) { clearCache(); - for (Identifier rawId : manager.findResources("bedrock", filename -> filename.getPath().endsWith("geo.json")).keySet()) { + for (Identifier rawId : manager.findResources("bedrock", filename -> filename.getPath().endsWith("geo.registry")).keySet()) { try (InputStream stream = manager.getResource(rawId).get().getInputStream()) { String path = rawId.getPath(); - // remove "bedrock/" prefix and ".geo.json" suffix - String idPath = path.substring("bedrock/".length(), path.length() - ".geo.json".length()); + // remove "bedrock/" prefix and ".geo.registry" suffix + String idPath = path.substring("bedrock/".length(), path.length() - ".geo.registry".length()); Identifier id = Identifier.of(rawId.getNamespace(), idPath); JsonObject json = JsonParser.parseReader(new InputStreamReader(stream)).getAsJsonObject(); @@ -54,7 +54,7 @@ public void reload(ResourceManager manager) { AmbleKit.LOGGER.debug("Loaded bedrock model {} {}", id, model); } catch (Exception e) { - AmbleKit.LOGGER.error("Error occurred while loading resource json {}", rawId.toString(), e); + AmbleKit.LOGGER.error("Error occurred while loading resource registry {}", rawId.toString(), e); } } } diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java new file mode 100644 index 0000000..cef3919 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java @@ -0,0 +1,77 @@ +package dev.amble.lib.client.gui; + + +import lombok.*; +import net.minecraft.client.gui.DrawContext; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Setter +public class AmbleButton extends AmbleContainer { + private AmbleDisplayType hoverDisplay; + private AmbleDisplayType pressDisplay; + private Runnable onClick; + private @Nullable AmbleDisplayType normalDisplay = null; + private boolean isClicked = false; + + public static AmbleButton of(AmbleContainer container, AmbleDisplayType hoverColor, AmbleDisplayType pressColor, Runnable onClick) { + AmbleButton button = new AmbleButton(); + button.setPosition(container.getPosition()); + button.setVisible(container.isVisible()); + button.setLayout(container.getLayout()); + button.setPreferredLayout(container.getPreferredLayout()); + button.setParent(container.getParent()); + button.setPadding(container.getPadding()); + button.setSpacing(container.getSpacing()); + button.setHorizontalAlign(container.getHorizontalAlign()); + button.setVerticalAlign(container.getVerticalAlign()); + button.setRequiresNewRow(container.requiresNewRow()); + button.setBackground(container.getBackground()); + + button.hoverDisplay = hoverColor; + button.pressDisplay = pressColor; + button.onClick = onClick; + + return button; + } + + @Override + public void onRelease(double mouseX, double mouseY, int button) { + onClick.run(); + this.setBackground( + isHovered(mouseX, mouseY) ? hoverDisplay : getNormalDisplay() + ); + this.isClicked = false; + } + + @Override + public void onClick(double mouseX, double mouseY, int button) { + this.setBackground(pressDisplay); + this.isClicked = true; + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + if (isClicked) { + setBackground(pressDisplay); + } else if (isHovered(mouseX, mouseY)) { + setBackground(hoverDisplay); + } else { + setBackground(getNormalDisplay()); + } + + super.render(context, mouseX, mouseY, delta); + } + + public @Nullable AmbleDisplayType getNormalDisplay() { + if (normalDisplay == null) { + normalDisplay = this.getBackground(); + } + + return normalDisplay; + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java new file mode 100644 index 0000000..22de180 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java @@ -0,0 +1,163 @@ +package dev.amble.lib.client.gui; + +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.AmbleKitClient; +import lombok.*; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AmbleContainer implements AmbleElement { + @Setter + @Builder.Default + private boolean visible = true; + + @Setter + private Rectangle layout; + + @Setter + private Rectangle preferredLayout; + + @Setter + @Nullable + @Builder.Default + private AmbleElement parent = null; + + @Setter + private int padding; + + @Setter + private int spacing; + + @Setter + @Builder.Default + private UIAlign horizontalAlign = UIAlign.START; + + @Setter + @Builder.Default + private UIAlign verticalAlign = UIAlign.START; + + @Setter + @Builder.Default + private boolean requiresNewRow = false; + + @Setter + @Builder.Default + private Text title = Text.empty(); + + @Setter + @Builder.Default + public AmbleDisplayType background = AmbleDisplayType.color(Color.WHITE); + + @Setter + private Identifier identifier; + + @Builder.Default + private @Nullable Screen convertedScreen = null; + @Builder.Default + private final List children = new ArrayList<>(); + + @Override + public boolean requiresNewRow() { + return requiresNewRow; + } + + @Override + public Identifier id() { + if (identifier == null) { + if (parent != null) { + identifier = new Identifier(parent.id().getNamespace(), + parent.id().getPath() + "/" + System.identityHashCode(this)); + } else { + identifier = new Identifier("amble", + "container/" + System.identityHashCode(this)); + AmbleKit.LOGGER.error("GUI element missing identifier, no parent found to derive from. Generated id: {}", identifier); + } + } + + return identifier; + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + this.background.render(context, getLayout()); + + AmbleElement.super.render(context, mouseX, mouseY, delta); + } + + public Rectangle getLayout() { + if (layout == null) return getPreferredLayout(); + + return layout; + } + + public Rectangle getPreferredLayout() { + if (preferredLayout == null) return layout != null ? layout : new Rectangle(0, 0, 100, 100); + return preferredLayout; + } + + public Screen toScreen() { + if (this.convertedScreen == null) { + this.convertedScreen = createScreen(); + } + return this.convertedScreen; + } + + protected Screen createScreen() { + return new AmbleScreen(this); + } + + public void display() { + var primary = AmbleContainer.primaryContainer(); + primary.addChild(this); + Screen screen = primary.toScreen(); + net.minecraft.client.MinecraftClient.getInstance().setScreen(screen); + } + + public static AmbleContainer primaryContainer() { + return AmbleContainer.builder() + .preferredLayout(new Rectangle(0, 0, + net.minecraft.client.MinecraftClient.getInstance().getWindow().getScaledWidth(), + net.minecraft.client.MinecraftClient.getInstance().getWindow().getScaledHeight())) + .background(AmbleDisplayType.color(new Color(0, 0, 0, 0))) + .build(); + } + + public static class AmbleScreen extends Screen { + public final AmbleContainer source; + + public AmbleScreen(AmbleContainer source) { + super(source.getTitle()); + this.source = source; + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + source.render(context, mouseX, mouseY, delta); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + source.onClick((int) mouseX, (int) mouseY, button); + return super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + source.onRelease((int) mouseX, (int) mouseY, button); + return super.mouseReleased(mouseX, mouseY, button); + } + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java b/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java new file mode 100644 index 0000000..bd3e2d4 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java @@ -0,0 +1,64 @@ +package dev.amble.lib.client.gui; + +import com.google.gson.JsonElement; +import dev.amble.lib.api.Identifiable; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; + +public record AmbleDisplayType(@Nullable Color color, @Nullable TextureData texture) { + public AmbleDisplayType { + if (color == null && texture == null) { + throw new IllegalArgumentException("Either color or texture must be provided"); + } + } + + public void render(DrawContext context, Rectangle layout) { + if (color != null) { + context.fill(layout.x, layout.y, layout.x + layout.width, layout.y + layout.height, + color.getRGB()); + } else if (texture != null) { + texture.render(context, layout); + } + } + + public static AmbleDisplayType color(Color color) { + return new AmbleDisplayType(color, null); + } + + public static AmbleDisplayType texture(TextureData identifier) { + return new AmbleDisplayType(null, identifier); + } + + public static AmbleDisplayType parse(JsonElement element) { + if (element.isJsonArray()) { + // parse 3 element array as RGB color, 4th element optional alpha + var arr = element.getAsJsonArray(); + int r = arr.get(0).getAsInt(); + int g = arr.get(1).getAsInt(); + int b = arr.get(2).getAsInt(); + int a = arr.size() > 3 ? arr.get(3).getAsInt() : 255; + return AmbleDisplayType.color(new Color(r, g, b, a)); + } else if (element.isJsonObject()) { + var obj = element.getAsJsonObject(); + Identifier texture = new Identifier(obj.get("texture").getAsString()); + int u = obj.get("u").getAsInt(); + int v = obj.get("v").getAsInt(); + int regionWidth = obj.get("regionWidth").getAsInt(); + int regionHeight = obj.get("regionHeight").getAsInt(); + int textureWidth = obj.get("textureWidth").getAsInt(); + int textureHeight = obj.get("textureHeight").getAsInt(); + return AmbleDisplayType.texture(new TextureData(texture, u, v, regionWidth, regionHeight, textureWidth, textureHeight)); + } + + throw new IllegalArgumentException("Invalid AmbleDisplayType JSON element"); + } + + public record TextureData(Identifier texture, int u, int v, int regionWidth, int regionHeight, int textureWidth, int textureHeight) { + public void render(DrawContext context, Rectangle layout) { + context.drawTexture(texture, layout.x, layout.y, layout.width, layout.height, u, v, regionWidth, regionHeight, textureWidth, textureHeight); + } + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleElement.java b/src/main/java/dev/amble/lib/client/gui/AmbleElement.java new file mode 100644 index 0000000..334993c --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleElement.java @@ -0,0 +1,193 @@ +package dev.amble.lib.client.gui; + +import dev.amble.lib.api.Identifiable; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.Drawable; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec2f; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; +import java.util.ArrayList; +import java.util.List; + +public interface AmbleElement extends Drawable, Identifiable { + default Vec2f getPosition() { + Rectangle layout = getLayout(); + return new Vec2f(layout.x, layout.y); + } + default void setPosition(Vec2f position) { + Rectangle layout = getLayout(); + layout.x = (int) position.x; + layout.y = (int) position.y; + setPreferredLayout(layout); + + recalcuateLayout(); + + if (getParent() != null) { + getParent().recalcuateLayout(); + } + } + + boolean isVisible(); + void setVisible(boolean visible); + + Rectangle getLayout(); + void setLayout(Rectangle layout); + + Rectangle getPreferredLayout(); + void setPreferredLayout(Rectangle preferredLayout); + + @Nullable AmbleElement getParent(); + void setParent(@Nullable AmbleElement parent); + + int getPadding(); + void setPadding(int padding); + + int getSpacing(); + void setSpacing(int spacing); + + UIAlign getHorizontalAlign(); + void setHorizontalAlign(UIAlign align); + + UIAlign getVerticalAlign(); + void setVerticalAlign(UIAlign align); + + boolean requiresNewRow(); + void setRequiresNewRow(boolean requiresNewRow); + + List getChildren(); + + default void addChild(AmbleElement child) { + if (!getChildren().contains(child)) { + getChildren().add(child); + child.setParent(this); + + recalcuateLayout(); + } + } + + default void recalcuateLayout() { + + int startX = getLayout().x + getPadding(); + int maxWidth = getLayout().width - getPadding() * 2; + + int cursorX = startX; + final int[] cursorY = {getLayout().y + getPadding()}; + final int[] rowHeight = {0}; + + List row = new ArrayList<>(); + + Runnable layoutRow = () -> + { + if (row.isEmpty()) return; + + int rowWidth = row.stream().mapToInt(e -> e.getPreferredLayout().width).sum() + + getSpacing() * (row.size() - 1); + + int offsetX = switch (row.get(0).getHorizontalAlign()) { + case CENTRE -> (maxWidth - rowWidth) / 2; + case END -> maxWidth - rowWidth; + default -> 0; + }; + + int x = startX + offsetX; + boolean singleElementFullCenter = + row.size() == 1 && + row.get(0).getVerticalAlign() == UIAlign.CENTRE; + int innerHeight = getLayout().height - getPadding() * 2; + + for (var e : row) { + int y; + + if (singleElementFullCenter) { + y = getLayout().y + getPadding() + + (innerHeight - e.getPreferredLayout().height) / 2; + } else { + y = cursorY[0]; + + if (e.getVerticalAlign() == UIAlign.CENTRE) + y += (rowHeight[0] - e.getPreferredLayout().height) / 2; + else if (e.getVerticalAlign() == UIAlign.END) + y += rowHeight[0] - e.getPreferredLayout().height; + } + + e.setLayout(new Rectangle(x, y, + e.getPreferredLayout().width, + e.getPreferredLayout().height)); + + e.recalcuateLayout(); + + x += e.getPreferredLayout().width + getSpacing(); + } + + cursorY[0] += rowHeight[0] + getSpacing(); + row.clear(); + rowHeight[0] = 0; + }; + + for (var child : getChildren()) + { + int w = child.getPreferredLayout().width; + int h = child.getPreferredLayout().height; + + if (cursorX + w > startX + maxWidth || child.requiresNewRow()) + { + layoutRow.run(); + cursorX = startX; + } + + row.add(child); + + //child.LayerDepth = LayerDepth + 0.025F; + child.recalcuateLayout(); + + cursorX += w + getSpacing(); + rowHeight[0] = Math.max(rowHeight[0], h); + } + + if (!row.isEmpty()) + layoutRow.run(); + } + + @Override + default void render(DrawContext context, int mouseX, int mouseY, float delta) { + if (!isVisible()) return; + + for (AmbleElement child : getChildren()) { + if (!child.isVisible()) continue; + + child.render(context, mouseX, mouseY, delta); + } + } + + default boolean isHovered(double mouseX, double mouseY) { + Rectangle layout = getLayout(); + return mouseX >= layout.x && mouseX <= layout.x + layout.width && + mouseY >= layout.y && mouseY <= layout.y + layout.height; + } + + default void onClick(double mouseX, double mouseY, int button) { + for (AmbleElement child : getChildren()) { + if (!child.isVisible()) continue; + + if (child.isHovered(mouseX, mouseY)) { + child.onClick(mouseX, mouseY, button); + } + } + } + + default void onRelease(double mouseX, double mouseY, int button) { + for (AmbleElement child : getChildren()) { + if (!child.isVisible()) continue; + + if (child.isHovered(mouseX, mouseY)) { + child.onRelease(mouseX, mouseY, button); + } + } + } + + default Identifier toMcssFile() { + return this.id().withPrefixedPath("gui/").withSuffixedPath(".json"); + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleText.java b/src/main/java/dev/amble/lib/client/gui/AmbleText.java new file mode 100644 index 0000000..4a6bee3 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleText.java @@ -0,0 +1,79 @@ +package dev.amble.lib.client.gui; + + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.text.OrderedText; +import net.minecraft.text.Text; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Setter +public class AmbleText extends AmbleContainer { + private Text text; + private UIAlign textHorizontalAlign = UIAlign.CENTRE; + private UIAlign textVerticalAlign = UIAlign.CENTRE; + + public static AmbleText of(AmbleContainer container, Text text) { + AmbleText ambleText = new AmbleText(); + ambleText.setPosition(container.getPosition()); + ambleText.setVisible(container.isVisible()); + ambleText.setLayout(container.getLayout()); + ambleText.setPreferredLayout(container.getPreferredLayout()); + ambleText.setParent(container.getParent()); + ambleText.setPadding(container.getPadding()); + ambleText.setSpacing(container.getSpacing()); + ambleText.setHorizontalAlign(container.getHorizontalAlign()); + ambleText.setVerticalAlign(container.getVerticalAlign()); + ambleText.setRequiresNewRow(container.requiresNewRow()); + ambleText.setBackground(container.getBackground()); + + ambleText.setText(text); + + return ambleText; + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + // Calculate text position based on alignment + int textX = getLayout().x; + int textY = getLayout().y; + int textWidth = MinecraftClient.getInstance().textRenderer.getWidth(text); + int textHeight = MinecraftClient.getInstance().textRenderer.fontHeight; + switch (textHorizontalAlign) { + case START -> textX += getPadding(); + case CENTRE -> textX += (getLayout().width - textWidth) / 2; + case END -> textX += getLayout().width - textWidth - getPadding(); + } + switch (textVerticalAlign) { + case START -> textY += getPadding(); + case CENTRE -> textY += (getLayout().height - textHeight) / 2; + case END -> textY += getLayout().height - textHeight - getPadding(); + } + + // Draw the text + drawTextWrappedWithShadow( + context, + MinecraftClient.getInstance().textRenderer, + text, + textX, textY, + getLayout().width, + 0xFFFFFF + ); + } + + public static void drawTextWrappedWithShadow(DrawContext context, TextRenderer textRenderer, Text text, int x, int y, int width, int color) { + for (OrderedText orderedText : textRenderer.wrapLines(text, width)) { + context.drawText(textRenderer, orderedText, x, y, color, true); + y += 9; + } + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/UIAlign.java b/src/main/java/dev/amble/lib/client/gui/UIAlign.java new file mode 100644 index 0000000..b7c95fe --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/UIAlign.java @@ -0,0 +1,8 @@ +package dev.amble.lib.client.gui; + +public enum UIAlign { + START, + CENTRE, + END, + STRETCH +} diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java new file mode 100644 index 0000000..251e414 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -0,0 +1,184 @@ +package dev.amble.lib.client.gui.registry; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.*; +import dev.amble.lib.register.datapack.DatapackRegistry; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.network.PacketByteBuf; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import org.apache.commons.lang3.NotImplementedException; + +import java.awt.*; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; + +public class AmbleGuiRegistry extends DatapackRegistry implements SimpleSynchronousResourceReloadListener { + private static final AmbleGuiRegistry INSTANCE = new AmbleGuiRegistry(); + + private AmbleGuiRegistry() { + ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES).registerReloadListener(this); + } + + @Override + public AmbleContainer fallback() { + throw new NotImplementedException(); + } + + @Override + public Identifier getFabricId() { + return AmbleKit.id("gui"); + } + + public static AmbleContainer parse(JsonObject json) { + // first parse background + AmbleDisplayType background; + if (json.has("background")) { + background = AmbleDisplayType.parse(json.get("background")); + } else { + throw new IllegalStateException("Amble container is missing background data"); + } + + Rectangle layout = new Rectangle(); + if (json.has("layout") && json.get("layout").isJsonArray()) { + var layoutArray = json.get("layout").getAsJsonArray(); + layout.setSize(layoutArray.get(0).getAsInt(), layoutArray.get(1).getAsInt()); + } else { + throw new IllegalStateException("Amble container is missing layout data"); + } + + int padding = 0; + if (json.has("padding")) { + padding = json.get("padding").getAsInt(); + } + + int spacing = 0; + if (json.has("spacing")) { + spacing = json.get("spacing").getAsInt(); + } + + UIAlign horizAlign = UIAlign.START; + UIAlign vertAlign = UIAlign.START; + if (json.has("alignment")) { + if (!json.get("alignment").isJsonArray()) { + throw new IllegalStateException("UI Alignment must be array [horizontal, vertical]"); + } + + var alignmentArray = json.get("alignment").getAsJsonArray(); + String horizAlignKey = alignmentArray.get(0).getAsString(); + String vertAlignKey = alignmentArray.get(1).getAsString(); + + if (vertAlignKey.equalsIgnoreCase("center")) { + vertAlignKey = "centre"; + } + + if (horizAlignKey.equalsIgnoreCase("center")) { + horizAlignKey = "centre"; + } + + // try parse to enums + horizAlign = UIAlign.valueOf(horizAlignKey.toUpperCase()); + vertAlign = UIAlign.valueOf(vertAlignKey.toUpperCase()); + } + + List children = new ArrayList<>(); + if (json.has("children")) { + if (!json.get("children").isJsonArray()) { + throw new IllegalStateException("UI children should be an object array of other ui elements"); + } + + var childrenArray = json.get("children").getAsJsonArray(); + + for (int i = 0; i < childrenArray.size(); i++) { + if (!(childrenArray.get(i).isJsonObject())) { + throw new IllegalStateException("UI child at index " + i + " is invalid, got " + childrenArray.get(i)); + } + + children.add(parse(childrenArray.get(i).getAsJsonObject())); + } + } + + boolean requiresNewRow = false; + if (json.has("requires_new_row")) { + if (!json.get("requires_new_row").isJsonPrimitive()) { + throw new IllegalStateException("UI requires_new_row should be boolean"); + } + requiresNewRow = json.get("requires_new_row").getAsBoolean(); + } + + AmbleContainer created = AmbleContainer.builder().background(background).layout(layout).preferredLayout(layout).padding(padding).spacing(spacing).horizontalAlign(horizAlign).verticalAlign(vertAlign).children(children).requiresNewRow(requiresNewRow).build(); + + // TODO - buttons + if (json.has("text")) { + String text = json.get("text").getAsString(); + created = AmbleText.of(created, Text.translatable(text)); + + UIAlign textHorizAlign = UIAlign.CENTRE; + UIAlign textVertAlign = UIAlign.CENTRE; + if (json.has("text_alignment")) { + if (!json.get("text_alignment").isJsonArray()) { + throw new IllegalStateException("UI text Alignment must be array [horizontal, vertical]"); + } + + var alignmentArray = json.get("text_alignment").getAsJsonArray(); + String horizAlignKey = alignmentArray.get(0).getAsString(); + String vertAlignKey = alignmentArray.get(1).getAsString(); + + // try parse to enums + textHorizAlign = UIAlign.valueOf(horizAlignKey.toUpperCase()); + textVertAlign = UIAlign.valueOf(vertAlignKey.toUpperCase()); + + ((AmbleText) created).setTextHorizontalAlign(textHorizAlign); + ((AmbleText) created).setTextVerticalAlign(textVertAlign); + } + } + + return created; + } + + @Override + public void reload(ResourceManager manager) { + clearCache(); + + for (Identifier rawId : manager.findResources("gui", filename -> filename.getPath().endsWith(".json")).keySet()) { + try (InputStream stream = manager.getResource(rawId).get().getInputStream()) { + String path = rawId.getPath(); + // remove "gui/" prefix and ".json" suffix + String idPath = path.substring("gui/".length(), path.length() - ".json".length()); + Identifier id = Identifier.of(rawId.getNamespace(), idPath); + + JsonObject json = JsonParser.parseReader(new InputStreamReader(stream)).getAsJsonObject(); + AmbleContainer model = parse(json); + model.setIdentifier(id); + + register(model); + + AmbleKit.LOGGER.debug("Loaded AmbleContainer {} {}", id, model); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while loading resource registry {}", rawId.toString(), e); + } + } + } + + @Override + public void syncToClient(ServerPlayerEntity player) { + throw new UnsupportedOperationException("Client-side only registry"); + } + + @Override + public void readFromServer(PacketByteBuf buf) { + throw new UnsupportedOperationException("Client-side only registry"); + } + + public static AmbleGuiRegistry getInstance() { + return INSTANCE; + } +} diff --git a/src/main/java/dev/amble/lib/datagen/lang/AmbleLanguageProvider.java b/src/main/java/dev/amble/lib/datagen/lang/AmbleLanguageProvider.java index 8d96109..27f236f 100644 --- a/src/main/java/dev/amble/lib/datagen/lang/AmbleLanguageProvider.java +++ b/src/main/java/dev/amble/lib/datagen/lang/AmbleLanguageProvider.java @@ -38,7 +38,7 @@ public void generateTranslations(TranslationBuilder builder) { } output.getModContainer() - .findPath("assets/" + modid + "/lang/" + language.name().toLowerCase() + ".existing.json") + .findPath("assets/" + modid + "/lang/" + language.name().toLowerCase() + ".existing.registry") .ifPresent(existingFilePath -> { try { builder.add(existingFilePath); diff --git a/src/main/java/dev/amble/lib/datagen/sound/AmbleSoundProvider.java b/src/main/java/dev/amble/lib/datagen/sound/AmbleSoundProvider.java index 67d33b5..41b2643 100644 --- a/src/main/java/dev/amble/lib/datagen/sound/AmbleSoundProvider.java +++ b/src/main/java/dev/amble/lib/datagen/sound/AmbleSoundProvider.java @@ -20,7 +20,7 @@ import dev.amble.lib.util.StringCursor; /** - * Datagen Provider for sounds, this class is used to generate the sounds.json file for the mod + * Datagen Provider for sounds, this class is used to generate the sounds.registry file for the mod */ public class AmbleSoundProvider implements DataProvider { @@ -95,7 +95,7 @@ public CompletableFuture run(DataWriter writer) { public Path getOutputPath() { return dataOutput.resolvePath(DataOutput.OutputType.RESOURCE_PACK).resolve(dataOutput.getModId()) - .resolve("sounds.json"); + .resolve("sounds.registry"); } @Override diff --git a/src/main/java/dev/amble/lib/register/datapack/SimpleDatapackRegistry.java b/src/main/java/dev/amble/lib/register/datapack/SimpleDatapackRegistry.java index 5cb7eef..59e3adf 100644 --- a/src/main/java/dev/amble/lib/register/datapack/SimpleDatapackRegistry.java +++ b/src/main/java/dev/amble/lib/register/datapack/SimpleDatapackRegistry.java @@ -137,7 +137,7 @@ public void reload(ResourceManager manager) { this.defaults(); for (Identifier id : manager - .findResources(this.name.getPath(), filename -> filename.getPath().endsWith(".json")).keySet()) { + .findResources(this.name.getPath(), filename -> filename.getPath().endsWith(".registry")).keySet()) { try (InputStream stream = manager.getResource(id).get().getInputStream()) { T created = this.read(stream); @@ -149,7 +149,7 @@ public void reload(ResourceManager manager) { this.register(created); AmbleKit.LOGGER.info("Loaded datapack {} {}", this.name, created.id().toString()); } catch (Exception e) { - AmbleKit.LOGGER.error("Error occurred while loading resource json {}", id.toString(), e); + AmbleKit.LOGGER.error("Error occurred while loading resource registry {}", id.toString(), e); } } diff --git a/src/main/java/dev/amble/lib/skin/SkinTracker.java b/src/main/java/dev/amble/lib/skin/SkinTracker.java index ed72120..417d632 100644 --- a/src/main/java/dev/amble/lib/skin/SkinTracker.java +++ b/src/main/java/dev/amble/lib/skin/SkinTracker.java @@ -147,7 +147,7 @@ public void sync(ServerPlayerEntity target) { } private static Path getSavePath(MinecraftServer server) { - return server.getSavePath(WorldSavePath.ROOT).resolve("amblekit").resolve("skins.json"); + return server.getSavePath(WorldSavePath.ROOT).resolve("amblekit").resolve("skins.registry"); } private void write(MinecraftServer server) { @@ -159,7 +159,7 @@ private void write(MinecraftServer server) { Files.writeString(savePath, AmbleKit.GSON.toJson(this, SkinTracker.class)); } catch (Exception e) { - AmbleKit.LOGGER.error("Failed to write skins.json", e); + AmbleKit.LOGGER.error("Failed to write skins.registry", e); } } @@ -172,7 +172,7 @@ private static void read(MinecraftServer server) { INSTANCE = AmbleKit.GSON.fromJson(object, SkinTracker.class); INSTANCE.sync(); } catch (Exception e) { - AmbleKit.LOGGER.error("Failed to read skins.json", e); + AmbleKit.LOGGER.error("Failed to read skins.registry", e); } } } diff --git a/src/main/java/dev/amble/lib/skin/client/SkinCache.java b/src/main/java/dev/amble/lib/skin/client/SkinCache.java index 0fe7617..1e0948e 100644 --- a/src/main/java/dev/amble/lib/skin/client/SkinCache.java +++ b/src/main/java/dev/amble/lib/skin/client/SkinCache.java @@ -15,12 +15,12 @@ import dev.amble.lib.AmbleKit; /** - * A handler for the skin cache files, stored in a .json file + * A handler for the skin cache files, stored in a .registry file * Stores the key and the URL of the skin */ public class SkinCache { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); - private static final String CACHE_FILE = "cache.json"; + private static final String CACHE_FILE = "cache.registry"; private boolean locked; private ArrayList data; diff --git a/src/test/java/dev/amble/litmus/client/LitmusClient.java b/src/test/java/dev/amble/litmus/client/LitmusClient.java index b235d78..bc9b9bf 100644 --- a/src/test/java/dev/amble/litmus/client/LitmusClient.java +++ b/src/test/java/dev/amble/litmus/client/LitmusClient.java @@ -3,8 +3,10 @@ import dev.amble.lib.animation.client.BedrockBlockEntityRenderer; import dev.amble.lib.animation.client.BedrockEntityRenderer; import dev.amble.litmus.block.entity.LitmusBlockEntityTypes; +import dev.amble.litmus.commands.TestScreenCommand; import dev.amble.litmus.entity.LitmusEntities; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry; import net.minecraft.client.render.block.entity.BlockEntityRendererFactories; import net.minecraft.client.render.entity.EntityRendererFactory; @@ -12,5 +14,8 @@ public class LitmusClient implements ClientModInitializer { @Override public void onInitializeClient() { + ClientCommandRegistrationCallback.EVENT.register((dispatcher, access) -> { + TestScreenCommand.register(dispatcher); + }); } } diff --git a/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java b/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java new file mode 100644 index 0000000..f3af3d1 --- /dev/null +++ b/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java @@ -0,0 +1,65 @@ +package dev.amble.litmus.commands; + +import com.mojang.brigadier.CommandDispatcher; +import dev.amble.lib.client.gui.*; +import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; +import dev.amble.litmus.LitmusMod; +import dev.drtheo.scheduler.api.TimeUnit; +import dev.drtheo.scheduler.api.client.ClientScheduler; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager; +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; +import net.minecraft.client.MinecraftClient; +import net.minecraft.command.argument.IdentifierArgumentType; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +import java.awt.*; + + +public class TestScreenCommand { + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(ClientCommandManager.literal("ambleScreen").executes(source -> { + source.getSource().sendFeedback(Text.literal("Available screens: ")); + + for (AmbleContainer container : AmbleGuiRegistry.getInstance().toList()) { + source.getSource().sendFeedback(Text.literal(" - " + container.id().toString())); + } + + MinecraftClient.getInstance().execute(() -> { + ClientScheduler.get().runTaskLater(() -> { + var container = AmbleContainer.builder().preferredLayout(new Rectangle(0,0, 216, 138)).background(AmbleDisplayType.texture(new AmbleDisplayType.TextureData(new Identifier(LitmusMod.MOD_ID, "textures/gui/test_screen.png"), 0, 0, 216, 138, 256, 256))).build(); + var child1 = AmbleContainer.builder().preferredLayout(new Rectangle(0,0, 50, 50)).background(AmbleDisplayType.color(Color.BLUE)).build(); + var child2 = AmbleContainer.builder().preferredLayout(new Rectangle(0,0, 25, 25)).background(AmbleDisplayType.color(Color.ORANGE)).build(); + AmbleButton child3 = AmbleButton.of(AmbleContainer.builder().preferredLayout(new Rectangle(0,0, 75, 40)).horizontalAlign(UIAlign.CENTRE).background(AmbleDisplayType.color(Color.GREEN)).build(), AmbleDisplayType.color(Color.YELLOW), AmbleDisplayType.color(Color.RED), () -> { + System.out.println("Button Clicked!"); + }); + AmbleText child4 = AmbleText.of(AmbleContainer.builder().background(AmbleDisplayType.color(new Color(0,0,0,0))).build(), Text.literal("press me")); + child4.setPreferredLayout(child3.getPreferredLayout()); + child3.addChild(child4); + container.setPadding(10); + container.setSpacing(1); + container.addChild(child1); + container.addChild(child2); + container.addChild(child3); + container.setHorizontalAlign(UIAlign.CENTRE); + container.setVerticalAlign(UIAlign.CENTRE); + container.recalcuateLayout(); + + container.display(); + }, TimeUnit.SECONDS, 1); + }); + return 1; + }).then(ClientCommandManager.argument("id", IdentifierArgumentType.identifier()).executes(source -> { + Identifier id = source.getArgument("id", Identifier.class); + AmbleContainer container = AmbleGuiRegistry.getInstance().get(id); + if (container == null) { + source.getSource().sendError(Text.literal("No screen found with id: " + id.toString())); + return 0; + } + + ClientScheduler.get().runTaskLater(container::display, TimeUnit.SECONDS, 1); + + return 1; + }))); + } +} diff --git a/src/test/resources/assets/litmus/gui/test.json b/src/test/resources/assets/litmus/gui/test.json new file mode 100644 index 0000000..a4803f7 --- /dev/null +++ b/src/test/resources/assets/litmus/gui/test.json @@ -0,0 +1,71 @@ +{ + "layout": [ + 216, + 138 + ], + "background": { + "texture": "litmus:textures/gui/test_screen.png", + "u": 0, + "v": 0, + "regionWidth": 216, + "regionHeight": 138, + "textureWidth": 256, + "textureHeight": 256 + }, + "padding": 10, + "spacing": 1, + "alignment": [ + "centre", + "centre" + ], + "children": [ + { + "layout": [ + 50, + 50 + ], + "background": [ + 0, + 0, + 255 + ] + }, + { + "layout": [ + 25, + 25 + ], + "background": [ + 255, + 200, + 0 + ] + }, + { + "layout": [ + 75, + 40 + ], + "background": [ + 0, + 255, + 0 + ], + "children": [ + { + "layout": [ + 75, + 40 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "hello world" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/assets/litmus/textures/gui/test_screen.png b/src/test/resources/assets/litmus/textures/gui/test_screen.png new file mode 100644 index 0000000000000000000000000000000000000000..f21b2a1848ce80a09e25e54aa7cd3dcb8a60db8b GIT binary patch literal 6311 zcmcIp`9Dc3SL9O181DWf=;|S`-;ugp#BTgRx8$!q_v6 zWymgLvhRE*@5kf&AAIMBJLlYc&+~Q9Ij`62`F!1*U}j>#4B>|W0Kk0Xx~>HPfGAB6 zKu=5gv-5xN`mYyYVW0z4^aw5i0H5;>-K)1kb4a<({u40tj~x|9)y$vcUmlDmx2F$O z|L!C^HR|klpEZ5a!YY3ECspm&Eo0TpGp793uOlA*9+;>OWn?rEtp#}@0FZZuqm;x8 z(W{+_SiVoGjn(tekbm;V{=|mB-!oc02}Vm5PG$=v@|d{7`7?QutG|%ql~Cj z*p6`lfSXro0Bqdp;Y}czaJb%e!TL>8_@3fU$QFA#A?u`rLe6zn`+nZ{(v_)?n$}|$?SP8 zGcHIlb?RdLMLS1FgRvr|LBHCl6SHhr_@eSTbe|da0f6j zFeoCWA$@yvq9>gk9W?JI)GO`ugnEm7n$=lU*?xQ^M}|m+IS4! z^IIW0_Lsxc2GskhOU(2z27+|xsi(gzh73)m~MMV(n}jh_>0yY&6L3f5u-FWmOU1(!I0 z(;FP{7HOj|1kBuG`y@ z%{wJ<6J$zYzqUHKcE7Sam+hpINlD4?kWB*r;RwnE%498mx%|QBT_P<(A26~S z`Pe!_vUlHb{#CAv#4TB5QCxZ_YW4Z!4CcaD51*2LS_!ACUlQ)g_+>Ap(Sm=no4dBC zX{r{~Onf{T?z03pM)K6VQC2v7pJ*DUvg5)*S|V9T!-$K z@mm>IAwBYJFx!4w-0kSP^UI!tPZapEW!O(v{}&aNvc6ulq5s|^Rnhq)=Etlf{a`%U*Thu_gHu6W1 zIncx$ijfGXIMQD@7IvCyD2+F6VQdZMw*}@9kIzvM`J6Q zGUX;_nfoII<;6RNxcR_yMksKo@8MMNHK;)Mf{a*5f;ZaDR(0w3<=;AR+;z<00_@;u z66vb8=89U<juFx_b;Q6l77L>vNFZL-C$a{R6LH!f6U5=SmMqR#Q>QIM zFHQUi;vr)e>KeOq(yzEX;MuR3xTC)qfW}du+y~PY_^lMczHxcO%L)p{^M#rEZ(*{5 z)_^%R^vp)?H1zG-tl2@v^+lMGs(AsN{O+;6!PJ!iLarEkloWD|`s6VfO+kl+k&bdp1=OIB&dkd`E#H7WO~Bvs<- zPI@Piu>Fwn7?{vfVRR_<-$;FGvo3oNB~wQFdRg>n8mu}{MN6xcLOG_}`MJ5c-f%HW zDrlu+Z9e*4tIr(&BIPb-PiUJVPm?_Ev%aZqC@B34&ha=;+7oQ!KHiM}m+$9hXP=sw zm^4K7`ZOQ$93gEtgeHdW>wX`0HW$0@&puAhY>+-!rm} zaZQL_$m1d!_IPi74oxpQ+}f?uYZuR6n}~FE5s`*FJ{OY4!!6qK-_RorXC?>lp8A*5 z5cQNtRUJFlE1n_8b-|tSw}W9eOw4j-wFpt|S#&#-wqqaQ7|XgW7>B7rMnNjZ9*^Z; z3}LG`KCk6CmeG+-hkZv$ACz#Va7)K?;AZv3y0>TBt#vt#-DI;hEW0OP=g7UaMfbl; zma^w(PX&Y{1!$%SA?5}hfqXuXe^up=v4}-HRL8@gZ>bW!gomk|2Ba#QK=4QXr_?8| z1%5vVO;-;P$Grei-+ASnlIeKw9-P!)lFVbz|3VPd?cpL-bHPPpB>jLx+?_aAi!0xD z72S7|00KTYMM)P^cs`M~(*TepWLd=jTxk$?<_>l2t$tjt{-=k4grLkn7+%w5*Y-*Y z^GX^WYfDnWd-L{=g$;DW?q(TWR~lHaI|6BZkJdg=lm?AkwJ zuFqS~Hv8%@m}3dK`U$Y*K=+OYdeJ@>rfo&(O$&p;@`IP^eXY2tA~+E<^CVr^pQhb? zrNRGE&O-wryZT>HZSsOb30VO`ZEXJ81MC2@H(LT>f-)~Py!KTBk>lsu18)dZRp9h+ z@n@Nl>&WR!Ly3qE=_@USuz}v=OE~S73Z%eUOjq~DTNad7D)vkUe`Qs1z=xIw*mQws zz;LOtbiJFlF}3z0)w3d9fuMRN>LP%T1}gp80BJ(mwkUSQt9H^n9M?Q#RW&-FlCHQ3 zn=JqD#!H}L(#eQsyE~M#^5wrdgBln`gQ9NoHma4H7bM@b=Z`^yaGDLwwUAS-V#TWc z8=4^$Qv_B&2aUeFpjXmN_r;tz_VR;UL=rEHA*D zB^VZO9_g^~u;*1DT+K2amL2F$eTSyRUEcJPT^r7Js3&RD!0GnXp7|T34}-mu*~8mT zvO~k~5T2oN>ElP|+&L9vS0^6~S0vxD=TGAXrC~uHSBw{h?N(-VE@V}%wiNgJzG)@L z__-cfr7Jfv&59Jee@LV)GXQS2xaS-m#(!sh4pFwm->&VZt+tL;V8-I3&)}u=Tl<(Y z{d$<|lY%Z#HLk^y?`MOh-85KqKqyh?zx&R6>sML~IU1O7J{yrIE&!f!+s$_w-x=SsP^-ZUC zr(G&E>MMeB_$tuE8v$c4xqSIT)P6XR&rJMDO8~-IP!~`4vr7;7u65QkSl~9vP>=9_ zpjh!MJwh?3O;ceI-Z69bZQlIZE9kXq0~(fV+;*;fGD%!G2{&IREgT zf!n_1$h7_dTMqj0R2?iDg}hLWTgu~{m=|onAz)8@GW+dsY_c`WjQ7L(Zf6ZZN`A=F z)aEoYPzeB&F#2sr>i~F+MtK@4*Y6ro&n0C85+rI#LAGcO2sx|4Wn+~GwQCEy_k?uq ziBa++wn2K}TBk61ZK&8)j zvGG|j7XK=sg4Yk|f%+j+$Ndp|p_N$p*)coeFGrGW24HW@OX`&L{wBd>aOglyg|Q)J ze?9@4;k%XPUA5E|IF)3)m|{w^N`cTkVZ8aO4E!8LJwH)^P$+4K%*oIeGivS3ffjqd zICWN%{G)>v5?Q#W-dA~XVbFNvg3zV;?Sx7GM3mN( z1;yEhaE@HCm&l(t5wM?$o`pSrSx8}cu+)?>{{d`@nNaQiDUp^EcG6d=ov7Lw2G{GN zVUd9u3->8OI)Mo(lKbI%p!=~st=%t2$z1c&i*8NK#HJc!W+sYmbOBm~2IZoRJqN8$ zUCuOLb8e0aT~~~LNJ|JWQnu%3D+ltlqD)SXwwk*pnBRBq?WyGE;wS&Bw0lB$uTm{neRyHU@hQCD{6N-USajx zLGR^@WkBTNn!)}|s+PXi$>E0SilfsB2mWA(7==8^tvi_5?9JB-@pBwMuD-|@dAzqU zjYBH$Cge|?93JfuF}19#3rkB&zK`it}2q6gl6af|-K7-15f>KC-vI~{V17Wn?b;kpIn2!sCo zeP?6$XO&Yr=oJX?E8}Le{2LLgSzKwjb{kTnsI3QJvt((0_w2CJRZQV!HZ^NIru1jz%5u>4X)C15TYQ@%fB9mXPEAwIXx3 zV~QQIz^2?)2V0Kc0o)*2P~~k`ja_e0M%?~(7!i^cud7sr@49D;DI!88JN~h0AH$o%n?~kz0on~^^~m*=?c-;(6cdd1xt%)s2O6 z8ESK&&nS?~$z&QGwzK_fm9jL7C-_i-O0IeG_eG%3q>^DOfNbZb9I>+}uDVo8XViXH z98|#WLk&d_n(Fkzy}LO9TWuwF8T8+=SI<5JpNcsEZ3!v%T_&NV?^~wQqLkQ zko_|clA}}3@kW5Az!#j>{toyjGYBdR zC`}2`o?j{-Xt`I281lqa(UIm_47rE$)Z{=Nb7r6+|C3w(a-el1iHhmrXJ7S8u z&gzNDuqqS4fd`B=MKM5?1s9;^(X)nEzjz+pIU8KW0${n!MYI#7qHWViH$~Ag33>`D zI}F&@BZkt)4UZ2zluJ3gd&Xwh<~Dxxpw+V?99FUoc_WROz&6+OAYPFB2|>FZWz_dR zQr9hQw-jtg9@I)sasy7d`;ClHW&Vyv*Of=!(~h%kfYrq+2ij~-=8Ot;vt7{jhgu=q zj^0XXYJ;g5lf;(PNT4A48fERLhTJ&D_l~EXjzd1XL>cy(RP}zo%nAVVT)7bSklH@Y z9g4>r1dwnwDpIgUzE%YA{oJtvLW9-OhM{&amNj-~x4)K6M2mmL`Pzmta1Wxcxk00y z<8gtiSf48R>K2Y8=jld!DvNoH+6F6)2<1Iu`(`ozx<)(6Y9k0$&9sGU7E%R$v$6Iv z#ty~o|C{zI_0u9MvphXnpfl&v-+yNgMY_-<$>2&co{xZxa}Ppob7Ab^LgGyT5m;71 zKif(Y4P>mDz-Wzsa8)gAAoP7eT{Raw!bt>doF zth$bv)qhR3XE1Zx76qmR67>NT_Fz9)(#e%`xIq~3boB`-`?ajT@righ(Z-QhvEYam zRXG9`#XIbLi?A@V8Chw$F-)feRiejcP}bz&mHmbg;9o+E;e0fH89d%X*5`iP3ErRr zrbg2*)i?w&X>1AsnDpLbZ^V&e+U%r9{2cvBv=k4SdRA5d|E4bW8QZ0Zk)SAynZIG2 za||2cG9sl-@*P$FOw1eh>}7;5HIadr(kmgU)7jwnT-evnW4};+>xA(|>;R4899egl z6*Wzww);AmB)4hj2)euI|N6LRZ;b66kenn7EIq^+T7E5j!L_PV1on+8NVp+AWg!8E zN%Bg);#tlxx%%{2TMjo&2%?G9D7hy?l^P3#4`D#%ofzjDjDzEc%JCilG?Yt8To10rCK%iiM z(?kpuf!`*mXY{F&<=&6$7S*qiZjHMujwXi|)ECb>M#Vrx$axaTzy7 zd9M00hNW4KUeD&RpbJss=wG6%=~&rTm%K&&_NM4iB3YlMNh)?U-vTiaV@jjO23pKr zq0><2Pdm@t!~T(a{NTkcux~_V&{tk+zq>mxYVtiBJpWRNVV314S0vy{$LO1 c$2EHb`VdpYv#hFJ^AG=qo{4USj#JG40IY7>00000 literal 0 HcmV?d00001 From dc3db9d49b6bb89f3a1581735f5af011c04c1ada Mon Sep 17 00:00:00 2001 From: James Hall Date: Wed, 7 Jan 2026 05:09:16 +0000 Subject: [PATCH 02/37] woops i replaced all "json" with "registry" by accident --- .../dev/amble/lib/animation/client/AnimationMetadata.java | 2 +- .../amble/lib/client/bedrock/BedrockAnimationAdapter.java | 2 +- .../lib/client/bedrock/BedrockAnimationRegistry.java | 8 ++++---- .../amble/lib/client/bedrock/BedrockModelRegistry.java | 8 ++++---- .../dev/amble/lib/datagen/lang/AmbleLanguageProvider.java | 2 +- .../dev/amble/lib/datagen/sound/AmbleSoundProvider.java | 4 ++-- .../lib/register/datapack/SimpleDatapackRegistry.java | 4 ++-- src/main/java/dev/amble/lib/skin/SkinTracker.java | 6 +++--- src/main/java/dev/amble/lib/skin/client/SkinCache.java | 4 ++-- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java b/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java index 046ca8d..59a71a7 100644 --- a/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java +++ b/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java @@ -13,7 +13,7 @@ /** * Metadata for animations, controlling how they behave in certain situations. - * in "filename.metadata.registry" + * in "filename.metadata.json" * @param movement Whether the animation should allow player movement. Default: true * @param perspective The perspective the animation should play in. Default: null (all perspectives) * @param fpsCamera Whether the animation should have FPS camera controls. Default: true diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java index e0075a4..ce0b9bf 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java @@ -34,7 +34,7 @@ public BedrockAnimationAdapter() {} @Override public BedrockAnimation deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { if (!json.isJsonObject()) { - throw new IllegalStateException("animation registry could not be parsed"); + throw new IllegalStateException("animation json could not be parsed"); } JsonObject jsonObj = json.getAsJsonObject(); diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationRegistry.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationRegistry.java index f34b7f1..dbec2cf 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationRegistry.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationRegistry.java @@ -61,12 +61,12 @@ public void reload(ResourceManager manager) { int animationCount = 0; groups.clear(); - for (Identifier rawId : manager.findResources("bedrock", filename -> filename.getPath().endsWith(".animation.registry")).keySet()) { + for (Identifier rawId : manager.findResources("bedrock", filename -> filename.getPath().endsWith(".animation.json")).keySet()) { try (InputStream stream = manager.getResource(rawId).get().getInputStream()) { JsonObject json = JsonParser.parseReader(new InputStreamReader(stream)).getAsJsonObject(); @Nullable JsonObject metadata = null; - Identifier metadataId = Identifier.of(rawId.getNamespace(), rawId.getPath().replaceFirst("\\.animation\\.json$", ".metadata.registry")); + Identifier metadataId = Identifier.of(rawId.getNamespace(), rawId.getPath().replaceFirst("\\.animation\\.json$", ".metadata.json")); if (manager.getResource(metadataId).isPresent()) { try (InputStream metaStream = manager.getResource(metadataId).get().getInputStream()) { metadata = JsonParser.parseReader(new InputStreamReader(metaStream)).getAsJsonObject(); @@ -88,11 +88,11 @@ public void reload(ResourceManager manager) { group.animations.forEach((name, animation) -> animation.name = name); - String groupName = rawId.getPath().substring(rawId.getPath().lastIndexOf("/") + 1).replace(".animation.registry", ""); + String groupName = rawId.getPath().substring(rawId.getPath().lastIndexOf("/") + 1).replace(".animation.json", ""); groups.put(groupName, group); animationCount += group.animations.size(); } catch (Exception e) { - AmbleKit.LOGGER.error("Error occurred while loading resource registry {}", rawId.toString(), e); + AmbleKit.LOGGER.error("Error occurred while loading resource json {}", rawId.toString(), e); } } diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockModelRegistry.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockModelRegistry.java index b2f2add..229610c 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockModelRegistry.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockModelRegistry.java @@ -40,11 +40,11 @@ public Identifier getFabricId() { public void reload(ResourceManager manager) { clearCache(); - for (Identifier rawId : manager.findResources("bedrock", filename -> filename.getPath().endsWith("geo.registry")).keySet()) { + for (Identifier rawId : manager.findResources("bedrock", filename -> filename.getPath().endsWith("geo.json")).keySet()) { try (InputStream stream = manager.getResource(rawId).get().getInputStream()) { String path = rawId.getPath(); - // remove "bedrock/" prefix and ".geo.registry" suffix - String idPath = path.substring("bedrock/".length(), path.length() - ".geo.registry".length()); + // remove "bedrock/" prefix and ".geo.json" suffix + String idPath = path.substring("bedrock/".length(), path.length() - ".geo.json".length()); Identifier id = Identifier.of(rawId.getNamespace(), idPath); JsonObject json = JsonParser.parseReader(new InputStreamReader(stream)).getAsJsonObject(); @@ -54,7 +54,7 @@ public void reload(ResourceManager manager) { AmbleKit.LOGGER.debug("Loaded bedrock model {} {}", id, model); } catch (Exception e) { - AmbleKit.LOGGER.error("Error occurred while loading resource registry {}", rawId.toString(), e); + AmbleKit.LOGGER.error("Error occurred while loading resource json {}", rawId.toString(), e); } } } diff --git a/src/main/java/dev/amble/lib/datagen/lang/AmbleLanguageProvider.java b/src/main/java/dev/amble/lib/datagen/lang/AmbleLanguageProvider.java index 27f236f..8d96109 100644 --- a/src/main/java/dev/amble/lib/datagen/lang/AmbleLanguageProvider.java +++ b/src/main/java/dev/amble/lib/datagen/lang/AmbleLanguageProvider.java @@ -38,7 +38,7 @@ public void generateTranslations(TranslationBuilder builder) { } output.getModContainer() - .findPath("assets/" + modid + "/lang/" + language.name().toLowerCase() + ".existing.registry") + .findPath("assets/" + modid + "/lang/" + language.name().toLowerCase() + ".existing.json") .ifPresent(existingFilePath -> { try { builder.add(existingFilePath); diff --git a/src/main/java/dev/amble/lib/datagen/sound/AmbleSoundProvider.java b/src/main/java/dev/amble/lib/datagen/sound/AmbleSoundProvider.java index 41b2643..67d33b5 100644 --- a/src/main/java/dev/amble/lib/datagen/sound/AmbleSoundProvider.java +++ b/src/main/java/dev/amble/lib/datagen/sound/AmbleSoundProvider.java @@ -20,7 +20,7 @@ import dev.amble.lib.util.StringCursor; /** - * Datagen Provider for sounds, this class is used to generate the sounds.registry file for the mod + * Datagen Provider for sounds, this class is used to generate the sounds.json file for the mod */ public class AmbleSoundProvider implements DataProvider { @@ -95,7 +95,7 @@ public CompletableFuture run(DataWriter writer) { public Path getOutputPath() { return dataOutput.resolvePath(DataOutput.OutputType.RESOURCE_PACK).resolve(dataOutput.getModId()) - .resolve("sounds.registry"); + .resolve("sounds.json"); } @Override diff --git a/src/main/java/dev/amble/lib/register/datapack/SimpleDatapackRegistry.java b/src/main/java/dev/amble/lib/register/datapack/SimpleDatapackRegistry.java index 59e3adf..5cb7eef 100644 --- a/src/main/java/dev/amble/lib/register/datapack/SimpleDatapackRegistry.java +++ b/src/main/java/dev/amble/lib/register/datapack/SimpleDatapackRegistry.java @@ -137,7 +137,7 @@ public void reload(ResourceManager manager) { this.defaults(); for (Identifier id : manager - .findResources(this.name.getPath(), filename -> filename.getPath().endsWith(".registry")).keySet()) { + .findResources(this.name.getPath(), filename -> filename.getPath().endsWith(".json")).keySet()) { try (InputStream stream = manager.getResource(id).get().getInputStream()) { T created = this.read(stream); @@ -149,7 +149,7 @@ public void reload(ResourceManager manager) { this.register(created); AmbleKit.LOGGER.info("Loaded datapack {} {}", this.name, created.id().toString()); } catch (Exception e) { - AmbleKit.LOGGER.error("Error occurred while loading resource registry {}", id.toString(), e); + AmbleKit.LOGGER.error("Error occurred while loading resource json {}", id.toString(), e); } } diff --git a/src/main/java/dev/amble/lib/skin/SkinTracker.java b/src/main/java/dev/amble/lib/skin/SkinTracker.java index 417d632..ed72120 100644 --- a/src/main/java/dev/amble/lib/skin/SkinTracker.java +++ b/src/main/java/dev/amble/lib/skin/SkinTracker.java @@ -147,7 +147,7 @@ public void sync(ServerPlayerEntity target) { } private static Path getSavePath(MinecraftServer server) { - return server.getSavePath(WorldSavePath.ROOT).resolve("amblekit").resolve("skins.registry"); + return server.getSavePath(WorldSavePath.ROOT).resolve("amblekit").resolve("skins.json"); } private void write(MinecraftServer server) { @@ -159,7 +159,7 @@ private void write(MinecraftServer server) { Files.writeString(savePath, AmbleKit.GSON.toJson(this, SkinTracker.class)); } catch (Exception e) { - AmbleKit.LOGGER.error("Failed to write skins.registry", e); + AmbleKit.LOGGER.error("Failed to write skins.json", e); } } @@ -172,7 +172,7 @@ private static void read(MinecraftServer server) { INSTANCE = AmbleKit.GSON.fromJson(object, SkinTracker.class); INSTANCE.sync(); } catch (Exception e) { - AmbleKit.LOGGER.error("Failed to read skins.registry", e); + AmbleKit.LOGGER.error("Failed to read skins.json", e); } } } diff --git a/src/main/java/dev/amble/lib/skin/client/SkinCache.java b/src/main/java/dev/amble/lib/skin/client/SkinCache.java index 1e0948e..0fe7617 100644 --- a/src/main/java/dev/amble/lib/skin/client/SkinCache.java +++ b/src/main/java/dev/amble/lib/skin/client/SkinCache.java @@ -15,12 +15,12 @@ import dev.amble.lib.AmbleKit; /** - * A handler for the skin cache files, stored in a .registry file + * A handler for the skin cache files, stored in a .json file * Stores the key and the URL of the skin */ public class SkinCache { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); - private static final String CACHE_FILE = "cache.registry"; + private static final String CACHE_FILE = "cache.json"; private boolean locked; private ArrayList data; From a4915b3248ad9d50f25d6d4d7181c5888464f12f Mon Sep 17 00:00:00 2001 From: James Hall Date: Wed, 7 Jan 2026 20:37:08 +0000 Subject: [PATCH 03/37] add builders for each ui elements, add AmbleContainer#copyFrom to remove bloat --- .../dev/amble/lib/client/gui/AmbleButton.java | 72 ++++++++--- .../amble/lib/client/gui/AmbleContainer.java | 118 ++++++++++++++++-- .../dev/amble/lib/client/gui/AmbleText.java | 51 +++++--- .../client/gui/registry/AmbleGuiRegistry.java | 6 +- .../litmus/commands/TestScreenCommand.java | 12 +- 5 files changed, 199 insertions(+), 60 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java index cef3919..9fd99ba 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java @@ -18,26 +18,6 @@ public class AmbleButton extends AmbleContainer { private @Nullable AmbleDisplayType normalDisplay = null; private boolean isClicked = false; - public static AmbleButton of(AmbleContainer container, AmbleDisplayType hoverColor, AmbleDisplayType pressColor, Runnable onClick) { - AmbleButton button = new AmbleButton(); - button.setPosition(container.getPosition()); - button.setVisible(container.isVisible()); - button.setLayout(container.getLayout()); - button.setPreferredLayout(container.getPreferredLayout()); - button.setParent(container.getParent()); - button.setPadding(container.getPadding()); - button.setSpacing(container.getSpacing()); - button.setHorizontalAlign(container.getHorizontalAlign()); - button.setVerticalAlign(container.getVerticalAlign()); - button.setRequiresNewRow(container.requiresNewRow()); - button.setBackground(container.getBackground()); - - button.hoverDisplay = hoverColor; - button.pressDisplay = pressColor; - button.onClick = onClick; - - return button; - } @Override public void onRelease(double mouseX, double mouseY, int button) { @@ -74,4 +54,56 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { return normalDisplay; } + + public static Builder buttonBuilder() { + return new Builder(); + } + + public static class Builder extends AbstractBuilder { + + @Override + protected AmbleButton create() { + return new AmbleButton(); + } + + @Override + protected Builder self() { + return this; + } + + public Builder hoverDisplay(AmbleDisplayType hoverDisplay) { + container.setHoverDisplay(hoverDisplay); + return this; + } + + public Builder hoverDisplay(Color hoverColor) { + container.setHoverDisplay(AmbleDisplayType.color(hoverColor)); + return this; + } + + public Builder hoverDisplay(AmbleDisplayType.TextureData hoverTexture) { + container.setHoverDisplay(AmbleDisplayType.texture(hoverTexture)); + return this; + } + + public Builder pressDisplay(AmbleDisplayType pressDisplay) { + container.setPressDisplay(pressDisplay); + return this; + } + + public Builder pressDisplay(Color pressColor) { + container.setPressDisplay(AmbleDisplayType.color(pressColor)); + return this; + } + + public Builder pressDisplay(AmbleDisplayType.TextureData pressTexture) { + container.setPressDisplay(AmbleDisplayType.texture(pressTexture)); + return this; + } + + public Builder onClick(Runnable onClick) { + container.setOnClick(onClick); + return this; + } + } } diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java index 22de180..78298ce 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java @@ -1,7 +1,6 @@ package dev.amble.lib.client.gui; import dev.amble.lib.AmbleKit; -import dev.amble.lib.client.AmbleKitClient; import lombok.*; import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.screen.Screen; @@ -14,12 +13,10 @@ import java.util.List; @Getter -@Builder @AllArgsConstructor @NoArgsConstructor public class AmbleContainer implements AmbleElement { @Setter - @Builder.Default private boolean visible = true; @Setter @@ -30,7 +27,6 @@ public class AmbleContainer implements AmbleElement { @Setter @Nullable - @Builder.Default private AmbleElement parent = null; @Setter @@ -40,31 +36,24 @@ public class AmbleContainer implements AmbleElement { private int spacing; @Setter - @Builder.Default private UIAlign horizontalAlign = UIAlign.START; @Setter - @Builder.Default private UIAlign verticalAlign = UIAlign.START; @Setter - @Builder.Default private boolean requiresNewRow = false; @Setter - @Builder.Default private Text title = Text.empty(); @Setter - @Builder.Default public AmbleDisplayType background = AmbleDisplayType.color(Color.WHITE); @Setter private Identifier identifier; - @Builder.Default private @Nullable Screen convertedScreen = null; - @Builder.Default private final List children = new ArrayList<>(); @Override @@ -124,12 +113,29 @@ public void display() { net.minecraft.client.MinecraftClient.getInstance().setScreen(screen); } + public void copyFrom(AmbleContainer other) { + this.visible = other.visible; + this.layout = other.layout; + this.preferredLayout = other.preferredLayout; + this.parent = other.parent; + this.padding = other.padding; + this.spacing = other.spacing; + this.horizontalAlign = other.horizontalAlign; + this.verticalAlign = other.verticalAlign; + this.requiresNewRow = other.requiresNewRow; + this.background = other.background; + } + + public static Builder builder() { + return new Builder(); + } + public static AmbleContainer primaryContainer() { return AmbleContainer.builder() - .preferredLayout(new Rectangle(0, 0, + .layout(new Rectangle(0, 0, net.minecraft.client.MinecraftClient.getInstance().getWindow().getScaledWidth(), net.minecraft.client.MinecraftClient.getInstance().getWindow().getScaledHeight())) - .background(AmbleDisplayType.color(new Color(0, 0, 0, 0))) + .background(new Color(0, 0, 0, 0)) .build(); } @@ -160,4 +166,90 @@ public boolean mouseReleased(double mouseX, double mouseY, int button) { return super.mouseReleased(mouseX, mouseY, button); } } + + public static class Builder extends AbstractBuilder { + @Override + protected AmbleContainer create() { + return new AmbleContainer(); + } + + @Override + protected Builder self() { + return this; + } + } + + public static abstract class AbstractBuilder> { + protected final T container = create(); + + protected abstract T create(); + protected abstract B self(); + + public B padding(int padding) { + container.setPadding(padding); + return self(); + } + + public B spacing(int spacing) { + container.setSpacing(spacing); + return self(); + } + + public B horizontalAlign(UIAlign align) { + container.setHorizontalAlign(align); + return self(); + } + + public B verticalAlign(UIAlign align) { + container.setVerticalAlign(align); + return self(); + } + + public B layout(Rectangle layout) { + container.setPreferredLayout(layout); + container.setLayout(layout); + return self(); + } + + public B background(AmbleDisplayType background) { + container.setBackground(background); + return self(); + } + + public B background(Color color) { + container.setBackground(AmbleDisplayType.color(color)); + return self(); + } + + public B background(AmbleDisplayType.TextureData texture) { + container.setBackground(AmbleDisplayType.texture(texture)); + return self(); + } + + public B title(Text title) { + container.setTitle(title); + return self(); + } + + public B requiresNewRow(boolean requiresNewRow) { + container.setRequiresNewRow(requiresNewRow); + return self(); + } + + public B visible(boolean visible) { + container.setVisible(visible); + return self(); + } + + public B children(List children) { + for (AmbleElement child : children) { + container.addChild(child); + } + return self(); + } + + public T build() { + return container; + } + } } diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleText.java b/src/main/java/dev/amble/lib/client/gui/AmbleText.java index 4a6bee3..ea033ba 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleText.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleText.java @@ -20,25 +20,6 @@ public class AmbleText extends AmbleContainer { private UIAlign textHorizontalAlign = UIAlign.CENTRE; private UIAlign textVerticalAlign = UIAlign.CENTRE; - public static AmbleText of(AmbleContainer container, Text text) { - AmbleText ambleText = new AmbleText(); - ambleText.setPosition(container.getPosition()); - ambleText.setVisible(container.isVisible()); - ambleText.setLayout(container.getLayout()); - ambleText.setPreferredLayout(container.getPreferredLayout()); - ambleText.setParent(container.getParent()); - ambleText.setPadding(container.getPadding()); - ambleText.setSpacing(container.getSpacing()); - ambleText.setHorizontalAlign(container.getHorizontalAlign()); - ambleText.setVerticalAlign(container.getVerticalAlign()); - ambleText.setRequiresNewRow(container.requiresNewRow()); - ambleText.setBackground(container.getBackground()); - - ambleText.setText(text); - - return ambleText; - } - @Override public void render(DrawContext context, int mouseX, int mouseY, float delta) { super.render(context, mouseX, mouseY, delta); @@ -76,4 +57,36 @@ public static void drawTextWrappedWithShadow(DrawContext context, TextRenderer t y += 9; } } + + public static Builder textBuilder() { + return new Builder(); + } + + public static class Builder extends AbstractBuilder { + + @Override + protected AmbleText create() { + return new AmbleText(); + } + + @Override + protected Builder self() { + return this; + } + + public Builder text(Text text) { + container.setText(text); + return this; + } + + public Builder textHorizontalAlign(UIAlign align) { + container.setTextHorizontalAlign(align); + return this; + } + + public Builder textVerticalAlign(UIAlign align) { + container.setTextVerticalAlign(align); + return this; + } + } } diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index 251e414..4e3050f 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -114,12 +114,14 @@ public static AmbleContainer parse(JsonObject json) { requiresNewRow = json.get("requires_new_row").getAsBoolean(); } - AmbleContainer created = AmbleContainer.builder().background(background).layout(layout).preferredLayout(layout).padding(padding).spacing(spacing).horizontalAlign(horizAlign).verticalAlign(vertAlign).children(children).requiresNewRow(requiresNewRow).build(); + AmbleContainer created = AmbleContainer.builder().background(background).layout(layout).padding(padding).spacing(spacing).horizontalAlign(horizAlign).verticalAlign(vertAlign).children(children).requiresNewRow(requiresNewRow).build(); // TODO - buttons if (json.has("text")) { String text = json.get("text").getAsString(); - created = AmbleText.of(created, Text.translatable(text)); + AmbleText ambleText = AmbleText.textBuilder().text(Text.translatable(text)).build(); + ambleText.copyFrom(created); + created = ambleText; UIAlign textHorizAlign = UIAlign.CENTRE; UIAlign textVertAlign = UIAlign.CENTRE; diff --git a/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java b/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java index f3af3d1..64164f7 100644 --- a/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java +++ b/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java @@ -27,13 +27,13 @@ public static void register(CommandDispatcher dispatc MinecraftClient.getInstance().execute(() -> { ClientScheduler.get().runTaskLater(() -> { - var container = AmbleContainer.builder().preferredLayout(new Rectangle(0,0, 216, 138)).background(AmbleDisplayType.texture(new AmbleDisplayType.TextureData(new Identifier(LitmusMod.MOD_ID, "textures/gui/test_screen.png"), 0, 0, 216, 138, 256, 256))).build(); - var child1 = AmbleContainer.builder().preferredLayout(new Rectangle(0,0, 50, 50)).background(AmbleDisplayType.color(Color.BLUE)).build(); - var child2 = AmbleContainer.builder().preferredLayout(new Rectangle(0,0, 25, 25)).background(AmbleDisplayType.color(Color.ORANGE)).build(); - AmbleButton child3 = AmbleButton.of(AmbleContainer.builder().preferredLayout(new Rectangle(0,0, 75, 40)).horizontalAlign(UIAlign.CENTRE).background(AmbleDisplayType.color(Color.GREEN)).build(), AmbleDisplayType.color(Color.YELLOW), AmbleDisplayType.color(Color.RED), () -> { + var container = AmbleContainer.builder().layout(new Rectangle(0,0, 216, 138)).background(AmbleDisplayType.texture(new AmbleDisplayType.TextureData(new Identifier(LitmusMod.MOD_ID, "textures/gui/test_screen.png"), 0, 0, 216, 138, 256, 256))).build(); + var child1 = AmbleContainer.builder().layout(new Rectangle(0,0, 50, 50)).background(AmbleDisplayType.color(Color.BLUE)).build(); + var child2 = AmbleContainer.builder().layout(new Rectangle(0,0, 25, 25)).background(AmbleDisplayType.color(Color.ORANGE)).build(); + AmbleButton child3 = AmbleButton.buttonBuilder().layout(new Rectangle(0,0, 75, 40)).horizontalAlign(UIAlign.CENTRE).background(Color.GREEN).hoverDisplay(Color.YELLOW).pressDisplay(Color.RED).onClick(() -> { System.out.println("Button Clicked!"); - }); - AmbleText child4 = AmbleText.of(AmbleContainer.builder().background(AmbleDisplayType.color(new Color(0,0,0,0))).build(), Text.literal("press me")); + }).build(); + AmbleText child4 = AmbleText.textBuilder().background(AmbleDisplayType.color(new Color(0,0,0,0))).text(Text.literal("press me")).build(); child4.setPreferredLayout(child3.getPreferredLayout()); child3.addChild(child4); container.setPadding(10); From de119c145036b36599eda5eb0ea3f95abbad1fd2 Mon Sep 17 00:00:00 2001 From: James Hall Date: Wed, 7 Jan 2026 20:42:30 +0000 Subject: [PATCH 04/37] remove stupid lambda --- .../amble/lib/client/gui/AmbleElement.java | 126 +++++++++++------- 1 file changed, 76 insertions(+), 50 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleElement.java b/src/main/java/dev/amble/lib/client/gui/AmbleElement.java index 334993c..b613c1b 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleElement.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleElement.java @@ -4,6 +4,7 @@ import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.Drawable; import net.minecraft.util.Identifier; +import net.minecraft.util.Pair; import net.minecraft.util.math.Vec2f; import org.jetbrains.annotations.Nullable; @@ -67,64 +68,74 @@ default void addChild(AmbleElement child) { } } - default void recalcuateLayout() { - - int startX = getLayout().x + getPadding(); - int maxWidth = getLayout().width - getPadding() * 2; - - int cursorX = startX; - final int[] cursorY = {getLayout().y + getPadding()}; - final int[] rowHeight = {0}; + private Pair layoutRow( + List row, + int startX, + int maxWidth, + int cursorY, + int rowHeight + ) { + if (row.isEmpty()) return new Pair<>(cursorY, rowHeight); + + int rowWidth = row.stream() + .mapToInt(e -> e.getPreferredLayout().width) + .sum() + + getSpacing() * (row.size() - 1); + + int offsetX = switch (row.get(0).getHorizontalAlign()) { + case CENTRE -> (maxWidth - rowWidth) / 2; + case END -> maxWidth - rowWidth; + default -> 0; + }; - List row = new ArrayList<>(); + int x = startX + offsetX; + boolean singleElementFullCenter = + row.size() == 1 && + row.get(0).getVerticalAlign() == UIAlign.CENTRE; - Runnable layoutRow = () -> - { - if (row.isEmpty()) return; + int innerHeight = getLayout().height - getPadding() * 2; - int rowWidth = row.stream().mapToInt(e -> e.getPreferredLayout().width).sum() - + getSpacing() * (row.size() - 1); + for (var e : row) { + int y; - int offsetX = switch (row.get(0).getHorizontalAlign()) { - case CENTRE -> (maxWidth - rowWidth) / 2; - case END -> maxWidth - rowWidth; - default -> 0; - }; + if (singleElementFullCenter) { + y = getLayout().y + getPadding() + + (innerHeight - e.getPreferredLayout().height) / 2; + } else { + y = cursorY; - int x = startX + offsetX; - boolean singleElementFullCenter = - row.size() == 1 && - row.get(0).getVerticalAlign() == UIAlign.CENTRE; - int innerHeight = getLayout().height - getPadding() * 2; + if (e.getVerticalAlign() == UIAlign.CENTRE) + y += (rowHeight - e.getPreferredLayout().height) / 2; + else if (e.getVerticalAlign() == UIAlign.END) + y += rowHeight - e.getPreferredLayout().height; + } - for (var e : row) { - int y; + e.setLayout(new Rectangle( + x, y, + e.getPreferredLayout().width, + e.getPreferredLayout().height + )); - if (singleElementFullCenter) { - y = getLayout().y + getPadding() - + (innerHeight - e.getPreferredLayout().height) / 2; - } else { - y = cursorY[0]; + e.recalcuateLayout(); + x += e.getPreferredLayout().width + getSpacing(); + } - if (e.getVerticalAlign() == UIAlign.CENTRE) - y += (rowHeight[0] - e.getPreferredLayout().height) / 2; - else if (e.getVerticalAlign() == UIAlign.END) - y += rowHeight[0] - e.getPreferredLayout().height; - } + cursorY += rowHeight + getSpacing(); + row.clear(); + rowHeight = 0; + return new Pair<>(cursorY, rowHeight); + } - e.setLayout(new Rectangle(x, y, - e.getPreferredLayout().width, - e.getPreferredLayout().height)); + default void recalcuateLayout() { - e.recalcuateLayout(); + int startX = getLayout().x + getPadding(); + int maxWidth = getLayout().width - getPadding() * 2; - x += e.getPreferredLayout().width + getSpacing(); - } + int cursorX = startX; + int cursorY = getLayout().y + getPadding(); + int rowHeight = 0; - cursorY[0] += rowHeight[0] + getSpacing(); - row.clear(); - rowHeight[0] = 0; - }; + List row = new ArrayList<>(); for (var child : getChildren()) { @@ -133,7 +144,15 @@ else if (e.getVerticalAlign() == UIAlign.END) if (cursorX + w > startX + maxWidth || child.requiresNewRow()) { - layoutRow.run(); + Pair result = layoutRow( + row, + startX, + maxWidth, + cursorY, + rowHeight + ); + cursorY = result.getLeft(); + rowHeight = result.getRight(); cursorX = startX; } @@ -143,11 +162,18 @@ else if (e.getVerticalAlign() == UIAlign.END) child.recalcuateLayout(); cursorX += w + getSpacing(); - rowHeight[0] = Math.max(rowHeight[0], h); + rowHeight = Math.max(rowHeight, h); } - if (!row.isEmpty()) - layoutRow.run(); + if (!row.isEmpty()) { + layoutRow( + row, + startX, + maxWidth, + cursorY, + rowHeight + ); + } } @Override From fb1d0bfcf358fb0247eafcee57d678c2d23d0804 Mon Sep 17 00:00:00 2001 From: James Hall Date: Wed, 7 Jan 2026 20:43:23 +0000 Subject: [PATCH 05/37] remove LVTI --- .../java/dev/amble/lib/client/gui/AmbleDisplayType.java | 6 ++++-- .../amble/lib/client/gui/registry/AmbleGuiRegistry.java | 9 +++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java b/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java index bd3e2d4..4e32fdf 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java @@ -1,6 +1,8 @@ package dev.amble.lib.client.gui; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import dev.amble.lib.api.Identifiable; import net.minecraft.client.gui.DrawContext; import net.minecraft.util.Identifier; @@ -35,14 +37,14 @@ public static AmbleDisplayType texture(TextureData identifier) { public static AmbleDisplayType parse(JsonElement element) { if (element.isJsonArray()) { // parse 3 element array as RGB color, 4th element optional alpha - var arr = element.getAsJsonArray(); + JsonArray arr = element.getAsJsonArray(); int r = arr.get(0).getAsInt(); int g = arr.get(1).getAsInt(); int b = arr.get(2).getAsInt(); int a = arr.size() > 3 ? arr.get(3).getAsInt() : 255; return AmbleDisplayType.color(new Color(r, g, b, a)); } else if (element.isJsonObject()) { - var obj = element.getAsJsonObject(); + JsonObject obj = element.getAsJsonObject(); Identifier texture = new Identifier(obj.get("texture").getAsString()); int u = obj.get("u").getAsInt(); int v = obj.get("v").getAsInt(); diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index 4e3050f..9ddcfd8 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -1,5 +1,6 @@ package dev.amble.lib.client.gui.registry; +import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import dev.amble.lib.AmbleKit; @@ -49,7 +50,7 @@ public static AmbleContainer parse(JsonObject json) { Rectangle layout = new Rectangle(); if (json.has("layout") && json.get("layout").isJsonArray()) { - var layoutArray = json.get("layout").getAsJsonArray(); + JsonArray layoutArray = json.get("layout").getAsJsonArray(); layout.setSize(layoutArray.get(0).getAsInt(), layoutArray.get(1).getAsInt()); } else { throw new IllegalStateException("Amble container is missing layout data"); @@ -72,7 +73,7 @@ public static AmbleContainer parse(JsonObject json) { throw new IllegalStateException("UI Alignment must be array [horizontal, vertical]"); } - var alignmentArray = json.get("alignment").getAsJsonArray(); + JsonArray alignmentArray = json.get("alignment").getAsJsonArray(); String horizAlignKey = alignmentArray.get(0).getAsString(); String vertAlignKey = alignmentArray.get(1).getAsString(); @@ -95,7 +96,7 @@ public static AmbleContainer parse(JsonObject json) { throw new IllegalStateException("UI children should be an object array of other ui elements"); } - var childrenArray = json.get("children").getAsJsonArray(); + JsonArray childrenArray = json.get("children").getAsJsonArray(); for (int i = 0; i < childrenArray.size(); i++) { if (!(childrenArray.get(i).isJsonObject())) { @@ -130,7 +131,7 @@ public static AmbleContainer parse(JsonObject json) { throw new IllegalStateException("UI text Alignment must be array [horizontal, vertical]"); } - var alignmentArray = json.get("text_alignment").getAsJsonArray(); + JsonArray alignmentArray = json.get("text_alignment").getAsJsonArray(); String horizAlignKey = alignmentArray.get(0).getAsString(); String vertAlignKey = alignmentArray.get(1).getAsString(); From c605ad1e26924e93f745db4fca27587008117a3a Mon Sep 17 00:00:00 2001 From: James Hall Date: Wed, 7 Jan 2026 20:44:14 +0000 Subject: [PATCH 06/37] fallback msg --- .../java/dev/amble/lib/client/gui/AmbleContainer.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java index 78298ce..fcf8668 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java @@ -91,10 +91,16 @@ public Rectangle getLayout() { } public Rectangle getPreferredLayout() { - if (preferredLayout == null) return layout != null ? layout : new Rectangle(0, 0, 100, 100); + if (preferredLayout == null) return layout != null ? layout : fallbackLayout(); return preferredLayout; } + protected Rectangle fallbackLayout() { + AmbleKit.LOGGER.error("GUI element {} is missing layout data, using fallback layout", id()); + + return new Rectangle(0, 0, 100, 100); + } + public Screen toScreen() { if (this.convertedScreen == null) { this.convertedScreen = createScreen(); From 34c794faa68b8e10c346f0d629f2d648dd313221 Mon Sep 17 00:00:00 2001 From: James Hall Date: Wed, 7 Jan 2026 20:47:24 +0000 Subject: [PATCH 07/37] add json data to error msg so they know which block it is --- .../client/gui/registry/AmbleGuiRegistry.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index 9ddcfd8..294386d 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -45,7 +45,7 @@ public static AmbleContainer parse(JsonObject json) { if (json.has("background")) { background = AmbleDisplayType.parse(json.get("background")); } else { - throw new IllegalStateException("Amble container is missing background data"); + throw new IllegalStateException("Amble container is missing background data | " + json); } Rectangle layout = new Rectangle(); @@ -53,7 +53,7 @@ public static AmbleContainer parse(JsonObject json) { JsonArray layoutArray = json.get("layout").getAsJsonArray(); layout.setSize(layoutArray.get(0).getAsInt(), layoutArray.get(1).getAsInt()); } else { - throw new IllegalStateException("Amble container is missing layout data"); + throw new IllegalStateException("Amble container is missing layout data | " + json); } int padding = 0; @@ -70,7 +70,7 @@ public static AmbleContainer parse(JsonObject json) { UIAlign vertAlign = UIAlign.START; if (json.has("alignment")) { if (!json.get("alignment").isJsonArray()) { - throw new IllegalStateException("UI Alignment must be array [horizontal, vertical]"); + throw new IllegalStateException("UI Alignment must be array [horizontal, vertical] | " + json); } JsonArray alignmentArray = json.get("alignment").getAsJsonArray(); @@ -93,14 +93,14 @@ public static AmbleContainer parse(JsonObject json) { List children = new ArrayList<>(); if (json.has("children")) { if (!json.get("children").isJsonArray()) { - throw new IllegalStateException("UI children should be an object array of other ui elements"); + throw new IllegalStateException("UI children should be an object array of other ui elements | " + json); } JsonArray childrenArray = json.get("children").getAsJsonArray(); for (int i = 0; i < childrenArray.size(); i++) { if (!(childrenArray.get(i).isJsonObject())) { - throw new IllegalStateException("UI child at index " + i + " is invalid, got " + childrenArray.get(i)); + throw new IllegalStateException("UI child at index " + i + " is invalid, got " + childrenArray.get(i) + " | " + json); } children.add(parse(childrenArray.get(i).getAsJsonObject())); @@ -110,7 +110,7 @@ public static AmbleContainer parse(JsonObject json) { boolean requiresNewRow = false; if (json.has("requires_new_row")) { if (!json.get("requires_new_row").isJsonPrimitive()) { - throw new IllegalStateException("UI requires_new_row should be boolean"); + throw new IllegalStateException("UI requires_new_row should be boolean | " + json); } requiresNewRow = json.get("requires_new_row").getAsBoolean(); } @@ -128,7 +128,7 @@ public static AmbleContainer parse(JsonObject json) { UIAlign textVertAlign = UIAlign.CENTRE; if (json.has("text_alignment")) { if (!json.get("text_alignment").isJsonArray()) { - throw new IllegalStateException("UI text Alignment must be array [horizontal, vertical]"); + throw new IllegalStateException("UI text Alignment must be array [horizontal, vertical] | " + json); } JsonArray alignmentArray = json.get("text_alignment").getAsJsonArray(); @@ -166,7 +166,7 @@ public void reload(ResourceManager manager) { AmbleKit.LOGGER.debug("Loaded AmbleContainer {} {}", id, model); } catch (Exception e) { - AmbleKit.LOGGER.error("Error occurred while loading resource registry {}", rawId.toString(), e); + AmbleKit.LOGGER.error("Error occurred while loading resource json {}", rawId.toString(), e); } } } From 62b7c7eb6261e24ee853cbf5069a210713790489 Mon Sep 17 00:00:00 2001 From: James Hall Date: Wed, 7 Jan 2026 21:24:07 +0000 Subject: [PATCH 08/37] json buttons & command running buttons --- .../amble/lib/client/gui/AmbleContainer.java | 19 ++++++- .../client/gui/registry/AmbleGuiRegistry.java | 56 ++++++++++++++++++- .../resources/assets/litmus/gui/test.json | 13 ++++- 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java index fcf8668..d3f367f 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java @@ -48,7 +48,10 @@ public class AmbleContainer implements AmbleElement { private Text title = Text.empty(); @Setter - public AmbleDisplayType background = AmbleDisplayType.color(Color.WHITE); + private AmbleDisplayType background = AmbleDisplayType.color(Color.WHITE); + + @Setter + private boolean shouldPause = false; @Setter private Identifier identifier; @@ -115,6 +118,7 @@ protected Screen createScreen() { public void display() { var primary = AmbleContainer.primaryContainer(); primary.addChild(this); + primary.setShouldPause(shouldPause); Screen screen = primary.toScreen(); net.minecraft.client.MinecraftClient.getInstance().setScreen(screen); } @@ -130,6 +134,9 @@ public void copyFrom(AmbleContainer other) { this.verticalAlign = other.verticalAlign; this.requiresNewRow = other.requiresNewRow; this.background = other.background; + this.children.forEach(e -> e.setParent(null)); + this.children.clear(); + other.children.forEach(this::addChild); } public static Builder builder() { @@ -171,6 +178,11 @@ public boolean mouseReleased(double mouseX, double mouseY, int button) { source.onRelease((int) mouseX, (int) mouseY, button); return super.mouseReleased(mouseX, mouseY, button); } + + @Override + public boolean shouldPause() { + return source.isShouldPause(); + } } public static class Builder extends AbstractBuilder { @@ -254,6 +266,11 @@ public B children(List children) { return self(); } + public B shouldPause(boolean shouldPause) { + container.setShouldPause(shouldPause); + return self(); + } + public T build() { return container; } diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index 294386d..3ef6afa 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -8,6 +8,8 @@ import dev.amble.lib.register.datapack.DatapackRegistry; import net.fabricmc.fabric.api.resource.ResourceManagerHelper; import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.SharedConstants; +import net.minecraft.client.MinecraftClient; import net.minecraft.network.PacketByteBuf; import net.minecraft.resource.ResourceManager; import net.minecraft.resource.ResourceType; @@ -90,6 +92,15 @@ public static AmbleContainer parse(JsonObject json) { vertAlign = UIAlign.valueOf(vertAlignKey.toUpperCase()); } + boolean shouldPause = false; + if (json.has("should_pause")) { + if (!json.get("should_pause").isJsonPrimitive()) { + throw new IllegalStateException("UI should_pause should be boolean | " + json); + } + + shouldPause = json.get("should_pause").getAsBoolean(); + } + List children = new ArrayList<>(); if (json.has("children")) { if (!json.get("children").isJsonArray()) { @@ -115,9 +126,8 @@ public static AmbleContainer parse(JsonObject json) { requiresNewRow = json.get("requires_new_row").getAsBoolean(); } - AmbleContainer created = AmbleContainer.builder().background(background).layout(layout).padding(padding).spacing(spacing).horizontalAlign(horizAlign).verticalAlign(vertAlign).children(children).requiresNewRow(requiresNewRow).build(); + AmbleContainer created = AmbleContainer.builder().background(background).layout(layout).padding(padding).spacing(spacing).horizontalAlign(horizAlign).verticalAlign(vertAlign).children(children).shouldPause(shouldPause).requiresNewRow(requiresNewRow).build(); - // TODO - buttons if (json.has("text")) { String text = json.get("text").getAsString(); AmbleText ambleText = AmbleText.textBuilder().text(Text.translatable(text)).build(); @@ -144,6 +154,48 @@ public static AmbleContainer parse(JsonObject json) { } } + if (json.has("on_click") || json.has("hover_background") || json.has("press_background")) { + AmbleButton button = AmbleButton.buttonBuilder().build(); + button.copyFrom(created); + created = button; + + if (json.has("on_click")) { + // todo run actual java methods via reflection + String clickCommand = json.get("on_click").getAsString(); + button.setOnClick(() -> { + try { + String string2 = SharedConstants.stripInvalidChars(clickCommand); + if (string2.startsWith("/")) { + if (!MinecraftClient.getInstance().player.networkHandler.sendCommand(string2.substring(1))) { + AmbleKit.LOGGER.error("Not allowed to run command with signed argument from click event: '{}'", string2); + } + } else { + AmbleKit.LOGGER.error("Failed to run command without '/' prefix from click event: '{}'", string2); + } + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while running command from click event: '{}'", clickCommand, e); + } + }); + } else { + button.setOnClick(() -> { + }); + } + + if (json.has("hover_background")) { + AmbleDisplayType hoverBg = AmbleDisplayType.parse(json.get("hover_background")); + button.setHoverDisplay(hoverBg); + } else { + button.setHoverDisplay(button.getBackground()); + } + + if (json.has("press_background")) { + AmbleDisplayType pressBg = AmbleDisplayType.parse(json.get("press_background")); + button.setPressDisplay(pressBg); + } else { + button.setPressDisplay(button.getBackground()); + } + } + return created; } diff --git a/src/test/resources/assets/litmus/gui/test.json b/src/test/resources/assets/litmus/gui/test.json index a4803f7..afcba54 100644 --- a/src/test/resources/assets/litmus/gui/test.json +++ b/src/test/resources/assets/litmus/gui/test.json @@ -42,6 +42,17 @@ ] }, { + "on_click": "/give @s apple", + "hover_background": [ + 255, + 0, + 0 + ], + "press_background": [ + 0, + 255, + 255 + ], "layout": [ 75, 40 @@ -63,7 +74,7 @@ 0, 0 ], - "text": "hello world" + "text": "give me appel" } ] } From 802ea35f27c201983f189faa19b6209e09a8a4cc Mon Sep 17 00:00:00 2001 From: James Hall Date: Thu, 8 Jan 2026 01:32:56 +0000 Subject: [PATCH 09/37] add Lua scripting support for GUI elements - Add core Lua infrastructure (GuiScript, GuiScriptManager, LuaBinder, LuaExpose annotation) - Add LuaElement wrapper to expose AmbleElement properties to Lua scripts - Add Minecraft API bindings (MinecraftData, MinecraftEntity, LuaItemStack) - Integrate script callbacks (onInit, onClick, onRelease, onHover) into AmbleButton - Add script property support in JSON GUI definitions - Improve AmbleText with proper text wrapping and shadow toggle - Add setDimensions helper to AmbleElement --- .../dev/amble/lib/client/gui/AmbleButton.java | 78 ++++++++++- .../amble/lib/client/gui/AmbleElement.java | 13 ++ .../dev/amble/lib/client/gui/AmbleText.java | 62 ++++++--- .../amble/lib/client/gui/lua/GuiScript.java | 10 ++ .../lib/client/gui/lua/GuiScriptManager.java | 63 +++++++++ .../amble/lib/client/gui/lua/LuaBinder.java | 128 ++++++++++++++++++ .../amble/lib/client/gui/lua/LuaElement.java | 105 ++++++++++++++ .../amble/lib/client/gui/lua/LuaExpose.java | 9 ++ .../lib/client/gui/lua/mc/LuaItemStack.java | 27 ++++ .../lib/client/gui/lua/mc/MinecraftData.java | 88 ++++++++++++ .../client/gui/lua/mc/MinecraftEntity.java | 90 ++++++++++++ .../client/gui/registry/AmbleGuiRegistry.java | 22 ++- .../assets/litmus/gui/script/test.lua | 33 +++++ .../resources/assets/litmus/gui/test.json | 4 +- 14 files changed, 706 insertions(+), 26 deletions(-) create mode 100644 src/main/java/dev/amble/lib/client/gui/lua/GuiScript.java create mode 100644 src/main/java/dev/amble/lib/client/gui/lua/GuiScriptManager.java create mode 100644 src/main/java/dev/amble/lib/client/gui/lua/LuaBinder.java create mode 100644 src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java create mode 100644 src/main/java/dev/amble/lib/client/gui/lua/LuaExpose.java create mode 100644 src/main/java/dev/amble/lib/client/gui/lua/mc/LuaItemStack.java create mode 100644 src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java create mode 100644 src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftEntity.java create mode 100644 src/test/resources/assets/litmus/gui/script/test.lua diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java index 9fd99ba..92d609e 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java @@ -1,9 +1,16 @@ package dev.amble.lib.client.gui; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.lua.GuiScript; +import dev.amble.lib.client.gui.lua.LuaBinder; +import dev.amble.lib.client.gui.lua.LuaElement; import lombok.*; import net.minecraft.client.gui.DrawContext; import org.jetbrains.annotations.Nullable; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; +import org.luaj.vm2.lib.jse.CoerceJavaToLua; import java.awt.*; @@ -14,28 +21,81 @@ public class AmbleButton extends AmbleContainer { private AmbleDisplayType hoverDisplay; private AmbleDisplayType pressDisplay; - private Runnable onClick; + private @Nullable Runnable onClick; private @Nullable AmbleDisplayType normalDisplay = null; private boolean isClicked = false; - + private @Nullable GuiScript script; @Override public void onRelease(double mouseX, double mouseY, int button) { - onClick.run(); + if (onClick != null) { + onClick.run(); + } this.setBackground( isHovered(mouseX, mouseY) ? hoverDisplay : getNormalDisplay() ); this.isClicked = false; + + if (script != null && script.onRelease() != null && !script.onRelease().isnil()) { + Varargs args = LuaValue.varargsOf(new LuaValue[]{ + LuaBinder.bind(new LuaElement(this)), + LuaValue.valueOf(mouseX), + LuaValue.valueOf(mouseY), + LuaValue.valueOf(button) + }); + + try { + script.onRelease().invoke(args); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onRelease script for AmbleButton {}:", id(), e); + } + } } @Override public void onClick(double mouseX, double mouseY, int button) { this.setBackground(pressDisplay); this.isClicked = true; + + + if (script != null && script.onClick() != null && !script.onClick().isnil()) { + Varargs args = LuaValue.varargsOf(new LuaValue[]{ + LuaBinder.bind(new LuaElement(this)), + LuaValue.valueOf(mouseX), + LuaValue.valueOf(mouseY), + LuaValue.valueOf(button) + }); + + try { + script.onClick().invoke(args); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onClick script for AmbleButton {}:", id(), e); + } + } + } + + public void onHover(double mouseX, double mouseY) { + if (script != null && script.onHover() != null && !script.onHover().isnil()) { + Varargs args = LuaValue.varargsOf(new LuaValue[]{ + LuaBinder.bind(new LuaElement(this)), + LuaValue.valueOf(mouseX), + LuaValue.valueOf(mouseY), + }); + + try { + script.onHover().invoke(args); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onHover script for AmbleButton {}:", id(), e); + } + } } @Override public void render(DrawContext context, int mouseX, int mouseY, float delta) { + if (isHovered(mouseX, mouseY)) { + onHover(mouseX, mouseY); + } + if (isClicked) { setBackground(pressDisplay); } else if (isHovered(mouseX, mouseY)) { @@ -55,6 +115,18 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { return normalDisplay; } + public void setScript(GuiScript script) { + this.script = script; + if (script.onInit() != null && !script.onInit().isnil()) { + try { + script.onInit().call(CoerceJavaToLua.coerce(new LuaElement(this))); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onInit script for AmbleButton {}:", id(), e); + } + } + } + + public static Builder buttonBuilder() { return new Builder(); } diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleElement.java b/src/main/java/dev/amble/lib/client/gui/AmbleElement.java index b613c1b..8b9d768 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleElement.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleElement.java @@ -30,6 +30,19 @@ default void setPosition(Vec2f position) { } } + default void setDimensions(Vec2f dimensions) { + Rectangle layout = getLayout(); + layout.width = (int) dimensions.x; + layout.height = (int) dimensions.y; + setPreferredLayout(layout); + + recalcuateLayout(); + + if (getParent() != null) { + getParent().recalcuateLayout(); + } + } + boolean isVisible(); void setVisible(boolean visible); diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleText.java b/src/main/java/dev/amble/lib/client/gui/AmbleText.java index ea033ba..f49063b 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleText.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleText.java @@ -11,6 +11,8 @@ import net.minecraft.text.OrderedText; import net.minecraft.text.Text; +import java.util.List; + @Getter @AllArgsConstructor @NoArgsConstructor @@ -19,42 +21,57 @@ public class AmbleText extends AmbleContainer { private Text text; private UIAlign textHorizontalAlign = UIAlign.CENTRE; private UIAlign textVerticalAlign = UIAlign.CENTRE; + private boolean shadow = true; @Override public void render(DrawContext context, int mouseX, int mouseY, float delta) { super.render(context, mouseX, mouseY, delta); - // Calculate text position based on alignment + TextRenderer tr = MinecraftClient.getInstance().textRenderer; + + // 1. Wrap text first + List lines = tr.wrapLines(text, getLayout().width - getPadding() * 2); + + int lineHeight = tr.fontHeight; + int wrappedHeight = lines.size() * lineHeight; + + int wrappedWidth = 0; + for (OrderedText line : lines) { + wrappedWidth = Math.max(wrappedWidth, tr.getWidth(line)); + } + + // 2. Calculate aligned position using WRAPPED size int textX = getLayout().x; int textY = getLayout().y; - int textWidth = MinecraftClient.getInstance().textRenderer.getWidth(text); - int textHeight = MinecraftClient.getInstance().textRenderer.fontHeight; + switch (textHorizontalAlign) { case START -> textX += getPadding(); - case CENTRE -> textX += (getLayout().width - textWidth) / 2; - case END -> textX += getLayout().width - textWidth - getPadding(); + case CENTRE -> textX += (getLayout().width - wrappedWidth) / 2; + case END -> textX += getLayout().width - wrappedWidth - getPadding(); } + switch (textVerticalAlign) { case START -> textY += getPadding(); - case CENTRE -> textY += (getLayout().height - textHeight) / 2; - case END -> textY += getLayout().height - textHeight - getPadding(); + case CENTRE -> textY += (getLayout().height - wrappedHeight) / 2; + case END -> textY += getLayout().height - wrappedHeight - getPadding(); } - // Draw the text - drawTextWrappedWithShadow( - context, - MinecraftClient.getInstance().textRenderer, - text, - textX, textY, - getLayout().width, - 0xFFFFFF - ); + // 3. Draw + drawWrappedLines(context, tr, lines, textX, textY, 0xFFFFFF, shadow); } - public static void drawTextWrappedWithShadow(DrawContext context, TextRenderer textRenderer, Text text, int x, int y, int width, int color) { - for (OrderedText orderedText : textRenderer.wrapLines(text, width)) { - context.drawText(textRenderer, orderedText, x, y, color, true); - y += 9; + public static void drawWrappedLines( + DrawContext context, + TextRenderer textRenderer, + List lines, + int x, + int y, + int color, + boolean shadow + ) { + for (OrderedText line : lines) { + context.drawText(textRenderer, line, x, y, color, shadow); + y += textRenderer.fontHeight; } } @@ -88,5 +105,10 @@ public Builder textVerticalAlign(UIAlign align) { container.setTextVerticalAlign(align); return this; } + + public Builder shadow(boolean shadow) { + container.setShadow(shadow); + return this; + } } } diff --git a/src/main/java/dev/amble/lib/client/gui/lua/GuiScript.java b/src/main/java/dev/amble/lib/client/gui/lua/GuiScript.java new file mode 100644 index 0000000..8753fbd --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/lua/GuiScript.java @@ -0,0 +1,10 @@ +package dev.amble.lib.client.gui.lua; + +import org.luaj.vm2.LuaValue; + +public record GuiScript( + LuaValue onInit, + LuaValue onClick, + LuaValue onRelease, + LuaValue onHover +) {} diff --git a/src/main/java/dev/amble/lib/client/gui/lua/GuiScriptManager.java b/src/main/java/dev/amble/lib/client/gui/lua/GuiScriptManager.java new file mode 100644 index 0000000..4cfb33f --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/lua/GuiScriptManager.java @@ -0,0 +1,63 @@ +package dev.amble.lib.client.gui.lua; + +import dev.amble.lib.AmbleKit; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.util.Identifier; +import org.luaj.vm2.*; +import org.luaj.vm2.lib.jse.JsePlatform; + +import java.io.InputStreamReader; +import java.util.HashMap; +import java.util.Map; + +public class GuiScriptManager implements SimpleSynchronousResourceReloadListener { + private static final GuiScriptManager INSTANCE = new GuiScriptManager(); + private static final Map CACHE = new HashMap<>(); + + private GuiScriptManager() { + ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES) + .registerReloadListener(this); + } + + public static GuiScriptManager getInstance() { + return INSTANCE; + } + + @Override + public Identifier getFabricId() { + return AmbleKit.id("gui_scripts"); + } + + @Override + public void reload(ResourceManager manager) { + CACHE.clear(); + } + + public static GuiScript load(Identifier id, ResourceManager manager) { + return CACHE.computeIfAbsent(id, key -> { + try { + Resource res = manager.getResource(key).orElseThrow(); + Globals globals = JsePlatform.standardGlobals(); + + LuaValue chunk = globals.load( + new InputStreamReader(res.getInputStream()), + key.toString() + ); + chunk.call(); + + return new GuiScript( + globals.get("onInit"), + globals.get("onClick"), + globals.get("onRelease"), + globals.get("onHover") + ); + } catch (Exception e) { + throw new RuntimeException("Failed to load GUI script " + key, e); + } + }); + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaBinder.java b/src/main/java/dev/amble/lib/client/gui/lua/LuaBinder.java new file mode 100644 index 0000000..1dedfea --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/lua/LuaBinder.java @@ -0,0 +1,128 @@ +package dev.amble.lib.client.gui.lua; + +import dev.amble.lib.client.gui.lua.mc.LuaItemStack; +import dev.amble.lib.client.gui.lua.mc.MinecraftEntity; +import net.minecraft.entity.Entity; +import net.minecraft.item.ItemStack; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import org.joml.Vector3f; +import org.luaj.vm2.*; +import org.luaj.vm2.lib.*; +import org.luaj.vm2.lib.jse.CoerceJavaToLua; +import org.luaj.vm2.lib.jse.CoerceLuaToJava; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class LuaBinder { + + private static final Map, LuaTable> CACHE = new HashMap<>(); + + public static LuaValue bind(Object target) { + LuaTable table = CACHE.computeIfAbsent( + target.getClass(), + LuaBinder::buildMetatable + ); + + LuaUserdata userdata = new LuaUserdata(target); + userdata.setmetatable(table); + return userdata; + } + + private static LuaValue coerceResult(Object obj) { + if (obj == null) return LuaValue.NIL; + if (obj instanceof LuaValue lv) return lv; + + // at language level 21 this would be a switch expression + if (obj instanceof String + || obj instanceof Number + || obj instanceof Boolean) { + return CoerceJavaToLua.coerce(obj); + } else if (obj instanceof List list) { + LuaTable table = new LuaTable(); + for (int i = 0; i < list.size(); i++) { + table.set(i + 1, coerceResult(list.get(i))); // recursive + } + return table; + } else if (obj instanceof Vector3f vec3) { + LuaTable table = new LuaTable(); + table.set("x", vec3.x()); + table.set("y", vec3.y()); + table.set("z", vec3.z()); + return table; + } else if (obj instanceof Vec3d vec3) { + LuaTable table = new LuaTable(); + table.set("x", vec3.x); + table.set("y", vec3.y); + table.set("z", vec3.z); + table.set("toString", new ZeroArgFunction() { + @Override + public LuaValue call() { + return LuaString.valueOf("(" + vec3.x + ", " + vec3.y + ", " + vec3.z + ")"); + } + }); + return table; + } else if (obj instanceof BlockPos pos) { + LuaTable table = new LuaTable(); + table.set("x", pos.getX()); + table.set("y", pos.getY()); + table.set("z", pos.getZ()); + return table; + } else if (obj instanceof ItemStack stack) { + return bind(new LuaItemStack(stack)); + } else if (obj instanceof Entity entity) { + return bind(new MinecraftEntity(entity)); + } + + return bind(obj); + } + + + private static LuaTable buildMetatable(Class clazz) { + LuaTable meta = new LuaTable(); + LuaTable index = new LuaTable(); + + for (Method method : clazz.getMethods()) { + LuaExpose expose = method.getAnnotation(LuaExpose.class); + if (expose == null) continue; + + String luaName = expose.name().isEmpty() + ? method.getName() + : expose.name(); + + index.set(luaName, new VarArgFunction() { + @Override + public Varargs invoke(Varargs args) { + try { + LuaValue selfValue = args.arg1(); + if (!selfValue.isuserdata()) { + throw new LuaError("Expected userdata but got " + selfValue.typename()); + } + Object javaSelf = selfValue.touserdata(); + if (javaSelf == null || !clazz.isInstance(javaSelf)) { + throw new LuaError("Expected userdata of type " + clazz.getName() + " but got " + (javaSelf == null ? "null" : javaSelf.getClass().getName())); + } + Object[] javaArgs = new Object[method.getParameterCount()]; + + for (int i = 0; i < javaArgs.length; i++) { + javaArgs[i] = CoerceLuaToJava.coerce( + args.arg(i + 2), + method.getParameterTypes()[i] + ); + } + Object result = method.invoke(javaSelf, javaArgs); + return LuaBinder.coerceResult(result); + } catch (Exception e) { + throw new LuaError("Lua call failed: " + method.getName() + " " + e); + } + } + }); + } + + meta.set("__index", index); + return meta; + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java new file mode 100644 index 0000000..b3c3d5f --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java @@ -0,0 +1,105 @@ +package dev.amble.lib.client.gui.lua; + +import dev.amble.lib.client.gui.*; +import dev.amble.lib.client.gui.lua.mc.MinecraftData; +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.Text; +import net.minecraft.util.math.Vec2f; + +public final class LuaElement { + + private final AmbleElement element; + private final MinecraftData minecraftData = new MinecraftData(); + + public LuaElement(AmbleElement element) { + this.element = element; + } + + @LuaExpose + public String id() { + return element.id().toString(); + } + + @LuaExpose + public int x() { + return element.getLayout().x; + } + + @LuaExpose + public int y() { + return element.getLayout().y; + } + + @LuaExpose + public int width() { + return element.getLayout().width; + } + + @LuaExpose + public int height() { + return element.getLayout().height; + } + + @LuaExpose + public void setPosition(int x, int y) { + element.setPosition(new Vec2f(x, y)); + } + + @LuaExpose + public void setDimensions(int width, int height) { + element.setDimensions(new Vec2f(width, height)); + } + + @LuaExpose + public void setVisible(boolean visible) { + element.setVisible(visible); + } + + @LuaExpose + public LuaElement parent() { + return element.getParent() == null + ? null + : new LuaElement(element.getParent()); + } + + @LuaExpose + public LuaElement child(int index) { + if (index < 0 || index >= element.getChildren().size()) return null; + return new LuaElement(element.getChildren().get(index)); + } + + @LuaExpose + public int childCount() { + return element.getChildren().size(); + } + + @LuaExpose + public void setText(String text) { + if (element instanceof AmbleText t) { + t.setText(Text.literal(text)); + } + } + + @LuaExpose + public String getText() { + if (element instanceof AmbleText t) { + return t.getText().getString(); + } + return null; + } + + @LuaExpose + public void closeScreen() { + MinecraftClient.getInstance().setScreen(null); + } + + @LuaExpose + public MinecraftData minecraft() { + return minecraftData; + } + + // INTERNAL — NOT EXPOSED + AmbleElement unwrap() { + return element; + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaExpose.java b/src/main/java/dev/amble/lib/client/gui/lua/LuaExpose.java new file mode 100644 index 0000000..24d04b9 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/lua/LuaExpose.java @@ -0,0 +1,9 @@ +package dev.amble.lib.client.gui.lua; + +import java.lang.annotation.*; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface LuaExpose { + String name() default ""; // optional Lua name override +} diff --git a/src/main/java/dev/amble/lib/client/gui/lua/mc/LuaItemStack.java b/src/main/java/dev/amble/lib/client/gui/lua/mc/LuaItemStack.java new file mode 100644 index 0000000..b6f5759 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/lua/mc/LuaItemStack.java @@ -0,0 +1,27 @@ +package dev.amble.lib.client.gui.lua.mc; + +import dev.amble.lib.client.gui.lua.LuaExpose; +import lombok.AllArgsConstructor; +import net.minecraft.item.ItemStack; +import net.minecraft.registry.Registries; +import org.luaj.vm2.LuaValue; + +@AllArgsConstructor +public class LuaItemStack { + public final ItemStack stack; + + @LuaExpose + public int count() { + return stack.getCount(); + } + + @LuaExpose + public String name() { + return stack.getName().getString(); + } + + @LuaExpose + public String id() { + return Registries.ITEM.getId(stack.getItem()).toString(); + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java b/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java new file mode 100644 index 0000000..55db784 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java @@ -0,0 +1,88 @@ +package dev.amble.lib.client.gui.lua.mc; + +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.lua.LuaExpose; +import net.minecraft.SharedConstants; +import net.minecraft.client.MinecraftClient; +import net.minecraft.entity.Entity; +import net.minecraft.item.ItemStack; +import net.minecraft.network.packet.c2s.play.PlayerActionC2SPacket; +import net.minecraft.text.Text; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Direction; +import net.minecraft.util.math.Vec3d; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +public class MinecraftData { + private static final MinecraftClient mc = MinecraftClient.getInstance(); + + @LuaExpose + public String username() { + return mc.getSession().getUsername(); + } + + @LuaExpose + public void runCommand(String command) { + try { + String string2 = SharedConstants.stripInvalidChars(command); + if (string2.startsWith("/")) { + if (!mc.player.networkHandler.sendCommand(string2.substring(1))) { + AmbleKit.LOGGER.error("Not allowed to run command with signed argument from lua: '{}'", string2); + } + } else { + AmbleKit.LOGGER.error("Failed to run command without '/' prefix from lua: '{}'", string2); + } + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while running command from lua: '{}'", command, e); + } + } + + @LuaExpose + public int selectedSlot() { + return mc.player.getInventory().selectedSlot + 1; + } + + @LuaExpose + public void selectSlot(int slot) { + mc.player.getInventory().selectedSlot = slot - 1; + } + + @LuaExpose + public void sendMessage(String message, boolean overlay) { + mc.player.sendMessage(Text.literal(message), overlay); + } + + @LuaExpose + public Entity player() { + return mc.player; + } + + @LuaExpose + public List entities() { + return StreamSupport.stream(mc.world.getEntities().spliterator(), false) + .collect(Collectors.toList()); + } + + @LuaExpose + public void dropStack(int slot, boolean entireStack) { + int selected = selectedSlot(); + swapStack(slot, selected); + PlayerActionC2SPacket.Action action = entireStack ? PlayerActionC2SPacket.Action.DROP_ALL_ITEMS : PlayerActionC2SPacket.Action.DROP_ITEM; + ItemStack itemStack = mc.player.getInventory().dropSelectedItem(entireStack); + mc.player.networkHandler.sendPacket(new PlayerActionC2SPacket(action, BlockPos.ORIGIN, Direction.DOWN)); + //swapStack(selected, slot); + } + + @LuaExpose + public void swapStack(int fromSlot, int toSlot) { + ItemStack stack = mc.player.getInventory().getStack(fromSlot - 1); + mc.player.getInventory().setStack(fromSlot - 1, mc.player.getInventory().getStack(toSlot - 1)); + mc.player.getInventory().setStack(toSlot - 1, stack); + + // todo sync change to server somehow + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftEntity.java b/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftEntity.java new file mode 100644 index 0000000..8ce12ad --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftEntity.java @@ -0,0 +1,90 @@ +package dev.amble.lib.client.gui.lua.mc; + +import dev.amble.lib.client.gui.lua.LuaExpose; +import lombok.AllArgsConstructor; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.registry.Registries; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; + +import java.util.ArrayList; +import java.util.List; + +@AllArgsConstructor +public class MinecraftEntity { + public final Entity entity; + + @LuaExpose + public String name() { + return entity.getName().getString(); + } + + @LuaExpose + public String type() { + return Registries.ENTITY_TYPE.getId(entity.getType()).toString(); + } + + @LuaExpose + public String uuid() { + return entity.getUuid().toString(); + } + + @LuaExpose + public boolean isPlayer() { + return entity.isPlayer(); + } + + @LuaExpose + public Vec3d position() { + return entity.getPos(); + } + + @LuaExpose + public BlockPos blockPosition() { + return entity.getBlockPos(); + } + + @LuaExpose + public double health() { + return entity instanceof LivingEntity livingEntity ? livingEntity.getHealth() : -1; + } + + @LuaExpose + public int age() { + return entity.age; + } + + @LuaExpose + public List inventory() { + if (entity instanceof PlayerEntity player) { + List combined = new ArrayList<>(player.getInventory().main); + combined.addAll(player.getInventory().armor); + combined.addAll(player.getInventory().offHand); + return combined; + } + + Iterable hands = entity.getHandItems(); + Iterable armor = entity.getArmorItems(); + // Combine both iterables into a single list + List combined = new ArrayList<>(); + for (ItemStack item : hands) { + combined.add(item); + } + for (ItemStack item : armor) { + combined.add(item); + } + + return combined; + } + + @LuaExpose + public int foodLevel() { + if (entity instanceof PlayerEntity player) { + return player.getHungerManager().getFoodLevel(); + } + return -1; + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index 3ef6afa..0f4882a 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -5,6 +5,9 @@ import com.google.gson.JsonParser; import dev.amble.lib.AmbleKit; import dev.amble.lib.client.gui.*; +import dev.amble.lib.client.gui.lua.GuiScript; +import dev.amble.lib.client.gui.lua.GuiScriptManager; +import dev.amble.lib.client.gui.lua.LuaBinder; import dev.amble.lib.register.datapack.DatapackRegistry; import net.fabricmc.fabric.api.resource.ResourceManagerHelper; import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; @@ -29,6 +32,8 @@ public class AmbleGuiRegistry extends DatapackRegistry implement private AmbleGuiRegistry() { ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES).registerReloadListener(this); + + GuiScriptManager.getInstance(); } @Override @@ -128,6 +133,11 @@ public static AmbleContainer parse(JsonObject json) { AmbleContainer created = AmbleContainer.builder().background(background).layout(layout).padding(padding).spacing(spacing).horizontalAlign(horizAlign).verticalAlign(vertAlign).children(children).shouldPause(shouldPause).requiresNewRow(requiresNewRow).build(); + if (json.has("id")) { + String idStr = json.get("id").getAsString(); + created.setIdentifier(new Identifier(idStr)); + } + if (json.has("text")) { String text = json.get("text").getAsString(); AmbleText ambleText = AmbleText.textBuilder().text(Text.translatable(text)).build(); @@ -154,7 +164,7 @@ public static AmbleContainer parse(JsonObject json) { } } - if (json.has("on_click") || json.has("hover_background") || json.has("press_background")) { + if (json.has("command") || json.has("script") || json.has("hover_background") || json.has("press_background")) { AmbleButton button = AmbleButton.buttonBuilder().build(); button.copyFrom(created); created = button; @@ -181,6 +191,16 @@ public static AmbleContainer parse(JsonObject json) { }); } + if (json.has("script")) { + Identifier scriptId = new Identifier(json.get("script").getAsString()).withPrefixedPath("gui/script/").withSuffixedPath(".lua"); + GuiScript script = GuiScriptManager.load( + scriptId, + MinecraftClient.getInstance().getResourceManager() + ); + + button.setScript(script); + } + if (json.has("hover_background")) { AmbleDisplayType hoverBg = AmbleDisplayType.parse(json.get("hover_background")); button.setHoverDisplay(hoverBg); diff --git a/src/test/resources/assets/litmus/gui/script/test.lua b/src/test/resources/assets/litmus/gui/script/test.lua new file mode 100644 index 0000000..11a778f --- /dev/null +++ b/src/test/resources/assets/litmus/gui/script/test.lua @@ -0,0 +1,33 @@ +function onClick(self, mouseX, mouseY, button) + local text = self:getText() + if (text == nil) then + --- find first child which is not nil + for i = 0, self:childCount() - 1 do + local child = self:child(i) + local childText = child:getText() + if (childText ~= nil) then + --- set to player username + child:setText("Hello " .. self:minecraft():username() .. "!") + break + end + end + end + + -- search the inventory for an apple and select it if its in the hotbar + local inventory = self:minecraft():player():inventory() -- a table of ItemStacks + for slotIndex, itemStack in pairs(inventory) do + if (itemStack ~= nil and itemStack:id() == "minecraft:apple") then + -- drop apple + self:minecraft():dropStack(slotIndex, True) + break + end + end + + -- print all entities + local entities = self:minecraft():entities() + print(entities) + for _, entity in pairs(entities) do + print(entity) + print("Entity: " .. entity:type() .. " at " .. entity:position():toString()) + end +end \ No newline at end of file diff --git a/src/test/resources/assets/litmus/gui/test.json b/src/test/resources/assets/litmus/gui/test.json index afcba54..983fc04 100644 --- a/src/test/resources/assets/litmus/gui/test.json +++ b/src/test/resources/assets/litmus/gui/test.json @@ -42,7 +42,7 @@ ] }, { - "on_click": "/give @s apple", + "script": "litmus:test", "hover_background": [ 255, 0, @@ -74,7 +74,7 @@ 0, 0 ], - "text": "give me appel" + "text": "i been pressed: " } ] } From 9a56e178a4af04f011f81854b79957d5bfb116a2 Mon Sep 17 00:00:00 2001 From: James Hall Date: Thu, 8 Jan 2026 01:36:08 +0000 Subject: [PATCH 10/37] add LuaJ dependency for GUI scripting --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index e0546b2..38c12ab 100644 --- a/build.gradle +++ b/build.gradle @@ -90,6 +90,9 @@ dependencies { include(modApi("dev.drtheo:scheduler:${project.scheduler_version}")) include(modApi("dev.drtheo:queue:${project.queue_version}")) + // LuaJ for GUI scripting + include(implementation("org.luaj:luaj-jse:3.0.1")) + // getter setter compileOnly 'org.projectlombok:lombok:1.18.34' annotationProcessor 'org.projectlombok:lombok:1.18.34' From 1980f9254db919936eeb6e99aa83ef6070727cc9 Mon Sep 17 00:00:00 2001 From: James Hall Date: Thu, 8 Jan 2026 02:03:53 +0000 Subject: [PATCH 11/37] Refactor GUI scripting into general-purpose script system - Rename GuiScript to AmbleScript and GuiScriptManager to ScriptManager - Move scripts from gui/script/ to script/ folder for broader use - Add ExecuteScriptCommand (/amblescript execute) to run scripts via chat - Auto-discover scripts on resource reload for command tab-completion - Add onExecute callback support for scripts triggered by command --- .../dev/amble/lib/client/AmbleKitClient.java | 14 ++- .../dev/amble/lib/client/gui/AmbleButton.java | 6 +- .../lib/client/gui/lua/GuiScriptManager.java | 63 -------------- .../client/gui/registry/AmbleGuiRegistry.java | 10 +-- .../lib/command/ExecuteScriptCommand.java | 61 +++++++++++++ .../AmbleScript.java} | 7 +- .../dev/amble/lib/script/ScriptManager.java | 86 +++++++++++++++++++ .../assets/litmus/script/hotbar_cycle.lua | 42 +++++++++ .../resources/assets/litmus/script/stats.lua | 86 +++++++++++++++++++ .../assets/litmus/{gui => }/script/test.lua | 6 +- 10 files changed, 303 insertions(+), 78 deletions(-) delete mode 100644 src/main/java/dev/amble/lib/client/gui/lua/GuiScriptManager.java create mode 100644 src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java rename src/main/java/dev/amble/lib/{client/gui/lua/GuiScript.java => script/AmbleScript.java} (51%) create mode 100644 src/main/java/dev/amble/lib/script/ScriptManager.java create mode 100644 src/test/resources/assets/litmus/script/hotbar_cycle.lua create mode 100644 src/test/resources/assets/litmus/script/stats.lua rename src/test/resources/assets/litmus/{gui => }/script/test.lua (93%) diff --git a/src/main/java/dev/amble/lib/client/AmbleKitClient.java b/src/main/java/dev/amble/lib/client/AmbleKitClient.java index 01362d0..6ce43d4 100644 --- a/src/main/java/dev/amble/lib/client/AmbleKitClient.java +++ b/src/main/java/dev/amble/lib/client/AmbleKitClient.java @@ -4,7 +4,9 @@ import dev.amble.lib.client.bedrock.BedrockModel; import dev.amble.lib.client.bedrock.BedrockModelRegistry; import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; +import dev.amble.lib.command.ExecuteScriptCommand; import dev.amble.lib.register.AmbleRegistries; +import dev.amble.lib.script.ScriptManager; import dev.amble.lib.skin.client.SkinGrabber; import dev.drtheo.scheduler.client.SchedulerClientMod; import net.fabricmc.api.ClientModInitializer; @@ -31,9 +33,15 @@ public void onInitializeClient() { AmbleGuiRegistry.getInstance() ); - ClientTickEvents.END_CLIENT_TICK.register((client) -> { - SkinGrabber.INSTANCE.tick(); - }); + ScriptManager.getInstance(); + + ClientCommandRegistrationCallback.EVENT.register((dispatcher, access) -> { + ExecuteScriptCommand.register(dispatcher); + }); + + ClientTickEvents.END_CLIENT_TICK.register((client) -> { + SkinGrabber.INSTANCE.tick(); + }); } } diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java index 92d609e..83c5ceb 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java @@ -2,9 +2,9 @@ import dev.amble.lib.AmbleKit; -import dev.amble.lib.client.gui.lua.GuiScript; import dev.amble.lib.client.gui.lua.LuaBinder; import dev.amble.lib.client.gui.lua.LuaElement; +import dev.amble.lib.script.AmbleScript; import lombok.*; import net.minecraft.client.gui.DrawContext; import org.jetbrains.annotations.Nullable; @@ -24,7 +24,7 @@ public class AmbleButton extends AmbleContainer { private @Nullable Runnable onClick; private @Nullable AmbleDisplayType normalDisplay = null; private boolean isClicked = false; - private @Nullable GuiScript script; + private @Nullable AmbleScript script; @Override public void onRelease(double mouseX, double mouseY, int button) { @@ -115,7 +115,7 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { return normalDisplay; } - public void setScript(GuiScript script) { + public void setScript(AmbleScript script) { this.script = script; if (script.onInit() != null && !script.onInit().isnil()) { try { diff --git a/src/main/java/dev/amble/lib/client/gui/lua/GuiScriptManager.java b/src/main/java/dev/amble/lib/client/gui/lua/GuiScriptManager.java deleted file mode 100644 index 4cfb33f..0000000 --- a/src/main/java/dev/amble/lib/client/gui/lua/GuiScriptManager.java +++ /dev/null @@ -1,63 +0,0 @@ -package dev.amble.lib.client.gui.lua; - -import dev.amble.lib.AmbleKit; -import net.fabricmc.fabric.api.resource.ResourceManagerHelper; -import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; -import net.minecraft.resource.Resource; -import net.minecraft.resource.ResourceManager; -import net.minecraft.resource.ResourceType; -import net.minecraft.util.Identifier; -import org.luaj.vm2.*; -import org.luaj.vm2.lib.jse.JsePlatform; - -import java.io.InputStreamReader; -import java.util.HashMap; -import java.util.Map; - -public class GuiScriptManager implements SimpleSynchronousResourceReloadListener { - private static final GuiScriptManager INSTANCE = new GuiScriptManager(); - private static final Map CACHE = new HashMap<>(); - - private GuiScriptManager() { - ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES) - .registerReloadListener(this); - } - - public static GuiScriptManager getInstance() { - return INSTANCE; - } - - @Override - public Identifier getFabricId() { - return AmbleKit.id("gui_scripts"); - } - - @Override - public void reload(ResourceManager manager) { - CACHE.clear(); - } - - public static GuiScript load(Identifier id, ResourceManager manager) { - return CACHE.computeIfAbsent(id, key -> { - try { - Resource res = manager.getResource(key).orElseThrow(); - Globals globals = JsePlatform.standardGlobals(); - - LuaValue chunk = globals.load( - new InputStreamReader(res.getInputStream()), - key.toString() - ); - chunk.call(); - - return new GuiScript( - globals.get("onInit"), - globals.get("onClick"), - globals.get("onRelease"), - globals.get("onHover") - ); - } catch (Exception e) { - throw new RuntimeException("Failed to load GUI script " + key, e); - } - }); - } -} diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index 0f4882a..c1caba5 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -5,8 +5,8 @@ import com.google.gson.JsonParser; import dev.amble.lib.AmbleKit; import dev.amble.lib.client.gui.*; -import dev.amble.lib.client.gui.lua.GuiScript; -import dev.amble.lib.client.gui.lua.GuiScriptManager; +import dev.amble.lib.script.AmbleScript; +import dev.amble.lib.script.ScriptManager; import dev.amble.lib.client.gui.lua.LuaBinder; import dev.amble.lib.register.datapack.DatapackRegistry; import net.fabricmc.fabric.api.resource.ResourceManagerHelper; @@ -33,7 +33,7 @@ public class AmbleGuiRegistry extends DatapackRegistry implement private AmbleGuiRegistry() { ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES).registerReloadListener(this); - GuiScriptManager.getInstance(); + ScriptManager.getInstance(); } @Override @@ -192,8 +192,8 @@ public static AmbleContainer parse(JsonObject json) { } if (json.has("script")) { - Identifier scriptId = new Identifier(json.get("script").getAsString()).withPrefixedPath("gui/script/").withSuffixedPath(".lua"); - GuiScript script = GuiScriptManager.load( + Identifier scriptId = new Identifier(json.get("script").getAsString()).withPrefixedPath("script/").withSuffixedPath(".lua"); + AmbleScript script = ScriptManager.load( scriptId, MinecraftClient.getInstance().getResourceManager() ); diff --git a/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java b/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java new file mode 100644 index 0000000..f1bc402 --- /dev/null +++ b/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java @@ -0,0 +1,61 @@ +package dev.amble.lib.command; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.suggestion.SuggestionProvider; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.AmbleScript; +import dev.amble.lib.script.ScriptManager; +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; +import net.minecraft.client.MinecraftClient; +import net.minecraft.command.CommandSource; +import net.minecraft.command.argument.IdentifierArgumentType; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument; +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; + +public class ExecuteScriptCommand { + + private static final SuggestionProvider SCRIPT_SUGGESTIONS = (context, builder) -> { + return CommandSource.suggestIdentifiers( + ScriptManager.getCache().keySet().stream() + .map(id -> Identifier.of(id.getNamespace(), id.getPath().replace("script/", "").replace(".lua", ""))), + builder + ); + }; + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(literal("amblescript") + .then(literal("execute") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(SCRIPT_SUGGESTIONS) + .executes(ExecuteScriptCommand::execute)))); + } + + private static int execute(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + + try { + AmbleScript script = ScriptManager.load( + fullScriptId, + MinecraftClient.getInstance().getResourceManager() + ); + + if (script.onExecute() == null || script.onExecute().isnil()) { + context.getSource().sendError(Text.literal("Script '" + scriptId + "' has no onExecute function")); + return 0; + } + + script.onExecute().call(); + context.getSource().sendFeedback(Text.literal("Executed script: " + scriptId)); + return 1; + } catch (Exception e) { + context.getSource().sendError(Text.literal("Failed to execute script '" + scriptId + "': " + e.getMessage())); + AmbleKit.LOGGER.error("Failed to execute script {}", scriptId, e); + return 0; + } + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/lua/GuiScript.java b/src/main/java/dev/amble/lib/script/AmbleScript.java similarity index 51% rename from src/main/java/dev/amble/lib/client/gui/lua/GuiScript.java rename to src/main/java/dev/amble/lib/script/AmbleScript.java index 8753fbd..8c74446 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/GuiScript.java +++ b/src/main/java/dev/amble/lib/script/AmbleScript.java @@ -1,10 +1,11 @@ -package dev.amble.lib.client.gui.lua; +package dev.amble.lib.script; import org.luaj.vm2.LuaValue; -public record GuiScript( +public record AmbleScript( LuaValue onInit, LuaValue onClick, LuaValue onRelease, - LuaValue onHover + LuaValue onHover, + LuaValue onExecute ) {} diff --git a/src/main/java/dev/amble/lib/script/ScriptManager.java b/src/main/java/dev/amble/lib/script/ScriptManager.java new file mode 100644 index 0000000..c7710f0 --- /dev/null +++ b/src/main/java/dev/amble/lib/script/ScriptManager.java @@ -0,0 +1,86 @@ +package dev.amble.lib.script; + +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.lua.LuaBinder; +import dev.amble.lib.client.gui.lua.mc.MinecraftData; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.util.Identifier; +import org.luaj.vm2.*; +import org.luaj.vm2.lib.jse.JsePlatform; + +import java.io.InputStreamReader; +import java.util.HashMap; +import java.util.Map; + +public class ScriptManager implements SimpleSynchronousResourceReloadListener { + private static final ScriptManager INSTANCE = new ScriptManager(); + private static final Map CACHE = new HashMap<>(); + + private ScriptManager() { + ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES) + .registerReloadListener(this); + } + + public static ScriptManager getInstance() { + return INSTANCE; + } + + @Override + public Identifier getFabricId() { + return AmbleKit.id("scripts"); + } + + @Override + public void reload(ResourceManager manager) { + CACHE.clear(); + + // Discover all script files and populate the cache for suggestions + manager.findResources("script", id -> id.getPath().endsWith(".lua")) + .keySet() + .forEach(id -> { + try { + load(id, manager); + } catch (Exception e) { + AmbleKit.LOGGER.error("Failed to load script {}", id, e); + } + }); + + AmbleKit.LOGGER.info("Loaded {} scripts", CACHE.size()); + } + + public static AmbleScript load(Identifier id, ResourceManager manager) { + return CACHE.computeIfAbsent(id, key -> { + try { + Resource res = manager.getResource(key).orElseThrow(); + Globals globals = JsePlatform.standardGlobals(); + + // Inject minecraft global for scripts to use + globals.set("minecraft", LuaBinder.bind(new MinecraftData())); + + LuaValue chunk = globals.load( + new InputStreamReader(res.getInputStream()), + key.toString() + ); + chunk.call(); + + return new AmbleScript( + globals.get("onInit"), + globals.get("onClick"), + globals.get("onRelease"), + globals.get("onHover"), + globals.get("onExecute") + ); + } catch (Exception e) { + throw new RuntimeException("Failed to load script " + key, e); + } + }); + } + + public static Map getCache() { + return CACHE; + } +} diff --git a/src/test/resources/assets/litmus/script/hotbar_cycle.lua b/src/test/resources/assets/litmus/script/hotbar_cycle.lua new file mode 100644 index 0000000..c81c620 --- /dev/null +++ b/src/test/resources/assets/litmus/script/hotbar_cycle.lua @@ -0,0 +1,42 @@ +-- Hotbar Cycle Script: Cycles through hotbar slots with a fun animation +-- Run with: /amblekit execute litmus:hotbar_cycle + +local cycleIndex = 1 +local cycleDirection = 1 + +function onExecute() + local currentSlot = minecraft:selectedSlot() + + -- Calculate next slot (1-9) + local nextSlot = currentSlot + cycleDirection + + if nextSlot > 9 then + nextSlot = 1 + cycleIndex = cycleIndex + 1 + elseif nextSlot < 1 then + nextSlot = 9 + cycleIndex = cycleIndex + 1 + end + + -- Select the next slot + minecraft:selectSlot(nextSlot) + + -- Create a visual indicator + local indicator = "" + for i = 1, 9 do + if i == nextSlot then + indicator = indicator .. "§e[" .. i .. "]" + else + indicator = indicator .. "§7 " .. i .. " " + end + end + + minecraft:sendMessage("§6Hotbar: " .. indicator, true) + + -- Change direction every full cycle + if cycleIndex > 2 then + cycleDirection = -cycleDirection + cycleIndex = 1 + minecraft:sendMessage("§d✦ Direction reversed! ✦", true) + end +end diff --git a/src/test/resources/assets/litmus/script/stats.lua b/src/test/resources/assets/litmus/script/stats.lua new file mode 100644 index 0000000..7ad20f9 --- /dev/null +++ b/src/test/resources/assets/litmus/script/stats.lua @@ -0,0 +1,86 @@ +-- Stats Script: Shows player info and nearby entities +-- Run with: /amblekit execute litmus:stats + +function onExecute() + -- Get player info + local player = minecraft:player() + local username = minecraft:username() + local pos = player:position() + local health = player:health() + local food = player:foodLevel() + local slot = minecraft:selectedSlot() + + -- Send a stylish header + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§e§l✦ Player Stats for §f" .. username .. " §e§l✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Health bar visualization + local maxHearts = 10 + local currentHearts = math.floor(health / 2) + local heartBar = "" + for i = 1, maxHearts do + if i <= currentHearts then + heartBar = heartBar .. "§c❤" + else + heartBar = heartBar .. "§8❤" + end + end + minecraft:sendMessage("§7Health: " .. heartBar .. " §f(" .. string.format("%.1f", health) .. ")", false) + + -- Food bar visualization + local maxFood = 10 + local currentFood = math.floor(food / 2) + local foodBar = "" + for i = 1, maxFood do + if i <= currentFood then + foodBar = foodBar .. "§6🍖" + else + foodBar = foodBar .. "§8🍖" + end + end + minecraft:sendMessage("§7Hunger: " .. foodBar .. " §f(" .. food .. ")", false) + + -- Position + minecraft:sendMessage("§7Position: §b" .. string.format("%.1f", pos.x) .. "§7, §a" .. string.format("%.1f", pos.y) .. "§7, §d" .. string.format("%.1f", pos.z), false) + minecraft:sendMessage("§7Selected Slot: §e" .. slot, false) + + -- Count nearby entities + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§e§l✦ Nearby Entities ✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + local entities = minecraft:entities() + local entityCounts = {} + local totalCount = 0 + + for _, entity in pairs(entities) do + local entityType = entity:type() + -- Skip the player themselves + if not (entity:isPlayer() and entity:name() == username) then + entityCounts[entityType] = (entityCounts[entityType] or 0) + 1 + totalCount = totalCount + 1 + end + end + + -- Display entity counts (limit to first 8 types) + local displayed = 0 + for entityType, count in pairs(entityCounts) do + if displayed < 8 then + -- Clean up the entity type name + local cleanName = entityType:gsub("minecraft:", ""):gsub("_", " ") + minecraft:sendMessage("§7• §f" .. cleanName .. "§7: §a" .. count, false) + displayed = displayed + 1 + end + end + + if displayed == 0 then + minecraft:sendMessage("§7No entities nearby!", false) + elseif totalCount > displayed then + minecraft:sendMessage("§8...and " .. (totalCount - displayed) .. " more types", false) + end + + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§7Total entities: §e" .. totalCount, false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/gui/script/test.lua b/src/test/resources/assets/litmus/script/test.lua similarity index 93% rename from src/test/resources/assets/litmus/gui/script/test.lua rename to src/test/resources/assets/litmus/script/test.lua index 11a778f..e5a962f 100644 --- a/src/test/resources/assets/litmus/gui/script/test.lua +++ b/src/test/resources/assets/litmus/script/test.lua @@ -30,4 +30,8 @@ function onClick(self, mouseX, mouseY, button) print(entity) print("Entity: " .. entity:type() .. " at " .. entity:position():toString()) end -end \ No newline at end of file +end + +function onExecute() + print("Script executed via command!") +end From 30252ca08b790d206cc6557b2ed424c4c3ee6fb5 Mon Sep 17 00:00:00 2001 From: James Hall Date: Thu, 8 Jan 2026 15:35:44 +0000 Subject: [PATCH 12/37] Expand Lua scripting API with world, player, entity, and item methods --- .../lib/client/gui/lua/mc/LuaItemStack.java | 85 +++++++++- .../lib/client/gui/lua/mc/MinecraftData.java | 155 ++++++++++++++++- .../client/gui/lua/mc/MinecraftEntity.java | 141 +++++++++++++++ .../assets/litmus/script/clipboard_demo.lua | 45 +++++ .../assets/litmus/script/entity_inspect.lua | 160 ++++++++++++++++++ .../assets/litmus/script/input_test.lua | 61 +++++++ .../assets/litmus/script/item_info.lua | 112 ++++++++++++ .../assets/litmus/script/player_state.lua | 115 +++++++++++++ .../assets/litmus/script/world_info.lua | 91 ++++++++++ 9 files changed, 962 insertions(+), 3 deletions(-) create mode 100644 src/test/resources/assets/litmus/script/clipboard_demo.lua create mode 100644 src/test/resources/assets/litmus/script/entity_inspect.lua create mode 100644 src/test/resources/assets/litmus/script/input_test.lua create mode 100644 src/test/resources/assets/litmus/script/item_info.lua create mode 100644 src/test/resources/assets/litmus/script/player_state.lua create mode 100644 src/test/resources/assets/litmus/script/world_info.lua diff --git a/src/main/java/dev/amble/lib/client/gui/lua/mc/LuaItemStack.java b/src/main/java/dev/amble/lib/client/gui/lua/mc/LuaItemStack.java index b6f5759..2563431 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/mc/LuaItemStack.java +++ b/src/main/java/dev/amble/lib/client/gui/lua/mc/LuaItemStack.java @@ -2,9 +2,13 @@ import dev.amble.lib.client.gui.lua.LuaExpose; import lombok.AllArgsConstructor; +import net.minecraft.enchantment.EnchantmentHelper; import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NbtCompound; import net.minecraft.registry.Registries; -import org.luaj.vm2.LuaValue; + +import java.util.ArrayList; +import java.util.List; @AllArgsConstructor public class LuaItemStack { @@ -24,4 +28,83 @@ public String name() { public String id() { return Registries.ITEM.getId(stack.getItem()).toString(); } + + // ===== Additional Methods ===== + + @LuaExpose + public int maxCount() { + return stack.getMaxCount(); + } + + @LuaExpose + public int maxDamage() { + return stack.getMaxDamage(); + } + + @LuaExpose + public int damage() { + return stack.getDamage(); + } + + @LuaExpose + public boolean isDamageable() { + return stack.isDamageable(); + } + + @LuaExpose + public boolean hasEnchantments() { + return stack.hasEnchantments(); + } + + @LuaExpose + public boolean isEmpty() { + return stack.isEmpty(); + } + + @LuaExpose + public boolean isStackable() { + return stack.isStackable(); + } + + @LuaExpose + public float durabilityPercent() { + if (!stack.isDamageable()) return 1.0f; + return 1.0f - ((float) stack.getDamage() / (float) stack.getMaxDamage()); + } + + @LuaExpose + public boolean hasCustomName() { + return stack.hasCustomName(); + } + + @LuaExpose + public boolean isFood() { + return stack.isFood(); + } + + @LuaExpose + public String rarity() { + return stack.getRarity().name().toLowerCase(); + } + + @LuaExpose + public List enchantments() { + List result = new ArrayList<>(); + EnchantmentHelper.get(stack).forEach((enchantment, level) -> { + String enchantId = Registries.ENCHANTMENT.getId(enchantment).toString(); + result.add(enchantId + ":" + level); + }); + return result; + } + + @LuaExpose + public boolean hasNbt() { + return stack.hasNbt(); + } + + @LuaExpose + public String nbtString() { + NbtCompound nbt = stack.getNbt(); + return nbt != null ? nbt.toString() : ""; + } } diff --git a/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java b/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java index 55db784..cac0a08 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java +++ b/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java @@ -1,19 +1,25 @@ package dev.amble.lib.client.gui.lua.mc; import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.AmbleContainer; import dev.amble.lib.client.gui.lua.LuaExpose; +import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; import net.minecraft.SharedConstants; import net.minecraft.client.MinecraftClient; import net.minecraft.entity.Entity; import net.minecraft.item.ItemStack; import net.minecraft.network.packet.c2s.play.PlayerActionC2SPacket; +import net.minecraft.registry.Registries; +import net.minecraft.sound.SoundEvent; import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import net.minecraft.util.hit.BlockHitResult; +import net.minecraft.util.hit.EntityHitResult; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Direction; -import net.minecraft.util.math.Vec3d; +import java.util.Comparator; import java.util.List; -import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.StreamSupport; @@ -85,4 +91,149 @@ public void swapStack(int fromSlot, int toSlot) { // todo sync change to server somehow } + + // ===== World & Environment ===== + + @LuaExpose + public String dimension() { + return mc.world.getRegistryKey().getValue().toString(); + } + + @LuaExpose + public long worldTime() { + return mc.world.getTimeOfDay(); + } + + @LuaExpose + public long dayCount() { + return mc.world.getTimeOfDay() / 24000L; + } + + @LuaExpose + public boolean isRaining() { + return mc.world.isRaining(); + } + + @LuaExpose + public boolean isThundering() { + return mc.world.isThundering(); + } + + @LuaExpose + public String biomeAt(int x, int y, int z) { + return mc.world.getBiome(new BlockPos(x, y, z)).getKey() + .map(k -> k.getValue().toString()).orElse("unknown"); + } + + @LuaExpose + public String blockAt(int x, int y, int z) { + return Registries.BLOCK.getId(mc.world.getBlockState(new BlockPos(x, y, z)).getBlock()).toString(); + } + + @LuaExpose + public int lightLevelAt(int x, int y, int z) { + return mc.world.getLightLevel(new BlockPos(x, y, z)); + } + + // ===== Input ===== + + @LuaExpose + public boolean isKeyPressed(String keyName) { + return switch (keyName.toLowerCase()) { + case "forward" -> mc.options.forwardKey.isPressed(); + case "back" -> mc.options.backKey.isPressed(); + case "left" -> mc.options.leftKey.isPressed(); + case "right" -> mc.options.rightKey.isPressed(); + case "jump" -> mc.options.jumpKey.isPressed(); + case "sneak" -> mc.options.sneakKey.isPressed(); + case "sprint" -> mc.options.sprintKey.isPressed(); + case "attack" -> mc.options.attackKey.isPressed(); + case "use" -> mc.options.useKey.isPressed(); + default -> false; + }; + } + + @LuaExpose + public String gameMode() { + return mc.interactionManager.getCurrentGameMode().getName(); + } + + // ===== Audio ===== + + @LuaExpose + public void playSound(String soundId, float volume, float pitch) { + Identifier id = new Identifier(soundId); + SoundEvent sound = Registries.SOUND_EVENT.get(id); + if (sound != null) { + mc.player.playSound(sound, volume, pitch); + } + } + + // ===== Entity Queries ===== + + @LuaExpose + public Entity nearestEntity(double maxDistance) { + return mc.world.getOtherEntities(mc.player, mc.player.getBoundingBox().expand(maxDistance), e -> true) + .stream() + .min(Comparator.comparingDouble(e -> e.squaredDistanceTo(mc.player))) + .orElse(null); + } + + @LuaExpose + public List entitiesInRadius(double radius) { + return mc.world.getOtherEntities(mc.player, mc.player.getBoundingBox().expand(radius), e -> true); + } + + @LuaExpose + public Entity lookingAtEntity() { + if (mc.crosshairTarget instanceof EntityHitResult hit) { + return hit.getEntity(); + } + return null; + } + + @LuaExpose + public BlockPos lookingAtBlock() { + if (mc.crosshairTarget instanceof BlockHitResult hit) { + return hit.getBlockPos(); + } + return null; + } + + // ===== UI & Clipboard ===== + + @LuaExpose + public void displayScreen(String screenId) { + AmbleContainer screen = AmbleGuiRegistry.getInstance().get(new Identifier(screenId)); + if (screen != null) { + screen.display(); + } else { + AmbleKit.LOGGER.warn("Screen '{}' not found in AmbleGuiRegistry", screenId); + } + } + + @LuaExpose + public void closeScreen() { + mc.setScreen(null); + } + + @LuaExpose + public String clipboard() { + return mc.keyboard.getClipboard(); + } + + @LuaExpose + public void setClipboard(String text) { + mc.keyboard.setClipboard(text); + } + + @LuaExpose + public int windowWidth() { + return mc.getWindow().getScaledWidth(); + } + + @LuaExpose + public int windowHeight() { + return mc.getWindow().getScaledHeight(); + } } diff --git a/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftEntity.java b/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftEntity.java index 8ce12ad..bfe18b9 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftEntity.java +++ b/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftEntity.java @@ -4,14 +4,17 @@ import lombok.AllArgsConstructor; import net.minecraft.entity.Entity; import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.effect.StatusEffect; import net.minecraft.entity.player.PlayerEntity; import net.minecraft.item.ItemStack; import net.minecraft.registry.Registries; +import net.minecraft.util.Identifier; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @AllArgsConstructor public class MinecraftEntity { @@ -87,4 +90,142 @@ public int foodLevel() { } return -1; } + + // ===== Additional Methods ===== + + @LuaExpose + public double maxHealth() { + return entity instanceof LivingEntity le ? le.getMaxHealth() : -1; + } + + @LuaExpose + public double distanceTo(double x, double y, double z) { + return entity.getPos().distanceTo(new Vec3d(x, y, z)); + } + + @LuaExpose + public Vec3d velocity() { + return entity.getVelocity(); + } + + @LuaExpose + public float yaw() { + return entity.getYaw(); + } + + @LuaExpose + public float pitch() { + return entity.getPitch(); + } + + @LuaExpose + public boolean isAlive() { + return entity.isAlive(); + } + + @LuaExpose + public boolean isSneaking() { + return entity.isSneaking(); + } + + @LuaExpose + public boolean isSprinting() { + return entity.isSprinting(); + } + + @LuaExpose + public boolean isOnFire() { + return entity.isOnFire(); + } + + @LuaExpose + public boolean isInvisible() { + return entity.isInvisible(); + } + + @LuaExpose + public boolean isGlowing() { + return entity.isGlowing(); + } + + @LuaExpose + public boolean isTouchingWater() { + return entity.isTouchingWater(); + } + + @LuaExpose + public List effects() { + if (entity instanceof LivingEntity le) { + return le.getStatusEffects().stream() + .map(e -> Registries.STATUS_EFFECT.getId(e.getEffectType()).toString()) + .collect(Collectors.toList()); + } + return List.of(); + } + + @LuaExpose + public float saturation() { + if (entity instanceof PlayerEntity player) { + return player.getHungerManager().getSaturationLevel(); + } + return -1; + } + + @LuaExpose + public int armorValue() { + return entity instanceof LivingEntity le ? le.getArmor() : 0; + } + + @LuaExpose + public boolean hasEffect(String effectId) { + if (entity instanceof LivingEntity le) { + StatusEffect effect = Registries.STATUS_EFFECT.get(new Identifier(effectId)); + return effect != null && le.hasStatusEffect(effect); + } + return false; + } + + // ===== Player-Specific Methods ===== + + @LuaExpose + public int experienceLevel() { + if (entity instanceof PlayerEntity player) { + return player.experienceLevel; + } + return -1; + } + + @LuaExpose + public float experienceProgress() { + if (entity instanceof PlayerEntity player) { + return player.experienceProgress; + } + return -1; + } + + @LuaExpose + public int totalExperience() { + if (entity instanceof PlayerEntity player) { + return player.totalExperience; + } + return -1; + } + + @LuaExpose + public boolean isOnGround() { + return entity.isOnGround(); + } + + @LuaExpose + public boolean isSwimming() { + return entity.isSwimming(); + } + + @LuaExpose + public boolean isFlying() { + if (entity instanceof PlayerEntity player) { + return player.getAbilities().flying; + } + return false; + } } diff --git a/src/test/resources/assets/litmus/script/clipboard_demo.lua b/src/test/resources/assets/litmus/script/clipboard_demo.lua new file mode 100644 index 0000000..3fc6831 --- /dev/null +++ b/src/test/resources/assets/litmus/script/clipboard_demo.lua @@ -0,0 +1,45 @@ +-- Clipboard Demo Script: Demonstrates clipboard and UI functionality +-- Run with: /amblescript execute litmus:clipboard_demo + +function onExecute() + local player = minecraft:player() + local pos = player:position() + + -- Header + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§e§l✦ Clipboard Demo ✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Show current clipboard content + local currentClipboard = minecraft:clipboard() + if currentClipboard and currentClipboard ~= "" then + local preview = currentClipboard + if #preview > 50 then + preview = preview:sub(1, 50) .. "..." + end + minecraft:sendMessage("§7Current clipboard: §f" .. preview, false) + else + minecraft:sendMessage("§7Current clipboard: §8(empty)", false) + end + + minecraft:sendMessage("", false) + + -- Copy coordinates to clipboard + local coords = string.format("%.0f %.0f %.0f", pos.x, pos.y, pos.z) + minecraft:setClipboard(coords) + minecraft:sendMessage("§a✓ Copied coordinates to clipboard!", false) + minecraft:sendMessage("§7 " .. coords, false) + + minecraft:sendMessage("", false) + + -- Window info + minecraft:sendMessage("§e§l✦ Window Info ✦", false) + minecraft:sendMessage("§7Window size: §f" .. minecraft:windowWidth() .. "§7 x §f" .. minecraft:windowHeight(), false) + + -- Play a sound to indicate success + minecraft:playSound("minecraft:entity.experience_orb.pickup", 1.0, 1.5) + + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§7Tip: Paste (Ctrl+V) to use the coordinates!", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/script/entity_inspect.lua b/src/test/resources/assets/litmus/script/entity_inspect.lua new file mode 100644 index 0000000..c0b2d95 --- /dev/null +++ b/src/test/resources/assets/litmus/script/entity_inspect.lua @@ -0,0 +1,160 @@ +-- Entity Inspect Script: Shows info about the entity you're looking at +-- Run with: /amblescript execute litmus:entity_inspect + +function onExecute() + local target = minecraft:lookingAtEntity() + + -- Header + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§e§l✦ Entity Inspector ✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + if not target then + minecraft:sendMessage("§8Look at an entity and run this script!", false) + + -- Show nearest entity instead + local nearest = minecraft:nearestEntity(10) + if nearest then + minecraft:sendMessage("", false) + minecraft:sendMessage("§7Nearest entity (within 10 blocks):", false) + minecraft:sendMessage("§a→ §f" .. nearest:name() .. " §7(" .. nearest:type():gsub("minecraft:", "") .. ")", false) + + local player = minecraft:player() + local playerPos = player:position() + local distance = nearest:distanceTo(playerPos.x, playerPos.y, playerPos.z) + minecraft:sendMessage("§7 Distance: §e" .. string.format("%.1f", distance) .. " blocks", false) + else + minecraft:sendMessage("§8No entities within 10 blocks!", false) + end + + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + return + end + + -- Basic info + minecraft:sendMessage("§7Name: §f" .. target:name(), false) + minecraft:sendMessage("§7Type: §b" .. target:type():gsub("minecraft:", ""), false) + minecraft:sendMessage("§7UUID: §8" .. target:uuid():sub(1, 8) .. "...", false) + + -- Position + local pos = target:position() + minecraft:sendMessage("§7Position: §f" .. string.format("%.1f", pos.x) .. "§7, §f" .. string.format("%.1f", pos.y) .. "§7, §f" .. string.format("%.1f", pos.z), false) + + -- Distance from player + local player = minecraft:player() + local playerPos = player:position() + local distance = target:distanceTo(playerPos.x, playerPos.y, playerPos.z) + minecraft:sendMessage("§7Distance: §e" .. string.format("%.1f", distance) .. " blocks", false) + + -- Health (if living entity) + local health = target:health() + local maxHealth = target:maxHealth() + if health >= 0 then + -- Health bar + local barLength = 15 + local healthPercent = health / maxHealth + local filledLength = math.floor(healthPercent * barLength) + local healthColor = "§a" + if healthPercent < 0.25 then + healthColor = "§c" + elseif healthPercent < 0.5 then + healthColor = "§e" + end + + local healthBar = healthColor + for i = 1, barLength do + if i <= filledLength then + healthBar = healthBar .. "❤" + else + healthBar = healthBar .. "§8❤" + end + end + + minecraft:sendMessage("§7Health: " .. healthBar .. " §f" .. string.format("%.1f", health) .. "§7/§f" .. string.format("%.0f", maxHealth), false) + end + + -- Armor + local armor = target:armorValue() + if armor > 0 then + minecraft:sendMessage("§9Armor: §f" .. armor, false) + end + + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§e§l✦ Entity State ✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- States + local states = {} + + if target:isPlayer() then + table.insert(states, "§a✓ Player") + end + + if target:isAlive() then + table.insert(states, "§a✓ Alive") + else + table.insert(states, "§c✗ Dead") + end + + if target:isOnGround() then + table.insert(states, "§7• On Ground") + else + table.insert(states, "§7• Airborne") + end + + if target:isSprinting() then + table.insert(states, "§e• Sprinting") + end + + if target:isSneaking() then + table.insert(states, "§7• Sneaking") + end + + if target:isTouchingWater() then + table.insert(states, "§b• In Water") + end + + if target:isSwimming() then + table.insert(states, "§b• Swimming") + end + + if target:isOnFire() then + table.insert(states, "§c🔥 On Fire!") + end + + if target:isInvisible() then + table.insert(states, "§7• Invisible") + end + + if target:isGlowing() then + table.insert(states, "§e• Glowing") + end + + for _, state in ipairs(states) do + minecraft:sendMessage(" " .. state, false) + end + + -- Velocity + local vel = target:velocity() + local speed = math.sqrt(vel.x * vel.x + vel.z * vel.z) + minecraft:sendMessage("§7Speed: §f" .. string.format("%.2f", speed * 20) .. " §7blocks/sec", false) + + -- Rotation + minecraft:sendMessage("§7Looking: §fYaw " .. string.format("%.0f", target:yaw()) .. "°, Pitch " .. string.format("%.0f", target:pitch()) .. "°", false) + + -- Age + minecraft:sendMessage("§7Age: §f" .. target:age() .. " ticks §8(" .. string.format("%.1f", target:age() / 20) .. "s)", false) + + -- Effects + local effects = target:effects() + if #effects > 0 then + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§d§l✦ Status Effects ✦", false) + for _, effect in ipairs(effects) do + local cleanEffect = effect:gsub("minecraft:", ""):gsub("_", " ") + minecraft:sendMessage(" §d✧ §f" .. cleanEffect, false) + end + end + + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/script/input_test.lua b/src/test/resources/assets/litmus/script/input_test.lua new file mode 100644 index 0000000..a0ef71b --- /dev/null +++ b/src/test/resources/assets/litmus/script/input_test.lua @@ -0,0 +1,61 @@ +-- Input Test Script: Shows which movement keys are currently pressed +-- Run with: /amblescript execute litmus:input_test + +function onExecute() + -- Header + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§e§l✦ Input State ✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Movement keys + local keys = { + {"forward", "W", "Forward"}, + {"back", "S", "Back"}, + {"left", "A", "Left"}, + {"right", "D", "Right"}, + {"jump", "Space", "Jump"}, + {"sneak", "Shift", "Sneak"}, + {"sprint", "Ctrl", "Sprint"}, + {"attack", "LMB", "Attack"}, + {"use", "RMB", "Use"} + } + + -- Visual keyboard layout for WASD + local w = minecraft:isKeyPressed("forward") and "§a[W]" or "§8[W]" + local a = minecraft:isKeyPressed("left") and "§a[A]" or "§8[A]" + local s = minecraft:isKeyPressed("back") and "§a[S]" or "§8[S]" + local d = minecraft:isKeyPressed("right") and "§a[D]" or "§8[D]" + + minecraft:sendMessage("§7Movement Keys:", false) + minecraft:sendMessage(" " .. w, false) + minecraft:sendMessage(" " .. a .. " " .. s .. " " .. d, false) + minecraft:sendMessage("", false) + + -- Other keys + minecraft:sendMessage("§7Action Keys:", false) + + local pressedKeys = {} + local unpressedKeys = {} + + for _, keyData in ipairs(keys) do + local keyName = keyData[1] + local displayKey = keyData[2] + local description = keyData[3] + + if minecraft:isKeyPressed(keyName) then + table.insert(pressedKeys, " §a✓ " .. displayKey .. " §7(" .. description .. ")") + end + end + + if #pressedKeys > 0 then + for _, msg in ipairs(pressedKeys) do + minecraft:sendMessage(msg, false) + end + else + minecraft:sendMessage(" §8No action keys pressed", false) + end + + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§7Tip: Hold keys while running this script!", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/script/item_info.lua b/src/test/resources/assets/litmus/script/item_info.lua new file mode 100644 index 0000000..02b6cac --- /dev/null +++ b/src/test/resources/assets/litmus/script/item_info.lua @@ -0,0 +1,112 @@ +-- Item Info Script: Shows detailed information about held item +-- Run with: /amblescript execute litmus:item_info + +function onExecute() + local player = minecraft:player() + local inventory = player:inventory() + local selectedSlot = minecraft:selectedSlot() + local heldItem = inventory[selectedSlot] + + -- Header + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§e§l✦ Held Item Info (Slot " .. selectedSlot .. ") ✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + if heldItem:isEmpty() then + minecraft:sendMessage("§8You're not holding anything!", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + return + end + + -- Basic info + minecraft:sendMessage("§7Name: §f" .. heldItem:name(), false) + minecraft:sendMessage("§7ID: §b" .. heldItem:id(), false) + + -- Rarity with color + local rarity = heldItem:rarity() + local rarityColor = "§f" + if rarity == "uncommon" then + rarityColor = "§e" + elseif rarity == "rare" then + rarityColor = "§b" + elseif rarity == "epic" then + rarityColor = "§d" + end + minecraft:sendMessage("§7Rarity: " .. rarityColor .. rarity:sub(1,1):upper() .. rarity:sub(2), false) + + -- Stack info + local count = heldItem:count() + local maxCount = heldItem:maxCount() + if heldItem:isStackable() then + minecraft:sendMessage("§7Stack: §f" .. count .. "§7/§f" .. maxCount, false) + else + minecraft:sendMessage("§7Stack: §8Not stackable", false) + end + + -- Durability + if heldItem:isDamageable() then + local damage = heldItem:damage() + local maxDamage = heldItem:maxDamage() + local durability = maxDamage - damage + local durabilityPercent = heldItem:durabilityPercent() + + -- Durability bar + local barLength = 20 + local filledLength = math.floor(durabilityPercent * barLength) + local durColor = "§a" + if durabilityPercent < 0.25 then + durColor = "§c" + elseif durabilityPercent < 0.5 then + durColor = "§e" + end + + local durBar = durColor + for i = 1, barLength do + if i <= filledLength then + durBar = durBar .. "|" + else + durBar = durBar .. "§8|" + end + end + + minecraft:sendMessage("§7Durability: " .. durBar .. " §f" .. durability .. "§7/§f" .. maxDamage, false) + end + + -- Food info + if heldItem:isFood() then + minecraft:sendMessage("§6🍖 This item is edible!", false) + end + + -- Custom name + if heldItem:hasCustomName() then + minecraft:sendMessage("§7Custom Named: §a✓", false) + end + + -- Enchantments + if heldItem:hasEnchantments() then + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§d§l✦ Enchantments ✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + local enchants = heldItem:enchantments() + for _, enchant in ipairs(enchants) do + -- Parse enchantment:level format + local colonPos = enchant:find(":") + if colonPos then + local lastColon = enchant:match(".*():") + local enchantName = enchant:sub(1, lastColon - 1):gsub("minecraft:", ""):gsub("_", " ") + local level = enchant:sub(lastColon + 1) + minecraft:sendMessage(" §d✧ §f" .. enchantName .. " §7" .. level, false) + else + minecraft:sendMessage(" §d✧ §f" .. enchant, false) + end + end + end + + -- NBT info + if heldItem:hasNbt() then + minecraft:sendMessage("§7Has NBT Data: §a✓", false) + end + + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/script/player_state.lua b/src/test/resources/assets/litmus/script/player_state.lua new file mode 100644 index 0000000..bb9f7bd --- /dev/null +++ b/src/test/resources/assets/litmus/script/player_state.lua @@ -0,0 +1,115 @@ +-- Player State Script: Shows detailed player state information +-- Run with: /amblescript execute litmus:player_state + +function onExecute() + local player = minecraft:player() + + -- Header + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§e§l✦ Player State: §f" .. minecraft:username() .. " §e§l✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Health & Hunger + local health = player:health() + local maxHealth = player:maxHealth() + local food = player:foodLevel() + local saturation = player:saturation() + local armor = player:armorValue() + + minecraft:sendMessage("§c❤ Health: §f" .. string.format("%.1f", health) .. "§7/§f" .. string.format("%.0f", maxHealth), false) + minecraft:sendMessage("§6🍖 Hunger: §f" .. food .. "§7/§f20 §8(Saturation: " .. string.format("%.1f", saturation) .. ")", false) + minecraft:sendMessage("§9🛡 Armor: §f" .. armor, false) + + -- Experience + local xpLevel = player:experienceLevel() + local xpProgress = player:experienceProgress() + local totalXp = player:totalExperience() + + -- XP bar visualization + local barLength = 20 + local filledLength = math.floor(xpProgress * barLength) + local xpBar = "§a" + for i = 1, barLength do + if i <= filledLength then + xpBar = xpBar .. "|" + else + xpBar = xpBar .. "§8|" + end + end + minecraft:sendMessage("§a✧ Level: §f" .. xpLevel .. " " .. xpBar .. " §7(" .. string.format("%.0f", xpProgress * 100) .. "%)", false) + minecraft:sendMessage("§7 Total XP: §e" .. totalXp, false) + + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§e§l✦ Movement State ✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Movement states + local states = {} + + if player:isOnGround() then + table.insert(states, "§a✓ On Ground") + else + table.insert(states, "§c✗ Airborne") + end + + if player:isSprinting() then + table.insert(states, "§a✓ Sprinting") + end + + if player:isSneaking() then + table.insert(states, "§a✓ Sneaking") + end + + if player:isSwimming() then + table.insert(states, "§b✓ Swimming") + end + + if player:isTouchingWater() then + table.insert(states, "§b✓ In Water") + end + + if player:isFlying() then + table.insert(states, "§d✓ Flying") + end + + if player:isOnFire() then + table.insert(states, "§c🔥 On Fire!") + end + + if player:isInvisible() then + table.insert(states, "§7✓ Invisible") + end + + if player:isGlowing() then + table.insert(states, "§e✓ Glowing") + end + + for _, state in ipairs(states) do + minecraft:sendMessage(" " .. state, false) + end + + -- Velocity + local vel = player:velocity() + local speed = math.sqrt(vel.x * vel.x + vel.z * vel.z) + minecraft:sendMessage("§7Speed: §f" .. string.format("%.2f", speed * 20) .. " §7blocks/sec", false) + + -- Game mode + minecraft:sendMessage("§7Game Mode: §e" .. minecraft:gameMode(), false) + + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§e§l✦ Active Effects ✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Status effects + local effects = player:effects() + if #effects > 0 then + for _, effect in ipairs(effects) do + local cleanEffect = effect:gsub("minecraft:", ""):gsub("_", " ") + minecraft:sendMessage(" §d✦ §f" .. cleanEffect, false) + end + else + minecraft:sendMessage(" §8No active effects", false) + end + + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/assets/litmus/script/world_info.lua b/src/test/resources/assets/litmus/script/world_info.lua new file mode 100644 index 0000000..825631b --- /dev/null +++ b/src/test/resources/assets/litmus/script/world_info.lua @@ -0,0 +1,91 @@ +-- World Info Script: Displays world environment information +-- Run with: /amblescript execute litmus:world_info + +function onExecute() + local player = minecraft:player() + local pos = player:blockPosition() + + -- Header + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§e§l✦ World Information ✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Dimension + local dimension = minecraft:dimension() + local dimColor = "§a" + if dimension:find("nether") then + dimColor = "§c" + elseif dimension:find("end") then + dimColor = "§d" + end + minecraft:sendMessage("§7Dimension: " .. dimColor .. dimension, false) + + -- Time of day + local worldTime = minecraft:worldTime() + local dayCount = minecraft:dayCount() + local timeOfDay = worldTime % 24000 + + local timeString = "Day" + local timeIcon = "☀" + if timeOfDay >= 13000 and timeOfDay < 23000 then + timeString = "Night" + timeIcon = "☾" + elseif timeOfDay >= 23000 or timeOfDay < 1000 then + timeString = "Dawn" + timeIcon = "✧" + elseif timeOfDay >= 11000 and timeOfDay < 13000 then + timeString = "Dusk" + timeIcon = "✧" + end + + minecraft:sendMessage("§7Time: §e" .. timeIcon .. " " .. timeString .. " §7(Day §f" .. dayCount .. "§7)", false) + + -- Weather + local weatherIcon = "☀" + local weatherText = "Clear" + local weatherColor = "§e" + if minecraft:isThundering() then + weatherIcon = "⚡" + weatherText = "Thunderstorm" + weatherColor = "§5" + elseif minecraft:isRaining() then + weatherIcon = "🌧" + weatherText = "Raining" + weatherColor = "§9" + end + minecraft:sendMessage("§7Weather: " .. weatherColor .. weatherIcon .. " " .. weatherText, false) + + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + minecraft:sendMessage("§e§l✦ Location Details ✦", false) + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Biome + local biome = minecraft:biomeAt(pos.x, pos.y, pos.z) + local cleanBiome = biome:gsub("minecraft:", ""):gsub("_", " ") + minecraft:sendMessage("§7Biome: §a" .. cleanBiome, false) + + -- Block below player + local blockBelow = minecraft:blockAt(pos.x, pos.y - 1, pos.z) + local cleanBlock = blockBelow:gsub("minecraft:", ""):gsub("_", " ") + minecraft:sendMessage("§7Standing on: §b" .. cleanBlock, false) + + -- Light level + local lightLevel = minecraft:lightLevelAt(pos.x, pos.y, pos.z) + local lightColor = "§a" + if lightLevel < 8 then + lightColor = "§c" -- Mobs can spawn + elseif lightLevel < 12 then + lightColor = "§e" + end + minecraft:sendMessage("§7Light Level: " .. lightColor .. lightLevel .. " §8(mobs spawn below 8)", false) + + -- Looking at block + local lookingAt = minecraft:lookingAtBlock() + if lookingAt then + local targetBlock = minecraft:blockAt(lookingAt.x, lookingAt.y, lookingAt.z) + local cleanTarget = targetBlock:gsub("minecraft:", ""):gsub("_", " ") + minecraft:sendMessage("§7Looking at: §d" .. cleanTarget .. " §8(" .. lookingAt.x .. ", " .. lookingAt.y .. ", " .. lookingAt.z .. ")", false) + end + + minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end From 4436c17f0acc5030638d4d4132a25974a03e4cfc Mon Sep 17 00:00:00 2001 From: James Hall Date: Thu, 8 Jan 2026 15:38:20 +0000 Subject: [PATCH 13/37] Add playSoundAt method to MinecraftData for Lua scripting support --- .../dev/amble/lib/client/gui/lua/mc/MinecraftData.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java b/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java index cac0a08..8ef4507 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java +++ b/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java @@ -169,6 +169,15 @@ public void playSound(String soundId, float volume, float pitch) { } } + @LuaExpose + public void playSoundAt(String soundId, double x, double y, double z, float volume, float pitch) { + Identifier id = new Identifier(soundId); + SoundEvent sound = Registries.SOUND_EVENT.get(id); + if (sound != null) { + mc.world.playSound(x, y, z, sound, mc.player.getSoundCategory(), volume, pitch, false); + } + } + // ===== Entity Queries ===== @LuaExpose From 9a69fd1cc6248a12dabc05e63817297d6ce2c861 Mon Sep 17 00:00:00 2001 From: James Hall Date: Thu, 8 Jan 2026 15:48:44 +0000 Subject: [PATCH 14/37] Add script lifecycle system with enable/disable/tick functionality --- .../dev/amble/lib/client/AmbleKitClient.java | 3 +- .../lib/command/ExecuteScriptCommand.java | 110 +++++++++++++++++- .../dev/amble/lib/script/AmbleScript.java | 5 +- .../dev/amble/lib/script/ScriptManager.java | 90 +++++++++++++- .../assets/litmus/script/auto_torch.lua | 45 +++++++ .../assets/litmus/script/sprint_monitor.lua | 67 +++++++++++ .../assets/litmus/script/tick_demo.lua | 38 ++++++ 7 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 src/test/resources/assets/litmus/script/auto_torch.lua create mode 100644 src/test/resources/assets/litmus/script/sprint_monitor.lua create mode 100644 src/test/resources/assets/litmus/script/tick_demo.lua diff --git a/src/main/java/dev/amble/lib/client/AmbleKitClient.java b/src/main/java/dev/amble/lib/client/AmbleKitClient.java index 6ce43d4..6cbc181 100644 --- a/src/main/java/dev/amble/lib/client/AmbleKitClient.java +++ b/src/main/java/dev/amble/lib/client/AmbleKitClient.java @@ -40,7 +40,8 @@ public void onInitializeClient() { }); ClientTickEvents.END_CLIENT_TICK.register((client) -> { - SkinGrabber.INSTANCE.tick(); + SkinGrabber.INSTANCE.tick(); + ScriptManager.tick(); }); } diff --git a/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java b/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java index f1bc402..25a50dc 100644 --- a/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java +++ b/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java @@ -11,8 +11,11 @@ import net.minecraft.command.CommandSource; import net.minecraft.command.argument.IdentifierArgumentType; import net.minecraft.text.Text; +import net.minecraft.util.Formatting; import net.minecraft.util.Identifier; +import java.util.Set; + import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument; import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; @@ -26,12 +29,34 @@ public class ExecuteScriptCommand { ); }; + private static final SuggestionProvider ENABLED_SCRIPT_SUGGESTIONS = (context, builder) -> { + return CommandSource.suggestIdentifiers( + ScriptManager.getEnabledScripts().stream() + .map(id -> Identifier.of(id.getNamespace(), id.getPath().replace("script/", "").replace(".lua", ""))), + builder + ); + }; + public static void register(CommandDispatcher dispatcher) { dispatcher.register(literal("amblescript") .then(literal("execute") .then(argument("id", IdentifierArgumentType.identifier()) .suggests(SCRIPT_SUGGESTIONS) - .executes(ExecuteScriptCommand::execute)))); + .executes(ExecuteScriptCommand::execute))) + .then(literal("enable") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(SCRIPT_SUGGESTIONS) + .executes(ExecuteScriptCommand::enable))) + .then(literal("disable") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(ENABLED_SCRIPT_SUGGESTIONS) + .executes(ExecuteScriptCommand::disable))) + .then(literal("toggle") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(SCRIPT_SUGGESTIONS) + .executes(ExecuteScriptCommand::toggle))) + .then(literal("list") + .executes(ExecuteScriptCommand::listEnabled))); } private static int execute(CommandContext context) { @@ -58,4 +83,87 @@ private static int execute(CommandContext context) { return 0; } } + + private static int enable(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + + // Ensure script is loaded + try { + ScriptManager.load(fullScriptId, MinecraftClient.getInstance().getResourceManager()); + } catch (Exception e) { + context.getSource().sendError(Text.literal("Script '" + scriptId + "' not found")); + return 0; + } + + if (ScriptManager.isEnabled(fullScriptId)) { + context.getSource().sendError(Text.literal("Script '" + scriptId + "' is already enabled")); + return 0; + } + + if (ScriptManager.enable(fullScriptId)) { + context.getSource().sendFeedback(Text.literal("§aEnabled script: " + scriptId)); + return 1; + } else { + context.getSource().sendError(Text.literal("Failed to enable script '" + scriptId + "'")); + return 0; + } + } + + private static int disable(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + + if (!ScriptManager.isEnabled(fullScriptId)) { + context.getSource().sendError(Text.literal("Script '" + scriptId + "' is not enabled")); + return 0; + } + + if (ScriptManager.disable(fullScriptId)) { + context.getSource().sendFeedback(Text.literal("§cDisabled script: " + scriptId)); + return 1; + } else { + context.getSource().sendError(Text.literal("Failed to disable script '" + scriptId + "'")); + return 0; + } + } + + private static int toggle(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + + // Ensure script is loaded + try { + ScriptManager.load(fullScriptId, MinecraftClient.getInstance().getResourceManager()); + } catch (Exception e) { + context.getSource().sendError(Text.literal("Script '" + scriptId + "' not found")); + return 0; + } + + boolean wasEnabled = ScriptManager.isEnabled(fullScriptId); + ScriptManager.toggle(fullScriptId); + + if (wasEnabled) { + context.getSource().sendFeedback(Text.literal("§cDisabled script: " + scriptId)); + } else { + context.getSource().sendFeedback(Text.literal("§aEnabled script: " + scriptId)); + } + return 1; + } + + private static int listEnabled(CommandContext context) { + Set enabled = ScriptManager.getEnabledScripts(); + + if (enabled.isEmpty()) { + context.getSource().sendFeedback(Text.literal("§7No scripts are currently enabled")); + return 1; + } + + context.getSource().sendFeedback(Text.literal("§6§l━━━ Enabled Scripts (" + enabled.size() + ") ━━━")); + for (Identifier id : enabled) { + String displayId = id.getPath().replace("script/", "").replace(".lua", ""); + context.getSource().sendFeedback(Text.literal("§a✓ §f" + id.getNamespace() + ":" + displayId)); + } + return 1; + } } diff --git a/src/main/java/dev/amble/lib/script/AmbleScript.java b/src/main/java/dev/amble/lib/script/AmbleScript.java index 8c74446..bd61dba 100644 --- a/src/main/java/dev/amble/lib/script/AmbleScript.java +++ b/src/main/java/dev/amble/lib/script/AmbleScript.java @@ -7,5 +7,8 @@ public record AmbleScript( LuaValue onClick, LuaValue onRelease, LuaValue onHover, - LuaValue onExecute + LuaValue onExecute, + LuaValue onEnable, + LuaValue onTick, + LuaValue onDisable ) {} diff --git a/src/main/java/dev/amble/lib/script/ScriptManager.java b/src/main/java/dev/amble/lib/script/ScriptManager.java index c7710f0..c8b5b16 100644 --- a/src/main/java/dev/amble/lib/script/ScriptManager.java +++ b/src/main/java/dev/amble/lib/script/ScriptManager.java @@ -14,11 +14,14 @@ import java.io.InputStreamReader; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; public class ScriptManager implements SimpleSynchronousResourceReloadListener { private static final ScriptManager INSTANCE = new ScriptManager(); private static final Map CACHE = new HashMap<>(); + private static final Set ENABLED_SCRIPTS = new HashSet<>(); private ScriptManager() { ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES) @@ -36,6 +39,11 @@ public Identifier getFabricId() { @Override public void reload(ResourceManager manager) { + // Disable all scripts before clearing cache + for (Identifier id : new HashSet<>(ENABLED_SCRIPTS)) { + disable(id); + } + CACHE.clear(); // Discover all script files and populate the cache for suggestions @@ -72,7 +80,10 @@ public static AmbleScript load(Identifier id, ResourceManager manager) { globals.get("onClick"), globals.get("onRelease"), globals.get("onHover"), - globals.get("onExecute") + globals.get("onExecute"), + globals.get("onEnable"), + globals.get("onTick"), + globals.get("onDisable") ); } catch (Exception e) { throw new RuntimeException("Failed to load script " + key, e); @@ -83,4 +94,81 @@ public static AmbleScript load(Identifier id, ResourceManager manager) { public static Map getCache() { return CACHE; } + + public static Set getEnabledScripts() { + return ENABLED_SCRIPTS; + } + + public static boolean isEnabled(Identifier id) { + return ENABLED_SCRIPTS.contains(id); + } + + public static boolean enable(Identifier id) { + if (ENABLED_SCRIPTS.contains(id)) { + return false; // Already enabled + } + + AmbleScript script = CACHE.get(id); + if (script == null) { + return false; + } + + ENABLED_SCRIPTS.add(id); + + // Call onEnable + if (script.onEnable() != null && !script.onEnable().isnil()) { + try { + script.onEnable().call(); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onEnable for script {}", id, e); + } + } + + AmbleKit.LOGGER.info("Enabled script: {}", id); + return true; + } + + public static boolean disable(Identifier id) { + if (!ENABLED_SCRIPTS.contains(id)) { + return false; // Not enabled + } + + AmbleScript script = CACHE.get(id); + + // Call onDisable before removing + if (script != null && script.onDisable() != null && !script.onDisable().isnil()) { + try { + script.onDisable().call(); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onDisable for script {}", id, e); + } + } + + ENABLED_SCRIPTS.remove(id); + AmbleKit.LOGGER.info("Disabled script: {}", id); + return true; + } + + public static boolean toggle(Identifier id) { + if (isEnabled(id)) { + return disable(id); + } else { + return enable(id); + } + } + + public static void tick() { + for (Identifier id : ENABLED_SCRIPTS) { + AmbleScript script = CACHE.get(id); + if (script != null && script.onTick() != null && !script.onTick().isnil()) { + try { + script.onTick().call(); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onTick for script {}", id, e); + // Optionally disable the script on error + // disable(id); + } + } + } + } } diff --git a/src/test/resources/assets/litmus/script/auto_torch.lua b/src/test/resources/assets/litmus/script/auto_torch.lua new file mode 100644 index 0000000..6cf6fcb --- /dev/null +++ b/src/test/resources/assets/litmus/script/auto_torch.lua @@ -0,0 +1,45 @@ +-- Auto Torch Script: Warns when light level is low (mobs can spawn) +-- Enable with: /amblescript enable litmus:auto_torch +-- Disable with: /amblescript disable litmus:auto_torch + +local lastWarning = 0 +local WARNING_COOLDOWN = 100 -- ticks between warnings (5 seconds) +local ticksSinceWarning = WARNING_COOLDOWN + +function onEnable() + minecraft:sendMessage("§e🔦 Auto Torch Advisor enabled!", false) + minecraft:sendMessage("§7 Will warn you when light level is dangerously low", false) +end + +function onTick() + ticksSinceWarning = ticksSinceWarning + 1 + + -- Only check every 10 ticks for performance + if ticksSinceWarning % 10 ~= 0 then + return + end + + local player = minecraft:player() + local pos = player:blockPosition() + local lightLevel = minecraft:lightLevelAt(pos.x, pos.y, pos.z) + + -- Check if we're on the ground and light is low + if player:isOnGround() and lightLevel < 8 and ticksSinceWarning >= WARNING_COOLDOWN then + ticksSinceWarning = 0 + + -- Different warnings based on light level + if lightLevel <= 0 then + minecraft:sendMessage("§4⚠ DANGER! §cComplete darkness (Light: " .. lightLevel .. ") - Mobs WILL spawn!", true) + minecraft:playSound("minecraft:block.note_block.bass", 0.8, 0.5) + elseif lightLevel <= 3 then + minecraft:sendMessage("§c⚠ Warning! §eVery dark (Light: " .. lightLevel .. ") - High spawn risk!", true) + minecraft:playSound("minecraft:block.note_block.hat", 0.5, 0.8) + else + minecraft:sendMessage("§e⚠ Caution: §7Low light (Light: " .. lightLevel .. ") - Mobs can spawn", true) + end + end +end + +function onDisable() + minecraft:sendMessage("§7🔦 Auto Torch Advisor disabled", false) +end diff --git a/src/test/resources/assets/litmus/script/sprint_monitor.lua b/src/test/resources/assets/litmus/script/sprint_monitor.lua new file mode 100644 index 0000000..ce64eed --- /dev/null +++ b/src/test/resources/assets/litmus/script/sprint_monitor.lua @@ -0,0 +1,67 @@ +-- Sprint Monitor Script: Shows sprint/movement info in action bar +-- Enable with: /amblescript enable litmus:sprint_monitor +-- Disable with: /amblescript disable litmus:sprint_monitor + +local lastUpdate = 0 +local UPDATE_INTERVAL = 2 -- Update every 2 ticks for smooth display + +function onEnable() + minecraft:sendMessage("§b🏃 Sprint Monitor enabled!", false) +end + +function onTick() + lastUpdate = lastUpdate + 1 + if lastUpdate < UPDATE_INTERVAL then + return + end + lastUpdate = 0 + + local player = minecraft:player() + local vel = player:velocity() + local speed = math.sqrt(vel.x * vel.x + vel.z * vel.z) * 20 -- blocks per second + + -- Build status string + local status = "" + + -- Movement mode + if player:isFlying() then + status = status .. "§b✈ Flying " + elseif player:isSwimming() then + status = status .. "§3🏊 Swimming " + elseif player:isSprinting() then + status = status .. "§a🏃 Sprinting " + elseif player:isSneaking() then + status = status .. "§7🚶 Sneaking " + elseif speed > 0.1 then + status = status .. "§f🚶 Walking " + else + status = status .. "§8⏸ Still " + end + + -- Speed indicator + local speedColor = "§7" + if speed > 10 then + speedColor = "§c" + elseif speed > 7 then + speedColor = "§e" + elseif speed > 4 then + speedColor = "§a" + end + status = status .. speedColor .. string.format("%.1f", speed) .. " m/s" + + -- Ground state + if not player:isOnGround() and not player:isFlying() and not player:isSwimming() then + status = status .. " §d↑ Airborne" + end + + -- Water state + if player:isTouchingWater() and not player:isSwimming() then + status = status .. " §9💧" + end + + minecraft:sendMessage(status, true) +end + +function onDisable() + minecraft:sendMessage("§7🏃 Sprint Monitor disabled", false) +end diff --git a/src/test/resources/assets/litmus/script/tick_demo.lua b/src/test/resources/assets/litmus/script/tick_demo.lua new file mode 100644 index 0000000..debb78c --- /dev/null +++ b/src/test/resources/assets/litmus/script/tick_demo.lua @@ -0,0 +1,38 @@ +-- Tick Demo Script: Demonstrates onEnable, onTick, onDisable lifecycle +-- Enable with: /amblescript enable litmus:tick_demo +-- Disable with: /amblescript disable litmus:tick_demo + +local tickCount = 0 +local lastSecond = 0 + +function onEnable() + tickCount = 0 + lastSecond = 0 + minecraft:sendMessage("§a✓ Tick Demo enabled! Counting ticks...", false) + minecraft:playSound("minecraft:block.note_block.pling", 1.0, 2.0) +end + +function onTick() + tickCount = tickCount + 1 + + -- Every 20 ticks (1 second), show a message + local currentSecond = math.floor(tickCount / 20) + if currentSecond > lastSecond then + lastSecond = currentSecond + + -- Show in action bar every second + minecraft:sendMessage("§7Tick Demo: §e" .. tickCount .. " ticks §7(§f" .. currentSecond .. "s§7)", true) + + -- Play a subtle sound every 5 seconds + if currentSecond % 5 == 0 then + minecraft:playSound("minecraft:block.note_block.hat", 0.5, 1.0) + end + end +end + +function onDisable() + local totalSeconds = math.floor(tickCount / 20) + minecraft:sendMessage("§c✗ Tick Demo disabled!", false) + minecraft:sendMessage("§7 Ran for §e" .. tickCount .. " ticks §7(§f" .. totalSeconds .. " seconds§7)", false) + minecraft:playSound("minecraft:block.note_block.bass", 1.0, 0.5) +end From 6f3bf617eee51f43f57c8e10afc152360d96d5bd Mon Sep 17 00:00:00 2001 From: James Hall Date: Thu, 8 Jan 2026 21:28:35 +0000 Subject: [PATCH 15/37] Add server-side scripting support with ServerScriptManager and commands Introduce server-side Lua scripting system parallel to client scripts: - Add ServerScriptManager for managing data pack scripts - Add ServerScriptCommand with enable/disable/execute/toggle/list commands - Refactor Lua bindings to shared script.lua package - Add ServerMinecraftData with server-specific APIs - Include example server scripts (admin_commands, tick_counter, etc.) --- src/main/java/dev/amble/lib/AmbleKit.java | 4 + .../dev/amble/lib/client/gui/AmbleButton.java | 2 +- .../amble/lib/client/gui/lua/LuaElement.java | 7 +- .../client/gui/registry/AmbleGuiRegistry.java | 2 +- .../lib/command/ExecuteScriptCommand.java | 32 ++- .../lib/command/ServerScriptCommand.java | 202 ++++++++++++++++ .../dev/amble/lib/script/ScriptManager.java | 35 ++- .../amble/lib/script/ServerScriptManager.java | 228 ++++++++++++++++++ .../lua/ClientMinecraftData.java} | 164 +++++-------- .../{client/gui => script}/lua/LuaBinder.java | 4 +- .../{client/gui => script}/lua/LuaExpose.java | 2 +- .../lua/mc => script/lua}/LuaItemStack.java | 3 +- .../amble/lib/script/lua/MinecraftData.java | 180 ++++++++++++++ .../mc => script/lua}/MinecraftEntity.java | 3 +- .../lib/script/lua/ServerMinecraftData.java | 227 +++++++++++++++++ .../assets/litmus/script/auto_torch.lua | 33 +-- .../assets/litmus/script/clipboard_demo.lua | 46 ++-- .../assets/litmus/script/entity_inspect.lua | 73 +++--- .../assets/litmus/script/hotbar_cycle.lua | 80 +++--- .../assets/litmus/script/input_test.lua | 47 ++-- .../assets/litmus/script/item_info.lua | 54 +++-- .../assets/litmus/script/player_state.lua | 57 +++-- .../assets/litmus/script/sprint_monitor.lua | 19 +- .../resources/assets/litmus/script/stats.lua | 178 +++++++------- .../resources/assets/litmus/script/test.lua | 70 +++--- .../assets/litmus/script/tick_demo.lua | 35 ++- .../assets/litmus/script/world_info.lua | 62 ++--- .../data/litmus/script/admin_commands.lua | 65 +++++ .../data/litmus/script/auto_broadcast.lua | 49 ++++ .../data/litmus/script/player_tracker.lua | 51 ++++ .../data/litmus/script/server_status.lua | 84 +++++++ .../data/litmus/script/tick_counter.lua | 51 ++++ .../data/litmus/script/weather_announcer.lua | 70 ++++++ 33 files changed, 1763 insertions(+), 456 deletions(-) create mode 100644 src/main/java/dev/amble/lib/command/ServerScriptCommand.java create mode 100644 src/main/java/dev/amble/lib/script/ServerScriptManager.java rename src/main/java/dev/amble/lib/{client/gui/lua/mc/MinecraftData.java => script/lua/ClientMinecraftData.java} (66%) rename src/main/java/dev/amble/lib/{client/gui => script}/lua/LuaBinder.java (96%) rename src/main/java/dev/amble/lib/{client/gui => script}/lua/LuaExpose.java (83%) rename src/main/java/dev/amble/lib/{client/gui/lua/mc => script/lua}/LuaItemStack.java (95%) create mode 100644 src/main/java/dev/amble/lib/script/lua/MinecraftData.java rename src/main/java/dev/amble/lib/{client/gui/lua/mc => script/lua}/MinecraftEntity.java (98%) create mode 100644 src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java create mode 100644 src/test/resources/data/litmus/script/admin_commands.lua create mode 100644 src/test/resources/data/litmus/script/auto_broadcast.lua create mode 100644 src/test/resources/data/litmus/script/player_tracker.lua create mode 100644 src/test/resources/data/litmus/script/server_status.lua create mode 100644 src/test/resources/data/litmus/script/tick_counter.lua create mode 100644 src/test/resources/data/litmus/script/weather_announcer.lua diff --git a/src/main/java/dev/amble/lib/AmbleKit.java b/src/main/java/dev/amble/lib/AmbleKit.java index 9c0fbbc..daa4de1 100644 --- a/src/main/java/dev/amble/lib/AmbleKit.java +++ b/src/main/java/dev/amble/lib/AmbleKit.java @@ -7,7 +7,9 @@ import dev.amble.lib.client.bedrock.BedrockAnimationAdapter; import dev.amble.lib.client.bedrock.BedrockModel; import dev.amble.lib.command.PlayAnimationCommand; +import dev.amble.lib.command.ServerScriptCommand; import dev.amble.lib.command.SetSkinCommand; +import dev.amble.lib.script.ServerScriptManager; import dev.amble.lib.skin.SkinTracker; import dev.drtheo.multidim.MultiDimMod; import dev.drtheo.scheduler.SchedulerMod; @@ -35,10 +37,12 @@ public void onInitialize() { ServerLifecycleHooks.init(); SkinTracker.init(); AnimationTracker.init(); + ServerScriptManager.getInstance().init(); CommandRegistrationCallback.EVENT.register((dispatcher, access, env) -> { SetSkinCommand.register(dispatcher); PlayAnimationCommand.register(dispatcher); + ServerScriptCommand.register(dispatcher); }); FabricLoader.getInstance().invokeEntrypoints("amblekit-main", AmbleKitInitializer.class, diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java index 83c5ceb..5d262e3 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java @@ -2,7 +2,7 @@ import dev.amble.lib.AmbleKit; -import dev.amble.lib.client.gui.lua.LuaBinder; +import dev.amble.lib.script.lua.LuaBinder; import dev.amble.lib.client.gui.lua.LuaElement; import dev.amble.lib.script.AmbleScript; import lombok.*; diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java index b3c3d5f..6c4399a 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java +++ b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java @@ -1,7 +1,8 @@ package dev.amble.lib.client.gui.lua; import dev.amble.lib.client.gui.*; -import dev.amble.lib.client.gui.lua.mc.MinecraftData; +import dev.amble.lib.script.lua.ClientMinecraftData; +import dev.amble.lib.script.lua.LuaExpose; import net.minecraft.client.MinecraftClient; import net.minecraft.text.Text; import net.minecraft.util.math.Vec2f; @@ -9,7 +10,7 @@ public final class LuaElement { private final AmbleElement element; - private final MinecraftData minecraftData = new MinecraftData(); + private final ClientMinecraftData minecraftData = new ClientMinecraftData(); public LuaElement(AmbleElement element) { this.element = element; @@ -94,7 +95,7 @@ public void closeScreen() { } @LuaExpose - public MinecraftData minecraft() { + public ClientMinecraftData minecraft() { return minecraftData; } diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index c1caba5..98bf822 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -7,7 +7,7 @@ import dev.amble.lib.client.gui.*; import dev.amble.lib.script.AmbleScript; import dev.amble.lib.script.ScriptManager; -import dev.amble.lib.client.gui.lua.LuaBinder; +import dev.amble.lib.script.lua.LuaBinder; import dev.amble.lib.register.datapack.DatapackRegistry; import net.fabricmc.fabric.api.resource.ResourceManagerHelper; import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; diff --git a/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java b/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java index 25a50dc..860e560 100644 --- a/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java +++ b/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java @@ -11,8 +11,8 @@ import net.minecraft.command.CommandSource; import net.minecraft.command.argument.IdentifierArgumentType; import net.minecraft.text.Text; -import net.minecraft.util.Formatting; import net.minecraft.util.Identifier; +import org.luaj.vm2.LuaValue; import java.util.Set; @@ -56,7 +56,9 @@ public static void register(CommandDispatcher dispatc .suggests(SCRIPT_SUGGESTIONS) .executes(ExecuteScriptCommand::toggle))) .then(literal("list") - .executes(ExecuteScriptCommand::listEnabled))); + .executes(ExecuteScriptCommand::listEnabled)) + .then(literal("available") + .executes(ExecuteScriptCommand::listAvailable))); } private static int execute(CommandContext context) { @@ -74,7 +76,8 @@ private static int execute(CommandContext context) { return 0; } - script.onExecute().call(); + LuaValue data = ScriptManager.getScriptData(fullScriptId); + script.onExecute().call(data); context.getSource().sendFeedback(Text.literal("Executed script: " + scriptId)); return 1; } catch (Exception e) { @@ -155,15 +158,34 @@ private static int listEnabled(CommandContext context Set enabled = ScriptManager.getEnabledScripts(); if (enabled.isEmpty()) { - context.getSource().sendFeedback(Text.literal("§7No scripts are currently enabled")); + context.getSource().sendFeedback(Text.literal("§7No client scripts are currently enabled")); return 1; } - context.getSource().sendFeedback(Text.literal("§6§l━━━ Enabled Scripts (" + enabled.size() + ") ━━━")); + context.getSource().sendFeedback(Text.literal("§6§l━━━ Enabled Client Scripts (" + enabled.size() + ") ━━━")); for (Identifier id : enabled) { String displayId = id.getPath().replace("script/", "").replace(".lua", ""); context.getSource().sendFeedback(Text.literal("§a✓ §f" + id.getNamespace() + ":" + displayId)); } return 1; } + + private static int listAvailable(CommandContext context) { + Set available = ScriptManager.getCache().keySet(); + Set enabled = ScriptManager.getEnabledScripts(); + + if (available.isEmpty()) { + context.getSource().sendFeedback(Text.literal("§7No client scripts available")); + return 1; + } + + context.getSource().sendFeedback(Text.literal("§6§l━━━ Available Client Scripts (" + available.size() + ") ━━━")); + for (Identifier id : available) { + String displayId = id.getPath().replace("script/", "").replace(".lua", ""); + String status = enabled.contains(id) ? "§a✓" : "§7○"; + context.getSource().sendFeedback(Text.literal(status + " §f" + id.getNamespace() + ":" + displayId)); + } + return 1; + } + } diff --git a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java new file mode 100644 index 0000000..2cdbe6c --- /dev/null +++ b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java @@ -0,0 +1,202 @@ +package dev.amble.lib.command; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.suggestion.SuggestionProvider; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.AmbleScript; +import dev.amble.lib.script.ServerScriptManager; +import dev.amble.lib.script.lua.LuaBinder; +import dev.amble.lib.script.lua.ServerMinecraftData; +import org.luaj.vm2.LuaValue; +import net.minecraft.command.CommandSource; +import net.minecraft.command.argument.IdentifierArgumentType; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; + +import java.util.Set; + +import static net.minecraft.server.command.CommandManager.argument; +import static net.minecraft.server.command.CommandManager.literal; + +/** + * Server-side command for managing server scripts. + * Usage: /serverscript [enable|disable|execute|toggle|list|available] [script_id] + */ +public class ServerScriptCommand { + + private static final SuggestionProvider SCRIPT_SUGGESTIONS = (context, builder) -> { + return CommandSource.suggestIdentifiers( + ServerScriptManager.getCache().keySet().stream() + .map(id -> Identifier.of(id.getNamespace(), id.getPath().replace("script/", "").replace(".lua", ""))), + builder + ); + }; + + private static final SuggestionProvider ENABLED_SCRIPT_SUGGESTIONS = (context, builder) -> { + return CommandSource.suggestIdentifiers( + ServerScriptManager.getEnabledScripts().stream() + .map(id -> Identifier.of(id.getNamespace(), id.getPath().replace("script/", "").replace(".lua", ""))), + builder + ); + }; + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(literal("serverscript") + .requires(source -> source.hasPermissionLevel(2)) // Require operator permissions + .then(literal("execute") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(SCRIPT_SUGGESTIONS) + .executes(ServerScriptCommand::execute))) + .then(literal("enable") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(SCRIPT_SUGGESTIONS) + .executes(ServerScriptCommand::enable))) + .then(literal("disable") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(ENABLED_SCRIPT_SUGGESTIONS) + .executes(ServerScriptCommand::disable))) + .then(literal("toggle") + .then(argument("id", IdentifierArgumentType.identifier()) + .suggests(SCRIPT_SUGGESTIONS) + .executes(ServerScriptCommand::toggle))) + .then(literal("list") + .executes(ServerScriptCommand::listEnabled)) + .then(literal("available") + .executes(ServerScriptCommand::listAvailable))); + } + + private static int execute(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + + try { + AmbleScript script = ServerScriptManager.getCache().get(fullScriptId); + + if (script == null) { + context.getSource().sendError(Text.literal("Server script '" + scriptId + "' not found")); + return 0; + } + + if (script.onExecute() == null || script.onExecute().isnil()) { + context.getSource().sendError(Text.literal("Server script '" + scriptId + "' has no onExecute function")); + return 0; + } + + // Create a new ServerMinecraftData with the executing player + ServerCommandSource source = context.getSource(); + ServerPlayerEntity player = source.getPlayer(); + ServerMinecraftData data = new ServerMinecraftData( + source.getServer(), + source.getWorld(), + player + ); + LuaValue boundData = LuaBinder.bind(data); + + script.onExecute().call(boundData); + context.getSource().sendFeedback(() -> Text.literal("Executed server script: " + scriptId), true); + return 1; + } catch (Exception e) { + context.getSource().sendError(Text.literal("Failed to execute server script '" + scriptId + "': " + e.getMessage())); + AmbleKit.LOGGER.error("Failed to execute server script {}", scriptId, e); + return 0; + } + } + + private static int enable(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + + if (!ServerScriptManager.getCache().containsKey(fullScriptId)) { + context.getSource().sendError(Text.literal("Server script '" + scriptId + "' not found")); + return 0; + } + + if (ServerScriptManager.isEnabled(fullScriptId)) { + context.getSource().sendError(Text.literal("Server script '" + scriptId + "' is already enabled")); + return 0; + } + + if (ServerScriptManager.enable(fullScriptId)) { + context.getSource().sendFeedback(() -> Text.literal("§aEnabled server script: " + scriptId), true); + return 1; + } else { + context.getSource().sendError(Text.literal("Failed to enable server script '" + scriptId + "'")); + return 0; + } + } + + private static int disable(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + + if (!ServerScriptManager.isEnabled(fullScriptId)) { + context.getSource().sendError(Text.literal("Server script '" + scriptId + "' is not enabled")); + return 0; + } + + if (ServerScriptManager.disable(fullScriptId)) { + context.getSource().sendFeedback(() -> Text.literal("§cDisabled server script: " + scriptId), true); + return 1; + } else { + context.getSource().sendError(Text.literal("Failed to disable server script '" + scriptId + "'")); + return 0; + } + } + + private static int toggle(CommandContext context) { + Identifier scriptId = context.getArgument("id", Identifier.class); + Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + + if (!ServerScriptManager.getCache().containsKey(fullScriptId)) { + context.getSource().sendError(Text.literal("Server script '" + scriptId + "' not found")); + return 0; + } + + boolean wasEnabled = ServerScriptManager.isEnabled(fullScriptId); + ServerScriptManager.toggle(fullScriptId); + + if (wasEnabled) { + context.getSource().sendFeedback(() -> Text.literal("§cDisabled server script: " + scriptId), true); + } else { + context.getSource().sendFeedback(() -> Text.literal("§aEnabled server script: " + scriptId), true); + } + return 1; + } + + private static int listEnabled(CommandContext context) { + Set enabled = ServerScriptManager.getEnabledScripts(); + + if (enabled.isEmpty()) { + context.getSource().sendFeedback(() -> Text.literal("§7No server scripts are currently enabled"), false); + return 1; + } + + context.getSource().sendFeedback(() -> Text.literal("§6§l━━━ Enabled Server Scripts (" + enabled.size() + ") ━━━"), false); + for (Identifier id : enabled) { + String displayId = id.getPath().replace("script/", "").replace(".lua", ""); + context.getSource().sendFeedback(() -> Text.literal("§a✓ §f" + id.getNamespace() + ":" + displayId), false); + } + return 1; + } + + private static int listAvailable(CommandContext context) { + Set available = ServerScriptManager.getCache().keySet(); + Set enabled = ServerScriptManager.getEnabledScripts(); + + if (available.isEmpty()) { + context.getSource().sendFeedback(() -> Text.literal("§7No server scripts available"), false); + return 1; + } + + context.getSource().sendFeedback(() -> Text.literal("§6§l━━━ Available Server Scripts (" + available.size() + ") ━━━"), false); + for (Identifier id : available) { + String displayId = id.getPath().replace("script/", "").replace(".lua", ""); + String status = enabled.contains(id) ? "§a✓" : "§7○"; + context.getSource().sendFeedback(() -> Text.literal(status + " §f" + id.getNamespace() + ":" + displayId), false); + } + return 1; + } +} diff --git a/src/main/java/dev/amble/lib/script/ScriptManager.java b/src/main/java/dev/amble/lib/script/ScriptManager.java index c8b5b16..eda00bf 100644 --- a/src/main/java/dev/amble/lib/script/ScriptManager.java +++ b/src/main/java/dev/amble/lib/script/ScriptManager.java @@ -1,8 +1,8 @@ package dev.amble.lib.script; import dev.amble.lib.AmbleKit; -import dev.amble.lib.client.gui.lua.LuaBinder; -import dev.amble.lib.client.gui.lua.mc.MinecraftData; +import dev.amble.lib.script.lua.ClientMinecraftData; +import dev.amble.lib.script.lua.LuaBinder; import net.fabricmc.fabric.api.resource.ResourceManagerHelper; import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; import net.minecraft.resource.Resource; @@ -21,6 +21,7 @@ public class ScriptManager implements SimpleSynchronousResourceReloadListener { private static final ScriptManager INSTANCE = new ScriptManager(); private static final Map CACHE = new HashMap<>(); + private static final Map DATA_CACHE = new HashMap<>(); private static final Set ENABLED_SCRIPTS = new HashSet<>(); private ScriptManager() { @@ -45,6 +46,7 @@ public void reload(ResourceManager manager) { } CACHE.clear(); + DATA_CACHE.clear(); // Discover all script files and populate the cache for suggestions manager.findResources("script", id -> id.getPath().endsWith(".lua")) @@ -66,8 +68,13 @@ public static AmbleScript load(Identifier id, ResourceManager manager) { Resource res = manager.getResource(key).orElseThrow(); Globals globals = JsePlatform.standardGlobals(); - // Inject minecraft global for scripts to use - globals.set("minecraft", LuaBinder.bind(new MinecraftData())); + // Create and cache the minecraft data for this script + ClientMinecraftData data = new ClientMinecraftData(); + LuaValue boundData = LuaBinder.bind(data); + DATA_CACHE.put(key, boundData); + + // Inject minecraft global for scripts to use (backward compatibility) + globals.set("minecraft", boundData); LuaValue chunk = globals.load( new InputStreamReader(res.getInputStream()), @@ -115,10 +122,11 @@ public static boolean enable(Identifier id) { ENABLED_SCRIPTS.add(id); - // Call onEnable + // Call onEnable with minecraft data as first argument if (script.onEnable() != null && !script.onEnable().isnil()) { try { - script.onEnable().call(); + LuaValue data = DATA_CACHE.get(id); + script.onEnable().call(data); } catch (Exception e) { AmbleKit.LOGGER.error("Error in onEnable for script {}", id, e); } @@ -135,10 +143,11 @@ public static boolean disable(Identifier id) { AmbleScript script = CACHE.get(id); - // Call onDisable before removing + // Call onDisable with minecraft data as first argument before removing if (script != null && script.onDisable() != null && !script.onDisable().isnil()) { try { - script.onDisable().call(); + LuaValue data = DATA_CACHE.get(id); + script.onDisable().call(data); } catch (Exception e) { AmbleKit.LOGGER.error("Error in onDisable for script {}", id, e); } @@ -162,7 +171,8 @@ public static void tick() { AmbleScript script = CACHE.get(id); if (script != null && script.onTick() != null && !script.onTick().isnil()) { try { - script.onTick().call(); + LuaValue data = DATA_CACHE.get(id); + script.onTick().call(data); } catch (Exception e) { AmbleKit.LOGGER.error("Error in onTick for script {}", id, e); // Optionally disable the script on error @@ -171,4 +181,11 @@ public static void tick() { } } } + + /** + * Get the bound minecraft data for a script. + */ + public static LuaValue getScriptData(Identifier id) { + return DATA_CACHE.get(id); + } } diff --git a/src/main/java/dev/amble/lib/script/ServerScriptManager.java b/src/main/java/dev/amble/lib/script/ServerScriptManager.java new file mode 100644 index 0000000..4b33274 --- /dev/null +++ b/src/main/java/dev/amble/lib/script/ServerScriptManager.java @@ -0,0 +1,228 @@ +package dev.amble.lib.script; + +import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.lua.LuaBinder; +import dev.amble.lib.script.lua.ServerMinecraftData; +import dev.amble.lib.util.ServerLifecycleHooks; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.resource.ResourceManagerHelper; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.resource.ResourceType; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.Identifier; +import org.luaj.vm2.Globals; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.lib.jse.JsePlatform; + +import java.io.InputStreamReader; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Manages server-side Lua scripts loaded from the data folder. + * Scripts are loaded from data/<namespace>/script/*.lua + */ +public class ServerScriptManager implements SimpleSynchronousResourceReloadListener { + private static final ServerScriptManager INSTANCE = new ServerScriptManager(); + private static final Map CACHE = new HashMap<>(); + private static final Map DATA_CACHE = new HashMap<>(); + private static final Set ENABLED_SCRIPTS = new HashSet<>(); + + private MinecraftServer currentServer; + private boolean initialized = false; + + private ServerScriptManager() { + } + + public static ServerScriptManager getInstance() { + return INSTANCE; + } + + /** + * Initialize the server script manager. Should be called from main mod initializer. + */ + public void init() { + if (initialized) return; + initialized = true; + + ResourceManagerHelper.get(ResourceType.SERVER_DATA) + .registerReloadListener(this); + + ServerLifecycleEvents.SERVER_STARTED.register(server -> { + this.currentServer = server; + AmbleKit.LOGGER.info("Server script manager ready"); + }); + + ServerLifecycleEvents.SERVER_STOPPING.register(server -> { + // Disable all scripts before server stops + for (Identifier id : new HashSet<>(ENABLED_SCRIPTS)) { + disable(id); + } + this.currentServer = null; + }); + + ServerTickEvents.END_SERVER_TICK.register(this::onServerTick); + } + + @Override + public Identifier getFabricId() { + return AmbleKit.id("server_scripts"); + } + + @Override + public void reload(ResourceManager manager) { + // Disable all scripts before clearing cache + for (Identifier id : new HashSet<>(ENABLED_SCRIPTS)) { + disable(id); + } + + CACHE.clear(); + DATA_CACHE.clear(); + + // Discover all script files and populate the cache + manager.findResources("script", id -> id.getPath().endsWith(".lua")) + .keySet() + .forEach(id -> { + try { + load(id, manager); + } catch (Exception e) { + AmbleKit.LOGGER.error("Failed to load server script {}", id, e); + } + }); + + AmbleKit.LOGGER.info("Loaded {} server scripts", CACHE.size()); + } + + public AmbleScript load(Identifier id, ResourceManager manager) { + return CACHE.computeIfAbsent(id, key -> { + try { + Resource res = manager.getResource(key).orElseThrow(); + Globals globals = JsePlatform.standardGlobals(); + + // Create server minecraft data - world will be set when script is enabled/executed + MinecraftServer server = currentServer != null ? currentServer : ServerLifecycleHooks.get(); + ServerWorld world = server != null ? server.getOverworld() : null; + ServerMinecraftData data = new ServerMinecraftData(server, world); + LuaValue boundData = LuaBinder.bind(data); + DATA_CACHE.put(key, boundData); + + // Inject minecraft global for scripts to use (backward compatibility) + globals.set("minecraft", boundData); + + LuaValue chunk = globals.load( + new InputStreamReader(res.getInputStream()), + key.toString() + ); + chunk.call(); + + return new AmbleScript( + globals.get("onInit"), + globals.get("onClick"), + globals.get("onRelease"), + globals.get("onHover"), + globals.get("onExecute"), + globals.get("onEnable"), + globals.get("onTick"), + globals.get("onDisable") + ); + } catch (Exception e) { + throw new RuntimeException("Failed to load server script " + key, e); + } + }); + } + + public static Map getCache() { + return CACHE; + } + + public static Set getEnabledScripts() { + return ENABLED_SCRIPTS; + } + + public static boolean isEnabled(Identifier id) { + return ENABLED_SCRIPTS.contains(id); + } + + public static boolean enable(Identifier id) { + if (ENABLED_SCRIPTS.contains(id)) { + return false; // Already enabled + } + + AmbleScript script = CACHE.get(id); + if (script == null) { + return false; + } + + ENABLED_SCRIPTS.add(id); + + // Call onEnable with minecraft data as first argument + if (script.onEnable() != null && !script.onEnable().isnil()) { + try { + LuaValue data = DATA_CACHE.get(id); + script.onEnable().call(data); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onEnable for server script {}", id, e); + } + } + + AmbleKit.LOGGER.info("Enabled server script: {}", id); + return true; + } + + public static boolean disable(Identifier id) { + if (!ENABLED_SCRIPTS.contains(id)) { + return false; // Not enabled + } + + AmbleScript script = CACHE.get(id); + + // Call onDisable with minecraft data as first argument before removing + if (script != null && script.onDisable() != null && !script.onDisable().isnil()) { + try { + LuaValue data = DATA_CACHE.get(id); + script.onDisable().call(data); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onDisable for server script {}", id, e); + } + } + + ENABLED_SCRIPTS.remove(id); + AmbleKit.LOGGER.info("Disabled server script: {}", id); + return true; + } + + public static boolean toggle(Identifier id) { + if (isEnabled(id)) { + return disable(id); + } else { + return enable(id); + } + } + + private void onServerTick(MinecraftServer server) { + for (Identifier id : ENABLED_SCRIPTS) { + AmbleScript script = CACHE.get(id); + if (script != null && script.onTick() != null && !script.onTick().isnil()) { + try { + LuaValue data = DATA_CACHE.get(id); + script.onTick().call(data); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onTick for server script {}", id, e); + } + } + } + } + + /** + * Get the bound minecraft data for a script. + */ + public static LuaValue getScriptData(Identifier id) { + return DATA_CACHE.get(id); + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java b/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java similarity index 66% rename from src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java rename to src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java index 8ef4507..3d39142 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java @@ -1,8 +1,7 @@ -package dev.amble.lib.client.gui.lua.mc; +package dev.amble.lib.script.lua; import dev.amble.lib.AmbleKit; import dev.amble.lib.client.gui.AmbleContainer; -import dev.amble.lib.client.gui.lua.LuaExpose; import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; import net.minecraft.SharedConstants; import net.minecraft.client.MinecraftClient; @@ -17,128 +16,118 @@ import net.minecraft.util.hit.EntityHitResult; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Direction; +import net.minecraft.world.World; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; import java.util.stream.StreamSupport; -public class MinecraftData { +/** + * Client-side implementation of MinecraftData. + * Provides access to client-only features like input, GUI, clipboard, etc. + */ +public class ClientMinecraftData extends MinecraftData { private static final MinecraftClient mc = MinecraftClient.getInstance(); + @Override @LuaExpose - public String username() { - return mc.getSession().getUsername(); + public boolean isClientSide() { + return true; } - @LuaExpose - public void runCommand(String command) { - try { - String string2 = SharedConstants.stripInvalidChars(command); - if (string2.startsWith("/")) { - if (!mc.player.networkHandler.sendCommand(string2.substring(1))) { - AmbleKit.LOGGER.error("Not allowed to run command with signed argument from lua: '{}'", string2); - } - } else { - AmbleKit.LOGGER.error("Failed to run command without '/' prefix from lua: '{}'", string2); - } - } catch (Exception e) { - AmbleKit.LOGGER.error("Error occurred while running command from lua: '{}'", command, e); - } + @Override + protected World getWorld() { + return mc.world; } - @LuaExpose - public int selectedSlot() { - return mc.player.getInventory().selectedSlot + 1; + @Override + protected Entity getPlayer() { + return mc.player; } + // ===== Client-specific entity methods ===== + + @Override @LuaExpose - public void selectSlot(int slot) { - mc.player.getInventory().selectedSlot = slot - 1; + public List entities() { + if (mc.world == null) return List.of(); + return StreamSupport.stream(mc.world.getEntities().spliterator(), false) + .collect(Collectors.toList()); } + // ===== Session & Identity ===== + @LuaExpose - public void sendMessage(String message, boolean overlay) { - mc.player.sendMessage(Text.literal(message), overlay); + public String username() { + return mc.getSession().getUsername(); } + // ===== Inventory ===== + @LuaExpose - public Entity player() { - return mc.player; + public int selectedSlot() { + return mc.player != null ? mc.player.getInventory().selectedSlot + 1 : 0; } @LuaExpose - public List entities() { - return StreamSupport.stream(mc.world.getEntities().spliterator(), false) - .collect(Collectors.toList()); + public void selectSlot(int slot) { + if (mc.player != null) { + mc.player.getInventory().selectedSlot = slot - 1; + } } @LuaExpose public void dropStack(int slot, boolean entireStack) { + if (mc.player == null) return; int selected = selectedSlot(); swapStack(slot, selected); PlayerActionC2SPacket.Action action = entireStack ? PlayerActionC2SPacket.Action.DROP_ALL_ITEMS : PlayerActionC2SPacket.Action.DROP_ITEM; ItemStack itemStack = mc.player.getInventory().dropSelectedItem(entireStack); mc.player.networkHandler.sendPacket(new PlayerActionC2SPacket(action, BlockPos.ORIGIN, Direction.DOWN)); - //swapStack(selected, slot); } @LuaExpose public void swapStack(int fromSlot, int toSlot) { + if (mc.player == null) return; ItemStack stack = mc.player.getInventory().getStack(fromSlot - 1); mc.player.getInventory().setStack(fromSlot - 1, mc.player.getInventory().getStack(toSlot - 1)); mc.player.getInventory().setStack(toSlot - 1, stack); - - // todo sync change to server somehow - } - - // ===== World & Environment ===== - - @LuaExpose - public String dimension() { - return mc.world.getRegistryKey().getValue().toString(); - } - - @LuaExpose - public long worldTime() { - return mc.world.getTimeOfDay(); } - @LuaExpose - public long dayCount() { - return mc.world.getTimeOfDay() / 24000L; - } + // ===== Commands & Messages ===== + @Override @LuaExpose - public boolean isRaining() { - return mc.world.isRaining(); - } - - @LuaExpose - public boolean isThundering() { - return mc.world.isThundering(); - } - - @LuaExpose - public String biomeAt(int x, int y, int z) { - return mc.world.getBiome(new BlockPos(x, y, z)).getKey() - .map(k -> k.getValue().toString()).orElse("unknown"); - } - - @LuaExpose - public String blockAt(int x, int y, int z) { - return Registries.BLOCK.getId(mc.world.getBlockState(new BlockPos(x, y, z)).getBlock()).toString(); + public void runCommand(String command) { + if (mc.player == null) return; + try { + String string2 = SharedConstants.stripInvalidChars(command); + if (string2.startsWith("/")) { + if (!mc.player.networkHandler.sendCommand(string2.substring(1))) { + AmbleKit.LOGGER.error("Not allowed to run command with signed argument from lua: '{}'", string2); + } + } else { + AmbleKit.LOGGER.error("Failed to run command without '/' prefix from lua: '{}'", string2); + } + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while running command from lua: '{}'", command, e); + } } + @Override @LuaExpose - public int lightLevelAt(int x, int y, int z) { - return mc.world.getLightLevel(new BlockPos(x, y, z)); + public void sendMessage(String message, boolean overlay) { + if (mc.player != null) { + mc.player.sendMessage(Text.literal(message), overlay); + } } // ===== Input ===== @LuaExpose public boolean isKeyPressed(String keyName) { + if (mc.options == null) return false; return switch (keyName.toLowerCase()) { case "forward" -> mc.options.forwardKey.isPressed(); case "back" -> mc.options.backKey.isPressed(); @@ -155,13 +144,14 @@ public boolean isKeyPressed(String keyName) { @LuaExpose public String gameMode() { - return mc.interactionManager.getCurrentGameMode().getName(); + return mc.interactionManager != null ? mc.interactionManager.getCurrentGameMode().getName() : "unknown"; } // ===== Audio ===== @LuaExpose public void playSound(String soundId, float volume, float pitch) { + if (mc.player == null) return; Identifier id = new Identifier(soundId); SoundEvent sound = Registries.SOUND_EVENT.get(id); if (sound != null) { @@ -169,30 +159,8 @@ public void playSound(String soundId, float volume, float pitch) { } } - @LuaExpose - public void playSoundAt(String soundId, double x, double y, double z, float volume, float pitch) { - Identifier id = new Identifier(soundId); - SoundEvent sound = Registries.SOUND_EVENT.get(id); - if (sound != null) { - mc.world.playSound(x, y, z, sound, mc.player.getSoundCategory(), volume, pitch, false); - } - } - // ===== Entity Queries ===== - @LuaExpose - public Entity nearestEntity(double maxDistance) { - return mc.world.getOtherEntities(mc.player, mc.player.getBoundingBox().expand(maxDistance), e -> true) - .stream() - .min(Comparator.comparingDouble(e -> e.squaredDistanceTo(mc.player))) - .orElse(null); - } - - @LuaExpose - public List entitiesInRadius(double radius) { - return mc.world.getOtherEntities(mc.player, mc.player.getBoundingBox().expand(radius), e -> true); - } - @LuaExpose public Entity lookingAtEntity() { if (mc.crosshairTarget instanceof EntityHitResult hit) { @@ -228,21 +196,23 @@ public void closeScreen() { @LuaExpose public String clipboard() { - return mc.keyboard.getClipboard(); + return mc.keyboard != null ? mc.keyboard.getClipboard() : ""; } @LuaExpose public void setClipboard(String text) { - mc.keyboard.setClipboard(text); + if (mc.keyboard != null) { + mc.keyboard.setClipboard(text); + } } @LuaExpose public int windowWidth() { - return mc.getWindow().getScaledWidth(); + return mc.getWindow() != null ? mc.getWindow().getScaledWidth() : 0; } @LuaExpose public int windowHeight() { - return mc.getWindow().getScaledHeight(); + return mc.getWindow() != null ? mc.getWindow().getScaledHeight() : 0; } } diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaBinder.java b/src/main/java/dev/amble/lib/script/lua/LuaBinder.java similarity index 96% rename from src/main/java/dev/amble/lib/client/gui/lua/LuaBinder.java rename to src/main/java/dev/amble/lib/script/lua/LuaBinder.java index 1dedfea..e5fd8d4 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/LuaBinder.java +++ b/src/main/java/dev/amble/lib/script/lua/LuaBinder.java @@ -1,7 +1,5 @@ -package dev.amble.lib.client.gui.lua; +package dev.amble.lib.script.lua; -import dev.amble.lib.client.gui.lua.mc.LuaItemStack; -import dev.amble.lib.client.gui.lua.mc.MinecraftEntity; import net.minecraft.entity.Entity; import net.minecraft.item.ItemStack; import net.minecraft.util.math.BlockPos; diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaExpose.java b/src/main/java/dev/amble/lib/script/lua/LuaExpose.java similarity index 83% rename from src/main/java/dev/amble/lib/client/gui/lua/LuaExpose.java rename to src/main/java/dev/amble/lib/script/lua/LuaExpose.java index 24d04b9..ae685ea 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/LuaExpose.java +++ b/src/main/java/dev/amble/lib/script/lua/LuaExpose.java @@ -1,4 +1,4 @@ -package dev.amble.lib.client.gui.lua; +package dev.amble.lib.script.lua; import java.lang.annotation.*; diff --git a/src/main/java/dev/amble/lib/client/gui/lua/mc/LuaItemStack.java b/src/main/java/dev/amble/lib/script/lua/LuaItemStack.java similarity index 95% rename from src/main/java/dev/amble/lib/client/gui/lua/mc/LuaItemStack.java rename to src/main/java/dev/amble/lib/script/lua/LuaItemStack.java index 2563431..b4d14ac 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/mc/LuaItemStack.java +++ b/src/main/java/dev/amble/lib/script/lua/LuaItemStack.java @@ -1,6 +1,5 @@ -package dev.amble.lib.client.gui.lua.mc; +package dev.amble.lib.script.lua; -import dev.amble.lib.client.gui.lua.LuaExpose; import lombok.AllArgsConstructor; import net.minecraft.enchantment.EnchantmentHelper; import net.minecraft.item.ItemStack; diff --git a/src/main/java/dev/amble/lib/script/lua/MinecraftData.java b/src/main/java/dev/amble/lib/script/lua/MinecraftData.java new file mode 100644 index 0000000..00f8776 --- /dev/null +++ b/src/main/java/dev/amble/lib/script/lua/MinecraftData.java @@ -0,0 +1,180 @@ +package dev.amble.lib.script.lua; + +import dev.amble.lib.AmbleKit; +import net.minecraft.entity.Entity; +import net.minecraft.registry.Registries; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvent; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Abstract base class for Minecraft data exposed to Lua scripts. + * Contains methods that work on both client and server sides. + */ +public abstract class MinecraftData { + + /** + * @return true if this is client-side data, false if server-side + */ + @LuaExpose + public abstract boolean isClientSide(); + + /** + * @return the world this data operates on + */ + protected abstract World getWorld(); + + /** + * @return the player entity for this context (may be null on server for some contexts) + */ + protected abstract Entity getPlayer(); + + // ===== World & Environment ===== + + @LuaExpose + public String dimension() { + World world = getWorld(); + return world != null ? world.getRegistryKey().getValue().toString() : "unknown"; + } + + @LuaExpose + public long worldTime() { + World world = getWorld(); + return world != null ? world.getTimeOfDay() : 0; + } + + @LuaExpose + public long dayCount() { + World world = getWorld(); + return world != null ? world.getTimeOfDay() / 24000L : 0; + } + + @LuaExpose + public boolean isRaining() { + World world = getWorld(); + return world != null && world.isRaining(); + } + + @LuaExpose + public boolean isThundering() { + World world = getWorld(); + return world != null && world.isThundering(); + } + + @LuaExpose + public String biomeAt(int x, int y, int z) { + World world = getWorld(); + if (world == null) return "unknown"; + return world.getBiome(new BlockPos(x, y, z)).getKey() + .map(k -> k.getValue().toString()).orElse("unknown"); + } + + @LuaExpose + public String blockAt(int x, int y, int z) { + World world = getWorld(); + if (world == null) return "minecraft:air"; + return Registries.BLOCK.getId(world.getBlockState(new BlockPos(x, y, z)).getBlock()).toString(); + } + + @LuaExpose + public int lightLevelAt(int x, int y, int z) { + World world = getWorld(); + return world != null ? world.getLightLevel(new BlockPos(x, y, z)) : 0; + } + + // ===== Player & Entity ===== + + @LuaExpose + public Entity player() { + return getPlayer(); + } + + @LuaExpose + public List entities() { + World world = getWorld(); + if (world == null) return List.of(); + + if (world instanceof ServerWorld serverWorld) { + return StreamSupport.stream(serverWorld.iterateEntities().spliterator(), false) + .collect(Collectors.toList()); + } + return List.of(); + } + + @LuaExpose + public Entity nearestEntity(double maxDistance) { + World world = getWorld(); + Entity player = getPlayer(); + if (world == null || player == null) return null; + + return world.getOtherEntities(player, player.getBoundingBox().expand(maxDistance), e -> true) + .stream() + .min(Comparator.comparingDouble(e -> e.squaredDistanceTo(player))) + .orElse(null); + } + + @LuaExpose + public List entitiesInRadius(double radius) { + World world = getWorld(); + Entity player = getPlayer(); + if (world == null || player == null) return List.of(); + + return world.getOtherEntities(player, player.getBoundingBox().expand(radius), e -> true); + } + + // ===== Audio (shared implementation) ===== + + @LuaExpose + public void playSoundAt(String soundId, double x, double y, double z, float volume, float pitch) { + World world = getWorld(); + if (world == null) return; + + Identifier id = new Identifier(soundId); + SoundEvent sound = Registries.SOUND_EVENT.get(id); + if (sound != null) { + world.playSound(null, x, y, z, sound, SoundCategory.MASTER, volume, pitch); + } + } + + // ===== Commands ===== + + /** + * Runs a command. Implementation differs between client and server. + * On client: sends command through player network handler + * On server: executes command with server permissions + */ + @LuaExpose + public abstract void runCommand(String command); + + /** + * Sends a message to the player. Implementation differs between client and server. + */ + @LuaExpose + public abstract void sendMessage(String message, boolean overlay); + + /** + * Logs a message to the console. + */ + @LuaExpose + public void log(String message) { + AmbleKit.LOGGER.info("[Script] {}", message); + } + + @LuaExpose + public void logWarn(String message) { + AmbleKit.LOGGER.warn("[Script] {}", message); + } + + @LuaExpose + public void logError(String message) { + AmbleKit.LOGGER.error("[Script] {}", message); + } +} diff --git a/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftEntity.java b/src/main/java/dev/amble/lib/script/lua/MinecraftEntity.java similarity index 98% rename from src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftEntity.java rename to src/main/java/dev/amble/lib/script/lua/MinecraftEntity.java index bfe18b9..000c44a 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/mc/MinecraftEntity.java +++ b/src/main/java/dev/amble/lib/script/lua/MinecraftEntity.java @@ -1,6 +1,5 @@ -package dev.amble.lib.client.gui.lua.mc; +package dev.amble.lib.script.lua; -import dev.amble.lib.client.gui.lua.LuaExpose; import lombok.AllArgsConstructor; import net.minecraft.entity.Entity; import net.minecraft.entity.LivingEntity; diff --git a/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java b/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java new file mode 100644 index 0000000..6eb7d36 --- /dev/null +++ b/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java @@ -0,0 +1,227 @@ +package dev.amble.lib.script.lua; + +import dev.amble.lib.AmbleKit; +import dev.amble.lib.util.ServerLifecycleHooks; +import net.minecraft.entity.Entity; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.world.World; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Server-side implementation of MinecraftData. + * Provides access to server-only features like broadcasting, player management, etc. + */ +public class ServerMinecraftData extends MinecraftData { + private MinecraftServer server; + private ServerWorld world; + private final ServerPlayerEntity player; // may be null for server-context scripts + + public ServerMinecraftData(MinecraftServer server, ServerWorld world, ServerPlayerEntity player) { + this.server = server; + this.world = world; + this.player = player; + } + + public ServerMinecraftData(MinecraftServer server, ServerWorld world) { + this(server, world, null); + } + + /** + * Gets the server, fetching from lifecycle hooks if not set. + */ + private MinecraftServer getServer() { + if (server == null) { + server = ServerLifecycleHooks.get(); + } + return server; + } + + /** + * Gets the world, fetching overworld from server if not set. + */ + private ServerWorld getServerWorld() { + if (world == null && getServer() != null) { + world = getServer().getOverworld(); + } + return world; + } + + @Override + @LuaExpose + public boolean isClientSide() { + return false; + } + + @Override + protected World getWorld() { + return getServerWorld(); + } + + @Override + protected Entity getPlayer() { + return player; + } + + // ===== Server-specific methods ===== + + @LuaExpose + public List allPlayerNames() { + MinecraftServer srv = getServer(); + if (srv == null) return List.of(); + return srv.getPlayerManager().getPlayerList().stream() + .map(p -> p.getName().getString()) + .collect(Collectors.toList()); + } + + @LuaExpose + public List allPlayers() { + MinecraftServer srv = getServer(); + if (srv == null) return List.of(); + return srv.getPlayerManager().getPlayerList().stream() + .map(p -> (Entity) p) + .collect(Collectors.toList()); + } + + @LuaExpose + public Entity getPlayerByName(String name) { + MinecraftServer srv = getServer(); + if (srv == null) return null; + return srv.getPlayerManager().getPlayer(name); + } + + @LuaExpose + public int playerCount() { + MinecraftServer srv = getServer(); + if (srv == null) return 0; + return srv.getPlayerManager().getCurrentPlayerCount(); + } + + @LuaExpose + public int maxPlayers() { + MinecraftServer srv = getServer(); + if (srv == null) return 0; + return srv.getPlayerManager().getMaxPlayerCount(); + } + + @LuaExpose + public void broadcast(String message) { + MinecraftServer srv = getServer(); + if (srv == null) { + AmbleKit.LOGGER.warn("Cannot broadcast: server not available"); + return; + } + srv.getPlayerManager().broadcast(Text.literal(message), false); + } + + @LuaExpose + public void broadcastToPlayer(String playerName, String message, boolean overlay) { + MinecraftServer srv = getServer(); + if (srv == null) return; + ServerPlayerEntity target = srv.getPlayerManager().getPlayer(playerName); + if (target != null) { + target.sendMessage(Text.literal(message), overlay); + } + } + + // ===== Commands & Messages ===== + + @Override + @LuaExpose + public void runCommand(String command) { + MinecraftServer srv = getServer(); + if (srv == null) return; + try { + String cmd = command.startsWith("/") ? command.substring(1) : command; + ServerCommandSource source = srv.getCommandSource(); + srv.getCommandManager().executeWithPrefix(source, cmd); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while running server command from lua: '{}'", command, e); + } + } + + /** + * Runs a command as a specific player. + */ + @LuaExpose + public void runCommandAs(String playerName, String command) { + MinecraftServer srv = getServer(); + if (srv == null) return; + ServerPlayerEntity target = srv.getPlayerManager().getPlayer(playerName); + if (target == null) { + AmbleKit.LOGGER.warn("Cannot run command as '{}': player not found", playerName); + return; + } + try { + String cmd = command.startsWith("/") ? command.substring(1) : command; + srv.getCommandManager().executeWithPrefix(target.getCommandSource(), cmd); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while running command as {} from lua: '{}'", playerName, command, e); + } + } + + @Override + @LuaExpose + public void sendMessage(String message, boolean overlay) { + if (player != null) { + player.sendMessage(Text.literal(message), overlay); + } else { + // If no specific player, log to console + AmbleKit.LOGGER.info("[Script Message] {}", message); + } + } + + // ===== Server Info ===== + + @LuaExpose + public String serverName() { + MinecraftServer srv = getServer(); + return srv != null ? srv.getName() : "unknown"; + } + + @LuaExpose + public int tickCount() { + MinecraftServer srv = getServer(); + return srv != null ? srv.getTicks() : 0; + } + + @LuaExpose + public double serverTps() { + MinecraftServer srv = getServer(); + if (srv == null) return 20.0; + // Average TPS calculation based on tick times + long[] tickTimes = srv.lastTickLengths; + if (tickTimes == null || tickTimes.length == 0) return 20.0; + + long sum = 0; + for (long tickTime : tickTimes) { + sum += tickTime; + } + double avgTickTime = (double) sum / tickTimes.length / 1_000_000.0; // Convert to milliseconds + return Math.min(20.0, 1000.0 / avgTickTime); + } + + @LuaExpose + public boolean isDedicatedServer() { + MinecraftServer srv = getServer(); + return srv != null && srv.isDedicated(); + } + + // ===== World Management ===== + + @LuaExpose + public List worldNames() { + MinecraftServer srv = getServer(); + if (srv == null) return List.of(); + return srv.getWorlds().iterator().hasNext() + ? srv.getWorldRegistryKeys().stream() + .map(key -> key.getValue().toString()) + .collect(Collectors.toList()) + : List.of(); + } +} diff --git a/src/test/resources/assets/litmus/script/auto_torch.lua b/src/test/resources/assets/litmus/script/auto_torch.lua index 6cf6fcb..c2ff150 100644 --- a/src/test/resources/assets/litmus/script/auto_torch.lua +++ b/src/test/resources/assets/litmus/script/auto_torch.lua @@ -1,17 +1,18 @@ -- Auto Torch Script: Warns when light level is low (mobs can spawn) -- Enable with: /amblescript enable litmus:auto_torch -- Disable with: /amblescript disable litmus:auto_torch +-- +-- Note: minecraft data is passed as first argument to callbacks. -local lastWarning = 0 local WARNING_COOLDOWN = 100 -- ticks between warnings (5 seconds) local ticksSinceWarning = WARNING_COOLDOWN -function onEnable() - minecraft:sendMessage("§e🔦 Auto Torch Advisor enabled!", false) - minecraft:sendMessage("§7 Will warn you when light level is dangerously low", false) +function onEnable(mc) + mc:sendMessage("§e🔦 Auto Torch Advisor enabled!", false) + mc:sendMessage("§7 Will warn you when light level is dangerously low", false) end -function onTick() +function onTick(mc) ticksSinceWarning = ticksSinceWarning + 1 -- Only check every 10 ticks for performance @@ -19,9 +20,9 @@ function onTick() return end - local player = minecraft:player() + local player = mc:player() local pos = player:blockPosition() - local lightLevel = minecraft:lightLevelAt(pos.x, pos.y, pos.z) + local lightLevel = mc:lightLevelAt(pos.x, pos.y, pos.z) -- Check if we're on the ground and light is low if player:isOnGround() and lightLevel < 8 and ticksSinceWarning >= WARNING_COOLDOWN then @@ -29,17 +30,21 @@ function onTick() -- Different warnings based on light level if lightLevel <= 0 then - minecraft:sendMessage("§4⚠ DANGER! §cComplete darkness (Light: " .. lightLevel .. ") - Mobs WILL spawn!", true) - minecraft:playSound("minecraft:block.note_block.bass", 0.8, 0.5) + mc:sendMessage("§4⚠ DANGER! §cComplete darkness (Light: " .. lightLevel .. ") - Mobs WILL spawn!", true) + if mc:isClientSide() then + mc:playSound("minecraft:block.note_block.bass", 0.8, 0.5) + end elseif lightLevel <= 3 then - minecraft:sendMessage("§c⚠ Warning! §eVery dark (Light: " .. lightLevel .. ") - High spawn risk!", true) - minecraft:playSound("minecraft:block.note_block.hat", 0.5, 0.8) + mc:sendMessage("§c⚠ Warning! §eVery dark (Light: " .. lightLevel .. ") - High spawn risk!", true) + if mc:isClientSide() then + mc:playSound("minecraft:block.note_block.hat", 0.5, 0.8) + end else - minecraft:sendMessage("§e⚠ Caution: §7Low light (Light: " .. lightLevel .. ") - Mobs can spawn", true) + mc:sendMessage("§e⚠ Caution: §7Low light (Light: " .. lightLevel .. ") - Mobs can spawn", true) end end end -function onDisable() - minecraft:sendMessage("§7🔦 Auto Torch Advisor disabled", false) +function onDisable(mc) + mc:sendMessage("§7🔦 Auto Torch Advisor disabled", false) end diff --git a/src/test/resources/assets/litmus/script/clipboard_demo.lua b/src/test/resources/assets/litmus/script/clipboard_demo.lua index 3fc6831..d56533a 100644 --- a/src/test/resources/assets/litmus/script/clipboard_demo.lua +++ b/src/test/resources/assets/litmus/script/clipboard_demo.lua @@ -1,45 +1,53 @@ -- Clipboard Demo Script: Demonstrates clipboard and UI functionality -- Run with: /amblescript execute litmus:clipboard_demo +-- +-- Note: This script uses client-only features (clipboard, window size) -function onExecute() - local player = minecraft:player() +function onExecute(mc) + -- Check if we're on the client side + if not mc:isClientSide() then + mc:sendMessage("§cThis script requires client-side features!", false) + return + end + + local player = mc:player() local pos = player:position() -- Header - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§e§l✦ Clipboard Demo ✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Clipboard Demo ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) -- Show current clipboard content - local currentClipboard = minecraft:clipboard() + local currentClipboard = mc:clipboard() if currentClipboard and currentClipboard ~= "" then local preview = currentClipboard if #preview > 50 then preview = preview:sub(1, 50) .. "..." end - minecraft:sendMessage("§7Current clipboard: §f" .. preview, false) + mc:sendMessage("§7Current clipboard: §f" .. preview, false) else - minecraft:sendMessage("§7Current clipboard: §8(empty)", false) + mc:sendMessage("§7Current clipboard: §8(empty)", false) end - minecraft:sendMessage("", false) + mc:sendMessage("", false) -- Copy coordinates to clipboard local coords = string.format("%.0f %.0f %.0f", pos.x, pos.y, pos.z) - minecraft:setClipboard(coords) - minecraft:sendMessage("§a✓ Copied coordinates to clipboard!", false) - minecraft:sendMessage("§7 " .. coords, false) + mc:setClipboard(coords) + mc:sendMessage("§a✓ Copied coordinates to clipboard!", false) + mc:sendMessage("§7 " .. coords, false) - minecraft:sendMessage("", false) + mc:sendMessage("", false) -- Window info - minecraft:sendMessage("§e§l✦ Window Info ✦", false) - minecraft:sendMessage("§7Window size: §f" .. minecraft:windowWidth() .. "§7 x §f" .. minecraft:windowHeight(), false) + mc:sendMessage("§e§l✦ Window Info ✦", false) + mc:sendMessage("§7Window size: §f" .. mc:windowWidth() .. "§7 x §f" .. mc:windowHeight(), false) -- Play a sound to indicate success - minecraft:playSound("minecraft:entity.experience_orb.pickup", 1.0, 1.5) + mc:playSound("minecraft:entity.experience_orb.pickup", 1.0, 1.5) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§7Tip: Paste (Ctrl+V) to use the coordinates!", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§7Tip: Paste (Ctrl+V) to use the coordinates!", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) end diff --git a/src/test/resources/assets/litmus/script/entity_inspect.lua b/src/test/resources/assets/litmus/script/entity_inspect.lua index c0b2d95..cfffe44 100644 --- a/src/test/resources/assets/litmus/script/entity_inspect.lua +++ b/src/test/resources/assets/litmus/script/entity_inspect.lua @@ -1,50 +1,57 @@ -- Entity Inspect Script: Shows info about the entity you're looking at -- Run with: /amblescript execute litmus:entity_inspect +-- +-- Note: Uses client-only lookingAtEntity feature -function onExecute() - local target = minecraft:lookingAtEntity() +function onExecute(mc) + local target = nil + + -- lookingAtEntity is client-only + if mc:isClientSide() then + target = mc:lookingAtEntity() + end -- Header - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§e§l✦ Entity Inspector ✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Entity Inspector ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) if not target then - minecraft:sendMessage("§8Look at an entity and run this script!", false) + mc:sendMessage("§8Look at an entity and run this script!", false) -- Show nearest entity instead - local nearest = minecraft:nearestEntity(10) + local nearest = mc:nearestEntity(10) if nearest then - minecraft:sendMessage("", false) - minecraft:sendMessage("§7Nearest entity (within 10 blocks):", false) - minecraft:sendMessage("§a→ §f" .. nearest:name() .. " §7(" .. nearest:type():gsub("minecraft:", "") .. ")", false) + mc:sendMessage("", false) + mc:sendMessage("§7Nearest entity (within 10 blocks):", false) + mc:sendMessage("§a→ §f" .. nearest:name() .. " §7(" .. nearest:type():gsub("minecraft:", "") .. ")", false) - local player = minecraft:player() + local player = mc:player() local playerPos = player:position() local distance = nearest:distanceTo(playerPos.x, playerPos.y, playerPos.z) - minecraft:sendMessage("§7 Distance: §e" .. string.format("%.1f", distance) .. " blocks", false) + mc:sendMessage("§7 Distance: §e" .. string.format("%.1f", distance) .. " blocks", false) else - minecraft:sendMessage("§8No entities within 10 blocks!", false) + mc:sendMessage("§8No entities within 10 blocks!", false) end - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) return end -- Basic info - minecraft:sendMessage("§7Name: §f" .. target:name(), false) - minecraft:sendMessage("§7Type: §b" .. target:type():gsub("minecraft:", ""), false) - minecraft:sendMessage("§7UUID: §8" .. target:uuid():sub(1, 8) .. "...", false) + mc:sendMessage("§7Name: §f" .. target:name(), false) + mc:sendMessage("§7Type: §b" .. target:type():gsub("minecraft:", ""), false) + mc:sendMessage("§7UUID: §8" .. target:uuid():sub(1, 8) .. "...", false) -- Position local pos = target:position() - minecraft:sendMessage("§7Position: §f" .. string.format("%.1f", pos.x) .. "§7, §f" .. string.format("%.1f", pos.y) .. "§7, §f" .. string.format("%.1f", pos.z), false) + mc:sendMessage("§7Position: §f" .. string.format("%.1f", pos.x) .. "§7, §f" .. string.format("%.1f", pos.y) .. "§7, §f" .. string.format("%.1f", pos.z), false) -- Distance from player - local player = minecraft:player() + local player = mc:player() local playerPos = player:position() local distance = target:distanceTo(playerPos.x, playerPos.y, playerPos.z) - minecraft:sendMessage("§7Distance: §e" .. string.format("%.1f", distance) .. " blocks", false) + mc:sendMessage("§7Distance: §e" .. string.format("%.1f", distance) .. " blocks", false) -- Health (if living entity) local health = target:health() @@ -70,18 +77,18 @@ function onExecute() end end - minecraft:sendMessage("§7Health: " .. healthBar .. " §f" .. string.format("%.1f", health) .. "§7/§f" .. string.format("%.0f", maxHealth), false) + mc:sendMessage("§7Health: " .. healthBar .. " §f" .. string.format("%.1f", health) .. "§7/§f" .. string.format("%.0f", maxHealth), false) end -- Armor local armor = target:armorValue() if armor > 0 then - minecraft:sendMessage("§9Armor: §f" .. armor, false) + mc:sendMessage("§9Armor: §f" .. armor, false) end - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§e§l✦ Entity State ✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Entity State ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) -- States local states = {} @@ -131,30 +138,30 @@ function onExecute() end for _, state in ipairs(states) do - minecraft:sendMessage(" " .. state, false) + mc:sendMessage(" " .. state, false) end -- Velocity local vel = target:velocity() local speed = math.sqrt(vel.x * vel.x + vel.z * vel.z) - minecraft:sendMessage("§7Speed: §f" .. string.format("%.2f", speed * 20) .. " §7blocks/sec", false) + mc:sendMessage("§7Speed: §f" .. string.format("%.2f", speed * 20) .. " §7blocks/sec", false) -- Rotation - minecraft:sendMessage("§7Looking: §fYaw " .. string.format("%.0f", target:yaw()) .. "°, Pitch " .. string.format("%.0f", target:pitch()) .. "°", false) + mc:sendMessage("§7Looking: §fYaw " .. string.format("%.0f", target:yaw()) .. "°, Pitch " .. string.format("%.0f", target:pitch()) .. "°", false) -- Age - minecraft:sendMessage("§7Age: §f" .. target:age() .. " ticks §8(" .. string.format("%.1f", target:age() / 20) .. "s)", false) + mc:sendMessage("§7Age: §f" .. target:age() .. " ticks §8(" .. string.format("%.1f", target:age() / 20) .. "s)", false) -- Effects local effects = target:effects() if #effects > 0 then - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§d§l✦ Status Effects ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§d§l✦ Status Effects ✦", false) for _, effect in ipairs(effects) do local cleanEffect = effect:gsub("minecraft:", ""):gsub("_", " ") - minecraft:sendMessage(" §d✧ §f" .. cleanEffect, false) + mc:sendMessage(" §d✧ §f" .. cleanEffect, false) end end - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) end diff --git a/src/test/resources/assets/litmus/script/hotbar_cycle.lua b/src/test/resources/assets/litmus/script/hotbar_cycle.lua index c81c620..9cae63e 100644 --- a/src/test/resources/assets/litmus/script/hotbar_cycle.lua +++ b/src/test/resources/assets/litmus/script/hotbar_cycle.lua @@ -1,42 +1,50 @@ -- Hotbar Cycle Script: Cycles through hotbar slots with a fun animation --- Run with: /amblekit execute litmus:hotbar_cycle +-- Run with: /amblescript execute litmus:hotbar_cycle +-- +-- Note: Uses client-only hotbar selection features local cycleIndex = 1 local cycleDirection = 1 -function onExecute() - local currentSlot = minecraft:selectedSlot() - - -- Calculate next slot (1-9) - local nextSlot = currentSlot + cycleDirection - - if nextSlot > 9 then - nextSlot = 1 - cycleIndex = cycleIndex + 1 - elseif nextSlot < 1 then - nextSlot = 9 - cycleIndex = cycleIndex + 1 - end - - -- Select the next slot - minecraft:selectSlot(nextSlot) - - -- Create a visual indicator - local indicator = "" - for i = 1, 9 do - if i == nextSlot then - indicator = indicator .. "§e[" .. i .. "]" - else - indicator = indicator .. "§7 " .. i .. " " - end - end - - minecraft:sendMessage("§6Hotbar: " .. indicator, true) - - -- Change direction every full cycle - if cycleIndex > 2 then - cycleDirection = -cycleDirection - cycleIndex = 1 - minecraft:sendMessage("§d✦ Direction reversed! ✦", true) - end +function onExecute(mc) + -- Check if we're on the client side + if not mc:isClientSide() then + mc:sendMessage("§cThis script requires client-side features!", false) + return + end + + local currentSlot = mc:selectedSlot() + + -- Calculate next slot (1-9) + local nextSlot = currentSlot + cycleDirection + + if nextSlot > 9 then + nextSlot = 1 + cycleIndex = cycleIndex + 1 + elseif nextSlot < 1 then + nextSlot = 9 + cycleIndex = cycleIndex + 1 + end + + -- Select the next slot + mc:selectSlot(nextSlot) + + -- Create a visual indicator + local indicator = "" + for i = 1, 9 do + if i == nextSlot then + indicator = indicator .. "§e[" .. i .. "]" + else + indicator = indicator .. "§7 " .. i .. " " + end + end + + mc:sendMessage("§6Hotbar: " .. indicator, true) + + -- Change direction every full cycle + if cycleIndex > 2 then + cycleDirection = -cycleDirection + cycleIndex = 1 + mc:sendMessage("§d✦ Direction reversed! ✦", true) + end end diff --git a/src/test/resources/assets/litmus/script/input_test.lua b/src/test/resources/assets/litmus/script/input_test.lua index a0ef71b..df997ff 100644 --- a/src/test/resources/assets/litmus/script/input_test.lua +++ b/src/test/resources/assets/litmus/script/input_test.lua @@ -1,11 +1,19 @@ -- Input Test Script: Shows which movement keys are currently pressed -- Run with: /amblescript execute litmus:input_test +-- +-- Note: Uses client-only input detection features -function onExecute() +function onExecute(mc) + -- Check if we're on the client side + if not mc:isClientSide() then + mc:sendMessage("§cThis script requires client-side features!", false) + return + end + -- Header - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§e§l✦ Input State ✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Input State ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) -- Movement keys local keys = { @@ -21,41 +29,40 @@ function onExecute() } -- Visual keyboard layout for WASD - local w = minecraft:isKeyPressed("forward") and "§a[W]" or "§8[W]" - local a = minecraft:isKeyPressed("left") and "§a[A]" or "§8[A]" - local s = minecraft:isKeyPressed("back") and "§a[S]" or "§8[S]" - local d = minecraft:isKeyPressed("right") and "§a[D]" or "§8[D]" + local w = mc:isKeyPressed("forward") and "§a[W]" or "§8[W]" + local a = mc:isKeyPressed("left") and "§a[A]" or "§8[A]" + local s = mc:isKeyPressed("back") and "§a[S]" or "§8[S]" + local d = mc:isKeyPressed("right") and "§a[D]" or "§8[D]" - minecraft:sendMessage("§7Movement Keys:", false) - minecraft:sendMessage(" " .. w, false) - minecraft:sendMessage(" " .. a .. " " .. s .. " " .. d, false) - minecraft:sendMessage("", false) + mc:sendMessage("§7Movement Keys:", false) + mc:sendMessage(" " .. w, false) + mc:sendMessage(" " .. a .. " " .. s .. " " .. d, false) + mc:sendMessage("", false) -- Other keys - minecraft:sendMessage("§7Action Keys:", false) + mc:sendMessage("§7Action Keys:", false) local pressedKeys = {} - local unpressedKeys = {} for _, keyData in ipairs(keys) do local keyName = keyData[1] local displayKey = keyData[2] local description = keyData[3] - if minecraft:isKeyPressed(keyName) then + if mc:isKeyPressed(keyName) then table.insert(pressedKeys, " §a✓ " .. displayKey .. " §7(" .. description .. ")") end end if #pressedKeys > 0 then for _, msg in ipairs(pressedKeys) do - minecraft:sendMessage(msg, false) + mc:sendMessage(msg, false) end else - minecraft:sendMessage(" §8No action keys pressed", false) + mc:sendMessage(" §8No action keys pressed", false) end - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§7Tip: Hold keys while running this script!", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§7Tip: Hold keys while running this script!", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) end diff --git a/src/test/resources/assets/litmus/script/item_info.lua b/src/test/resources/assets/litmus/script/item_info.lua index 02b6cac..bfc14fa 100644 --- a/src/test/resources/assets/litmus/script/item_info.lua +++ b/src/test/resources/assets/litmus/script/item_info.lua @@ -1,26 +1,34 @@ -- Item Info Script: Shows detailed information about held item -- Run with: /amblescript execute litmus:item_info +-- +-- Note: Uses client-only hotbar selection features -function onExecute() - local player = minecraft:player() +function onExecute(mc) + -- Check if we're on the client side + if not mc:isClientSide() then + mc:sendMessage("§cThis script requires client-side features!", false) + return + end + + local player = mc:player() local inventory = player:inventory() - local selectedSlot = minecraft:selectedSlot() + local selectedSlot = mc:selectedSlot() local heldItem = inventory[selectedSlot] -- Header - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§e§l✦ Held Item Info (Slot " .. selectedSlot .. ") ✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Held Item Info (Slot " .. selectedSlot .. ") ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) if heldItem:isEmpty() then - minecraft:sendMessage("§8You're not holding anything!", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§8You're not holding anything!", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) return end -- Basic info - minecraft:sendMessage("§7Name: §f" .. heldItem:name(), false) - minecraft:sendMessage("§7ID: §b" .. heldItem:id(), false) + mc:sendMessage("§7Name: §f" .. heldItem:name(), false) + mc:sendMessage("§7ID: §b" .. heldItem:id(), false) -- Rarity with color local rarity = heldItem:rarity() @@ -32,15 +40,15 @@ function onExecute() elseif rarity == "epic" then rarityColor = "§d" end - minecraft:sendMessage("§7Rarity: " .. rarityColor .. rarity:sub(1,1):upper() .. rarity:sub(2), false) + mc:sendMessage("§7Rarity: " .. rarityColor .. rarity:sub(1,1):upper() .. rarity:sub(2), false) -- Stack info local count = heldItem:count() local maxCount = heldItem:maxCount() if heldItem:isStackable() then - minecraft:sendMessage("§7Stack: §f" .. count .. "§7/§f" .. maxCount, false) + mc:sendMessage("§7Stack: §f" .. count .. "§7/§f" .. maxCount, false) else - minecraft:sendMessage("§7Stack: §8Not stackable", false) + mc:sendMessage("§7Stack: §8Not stackable", false) end -- Durability @@ -69,24 +77,24 @@ function onExecute() end end - minecraft:sendMessage("§7Durability: " .. durBar .. " §f" .. durability .. "§7/§f" .. maxDamage, false) + mc:sendMessage("§7Durability: " .. durBar .. " §f" .. durability .. "§7/§f" .. maxDamage, false) end -- Food info if heldItem:isFood() then - minecraft:sendMessage("§6🍖 This item is edible!", false) + mc:sendMessage("§6🍖 This item is edible!", false) end -- Custom name if heldItem:hasCustomName() then - minecraft:sendMessage("§7Custom Named: §a✓", false) + mc:sendMessage("§7Custom Named: §a✓", false) end -- Enchantments if heldItem:hasEnchantments() then - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§d§l✦ Enchantments ✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§d§l✦ Enchantments ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) local enchants = heldItem:enchantments() for _, enchant in ipairs(enchants) do @@ -96,17 +104,17 @@ function onExecute() local lastColon = enchant:match(".*():") local enchantName = enchant:sub(1, lastColon - 1):gsub("minecraft:", ""):gsub("_", " ") local level = enchant:sub(lastColon + 1) - minecraft:sendMessage(" §d✧ §f" .. enchantName .. " §7" .. level, false) + mc:sendMessage(" §d✧ §f" .. enchantName .. " §7" .. level, false) else - minecraft:sendMessage(" §d✧ §f" .. enchant, false) + mc:sendMessage(" §d✧ §f" .. enchant, false) end end end -- NBT info if heldItem:hasNbt() then - minecraft:sendMessage("§7Has NBT Data: §a✓", false) + mc:sendMessage("§7Has NBT Data: §a✓", false) end - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) end diff --git a/src/test/resources/assets/litmus/script/player_state.lua b/src/test/resources/assets/litmus/script/player_state.lua index bb9f7bd..4490441 100644 --- a/src/test/resources/assets/litmus/script/player_state.lua +++ b/src/test/resources/assets/litmus/script/player_state.lua @@ -1,13 +1,20 @@ -- Player State Script: Shows detailed player state information -- Run with: /amblescript execute litmus:player_state +-- +-- Note: minecraft data is passed as first argument to callbacks -function onExecute() - local player = minecraft:player() +function onExecute(mc) + local player = mc:player() - -- Header - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§e§l✦ Player State: §f" .. minecraft:username() .. " §e§l✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + -- Header - username is client-only, so we use player name instead + local playerName = player:name() + if mc:isClientSide() then + playerName = mc:username() + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Player State: §f" .. playerName .. " §e§l✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) -- Health & Hunger local health = player:health() @@ -16,9 +23,9 @@ function onExecute() local saturation = player:saturation() local armor = player:armorValue() - minecraft:sendMessage("§c❤ Health: §f" .. string.format("%.1f", health) .. "§7/§f" .. string.format("%.0f", maxHealth), false) - minecraft:sendMessage("§6🍖 Hunger: §f" .. food .. "§7/§f20 §8(Saturation: " .. string.format("%.1f", saturation) .. ")", false) - minecraft:sendMessage("§9🛡 Armor: §f" .. armor, false) + mc:sendMessage("§c❤ Health: §f" .. string.format("%.1f", health) .. "§7/§f" .. string.format("%.0f", maxHealth), false) + mc:sendMessage("§6🍖 Hunger: §f" .. food .. "§7/§f20 §8(Saturation: " .. string.format("%.1f", saturation) .. ")", false) + mc:sendMessage("§9🛡 Armor: §f" .. armor, false) -- Experience local xpLevel = player:experienceLevel() @@ -36,12 +43,12 @@ function onExecute() xpBar = xpBar .. "§8|" end end - minecraft:sendMessage("§a✧ Level: §f" .. xpLevel .. " " .. xpBar .. " §7(" .. string.format("%.0f", xpProgress * 100) .. "%)", false) - minecraft:sendMessage("§7 Total XP: §e" .. totalXp, false) + mc:sendMessage("§a✧ Level: §f" .. xpLevel .. " " .. xpBar .. " §7(" .. string.format("%.0f", xpProgress * 100) .. "%)", false) + mc:sendMessage("§7 Total XP: §e" .. totalXp, false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§e§l✦ Movement State ✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Movement State ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) -- Movement states local states = {} @@ -85,31 +92,33 @@ function onExecute() end for _, state in ipairs(states) do - minecraft:sendMessage(" " .. state, false) + mc:sendMessage(" " .. state, false) end -- Velocity local vel = player:velocity() local speed = math.sqrt(vel.x * vel.x + vel.z * vel.z) - minecraft:sendMessage("§7Speed: §f" .. string.format("%.2f", speed * 20) .. " §7blocks/sec", false) + mc:sendMessage("§7Speed: §f" .. string.format("%.2f", speed * 20) .. " §7blocks/sec", false) - -- Game mode - minecraft:sendMessage("§7Game Mode: §e" .. minecraft:gameMode(), false) + -- Game mode (client only) + if mc:isClientSide() then + mc:sendMessage("§7Game Mode: §e" .. mc:gameMode(), false) + end - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§e§l✦ Active Effects ✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Active Effects ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) -- Status effects local effects = player:effects() if #effects > 0 then for _, effect in ipairs(effects) do local cleanEffect = effect:gsub("minecraft:", ""):gsub("_", " ") - minecraft:sendMessage(" §d✦ §f" .. cleanEffect, false) + mc:sendMessage(" §d✦ §f" .. cleanEffect, false) end else - minecraft:sendMessage(" §8No active effects", false) + mc:sendMessage(" §8No active effects", false) end - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) end diff --git a/src/test/resources/assets/litmus/script/sprint_monitor.lua b/src/test/resources/assets/litmus/script/sprint_monitor.lua index ce64eed..e45ca56 100644 --- a/src/test/resources/assets/litmus/script/sprint_monitor.lua +++ b/src/test/resources/assets/litmus/script/sprint_monitor.lua @@ -1,22 +1,27 @@ -- Sprint Monitor Script: Shows sprint/movement info in action bar -- Enable with: /amblescript enable litmus:sprint_monitor -- Disable with: /amblescript disable litmus:sprint_monitor +-- +-- Note: minecraft data is passed as first argument to callbacks (optional). +-- The 'minecraft' global is also available for backward compatibility. local lastUpdate = 0 local UPDATE_INTERVAL = 2 -- Update every 2 ticks for smooth display -function onEnable() - minecraft:sendMessage("§b🏃 Sprint Monitor enabled!", false) +function onEnable(mc) + -- mc is the ClientMinecraftData passed as argument + -- You can also use the global 'minecraft' variable + mc:sendMessage("§b🏃 Sprint Monitor enabled!", false) end -function onTick() +function onTick(mc) lastUpdate = lastUpdate + 1 if lastUpdate < UPDATE_INTERVAL then return end lastUpdate = 0 - local player = minecraft:player() + local player = mc:player() local vel = player:velocity() local speed = math.sqrt(vel.x * vel.x + vel.z * vel.z) * 20 -- blocks per second @@ -59,9 +64,9 @@ function onTick() status = status .. " §9💧" end - minecraft:sendMessage(status, true) + mc:sendMessage(status, true) end -function onDisable() - minecraft:sendMessage("§7🏃 Sprint Monitor disabled", false) +function onDisable(mc) + mc:sendMessage("§7🏃 Sprint Monitor disabled", false) end diff --git a/src/test/resources/assets/litmus/script/stats.lua b/src/test/resources/assets/litmus/script/stats.lua index 7ad20f9..ce422b7 100644 --- a/src/test/resources/assets/litmus/script/stats.lua +++ b/src/test/resources/assets/litmus/script/stats.lua @@ -1,86 +1,98 @@ -- Stats Script: Shows player info and nearby entities --- Run with: /amblekit execute litmus:stats +-- Run with: /amblescript execute litmus:stats +-- +-- Note: minecraft data is passed as first argument to callbacks -function onExecute() - -- Get player info - local player = minecraft:player() - local username = minecraft:username() - local pos = player:position() - local health = player:health() - local food = player:foodLevel() - local slot = minecraft:selectedSlot() - - -- Send a stylish header - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§e§l✦ Player Stats for §f" .. username .. " §e§l✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - - -- Health bar visualization - local maxHearts = 10 - local currentHearts = math.floor(health / 2) - local heartBar = "" - for i = 1, maxHearts do - if i <= currentHearts then - heartBar = heartBar .. "§c❤" - else - heartBar = heartBar .. "§8❤" - end - end - minecraft:sendMessage("§7Health: " .. heartBar .. " §f(" .. string.format("%.1f", health) .. ")", false) - - -- Food bar visualization - local maxFood = 10 - local currentFood = math.floor(food / 2) - local foodBar = "" - for i = 1, maxFood do - if i <= currentFood then - foodBar = foodBar .. "§6🍖" - else - foodBar = foodBar .. "§8🍖" - end - end - minecraft:sendMessage("§7Hunger: " .. foodBar .. " §f(" .. food .. ")", false) - - -- Position - minecraft:sendMessage("§7Position: §b" .. string.format("%.1f", pos.x) .. "§7, §a" .. string.format("%.1f", pos.y) .. "§7, §d" .. string.format("%.1f", pos.z), false) - minecraft:sendMessage("§7Selected Slot: §e" .. slot, false) - - -- Count nearby entities - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§e§l✦ Nearby Entities ✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - - local entities = minecraft:entities() - local entityCounts = {} - local totalCount = 0 - - for _, entity in pairs(entities) do - local entityType = entity:type() - -- Skip the player themselves - if not (entity:isPlayer() and entity:name() == username) then - entityCounts[entityType] = (entityCounts[entityType] or 0) + 1 - totalCount = totalCount + 1 - end - end - - -- Display entity counts (limit to first 8 types) - local displayed = 0 - for entityType, count in pairs(entityCounts) do - if displayed < 8 then - -- Clean up the entity type name - local cleanName = entityType:gsub("minecraft:", ""):gsub("_", " ") - minecraft:sendMessage("§7• §f" .. cleanName .. "§7: §a" .. count, false) - displayed = displayed + 1 - end - end - - if displayed == 0 then - minecraft:sendMessage("§7No entities nearby!", false) - elseif totalCount > displayed then - minecraft:sendMessage("§8...and " .. (totalCount - displayed) .. " more types", false) - end - - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§7Total entities: §e" .. totalCount, false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +function onExecute(mc) + -- Get player info + local player = mc:player() + local pos = player:position() + local health = player:health() + local food = player:foodLevel() + + -- Username is client-only, use player name on server + local username = player:name() + if mc:isClientSide() then + username = mc:username() + end + + -- Selected slot is client-only + local slot = "N/A" + if mc:isClientSide() then + slot = tostring(mc:selectedSlot()) + end + + -- Send a stylish header + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Player Stats for §f" .. username .. " §e§l✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Health bar visualization + local maxHearts = 10 + local currentHearts = math.floor(health / 2) + local heartBar = "" + for i = 1, maxHearts do + if i <= currentHearts then + heartBar = heartBar .. "§c❤" + else + heartBar = heartBar .. "§8❤" + end + end + mc:sendMessage("§7Health: " .. heartBar .. " §f(" .. string.format("%.1f", health) .. ")", false) + + -- Food bar visualization + local maxFood = 10 + local currentFood = math.floor(food / 2) + local foodBar = "" + for i = 1, maxFood do + if i <= currentFood then + foodBar = foodBar .. "§6🍖" + else + foodBar = foodBar .. "§8🍖" + end + end + mc:sendMessage("§7Hunger: " .. foodBar .. " §f(" .. food .. ")", false) + + -- Position + mc:sendMessage("§7Position: §b" .. string.format("%.1f", pos.x) .. "§7, §a" .. string.format("%.1f", pos.y) .. "§7, §d" .. string.format("%.1f", pos.z), false) + mc:sendMessage("§7Selected Slot: §e" .. slot, false) + + -- Count nearby entities + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Nearby Entities ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + local entities = mc:entities() + local entityCounts = {} + local totalCount = 0 + + for _, entity in pairs(entities) do + local entityType = entity:type() + -- Skip the player themselves + if not (entity:isPlayer() and entity:name() == username) then + entityCounts[entityType] = (entityCounts[entityType] or 0) + 1 + totalCount = totalCount + 1 + end + end + + -- Display entity counts (limit to first 8 types) + local displayed = 0 + for entityType, count in pairs(entityCounts) do + if displayed < 8 then + -- Clean up the entity type name + local cleanName = entityType:gsub("minecraft:", ""):gsub("_", " ") + mc:sendMessage("§7• §f" .. cleanName .. "§7: §a" .. count, false) + displayed = displayed + 1 + end + end + + if displayed == 0 then + mc:sendMessage("§7No entities nearby!", false) + elseif totalCount > displayed then + mc:sendMessage("§8...and " .. (totalCount - displayed) .. " more types", false) + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§7Total entities: §e" .. totalCount, false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) end diff --git a/src/test/resources/assets/litmus/script/test.lua b/src/test/resources/assets/litmus/script/test.lua index e5a962f..2ef6a06 100644 --- a/src/test/resources/assets/litmus/script/test.lua +++ b/src/test/resources/assets/litmus/script/test.lua @@ -1,37 +1,43 @@ +-- Test Script: Basic test script for GUI interactions +-- This script is designed for GUI element click handlers +-- +-- Note: GUI scripts receive 'self' (LuaElement) and use self:minecraft() for data + function onClick(self, mouseX, mouseY, button) - local text = self:getText() - if (text == nil) then - --- find first child which is not nil - for i = 0, self:childCount() - 1 do - local child = self:child(i) - local childText = child:getText() - if (childText ~= nil) then - --- set to player username - child:setText("Hello " .. self:minecraft():username() .. "!") - break - end - end - end + local text = self:getText() + if (text == nil) then + --- find first child which is not nil + for i = 0, self:childCount() - 1 do + local child = self:child(i) + local childText = child:getText() + if (childText ~= nil) then + --- set to player username + child:setText("Hello " .. self:minecraft():username() .. "!") + break + end + end + end - -- search the inventory for an apple and select it if its in the hotbar - local inventory = self:minecraft():player():inventory() -- a table of ItemStacks - for slotIndex, itemStack in pairs(inventory) do - if (itemStack ~= nil and itemStack:id() == "minecraft:apple") then - -- drop apple - self:minecraft():dropStack(slotIndex, True) - break - end - end - - -- print all entities - local entities = self:minecraft():entities() - print(entities) - for _, entity in pairs(entities) do - print(entity) - print("Entity: " .. entity:type() .. " at " .. entity:position():toString()) - end + -- search the inventory for an apple and select it if its in the hotbar + local inventory = self:minecraft():player():inventory() -- a table of ItemStacks + for slotIndex, itemStack in pairs(inventory) do + if (itemStack ~= nil and itemStack:id() == "minecraft:apple") then + -- drop apple + self:minecraft():dropStack(slotIndex, true) + break + end + end + + -- print all entities + local entities = self:minecraft():entities() + print(entities) + for _, entity in pairs(entities) do + print(entity) + print("Entity: " .. entity:type() .. " at " .. entity:position():toString()) + end end -function onExecute() - print("Script executed via command!") +function onExecute(mc) + mc:log("Test script executed via command!") + mc:sendMessage("§aTest script executed!", false) end diff --git a/src/test/resources/assets/litmus/script/tick_demo.lua b/src/test/resources/assets/litmus/script/tick_demo.lua index debb78c..fd7c5b6 100644 --- a/src/test/resources/assets/litmus/script/tick_demo.lua +++ b/src/test/resources/assets/litmus/script/tick_demo.lua @@ -1,18 +1,26 @@ -- Tick Demo Script: Demonstrates onEnable, onTick, onDisable lifecycle -- Enable with: /amblescript enable litmus:tick_demo -- Disable with: /amblescript disable litmus:tick_demo +-- +-- Note: minecraft data is passed as first argument to callbacks. +-- Use mc:isClientSide() to check if running on client or server. local tickCount = 0 local lastSecond = 0 -function onEnable() +function onEnable(mc) tickCount = 0 lastSecond = 0 - minecraft:sendMessage("§a✓ Tick Demo enabled! Counting ticks...", false) - minecraft:playSound("minecraft:block.note_block.pling", 1.0, 2.0) + mc:sendMessage("§a✓ Tick Demo enabled! Counting ticks...", false) + mc:log("Tick Demo enabled - isClientSide: " .. tostring(mc:isClientSide())) + + -- playSound only available on client + if mc:isClientSide() then + mc:playSound("minecraft:block.note_block.pling", 1.0, 2.0) + end end -function onTick() +function onTick(mc) tickCount = tickCount + 1 -- Every 20 ticks (1 second), show a message @@ -21,18 +29,21 @@ function onTick() lastSecond = currentSecond -- Show in action bar every second - minecraft:sendMessage("§7Tick Demo: §e" .. tickCount .. " ticks §7(§f" .. currentSecond .. "s§7)", true) + mc:sendMessage("§7Tick Demo: §e" .. tickCount .. " ticks §7(§f" .. currentSecond .. "s§7)", true) - -- Play a subtle sound every 5 seconds - if currentSecond % 5 == 0 then - minecraft:playSound("minecraft:block.note_block.hat", 0.5, 1.0) + -- Play a subtle sound every 5 seconds (client only) + if currentSecond % 5 == 0 and mc:isClientSide() then + mc:playSound("minecraft:block.note_block.hat", 0.5, 1.0) end end end -function onDisable() +function onDisable(mc) local totalSeconds = math.floor(tickCount / 20) - minecraft:sendMessage("§c✗ Tick Demo disabled!", false) - minecraft:sendMessage("§7 Ran for §e" .. tickCount .. " ticks §7(§f" .. totalSeconds .. " seconds§7)", false) - minecraft:playSound("minecraft:block.note_block.bass", 1.0, 0.5) + mc:sendMessage("§c✗ Tick Demo disabled!", false) + mc:sendMessage("§7 Ran for §e" .. tickCount .. " ticks §7(§f" .. totalSeconds .. " seconds§7)", false) + + if mc:isClientSide() then + mc:playSound("minecraft:block.note_block.bass", 1.0, 0.5) + end end diff --git a/src/test/resources/assets/litmus/script/world_info.lua b/src/test/resources/assets/litmus/script/world_info.lua index 825631b..b3fbbd9 100644 --- a/src/test/resources/assets/litmus/script/world_info.lua +++ b/src/test/resources/assets/litmus/script/world_info.lua @@ -1,28 +1,30 @@ -- World Info Script: Displays world environment information -- Run with: /amblescript execute litmus:world_info +-- +-- Note: minecraft data is passed as first argument to callbacks. -function onExecute() - local player = minecraft:player() +function onExecute(mc) + local player = mc:player() local pos = player:blockPosition() -- Header - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§e§l✦ World Information ✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ World Information ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) -- Dimension - local dimension = minecraft:dimension() + local dimension = mc:dimension() local dimColor = "§a" if dimension:find("nether") then dimColor = "§c" elseif dimension:find("end") then dimColor = "§d" end - minecraft:sendMessage("§7Dimension: " .. dimColor .. dimension, false) + mc:sendMessage("§7Dimension: " .. dimColor .. dimension, false) -- Time of day - local worldTime = minecraft:worldTime() - local dayCount = minecraft:dayCount() + local worldTime = mc:worldTime() + local dayCount = mc:dayCount() local timeOfDay = worldTime % 24000 local timeString = "Day" @@ -38,54 +40,56 @@ function onExecute() timeIcon = "✧" end - minecraft:sendMessage("§7Time: §e" .. timeIcon .. " " .. timeString .. " §7(Day §f" .. dayCount .. "§7)", false) + mc:sendMessage("§7Time: §e" .. timeIcon .. " " .. timeString .. " §7(Day §f" .. dayCount .. "§7)", false) -- Weather local weatherIcon = "☀" local weatherText = "Clear" local weatherColor = "§e" - if minecraft:isThundering() then + if mc:isThundering() then weatherIcon = "⚡" weatherText = "Thunderstorm" weatherColor = "§5" - elseif minecraft:isRaining() then + elseif mc:isRaining() then weatherIcon = "🌧" weatherText = "Raining" weatherColor = "§9" end - minecraft:sendMessage("§7Weather: " .. weatherColor .. weatherIcon .. " " .. weatherText, false) + mc:sendMessage("§7Weather: " .. weatherColor .. weatherIcon .. " " .. weatherText, false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) - minecraft:sendMessage("§e§l✦ Location Details ✦", false) - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Location Details ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) -- Biome - local biome = minecraft:biomeAt(pos.x, pos.y, pos.z) + local biome = mc:biomeAt(pos.x, pos.y, pos.z) local cleanBiome = biome:gsub("minecraft:", ""):gsub("_", " ") - minecraft:sendMessage("§7Biome: §a" .. cleanBiome, false) + mc:sendMessage("§7Biome: §a" .. cleanBiome, false) -- Block below player - local blockBelow = minecraft:blockAt(pos.x, pos.y - 1, pos.z) + local blockBelow = mc:blockAt(pos.x, pos.y - 1, pos.z) local cleanBlock = blockBelow:gsub("minecraft:", ""):gsub("_", " ") - minecraft:sendMessage("§7Standing on: §b" .. cleanBlock, false) + mc:sendMessage("§7Standing on: §b" .. cleanBlock, false) -- Light level - local lightLevel = minecraft:lightLevelAt(pos.x, pos.y, pos.z) + local lightLevel = mc:lightLevelAt(pos.x, pos.y, pos.z) local lightColor = "§a" if lightLevel < 8 then lightColor = "§c" -- Mobs can spawn elseif lightLevel < 12 then lightColor = "§e" end - minecraft:sendMessage("§7Light Level: " .. lightColor .. lightLevel .. " §8(mobs spawn below 8)", false) + mc:sendMessage("§7Light Level: " .. lightColor .. lightLevel .. " §8(mobs spawn below 8)", false) - -- Looking at block - local lookingAt = minecraft:lookingAtBlock() - if lookingAt then - local targetBlock = minecraft:blockAt(lookingAt.x, lookingAt.y, lookingAt.z) - local cleanTarget = targetBlock:gsub("minecraft:", ""):gsub("_", " ") - minecraft:sendMessage("§7Looking at: §d" .. cleanTarget .. " §8(" .. lookingAt.x .. ", " .. lookingAt.y .. ", " .. lookingAt.z .. ")", false) + -- Looking at block (client only feature) + if mc:isClientSide() then + local lookingAt = mc:lookingAtBlock() + if lookingAt then + local targetBlock = mc:blockAt(lookingAt.x, lookingAt.y, lookingAt.z) + local cleanTarget = targetBlock:gsub("minecraft:", ""):gsub("_", " ") + mc:sendMessage("§7Looking at: §d" .. cleanTarget .. " §8(" .. lookingAt.x .. ", " .. lookingAt.y .. ", " .. lookingAt.z .. ")", false) + end end - minecraft:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) end diff --git a/src/test/resources/data/litmus/script/admin_commands.lua b/src/test/resources/data/litmus/script/admin_commands.lua new file mode 100644 index 0000000..0842a18 --- /dev/null +++ b/src/test/resources/data/litmus/script/admin_commands.lua @@ -0,0 +1,65 @@ +-- Admin Commands Script: Utility commands for server administration +-- Run with: /serverscript execute litmus:admin_commands +-- +-- This is a SERVER-SIDE script with various admin utilities. + +function onExecute(mc) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Admin Commands Executed ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Log current server state + mc:log("=== Admin Commands Executed ===") + mc:log("Server: " .. mc:serverName()) + mc:log("TPS: " .. string.format("%.1f", mc:serverTps())) + mc:log("Players: " .. mc:playerCount() .. "/" .. mc:maxPlayers()) + + -- List all players with their locations + local players = mc:allPlayers() + mc:sendMessage("§e§l✦ Player Locations ✦", false) + + for _, player in ipairs(players) do + local pos = player:position() + local health = player:health() + local maxHealth = player:maxHealth() + + local healthColor = "§a" + if health / maxHealth < 0.25 then + healthColor = "§c" + elseif health / maxHealth < 0.5 then + healthColor = "§e" + end + + local locationStr = string.format("§f%.0f§7, §f%.0f§7, §f%.0f", pos.x, pos.y, pos.z) + local healthStr = healthColor .. string.format("%.0f", health) .. "§7/" .. string.format("%.0f", maxHealth) + + mc:sendMessage(" §a" .. player:name() .. " §7→ " .. locationStr .. " §7(" .. healthStr .. "§7)", false) + mc:log("Player: " .. player:name() .. " at " .. locationStr .. " health: " .. health) + end + + if #players == 0 then + mc:sendMessage(" §8No players online", false) + end + + -- World info + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ World State ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + mc:sendMessage("§7Dimension: §f" .. mc:dimension(), false) + mc:sendMessage("§7World Time: §f" .. mc:worldTime() .. " §7(Day " .. mc:dayCount() .. ")", false) + + local weather = "Clear" + if mc:isThundering() then + weather = "Thunderstorm" + elseif mc:isRaining() then + weather = "Raining" + end + mc:sendMessage("§7Weather: §f" .. weather, false) + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§7Admin report logged to console.", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + mc:log("=== End Admin Report ===") +end diff --git a/src/test/resources/data/litmus/script/auto_broadcast.lua b/src/test/resources/data/litmus/script/auto_broadcast.lua new file mode 100644 index 0000000..25d2a86 --- /dev/null +++ b/src/test/resources/data/litmus/script/auto_broadcast.lua @@ -0,0 +1,49 @@ +-- Auto Broadcast Script: Periodically broadcasts messages to all players +-- Enable with: /serverscript enable litmus:auto_broadcast +-- Disable with: /serverscript disable litmus:auto_broadcast +-- +-- This is a SERVER-SIDE script that broadcasts messages periodically. + +local tickCount = 0 +local messageIndex = 1 +local BROADCAST_INTERVAL = 1200 -- Every 60 seconds (1200 ticks) + +-- Messages to cycle through +local messages = { + "§6§l[TIP] §r§7Use §e/serverscript list §7to see enabled scripts!", + "§6§l[TIP] §r§7Server-side scripts run for all players automatically.", + "§6§l[TIP] §r§7Scripts are loaded from the §edata §7folder.", + "§6§l[INFO] §r§7This message was sent by a Lua script!", + "§a§l[SERVER] §r§7Welcome to the server! Enjoy your stay.", +} + +function onEnable(mc) + tickCount = 0 + messageIndex = 1 + mc:log("Auto Broadcast enabled with " .. #messages .. " messages") + mc:broadcast("§a§l[Server] §r§7Auto broadcast enabled!") +end + +function onTick(mc) + tickCount = tickCount + 1 + + if tickCount % BROADCAST_INTERVAL ~= 0 then + return + end + + -- Broadcast current message + local message = messages[messageIndex] + mc:broadcast(message) + mc:log("Broadcasted message " .. messageIndex .. ": " .. message) + + -- Move to next message + messageIndex = messageIndex + 1 + if messageIndex > #messages then + messageIndex = 1 + end +end + +function onDisable(mc) + mc:broadcast("§c§l[Server] §r§7Auto broadcast disabled") + mc:log("Auto Broadcast disabled after " .. tickCount .. " ticks") +end diff --git a/src/test/resources/data/litmus/script/player_tracker.lua b/src/test/resources/data/litmus/script/player_tracker.lua new file mode 100644 index 0000000..5a897f9 --- /dev/null +++ b/src/test/resources/data/litmus/script/player_tracker.lua @@ -0,0 +1,51 @@ +-- Player Tracker Script: Monitors players and logs activity +-- Enable with: /serverscript enable litmus:player_tracker +-- Disable with: /serverscript disable litmus:player_tracker +-- +-- This is a SERVER-SIDE script that tracks player activity. + +local lastPlayerCount = 0 +local tickCount = 0 +local CHECK_INTERVAL = 100 -- Check every 5 seconds (100 ticks) + +function onEnable(mc) + lastPlayerCount = mc:playerCount() + tickCount = 0 + mc:log("Player Tracker enabled - tracking " .. lastPlayerCount .. " players") + mc:broadcast("§e[Server] §7Player tracking enabled") +end + +function onTick(mc) + tickCount = tickCount + 1 + + if tickCount % CHECK_INTERVAL ~= 0 then + return + end + + local currentCount = mc:playerCount() + + -- Check if player count changed + if currentCount ~= lastPlayerCount then + local diff = currentCount - lastPlayerCount + if diff > 0 then + mc:log("Player count increased: " .. lastPlayerCount .. " -> " .. currentCount) + else + mc:log("Player count decreased: " .. lastPlayerCount .. " -> " .. currentCount) + end + lastPlayerCount = currentCount + end + + -- Log all player positions every 5 seconds + local players = mc:allPlayers() + for _, player in ipairs(players) do + local pos = player:position() + mc:log("Player " .. player:name() .. " at " .. + string.format("%.0f, %.0f, %.0f", pos.x, pos.y, pos.z) .. + " (Health: " .. string.format("%.1f", player:health()) .. ")") + end +end + +function onDisable(mc) + mc:log("Player Tracker disabled after " .. tickCount .. " ticks") + mc:broadcast("§e[Server] §7Player tracking disabled") +end diff --git a/src/test/resources/data/litmus/script/server_status.lua b/src/test/resources/data/litmus/script/server_status.lua new file mode 100644 index 0000000..3f79bed --- /dev/null +++ b/src/test/resources/data/litmus/script/server_status.lua @@ -0,0 +1,84 @@ +-- Server Status Script: Shows server information and statistics +-- Run with: /serverscript execute litmus:server_status +-- +-- This is a SERVER-SIDE script. It runs on the server and has access to +-- all players, server TPS, and other server-specific information. + +function onExecute(mc) + -- Confirm we're on the server + if mc:isClientSide() then + mc:sendMessage("§cThis script should only run on the server!", false) + return + end + + mc:log("Server status script executed") + + -- Header + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Server Status ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Server info + mc:sendMessage("§7Server: §f" .. mc:serverName(), false) + mc:sendMessage("§7Type: §f" .. (mc:isDedicatedServer() and "Dedicated" or "Integrated"), false) + + -- Performance + local tps = mc:serverTps() + local tpsColor = "§a" + if tps < 15 then + tpsColor = "§c" + elseif tps < 18 then + tpsColor = "§e" + end + mc:sendMessage("§7TPS: " .. tpsColor .. string.format("%.1f", tps) .. "§7/20", false) + mc:sendMessage("§7Tick Count: §f" .. mc:tickCount(), false) + + -- World info + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ World Info ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + mc:sendMessage("§7Dimension: §f" .. mc:dimension(), false) + mc:sendMessage("§7Day: §f" .. mc:dayCount(), false) + + local weatherIcon = "☀" + local weatherText = "Clear" + local weatherColor = "§e" + if mc:isThundering() then + weatherIcon = "⚡" + weatherText = "Thunderstorm" + weatherColor = "§5" + elseif mc:isRaining() then + weatherIcon = "🌧" + weatherText = "Raining" + weatherColor = "§9" + end + mc:sendMessage("§7Weather: " .. weatherColor .. weatherIcon .. " " .. weatherText, false) + + -- Players + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Players (" .. mc:playerCount() .. "/" .. mc:maxPlayers() .. ") ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + local players = mc:allPlayerNames() + if #players > 0 then + for _, name in ipairs(players) do + mc:sendMessage(" §a• §f" .. name, false) + end + else + mc:sendMessage(" §8No players online", false) + end + + -- Worlds + local worlds = mc:worldNames() + if #worlds > 0 then + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Loaded Worlds ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + for _, world in ipairs(worlds) do + mc:sendMessage(" §b• §f" .. world, false) + end + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end diff --git a/src/test/resources/data/litmus/script/tick_counter.lua b/src/test/resources/data/litmus/script/tick_counter.lua new file mode 100644 index 0000000..a3b47d2 --- /dev/null +++ b/src/test/resources/data/litmus/script/tick_counter.lua @@ -0,0 +1,51 @@ +-- Tick Counter Script: Simple server-side tick counter demonstration +-- Enable with: /serverscript enable litmus:tick_counter +-- Disable with: /serverscript disable litmus:tick_counter +-- +-- This is a SERVER-SIDE script demonstrating the tick lifecycle. + +local tickCount = 0 +local lastSecond = 0 +local LOG_INTERVAL = 200 -- Log every 10 seconds (200 ticks) + +function onEnable(mc) + tickCount = 0 + lastSecond = 0 + + mc:log("Server Tick Counter enabled") + mc:log(" - TPS: " .. string.format("%.1f", mc:serverTps())) + mc:log(" - Players online: " .. mc:playerCount()) + + -- Notify all players + mc:broadcast("§a[Server] §7Tick counter script enabled!") +end + +function onTick(mc) + tickCount = tickCount + 1 + + -- Log to console every LOG_INTERVAL ticks + if tickCount % LOG_INTERVAL == 0 then + local seconds = tickCount / 20 + mc:log("Tick Counter: " .. tickCount .. " ticks (" .. seconds .. "s) - TPS: " .. string.format("%.1f", mc:serverTps())) + end + + -- Broadcast to players every 60 seconds + local currentSecond = math.floor(tickCount / 20) + if currentSecond > lastSecond and currentSecond % 60 == 0 then + lastSecond = currentSecond + local minutes = math.floor(currentSecond / 60) + mc:broadcast("§7[Server] Script running for §e" .. minutes .. " minute" .. (minutes > 1 and "s" or "")) + end +end + +function onDisable(mc) + local totalSeconds = math.floor(tickCount / 20) + local minutes = math.floor(totalSeconds / 60) + local seconds = totalSeconds % 60 + + mc:log("Server Tick Counter disabled") + mc:log(" - Total ticks: " .. tickCount) + mc:log(" - Runtime: " .. minutes .. "m " .. seconds .. "s") + + mc:broadcast("§c[Server] §7Tick counter disabled after §e" .. minutes .. "m " .. seconds .. "s") +end diff --git a/src/test/resources/data/litmus/script/weather_announcer.lua b/src/test/resources/data/litmus/script/weather_announcer.lua new file mode 100644 index 0000000..73a7221 --- /dev/null +++ b/src/test/resources/data/litmus/script/weather_announcer.lua @@ -0,0 +1,70 @@ +-- Weather Announcer Script: Announces weather changes to all players +-- Enable with: /serverscript enable litmus:weather_announcer +-- Disable with: /serverscript disable litmus:weather_announcer +-- +-- This is a SERVER-SIDE script that monitors and announces weather changes. + +local lastRaining = false +local lastThundering = false +local CHECK_INTERVAL = 20 -- Check every second + +local tickCount = 0 + +function onEnable(mc) + lastRaining = mc:isRaining() + lastThundering = mc:isThundering() + tickCount = 0 + + -- Announce current weather + local weather = getWeatherName(lastRaining, lastThundering) + mc:broadcast("§e☁ Weather Announcer enabled! §7Current weather: " .. weather) + mc:log("Weather Announcer enabled - current weather: " .. weather) +end + +function getWeatherName(raining, thundering) + if thundering then + return "§5⚡ Thunderstorm" + elseif raining then + return "§9🌧 Rain" + else + return "§e☀ Clear" + end +end + +function onTick(mc) + tickCount = tickCount + 1 + + if tickCount % CHECK_INTERVAL ~= 0 then + return + end + + local currentRaining = mc:isRaining() + local currentThundering = mc:isThundering() + + -- Check for weather changes + if currentRaining ~= lastRaining or currentThundering ~= lastThundering then + local oldWeather = getWeatherName(lastRaining, lastThundering) + local newWeather = getWeatherName(currentRaining, currentThundering) + + mc:broadcast("§6§l[Weather] §r" .. oldWeather .. " §7→ " .. newWeather) + mc:log("Weather changed: " .. oldWeather .. " -> " .. newWeather) + + -- Play ambient sounds based on weather + if currentThundering and not lastThundering then + -- Could play thunder sound at all player locations + mc:broadcast("§5⚡ A thunderstorm is approaching!") + elseif currentRaining and not lastRaining then + mc:broadcast("§9🌧 It's starting to rain...") + elseif not currentRaining and lastRaining then + mc:broadcast("§e☀ The weather is clearing up!") + end + + lastRaining = currentRaining + lastThundering = currentThundering + end +end + +function onDisable(mc) + mc:broadcast("§7☁ Weather Announcer disabled") + mc:log("Weather Announcer disabled") +end From 853649b0a13a3d0b5beffa004a798e18f989904c Mon Sep 17 00:00:00 2001 From: James Hall Date: Thu, 8 Jan 2026 21:32:04 +0000 Subject: [PATCH 16/37] Filter script command suggestions by available methods - enable/disable/toggle only suggest scripts with onTick method - execute only suggests scripts with onExecute method --- .../lib/command/ServerScriptCommand.java | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java index 2cdbe6c..07d9e60 100644 --- a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java +++ b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java @@ -27,40 +27,54 @@ */ public class ServerScriptCommand { - private static final SuggestionProvider SCRIPT_SUGGESTIONS = (context, builder) -> { + private static final SuggestionProvider TICKABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { return CommandSource.suggestIdentifiers( - ServerScriptManager.getCache().keySet().stream() - .map(id -> Identifier.of(id.getNamespace(), id.getPath().replace("script/", "").replace(".lua", ""))), + ServerScriptManager.getCache().entrySet().stream() + .filter(entry -> entry.getValue().onTick() != null && !entry.getValue().onTick().isnil()) + .map(entry -> Identifier.of(entry.getKey().getNamespace(), entry.getKey().getPath().replace("script/", "").replace(".lua", ""))), builder ); }; - private static final SuggestionProvider ENABLED_SCRIPT_SUGGESTIONS = (context, builder) -> { + private static final SuggestionProvider ENABLED_TICKABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { return CommandSource.suggestIdentifiers( ServerScriptManager.getEnabledScripts().stream() + .filter(id -> { + AmbleScript script = ServerScriptManager.getCache().get(id); + return script != null && script.onTick() != null && !script.onTick().isnil(); + }) .map(id -> Identifier.of(id.getNamespace(), id.getPath().replace("script/", "").replace(".lua", ""))), builder ); }; + private static final SuggestionProvider EXECUTABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { + return CommandSource.suggestIdentifiers( + ServerScriptManager.getCache().entrySet().stream() + .filter(entry -> entry.getValue().onExecute() != null && !entry.getValue().onExecute().isnil()) + .map(entry -> Identifier.of(entry.getKey().getNamespace(), entry.getKey().getPath().replace("script/", "").replace(".lua", ""))), + builder + ); + }; + public static void register(CommandDispatcher dispatcher) { dispatcher.register(literal("serverscript") .requires(source -> source.hasPermissionLevel(2)) // Require operator permissions .then(literal("execute") .then(argument("id", IdentifierArgumentType.identifier()) - .suggests(SCRIPT_SUGGESTIONS) + .suggests(EXECUTABLE_SCRIPT_SUGGESTIONS) .executes(ServerScriptCommand::execute))) .then(literal("enable") .then(argument("id", IdentifierArgumentType.identifier()) - .suggests(SCRIPT_SUGGESTIONS) + .suggests(TICKABLE_SCRIPT_SUGGESTIONS) .executes(ServerScriptCommand::enable))) .then(literal("disable") .then(argument("id", IdentifierArgumentType.identifier()) - .suggests(ENABLED_SCRIPT_SUGGESTIONS) + .suggests(ENABLED_TICKABLE_SCRIPT_SUGGESTIONS) .executes(ServerScriptCommand::disable))) .then(literal("toggle") .then(argument("id", IdentifierArgumentType.identifier()) - .suggests(SCRIPT_SUGGESTIONS) + .suggests(TICKABLE_SCRIPT_SUGGESTIONS) .executes(ServerScriptCommand::toggle))) .then(literal("list") .executes(ServerScriptCommand::listEnabled)) From e3e1532e1739ac956de35d8a639141044ca6b1d1 Mon Sep 17 00:00:00 2001 From: James Hall Date: Fri, 9 Jan 2026 00:05:43 +0000 Subject: [PATCH 17/37] Address PR review feedback for AmbleScript and GUI system Key improvements: - Extract AbstractScriptManager for DRY between client/server scripts - Add type-specific coercion methods to LuaBinder for clarity - Add toString methods for Vector3f, Vec3d, BlockPos in Lua - Add structured NBT access via nbt() method, deprecate nbtString() - Use IntIntImmutablePair from fastutil in AmbleElement - Add text caching in AmbleText to avoid recalculation - Add @ApiStatus.Internal and javadoc to LuaElement.unwrap() - Add init() method with javadoc to AmbleGuiRegistry - Pass resourceId to parse() for better error context - Add validation for array sizes and identifier parsing - Add FALLBACK_LAYOUT constant in AmbleContainer - Add script name logging via setScriptName/getLogPrefix - Rename ExecuteScriptCommand to ClientScriptCommand - Use Command.SINGLE_SUCCESS consistently --- .../dev/amble/lib/client/AmbleKitClient.java | 8 +- .../command/ClientScriptCommand.java} | 117 ++++--- .../dev/amble/lib/client/gui/AmbleButton.java | 6 +- .../amble/lib/client/gui/AmbleContainer.java | 19 +- .../lib/client/gui/AmbleDisplayType.java | 4 +- .../amble/lib/client/gui/AmbleElement.java | 25 +- .../dev/amble/lib/client/gui/AmbleText.java | 57 +++- .../amble/lib/client/gui/lua/LuaElement.java | 9 +- .../client/gui/registry/AmbleGuiRegistry.java | 74 +++-- .../lib/command/ServerScriptCommand.java | 98 +++--- .../lib/script/AbstractScriptManager.java | 199 ++++++++++++ .../{AmbleScript.java => LuaScript.java} | 6 +- .../dev/amble/lib/script/ScriptManager.java | 187 ++---------- .../amble/lib/script/ServerScriptManager.java | 174 +---------- .../dev/amble/lib/script/lua/LuaBinder.java | 286 ++++++++++++++---- .../amble/lib/script/lua/LuaItemStack.java | 24 ++ .../amble/lib/script/lua/MinecraftData.java | 24 +- .../litmus/commands/TestScreenCommand.java | 14 +- 18 files changed, 801 insertions(+), 530 deletions(-) rename src/main/java/dev/amble/lib/{command/ExecuteScriptCommand.java => client/command/ClientScriptCommand.java} (50%) create mode 100644 src/main/java/dev/amble/lib/script/AbstractScriptManager.java rename src/main/java/dev/amble/lib/script/{AmbleScript.java => LuaScript.java} (60%) diff --git a/src/main/java/dev/amble/lib/client/AmbleKitClient.java b/src/main/java/dev/amble/lib/client/AmbleKitClient.java index 6cbc181..b32c964 100644 --- a/src/main/java/dev/amble/lib/client/AmbleKitClient.java +++ b/src/main/java/dev/amble/lib/client/AmbleKitClient.java @@ -4,7 +4,7 @@ import dev.amble.lib.client.bedrock.BedrockModel; import dev.amble.lib.client.bedrock.BedrockModelRegistry; import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; -import dev.amble.lib.command.ExecuteScriptCommand; +import dev.amble.lib.client.command.ClientScriptCommand; import dev.amble.lib.register.AmbleRegistries; import dev.amble.lib.script.ScriptManager; import dev.amble.lib.skin.client.SkinGrabber; @@ -33,15 +33,15 @@ public void onInitializeClient() { AmbleGuiRegistry.getInstance() ); - ScriptManager.getInstance(); + AmbleGuiRegistry.init(); ClientCommandRegistrationCallback.EVENT.register((dispatcher, access) -> { - ExecuteScriptCommand.register(dispatcher); + ClientScriptCommand.register(dispatcher); }); ClientTickEvents.END_CLIENT_TICK.register((client) -> { SkinGrabber.INSTANCE.tick(); - ScriptManager.tick(); + ScriptManager.getInstance().tick(); }); } diff --git a/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java similarity index 50% rename from src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java rename to src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java index 860e560..7ba9587 100644 --- a/src/main/java/dev/amble/lib/command/ExecuteScriptCommand.java +++ b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java @@ -1,16 +1,17 @@ -package dev.amble.lib.command; +package dev.amble.lib.client.command; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.SuggestionProvider; import dev.amble.lib.AmbleKit; -import dev.amble.lib.script.AmbleScript; +import dev.amble.lib.script.LuaScript; import dev.amble.lib.script.ScriptManager; import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; import net.minecraft.client.MinecraftClient; import net.minecraft.command.CommandSource; import net.minecraft.command.argument.IdentifierArgumentType; import net.minecraft.text.Text; +import net.minecraft.util.Formatting; import net.minecraft.util.Identifier; import org.luaj.vm2.LuaValue; @@ -19,20 +20,42 @@ import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument; import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; -public class ExecuteScriptCommand { +/** + * Client-side command for managing client scripts. + * Usage: /amblescript [execute|enable|disable|toggle|list|available] [script_id] + */ +public class ClientScriptCommand { + + private static final String SCRIPT_PREFIX = "script/"; + private static final String SCRIPT_SUFFIX = ".lua"; + + /** + * Converts a full script identifier to a display-friendly format. + * Removes the "script/" prefix and ".lua" suffix. + */ + private static String getDisplayId(Identifier id) { + return id.getPath().replace(SCRIPT_PREFIX, "").replace(SCRIPT_SUFFIX, ""); + } + + /** + * Converts a user-provided script ID to the full internal identifier. + */ + private static Identifier toFullScriptId(Identifier scriptId) { + return scriptId.withPrefixedPath(SCRIPT_PREFIX).withSuffixedPath(SCRIPT_SUFFIX); + } private static final SuggestionProvider SCRIPT_SUGGESTIONS = (context, builder) -> { return CommandSource.suggestIdentifiers( - ScriptManager.getCache().keySet().stream() - .map(id -> Identifier.of(id.getNamespace(), id.getPath().replace("script/", "").replace(".lua", ""))), + ScriptManager.getInstance().getCache().keySet().stream() + .map(id -> Identifier.of(id.getNamespace(), getDisplayId(id))), builder ); }; private static final SuggestionProvider ENABLED_SCRIPT_SUGGESTIONS = (context, builder) -> { return CommandSource.suggestIdentifiers( - ScriptManager.getEnabledScripts().stream() - .map(id -> Identifier.of(id.getNamespace(), id.getPath().replace("script/", "").replace(".lua", ""))), + ScriptManager.getInstance().getEnabledScripts().stream() + .map(id -> Identifier.of(id.getNamespace(), getDisplayId(id))), builder ); }; @@ -42,31 +65,31 @@ public static void register(CommandDispatcher dispatc .then(literal("execute") .then(argument("id", IdentifierArgumentType.identifier()) .suggests(SCRIPT_SUGGESTIONS) - .executes(ExecuteScriptCommand::execute))) + .executes(ClientScriptCommand::execute))) .then(literal("enable") .then(argument("id", IdentifierArgumentType.identifier()) .suggests(SCRIPT_SUGGESTIONS) - .executes(ExecuteScriptCommand::enable))) + .executes(ClientScriptCommand::enable))) .then(literal("disable") .then(argument("id", IdentifierArgumentType.identifier()) .suggests(ENABLED_SCRIPT_SUGGESTIONS) - .executes(ExecuteScriptCommand::disable))) + .executes(ClientScriptCommand::disable))) .then(literal("toggle") .then(argument("id", IdentifierArgumentType.identifier()) .suggests(SCRIPT_SUGGESTIONS) - .executes(ExecuteScriptCommand::toggle))) + .executes(ClientScriptCommand::toggle))) .then(literal("list") - .executes(ExecuteScriptCommand::listEnabled)) + .executes(ClientScriptCommand::listEnabled)) .then(literal("available") - .executes(ExecuteScriptCommand::listAvailable))); + .executes(ClientScriptCommand::listAvailable))); } private static int execute(CommandContext context) { Identifier scriptId = context.getArgument("id", Identifier.class); - Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + Identifier fullScriptId = toFullScriptId(scriptId); try { - AmbleScript script = ScriptManager.load( + LuaScript script = ScriptManager.getInstance().load( fullScriptId, MinecraftClient.getInstance().getResourceManager() ); @@ -76,7 +99,7 @@ private static int execute(CommandContext context) { return 0; } - LuaValue data = ScriptManager.getScriptData(fullScriptId); + LuaValue data = ScriptManager.getInstance().getScriptData(fullScriptId); script.onExecute().call(data); context.getSource().sendFeedback(Text.literal("Executed script: " + scriptId)); return 1; @@ -89,23 +112,23 @@ private static int execute(CommandContext context) { private static int enable(CommandContext context) { Identifier scriptId = context.getArgument("id", Identifier.class); - Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + Identifier fullScriptId = toFullScriptId(scriptId); // Ensure script is loaded try { - ScriptManager.load(fullScriptId, MinecraftClient.getInstance().getResourceManager()); + ScriptManager.getInstance().load(fullScriptId, MinecraftClient.getInstance().getResourceManager()); } catch (Exception e) { context.getSource().sendError(Text.literal("Script '" + scriptId + "' not found")); return 0; } - if (ScriptManager.isEnabled(fullScriptId)) { + if (ScriptManager.getInstance().isEnabled(fullScriptId)) { context.getSource().sendError(Text.literal("Script '" + scriptId + "' is already enabled")); return 0; } - if (ScriptManager.enable(fullScriptId)) { - context.getSource().sendFeedback(Text.literal("§aEnabled script: " + scriptId)); + if (ScriptManager.getInstance().enable(fullScriptId)) { + context.getSource().sendFeedback(Text.literal("Enabled script: " + scriptId).formatted(Formatting.GREEN)); return 1; } else { context.getSource().sendError(Text.literal("Failed to enable script '" + scriptId + "'")); @@ -115,15 +138,15 @@ private static int enable(CommandContext context) { private static int disable(CommandContext context) { Identifier scriptId = context.getArgument("id", Identifier.class); - Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + Identifier fullScriptId = toFullScriptId(scriptId); - if (!ScriptManager.isEnabled(fullScriptId)) { + if (!ScriptManager.getInstance().isEnabled(fullScriptId)) { context.getSource().sendError(Text.literal("Script '" + scriptId + "' is not enabled")); return 0; } - if (ScriptManager.disable(fullScriptId)) { - context.getSource().sendFeedback(Text.literal("§cDisabled script: " + scriptId)); + if (ScriptManager.getInstance().disable(fullScriptId)) { + context.getSource().sendFeedback(Text.literal("Disabled script: " + scriptId).formatted(Formatting.RED)); return 1; } else { context.getSource().sendError(Text.literal("Failed to disable script '" + scriptId + "'")); @@ -133,59 +156,65 @@ private static int disable(CommandContext context) { private static int toggle(CommandContext context) { Identifier scriptId = context.getArgument("id", Identifier.class); - Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + Identifier fullScriptId = toFullScriptId(scriptId); // Ensure script is loaded try { - ScriptManager.load(fullScriptId, MinecraftClient.getInstance().getResourceManager()); + ScriptManager.getInstance().load(fullScriptId, MinecraftClient.getInstance().getResourceManager()); } catch (Exception e) { context.getSource().sendError(Text.literal("Script '" + scriptId + "' not found")); return 0; } - boolean wasEnabled = ScriptManager.isEnabled(fullScriptId); - ScriptManager.toggle(fullScriptId); + boolean wasEnabled = ScriptManager.getInstance().isEnabled(fullScriptId); + ScriptManager.getInstance().toggle(fullScriptId); if (wasEnabled) { - context.getSource().sendFeedback(Text.literal("§cDisabled script: " + scriptId)); + context.getSource().sendFeedback(Text.literal("Disabled script: " + scriptId).formatted(Formatting.RED)); } else { - context.getSource().sendFeedback(Text.literal("§aEnabled script: " + scriptId)); + context.getSource().sendFeedback(Text.literal("Enabled script: " + scriptId).formatted(Formatting.GREEN)); } return 1; } private static int listEnabled(CommandContext context) { - Set enabled = ScriptManager.getEnabledScripts(); + Set enabled = ScriptManager.getInstance().getEnabledScripts(); if (enabled.isEmpty()) { - context.getSource().sendFeedback(Text.literal("§7No client scripts are currently enabled")); + context.getSource().sendFeedback(Text.literal("No client scripts are currently enabled").formatted(Formatting.GRAY)); return 1; } - context.getSource().sendFeedback(Text.literal("§6§l━━━ Enabled Client Scripts (" + enabled.size() + ") ━━━")); + context.getSource().sendFeedback(Text.literal("━━━ Enabled Client Scripts (" + enabled.size() + ") ━━━").formatted(Formatting.GOLD, Formatting.BOLD)); for (Identifier id : enabled) { - String displayId = id.getPath().replace("script/", "").replace(".lua", ""); - context.getSource().sendFeedback(Text.literal("§a✓ §f" + id.getNamespace() + ":" + displayId)); + String displayId = getDisplayId(id); + context.getSource().sendFeedback( + Text.literal("✓ ").formatted(Formatting.GREEN) + .append(Text.literal(id.getNamespace() + ":" + displayId).formatted(Formatting.WHITE)) + ); } return 1; } private static int listAvailable(CommandContext context) { - Set available = ScriptManager.getCache().keySet(); - Set enabled = ScriptManager.getEnabledScripts(); + Set available = ScriptManager.getInstance().getCache().keySet(); + Set enabled = ScriptManager.getInstance().getEnabledScripts(); if (available.isEmpty()) { - context.getSource().sendFeedback(Text.literal("§7No client scripts available")); + context.getSource().sendFeedback(Text.literal("No client scripts available").formatted(Formatting.GRAY)); return 1; } - context.getSource().sendFeedback(Text.literal("§6§l━━━ Available Client Scripts (" + available.size() + ") ━━━")); + context.getSource().sendFeedback(Text.literal("━━━ Available Client Scripts (" + available.size() + ") ━━━").formatted(Formatting.GOLD, Formatting.BOLD)); for (Identifier id : available) { - String displayId = id.getPath().replace("script/", "").replace(".lua", ""); - String status = enabled.contains(id) ? "§a✓" : "§7○"; - context.getSource().sendFeedback(Text.literal(status + " §f" + id.getNamespace() + ":" + displayId)); + String displayId = getDisplayId(id); + Text statusIcon = enabled.contains(id) + ? Text.literal("✓ ").formatted(Formatting.GREEN) + : Text.literal("○ ").formatted(Formatting.GRAY); + context.getSource().sendFeedback( + statusIcon.copy().append(Text.literal(id.getNamespace() + ":" + displayId).formatted(Formatting.WHITE)) + ); } return 1; } - } diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java index 5d262e3..c77b29a 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java @@ -4,7 +4,7 @@ import dev.amble.lib.AmbleKit; import dev.amble.lib.script.lua.LuaBinder; import dev.amble.lib.client.gui.lua.LuaElement; -import dev.amble.lib.script.AmbleScript; +import dev.amble.lib.script.LuaScript; import lombok.*; import net.minecraft.client.gui.DrawContext; import org.jetbrains.annotations.Nullable; @@ -24,7 +24,7 @@ public class AmbleButton extends AmbleContainer { private @Nullable Runnable onClick; private @Nullable AmbleDisplayType normalDisplay = null; private boolean isClicked = false; - private @Nullable AmbleScript script; + private @Nullable LuaScript script; @Override public void onRelease(double mouseX, double mouseY, int button) { @@ -115,7 +115,7 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { return normalDisplay; } - public void setScript(AmbleScript script) { + public void setScript(LuaScript script) { this.script = script; if (script.onInit() != null && !script.onInit().isnil()) { try { diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java index d3f367f..12b3231 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java @@ -2,6 +2,7 @@ import dev.amble.lib.AmbleKit; import lombok.*; +import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.screen.Screen; import net.minecraft.text.Text; @@ -68,11 +69,9 @@ public boolean requiresNewRow() { public Identifier id() { if (identifier == null) { if (parent != null) { - identifier = new Identifier(parent.id().getNamespace(), - parent.id().getPath() + "/" + System.identityHashCode(this)); + identifier = parent.id().withPath(parent.id().getPath() + "/" + System.identityHashCode(this)); } else { - identifier = new Identifier("amble", - "container/" + System.identityHashCode(this)); + identifier = AmbleKit.id("container/" + System.identityHashCode(this)); AmbleKit.LOGGER.error("GUI element missing identifier, no parent found to derive from. Generated id: {}", identifier); } } @@ -98,10 +97,12 @@ public Rectangle getPreferredLayout() { return preferredLayout; } + private static final Rectangle FALLBACK_LAYOUT = new Rectangle(0, 0, 100, 100); + protected Rectangle fallbackLayout() { AmbleKit.LOGGER.error("GUI element {} is missing layout data, using fallback layout", id()); - return new Rectangle(0, 0, 100, 100); + return new Rectangle(FALLBACK_LAYOUT); } public Screen toScreen() { @@ -116,11 +117,11 @@ protected Screen createScreen() { } public void display() { - var primary = AmbleContainer.primaryContainer(); + AmbleContainer primary = AmbleContainer.primaryContainer(); primary.addChild(this); primary.setShouldPause(shouldPause); Screen screen = primary.toScreen(); - net.minecraft.client.MinecraftClient.getInstance().setScreen(screen); + MinecraftClient.getInstance().setScreen(screen); } public void copyFrom(AmbleContainer other) { @@ -146,8 +147,8 @@ public static Builder builder() { public static AmbleContainer primaryContainer() { return AmbleContainer.builder() .layout(new Rectangle(0, 0, - net.minecraft.client.MinecraftClient.getInstance().getWindow().getScaledWidth(), - net.minecraft.client.MinecraftClient.getInstance().getWindow().getScaledHeight())) + MinecraftClient.getInstance().getWindow().getScaledWidth(), + MinecraftClient.getInstance().getWindow().getScaledHeight())) .background(new Color(0, 0, 0, 0)) .build(); } diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java b/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java index 4e32fdf..1ba68c7 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleDisplayType.java @@ -3,12 +3,12 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import dev.amble.lib.api.Identifiable; import net.minecraft.client.gui.DrawContext; import net.minecraft.util.Identifier; import org.jetbrains.annotations.Nullable; -import java.awt.*; +import java.awt.Color; +import java.awt.Rectangle; public record AmbleDisplayType(@Nullable Color color, @Nullable TextureData texture) { public AmbleDisplayType { diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleElement.java b/src/main/java/dev/amble/lib/client/gui/AmbleElement.java index 8b9d768..551cc86 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleElement.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleElement.java @@ -1,10 +1,11 @@ package dev.amble.lib.client.gui; import dev.amble.lib.api.Identifiable; +import it.unimi.dsi.fastutil.ints.IntIntImmutablePair; +import it.unimi.dsi.fastutil.ints.IntIntPair; import net.minecraft.client.gui.DrawContext; import net.minecraft.client.gui.Drawable; import net.minecraft.util.Identifier; -import net.minecraft.util.Pair; import net.minecraft.util.math.Vec2f; import org.jetbrains.annotations.Nullable; @@ -81,14 +82,14 @@ default void addChild(AmbleElement child) { } } - private Pair layoutRow( + private IntIntPair layoutRow( List row, int startX, int maxWidth, int cursorY, int rowHeight ) { - if (row.isEmpty()) return new Pair<>(cursorY, rowHeight); + if (row.isEmpty()) return IntIntImmutablePair.of(cursorY, rowHeight); int rowWidth = row.stream() .mapToInt(e -> e.getPreferredLayout().width) @@ -108,7 +109,7 @@ private Pair layoutRow( int innerHeight = getLayout().height - getPadding() * 2; - for (var e : row) { + for (AmbleElement e : row) { int y; if (singleElementFullCenter) { @@ -136,7 +137,7 @@ else if (e.getVerticalAlign() == UIAlign.END) cursorY += rowHeight + getSpacing(); row.clear(); rowHeight = 0; - return new Pair<>(cursorY, rowHeight); + return IntIntImmutablePair.of(cursorY, rowHeight); } default void recalcuateLayout() { @@ -150,28 +151,24 @@ default void recalcuateLayout() { List row = new ArrayList<>(); - for (var child : getChildren()) - { + for (AmbleElement child : getChildren()) { int w = child.getPreferredLayout().width; int h = child.getPreferredLayout().height; - if (cursorX + w > startX + maxWidth || child.requiresNewRow()) - { - Pair result = layoutRow( + if (cursorX + w > startX + maxWidth || child.requiresNewRow()) { + IntIntPair result = layoutRow( row, startX, maxWidth, cursorY, rowHeight ); - cursorY = result.getLeft(); - rowHeight = result.getRight(); + cursorY = result.leftInt(); + rowHeight = result.rightInt(); cursorX = startX; } row.add(child); - - //child.LayerDepth = LayerDepth + 0.025F; child.recalcuateLayout(); cursorX += w + getSpacing(); diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleText.java b/src/main/java/dev/amble/lib/client/gui/AmbleText.java index f49063b..26fde60 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleText.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleText.java @@ -16,21 +16,69 @@ @Getter @AllArgsConstructor @NoArgsConstructor -@Setter public class AmbleText extends AmbleContainer { + @Setter private Text text; + @Setter private UIAlign textHorizontalAlign = UIAlign.CENTRE; + @Setter private UIAlign textVerticalAlign = UIAlign.CENTRE; + @Setter private boolean shadow = true; + // Cached wrapped lines to avoid recalculating every frame + private transient List cachedLines; + private transient int cachedWidth = -1; + private transient Text cachedText; + + /** + * Sets the text and invalidates the cache. + */ + public void setText(Text text) { + if (this.text != text) { + this.text = text; + invalidateTextCache(); + } + } + + /** + * Invalidates the cached wrapped lines, forcing recalculation on next render. + */ + public void invalidateTextCache() { + cachedLines = null; + cachedWidth = -1; + cachedText = null; + } + + @Override + public void recalcuateLayout() { + super.recalcuateLayout(); + invalidateTextCache(); + } + + private List getWrappedLines(TextRenderer tr) { + int currentWidth = getLayout().width - getPadding() * 2; + + // Recalculate if width changed or text changed + if (cachedLines == null || cachedWidth != currentWidth || cachedText != text) { + cachedLines = tr.wrapLines(text, currentWidth); + cachedWidth = currentWidth; + cachedText = text; + } + + return cachedLines; + } + @Override public void render(DrawContext context, int mouseX, int mouseY, float delta) { super.render(context, mouseX, mouseY, delta); + if (text == null) return; + TextRenderer tr = MinecraftClient.getInstance().textRenderer; - // 1. Wrap text first - List lines = tr.wrapLines(text, getLayout().width - getPadding() * 2); + // Use cached wrapped lines + List lines = getWrappedLines(tr); int lineHeight = tr.fontHeight; int wrappedHeight = lines.size() * lineHeight; @@ -40,7 +88,7 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { wrappedWidth = Math.max(wrappedWidth, tr.getWidth(line)); } - // 2. Calculate aligned position using WRAPPED size + // Calculate aligned position using WRAPPED size int textX = getLayout().x; int textY = getLayout().y; @@ -56,7 +104,6 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { case END -> textY += getLayout().height - wrappedHeight - getPadding(); } - // 3. Draw drawWrappedLines(context, tr, lines, textX, textY, 0xFFFFFF, shadow); } diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java index 6c4399a..79f9d68 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java +++ b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java @@ -6,6 +6,7 @@ import net.minecraft.client.MinecraftClient; import net.minecraft.text.Text; import net.minecraft.util.math.Vec2f; +import org.jetbrains.annotations.ApiStatus; public final class LuaElement { @@ -99,7 +100,13 @@ public ClientMinecraftData minecraft() { return minecraftData; } - // INTERNAL — NOT EXPOSED + /** + * Returns the underlying AmbleElement wrapped by this LuaElement. + * This method is for internal use only and should not be called from Lua scripts. + * + * @return the wrapped AmbleElement + */ + @ApiStatus.Internal AmbleElement unwrap() { return element; } diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index 98bf822..c73618c 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -5,9 +5,8 @@ import com.google.gson.JsonParser; import dev.amble.lib.AmbleKit; import dev.amble.lib.client.gui.*; -import dev.amble.lib.script.AmbleScript; +import dev.amble.lib.script.LuaScript; import dev.amble.lib.script.ScriptManager; -import dev.amble.lib.script.lua.LuaBinder; import dev.amble.lib.register.datapack.DatapackRegistry; import net.fabricmc.fabric.api.resource.ResourceManagerHelper; import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; @@ -32,7 +31,14 @@ public class AmbleGuiRegistry extends DatapackRegistry implement private AmbleGuiRegistry() { ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES).registerReloadListener(this); + } + /** + * Initializes the GUI registry and related systems. + * Should be called during client initialization. + */ + public static void init() { + getInstance(); ScriptManager.getInstance(); } @@ -46,21 +52,45 @@ public Identifier getFabricId() { return AmbleKit.id("gui"); } + /** + * Parses a JSON object into an AmbleContainer. + * + * @param json the JSON object to parse + * @return the parsed AmbleContainer + * @throws IllegalStateException if required fields are missing or invalid + */ public static AmbleContainer parse(JsonObject json) { + return parse(json, null); + } + + /** + * Parses a JSON object into an AmbleContainer. + * + * @param json the JSON object to parse + * @param resourceId the identifier of the resource being parsed (for error context), may be null + * @return the parsed AmbleContainer + * @throws IllegalStateException if required fields are missing or invalid + */ + public static AmbleContainer parse(JsonObject json, Identifier resourceId) { + String context = resourceId != null ? " (resource: " + resourceId + ")" : ""; + // first parse background AmbleDisplayType background; if (json.has("background")) { background = AmbleDisplayType.parse(json.get("background")); } else { - throw new IllegalStateException("Amble container is missing background data | " + json); + throw new IllegalStateException("Amble container is missing background data" + context); } Rectangle layout = new Rectangle(); if (json.has("layout") && json.get("layout").isJsonArray()) { JsonArray layoutArray = json.get("layout").getAsJsonArray(); + if (layoutArray.size() < 2) { + throw new IllegalStateException("Amble container layout must have at least 2 elements (width, height)" + context); + } layout.setSize(layoutArray.get(0).getAsInt(), layoutArray.get(1).getAsInt()); } else { - throw new IllegalStateException("Amble container is missing layout data | " + json); + throw new IllegalStateException("Amble container is missing layout data" + context); } int padding = 0; @@ -77,10 +107,13 @@ public static AmbleContainer parse(JsonObject json) { UIAlign vertAlign = UIAlign.START; if (json.has("alignment")) { if (!json.get("alignment").isJsonArray()) { - throw new IllegalStateException("UI Alignment must be array [horizontal, vertical] | " + json); + throw new IllegalStateException("UI Alignment must be array [horizontal, vertical]" + context); } JsonArray alignmentArray = json.get("alignment").getAsJsonArray(); + if (alignmentArray.size() < 2) { + throw new IllegalStateException("UI Alignment array must have at least 2 elements" + context); + } String horizAlignKey = alignmentArray.get(0).getAsString(); String vertAlignKey = alignmentArray.get(1).getAsString(); @@ -100,7 +133,7 @@ public static AmbleContainer parse(JsonObject json) { boolean shouldPause = false; if (json.has("should_pause")) { if (!json.get("should_pause").isJsonPrimitive()) { - throw new IllegalStateException("UI should_pause should be boolean | " + json); + throw new IllegalStateException("UI should_pause should be boolean" + context); } shouldPause = json.get("should_pause").getAsBoolean(); @@ -109,24 +142,24 @@ public static AmbleContainer parse(JsonObject json) { List children = new ArrayList<>(); if (json.has("children")) { if (!json.get("children").isJsonArray()) { - throw new IllegalStateException("UI children should be an object array of other ui elements | " + json); + throw new IllegalStateException("UI children should be an object array of other ui elements" + context); } JsonArray childrenArray = json.get("children").getAsJsonArray(); for (int i = 0; i < childrenArray.size(); i++) { if (!(childrenArray.get(i).isJsonObject())) { - throw new IllegalStateException("UI child at index " + i + " is invalid, got " + childrenArray.get(i) + " | " + json); + throw new IllegalStateException("UI child at index " + i + " is invalid, got " + childrenArray.get(i) + context); } - children.add(parse(childrenArray.get(i).getAsJsonObject())); + children.add(parse(childrenArray.get(i).getAsJsonObject(), resourceId)); } } boolean requiresNewRow = false; if (json.has("requires_new_row")) { if (!json.get("requires_new_row").isJsonPrimitive()) { - throw new IllegalStateException("UI requires_new_row should be boolean | " + json); + throw new IllegalStateException("UI requires_new_row should be boolean" + context); } requiresNewRow = json.get("requires_new_row").getAsBoolean(); } @@ -135,7 +168,11 @@ public static AmbleContainer parse(JsonObject json) { if (json.has("id")) { String idStr = json.get("id").getAsString(); - created.setIdentifier(new Identifier(idStr)); + Identifier parsedId = Identifier.tryParse(idStr); + if (parsedId == null) { + throw new IllegalStateException("Invalid identifier '" + idStr + "'" + context); + } + created.setIdentifier(parsedId); } if (json.has("text")) { @@ -148,10 +185,13 @@ public static AmbleContainer parse(JsonObject json) { UIAlign textVertAlign = UIAlign.CENTRE; if (json.has("text_alignment")) { if (!json.get("text_alignment").isJsonArray()) { - throw new IllegalStateException("UI text Alignment must be array [horizontal, vertical] | " + json); + throw new IllegalStateException("UI text Alignment must be array [horizontal, vertical]" + context); } JsonArray alignmentArray = json.get("text_alignment").getAsJsonArray(); + if (alignmentArray.size() < 2) { + throw new IllegalStateException("UI text Alignment array must have at least 2 elements" + context); + } String horizAlignKey = alignmentArray.get(0).getAsString(); String vertAlignKey = alignmentArray.get(1).getAsString(); @@ -193,10 +233,10 @@ public static AmbleContainer parse(JsonObject json) { if (json.has("script")) { Identifier scriptId = new Identifier(json.get("script").getAsString()).withPrefixedPath("script/").withSuffixedPath(".lua"); - AmbleScript script = ScriptManager.load( - scriptId, - MinecraftClient.getInstance().getResourceManager() - ); + LuaScript script = ScriptManager.getInstance().load( + scriptId, + MinecraftClient.getInstance().getResourceManager() + ); button.setScript(script); } @@ -231,7 +271,7 @@ public void reload(ResourceManager manager) { Identifier id = Identifier.of(rawId.getNamespace(), idPath); JsonObject json = JsonParser.parseReader(new InputStreamReader(stream)).getAsJsonObject(); - AmbleContainer model = parse(json); + AmbleContainer model = parse(json, id); model.setIdentifier(id); register(model); diff --git a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java index 07d9e60..4f96031 100644 --- a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java +++ b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java @@ -4,7 +4,7 @@ import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.SuggestionProvider; import dev.amble.lib.AmbleKit; -import dev.amble.lib.script.AmbleScript; +import dev.amble.lib.script.LuaScript; import dev.amble.lib.script.ServerScriptManager; import dev.amble.lib.script.lua.LuaBinder; import dev.amble.lib.script.lua.ServerMinecraftData; @@ -14,6 +14,7 @@ import net.minecraft.server.command.ServerCommandSource; import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.text.Text; +import net.minecraft.util.Formatting; import net.minecraft.util.Identifier; import java.util.Set; @@ -27,32 +28,50 @@ */ public class ServerScriptCommand { + private static final String SCRIPT_PREFIX = "script/"; + private static final String SCRIPT_SUFFIX = ".lua"; + + /** + * Converts a full script identifier to a display-friendly format. + * Removes the "script/" prefix and ".lua" suffix. + */ + private static String getDisplayId(Identifier id) { + return id.getPath().replace(SCRIPT_PREFIX, "").replace(SCRIPT_SUFFIX, ""); + } + + /** + * Converts a user-provided script ID to the full internal identifier. + */ + private static Identifier toFullScriptId(Identifier scriptId) { + return scriptId.withPrefixedPath(SCRIPT_PREFIX).withSuffixedPath(SCRIPT_SUFFIX); + } + private static final SuggestionProvider TICKABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { return CommandSource.suggestIdentifiers( - ServerScriptManager.getCache().entrySet().stream() + ServerScriptManager.getInstance().getCache().entrySet().stream() .filter(entry -> entry.getValue().onTick() != null && !entry.getValue().onTick().isnil()) - .map(entry -> Identifier.of(entry.getKey().getNamespace(), entry.getKey().getPath().replace("script/", "").replace(".lua", ""))), + .map(entry -> Identifier.of(entry.getKey().getNamespace(), getDisplayId(entry.getKey()))), builder ); }; private static final SuggestionProvider ENABLED_TICKABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { return CommandSource.suggestIdentifiers( - ServerScriptManager.getEnabledScripts().stream() + ServerScriptManager.getInstance().getEnabledScripts().stream() .filter(id -> { - AmbleScript script = ServerScriptManager.getCache().get(id); + LuaScript script = ServerScriptManager.getInstance().getCache().get(id); return script != null && script.onTick() != null && !script.onTick().isnil(); }) - .map(id -> Identifier.of(id.getNamespace(), id.getPath().replace("script/", "").replace(".lua", ""))), + .map(id -> Identifier.of(id.getNamespace(), getDisplayId(id))), builder ); }; private static final SuggestionProvider EXECUTABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { return CommandSource.suggestIdentifiers( - ServerScriptManager.getCache().entrySet().stream() + ServerScriptManager.getInstance().getCache().entrySet().stream() .filter(entry -> entry.getValue().onExecute() != null && !entry.getValue().onExecute().isnil()) - .map(entry -> Identifier.of(entry.getKey().getNamespace(), entry.getKey().getPath().replace("script/", "").replace(".lua", ""))), + .map(entry -> Identifier.of(entry.getKey().getNamespace(), getDisplayId(entry.getKey()))), builder ); }; @@ -84,10 +103,10 @@ public static void register(CommandDispatcher dispatcher) { private static int execute(CommandContext context) { Identifier scriptId = context.getArgument("id", Identifier.class); - Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + Identifier fullScriptId = toFullScriptId(scriptId); try { - AmbleScript script = ServerScriptManager.getCache().get(fullScriptId); + LuaScript script = ServerScriptManager.getInstance().getCache().get(fullScriptId); if (script == null) { context.getSource().sendError(Text.literal("Server script '" + scriptId + "' not found")); @@ -121,20 +140,20 @@ private static int execute(CommandContext context) { private static int enable(CommandContext context) { Identifier scriptId = context.getArgument("id", Identifier.class); - Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + Identifier fullScriptId = toFullScriptId(scriptId); - if (!ServerScriptManager.getCache().containsKey(fullScriptId)) { + if (!ServerScriptManager.getInstance().getCache().containsKey(fullScriptId)) { context.getSource().sendError(Text.literal("Server script '" + scriptId + "' not found")); return 0; } - if (ServerScriptManager.isEnabled(fullScriptId)) { + if (ServerScriptManager.getInstance().isEnabled(fullScriptId)) { context.getSource().sendError(Text.literal("Server script '" + scriptId + "' is already enabled")); return 0; } - if (ServerScriptManager.enable(fullScriptId)) { - context.getSource().sendFeedback(() -> Text.literal("§aEnabled server script: " + scriptId), true); + if (ServerScriptManager.getInstance().enable(fullScriptId)) { + context.getSource().sendFeedback(() -> Text.literal("Enabled server script: " + scriptId).formatted(Formatting.GREEN), true); return 1; } else { context.getSource().sendError(Text.literal("Failed to enable server script '" + scriptId + "'")); @@ -144,15 +163,15 @@ private static int enable(CommandContext context) { private static int disable(CommandContext context) { Identifier scriptId = context.getArgument("id", Identifier.class); - Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + Identifier fullScriptId = toFullScriptId(scriptId); - if (!ServerScriptManager.isEnabled(fullScriptId)) { + if (!ServerScriptManager.getInstance().isEnabled(fullScriptId)) { context.getSource().sendError(Text.literal("Server script '" + scriptId + "' is not enabled")); return 0; } - if (ServerScriptManager.disable(fullScriptId)) { - context.getSource().sendFeedback(() -> Text.literal("§cDisabled server script: " + scriptId), true); + if (ServerScriptManager.getInstance().disable(fullScriptId)) { + context.getSource().sendFeedback(() -> Text.literal("Disabled server script: " + scriptId).formatted(Formatting.RED), true); return 1; } else { context.getSource().sendError(Text.literal("Failed to disable server script '" + scriptId + "'")); @@ -162,54 +181,59 @@ private static int disable(CommandContext context) { private static int toggle(CommandContext context) { Identifier scriptId = context.getArgument("id", Identifier.class); - Identifier fullScriptId = scriptId.withPrefixedPath("script/").withSuffixedPath(".lua"); + Identifier fullScriptId = toFullScriptId(scriptId); - if (!ServerScriptManager.getCache().containsKey(fullScriptId)) { + if (!ServerScriptManager.getInstance().getCache().containsKey(fullScriptId)) { context.getSource().sendError(Text.literal("Server script '" + scriptId + "' not found")); return 0; } - boolean wasEnabled = ServerScriptManager.isEnabled(fullScriptId); - ServerScriptManager.toggle(fullScriptId); + boolean wasEnabled = ServerScriptManager.getInstance().isEnabled(fullScriptId); + ServerScriptManager.getInstance().toggle(fullScriptId); if (wasEnabled) { - context.getSource().sendFeedback(() -> Text.literal("§cDisabled server script: " + scriptId), true); + context.getSource().sendFeedback(() -> Text.literal("Disabled server script: " + scriptId).formatted(Formatting.RED), true); } else { - context.getSource().sendFeedback(() -> Text.literal("§aEnabled server script: " + scriptId), true); + context.getSource().sendFeedback(() -> Text.literal("Enabled server script: " + scriptId).formatted(Formatting.GREEN), true); } return 1; } private static int listEnabled(CommandContext context) { - Set enabled = ServerScriptManager.getEnabledScripts(); + Set enabled = ServerScriptManager.getInstance().getEnabledScripts(); if (enabled.isEmpty()) { - context.getSource().sendFeedback(() -> Text.literal("§7No server scripts are currently enabled"), false); + context.getSource().sendFeedback(() -> Text.literal("No server scripts are currently enabled").formatted(Formatting.GRAY), false); return 1; } - context.getSource().sendFeedback(() -> Text.literal("§6§l━━━ Enabled Server Scripts (" + enabled.size() + ") ━━━"), false); + context.getSource().sendFeedback(() -> Text.literal("━━━ Enabled Server Scripts (" + enabled.size() + ") ━━━").formatted(Formatting.GOLD, Formatting.BOLD), false); for (Identifier id : enabled) { - String displayId = id.getPath().replace("script/", "").replace(".lua", ""); - context.getSource().sendFeedback(() -> Text.literal("§a✓ §f" + id.getNamespace() + ":" + displayId), false); + String displayId = getDisplayId(id); + context.getSource().sendFeedback(() -> + Text.literal("✓ ").formatted(Formatting.GREEN) + .append(Text.literal(id.getNamespace() + ":" + displayId).formatted(Formatting.WHITE)), false); } return 1; } private static int listAvailable(CommandContext context) { - Set available = ServerScriptManager.getCache().keySet(); - Set enabled = ServerScriptManager.getEnabledScripts(); + Set available = ServerScriptManager.getInstance().getCache().keySet(); + Set enabled = ServerScriptManager.getInstance().getEnabledScripts(); if (available.isEmpty()) { - context.getSource().sendFeedback(() -> Text.literal("§7No server scripts available"), false); + context.getSource().sendFeedback(() -> Text.literal("No server scripts available").formatted(Formatting.GRAY), false); return 1; } - context.getSource().sendFeedback(() -> Text.literal("§6§l━━━ Available Server Scripts (" + available.size() + ") ━━━"), false); + context.getSource().sendFeedback(() -> Text.literal("━━━ Available Server Scripts (" + available.size() + ") ━━━").formatted(Formatting.GOLD, Formatting.BOLD), false); for (Identifier id : available) { - String displayId = id.getPath().replace("script/", "").replace(".lua", ""); - String status = enabled.contains(id) ? "§a✓" : "§7○"; - context.getSource().sendFeedback(() -> Text.literal(status + " §f" + id.getNamespace() + ":" + displayId), false); + String displayId = getDisplayId(id); + Text statusIcon = enabled.contains(id) + ? Text.literal("✓ ").formatted(Formatting.GREEN) + : Text.literal("○ ").formatted(Formatting.GRAY); + context.getSource().sendFeedback(() -> + statusIcon.copy().append(Text.literal(id.getNamespace() + ":" + displayId).formatted(Formatting.WHITE)), false); } return 1; } diff --git a/src/main/java/dev/amble/lib/script/AbstractScriptManager.java b/src/main/java/dev/amble/lib/script/AbstractScriptManager.java new file mode 100644 index 0000000..b0de082 --- /dev/null +++ b/src/main/java/dev/amble/lib/script/AbstractScriptManager.java @@ -0,0 +1,199 @@ +package dev.amble.lib.script; + +import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.lua.LuaBinder; +import dev.amble.lib.script.lua.MinecraftData; +import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; +import net.minecraft.resource.Resource; +import net.minecraft.resource.ResourceManager; +import net.minecraft.util.Identifier; +import org.luaj.vm2.Globals; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.lib.jse.JsePlatform; + +import java.io.InputStreamReader; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Abstract base class for script managers. + * Provides common functionality for loading, caching, and managing Lua scripts. + */ +public abstract class AbstractScriptManager implements SimpleSynchronousResourceReloadListener { + + protected final Map cache = new HashMap<>(); + protected final Map dataCache = new HashMap<>(); + protected final Set enabledScripts = new HashSet<>(); + + /** + * Creates the MinecraftData instance for a script. + * Subclasses should override to provide client or server-specific data. + */ + protected abstract MinecraftData createMinecraftData(); + + /** + * Gets the log prefix for this script manager (e.g., "script" or "server script"). + */ + protected abstract String getLogPrefix(); + + /** + * Reloads all scripts from the given resource manager. + */ + @Override + public void reload(ResourceManager manager) { + // Disable all scripts before clearing cache + for (Identifier id : new HashSet<>(enabledScripts)) { + disable(id); + } + + cache.clear(); + dataCache.clear(); + + // Discover all script files and populate the cache + manager.findResources("script", id -> id.getPath().endsWith(".lua")) + .keySet() + .forEach(id -> { + try { + load(id, manager); + } catch (Exception e) { + AmbleKit.LOGGER.error("Failed to load {} {}", getLogPrefix(), id, e); + } + }); + + AmbleKit.LOGGER.info("Loaded {} {}s", cache.size(), getLogPrefix()); + } + + /** + * Loads a script from the resource manager. + */ + public LuaScript load(Identifier id, ResourceManager manager) { + return cache.computeIfAbsent(id, key -> { + try { + Resource res = manager.getResource(key).orElseThrow(); + Globals globals = JsePlatform.standardGlobals(); + + // Create and cache the minecraft data for this script + MinecraftData data = createMinecraftData(); + data.setScriptName(key.toString()); + LuaValue boundData = LuaBinder.bind(data); + dataCache.put(key, boundData); + + // Inject minecraft global for scripts to use + globals.set("minecraft", boundData); + + LuaValue chunk = globals.load( + new InputStreamReader(res.getInputStream()), + key.toString() + ); + chunk.call(); + + return new LuaScript( + globals.get("onInit"), + globals.get("onClick"), + globals.get("onRelease"), + globals.get("onHover"), + globals.get("onExecute"), + globals.get("onEnable"), + globals.get("onTick"), + globals.get("onDisable") + ); + } catch (Exception e) { + throw new RuntimeException("Failed to load " + getLogPrefix() + " " + key, e); + } + }); + } + + public Map getCache() { + return cache; + } + + public Set getEnabledScripts() { + return enabledScripts; + } + + public boolean isEnabled(Identifier id) { + return enabledScripts.contains(id); + } + + public boolean enable(Identifier id) { + if (enabledScripts.contains(id)) { + return false; // Already enabled + } + + LuaScript script = cache.get(id); + if (script == null) { + return false; + } + + enabledScripts.add(id); + + // Call onEnable with minecraft data as first argument + if (script.onEnable() != null && !script.onEnable().isnil()) { + try { + LuaValue data = dataCache.get(id); + script.onEnable().call(data); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onEnable for {} {}", getLogPrefix(), id, e); + } + } + + AmbleKit.LOGGER.info("Enabled {}: {}", getLogPrefix(), id); + return true; + } + + public boolean disable(Identifier id) { + if (!enabledScripts.contains(id)) { + return false; // Not enabled + } + + LuaScript script = cache.get(id); + + // Call onDisable with minecraft data as first argument before removing + if (script != null && script.onDisable() != null && !script.onDisable().isnil()) { + try { + LuaValue data = dataCache.get(id); + script.onDisable().call(data); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onDisable for {} {}", getLogPrefix(), id, e); + } + } + + enabledScripts.remove(id); + AmbleKit.LOGGER.info("Disabled {}: {}", getLogPrefix(), id); + return true; + } + + public boolean toggle(Identifier id) { + if (isEnabled(id)) { + return disable(id); + } else { + return enable(id); + } + } + + /** + * Called each tick to update enabled scripts. + */ + public void tick() { + for (Identifier id : enabledScripts) { + LuaScript script = cache.get(id); + if (script != null && script.onTick() != null && !script.onTick().isnil()) { + try { + LuaValue data = dataCache.get(id); + script.onTick().call(data); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onTick for {} {}", getLogPrefix(), id, e); + } + } + } + } + + /** + * Get the bound minecraft data for a script. + */ + public LuaValue getScriptData(Identifier id) { + return dataCache.get(id); + } +} diff --git a/src/main/java/dev/amble/lib/script/AmbleScript.java b/src/main/java/dev/amble/lib/script/LuaScript.java similarity index 60% rename from src/main/java/dev/amble/lib/script/AmbleScript.java rename to src/main/java/dev/amble/lib/script/LuaScript.java index bd61dba..f909160 100644 --- a/src/main/java/dev/amble/lib/script/AmbleScript.java +++ b/src/main/java/dev/amble/lib/script/LuaScript.java @@ -2,7 +2,11 @@ import org.luaj.vm2.LuaValue; -public record AmbleScript( +/** + * Represents a loaded Lua script with its lifecycle callback functions. + * Scripts can define any of these callback functions to handle various events. + */ +public record LuaScript( LuaValue onInit, LuaValue onClick, LuaValue onRelease, diff --git a/src/main/java/dev/amble/lib/script/ScriptManager.java b/src/main/java/dev/amble/lib/script/ScriptManager.java index eda00bf..67d587f 100644 --- a/src/main/java/dev/amble/lib/script/ScriptManager.java +++ b/src/main/java/dev/amble/lib/script/ScriptManager.java @@ -2,27 +2,17 @@ import dev.amble.lib.AmbleKit; import dev.amble.lib.script.lua.ClientMinecraftData; -import dev.amble.lib.script.lua.LuaBinder; +import dev.amble.lib.script.lua.MinecraftData; import net.fabricmc.fabric.api.resource.ResourceManagerHelper; -import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; -import net.minecraft.resource.Resource; -import net.minecraft.resource.ResourceManager; import net.minecraft.resource.ResourceType; import net.minecraft.util.Identifier; -import org.luaj.vm2.*; -import org.luaj.vm2.lib.jse.JsePlatform; -import java.io.InputStreamReader; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -public class ScriptManager implements SimpleSynchronousResourceReloadListener { +/** + * Client-side script manager for loading and managing Lua scripts from asset packs. + * Scripts are loaded from assets/<namespace>/script/*.lua + */ +public class ScriptManager extends AbstractScriptManager { private static final ScriptManager INSTANCE = new ScriptManager(); - private static final Map CACHE = new HashMap<>(); - private static final Map DATA_CACHE = new HashMap<>(); - private static final Set ENABLED_SCRIPTS = new HashSet<>(); private ScriptManager() { ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES) @@ -33,159 +23,18 @@ public static ScriptManager getInstance() { return INSTANCE; } - @Override - public Identifier getFabricId() { - return AmbleKit.id("scripts"); - } - - @Override - public void reload(ResourceManager manager) { - // Disable all scripts before clearing cache - for (Identifier id : new HashSet<>(ENABLED_SCRIPTS)) { - disable(id); - } - - CACHE.clear(); - DATA_CACHE.clear(); - - // Discover all script files and populate the cache for suggestions - manager.findResources("script", id -> id.getPath().endsWith(".lua")) - .keySet() - .forEach(id -> { - try { - load(id, manager); - } catch (Exception e) { - AmbleKit.LOGGER.error("Failed to load script {}", id, e); - } - }); - - AmbleKit.LOGGER.info("Loaded {} scripts", CACHE.size()); - } - - public static AmbleScript load(Identifier id, ResourceManager manager) { - return CACHE.computeIfAbsent(id, key -> { - try { - Resource res = manager.getResource(key).orElseThrow(); - Globals globals = JsePlatform.standardGlobals(); - - // Create and cache the minecraft data for this script - ClientMinecraftData data = new ClientMinecraftData(); - LuaValue boundData = LuaBinder.bind(data); - DATA_CACHE.put(key, boundData); - - // Inject minecraft global for scripts to use (backward compatibility) - globals.set("minecraft", boundData); - - LuaValue chunk = globals.load( - new InputStreamReader(res.getInputStream()), - key.toString() - ); - chunk.call(); - - return new AmbleScript( - globals.get("onInit"), - globals.get("onClick"), - globals.get("onRelease"), - globals.get("onHover"), - globals.get("onExecute"), - globals.get("onEnable"), - globals.get("onTick"), - globals.get("onDisable") - ); - } catch (Exception e) { - throw new RuntimeException("Failed to load script " + key, e); - } - }); - } - - public static Map getCache() { - return CACHE; - } - - public static Set getEnabledScripts() { - return ENABLED_SCRIPTS; - } - - public static boolean isEnabled(Identifier id) { - return ENABLED_SCRIPTS.contains(id); - } - - public static boolean enable(Identifier id) { - if (ENABLED_SCRIPTS.contains(id)) { - return false; // Already enabled - } - - AmbleScript script = CACHE.get(id); - if (script == null) { - return false; - } - - ENABLED_SCRIPTS.add(id); - - // Call onEnable with minecraft data as first argument - if (script.onEnable() != null && !script.onEnable().isnil()) { - try { - LuaValue data = DATA_CACHE.get(id); - script.onEnable().call(data); - } catch (Exception e) { - AmbleKit.LOGGER.error("Error in onEnable for script {}", id, e); - } - } - - AmbleKit.LOGGER.info("Enabled script: {}", id); - return true; - } - - public static boolean disable(Identifier id) { - if (!ENABLED_SCRIPTS.contains(id)) { - return false; // Not enabled - } - - AmbleScript script = CACHE.get(id); - - // Call onDisable with minecraft data as first argument before removing - if (script != null && script.onDisable() != null && !script.onDisable().isnil()) { - try { - LuaValue data = DATA_CACHE.get(id); - script.onDisable().call(data); - } catch (Exception e) { - AmbleKit.LOGGER.error("Error in onDisable for script {}", id, e); - } - } - - ENABLED_SCRIPTS.remove(id); - AmbleKit.LOGGER.info("Disabled script: {}", id); - return true; - } - - public static boolean toggle(Identifier id) { - if (isEnabled(id)) { - return disable(id); - } else { - return enable(id); - } - } + @Override + public Identifier getFabricId() { + return AmbleKit.id("scripts"); + } - public static void tick() { - for (Identifier id : ENABLED_SCRIPTS) { - AmbleScript script = CACHE.get(id); - if (script != null && script.onTick() != null && !script.onTick().isnil()) { - try { - LuaValue data = DATA_CACHE.get(id); - script.onTick().call(data); - } catch (Exception e) { - AmbleKit.LOGGER.error("Error in onTick for script {}", id, e); - // Optionally disable the script on error - // disable(id); - } - } - } - } + @Override + protected MinecraftData createMinecraftData() { + return new ClientMinecraftData(); + } - /** - * Get the bound minecraft data for a script. - */ - public static LuaValue getScriptData(Identifier id) { - return DATA_CACHE.get(id); - } + @Override + protected String getLogPrefix() { + return "script"; + } } diff --git a/src/main/java/dev/amble/lib/script/ServerScriptManager.java b/src/main/java/dev/amble/lib/script/ServerScriptManager.java index 4b33274..c969dd1 100644 --- a/src/main/java/dev/amble/lib/script/ServerScriptManager.java +++ b/src/main/java/dev/amble/lib/script/ServerScriptManager.java @@ -1,39 +1,26 @@ package dev.amble.lib.script; import dev.amble.lib.AmbleKit; -import dev.amble.lib.script.lua.LuaBinder; +import dev.amble.lib.script.lua.MinecraftData; import dev.amble.lib.script.lua.ServerMinecraftData; import dev.amble.lib.util.ServerLifecycleHooks; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.fabricmc.fabric.api.resource.ResourceManagerHelper; -import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; -import net.minecraft.resource.Resource; -import net.minecraft.resource.ResourceManager; import net.minecraft.resource.ResourceType; import net.minecraft.server.MinecraftServer; import net.minecraft.server.world.ServerWorld; import net.minecraft.util.Identifier; -import org.luaj.vm2.Globals; -import org.luaj.vm2.LuaValue; -import org.luaj.vm2.lib.jse.JsePlatform; -import java.io.InputStreamReader; -import java.util.HashMap; import java.util.HashSet; -import java.util.Map; -import java.util.Set; /** - * Manages server-side Lua scripts loaded from the data folder. + * Server-side script manager for loading and managing Lua scripts from data packs. * Scripts are loaded from data/<namespace>/script/*.lua */ -public class ServerScriptManager implements SimpleSynchronousResourceReloadListener { +public class ServerScriptManager extends AbstractScriptManager { private static final ServerScriptManager INSTANCE = new ServerScriptManager(); - private static final Map CACHE = new HashMap<>(); - private static final Map DATA_CACHE = new HashMap<>(); - private static final Set ENABLED_SCRIPTS = new HashSet<>(); - + private MinecraftServer currentServer; private boolean initialized = false; @@ -61,7 +48,7 @@ public void init() { ServerLifecycleEvents.SERVER_STOPPING.register(server -> { // Disable all scripts before server stops - for (Identifier id : new HashSet<>(ENABLED_SCRIPTS)) { + for (Identifier id : new HashSet<>(enabledScripts)) { disable(id); } this.currentServer = null; @@ -76,153 +63,18 @@ public Identifier getFabricId() { } @Override - public void reload(ResourceManager manager) { - // Disable all scripts before clearing cache - for (Identifier id : new HashSet<>(ENABLED_SCRIPTS)) { - disable(id); - } - - CACHE.clear(); - DATA_CACHE.clear(); - - // Discover all script files and populate the cache - manager.findResources("script", id -> id.getPath().endsWith(".lua")) - .keySet() - .forEach(id -> { - try { - load(id, manager); - } catch (Exception e) { - AmbleKit.LOGGER.error("Failed to load server script {}", id, e); - } - }); - - AmbleKit.LOGGER.info("Loaded {} server scripts", CACHE.size()); + protected MinecraftData createMinecraftData() { + MinecraftServer server = currentServer != null ? currentServer : ServerLifecycleHooks.get(); + ServerWorld world = server != null ? server.getOverworld() : null; + return new ServerMinecraftData(server, world); } - public AmbleScript load(Identifier id, ResourceManager manager) { - return CACHE.computeIfAbsent(id, key -> { - try { - Resource res = manager.getResource(key).orElseThrow(); - Globals globals = JsePlatform.standardGlobals(); - - // Create server minecraft data - world will be set when script is enabled/executed - MinecraftServer server = currentServer != null ? currentServer : ServerLifecycleHooks.get(); - ServerWorld world = server != null ? server.getOverworld() : null; - ServerMinecraftData data = new ServerMinecraftData(server, world); - LuaValue boundData = LuaBinder.bind(data); - DATA_CACHE.put(key, boundData); - - // Inject minecraft global for scripts to use (backward compatibility) - globals.set("minecraft", boundData); - - LuaValue chunk = globals.load( - new InputStreamReader(res.getInputStream()), - key.toString() - ); - chunk.call(); - - return new AmbleScript( - globals.get("onInit"), - globals.get("onClick"), - globals.get("onRelease"), - globals.get("onHover"), - globals.get("onExecute"), - globals.get("onEnable"), - globals.get("onTick"), - globals.get("onDisable") - ); - } catch (Exception e) { - throw new RuntimeException("Failed to load server script " + key, e); - } - }); - } - - public static Map getCache() { - return CACHE; - } - - public static Set getEnabledScripts() { - return ENABLED_SCRIPTS; - } - - public static boolean isEnabled(Identifier id) { - return ENABLED_SCRIPTS.contains(id); - } - - public static boolean enable(Identifier id) { - if (ENABLED_SCRIPTS.contains(id)) { - return false; // Already enabled - } - - AmbleScript script = CACHE.get(id); - if (script == null) { - return false; - } - - ENABLED_SCRIPTS.add(id); - - // Call onEnable with minecraft data as first argument - if (script.onEnable() != null && !script.onEnable().isnil()) { - try { - LuaValue data = DATA_CACHE.get(id); - script.onEnable().call(data); - } catch (Exception e) { - AmbleKit.LOGGER.error("Error in onEnable for server script {}", id, e); - } - } - - AmbleKit.LOGGER.info("Enabled server script: {}", id); - return true; - } - - public static boolean disable(Identifier id) { - if (!ENABLED_SCRIPTS.contains(id)) { - return false; // Not enabled - } - - AmbleScript script = CACHE.get(id); - - // Call onDisable with minecraft data as first argument before removing - if (script != null && script.onDisable() != null && !script.onDisable().isnil()) { - try { - LuaValue data = DATA_CACHE.get(id); - script.onDisable().call(data); - } catch (Exception e) { - AmbleKit.LOGGER.error("Error in onDisable for server script {}", id, e); - } - } - - ENABLED_SCRIPTS.remove(id); - AmbleKit.LOGGER.info("Disabled server script: {}", id); - return true; - } - - public static boolean toggle(Identifier id) { - if (isEnabled(id)) { - return disable(id); - } else { - return enable(id); - } + @Override + protected String getLogPrefix() { + return "server script"; } private void onServerTick(MinecraftServer server) { - for (Identifier id : ENABLED_SCRIPTS) { - AmbleScript script = CACHE.get(id); - if (script != null && script.onTick() != null && !script.onTick().isnil()) { - try { - LuaValue data = DATA_CACHE.get(id); - script.onTick().call(data); - } catch (Exception e) { - AmbleKit.LOGGER.error("Error in onTick for server script {}", id, e); - } - } - } - } - - /** - * Get the bound minecraft data for a script. - */ - public static LuaValue getScriptData(Identifier id) { - return DATA_CACHE.get(id); + tick(); } } diff --git a/src/main/java/dev/amble/lib/script/lua/LuaBinder.java b/src/main/java/dev/amble/lib/script/lua/LuaBinder.java index e5fd8d4..90b62e9 100644 --- a/src/main/java/dev/amble/lib/script/lua/LuaBinder.java +++ b/src/main/java/dev/amble/lib/script/lua/LuaBinder.java @@ -2,6 +2,9 @@ import net.minecraft.entity.Entity; import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.nbt.NbtElement; +import net.minecraft.nbt.NbtList; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; import org.joml.Vector3f; @@ -15,6 +18,9 @@ import java.util.List; import java.util.Map; +/** + * Binds Java objects to Lua, exposing methods annotated with @LuaExpose. + */ public final class LuaBinder { private static final Map, LuaTable> CACHE = new HashMap<>(); @@ -30,56 +36,228 @@ public static LuaValue bind(Object target) { return userdata; } - private static LuaValue coerceResult(Object obj) { - if (obj == null) return LuaValue.NIL; - if (obj instanceof LuaValue lv) return lv; - - // at language level 21 this would be a switch expression - if (obj instanceof String - || obj instanceof Number - || obj instanceof Boolean) { - return CoerceJavaToLua.coerce(obj); - } else if (obj instanceof List list) { - LuaTable table = new LuaTable(); - for (int i = 0; i < list.size(); i++) { - table.set(i + 1, coerceResult(list.get(i))); // recursive - } - return table; - } else if (obj instanceof Vector3f vec3) { - LuaTable table = new LuaTable(); - table.set("x", vec3.x()); - table.set("y", vec3.y()); - table.set("z", vec3.z()); - return table; - } else if (obj instanceof Vec3d vec3) { - LuaTable table = new LuaTable(); - table.set("x", vec3.x); - table.set("y", vec3.y); - table.set("z", vec3.z); - table.set("toString", new ZeroArgFunction() { - @Override - public LuaValue call() { - return LuaString.valueOf("(" + vec3.x + ", " + vec3.y + ", " + vec3.z + ")"); - } - }); - return table; - } else if (obj instanceof BlockPos pos) { - LuaTable table = new LuaTable(); - table.set("x", pos.getX()); - table.set("y", pos.getY()); - table.set("z", pos.getZ()); - return table; - } else if (obj instanceof ItemStack stack) { - return bind(new LuaItemStack(stack)); - } else if (obj instanceof Entity entity) { - return bind(new MinecraftEntity(entity)); - } - - return bind(obj); - } - - - private static LuaTable buildMetatable(Class clazz) { + // ===== Separate coerceResult methods for each type ===== + + /** + * Coerces a null value to Lua NIL. + */ + public static LuaValue coerceNull() { + return LuaValue.NIL; + } + + /** + * Coerces a String to a Lua string. + */ + public static LuaValue coerceString(String value) { + return LuaString.valueOf(value); + } + + /** + * Coerces an integer to a Lua number. + */ + public static LuaValue coerceInt(int value) { + return LuaInteger.valueOf(value); + } + + /** + * Coerces a long to a Lua number. + */ + public static LuaValue coerceLong(long value) { + return LuaInteger.valueOf(value); + } + + /** + * Coerces a float to a Lua number. + */ + public static LuaValue coerceFloat(float value) { + return LuaDouble.valueOf(value); + } + + /** + * Coerces a double to a Lua number. + */ + public static LuaValue coerceDouble(double value) { + return LuaDouble.valueOf(value); + } + + /** + * Coerces a boolean to a Lua boolean. + */ + public static LuaValue coerceBoolean(boolean value) { + return LuaBoolean.valueOf(value); + } + + /** + * Coerces a List to a Lua table (1-indexed array). + */ + public static LuaValue coerceList(List list) { + LuaTable table = new LuaTable(); + for (int i = 0; i < list.size(); i++) { + table.set(i + 1, coerceResult(list.get(i))); + } + return table; + } + + /** + * Coerces a Vector3f to a Lua table with x, y, z fields. + */ + public static LuaValue coerceVector3f(Vector3f vec3) { + LuaTable table = new LuaTable(); + table.set("x", vec3.x()); + table.set("y", vec3.y()); + table.set("z", vec3.z()); + table.set("toString", new ZeroArgFunction() { + @Override + public LuaValue call() { + return LuaString.valueOf("(" + vec3.x() + ", " + vec3.y() + ", " + vec3.z() + ")"); + } + }); + return table; + } + + /** + * Coerces a Vec3d to a Lua table with x, y, z fields. + */ + public static LuaValue coerceVec3d(Vec3d vec3) { + LuaTable table = new LuaTable(); + table.set("x", vec3.x); + table.set("y", vec3.y); + table.set("z", vec3.z); + table.set("toString", new ZeroArgFunction() { + @Override + public LuaValue call() { + return LuaString.valueOf("(" + vec3.x + ", " + vec3.y + ", " + vec3.z + ")"); + } + }); + return table; + } + + /** + * Coerces a BlockPos to a Lua table with x, y, z fields. + */ + public static LuaValue coerceBlockPos(BlockPos pos) { + LuaTable table = new LuaTable(); + table.set("x", pos.getX()); + table.set("y", pos.getY()); + table.set("z", pos.getZ()); + table.set("toString", new ZeroArgFunction() { + @Override + public LuaValue call() { + return LuaString.valueOf("(" + pos.getX() + ", " + pos.getY() + ", " + pos.getZ() + ")"); + } + }); + return table; + } + + /** + * Coerces an ItemStack to a bound LuaItemStack. + */ + public static LuaValue coerceItemStack(ItemStack stack) { + return bind(new LuaItemStack(stack)); + } + + /** + * Coerces an Entity to a bound MinecraftEntity. + */ + public static LuaValue coerceEntity(Entity entity) { + return bind(new MinecraftEntity(entity)); + } + + /** + * Coerces an NbtCompound to a Lua table. + */ + public static LuaValue coerceNbtCompound(NbtCompound nbt) { + LuaTable table = new LuaTable(); + for (String key : nbt.getKeys()) { + NbtElement element = nbt.get(key); + table.set(key, coerceNbtElement(element)); + } + return table; + } + + /** + * Coerces any NbtElement to the appropriate Lua type. + */ + public static LuaValue coerceNbtElement(NbtElement element) { + if (element == null) { + return LuaValue.NIL; + } + + return switch (element.getType()) { + case NbtElement.BYTE_TYPE -> LuaInteger.valueOf(((net.minecraft.nbt.NbtByte) element).byteValue()); + case NbtElement.SHORT_TYPE -> LuaInteger.valueOf(((net.minecraft.nbt.NbtShort) element).shortValue()); + case NbtElement.INT_TYPE -> LuaInteger.valueOf(((net.minecraft.nbt.NbtInt) element).intValue()); + case NbtElement.LONG_TYPE -> LuaInteger.valueOf(((net.minecraft.nbt.NbtLong) element).longValue()); + case NbtElement.FLOAT_TYPE -> LuaDouble.valueOf(((net.minecraft.nbt.NbtFloat) element).floatValue()); + case NbtElement.DOUBLE_TYPE -> LuaDouble.valueOf(((net.minecraft.nbt.NbtDouble) element).doubleValue()); + case NbtElement.STRING_TYPE -> LuaString.valueOf(element.asString()); + case NbtElement.BYTE_ARRAY_TYPE -> { + byte[] bytes = ((net.minecraft.nbt.NbtByteArray) element).getByteArray(); + LuaTable table = new LuaTable(); + for (int i = 0; i < bytes.length; i++) { + table.set(i + 1, LuaInteger.valueOf(bytes[i])); + } + yield table; + } + case NbtElement.INT_ARRAY_TYPE -> { + int[] ints = ((net.minecraft.nbt.NbtIntArray) element).getIntArray(); + LuaTable table = new LuaTable(); + for (int i = 0; i < ints.length; i++) { + table.set(i + 1, LuaInteger.valueOf(ints[i])); + } + yield table; + } + case NbtElement.LONG_ARRAY_TYPE -> { + long[] longs = ((net.minecraft.nbt.NbtLongArray) element).getLongArray(); + LuaTable table = new LuaTable(); + for (int i = 0; i < longs.length; i++) { + table.set(i + 1, LuaInteger.valueOf(longs[i])); + } + yield table; + } + case NbtElement.LIST_TYPE -> { + NbtList list = (NbtList) element; + LuaTable table = new LuaTable(); + for (int i = 0; i < list.size(); i++) { + table.set(i + 1, coerceNbtElement(list.get(i))); + } + yield table; + } + case NbtElement.COMPOUND_TYPE -> coerceNbtCompound((NbtCompound) element); + default -> LuaString.valueOf(element.asString()); + }; + } + + // ===== Main coerceResult dispatcher ===== + + /** + * Coerces any Java object to an appropriate Lua value. + * Dispatches to type-specific coercion methods. + */ + public static LuaValue coerceResult(Object obj) { + if (obj == null) return coerceNull(); + if (obj instanceof LuaValue lv) return lv; + if (obj instanceof String s) return coerceString(s); + if (obj instanceof Integer i) return coerceInt(i); + if (obj instanceof Long l) return coerceLong(l); + if (obj instanceof Float f) return coerceFloat(f); + if (obj instanceof Double d) return coerceDouble(d); + if (obj instanceof Boolean b) return coerceBoolean(b); + if (obj instanceof Number n) return CoerceJavaToLua.coerce(n); + if (obj instanceof List list) return coerceList(list); + if (obj instanceof Vector3f vec3) return coerceVector3f(vec3); + if (obj instanceof Vec3d vec3) return coerceVec3d(vec3); + if (obj instanceof BlockPos pos) return coerceBlockPos(pos); + if (obj instanceof ItemStack stack) return coerceItemStack(stack); + if (obj instanceof Entity entity) return coerceEntity(entity); + if (obj instanceof NbtCompound nbt) return coerceNbtCompound(nbt); + if (obj instanceof NbtElement nbt) return coerceNbtElement(nbt); + + // Fall back to binding the object + return bind(obj); + } + + private static LuaTable buildMetatable(Class clazz) { LuaTable meta = new LuaTable(); LuaTable index = new LuaTable(); @@ -92,10 +270,10 @@ private static LuaTable buildMetatable(Class clazz) { : expose.name(); index.set(luaName, new VarArgFunction() { - @Override + @Override public Varargs invoke(Varargs args) { try { - LuaValue selfValue = args.arg1(); + LuaValue selfValue = args.arg1(); if (!selfValue.isuserdata()) { throw new LuaError("Expected userdata but got " + selfValue.typename()); } @@ -111,8 +289,8 @@ public Varargs invoke(Varargs args) { method.getParameterTypes()[i] ); } - Object result = method.invoke(javaSelf, javaArgs); - return LuaBinder.coerceResult(result); + Object result = method.invoke(javaSelf, javaArgs); + return LuaBinder.coerceResult(result); } catch (Exception e) { throw new LuaError("Lua call failed: " + method.getName() + " " + e); } diff --git a/src/main/java/dev/amble/lib/script/lua/LuaItemStack.java b/src/main/java/dev/amble/lib/script/lua/LuaItemStack.java index b4d14ac..99a96ef 100644 --- a/src/main/java/dev/amble/lib/script/lua/LuaItemStack.java +++ b/src/main/java/dev/amble/lib/script/lua/LuaItemStack.java @@ -5,10 +5,15 @@ import net.minecraft.item.ItemStack; import net.minecraft.nbt.NbtCompound; import net.minecraft.registry.Registries; +import org.luaj.vm2.LuaValue; import java.util.ArrayList; import java.util.List; +/** + * Lua wrapper for Minecraft ItemStack. + * Provides access to item properties and NBT data from Lua scripts. + */ @AllArgsConstructor public class LuaItemStack { public final ItemStack stack; @@ -101,6 +106,25 @@ public boolean hasNbt() { return stack.hasNbt(); } + /** + * Returns the NBT data as a Lua table for structured access. + * @return Lua table representation of the NBT data, or NIL if no NBT + */ + @LuaExpose + public LuaValue nbt() { + NbtCompound nbt = stack.getNbt(); + if (nbt == null) { + return LuaValue.NIL; + } + return LuaBinder.coerceNbtCompound(nbt); + } + + /** + * Returns the NBT data as a string (for debugging/display purposes). + * @return String representation of the NBT data + * @deprecated Use nbt() for structured access + */ + @Deprecated @LuaExpose public String nbtString() { NbtCompound nbt = stack.getNbt(); diff --git a/src/main/java/dev/amble/lib/script/lua/MinecraftData.java b/src/main/java/dev/amble/lib/script/lua/MinecraftData.java index 00f8776..bd3110b 100644 --- a/src/main/java/dev/amble/lib/script/lua/MinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/MinecraftData.java @@ -21,6 +21,24 @@ */ public abstract class MinecraftData { + private String scriptName = null; + + /** + * Sets the name of the script using this data, for logging purposes. + * + * @param scriptName the script name or identifier + */ + public void setScriptName(String scriptName) { + this.scriptName = scriptName; + } + + /** + * Gets the log prefix including the script name if available. + */ + private String getLogPrefix() { + return scriptName != null ? "[Script: " + scriptName + "]" : "[Script]"; + } + /** * @return true if this is client-side data, false if server-side */ @@ -165,16 +183,16 @@ public void playSoundAt(String soundId, double x, double y, double z, float volu */ @LuaExpose public void log(String message) { - AmbleKit.LOGGER.info("[Script] {}", message); + AmbleKit.LOGGER.info("{} {}", getLogPrefix(), message); } @LuaExpose public void logWarn(String message) { - AmbleKit.LOGGER.warn("[Script] {}", message); + AmbleKit.LOGGER.warn("{} {}", getLogPrefix(), message); } @LuaExpose public void logError(String message) { - AmbleKit.LOGGER.error("[Script] {}", message); + AmbleKit.LOGGER.error("{} {}", getLogPrefix(), message); } } diff --git a/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java b/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java index 64164f7..fb8d024 100644 --- a/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java +++ b/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java @@ -1,5 +1,6 @@ package dev.amble.litmus.commands; +import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; import dev.amble.lib.client.gui.*; import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; @@ -13,7 +14,8 @@ import net.minecraft.text.Text; import net.minecraft.util.Identifier; -import java.awt.*; +import java.awt.Color; +import java.awt.Rectangle; public class TestScreenCommand { @@ -27,9 +29,9 @@ public static void register(CommandDispatcher dispatc MinecraftClient.getInstance().execute(() -> { ClientScheduler.get().runTaskLater(() -> { - var container = AmbleContainer.builder().layout(new Rectangle(0,0, 216, 138)).background(AmbleDisplayType.texture(new AmbleDisplayType.TextureData(new Identifier(LitmusMod.MOD_ID, "textures/gui/test_screen.png"), 0, 0, 216, 138, 256, 256))).build(); - var child1 = AmbleContainer.builder().layout(new Rectangle(0,0, 50, 50)).background(AmbleDisplayType.color(Color.BLUE)).build(); - var child2 = AmbleContainer.builder().layout(new Rectangle(0,0, 25, 25)).background(AmbleDisplayType.color(Color.ORANGE)).build(); + AmbleContainer container = AmbleContainer.builder().layout(new Rectangle(0,0, 216, 138)).background(AmbleDisplayType.texture(new AmbleDisplayType.TextureData(new Identifier(LitmusMod.MOD_ID, "textures/gui/test_screen.png"), 0, 0, 216, 138, 256, 256))).build(); + AmbleContainer child1 = AmbleContainer.builder().layout(new Rectangle(0,0, 50, 50)).background(AmbleDisplayType.color(Color.BLUE)).build(); + AmbleContainer child2 = AmbleContainer.builder().layout(new Rectangle(0,0, 25, 25)).background(AmbleDisplayType.color(Color.ORANGE)).build(); AmbleButton child3 = AmbleButton.buttonBuilder().layout(new Rectangle(0,0, 75, 40)).horizontalAlign(UIAlign.CENTRE).background(Color.GREEN).hoverDisplay(Color.YELLOW).pressDisplay(Color.RED).onClick(() -> { System.out.println("Button Clicked!"); }).build(); @@ -48,7 +50,7 @@ public static void register(CommandDispatcher dispatc container.display(); }, TimeUnit.SECONDS, 1); }); - return 1; + return Command.SINGLE_SUCCESS; }).then(ClientCommandManager.argument("id", IdentifierArgumentType.identifier()).executes(source -> { Identifier id = source.getArgument("id", Identifier.class); AmbleContainer container = AmbleGuiRegistry.getInstance().get(id); @@ -59,7 +61,7 @@ public static void register(CommandDispatcher dispatc ClientScheduler.get().runTaskLater(container::display, TimeUnit.SECONDS, 1); - return 1; + return Command.SINGLE_SUCCESS; }))); } } From 94286470e7a023967bd24293816be98bdfb1b206 Mon Sep 17 00:00:00 2001 From: James Hall Date: Fri, 9 Jan 2026 00:44:49 +0000 Subject: [PATCH 18/37] Address PR review feedback: MethodHandles, getExecutor, LuaScript refactor, constants, docs --- .../dev/amble/lib/client/gui/AmbleButton.java | 2 +- .../amble/lib/client/gui/AmbleContainer.java | 3 +- .../dev/amble/lib/client/gui/AmbleText.java | 3 +- .../amble/lib/client/gui/lua/LuaElement.java | 11 ++++++ .../client/gui/registry/AmbleGuiRegistry.java | 1 + .../lib/script/AbstractScriptManager.java | 4 +-- .../java/dev/amble/lib/script/LuaScript.java | 32 ++++++++++++++--- .../amble/lib/script/ServerScriptManager.java | 6 +--- .../lib/script/lua/ClientMinecraftData.java | 2 +- .../dev/amble/lib/script/lua/LuaBinder.java | 35 +++++++++++++++---- .../amble/lib/script/lua/MinecraftData.java | 24 +++++++------ .../lib/script/lua/ServerMinecraftData.java | 2 +- 12 files changed, 90 insertions(+), 35 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java index c77b29a..9063a04 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java @@ -2,7 +2,7 @@ import dev.amble.lib.AmbleKit; -import dev.amble.lib.script.lua.LuaBinder; +herimport dev.amble.lib.script.lua.LuaBinder; import dev.amble.lib.client.gui.lua.LuaElement; import dev.amble.lib.script.LuaScript; import lombok.*; diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java index 12b3231..2165bdd 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java @@ -98,6 +98,7 @@ public Rectangle getPreferredLayout() { } private static final Rectangle FALLBACK_LAYOUT = new Rectangle(0, 0, 100, 100); + private static final Color TRANSPARENT = new Color(0, 0, 0, 0); protected Rectangle fallbackLayout() { AmbleKit.LOGGER.error("GUI element {} is missing layout data, using fallback layout", id()); @@ -149,7 +150,7 @@ public static AmbleContainer primaryContainer() { .layout(new Rectangle(0, 0, MinecraftClient.getInstance().getWindow().getScaledWidth(), MinecraftClient.getInstance().getWindow().getScaledHeight())) - .background(new Color(0, 0, 0, 0)) + .background(TRANSPARENT) .build(); } diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleText.java b/src/main/java/dev/amble/lib/client/gui/AmbleText.java index 26fde60..63028ad 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleText.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleText.java @@ -26,7 +26,8 @@ public class AmbleText extends AmbleContainer { @Setter private boolean shadow = true; - // Cached wrapped lines to avoid recalculating every frame + // Cache fields for wrapped lines - marked transient to exclude from serialization + // These are recalculated at runtime based on layout and text content private transient List cachedLines; private transient int cachedWidth = -1; private transient Text cachedText; diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java index 79f9d68..b4508bb 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java +++ b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java @@ -8,6 +8,17 @@ import net.minecraft.util.math.Vec2f; import org.jetbrains.annotations.ApiStatus; +/** + * A Lua-friendly wrapper around {@link AmbleElement}. + *

+ * This class uses the wrapper/facade pattern to expose a simplified API for Lua scripts. + * It intentionally does NOT extend AmbleElement because: + *

    + *
  • It provides only the methods that make sense for Lua scripting
  • + *
  • It converts Java types to Lua-compatible return values
  • + *
  • It encapsulates the underlying element, preventing direct manipulation from Lua
  • + *
+ */ public final class LuaElement { private final AmbleElement element; diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index c73618c..555f35d 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -26,6 +26,7 @@ import java.util.ArrayList; import java.util.List; +// TODO: Consider removing dependency on DatapackRegistry - see team discussion public class AmbleGuiRegistry extends DatapackRegistry implements SimpleSynchronousResourceReloadListener { private static final AmbleGuiRegistry INSTANCE = new AmbleGuiRegistry(); diff --git a/src/main/java/dev/amble/lib/script/AbstractScriptManager.java b/src/main/java/dev/amble/lib/script/AbstractScriptManager.java index b0de082..879aa89 100644 --- a/src/main/java/dev/amble/lib/script/AbstractScriptManager.java +++ b/src/main/java/dev/amble/lib/script/AbstractScriptManager.java @@ -90,10 +90,8 @@ public LuaScript load(Identifier id, ResourceManager manager) { chunk.call(); return new LuaScript( + globals, globals.get("onInit"), - globals.get("onClick"), - globals.get("onRelease"), - globals.get("onHover"), globals.get("onExecute"), globals.get("onEnable"), globals.get("onTick"), diff --git a/src/main/java/dev/amble/lib/script/LuaScript.java b/src/main/java/dev/amble/lib/script/LuaScript.java index f909160..24f8833 100644 --- a/src/main/java/dev/amble/lib/script/LuaScript.java +++ b/src/main/java/dev/amble/lib/script/LuaScript.java @@ -1,18 +1,40 @@ package dev.amble.lib.script; +import org.luaj.vm2.Globals; import org.luaj.vm2.LuaValue; /** * Represents a loaded Lua script with its lifecycle callback functions. - * Scripts can define any of these callback functions to handle various events. + *

+ * Core lifecycle callbacks (onInit, onExecute, onEnable, onTick, onDisable) are stored directly. + * GUI-specific callbacks (onClick, onRelease, onHover) are looked up from globals on demand + * to keep this record focused on script lifecycle rather than GUI concerns. */ public record LuaScript( + Globals globals, LuaValue onInit, - LuaValue onClick, - LuaValue onRelease, - LuaValue onHover, LuaValue onExecute, LuaValue onEnable, LuaValue onTick, LuaValue onDisable -) {} +) { + /** + * Gets a GUI callback by name (onClick, onRelease, onHover). + * Returns NIL if the callback is not defined. + */ + public LuaValue getGuiCallback(String name) { + return globals.get(name); + } + + public LuaValue onClick() { + return getGuiCallback("onClick"); + } + + public LuaValue onRelease() { + return getGuiCallback("onRelease"); + } + + public LuaValue onHover() { + return getGuiCallback("onHover"); + } +} diff --git a/src/main/java/dev/amble/lib/script/ServerScriptManager.java b/src/main/java/dev/amble/lib/script/ServerScriptManager.java index c969dd1..edca734 100644 --- a/src/main/java/dev/amble/lib/script/ServerScriptManager.java +++ b/src/main/java/dev/amble/lib/script/ServerScriptManager.java @@ -54,7 +54,7 @@ public void init() { this.currentServer = null; }); - ServerTickEvents.END_SERVER_TICK.register(this::onServerTick); + ServerTickEvents.END_SERVER_TICK.register(server -> tick()); } @Override @@ -73,8 +73,4 @@ protected MinecraftData createMinecraftData() { protected String getLogPrefix() { return "server script"; } - - private void onServerTick(MinecraftServer server) { - tick(); - } } diff --git a/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java b/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java index 3d39142..694262b 100644 --- a/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java @@ -42,7 +42,7 @@ protected World getWorld() { } @Override - protected Entity getPlayer() { + protected Entity getExecutor() { return mc.player; } diff --git a/src/main/java/dev/amble/lib/script/lua/LuaBinder.java b/src/main/java/dev/amble/lib/script/lua/LuaBinder.java index 90b62e9..80285ca 100644 --- a/src/main/java/dev/amble/lib/script/lua/LuaBinder.java +++ b/src/main/java/dev/amble/lib/script/lua/LuaBinder.java @@ -13,6 +13,8 @@ import org.luaj.vm2.lib.jse.CoerceJavaToLua; import org.luaj.vm2.lib.jse.CoerceLuaToJava; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; import java.lang.reflect.Method; import java.util.HashMap; import java.util.List; @@ -20,9 +22,11 @@ /** * Binds Java objects to Lua, exposing methods annotated with @LuaExpose. + * Uses MethodHandles for improved performance over reflection. */ public final class LuaBinder { + private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); private static final Map, LuaTable> CACHE = new HashMap<>(); public static LuaValue bind(Object target) { @@ -269,6 +273,17 @@ private static LuaTable buildMetatable(Class clazz) { ? method.getName() : expose.name(); + // Convert Method to MethodHandle for better performance + MethodHandle handle; + try { + handle = LOOKUP.unreflect(method); + } catch (IllegalAccessException e) { + throw new RuntimeException("Failed to create MethodHandle for " + method.getName(), e); + } + + Class[] paramTypes = method.getParameterTypes(); + String methodName = method.getName(); + index.set(luaName, new VarArgFunction() { @Override public Varargs invoke(Varargs args) { @@ -281,18 +296,24 @@ public Varargs invoke(Varargs args) { if (javaSelf == null || !clazz.isInstance(javaSelf)) { throw new LuaError("Expected userdata of type " + clazz.getName() + " but got " + (javaSelf == null ? "null" : javaSelf.getClass().getName())); } - Object[] javaArgs = new Object[method.getParameterCount()]; - for (int i = 0; i < javaArgs.length; i++) { - javaArgs[i] = CoerceLuaToJava.coerce( + // Build argument array: [self, arg1, arg2, ...] + Object[] invokeArgs = new Object[paramTypes.length + 1]; + invokeArgs[0] = javaSelf; + + for (int i = 0; i < paramTypes.length; i++) { + invokeArgs[i + 1] = CoerceLuaToJava.coerce( args.arg(i + 2), - method.getParameterTypes()[i] + paramTypes[i] ); } - Object result = method.invoke(javaSelf, javaArgs); + + Object result = handle.invokeWithArguments(invokeArgs); return LuaBinder.coerceResult(result); - } catch (Exception e) { - throw new LuaError("Lua call failed: " + method.getName() + " " + e); + } catch (LuaError e) { + throw e; + } catch (Throwable e) { + throw new LuaError("Lua call failed: " + methodName + " " + e); } } }); diff --git a/src/main/java/dev/amble/lib/script/lua/MinecraftData.java b/src/main/java/dev/amble/lib/script/lua/MinecraftData.java index bd3110b..f3a3d16 100644 --- a/src/main/java/dev/amble/lib/script/lua/MinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/MinecraftData.java @@ -51,9 +51,13 @@ private String getLogPrefix() { protected abstract World getWorld(); /** - * @return the player entity for this context (may be null on server for some contexts) + * Returns the entity that is executing this script context. + * On client, this is typically the local player. + * On server, this may be the player who ran a command, or null for server-initiated scripts. + * + * @return the executor entity, or null if not applicable */ - protected abstract Entity getPlayer(); + protected abstract Entity getExecutor(); // ===== World & Environment ===== @@ -112,7 +116,7 @@ public int lightLevelAt(int x, int y, int z) { @LuaExpose public Entity player() { - return getPlayer(); + return getExecutor(); } @LuaExpose @@ -130,22 +134,22 @@ public List entities() { @LuaExpose public Entity nearestEntity(double maxDistance) { World world = getWorld(); - Entity player = getPlayer(); - if (world == null || player == null) return null; + Entity executor = getExecutor(); + if (world == null || executor == null) return null; - return world.getOtherEntities(player, player.getBoundingBox().expand(maxDistance), e -> true) + return world.getOtherEntities(executor, executor.getBoundingBox().expand(maxDistance), e -> true) .stream() - .min(Comparator.comparingDouble(e -> e.squaredDistanceTo(player))) + .min(Comparator.comparingDouble(e -> e.squaredDistanceTo(executor))) .orElse(null); } @LuaExpose public List entitiesInRadius(double radius) { World world = getWorld(); - Entity player = getPlayer(); - if (world == null || player == null) return List.of(); + Entity executor = getExecutor(); + if (world == null || executor == null) return List.of(); - return world.getOtherEntities(player, player.getBoundingBox().expand(radius), e -> true); + return world.getOtherEntities(executor, executor.getBoundingBox().expand(radius), e -> true); } // ===== Audio (shared implementation) ===== diff --git a/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java b/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java index 6eb7d36..e472e64 100644 --- a/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java @@ -64,7 +64,7 @@ protected World getWorld() { } @Override - protected Entity getPlayer() { + protected Entity getExecutor() { return player; } From 1116d63dfd9c82f1ae368eac2e9a517644380a34 Mon Sep 17 00:00:00 2001 From: James Hall Date: Fri, 9 Jan 2026 00:48:36 +0000 Subject: [PATCH 19/37] Fix corrupted import in AmbleButton.java --- src/main/java/dev/amble/lib/client/gui/AmbleButton.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java index 9063a04..c77b29a 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java @@ -2,7 +2,7 @@ import dev.amble.lib.AmbleKit; -herimport dev.amble.lib.script.lua.LuaBinder; +import dev.amble.lib.script.lua.LuaBinder; import dev.amble.lib.client.gui.lua.LuaElement; import dev.amble.lib.script.LuaScript; import lombok.*; From 494aa3dc8966016d0a5b2e58900414e2e8cdc111 Mon Sep 17 00:00:00 2001 From: James Hall Date: Fri, 9 Jan 2026 01:04:12 +0000 Subject: [PATCH 20/37] Add support for checking any key in isKeyPressed - Support shorthand keybind names: forward, jump, inventory, sprint, etc. - Support raw keyboard keys: r, h, space, left_shift, escape, f1, etc. - Support registered keybind translation keys - Add isMouseButtonPressed for mouse button checks - Falls back through keybinds then raw keys for maximum flexibility --- .../lib/script/lua/ClientMinecraftData.java | 135 ++++++++++++++++-- 1 file changed, 123 insertions(+), 12 deletions(-) diff --git a/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java b/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java index 694262b..ffc9bc5 100644 --- a/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java @@ -5,6 +5,7 @@ import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; import net.minecraft.SharedConstants; import net.minecraft.client.MinecraftClient; +import net.minecraft.client.util.InputUtil; import net.minecraft.entity.Entity; import net.minecraft.item.ItemStack; import net.minecraft.network.packet.c2s.play.PlayerActionC2SPacket; @@ -125,21 +126,131 @@ public void sendMessage(String message, boolean overlay) { // ===== Input ===== + /** + * Checks if a key or keybind is currently pressed. + *

+ * Supports multiple input types: + *

    + *
  • Shorthand keybind names: "forward", "jump", "inventory", "sprint", etc.
  • + *
  • Raw keyboard keys: "r", "h", "space", "left_shift", "escape", "f1", etc.
  • + *
  • Registered keybind translation keys: "key.inventory", "key.sprint", etc.
  • + *
+ * + * @param keyName the name of the key or keybind to check + * @return true if the key is currently pressed + */ @LuaExpose public boolean isKeyPressed(String keyName) { - if (mc.options == null) return false; - return switch (keyName.toLowerCase()) { - case "forward" -> mc.options.forwardKey.isPressed(); - case "back" -> mc.options.backKey.isPressed(); - case "left" -> mc.options.leftKey.isPressed(); - case "right" -> mc.options.rightKey.isPressed(); - case "jump" -> mc.options.jumpKey.isPressed(); - case "sneak" -> mc.options.sneakKey.isPressed(); - case "sprint" -> mc.options.sprintKey.isPressed(); - case "attack" -> mc.options.attackKey.isPressed(); - case "use" -> mc.options.useKey.isPressed(); - default -> false; + if (mc.getWindow() == null) return false; + + String lowerName = keyName.toLowerCase(); + + // Check common shorthand keybind names first for backwards compatibility + if (mc.options != null) { + Boolean result = switch (lowerName) { + case "forward" -> mc.options.forwardKey.isPressed(); + case "back" -> mc.options.backKey.isPressed(); + case "left" -> mc.options.leftKey.isPressed(); + case "right" -> mc.options.rightKey.isPressed(); + case "jump" -> mc.options.jumpKey.isPressed(); + case "sneak" -> mc.options.sneakKey.isPressed(); + case "sprint" -> mc.options.sprintKey.isPressed(); + case "attack" -> mc.options.attackKey.isPressed(); + case "use" -> mc.options.useKey.isPressed(); + case "inventory" -> mc.options.inventoryKey.isPressed(); + case "drop" -> mc.options.dropKey.isPressed(); + case "chat" -> mc.options.chatKey.isPressed(); + case "pick_item" -> mc.options.pickItemKey.isPressed(); + case "swap_hands" -> mc.options.swapHandsKey.isPressed(); + default -> null; + }; + + if (result != null) { + return result; + } + + // Search all registered keybinds by translation key + for (net.minecraft.client.option.KeyBinding keyBinding : mc.options.allKeys) { + String translationKey = keyBinding.getTranslationKey(); + // Match by exact translation key or by the suffix after the last dot + if (translationKey.equals(keyName) || translationKey.endsWith("." + lowerName)) { + return keyBinding.isPressed(); + } + } + } + + // Fall back to checking raw keyboard key + long windowHandle = mc.getWindow().getHandle(); + String translationKey = "key.keyboard." + lowerName; + + try { + InputUtil.Key key = InputUtil.fromTranslationKey(translationKey); + if (key != InputUtil.UNKNOWN_KEY) { + return InputUtil.isKeyPressed(windowHandle, key.getCode()); + } + } catch (Exception e) { + // Key not found + } + + return false; + } + + /** + * Checks if a mouse button is currently pressed. + * Accepts button names like "left", "right", "middle", or button numbers (0, 1, 2, etc.). + * + * @param button the mouse button to check (e.g., "left", "right", "middle", or "0", "1", "2") + * @return true if the mouse button is currently pressed + */ + @LuaExpose + public boolean isMouseButtonPressed(String button) { + if (mc.getWindow() == null) return false; + + long windowHandle = mc.getWindow().getHandle(); + String lowerButton = button.toLowerCase(); + + int buttonCode = switch (lowerButton) { + case "left" -> 0; + case "right" -> 1; + case "middle" -> 2; + default -> { + try { + yield Integer.parseInt(button); + } catch (NumberFormatException e) { + yield -1; + } + } }; + + if (buttonCode >= 0) { + String translationKey = "key.mouse." + (buttonCode + 1); + try { + InputUtil.Key key = InputUtil.fromTranslationKey(translationKey); + if (key != InputUtil.UNKNOWN_KEY) { + return InputUtil.isKeyPressed(windowHandle, key.getCode()); + } + } catch (Exception e) { + // Button not found + } + } + + return false; + } + + /** + * Gets a list of all registered keybind translation keys. + * Useful for discovering available keybinds. + * + * @return list of all keybind translation keys + */ + @LuaExpose + public List getKeybinds() { + if (mc.options == null) return List.of(); + List keybinds = new java.util.ArrayList<>(); + for (net.minecraft.client.option.KeyBinding keyBinding : mc.options.allKeys) { + keybinds.add(keyBinding.getTranslationKey()); + } + return keybinds; } @LuaExpose From bcc9d82a38631c09af3cb760e64f0559b94ca7ec Mon Sep 17 00:00:00 2001 From: James Hall Date: Fri, 9 Jan 2026 01:29:02 +0000 Subject: [PATCH 21/37] Add comprehensive documentation for Lua scripting and JSON GUI systems - Add LUA_SCRIPTING.md with full API reference, lifecycle callbacks, and examples - Add GUI_SYSTEM.md with JSON structure docs, properties reference, and Lua integration - Update README.md with brief descriptions and links to new documentation - Fix AmbleGuiRegistry to auto-create child AmbleText when button has text property --- GUI_SYSTEM.md | 464 ++++++++++++++++++ LUA_SCRIPTING.md | 383 +++++++++++++++ README.md | 12 + .../client/gui/registry/AmbleGuiRegistry.java | 38 +- 4 files changed, 886 insertions(+), 11 deletions(-) create mode 100644 GUI_SYSTEM.md create mode 100644 LUA_SCRIPTING.md diff --git a/GUI_SYSTEM.md b/GUI_SYSTEM.md new file mode 100644 index 0000000..957944e --- /dev/null +++ b/GUI_SYSTEM.md @@ -0,0 +1,464 @@ +# JSON GUI System + +AmbleKit provides a declarative JSON-based GUI system that lets you create Minecraft screens without writing Java code. Define layouts, colors, textures, and interactive buttons entirely in JSON files loaded from resource packs. + +## Table of Contents +- [Getting Started](#getting-started) +- [File Location](#file-location) +- [JSON Structure](#json-structure) +- [Properties Reference](#properties-reference) +- [Background Types](#background-types) +- [Text Elements](#text-elements) +- [Buttons & Interactivity](#buttons--interactivity) +- [Lua Script Integration](#lua-script-integration) +- [Displaying Screens](#displaying-screens) +- [Complete Example](#complete-example) + +--- + +## Getting Started + +The AmbleKit GUI system allows you to: +- Define screen layouts in JSON files +- Use solid colors or textures as backgrounds +- Create nested container hierarchies +- Add text with automatic word wrapping +- Create interactive buttons with hover/press states +- Attach Lua scripts for dynamic behavior + +--- + +## File Location + +GUI definitions are loaded from resource packs: + +``` +assets//gui/.json +``` + +For example, `assets/mymod/gui/main_menu.json` creates a screen with ID `mymod:main_menu`. + +--- + +## JSON Structure + +Every GUI element shares these core properties: + +```json +{ + "layout": [width, height], + "background": , + "padding": 0, + "spacing": 0, + "alignment": ["centre", "centre"], + "children": [] +} +``` + +--- + +## Properties Reference + +### Layout + +Defines the element's dimensions as `[width, height]`: + +```json +"layout": [200, 150] +``` + +### Padding + +Internal spacing between the element's edge and its children: + +```json +"padding": 10 +``` + +### Spacing + +Gap between child elements: + +```json +"spacing": 5 +``` + +### Alignment + +Controls how children are positioned within the container. Format: `[horizontal, vertical]` + +| Value | Description | +|-------|-------------| +| `"start"` | Align to left/top | +| `"centre"` / `"center"` | Center alignment | +| `"end"` | Align to right/bottom | + +```json +"alignment": ["centre", "centre"] +``` + +### Requires New Row + +Forces this element to start on a new row (for flow layout): + +```json +"requires_new_row": true +``` + +### Should Pause + +Whether the screen pauses the game (singleplayer): + +```json +"should_pause": true +``` + +### ID + +Optional explicit identifier for the element: + +```json +"id": "mymod:my_button" +``` + +--- + +## Background Types + +### Solid Color + +RGB array (0-255), with optional alpha: + +```json +"background": [255, 128, 0] +``` + +With transparency: + +```json +"background": [0, 0, 0, 128] +``` + +### Texture + +Reference a texture file with UV coordinates: + +```json +"background": { + "texture": "mymod:textures/gui/panel.png", + "u": 0, + "v": 0, + "regionWidth": 200, + "regionHeight": 150, + "textureWidth": 256, + "textureHeight": 256 +} +``` + +| Property | Description | +|----------|-------------| +| `texture` | Resource location of the texture | +| `u`, `v` | Top-left corner of the region in the texture | +| `regionWidth`, `regionHeight` | Size of the region to sample | +| `textureWidth`, `textureHeight` | Full dimensions of the texture file | + +--- + +## Text Elements + +Add the `text` property to display text. The text will automatically wrap to fit the container width: + +```json +{ + "layout": [100, 30], + "background": [0, 0, 0, 0], + "text": "gui.mymod.welcome_message" +} +``` + +The `text` value is passed through `Text.translatable()`, so you can use translation keys from your lang files. + +### Text Alignment + +Control text positioning within the element: + +```json +{ + "layout": [100, 30], + "background": [50, 50, 50], + "text": "Hello World", + "text_alignment": ["centre", "centre"] +} +``` + +--- + +## Buttons & Interactivity + +Adding any of these properties converts an element into a button: +- `script` - Attach a Lua script +- `on_click` - Run a command on click +- `hover_background` - Background when hovered +- `press_background` - Background when pressed + +When a button has a `text` property, a child text element is automatically created with a transparent background, so you can define text directly on buttons without manually creating children. + +### Basic Button + +```json +{ + "layout": [80, 24], + "background": [100, 100, 100], + "hover_background": [150, 150, 150], + "press_background": [50, 50, 50], + "text": "Click Me", + "on_click": "/say Button clicked!" +} +``` + +### Button with Text Alignment + +```json +{ + "layout": [120, 30], + "background": [80, 80, 80], + "hover_background": [100, 100, 100], + "text": "gui.mymod.button_label", + "text_alignment": ["centre", "centre"], + "script": "mymod:my_handler" +} +``` + +### Command Execution + +The `on_click` property runs a command when clicked: + +```json +"on_click": "/gamemode creative" +``` + +Commands must start with `/` and are executed as the local player. + +--- + +## Lua Script Integration + +For dynamic behavior, attach Lua scripts to buttons. This is where the GUI system integrates with AmbleKit's [Lua Scripting System](LUA_SCRIPTING.md). + +### Attaching a Script + +Reference a script by its ID (without the `script/` prefix or `.lua` suffix): + +```json +{ + "layout": [80, 24], + "background": [0, 200, 0], + "hover_background": [0, 255, 0], + "press_background": [0, 150, 0], + "script": "mymod:button_handler" +} +``` + +This loads `assets/mymod/script/button_handler.lua`. + +### GUI Script Callbacks + +GUI scripts use different callbacks than standalone scripts. They receive a `self` (LuaElement) parameter: + +| Callback | When Called | Parameters | +|----------|-------------|------------| +| `onInit(self)` | When script is attached to element | `self` | +| `onClick(self, mouseX, mouseY, button)` | Mouse button pressed | `self`, coordinates, button (0=left, 1=right) | +| `onRelease(self, mouseX, mouseY, button)` | Mouse button released | `self`, coordinates, button | +| `onHover(self, mouseX, mouseY)` | Mouse hovering over element | `self`, coordinates | + +### LuaElement API + +The `self` parameter provides access to the GUI element: + +| Method | Description | +|--------|-------------| +| `self:id()` | Element's identifier | +| `self:x()`, `self:y()` | Current position | +| `self:width()`, `self:height()` | Current dimensions | +| `self:setPosition(x, y)` | Update position | +| `self:setDimensions(w, h)` | Update size | +| `self:setVisible(bool)` | Show/hide element | +| `self:parent()` | Parent LuaElement (or nil) | +| `self:child(index)` | Get child at index (0-based) | +| `self:childCount()` | Number of children | +| `self:getText()` | Get text content (text elements only) | +| `self:setText(text)` | Set text content (text elements only) | +| `self:closeScreen()` | Close the current screen | +| `self:minecraft()` | Get MinecraftData for world/player access | + +### Example GUI Script + +```lua +-- assets/mymod/script/button_handler.lua + +local clickCount = 0 + +function onInit(self) + -- Called when the script is attached to the button + print("Button initialized: " .. self:id()) +end + +function onClick(self, mouseX, mouseY, button) + clickCount = clickCount + 1 + + -- Update button text + for i = 0, self:childCount() - 1 do + local child = self:child(i) + if child:getText() then + child:setText("Clicks: " .. clickCount) + break + end + end + + -- Access Minecraft data + local mc = self:minecraft() + mc:sendMessage("Button clicked " .. clickCount .. " times!", false) + + -- Play a sound + mc:playSound("minecraft:ui.button.click", 1.0, 1.0) +end + +function onHover(self, mouseX, mouseY) + -- Called every frame while hovering +end + +function onRelease(self, mouseX, mouseY, button) + -- Called when mouse button is released +end +``` + +### Accessing World Data from GUI + +Use `self:minecraft()` to access the full [Minecraft API](LUA_SCRIPTING.md#minecraft-api-reference): + +```lua +function onClick(self, mouseX, mouseY, button) + local mc = self:minecraft() + + -- Get player info + local player = mc:player() + local health = player:health() + + -- Check input + if mc:isKeyPressed("sneak") then + mc:sendMessage("Shift-clicked!", false) + end + + -- Run commands + mc:runCommand("/time set day") + + -- Close the screen + self:closeScreen() +end +``` + +--- + +## Displaying Screens + +### From Lua Scripts + +Use `mc:displayScreen(screenId)` to open a registered GUI: + +```lua +function onExecute(mc) + mc:displayScreen("mymod:main_menu") +end +``` + +### From Java + +```java +AmbleContainer screen = AmbleGuiRegistry.getInstance().get(new Identifier("mymod", "main_menu")); +if (screen != null) { + screen.display(); +} +``` + +--- + +## Complete Example + +### GUI Definition + +`assets/mymod/gui/example_menu.json`: + +```json +{ + "layout": [200, 150], + "background": { + "texture": "mymod:textures/gui/menu_bg.png", + "u": 0, "v": 0, + "regionWidth": 200, "regionHeight": 150, + "textureWidth": 256, "textureHeight": 256 + }, + "padding": 15, + "spacing": 8, + "alignment": ["centre", "start"], + "should_pause": true, + "children": [ + { + "layout": [170, 20], + "background": [0, 0, 0, 0], + "text": "gui.mymod.title", + "text_alignment": ["centre", "centre"] + }, + { + "layout": [120, 24], + "background": [80, 80, 80], + "hover_background": [100, 100, 100], + "press_background": [60, 60, 60], + "text": "gui.mymod.play", + "script": "mymod:play_button", + "requires_new_row": true + }, + { + "layout": [120, 24], + "background": [80, 80, 80], + "hover_background": [100, 100, 100], + "press_background": [60, 60, 60], + "text": "gui.mymod.quit", + "on_click": "/quit", + "requires_new_row": true + } + ] +} +``` + +### Attached Lua Script + +`assets/mymod/script/play_button.lua`: + +```lua +function onClick(self, mouseX, mouseY, button) + local mc = self:minecraft() + mc:sendMessage("Starting game...", false) + mc:playSound("minecraft:ui.button.click", 1.0, 1.0) + self:closeScreen() +end +``` + +### Opening the Screen + +`assets/mymod/script/open_menu.lua`: + +```lua +-- Run with: /amblescript execute mymod:open_menu + +function onExecute(mc) + mc:displayScreen("mymod:example_menu") +end +``` + +--- + +## See Also + +- [Lua Scripting System](LUA_SCRIPTING.md) - Full Lua API documentation +- [AmbleGuiRegistry](src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java) - Java registry source diff --git a/LUA_SCRIPTING.md b/LUA_SCRIPTING.md new file mode 100644 index 0000000..9a4c122 --- /dev/null +++ b/LUA_SCRIPTING.md @@ -0,0 +1,383 @@ +# Lua Scripting System + +AmbleKit includes a powerful Lua scripting engine (powered by LuaJ) that allows you to extend Minecraft functionality without writing Java code. Scripts can run on both the client and server sides. + +## Table of Contents +- [Script Types & Locations](#script-types--locations) +- [Commands](#commands) +- [Lifecycle Callbacks](#lifecycle-callbacks) +- [Minecraft API Reference](#minecraft-api-reference) +- [Entity API](#entity-api) +- [ItemStack API](#itemstack-api) +- [Example Scripts](#example-scripts) +- [GUI Integration](#gui-integration) + +--- + +## Script Types & Locations + +| Type | Location | Loaded From | +|------|----------|-------------| +| **Client Scripts** | `assets//script/*.lua` | Resource Packs | +| **Server Scripts** | `data//script/*.lua` | Data Packs | + +--- + +## Commands + +### Client-side (available to all players) +``` +/amblescript execute - Run a script's onExecute function +/amblescript enable - Enable a script (starts onTick loop) +/amblescript disable - Disable a running script +/amblescript toggle - Toggle script enabled state +/amblescript list - Show enabled scripts +/amblescript available - Show all available scripts +``` + +### Server-side (requires operator permissions) +``` +/serverscript execute - Run a script's onExecute function +/serverscript enable - Enable a script (starts onTick loop) +/serverscript disable - Disable a running script +/serverscript toggle - Toggle script enabled state +/serverscript list - Show enabled scripts +/serverscript available - Show all available scripts +``` + +--- + +## Lifecycle Callbacks + +Scripts can define the following callback functions. Each receives a `mc` (MinecraftData) parameter: + +| Callback | When Called | Use Case | +|----------|-------------|----------| +| `onExecute(mc)` | Via `/amblescript execute` or `/serverscript execute` | One-time actions, info displays | +| `onEnable(mc)` | When script is enabled | Initialize state, play sounds | +| `onTick(mc)` | Every game tick while enabled | Continuous monitoring, automation | +| `onDisable(mc)` | When script is disabled | Cleanup, final messages | + +--- + +## Minecraft API Reference + +The `mc` parameter provides access to Minecraft data. Methods vary by side: + +### Shared Methods (Client & Server) +| Method | Description | +|--------|-------------| +| `mc:isClientSide()` | Returns true if running on client | +| `mc:dimension()` | Current dimension ID (e.g., "minecraft:overworld") | +| `mc:worldTime()` | Current world time in ticks | +| `mc:dayCount()` | Number of days elapsed | +| `mc:isRaining()` / `mc:isThundering()` | Weather state | +| `mc:biomeAt(x, y, z)` | Biome ID at position | +| `mc:blockAt(x, y, z)` | Block ID at position | +| `mc:lightLevelAt(x, y, z)` | Light level at position | +| `mc:player()` | The executing player entity | +| `mc:entities()` | All entities in the world | +| `mc:nearestEntity(distance)` | Closest entity within range | +| `mc:entitiesInRadius(radius)` | All entities within radius | +| `mc:runCommand(command)` | Execute a command | +| `mc:sendMessage(text, overlay)` | Send message to player (overlay = action bar) | +| `mc:log(message)` | Log to console | + +### Client-Only Methods +| Method | Description | +|--------|-------------| +| `mc:username()` | Local player's username | +| `mc:selectedSlot()` | Currently selected hotbar slot (1-9) | +| `mc:selectSlot(slot)` | Set hotbar selection | +| `mc:dropStack(slot, entireStack)` | Drop item from inventory | +| `mc:isKeyPressed(key)` | Check if key is pressed ("forward", "jump", "w", etc.) | +| `mc:isMouseButtonPressed(button)` | Check mouse button ("left", "right", "middle") | +| `mc:gameMode()` | Current game mode | +| `mc:playSound(id, volume, pitch)` | Play a sound | +| `mc:lookingAtEntity()` | Entity in crosshairs (or nil) | +| `mc:lookingAtBlock()` | Block position in crosshairs (or nil) | +| `mc:clipboard()` / `mc:setClipboard(text)` | System clipboard access | +| `mc:displayScreen(screenId)` | Open a registered AmbleKit screen | +| `mc:closeScreen()` | Close current screen | + +### Server-Only Methods +| Method | Description | +|--------|-------------| +| `mc:allPlayers()` | List of all online player entities | +| `mc:allPlayerNames()` | List of all online player names | +| `mc:playerCount()` / `mc:maxPlayers()` | Player counts | +| `mc:getPlayerByName(name)` | Get player entity by name | +| `mc:broadcast(message)` | Send message to all players | +| `mc:broadcastToPlayer(name, msg, overlay)` | Send message to specific player | +| `mc:serverName()` | Server name | +| `mc:serverTps()` | Current server TPS | +| `mc:tickCount()` | Total server ticks | +| `mc:isDedicatedServer()` | True if dedicated server | +| `mc:runCommandAs(playerName, command)` | Run command as specific player | + +--- + +## Entity API + +When you get an entity via `mc:player()`, `mc:entities()`, etc., you can call: + +| Method | Description | +|--------|-------------| +| `entity:name()` | Display name | +| `entity:type()` | Entity type ID (e.g., "minecraft:player") | +| `entity:uuid()` | Entity UUID | +| `entity:isPlayer()` | True if player | +| `entity:position()` | Vec3d with x, y, z fields | +| `entity:blockPosition()` | BlockPos with x, y, z fields | +| `entity:health()` / `entity:maxHealth()` | Health values | +| `entity:velocity()` | Current velocity vector | +| `entity:yaw()` / `entity:pitch()` | Rotation | +| `entity:isAlive()` / `entity:isSneaking()` / `entity:isSprinting()` | State checks | +| `entity:isOnFire()` / `entity:isInvisible()` / `entity:isTouchingWater()` | Condition checks | +| `entity:inventory()` | List of ItemStacks | +| `entity:foodLevel()` / `entity:saturation()` | Hunger (players only) | +| `entity:experienceLevel()` / `entity:totalExperience()` | XP (players only) | +| `entity:effects()` | List of active effect IDs | +| `entity:hasEffect(effectId)` | Check for specific effect | +| `entity:armorValue()` | Total armor points | + +--- + +## ItemStack API + +ItemStacks from inventories provide: + +| Method | Description | +|--------|-------------| +| `item:id()` | Item ID (e.g., "minecraft:diamond_sword") | +| `item:name()` | Display name | +| `item:count()` / `item:maxCount()` | Stack counts | +| `item:damage()` / `item:maxDamage()` | Durability | +| `item:durabilityPercent()` | Remaining durability (0.0 - 1.0) | +| `item:isEmpty()` / `item:isStackable()` | Stack properties | +| `item:hasEnchantments()` | Has enchantments | +| `item:enchantments()` | List of "enchant_id:level" strings | +| `item:rarity()` | Item rarity | +| `item:isFood()` | Is food item | +| `item:hasNbt()` / `item:nbt()` | NBT data access | + +--- + +## Example Scripts + +### Simple Execute Script +Display world info on command: + +```lua +-- assets/mymod/script/world_info.lua +-- Run with: /amblescript execute mymod:world_info + +function onExecute(mc) + local player = mc:player() + local pos = player:blockPosition() + + mc:sendMessage("§6=== World Info ===", false) + mc:sendMessage("§7Dimension: §a" .. mc:dimension(), false) + mc:sendMessage("§7Day: §e" .. mc:dayCount(), false) + mc:sendMessage("§7Biome: §b" .. mc:biomeAt(pos.x, pos.y, pos.z), false) + mc:sendMessage("§7Light: §f" .. mc:lightLevelAt(pos.x, pos.y, pos.z), false) +end +``` + +### Tick-Based Script +Continuous monitoring with enable/disable: + +```lua +-- assets/mymod/script/health_monitor.lua +-- Enable with: /amblescript enable mymod:health_monitor +-- Disable with: /amblescript disable mymod:health_monitor + +local lastHealth = 0 + +function onEnable(mc) + lastHealth = mc:player():health() + mc:sendMessage("§aHealth monitor enabled!", false) +end + +function onTick(mc) + local health = mc:player():health() + if health < lastHealth then + mc:sendMessage("§cDamage taken! Health: " .. health, true) + if mc:isClientSide() then + mc:playSound("minecraft:entity.player.hurt", 0.5, 1.0) + end + end + lastHealth = health +end + +function onDisable(mc) + mc:sendMessage("§7Health monitor disabled.", false) +end +``` + +### Server Script +Admin broadcast utility: + +```lua +-- data/mymod/script/server_status.lua +-- Run with: /serverscript execute mymod:server_status + +function onExecute(mc) + local playerCount = mc:playerCount() + local maxPlayers = mc:maxPlayers() + local tps = string.format("%.1f", mc:serverTps()) + + mc:broadcast("§6[Server] §fPlayers: §e" .. playerCount .. "/" .. maxPlayers) + mc:broadcast("§6[Server] §fTPS: §a" .. tps) + mc:log("Server status broadcast by admin") +end +``` + +--- + +## GUI Integration + +Lua scripts integrate with AmbleKit's [JSON GUI System](GUI_SYSTEM.md) in two ways: + +1. **Opening screens** from scripts using `mc:displayScreen()` +2. **Handling GUI events** by attaching scripts to buttons + +### Opening GUI Screens from Scripts + +Use `mc:displayScreen(screenId)` to open any registered AmbleKit GUI: + +```lua +-- assets/mymod/script/open_menu.lua + +function onExecute(mc) + -- Open a GUI defined in assets/mymod/gui/my_menu.json + mc:displayScreen("mymod:my_menu") +end +``` + +### Attaching Scripts to GUI Elements + +In your JSON GUI definition, use the `script` property to attach a Lua script to a button: + +```json +{ + "layout": [80, 24], + "background": [100, 100, 100], + "hover_background": [150, 150, 150], + "script": "mymod:my_button_handler" +} +``` + +This loads `assets/mymod/script/my_button_handler.lua`. + +### GUI Callbacks + +GUI scripts use different callbacks than standalone scripts. Instead of `mc`, they receive `self` (a LuaElement wrapper): + +| Callback | When Called | Parameters | +|----------|-------------|------------| +| `onInit(self)` | When script is attached | `self` | +| `onClick(self, mouseX, mouseY, button)` | Mouse pressed on element | `self`, coordinates, button (0=left) | +| `onRelease(self, mouseX, mouseY, button)` | Mouse released on element | `self`, coordinates, button | +| `onHover(self, mouseX, mouseY)` | Mouse hovering over element | `self`, coordinates | + +### LuaElement API + +The `self` parameter provides access to the GUI element: + +| Method | Description | +|--------|-------------| +| `self:id()` | Element's identifier | +| `self:x()`, `self:y()` | Current position | +| `self:width()`, `self:height()` | Current dimensions | +| `self:setPosition(x, y)` | Update position | +| `self:setDimensions(w, h)` | Update size | +| `self:setVisible(bool)` | Show/hide element | +| `self:parent()` | Parent LuaElement (or nil) | +| `self:child(index)` | Get child at index (0-based) | +| `self:childCount()` | Number of children | +| `self:getText()` | Get text content (text elements only) | +| `self:setText(text)` | Set text content (text elements only) | +| `self:closeScreen()` | Close the current screen | +| `self:minecraft()` | Get MinecraftData for full API access | + +### Accessing Minecraft Data from GUI Scripts + +Use `self:minecraft()` to get a MinecraftData object with full API access: + +```lua +function onClick(self, mouseX, mouseY, button) + local mc = self:minecraft() + + -- Access player data + local player = mc:player() + local health = player:health() + + -- Play sounds + mc:playSound("minecraft:ui.button.click", 1.0, 1.0) + + -- Send messages + mc:sendMessage("Health: " .. health, false) + + -- Check input + if mc:isKeyPressed("sneak") then + mc:sendMessage("Shift-click detected!", false) + end +end +``` + +### Complete GUI Script Example + +```lua +-- assets/mymod/script/inventory_button.lua +-- Attached to a button in a JSON GUI + +local clickCount = 0 + +function onInit(self) + -- Initialize when script is attached to the button + print("Button initialized: " .. self:id()) +end + +function onClick(self, mouseX, mouseY, button) + clickCount = clickCount + 1 + local mc = self:minecraft() + + -- Update a child text element + for i = 0, self:childCount() - 1 do + local child = self:child(i) + if child:getText() then + child:setText("Clicked: " .. clickCount) + break + end + end + + -- Show player inventory info + local player = mc:player() + local inventory = player:inventory() + local itemCount = 0 + + for _, item in pairs(inventory) do + if not item:isEmpty() then + itemCount = itemCount + item:count() + end + end + + mc:sendMessage("You have " .. itemCount .. " items!", false) + mc:playSound("minecraft:ui.button.click", 1.0, 1.0) +end + +function onHover(self, mouseX, mouseY) + -- Called every frame while hovering (use sparingly) +end + +function onRelease(self, mouseX, mouseY, button) + -- Called when mouse button is released over element +end +``` + +--- + +## See Also + +- [JSON GUI System](GUI_SYSTEM.md) - Full GUI definition documentation diff --git a/README.md b/README.md index 2c4e71e..9a60e27 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,18 @@ There are more datagen utilities akin to this. ### Much more! +### Lua Scripting System + +Extend Minecraft with Lua scripts - no Java required! AmbleKit's built-in scripting engine lets you create client-side automation, server utilities, and GUI interactions using simple Lua code. Scripts load from resource packs (client) or data packs (server) and have full access to player data, world info, entities, inventories, and more. + +**[Read the full Lua Scripting documentation](LUA_SCRIPTING.md)** + +### JSON GUI System + +Build custom Minecraft screens entirely in JSON - no Java required! Define layouts, backgrounds (colors or textures), text elements, and interactive buttons with hover/press states. Attach Lua scripts to buttons for dynamic behavior like updating text, playing sounds, or accessing player data. GUIs load from resource packs and can be opened via Lua scripts or Java code. + +**[Read the full GUI System documentation](GUI_SYSTEM.md)** +

Where can I start with this? Date: Fri, 9 Jan 2026 02:10:45 +0000 Subject: [PATCH 22/37] Add skin system API, documentation, and example scripts --- .gitignore | 6 +- ANIMATION_SYSTEM.md | 449 +++++++++++++++++ LUA_SCRIPTING.md | 229 ++++++++- README.md | 90 +++- SKIN_SYSTEM.md | 475 ++++++++++++++++++ .../client/command/ClientScriptCommand.java | 20 +- .../lib/command/ServerScriptCommand.java | 21 +- .../lib/script/lua/ServerMinecraftData.java | 187 +++++++ .../assets/litmus/script/clipboard_demo.lua | 2 +- .../assets/litmus/script/entity_inspect.lua | 2 +- .../assets/litmus/script/hotbar_cycle.lua | 2 +- .../assets/litmus/script/input_test.lua | 2 +- .../assets/litmus/script/item_info.lua | 2 +- .../assets/litmus/script/player_state.lua | 2 +- .../resources/assets/litmus/script/stats.lua | 2 +- .../resources/assets/litmus/script/test.lua | 2 +- .../assets/litmus/script/world_info.lua | 2 +- .../data/litmus/script/admin_commands.lua | 2 +- .../data/litmus/script/server_status.lua | 2 +- .../data/litmus/script/skin_disguise.lua | 48 ++ .../data/litmus/script/skin_manager.lua | 97 ++++ .../data/litmus/script/skin_party.lua | 63 +++ .../resources/data/litmus/script/skin_set.lua | 59 +++ .../data/litmus/script/skin_team.lua | 155 ++++++ .../data/litmus/script/skin_url_test.lua | 122 +++++ 25 files changed, 1992 insertions(+), 51 deletions(-) create mode 100644 ANIMATION_SYSTEM.md create mode 100644 SKIN_SYSTEM.md create mode 100644 src/test/resources/data/litmus/script/skin_disguise.lua create mode 100644 src/test/resources/data/litmus/script/skin_manager.lua create mode 100644 src/test/resources/data/litmus/script/skin_party.lua create mode 100644 src/test/resources/data/litmus/script/skin_set.lua create mode 100644 src/test/resources/data/litmus/script/skin_team.lua create mode 100644 src/test/resources/data/litmus/script/skin_url_test.lua diff --git a/.gitignore b/.gitignore index faa687c..bbb1918 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,8 @@ run/ src/main/generated/.cache src/main/generated -.direnv \ No newline at end of file +.direnv + +# cursor + +.cursorrules \ No newline at end of file diff --git a/ANIMATION_SYSTEM.md b/ANIMATION_SYSTEM.md new file mode 100644 index 0000000..8f8d92a --- /dev/null +++ b/ANIMATION_SYSTEM.md @@ -0,0 +1,449 @@ +# Bedrock Animation System + +AmbleKit provides a powerful Bedrock Edition animation system that lets you use Blockbench-style geometry and animations on your entities and block entities. This system supports the standard Bedrock model/animation JSON format, making it easy to create and import complex animated models. + +## Table of Contents +- [Overview](#overview) +- [Model Format](#model-format) +- [Animation Format](#animation-format) +- [Setting Up Animated Entities](#setting-up-animated-entities) +- [Setting Up Animated Block Entities](#setting-up-animated-block-entities) +- [Playing Animations](#playing-animations) +- [Animation Features](#animation-features) +- [Commands](#commands) +- [File Locations](#file-locations) + +--- + +## Overview + +The Bedrock Animation System provides: +- **Bedrock Model Support** - Load `.geo.json` models from Blockbench +- **Bedrock Animation Support** - Load `.animation.json` animation files +- **Automatic Renderer Registration** - Use `@HasBedrockModel` annotation for automatic setup +- **Sound Event Integration** - Play sounds at specific animation keyframes +- **Animation Metadata** - Control movement and other behaviors during animations +- **Looping & One-Shot** - Support for both looping and single-play animations + +--- + +## Model Format + +AmbleKit uses the standard Bedrock Edition geometry format (`format_version` 1.12.0+). + +### Model File Structure + +Models should be placed in your resource pack: +``` +assets//geo/.geo.json +``` + +### Example Model File + +```json +{ + "format_version": "1.12.0", + "minecraft:geometry": [ + { + "description": { + "identifier": "geometry.my_entity", + "texture_width": 64, + "texture_height": 64, + "visible_bounds_width": 2, + "visible_bounds_height": 3, + "visible_bounds_offset": [0, 1.5, 0] + }, + "bones": [ + { + "name": "root", + "pivot": [0, 0, 0] + }, + { + "name": "body", + "parent": "root", + "pivot": [0, 24, 0], + "cubes": [ + { + "origin": [-4, 12, -2], + "size": [8, 12, 4], + "uv": [16, 16] + } + ] + }, + { + "name": "head", + "parent": "body", + "pivot": [0, 24, 0], + "cubes": [ + { + "origin": [-4, 24, -4], + "size": [8, 8, 8], + "uv": [0, 0] + } + ] + } + ] + } + ] +} +``` + +### Supported Model Features + +| Feature | Description | +|---------|-------------| +| **Bones** | Hierarchical bone structure with parent-child relationships | +| **Cubes** | Box-shaped geometry with position, size, UV mapping | +| **Pivots** | Rotation pivot points for bones | +| **Rotation** | Default bone rotations | +| **Mirroring** | UV mirroring for symmetric parts | +| **Inflate** | Expansion/contraction of cubes | +| **Locators** | Named positions for particle/effect spawning | + +--- + +## Animation Format + +AmbleKit uses the standard Bedrock Edition animation format. + +### Animation File Structure + +Animations should be placed in your resource pack: +``` +assets//animations/.animation.json +``` + +### Example Animation File + +```json +{ + "format_version": "1.8.0", + "animations": { + "animation.my_entity.walk": { + "loop": true, + "animation_length": 1.0, + "bones": { + "right_leg": { + "rotation": { + "0.0": [22.5, 0, 0], + "0.5": [-22.5, 0, 0], + "1.0": [22.5, 0, 0] + } + }, + "left_leg": { + "rotation": { + "0.0": [-22.5, 0, 0], + "0.5": [22.5, 0, 0], + "1.0": [-22.5, 0, 0] + } + } + } + }, + "animation.my_entity.attack": { + "loop": false, + "animation_length": 0.5, + "bones": { + "right_arm": { + "rotation": { + "0.0": [0, 0, 0], + "0.25": [-90, 0, 0], + "0.5": [0, 0, 0] + } + } + } + } + } +} +``` + +### Keyframe Interpolation + +| Type | Description | +|------|-------------| +| **Linear** | Default smooth interpolation between keyframes | +| **Smooth (Catmull-Rom)** | Extra-smooth transitions using catmull-rom splines | + +### Animation Properties + +| Property | Description | +|----------|-------------| +| `loop` | Whether the animation repeats (`true`/`false`) | +| `animation_length` | Duration in seconds | +| `bones` | Map of bone names to transformation timelines | + +### Bone Transformation Channels + +| Channel | Description | +|---------|-------------| +| `position` | Translate the bone (x, y, z) | +| `rotation` | Rotate the bone (pitch, yaw, roll in degrees) | +| `scale` | Scale the bone (x, y, z multipliers) | + +--- + +## Setting Up Animated Entities + +### Step 1: Implement AnimatedEntity + +Make your entity implement `AnimatedEntity`: + +```java +public class MyEntity extends LivingEntity implements AnimatedEntity { + private final AnimationState animationState = new AnimationState(); + + @Override + public AnimationState getAnimationState() { + return animationState; + } + + @Override + public BedrockModelReference getModel() { + return new BedrockModelReference(new Identifier("mymod", "geo/my_entity.geo.json")); + } + + @Override + public Identifier getTexture() { + return new Identifier("mymod", "textures/entity/my_entity.png"); + } + + @Override + public BedrockAnimationReference getDefaultAnimation() { + return BedrockAnimationReference.parse(new Identifier("mymod", "my_entity.walk")); + } +} +``` + +### Step 2: Register with @HasBedrockModel + +In your `EntityContainer`, annotate the entity type with `@HasBedrockModel`: + +```java +public class MyEntities implements EntityContainer { + @HasBedrockModel + public static final EntityType MY_ENTITY = EntityType.Builder + .create(MyEntity::new, SpawnGroup.CREATURE) + .setDimensions(0.6f, 1.8f) + .build("my_entity"); +} +``` + +The renderer will be automatically registered on the client side. + +--- + +## Setting Up Animated Block Entities + +### Step 1: Implement AnimatedBlockEntity + +```java +public class MyBlockEntity extends BlockEntity implements AnimatedBlockEntity { + private final AnimationState animationState = new AnimationState(); + + @Override + public AnimationState getAnimationState() { + return animationState; + } + + @Override + public BedrockModelReference getModel() { + return new BedrockModelReference(new Identifier("mymod", "geo/my_block.geo.json")); + } + + @Override + public Identifier getTexture() { + return new Identifier("mymod", "textures/block/my_block.png"); + } +} +``` + +### Step 2: Register the Renderer + +Use `BedrockBlockEntityRenderer` for your block entity: + +```java +BlockEntityRendererRegistry.register(MY_BLOCK_ENTITY, BedrockBlockEntityRenderer::new); +``` + +--- + +## Playing Animations + +### Via Java Code + +```java +// Get an AnimatedEntity +AnimatedEntity entity = ...; + +// Create an animation reference +BedrockAnimationReference animation = BedrockAnimationReference.parse( + new Identifier("mymod", "my_entity.attack") +); + +// Play the animation +entity.playAnimation(animation); +``` + +### Animation Reference Format + +Animation references follow this format: +``` +namespace:animation_file.animation_name +``` + +For example: +- `mymod:my_entity.walk` → loads `animation.my_entity.walk` from `assets/mymod/animations/my_entity.animation.json` + +--- + +## Animation Features + +### Sound Events + +Add sounds to play at specific times during animations: + +```json +{ + "animations": { + "animation.my_entity.attack": { + "animation_length": 0.5, + "sound_effects": { + "0.0": { + "effect": "minecraft:entity.player.attack.sweep" + }, + "0.25": { + "effect": "mymod:custom_sound" + } + } + } + } +} +``` + +### Animation Metadata + +Control entity behavior during animations: + +```java +// In your AnimatedEntity implementation +@Override +public AnimationMetadata getAnimationMetadata() { + return new AnimationMetadata( + false // movement: false = freeze entity during animation + ); +} +``` + +### Locators + +Define named positions in your model for spawning particles or effects: + +```json +{ + "bones": [ + { + "name": "right_arm", + "locators": { + "hand": { + "offset": [0, -10, 0], + "rotation": [0, 0, 0] + } + } + } + ] +} +``` + +--- + +## Commands + +### Play Animation Command + +Operators can play animations on entities via command: + +``` +/amblekit animation +``` + +| Argument | Description | +|----------|-------------| +| `target` | Entity selector (e.g., `@e[type=mymod:my_entity,limit=1]`) | +| `animation_id` | Animation identifier (e.g., `mymod:my_entity.attack`) | + +**Examples:** +``` +/amblekit animation @e[type=mymod:my_entity,limit=1] mymod:my_entity.attack +/amblekit animation @s mymod:player.wave +``` + +--- + +## File Locations + +### Resource Pack Structure + +``` +assets// +├── geo/ +│ └── my_entity.geo.json # Model geometry +├── animations/ +│ └── my_entity.animation.json # Animation data +└── textures/ + └── entity/ + └── my_entity.png # Entity texture +``` + +### Automatic Registration + +When using `@HasBedrockModel`: +- The model is loaded from `assets//geo/.geo.json` +- Animations are loaded from `assets//animations/.animation.json` +- Textures should be at `assets//textures/entity/.png` + +--- + +## Advanced Usage + +### Custom Renderers + +For advanced rendering needs, extend `BedrockEntityRenderer`: + +```java +public class MyCustomRenderer extends BedrockEntityRenderer { + public MyCustomRenderer(EntityRendererFactory.Context ctx) { + super(ctx); + } + + @Override + protected void setupAnimations(MyEntity entity, ModelPart root, float tickDelta) { + super.setupAnimations(entity, root, tickDelta); + // Custom animation logic + } +} +``` + +### Animation Tracking + +Query animation state programmatically: + +```java +AnimatedEntity entity = ...; + +// Check if currently animating +BedrockAnimationReference current = entity.getCurrentAnimation(); +if (current != null) { + // Animation is playing +} + +// Check if animation state has changed +if (entity.isAnimationDirty()) { + // Handle animation change +} +``` + +--- + +## See Also + +- [Lua Scripting System](LUA_SCRIPTING.md) - Trigger animations from Lua scripts +- [Registry Containers](README.md#minecraft-registration) - Automatic entity registration diff --git a/LUA_SCRIPTING.md b/LUA_SCRIPTING.md index 9a4c122..a14a91f 100644 --- a/LUA_SCRIPTING.md +++ b/LUA_SCRIPTING.md @@ -27,22 +27,40 @@ AmbleKit includes a powerful Lua scripting engine (powered by LuaJ) that allows ### Client-side (available to all players) ``` -/amblescript execute - Run a script's onExecute function -/amblescript enable - Enable a script (starts onTick loop) -/amblescript disable - Disable a running script -/amblescript toggle - Toggle script enabled state -/amblescript list - Show enabled scripts -/amblescript available - Show all available scripts +/amblescript execute [args...] - Run a script's onExecute function with optional arguments +/amblescript enable - Enable a script (starts onTick loop) +/amblescript disable - Disable a running script +/amblescript toggle - Toggle script enabled state +/amblescript list - Show enabled scripts +/amblescript available - Show all available scripts ``` ### Server-side (requires operator permissions) ``` -/serverscript execute - Run a script's onExecute function -/serverscript enable - Enable a script (starts onTick loop) -/serverscript disable - Disable a running script -/serverscript toggle - Toggle script enabled state -/serverscript list - Show enabled scripts -/serverscript available - Show all available scripts +/serverscript execute [args...] - Run a script's onExecute function with optional arguments +/serverscript enable - Enable a script (starts onTick loop) +/serverscript disable - Disable a running script +/serverscript toggle - Toggle script enabled state +/serverscript list - Show enabled scripts +/serverscript available - Show all available scripts +``` + +### Command Arguments + +The `execute` command accepts optional space-separated arguments that are passed to the script's `onExecute` function as a Lua table: + +``` +/serverscript execute mymod:my_script arg1 arg2 arg3 +``` + +In the script, access arguments via the second parameter: + +```lua +function onExecute(mc, args) + if args[1] then + mc:sendMessage("First argument: " .. args[1], false) + end +end ``` --- @@ -53,11 +71,13 @@ Scripts can define the following callback functions. Each receives a `mc` (Minec | Callback | When Called | Use Case | |----------|-------------|----------| -| `onExecute(mc)` | Via `/amblescript execute` or `/serverscript execute` | One-time actions, info displays | +| `onExecute(mc, args)` | Via `/amblescript execute` or `/serverscript execute` | One-time actions, parameterized commands | | `onEnable(mc)` | When script is enabled | Initialize state, play sounds | | `onTick(mc)` | Every game tick while enabled | Continuous monitoring, automation | | `onDisable(mc)` | When script is disabled | Cleanup, final messages | +The `args` parameter in `onExecute` is a Lua table containing space-separated arguments from the command (1-indexed, may be empty). + --- ## Minecraft API Reference @@ -115,6 +135,22 @@ The `mc` parameter provides access to Minecraft data. Methods vary by side: | `mc:isDedicatedServer()` | True if dedicated server | | `mc:runCommandAs(playerName, command)` | Run command as specific player | +### Skin Management (Server-Only) + +All skin methods return `true` on success, `false` on failure. + +| Method | Description | +|--------|-------------| +| `mc:setSkin(playerName, skinUsername)` | Set player's skin to another player's skin | +| `mc:setSkinUrl(playerName, url, slim)` | Set player's skin from URL (slim: true/false) | +| `mc:setSkinSlim(playerName, slim)` | Change arm model without changing texture | +| `mc:clearSkin(playerName)` | Remove custom skin, restore original | +| `mc:hasSkin(playerName)` | Check if player has a custom skin | +| `mc:setSkinByUuid(uuid, skinUsername)` | Set skin by UUID string | +| `mc:setSkinUrlByUuid(uuid, url, slim)` | Set skin from URL by UUID string | +| `mc:clearSkinByUuid(uuid)` | Clear skin by UUID string | +| `mc:hasSkinByUuid(uuid)` | Check if entity has custom skin by UUID | + --- ## Entity API @@ -165,6 +201,46 @@ ItemStacks from inventories provide: ## Example Scripts +### Script with Arguments +Set a player's skin with command arguments: + +```lua +-- data/mymod/script/skin_set.lua +-- Usage: +-- /serverscript execute mymod:skin_set Notch true +-- /serverscript execute mymod:skin_set Notch false duzo + +function onExecute(mc, args) + -- Validate arguments + if args == nil or #args < 2 then + mc:sendMessage("§cUsage: /serverscript execute mymod:skin_set [target_player]", false) + return + end + + local skinUsername = args[1] + local slim = args[2] == "true" + local targetPlayer = args[3] + + -- If no target player specified, use the executing player + if targetPlayer == nil then + local player = mc:player() + if player == nil then + mc:sendMessage("§cNo player context!", false) + return + end + targetPlayer = player:name() + end + + -- Apply the skin + if mc:setSkin(targetPlayer, skinUsername) then + mc:sendMessage("§aSkin applied to " .. targetPlayer .. "!", false) + mc:setSkinSlim(targetPlayer, slim) + else + mc:sendMessage("§cFailed to apply skin!", false) + end +end +``` + ### Simple Execute Script Display world info on command: @@ -172,7 +248,7 @@ Display world info on command: -- assets/mymod/script/world_info.lua -- Run with: /amblescript execute mymod:world_info -function onExecute(mc) +function onExecute(mc, args) local player = mc:player() local pos = player:blockPosition() @@ -222,7 +298,7 @@ Admin broadcast utility: -- data/mymod/script/server_status.lua -- Run with: /serverscript execute mymod:server_status -function onExecute(mc) +function onExecute(mc, args) local playerCount = mc:playerCount() local maxPlayers = mc:maxPlayers() local tps = string.format("%.1f", mc:serverTps()) @@ -233,6 +309,128 @@ function onExecute(mc) end ``` +### Skin Management Script +Change player skins on the server: + +```lua +-- data/mymod/script/disguise.lua +-- Run with: /serverscript execute mymod:disguise +-- Or with args: /serverscript execute mymod:disguise Herobrine + +function onExecute(mc, args) + local player = mc:player() + if player == nil then + mc:log("No player context for this script") + return + end + + local playerName = player:name() + local skinName = args[1] or "Notch" -- Default to Notch if no argument provided + + -- Set the player's skin to look like the specified user (returns true/false) + if mc:setSkin(playerName, skinName) then + mc:broadcastToPlayer(playerName, "§aYou are now disguised as " .. skinName .. "!", false) + else + mc:broadcastToPlayer(playerName, "§cFailed to apply disguise!", false) + end +end + +-- Example: Disguise all players as the same skin +function disguiseAll(mc, skinUsername) + local success = 0 + for _, playerName in pairs(mc:allPlayerNames()) do + if mc:setSkin(playerName, skinUsername) then + success = success + 1 + end + end + mc:broadcast("§eDisguised " .. success .. " players!") +end + +-- Example: Clear all disguises +function clearAllDisguises(mc) + for _, playerName in pairs(mc:allPlayerNames()) do + if mc:hasSkin(playerName) then + mc:clearSkin(playerName) + end + end + mc:broadcast("§7All disguises have been removed.") +end +``` + +### Skin from URL Example +Apply custom skins from URLs: + +```lua +-- data/mymod/script/custom_skin.lua +-- Run with: /serverscript execute mymod:custom_skin [slim] +-- Example: /serverscript execute mymod:custom_skin https://example.com/skin.png true + +function onExecute(mc, args) + local player = mc:player() + if player == nil then return end + + local playerName = player:name() + local skinUrl = args[1] or "https://example.com/skins/custom_skin.png" + local slim = args[2] == "true" + + -- Set skin from URL with slim (Alex-style) arms + if mc:setSkinUrl(playerName, skinUrl, slim) then + mc:broadcastToPlayer(playerName, "§aCustom skin applied!", false) + else + mc:broadcastToPlayer(playerName, "§cFailed to apply skin!", false) + end +end + +-- Toggle between slim and wide arm models +function toggleSlimArms(mc) + local player = mc:player() + if player == nil then return end + + local playerName = player:name() + + if mc:hasSkin(playerName) then + if mc:setSkinSlim(playerName, true) then + mc:broadcastToPlayer(playerName, "§7Switched to slim arms.", false) + end + else + mc:broadcastToPlayer(playerName, "§cYou don't have a custom skin!", false) + end +end +``` + +### UUID-Based Skin Management +Use UUIDs directly for non-player entities or stored references: + +```lua +-- data/mymod/script/npc_skins.lua +-- Run with: /serverscript execute mymod:npc_skins [skin_username] + +function onExecute(mc, args) + local player = mc:player() + if player == nil then return end + + -- Get the player's UUID + local uuid = player:uuid() + local skinName = args[1] or "Herobrine" + + -- Set skin using UUID string + if mc:setSkinByUuid(uuid, skinName) then + mc:sendMessage("§cSkin applied via UUID!", false) + end +end + +-- Apply skin to a stored NPC UUID +function applyNpcSkin(mc) + local npcUuid = "550e8400-e29b-41d4-a716-446655440000" -- example UUID + + if mc:setSkinUrlByUuid(npcUuid, "https://example.com/npc_skin.png", false) then + mc:log("NPC skin updated successfully") + else + mc:logWarn("Failed to update NPC skin") + end +end +``` + --- ## GUI Integration @@ -381,3 +579,4 @@ end ## See Also - [JSON GUI System](GUI_SYSTEM.md) - Full GUI definition documentation +- [Dynamic Skin System](SKIN_SYSTEM.md) - Skin commands, Java API, and persistence diff --git a/README.md b/README.md index 9a60e27..427795b 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,29 @@ By simply creating an instance of `AmbleLanguageProvider` and passing in your `B There are more datagen utilities akin to this. -### Much more! +### Bedrock Animation System + +Use Blockbench models and animations in your Fabric mods! AmbleKit supports the Bedrock Edition geometry and animation JSON formats, making it easy to import complex animated models. Features include: + +- **Bedrock Model Support** - Load `.geo.json` models directly from Blockbench +- **Bedrock Animations** - Full keyframe animation support with looping and one-shot modes +- **Sound Integration** - Play sounds at specific animation keyframes +- **Automatic Registration** - Use `@HasBedrockModel` annotation for zero-config setup +- **Commands** - Play animations via `/amblekit animation` command + +**[Read the full Animation System documentation](ANIMATION_SYSTEM.md)** + +### Dynamic Skin System + +Change player and entity skins at runtime! Perfect for NPCs, disguises, and roleplay servers: + +- **Multiple Sources** - Load skins by username or direct URL +- **Slim/Wide Support** - Both Alex and Steve arm models +- **Automatic Sync** - Skins sync to all clients automatically +- **Persistent Storage** - Skins persist across server restarts +- **Commands** - Manage skins via `/amblekit skin` command + +**[Read the full Skin System documentation](SKIN_SYSTEM.md)** ### Lua Scripting System @@ -63,6 +85,46 @@ Build custom Minecraft screens entirely in JSON - no Java required! Define layou **[Read the full GUI System documentation](GUI_SYSTEM.md)** +### Block Behavior System + +Build modular, composable blocks with reusable behaviors: + +- **Horizontal Facing** - Easy directional block placement +- **Block Entities** - Simplified block entity integration +- **Render Behaviors** - Control block rendering (invisible blocks, etc.) +- **Composable Design** - Mix and match behaviors as needed + +### Extended Registry Containers + +Beyond just blocks and items, register any Minecraft content type: + +| Container | Description | +|-----------|-------------| +| `BlockContainer` | Blocks with automatic BlockItem registration | +| `ItemContainer` | Standalone items | +| `EntityContainer` | Entity types with automatic bedrock renderer support | +| `BlockEntityContainer` | Block entity types | +| `SoundContainer` | Sound events | +| `FluidContainer` | Fluid types | +| `PaintingContainer` | Painting variants | +| `ItemGroupContainer` | Creative mode tabs | + +### Extended Data Generation + +Comprehensive datagen utilities beyond translations: + +| Provider | Features | +|----------|----------| +| `AmbleLanguageProvider` | Automatic translations from identifiers | +| `AmbleModelProvider` | Block/item model generation with `@AutomaticModel` | +| `AmbleRecipeProvider` | Shaped, shapeless, stonecutting, smithing, blasting recipes | +| `AmbleAdvancementProvider` | Fluent API for advancement trees | +| `AmbleSoundProvider` | Sound definition generation | +| `AmbleBlockTagProvider` | Block tags with mineable annotations (`@PickaxeMineable`, etc.) | +| `AmbleBlockLootTable` | Block loot table generation | + +### Much more! +

Where can I start with this? [!IMPORTANT] +> We have moved away from JitPack to our own maven repository. If you were using JitPack previously, please update your configuration. See [Issue #55](https://github.com/amblelabs/modkit/issues/55) for more details. - ``` + ```groovy repositories { maven { - url "https://jitpack.io" - - metadataSources { - artifact() // Look directly for artifact - } + url "https://amblelabs.dev/maven" } } dependencies { - modImplementation("com.github.amblelabs:modkit:${project.modkit_version}") { + modImplementation("dev.amble:lib:${project.amblekit_version}") { exclude(group: "net.fabricmc.fabric-api") } } ``` or if you are using kotlin - ``` - repositories { + ```kotlin + repositories { maven { - url = uri("https://jitpack.io") - metadataSources { - artifact() // Look directly for artifact - } + url = uri("https://amblelabs.dev/maven") } mavenCentral() } - - + dependencies { - modImplementation("com.github.amblelabs:modkit:${project.property("modkit_version")}") + modImplementation("dev.amble:lib:${project.property("amblekit_version")}") } ``` diff --git a/SKIN_SYSTEM.md b/SKIN_SYSTEM.md new file mode 100644 index 0000000..dab5635 --- /dev/null +++ b/SKIN_SYSTEM.md @@ -0,0 +1,475 @@ +# Dynamic Skin System + +AmbleKit provides a dynamic player skin system that allows you to change player skins at runtime. This is useful for NPCs, disguises, custom player appearances, and roleplay servers. + +## Table of Contents +- [Overview](#overview) +- [Commands](#commands) +- [Java API](#java-api) +- [Skin Sources](#skin-sources) +- [Persistence](#persistence) +- [Integration](#integration) + +--- + +## Overview + +The Dynamic Skin System provides: +- **Runtime Skin Changes** - Change player/entity skins without relogging +- **Multiple Sources** - Load skins by username or direct URL +- **Slim/Wide Models** - Support for both Alex (slim) and Steve (wide) arm models +- **Server Synchronization** - Skins sync automatically to all connected clients +- **Persistent Storage** - Skins persist across server restarts +- **Entity Support** - Works with any entity implementing `PlayerSkinTexturable` + +--- + +## Commands + +All skin commands require operator permissions (level 2). + +### Set Skin by Username + +Copy another player's skin: + +``` +/amblekit skin set +``` + +**Example:** +``` +/amblekit skin @p set Notch +``` + +### Set Skin by URL + +Load a skin from a direct image URL: + +``` +/amblekit skin slim +``` + +| Parameter | Description | +|-----------|-------------| +| `target` | Entity to modify | +| `slim` | `true` for slim arms (Alex), `false` for wide arms (Steve) | +| `url` | Direct URL to a skin image (PNG) | + +**Example:** +``` +/amblekit skin @p slim false https://example.com/skins/custom_skin.png +``` + +### Toggle Slim Arms + +Change the arm model without changing the skin: + +``` +/amblekit skin slim +``` + +**Example:** +``` +/amblekit skin @p slim true +``` + +### Clear/Reset Skin + +Remove custom skin and restore the original: + +``` +/amblekit skin clear +``` + +**Example:** +``` +/amblekit skin @p clear +``` + +--- + +## Java API + +### Setting Skins Programmatically + +```java +import dev.amble.lib.skin.SkinData; +import dev.amble.lib.skin.SkinTracker; + +// Get the target entity's UUID +UUID targetUuid = player.getUuid(); + +// Set skin by username (async lookup) +SkinData.username("Notch", skinData -> { + skinData.upload(targetUuid); +}); + +// Set skin by username with specific arm model +SkinData data = SkinData.username("Notch", true); // slim = true +SkinTracker.getInstance().putSynced(targetUuid, data); + +// Set skin by URL +SkinData urlSkin = SkinData.url("https://example.com/skin.png", false); // slim = false +SkinTracker.getInstance().putSynced(targetUuid, urlSkin); +``` + +### Clearing Skins + +```java +// Remove custom skin +SkinTracker.getInstance().removeSynced(player.getUuid()); +``` + +### Querying Skins + +```java +import dev.amble.lib.skin.SkinTracker; +import dev.amble.lib.skin.SkinData; + +// Check if entity has custom skin +Optional skin = SkinTracker.getInstance().getOptional(player.getUuid()); + +if (skin.isPresent()) { + SkinData data = skin.get(); + // Entity has a custom skin +} +``` + +### Implementing PlayerSkinTexturable + +To make your own entities support dynamic skins, implement `PlayerSkinTexturable`: + +```java +public class MyNpcEntity extends LivingEntity implements PlayerSkinTexturable { + + @Override + public UUID getUuid() { + return super.getUuid(); + } + + // The skin system will automatically apply skins to entities + // implementing this interface +} +``` + +--- + +## Skin Sources + +### Username-Based Skins + +When you set a skin by username, AmbleKit: +1. Looks up the player's UUID via Mojang API +2. Retrieves their skin data +3. Caches the result for performance + +```java +// Async version (recommended for usernames) +SkinData.username("PlayerName", result -> { + result.upload(targetUuid); +}); + +// Sync version (use with caution - may block) +SkinData data = SkinData.username("PlayerName", false); +``` + +### URL-Based Skins + +Direct URL skins load from any accessible image URL: + +```java +SkinData data = SkinData.url("https://example.com/skin.png", false); +SkinTracker.getInstance().putSynced(targetUuid, data); +``` + +**Requirements:** +- URL must be publicly accessible +- Image should be a valid Minecraft skin (64x64 or 64x32 PNG) +- HTTPS is recommended + +### Arm Model Types + +| Model | Description | Common Use | +|-------|-------------|------------| +| **Wide** (`slim = false`) | Classic Steve-style arms (4px wide) | Default male characters | +| **Slim** (`slim = true`) | Alex-style arms (3px wide) | Default female characters | + +```java +// Change arm model of existing skin +SkinData existingSkin = SkinTracker.getInstance().get(uuid); +if (existingSkin != null) { + SkinData newData = existingSkin.withSlim(true); + SkinTracker.getInstance().putSynced(uuid, newData); +} +``` + +--- + +## Persistence + +### Automatic Saving + +Skins are automatically saved when the server stops: +- Storage location: `/amblekit/skins.json` +- Format: JSON map of UUID to skin data + +### Automatic Loading + +On server start, skins are loaded and synced to all connecting players. + +### Manual Sync + +Force sync all skins to players: + +```java +// Sync to all players +SkinTracker.getInstance().sync(); + +// Sync to specific player +SkinTracker.getInstance().sync(serverPlayerEntity); +``` + +--- + +## Integration + +### With Lua Scripts (Server-Side) + +Server-side Lua scripts have direct access to the skin management API: + +```lua +-- data/mymod/script/disguise_manager.lua +-- Run with: /serverscript execute mymod:disguise_manager + +function onExecute(mc) + local player = mc:player() + if player == nil then return end + + local playerName = player:name() + + -- Set player's skin to another player's skin + mc:setSkin(playerName, "Notch") + mc:broadcastToPlayer(playerName, "§aDisguised as Notch!", false) +end +``` + +#### Server-Side Skin API Methods + +All skin methods return `true` on success, `false` on failure (player not found, invalid UUID, etc.). + +**By Player Name:** +| Method | Description | +|--------|-------------| +| `mc:setSkin(playerName, skinUsername)` | Set player's skin to another player's skin | +| `mc:setSkinUrl(playerName, url, slim)` | Set skin from URL (slim = true for Alex arms) | +| `mc:setSkinSlim(playerName, slim)` | Change arm model (true = slim/Alex, false = wide/Steve) | +| `mc:clearSkin(playerName)` | Remove custom skin, restore original | +| `mc:hasSkin(playerName)` | Check if player has a custom skin | + +**By UUID String:** +| Method | Description | +|--------|-------------| +| `mc:setSkinByUuid(uuid, skinUsername)` | Set skin by UUID string | +| `mc:setSkinUrlByUuid(uuid, url, slim)` | Set skin from URL by UUID string | +| `mc:clearSkinByUuid(uuid)` | Clear skin by UUID string | +| `mc:hasSkinByUuid(uuid)` | Check if entity has custom skin by UUID | + +#### Complete Example: Disguise System + +```lua +-- data/mymod/script/disguise_system.lua + +-- Disguise player as another username +function onExecute(mc) + local player = mc:player() + if player == nil then + mc:log("Script requires player context") + return + end + + local playerName = player:name() + if mc:setSkin(playerName, "Herobrine") then + mc:broadcastToPlayer(playerName, "§cYou are now disguised as Herobrine!", false) + mc:log("Player " .. playerName .. " disguised as Herobrine") + else + mc:logWarn("Failed to disguise player " .. playerName) + end +end + +-- Tick function to auto-disguise players on join (when enabled) +local lastPlayerCount = 0 +function onTick(mc) + local currentCount = mc:playerCount() + if currentCount > lastPlayerCount then + -- New player joined, could auto-apply skins here + mc:log("Player count changed: " .. lastPlayerCount .. " -> " .. currentCount) + end + lastPlayerCount = currentCount +end + +function onDisable(mc) + -- Clear all custom skins when script is disabled + for _, name in pairs(mc:allPlayerNames()) do + if mc:hasSkin(name) then + mc:clearSkin(name) + end + end + mc:broadcast("§7All disguises removed.") +end +``` + +#### Using URL Skins + +```lua +-- Apply custom skin from URL +function applyCustomSkin(mc) + local player = mc:player() + if player == nil then return end + + local playerName = player:name() + local url = "https://example.com/skins/custom.png" + + -- Use slim arms (Alex model) + if mc:setSkinUrl(playerName, url, true) then + mc:broadcastToPlayer(playerName, "§aCustom skin applied!", false) + else + mc:broadcastToPlayer(playerName, "§cFailed to apply skin!", false) + end +end +``` + +#### UUID-Based Skin Changes + +For NPCs or entities stored by UUID, use UUID-based methods: + +```lua +function onExecute(mc) + -- Get all players and apply skins using their UUIDs + local players = mc:allPlayers() + + for _, player in pairs(players) do + local uuid = player:uuid() + if not mc:hasSkinByUuid(uuid) then + if mc:setSkinByUuid(uuid, "Steve") then + mc:log("Applied default skin to " .. player:name()) + end + end + end +end + +-- Apply skin to a stored NPC by UUID +function applyNpcSkin(mc) + local npcUuid = "550e8400-e29b-41d4-a716-446655440000" + + if mc:setSkinUrlByUuid(npcUuid, "https://example.com/npc.png", false) then + mc:log("NPC skin updated") + else + mc:logWarn("Failed to update NPC skin - invalid UUID?") + end +end +``` + +### With Client-Side Scripts + +Client-side scripts can trigger skin changes via commands: + +```lua +-- assets/mymod/script/request_skin.lua +-- Run with: /amblescript execute mymod:request_skin + +function onExecute(mc) + -- Client scripts use commands (requires server permission) + mc:runCommand("/amblekit skin @p set Notch") + mc:sendMessage("§7Skin change requested!", false) +end +``` + +> **Note:** Direct skin API methods (`setSkin`, `clearSkin`, etc.) are only available in server-side scripts. Client scripts must use commands. + +### With NPCs + +Create NPCs with custom skins: + +```java +// Create your NPC entity +MyNpcEntity npc = new MyNpcEntity(world); +npc.setPosition(x, y, z); +world.spawnEntity(npc); + +// Set its skin +SkinData.username("SomePlayer", skin -> { + SkinTracker.getInstance().putSynced(npc.getUuid(), skin); +}); +``` + +### Events + +Listen for skin changes: + +```java +// The skin tracker uses Fabric networking +// Clients receive updates via the SYNC_KEY packet +ClientPlayNetworking.registerGlobalReceiver(SkinTracker.SYNC_KEY, (client, handler, buf, responseSender) -> { + // Handle skin update +}); +``` + +--- + +## Technical Details + +### Network Protocol + +Skins are synchronized using Fabric's networking API: +- **Packet ID:** `amblekit:skin_sync` +- **Direction:** Server → Client +- **Triggered:** On player join, skin change, or manual sync + +### Client-Side Caching + +The `SkinCache` handles texture management: +- Downloads and caches skin textures +- Converts PNG data to Minecraft textures +- Handles slim/wide model variations + +### Thread Safety + +The `SkinTracker` is thread-safe: +- Uses concurrent data structures +- Safe to call from any thread +- Networking handled on appropriate threads + +--- + +## Troubleshooting + +### Skin Not Updating + +1. Ensure the target entity implements `PlayerSkinTexturable` +2. Check that the skin URL is accessible +3. Verify the player has reconnected after server-side changes + +### Invalid Skin Source + +- Username lookups require internet access +- URL skins must be valid PNG images +- Some CDNs may block automated downloads + +### Slim Arms Not Working + +Ensure you're using `withSlim()` or specifying the slim parameter: + +```java +// Correct +SkinData data = SkinData.url(url, true); // slim = true + +// Or modify existing +data = data.withSlim(true); +``` + +--- + +## See Also + +- [Lua Scripting System](LUA_SCRIPTING.md) - Full skin management API for server scripts +- [Animation System](ANIMATION_SYSTEM.md) - Animate entities with custom skins diff --git a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java index 7ba9587..32ad025 100644 --- a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java +++ b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java @@ -1,6 +1,7 @@ package dev.amble.lib.client.command; import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.SuggestionProvider; import dev.amble.lib.AmbleKit; @@ -13,6 +14,7 @@ import net.minecraft.text.Text; import net.minecraft.util.Formatting; import net.minecraft.util.Identifier; +import org.luaj.vm2.LuaTable; import org.luaj.vm2.LuaValue; import java.util.Set; @@ -65,7 +67,9 @@ public static void register(CommandDispatcher dispatc .then(literal("execute") .then(argument("id", IdentifierArgumentType.identifier()) .suggests(SCRIPT_SUGGESTIONS) - .executes(ClientScriptCommand::execute))) + .executes(context -> execute(context, "")) + .then(argument("args", StringArgumentType.greedyString()) + .executes(context -> execute(context, StringArgumentType.getString(context, "args")))))) .then(literal("enable") .then(argument("id", IdentifierArgumentType.identifier()) .suggests(SCRIPT_SUGGESTIONS) @@ -84,7 +88,7 @@ public static void register(CommandDispatcher dispatc .executes(ClientScriptCommand::listAvailable))); } - private static int execute(CommandContext context) { + private static int execute(CommandContext context, String argsString) { Identifier scriptId = context.getArgument("id", Identifier.class); Identifier fullScriptId = toFullScriptId(scriptId); @@ -100,7 +104,17 @@ private static int execute(CommandContext context) { } LuaValue data = ScriptManager.getInstance().getScriptData(fullScriptId); - script.onExecute().call(data); + + // Parse arguments into a Lua table + LuaTable argsTable = new LuaTable(); + if (!argsString.isEmpty()) { + String[] args = argsString.split(" "); + for (int i = 0; i < args.length; i++) { + argsTable.set(i + 1, LuaValue.valueOf(args[i])); + } + } + + script.onExecute().call(data, argsTable); context.getSource().sendFeedback(Text.literal("Executed script: " + scriptId)); return 1; } catch (Exception e) { diff --git a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java index 4f96031..6fa7263 100644 --- a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java +++ b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java @@ -1,6 +1,7 @@ package dev.amble.lib.command; import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.SuggestionProvider; import dev.amble.lib.AmbleKit; @@ -8,6 +9,7 @@ import dev.amble.lib.script.ServerScriptManager; import dev.amble.lib.script.lua.LuaBinder; import dev.amble.lib.script.lua.ServerMinecraftData; +import org.luaj.vm2.LuaTable; import org.luaj.vm2.LuaValue; import net.minecraft.command.CommandSource; import net.minecraft.command.argument.IdentifierArgumentType; @@ -82,7 +84,9 @@ public static void register(CommandDispatcher dispatcher) { .then(literal("execute") .then(argument("id", IdentifierArgumentType.identifier()) .suggests(EXECUTABLE_SCRIPT_SUGGESTIONS) - .executes(ServerScriptCommand::execute))) + .executes(context -> execute(context, "")) + .then(argument("args", StringArgumentType.greedyString()) + .executes(context -> execute(context, StringArgumentType.getString(context, "args")))))) .then(literal("enable") .then(argument("id", IdentifierArgumentType.identifier()) .suggests(TICKABLE_SCRIPT_SUGGESTIONS) @@ -101,7 +105,7 @@ public static void register(CommandDispatcher dispatcher) { .executes(ServerScriptCommand::listAvailable))); } - private static int execute(CommandContext context) { + private static int execute(CommandContext context, String argsString) { Identifier scriptId = context.getArgument("id", Identifier.class); Identifier fullScriptId = toFullScriptId(scriptId); @@ -127,8 +131,17 @@ private static int execute(CommandContext context) { player ); LuaValue boundData = LuaBinder.bind(data); - - script.onExecute().call(boundData); + + // Parse arguments into a Lua table + LuaTable argsTable = new LuaTable(); + if (!argsString.isEmpty()) { + String[] args = argsString.split(" "); + for (int i = 0; i < args.length; i++) { + argsTable.set(i + 1, LuaValue.valueOf(args[i])); + } + } + + script.onExecute().call(boundData, argsTable); context.getSource().sendFeedback(() -> Text.literal("Executed server script: " + scriptId), true); return 1; } catch (Exception e) { diff --git a/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java b/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java index e472e64..bc82c61 100644 --- a/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java @@ -1,6 +1,8 @@ package dev.amble.lib.script.lua; import dev.amble.lib.AmbleKit; +import dev.amble.lib.skin.SkinData; +import dev.amble.lib.skin.SkinTracker; import dev.amble.lib.util.ServerLifecycleHooks; import net.minecraft.entity.Entity; import net.minecraft.server.MinecraftServer; @@ -11,6 +13,7 @@ import net.minecraft.world.World; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; /** @@ -224,4 +227,188 @@ public List worldNames() { .collect(Collectors.toList()) : List.of(); } + + // ===== Skin Management ===== + + /** + * Gets the UUID for a player by name. + * @param playerName the player's name + * @return the UUID, or null if player not found + */ + private UUID getPlayerUuid(String playerName) { + MinecraftServer srv = getServer(); + if (srv == null) return null; + ServerPlayerEntity target = srv.getPlayerManager().getPlayer(playerName); + return target != null ? target.getUuid() : null; + } + + /** + * Parses a UUID string. + * @param uuidString the UUID as a string + * @return the UUID, or null if invalid + */ + private UUID parseUuid(String uuidString) { + try { + return UUID.fromString(uuidString); + } catch (IllegalArgumentException e) { + AmbleKit.LOGGER.warn("Invalid UUID format: '{}'", uuidString); + return null; + } + } + + /** + * Sets a player's skin to match another player's skin (by username). + * This performs an async lookup of the skin and applies it when ready. + * + * @param playerName the player whose skin to change + * @param skinUsername the username to copy the skin from + * @return true if the player was found, false otherwise + */ + @LuaExpose + public boolean setSkin(String playerName, String skinUsername) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) { + AmbleKit.LOGGER.warn("Cannot set skin: player '{}' not found", playerName); + return false; + } + SkinData.usernameUpload(skinUsername, uuid); + return true; + } + + /** + * Sets a player's skin from a direct URL. + * + * @param playerName the player whose skin to change + * @param url the URL to the skin image + * @param slim true for slim (Alex) arms, false for wide (Steve) arms + * @return true if the player was found, false otherwise + */ + @LuaExpose + public boolean setSkinUrl(String playerName, String url, boolean slim) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) { + AmbleKit.LOGGER.warn("Cannot set skin: player '{}' not found", playerName); + return false; + } + SkinData.url(url, slim).upload(uuid); + return true; + } + + /** + * Changes a player's arm model (slim or wide) without changing the skin texture. + * + * @param playerName the player whose arm model to change + * @param slim true for slim (Alex) arms, false for wide (Steve) arms + * @return true if successful, false if player not found or has no custom skin + */ + @LuaExpose + public boolean setSkinSlim(String playerName, boolean slim) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) { + AmbleKit.LOGGER.warn("Cannot set skin slim: player '{}' not found", playerName); + return false; + } + SkinData existingSkin = SkinTracker.getInstance().get(uuid); + if (existingSkin == null) { + AmbleKit.LOGGER.warn("Cannot set skin slim: player '{}' has no custom skin", playerName); + return false; + } + SkinTracker.getInstance().putSynced(uuid, existingSkin.withSlim(slim)); + return true; + } + + /** + * Clears a player's custom skin, restoring their original skin. + * + * @param playerName the player whose skin to clear + * @return true if the player was found, false otherwise + */ + @LuaExpose + public boolean clearSkin(String playerName) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) { + AmbleKit.LOGGER.warn("Cannot clear skin: player '{}' not found", playerName); + return false; + } + SkinTracker.getInstance().removeSynced(uuid); + return true; + } + + /** + * Checks if a player has a custom skin applied. + * + * @param playerName the player to check + * @return true if the player has a custom skin, false otherwise + */ + @LuaExpose + public boolean hasSkin(String playerName) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) return false; + return SkinTracker.getInstance().containsKey(uuid); + } + + /** + * Sets a skin by UUID string. + * This performs an async lookup of the skin and applies it when ready. + * + * @param uuidString the UUID of the entity whose skin to change + * @param skinUsername the username to copy the skin from + * @return true if the UUID was valid, false otherwise + */ + @LuaExpose + public boolean setSkinByUuid(String uuidString, String skinUsername) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) { + return false; + } + SkinData.usernameUpload(skinUsername, uuid); + return true; + } + + /** + * Sets a skin from a URL by UUID string. + * + * @param uuidString the UUID of the entity whose skin to change + * @param url the URL to the skin image + * @param slim true for slim (Alex) arms, false for wide (Steve) arms + * @return true if the UUID was valid, false otherwise + */ + @LuaExpose + public boolean setSkinUrlByUuid(String uuidString, String url, boolean slim) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) { + return false; + } + SkinData.url(url, slim).upload(uuid); + return true; + } + + /** + * Clears a custom skin by UUID string. + * + * @param uuidString the UUID of the entity whose skin to clear + * @return true if the UUID was valid, false otherwise + */ + @LuaExpose + public boolean clearSkinByUuid(String uuidString) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) { + return false; + } + SkinTracker.getInstance().removeSynced(uuid); + return true; + } + + /** + * Checks if an entity has a custom skin applied by UUID string. + * + * @param uuidString the UUID to check + * @return true if the entity has a custom skin, false otherwise + */ + @LuaExpose + public boolean hasSkinByUuid(String uuidString) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) return false; + return SkinTracker.getInstance().containsKey(uuid); + } } diff --git a/src/test/resources/assets/litmus/script/clipboard_demo.lua b/src/test/resources/assets/litmus/script/clipboard_demo.lua index d56533a..bef1662 100644 --- a/src/test/resources/assets/litmus/script/clipboard_demo.lua +++ b/src/test/resources/assets/litmus/script/clipboard_demo.lua @@ -3,7 +3,7 @@ -- -- Note: This script uses client-only features (clipboard, window size) -function onExecute(mc) +function onExecute(mc, args) -- Check if we're on the client side if not mc:isClientSide() then mc:sendMessage("§cThis script requires client-side features!", false) diff --git a/src/test/resources/assets/litmus/script/entity_inspect.lua b/src/test/resources/assets/litmus/script/entity_inspect.lua index cfffe44..5ea1258 100644 --- a/src/test/resources/assets/litmus/script/entity_inspect.lua +++ b/src/test/resources/assets/litmus/script/entity_inspect.lua @@ -3,7 +3,7 @@ -- -- Note: Uses client-only lookingAtEntity feature -function onExecute(mc) +function onExecute(mc, args) local target = nil -- lookingAtEntity is client-only diff --git a/src/test/resources/assets/litmus/script/hotbar_cycle.lua b/src/test/resources/assets/litmus/script/hotbar_cycle.lua index 9cae63e..186b5a9 100644 --- a/src/test/resources/assets/litmus/script/hotbar_cycle.lua +++ b/src/test/resources/assets/litmus/script/hotbar_cycle.lua @@ -6,7 +6,7 @@ local cycleIndex = 1 local cycleDirection = 1 -function onExecute(mc) +function onExecute(mc, args) -- Check if we're on the client side if not mc:isClientSide() then mc:sendMessage("§cThis script requires client-side features!", false) diff --git a/src/test/resources/assets/litmus/script/input_test.lua b/src/test/resources/assets/litmus/script/input_test.lua index df997ff..3a9553e 100644 --- a/src/test/resources/assets/litmus/script/input_test.lua +++ b/src/test/resources/assets/litmus/script/input_test.lua @@ -3,7 +3,7 @@ -- -- Note: Uses client-only input detection features -function onExecute(mc) +function onExecute(mc, args) -- Check if we're on the client side if not mc:isClientSide() then mc:sendMessage("§cThis script requires client-side features!", false) diff --git a/src/test/resources/assets/litmus/script/item_info.lua b/src/test/resources/assets/litmus/script/item_info.lua index bfc14fa..9ee7f71 100644 --- a/src/test/resources/assets/litmus/script/item_info.lua +++ b/src/test/resources/assets/litmus/script/item_info.lua @@ -3,7 +3,7 @@ -- -- Note: Uses client-only hotbar selection features -function onExecute(mc) +function onExecute(mc, args) -- Check if we're on the client side if not mc:isClientSide() then mc:sendMessage("§cThis script requires client-side features!", false) diff --git a/src/test/resources/assets/litmus/script/player_state.lua b/src/test/resources/assets/litmus/script/player_state.lua index 4490441..4ffea09 100644 --- a/src/test/resources/assets/litmus/script/player_state.lua +++ b/src/test/resources/assets/litmus/script/player_state.lua @@ -3,7 +3,7 @@ -- -- Note: minecraft data is passed as first argument to callbacks -function onExecute(mc) +function onExecute(mc, args) local player = mc:player() -- Header - username is client-only, so we use player name instead diff --git a/src/test/resources/assets/litmus/script/stats.lua b/src/test/resources/assets/litmus/script/stats.lua index ce422b7..e8e7f02 100644 --- a/src/test/resources/assets/litmus/script/stats.lua +++ b/src/test/resources/assets/litmus/script/stats.lua @@ -3,7 +3,7 @@ -- -- Note: minecraft data is passed as first argument to callbacks -function onExecute(mc) +function onExecute(mc, args) -- Get player info local player = mc:player() local pos = player:position() diff --git a/src/test/resources/assets/litmus/script/test.lua b/src/test/resources/assets/litmus/script/test.lua index 2ef6a06..4e1e87c 100644 --- a/src/test/resources/assets/litmus/script/test.lua +++ b/src/test/resources/assets/litmus/script/test.lua @@ -37,7 +37,7 @@ function onClick(self, mouseX, mouseY, button) end end -function onExecute(mc) +function onExecute(mc, args) mc:log("Test script executed via command!") mc:sendMessage("§aTest script executed!", false) end diff --git a/src/test/resources/assets/litmus/script/world_info.lua b/src/test/resources/assets/litmus/script/world_info.lua index b3fbbd9..057a782 100644 --- a/src/test/resources/assets/litmus/script/world_info.lua +++ b/src/test/resources/assets/litmus/script/world_info.lua @@ -3,7 +3,7 @@ -- -- Note: minecraft data is passed as first argument to callbacks. -function onExecute(mc) +function onExecute(mc, args) local player = mc:player() local pos = player:blockPosition() diff --git a/src/test/resources/data/litmus/script/admin_commands.lua b/src/test/resources/data/litmus/script/admin_commands.lua index 0842a18..b3fb9ca 100644 --- a/src/test/resources/data/litmus/script/admin_commands.lua +++ b/src/test/resources/data/litmus/script/admin_commands.lua @@ -3,7 +3,7 @@ -- -- This is a SERVER-SIDE script with various admin utilities. -function onExecute(mc) +function onExecute(mc, args) mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) mc:sendMessage("§e§l✦ Admin Commands Executed ✦", false) mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) diff --git a/src/test/resources/data/litmus/script/server_status.lua b/src/test/resources/data/litmus/script/server_status.lua index 3f79bed..0768df4 100644 --- a/src/test/resources/data/litmus/script/server_status.lua +++ b/src/test/resources/data/litmus/script/server_status.lua @@ -4,7 +4,7 @@ -- This is a SERVER-SIDE script. It runs on the server and has access to -- all players, server TPS, and other server-specific information. -function onExecute(mc) +function onExecute(mc, args) -- Confirm we're on the server if mc:isClientSide() then mc:sendMessage("§cThis script should only run on the server!", false) diff --git a/src/test/resources/data/litmus/script/skin_disguise.lua b/src/test/resources/data/litmus/script/skin_disguise.lua new file mode 100644 index 0000000..b96921d --- /dev/null +++ b/src/test/resources/data/litmus/script/skin_disguise.lua @@ -0,0 +1,48 @@ +-- Skin Disguise Script: Disguise yourself as famous Minecraft players +-- Run with: /serverscript execute litmus:skin_disguise +-- +-- This script demonstrates the basic skin API methods. + +-- List of famous/notable Minecraft usernames +local DISGUISES = { + "Notch", + "jeb_", + "Dinnerbone", + "Herobrine", + "Dream", + "Technoblade", + "Ph1LzA", + "TommyInnit" +} + +function onExecute(mc, args) + local player = mc:player() + if player == nil then + mc:log("No player context - run this as a player!") + return + end + + local playerName = player:name() + + -- Check if already disguised + if mc:hasSkin(playerName) then + -- Clear the disguise + if mc:clearSkin(playerName) then + mc:sendMessage("§7Disguise removed! You look like yourself again.", false) + mc:log("Player " .. playerName .. " removed their disguise") + else + mc:sendMessage("§cFailed to remove disguise!", false) + end + else + -- Pick a random disguise + math.randomseed(os.time()) + local disguise = DISGUISES[math.random(#DISGUISES)] + + if mc:setSkin(playerName, disguise) then + mc:sendMessage("§aYou are now disguised as §e" .. disguise .. "§a!", false) + mc:log("Player " .. playerName .. " disguised as " .. disguise) + else + mc:sendMessage("§cFailed to apply disguise!", false) + end + end +end diff --git a/src/test/resources/data/litmus/script/skin_manager.lua b/src/test/resources/data/litmus/script/skin_manager.lua new file mode 100644 index 0000000..e9762cf --- /dev/null +++ b/src/test/resources/data/litmus/script/skin_manager.lua @@ -0,0 +1,97 @@ +-- Skin Manager Script: Comprehensive skin management with tick-based monitoring +-- Enable with: /serverscript enable litmus:skin_manager +-- Disable with: /serverscript disable litmus:skin_manager +-- Execute with: /serverscript execute litmus:skin_manager +-- +-- This script demonstrates: +-- - Setting skins by username and URL +-- - Checking skin status +-- - UUID-based skin operations +-- - Tick-based skin monitoring + +-- Track players we've given skins to +local skinnedPlayers = {} +local tickCounter = 0 + +function onExecute(mc, args) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Skin Manager Status ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + local players = mc:allPlayerNames() + local withSkin = 0 + local withoutSkin = 0 + + for _, name in ipairs(players) do + if mc:hasSkin(name) then + withSkin = withSkin + 1 + mc:sendMessage(" §a✓ §f" .. name .. " §7(custom skin)", false) + else + withoutSkin = withoutSkin + 1 + mc:sendMessage(" §7○ §f" .. name .. " §8(default skin)", false) + end + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§7Custom skins: §a" .. withSkin .. "§7 | Default: §8" .. withoutSkin, false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end + +function onEnable(mc) + skinnedPlayers = {} + tickCounter = 0 + mc:broadcast("§e[Skin Manager] §aEnabled! New players will receive skins.") + mc:log("Skin Manager enabled") +end + +function onTick(mc) + tickCounter = tickCounter + 1 + + -- Only check every 100 ticks (5 seconds) + if tickCounter % 100 ~= 0 then + return + end + + -- Check for new players without skins + local players = mc:allPlayers() + + for _, player in ipairs(players) do + local name = player:name() + local uuid = player:uuid() + + -- Skip if we've already processed this player + if skinnedPlayers[uuid] then + goto continue + end + + -- Check if player already has a custom skin + if not mc:hasSkinByUuid(uuid) then + -- Give them a default "welcome" skin + if mc:setSkinByUuid(uuid, "Steve") then + mc:broadcastToPlayer(name, "§e[Skin Manager] §7Welcome! Default skin applied.", false) + mc:log("Applied default skin to new player: " .. name) + end + end + + -- Mark as processed + skinnedPlayers[uuid] = true + + ::continue:: + end +end + +function onDisable(mc) + -- Clear all skins when disabled + local cleared = 0 + for _, name in ipairs(mc:allPlayerNames()) do + if mc:hasSkin(name) then + if mc:clearSkin(name) then + cleared = cleared + 1 + end + end + end + + mc:broadcast("§e[Skin Manager] §cDisabled. Cleared " .. cleared .. " custom skins.") + mc:log("Skin Manager disabled, cleared " .. cleared .. " skins") + skinnedPlayers = {} +end diff --git a/src/test/resources/data/litmus/script/skin_party.lua b/src/test/resources/data/litmus/script/skin_party.lua new file mode 100644 index 0000000..1fdc381 --- /dev/null +++ b/src/test/resources/data/litmus/script/skin_party.lua @@ -0,0 +1,63 @@ +-- Skin Party Script: Shuffle everyone's skins randomly! +-- Run with: /serverscript execute litmus:skin_party +-- +-- This script swaps everyone's skins around for fun. +-- Great for events and parties! + +function onExecute(mc, args) + local players = mc:allPlayers() + local count = #players + + if count < 2 then + mc:sendMessage("§cNeed at least 2 players for a skin party!", false) + return + end + + mc:broadcast("§d§l✨ SKIN PARTY! ✨") + mc:broadcast("§7Everyone's skins are being shuffled...") + + -- Collect all player names and UUIDs + local playerData = {} + for _, player in ipairs(players) do + table.insert(playerData, { + name = player:name(), + uuid = player:uuid() + }) + end + + -- Create a shuffled copy of names for skin sources + local skinSources = {} + for _, data in ipairs(playerData) do + table.insert(skinSources, data.name) + end + + -- Fisher-Yates shuffle + math.randomseed(os.time()) + for i = #skinSources, 2, -1 do + local j = math.random(i) + skinSources[i], skinSources[j] = skinSources[j], skinSources[i] + end + + -- Apply shuffled skins + local success = 0 + for i, data in ipairs(playerData) do + local skinSource = skinSources[i] + + -- Don't give someone their own skin + if skinSource == data.name then + -- Swap with next person + local nextIdx = (i % #skinSources) + 1 + skinSources[i], skinSources[nextIdx] = skinSources[nextIdx], skinSources[i] + skinSource = skinSources[i] + end + + if mc:setSkin(data.name, skinSource) then + mc:broadcastToPlayer(data.name, "§dYou now look like §e" .. skinSource .. "§d!", false) + success = success + 1 + end + end + + mc:broadcast("§d§l✨ " .. success .. " skins shuffled! ✨") + mc:broadcast("§7Run the command again to reshuffle!") + mc:log("Skin party: shuffled " .. success .. " skins") +end diff --git a/src/test/resources/data/litmus/script/skin_set.lua b/src/test/resources/data/litmus/script/skin_set.lua new file mode 100644 index 0000000..3639b18 --- /dev/null +++ b/src/test/resources/data/litmus/script/skin_set.lua @@ -0,0 +1,59 @@ +-- skin_set.lua +-- Set or clear a player's skin with command-line arguments +-- +-- Usage: +-- /serverscript execute litmus:skin_set - Clear your skin +-- /serverscript execute litmus:skin_set - Set skin (wide arms) +-- /serverscript execute litmus:skin_set - Set skin with arm style +-- /serverscript execute litmus:skin_set +-- +-- Examples: +-- /serverscript execute litmus:skin_set - Clear your own skin +-- /serverscript execute litmus:skin_set Notch - Set your skin to Notch (wide arms) +-- /serverscript execute litmus:skin_set Notch true - Set your skin to Notch (slim arms) +-- /serverscript execute litmus:skin_set Notch false duzo - Set duzo's skin to Notch (wide arms) +-- +-- Arguments: +-- skin_username - The Minecraft username to copy the skin from (omit to clear skin) +-- slim - (optional) "true" for slim (Alex) arms, defaults to false (Steve arms) +-- target_player - (optional) Player to apply the skin to; defaults to command executor + +function onExecute(mc, args) + -- Determine target player first + local targetPlayer = args[3] + if targetPlayer == nil or targetPlayer == "" then + local player = mc:player() + if player == nil then + mc:sendMessage("§cNo player context and no target player specified!", false) + return + end + targetPlayer = player:name() + end + + -- No arguments = clear skin + if args == nil or #args < 1 or args[1] == nil or args[1] == "" then + if mc:clearSkin(targetPlayer) then + mc:sendMessage("§aSkin cleared for §f" .. targetPlayer, false) + else + mc:sendMessage("§cFailed to clear skin! Player may not exist.", false) + end + return + end + + local skinUsername = args[1] + local slimArg = args[2] + + -- Parse slim boolean (defaults to false) + local slim = slimArg == "true" or slimArg == "1" or slimArg == "yes" + + -- Apply the skin + mc:sendMessage("§7Setting skin for §f" .. targetPlayer .. "§7 to §f" .. skinUsername .. "§7 (slim: §f" .. tostring(slim) .. "§7)...", false) + + if mc:setSkin(targetPlayer, skinUsername) then + mc:sendMessage("§aSkin applied successfully!", false) + -- Set the arm model + mc:setSkinSlim(targetPlayer, slim) + else + mc:sendMessage("§cFailed to apply skin! Player may not exist.", false) + end +end diff --git a/src/test/resources/data/litmus/script/skin_team.lua b/src/test/resources/data/litmus/script/skin_team.lua new file mode 100644 index 0000000..2bed54b --- /dev/null +++ b/src/test/resources/data/litmus/script/skin_team.lua @@ -0,0 +1,155 @@ +-- Skin Team Script: Assign team-based skins to players +-- Enable with: /serverscript enable litmus:skin_team +-- Disable with: /serverscript disable litmus:skin_team +-- Execute with: /serverscript execute litmus:skin_team +-- +-- This script assigns players to teams based on join order +-- and gives them matching team skins. + +-- Team configurations +local TEAMS = { + { + name = "Red Team", + color = "§c", + skin = "Notch" -- Team captain skin + }, + { + name = "Blue Team", + color = "§9", + skin = "jeb_" -- Team captain skin + }, + { + name = "Green Team", + color = "§a", + skin = "Dinnerbone" + }, + { + name = "Yellow Team", + color = "§e", + skin = "Dream" + } +} + +-- Track team assignments +local playerTeams = {} -- uuid -> team index +local teamCounts = {} -- team index -> player count + +function initTeamCounts() + teamCounts = {} + for i = 1, #TEAMS do + teamCounts[i] = 0 + end +end + +function getSmallestTeam() + local minCount = 999999 + local minTeam = 1 + + for i, count in ipairs(teamCounts) do + if count < minCount then + minCount = count + minTeam = i + end + end + + return minTeam +end + +function onExecute(mc, args) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Team Skin Status ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + -- Show team rosters + for i, team in ipairs(TEAMS) do + local count = teamCounts[i] or 0 + mc:sendMessage(team.color .. "§l" .. team.name .. " §7(" .. count .. " players)", false) + + -- List players on this team + for uuid, teamIdx in pairs(playerTeams) do + if teamIdx == i then + -- Find player name by UUID + for _, player in ipairs(mc:allPlayers()) do + if player:uuid() == uuid then + mc:sendMessage(" " .. team.color .. "• §f" .. player:name(), false) + break + end + end + end + end + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) +end + +function onEnable(mc) + initTeamCounts() + playerTeams = {} + + -- Assign existing players to teams + local players = mc:allPlayers() + for _, player in ipairs(players) do + assignPlayerToTeam(mc, player:name(), player:uuid()) + end + + mc:broadcast("§e[Teams] §aTeam mode enabled! Players assigned to teams.") + mc:log("Team Skin script enabled with " .. #players .. " players") +end + +function assignPlayerToTeam(mc, playerName, uuid) + -- Skip if already assigned + if playerTeams[uuid] then + return + end + + -- Assign to smallest team + local teamIdx = getSmallestTeam() + local team = TEAMS[teamIdx] + + playerTeams[uuid] = teamIdx + teamCounts[teamIdx] = (teamCounts[teamIdx] or 0) + 1 + + -- Apply team skin + if mc:setSkinByUuid(uuid, team.skin) then + mc:broadcastToPlayer(playerName, team.color .. "§lYou joined " .. team.name .. "!", false) + mc:log("Assigned " .. playerName .. " to " .. team.name) + else + mc:logWarn("Failed to apply team skin for " .. playerName) + end +end + +-- Track player count to detect new joins +local lastPlayerCount = 0 + +function onTick(mc) + local currentCount = mc:playerCount() + + -- Check for new players + if currentCount > lastPlayerCount then + local players = mc:allPlayers() + for _, player in ipairs(players) do + local uuid = player:uuid() + if not playerTeams[uuid] then + assignPlayerToTeam(mc, player:name(), uuid) + end + end + end + + lastPlayerCount = currentCount +end + +function onDisable(mc) + -- Clear all team skins + local cleared = 0 + for uuid, _ in pairs(playerTeams) do + if mc:clearSkinByUuid(uuid) then + cleared = cleared + 1 + end + end + + mc:broadcast("§e[Teams] §cTeam mode disabled. Cleared " .. cleared .. " team skins.") + mc:log("Team Skin script disabled") + + playerTeams = {} + initTeamCounts() +end diff --git a/src/test/resources/data/litmus/script/skin_url_test.lua b/src/test/resources/data/litmus/script/skin_url_test.lua new file mode 100644 index 0000000..8fe9331 --- /dev/null +++ b/src/test/resources/data/litmus/script/skin_url_test.lua @@ -0,0 +1,122 @@ +-- Skin URL Test Script: Test applying skins from URLs +-- Run with: /serverscript execute litmus:skin_url_test +-- +-- This script tests URL-based skin application and slim arm toggling. + +-- Example skin URLs (these are placeholder URLs - replace with real ones) +local TEST_SKINS = { + { + name = "Classic Steve", + url = "https://assets.mojang.com/SkinTemplates/steve.png", + slim = false + }, + { + name = "Classic Alex", + url = "https://assets.mojang.com/SkinTemplates/alex.png", + slim = true + } +} + +-- Current test index per player +local playerTestIndex = {} + +function onExecute(mc, args) + local player = mc:player() + if player == nil then + mc:log("No player context!") + return + end + + local playerName = player:name() + local uuid = player:uuid() + + -- Get or initialize test index for this player + if not playerTestIndex[uuid] then + playerTestIndex[uuid] = 0 + end + + -- Cycle through tests + playerTestIndex[uuid] = playerTestIndex[uuid] + 1 + local testNum = playerTestIndex[uuid] + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§e§l✦ Skin URL Test #" .. testNum .. " ✦", false) + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + + if testNum == 1 then + -- Test: Set skin by username + mc:sendMessage("§7Test: Setting skin by username (Notch)...", false) + if mc:setSkin(playerName, "Notch") then + mc:sendMessage("§a✓ Success! Skin set to Notch", false) + else + mc:sendMessage("§c✗ Failed to set skin by username", false) + end + + elseif testNum == 2 then + -- Test: Check hasSkin + mc:sendMessage("§7Test: Checking hasSkin()...", false) + local hasSkin = mc:hasSkin(playerName) + local hasSkinUuid = mc:hasSkinByUuid(uuid) + mc:sendMessage("§7hasSkin(name): " .. (hasSkin and "§atrue" or "§cfalse"), false) + mc:sendMessage("§7hasSkinByUuid(uuid): " .. (hasSkinUuid and "§atrue" or "§cfalse"), false) + + elseif testNum == 3 then + -- Test: Toggle slim arms + mc:sendMessage("§7Test: Toggling slim arms (true)...", false) + if mc:hasSkin(playerName) then + if mc:setSkinSlim(playerName, true) then + mc:sendMessage("§a✓ Success! Slim arms enabled", false) + else + mc:sendMessage("§c✗ Failed to set slim arms", false) + end + else + mc:sendMessage("§c✗ No custom skin to modify!", false) + end + + elseif testNum == 4 then + -- Test: Toggle slim arms back + mc:sendMessage("§7Test: Toggling slim arms (false)...", false) + if mc:hasSkin(playerName) then + if mc:setSkinSlim(playerName, false) then + mc:sendMessage("§a✓ Success! Wide arms enabled", false) + else + mc:sendMessage("§c✗ Failed to set wide arms", false) + end + else + mc:sendMessage("§c✗ No custom skin to modify!", false) + end + + elseif testNum == 5 then + -- Test: Set by UUID + mc:sendMessage("§7Test: Setting skin by UUID...", false) + mc:sendMessage("§8UUID: " .. uuid, false) + if mc:setSkinByUuid(uuid, "jeb_") then + mc:sendMessage("§a✓ Success! Skin set to jeb_ via UUID", false) + else + mc:sendMessage("§c✗ Failed to set skin by UUID", false) + end + + elseif testNum == 6 then + -- Test: Clear skin + mc:sendMessage("§7Test: Clearing skin...", false) + if mc:clearSkin(playerName) then + mc:sendMessage("§a✓ Success! Skin cleared", false) + else + mc:sendMessage("§c✗ Failed to clear skin", false) + end + + -- Verify it's cleared + if not mc:hasSkin(playerName) then + mc:sendMessage("§a✓ Verified: hasSkin() returns false", false) + else + mc:sendMessage("§c✗ Warning: hasSkin() still returns true!", false) + end + + -- Reset test counter + playerTestIndex[uuid] = 0 + mc:sendMessage("§7Tests complete! Run again to restart.", false) + end + + mc:sendMessage("§6§l━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", false) + mc:sendMessage("§7Run again for next test (" .. (testNum % 6 + 1) .. "/6)", false) +end From dc58258d2c94d007f307133970c6abd78012d95d Mon Sep 17 00:00:00 2001 From: James Hall <73184526+duzos@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:31:24 +0000 Subject: [PATCH 23/37] Update mod_version to 1.1.16 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index f2d5908..df643f9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,7 +9,7 @@ yarn_mappings=1.20.1+build.10 loader_version=0.16.10 # Mod Properties -mod_version=1.1.15 +mod_version=1.1.16 maven_group=dev.amble publication_base_name=lib archives_base_name=amblekit From 5a993ef01ec6b1d04ccb45a98220261ed8c81212 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 20:10:19 +0000 Subject: [PATCH 24/37] Add data generation support and translation keys for skin and script commands --- build.gradle | 15 +++++ .../client/command/ClientScriptCommand.java | 38 ++++++----- .../lib/command/ServerScriptCommand.java | 40 ++++++----- .../dev/amble/lib/command/SetSkinCommand.java | 33 ++++----- .../amble/lib/datagen/AmbleKitDatagen.java | 67 +++++++++++++++++++ src/main/resources/fabric.mod.json | 3 + .../litmus/commands/TestScreenCommand.java | 12 ++-- .../amble/litmus/datagen/LitmusDatagen.java | 35 ++++++++++ src/test/resources/fabric.mod.json | 5 +- 9 files changed, 193 insertions(+), 55 deletions(-) create mode 100644 src/main/java/dev/amble/lib/datagen/AmbleKitDatagen.java create mode 100644 src/test/java/dev/amble/litmus/datagen/LitmusDatagen.java diff --git a/build.gradle b/build.gradle index 38c12ab..8d6fc51 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,16 @@ loom { name = "Testmod Server" source sourceSets.test } + testmodDatagen { + inherit server + ideConfigGenerated project.rootProject == project + name = "Testmod Data Generation" + vmArg "-Dfabric-api.datagen" + vmArg "-Dfabric-api.datagen.output-dir=${file("src/test/generated")}" + vmArg "-Dfabric-api.datagen.modid=litmus" + runDir "build/datagen" + source sourceSets.test + } } } @@ -72,6 +82,11 @@ sourceSets { test { runtimeClasspath += main.runtimeClasspath compileClasspath += main.compileClasspath + resources { + srcDirs += [ + "src/test/generated" + ] + } } } diff --git a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java index 32ad025..120d005 100644 --- a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java +++ b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java @@ -31,6 +31,10 @@ public class ClientScriptCommand { private static final String SCRIPT_PREFIX = "script/"; private static final String SCRIPT_SUFFIX = ".lua"; + private static String translationKey(String key) { + return "command." + AmbleKit.MOD_ID + ".client_script." + key; + } + /** * Converts a full script identifier to a display-friendly format. * Removes the "script/" prefix and ".lua" suffix. @@ -99,7 +103,7 @@ private static int execute(CommandContext context, St ); if (script.onExecute() == null || script.onExecute().isnil()) { - context.getSource().sendError(Text.literal("Script '" + scriptId + "' has no onExecute function")); + context.getSource().sendError(Text.translatable(translationKey("error.no_execute"), scriptId)); return 0; } @@ -115,10 +119,10 @@ private static int execute(CommandContext context, St } script.onExecute().call(data, argsTable); - context.getSource().sendFeedback(Text.literal("Executed script: " + scriptId)); + context.getSource().sendFeedback(Text.translatable(translationKey("executed"), scriptId)); return 1; } catch (Exception e) { - context.getSource().sendError(Text.literal("Failed to execute script '" + scriptId + "': " + e.getMessage())); + context.getSource().sendError(Text.translatable(translationKey("error.execute_failed"), scriptId, e.getMessage())); AmbleKit.LOGGER.error("Failed to execute script {}", scriptId, e); return 0; } @@ -132,20 +136,20 @@ private static int enable(CommandContext context) { try { ScriptManager.getInstance().load(fullScriptId, MinecraftClient.getInstance().getResourceManager()); } catch (Exception e) { - context.getSource().sendError(Text.literal("Script '" + scriptId + "' not found")); + context.getSource().sendError(Text.translatable(translationKey("error.not_found"), scriptId)); return 0; } if (ScriptManager.getInstance().isEnabled(fullScriptId)) { - context.getSource().sendError(Text.literal("Script '" + scriptId + "' is already enabled")); + context.getSource().sendError(Text.translatable(translationKey("error.already_enabled"), scriptId)); return 0; } if (ScriptManager.getInstance().enable(fullScriptId)) { - context.getSource().sendFeedback(Text.literal("Enabled script: " + scriptId).formatted(Formatting.GREEN)); + context.getSource().sendFeedback(Text.translatable(translationKey("enabled"), scriptId).formatted(Formatting.GREEN)); return 1; } else { - context.getSource().sendError(Text.literal("Failed to enable script '" + scriptId + "'")); + context.getSource().sendError(Text.translatable(translationKey("error.enable_failed"), scriptId)); return 0; } } @@ -155,15 +159,15 @@ private static int disable(CommandContext context) { Identifier fullScriptId = toFullScriptId(scriptId); if (!ScriptManager.getInstance().isEnabled(fullScriptId)) { - context.getSource().sendError(Text.literal("Script '" + scriptId + "' is not enabled")); + context.getSource().sendError(Text.translatable(translationKey("error.not_enabled"), scriptId)); return 0; } if (ScriptManager.getInstance().disable(fullScriptId)) { - context.getSource().sendFeedback(Text.literal("Disabled script: " + scriptId).formatted(Formatting.RED)); + context.getSource().sendFeedback(Text.translatable(translationKey("disabled"), scriptId).formatted(Formatting.RED)); return 1; } else { - context.getSource().sendError(Text.literal("Failed to disable script '" + scriptId + "'")); + context.getSource().sendError(Text.translatable(translationKey("error.disable_failed"), scriptId)); return 0; } } @@ -176,7 +180,7 @@ private static int toggle(CommandContext context) { try { ScriptManager.getInstance().load(fullScriptId, MinecraftClient.getInstance().getResourceManager()); } catch (Exception e) { - context.getSource().sendError(Text.literal("Script '" + scriptId + "' not found")); + context.getSource().sendError(Text.translatable(translationKey("error.not_found"), scriptId)); return 0; } @@ -184,9 +188,9 @@ private static int toggle(CommandContext context) { ScriptManager.getInstance().toggle(fullScriptId); if (wasEnabled) { - context.getSource().sendFeedback(Text.literal("Disabled script: " + scriptId).formatted(Formatting.RED)); + context.getSource().sendFeedback(Text.translatable(translationKey("disabled"), scriptId).formatted(Formatting.RED)); } else { - context.getSource().sendFeedback(Text.literal("Enabled script: " + scriptId).formatted(Formatting.GREEN)); + context.getSource().sendFeedback(Text.translatable(translationKey("enabled"), scriptId).formatted(Formatting.GREEN)); } return 1; } @@ -195,11 +199,11 @@ private static int listEnabled(CommandContext context Set enabled = ScriptManager.getInstance().getEnabledScripts(); if (enabled.isEmpty()) { - context.getSource().sendFeedback(Text.literal("No client scripts are currently enabled").formatted(Formatting.GRAY)); + context.getSource().sendFeedback(Text.translatable(translationKey("list.none_enabled")).formatted(Formatting.GRAY)); return 1; } - context.getSource().sendFeedback(Text.literal("━━━ Enabled Client Scripts (" + enabled.size() + ") ━━━").formatted(Formatting.GOLD, Formatting.BOLD)); + context.getSource().sendFeedback(Text.translatable(translationKey("list.enabled_header"), enabled.size()).formatted(Formatting.GOLD, Formatting.BOLD)); for (Identifier id : enabled) { String displayId = getDisplayId(id); context.getSource().sendFeedback( @@ -215,11 +219,11 @@ private static int listAvailable(CommandContext conte Set enabled = ScriptManager.getInstance().getEnabledScripts(); if (available.isEmpty()) { - context.getSource().sendFeedback(Text.literal("No client scripts available").formatted(Formatting.GRAY)); + context.getSource().sendFeedback(Text.translatable(translationKey("list.none_available")).formatted(Formatting.GRAY)); return 1; } - context.getSource().sendFeedback(Text.literal("━━━ Available Client Scripts (" + available.size() + ") ━━━").formatted(Formatting.GOLD, Formatting.BOLD)); + context.getSource().sendFeedback(Text.translatable(translationKey("list.available_header"), available.size()).formatted(Formatting.GOLD, Formatting.BOLD)); for (Identifier id : available) { String displayId = getDisplayId(id); Text statusIcon = enabled.contains(id) diff --git a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java index 6fa7263..66aef80 100644 --- a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java +++ b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java @@ -33,6 +33,10 @@ public class ServerScriptCommand { private static final String SCRIPT_PREFIX = "script/"; private static final String SCRIPT_SUFFIX = ".lua"; + private static String translationKey(String key) { + return "command." + AmbleKit.MOD_ID + ".script." + key; + } + /** * Converts a full script identifier to a display-friendly format. * Removes the "script/" prefix and ".lua" suffix. @@ -113,12 +117,12 @@ private static int execute(CommandContext context, String a LuaScript script = ServerScriptManager.getInstance().getCache().get(fullScriptId); if (script == null) { - context.getSource().sendError(Text.literal("Server script '" + scriptId + "' not found")); + context.getSource().sendError(Text.translatable(translationKey("error.not_found"), scriptId)); return 0; } if (script.onExecute() == null || script.onExecute().isnil()) { - context.getSource().sendError(Text.literal("Server script '" + scriptId + "' has no onExecute function")); + context.getSource().sendError(Text.translatable(translationKey("error.no_execute"), scriptId)); return 0; } @@ -142,10 +146,10 @@ private static int execute(CommandContext context, String a } script.onExecute().call(boundData, argsTable); - context.getSource().sendFeedback(() -> Text.literal("Executed server script: " + scriptId), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("executed"), scriptId), true); return 1; } catch (Exception e) { - context.getSource().sendError(Text.literal("Failed to execute server script '" + scriptId + "': " + e.getMessage())); + context.getSource().sendError(Text.translatable(translationKey("error.execute_failed"), scriptId, e.getMessage())); AmbleKit.LOGGER.error("Failed to execute server script {}", scriptId, e); return 0; } @@ -156,20 +160,20 @@ private static int enable(CommandContext context) { Identifier fullScriptId = toFullScriptId(scriptId); if (!ServerScriptManager.getInstance().getCache().containsKey(fullScriptId)) { - context.getSource().sendError(Text.literal("Server script '" + scriptId + "' not found")); + context.getSource().sendError(Text.translatable(translationKey("error.not_found"), scriptId)); return 0; } if (ServerScriptManager.getInstance().isEnabled(fullScriptId)) { - context.getSource().sendError(Text.literal("Server script '" + scriptId + "' is already enabled")); + context.getSource().sendError(Text.translatable(translationKey("error.already_enabled"), scriptId)); return 0; } if (ServerScriptManager.getInstance().enable(fullScriptId)) { - context.getSource().sendFeedback(() -> Text.literal("Enabled server script: " + scriptId).formatted(Formatting.GREEN), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("enabled"), scriptId).formatted(Formatting.GREEN), true); return 1; } else { - context.getSource().sendError(Text.literal("Failed to enable server script '" + scriptId + "'")); + context.getSource().sendError(Text.translatable(translationKey("error.enable_failed"), scriptId)); return 0; } } @@ -179,15 +183,15 @@ private static int disable(CommandContext context) { Identifier fullScriptId = toFullScriptId(scriptId); if (!ServerScriptManager.getInstance().isEnabled(fullScriptId)) { - context.getSource().sendError(Text.literal("Server script '" + scriptId + "' is not enabled")); + context.getSource().sendError(Text.translatable(translationKey("error.not_enabled"), scriptId)); return 0; } if (ServerScriptManager.getInstance().disable(fullScriptId)) { - context.getSource().sendFeedback(() -> Text.literal("Disabled server script: " + scriptId).formatted(Formatting.RED), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("disabled"), scriptId).formatted(Formatting.RED), true); return 1; } else { - context.getSource().sendError(Text.literal("Failed to disable server script '" + scriptId + "'")); + context.getSource().sendError(Text.translatable(translationKey("error.disable_failed"), scriptId)); return 0; } } @@ -197,7 +201,7 @@ private static int toggle(CommandContext context) { Identifier fullScriptId = toFullScriptId(scriptId); if (!ServerScriptManager.getInstance().getCache().containsKey(fullScriptId)) { - context.getSource().sendError(Text.literal("Server script '" + scriptId + "' not found")); + context.getSource().sendError(Text.translatable(translationKey("error.not_found"), scriptId)); return 0; } @@ -205,9 +209,9 @@ private static int toggle(CommandContext context) { ServerScriptManager.getInstance().toggle(fullScriptId); if (wasEnabled) { - context.getSource().sendFeedback(() -> Text.literal("Disabled server script: " + scriptId).formatted(Formatting.RED), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("disabled"), scriptId).formatted(Formatting.RED), true); } else { - context.getSource().sendFeedback(() -> Text.literal("Enabled server script: " + scriptId).formatted(Formatting.GREEN), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("enabled"), scriptId).formatted(Formatting.GREEN), true); } return 1; } @@ -216,11 +220,11 @@ private static int listEnabled(CommandContext context) { Set enabled = ServerScriptManager.getInstance().getEnabledScripts(); if (enabled.isEmpty()) { - context.getSource().sendFeedback(() -> Text.literal("No server scripts are currently enabled").formatted(Formatting.GRAY), false); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("list.none_enabled")).formatted(Formatting.GRAY), false); return 1; } - context.getSource().sendFeedback(() -> Text.literal("━━━ Enabled Server Scripts (" + enabled.size() + ") ━━━").formatted(Formatting.GOLD, Formatting.BOLD), false); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("list.enabled_header"), enabled.size()).formatted(Formatting.GOLD, Formatting.BOLD), false); for (Identifier id : enabled) { String displayId = getDisplayId(id); context.getSource().sendFeedback(() -> @@ -235,11 +239,11 @@ private static int listAvailable(CommandContext context) { Set enabled = ServerScriptManager.getInstance().getEnabledScripts(); if (available.isEmpty()) { - context.getSource().sendFeedback(() -> Text.literal("No server scripts available").formatted(Formatting.GRAY), false); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("list.none_available")).formatted(Formatting.GRAY), false); return 1; } - context.getSource().sendFeedback(() -> Text.literal("━━━ Available Server Scripts (" + available.size() + ") ━━━").formatted(Formatting.GOLD, Formatting.BOLD), false); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("list.available_header"), available.size()).formatted(Formatting.GOLD, Formatting.BOLD), false); for (Identifier id : available) { String displayId = getDisplayId(id); Text statusIcon = enabled.contains(id) diff --git a/src/main/java/dev/amble/lib/command/SetSkinCommand.java b/src/main/java/dev/amble/lib/command/SetSkinCommand.java index fb54d56..9f2a762 100644 --- a/src/main/java/dev/amble/lib/command/SetSkinCommand.java +++ b/src/main/java/dev/amble/lib/command/SetSkinCommand.java @@ -12,13 +12,16 @@ import net.minecraft.command.argument.EntityArgumentType; import net.minecraft.entity.Entity; import net.minecraft.server.command.ServerCommandSource; -import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.text.Text; import static net.minecraft.server.command.CommandManager.argument; import static net.minecraft.server.command.CommandManager.literal; public class SetSkinCommand { + private static String translationKey(String key) { + return "command." + AmbleKit.MOD_ID + ".skin." + key; + } + public static void register(CommandDispatcher dispatcher) { dispatcher.register(literal(AmbleKit.MOD_ID) .requires(source -> source.hasPermissionLevel(2)) @@ -38,19 +41,19 @@ private static int executeClear(CommandContext context) { entity = EntityArgumentType.getEntity(context, "target"); if (!(entity instanceof PlayerSkinTexturable)) { - context.getSource().sendError(Text.literal("Target is not a PlayerSkinTexturable")); + context.getSource().sendError(Text.translatable(translationKey("error.not_texturable"))); return 0; } texturable = (PlayerSkinTexturable) entity; } catch (CommandSyntaxException e) { - context.getSource().sendError(Text.literal("Invalid Target")); + context.getSource().sendError(Text.translatable(translationKey("error.invalid_target"))); return 0; } SkinTracker.getInstance().removeSynced(texturable.getUuid()); String username = entity.getEntityName(); - context.getSource().sendFeedback(() -> Text.literal("Cleared skin of "+ username), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("cleared"), username), true); return 1; } @@ -64,19 +67,19 @@ private static int executeSlim(CommandContext context) { entity = EntityArgumentType.getEntity(context, "target"); if (!(entity instanceof PlayerSkinTexturable)) { - context.getSource().sendError(Text.literal("Target is not a PlayerSkinTexturable")); + context.getSource().sendError(Text.translatable(translationKey("error.not_texturable"))); return 0; } texturable = (PlayerSkinTexturable) entity; } catch (CommandSyntaxException e) { - context.getSource().sendError(Text.literal("Invalid Target")); + context.getSource().sendError(Text.translatable(translationKey("error.invalid_target"))); return 0; } SkinData data = SkinTracker.getInstance().get(texturable.getUuid()); if (data == null) { - context.getSource().sendError(Text.literal("Target is not disguised.")); + context.getSource().sendError(Text.translatable(translationKey("error.not_disguised"))); return 0; } @@ -85,7 +88,7 @@ private static int executeSlim(CommandContext context) { SkinTracker.getInstance().putSynced(texturable.getUuid(), data); String username = entity.getEntityName(); - context.getSource().sendFeedback(() -> Text.literal("Set slimness of "+ username +" to " + slim), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("slimness_set"), username, slim), true); return 1; } @@ -99,13 +102,13 @@ private static int execute(CommandContext context) { entity = EntityArgumentType.getEntity(context, "target"); if (!(entity instanceof PlayerSkinTexturable)) { - context.getSource().sendError(Text.literal("Target is not a PlayerSkinTexturable")); + context.getSource().sendError(Text.translatable(translationKey("error.not_texturable"))); return 0; } texturable = (PlayerSkinTexturable) entity; } catch (CommandSyntaxException e) { - context.getSource().sendError(Text.literal("Invalid Target")); + context.getSource().sendError(Text.translatable(translationKey("error.invalid_target"))); return 0; } @@ -117,7 +120,7 @@ private static int execute(CommandContext context) { SkinTracker.getInstance().putSynced(texturable.getUuid(), data); String username = entity.getEntityName(); - context.getSource().sendFeedback(() -> Text.literal("Set skin of " + username + " to " + value), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("set"), username, value), true); return 1; } @@ -126,7 +129,7 @@ private static int execute(CommandContext context) { result.upload(entity.getUuid()); String username = entity.getEntityName(); - context.getSource().sendFeedback(() -> Text.literal("Set skin of " + username + " to " + value), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("set"), username, value), true); }); return 1; @@ -142,13 +145,13 @@ private static int executeWithSlim(CommandContext context) entity = EntityArgumentType.getEntity(context, "target"); if (!(entity instanceof PlayerSkinTexturable)) { - context.getSource().sendError(Text.literal("Target is not a PlayerSkinTexturable")); + context.getSource().sendError(Text.translatable(translationKey("error.not_texturable"))); return 0; } texturable = (PlayerSkinTexturable) entity; } catch (CommandSyntaxException e) { - context.getSource().sendError(Text.literal("Invalid Target")); + context.getSource().sendError(Text.translatable(translationKey("error.invalid_target"))); return 0; } @@ -159,7 +162,7 @@ private static int executeWithSlim(CommandContext context) SkinTracker.getInstance().putSynced(texturable.getUuid(), data); String username = entity.getEntityName(); - context.getSource().sendFeedback(() -> Text.literal("Set skin of "+ username +" to " + value), true); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("set"), username, value), true); return 1; } diff --git a/src/main/java/dev/amble/lib/datagen/AmbleKitDatagen.java b/src/main/java/dev/amble/lib/datagen/AmbleKitDatagen.java new file mode 100644 index 0000000..b8e6b84 --- /dev/null +++ b/src/main/java/dev/amble/lib/datagen/AmbleKitDatagen.java @@ -0,0 +1,67 @@ +package dev.amble.lib.datagen; + +import dev.amble.lib.datagen.lang.AmbleLanguageProvider; +import dev.amble.lib.datagen.lang.LanguageType; +import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint; +import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator; +import net.fabricmc.fabric.api.datagen.v1.FabricDataOutput; + +public class AmbleKitDatagen implements DataGeneratorEntrypoint { + @Override + public void onInitializeDataGenerator(FabricDataGenerator generator) { + FabricDataGenerator.Pack pack = generator.createPack(); + pack.addProvider(AmbleKitLanguageProvider::new); + } + + public static class AmbleKitLanguageProvider extends AmbleLanguageProvider { + public AmbleKitLanguageProvider(FabricDataOutput output) { + super(output, LanguageType.EN_US); + } + + @Override + public void generateTranslations(TranslationBuilder builder) { + // Skin command translations + addTranslation("command." + modid + ".skin.error.not_texturable", "Target is not a PlayerSkinTexturable"); + addTranslation("command." + modid + ".skin.error.invalid_target", "Invalid Target"); + addTranslation("command." + modid + ".skin.error.not_disguised", "Target is not disguised."); + addTranslation("command." + modid + ".skin.cleared", "Cleared skin of %s"); + addTranslation("command." + modid + ".skin.slimness_set", "Set slimness of %s to %s"); + addTranslation("command." + modid + ".skin.set", "Set skin of %s to %s"); + + // Server script command translations + addTranslation("command." + modid + ".script.error.not_found", "Server script '%s' not found"); + addTranslation("command." + modid + ".script.error.no_execute", "Server script '%s' has no onExecute function"); + addTranslation("command." + modid + ".script.error.execute_failed", "Failed to execute server script '%s': %s"); + addTranslation("command." + modid + ".script.error.already_enabled", "Server script '%s' is already enabled"); + addTranslation("command." + modid + ".script.error.enable_failed", "Failed to enable server script '%s'"); + addTranslation("command." + modid + ".script.error.not_enabled", "Server script '%s' is not enabled"); + addTranslation("command." + modid + ".script.error.disable_failed", "Failed to disable server script '%s'"); + addTranslation("command." + modid + ".script.executed", "Executed server script: %s"); + addTranslation("command." + modid + ".script.enabled", "Enabled server script: %s"); + addTranslation("command." + modid + ".script.disabled", "Disabled server script: %s"); + addTranslation("command." + modid + ".script.list.none_enabled", "No server scripts are currently enabled"); + addTranslation("command." + modid + ".script.list.enabled_header", "━━━ Enabled Server Scripts (%s) ━━━"); + addTranslation("command." + modid + ".script.list.none_available", "No server scripts available"); + addTranslation("command." + modid + ".script.list.available_header", "━━━ Available Server Scripts (%s) ━━━"); + + // Client script command translations + addTranslation("command." + modid + ".client_script.error.not_found", "Script '%s' not found"); + addTranslation("command." + modid + ".client_script.error.no_execute", "Script '%s' has no onExecute function"); + addTranslation("command." + modid + ".client_script.error.execute_failed", "Failed to execute script '%s': %s"); + addTranslation("command." + modid + ".client_script.error.already_enabled", "Script '%s' is already enabled"); + addTranslation("command." + modid + ".client_script.error.enable_failed", "Failed to enable script '%s'"); + addTranslation("command." + modid + ".client_script.error.not_enabled", "Script '%s' is not enabled"); + addTranslation("command." + modid + ".client_script.error.disable_failed", "Failed to disable script '%s'"); + addTranslation("command." + modid + ".client_script.executed", "Executed script: %s"); + addTranslation("command." + modid + ".client_script.enabled", "Enabled script: %s"); + addTranslation("command." + modid + ".client_script.disabled", "Disabled script: %s"); + addTranslation("command." + modid + ".client_script.list.none_enabled", "No client scripts are currently enabled"); + addTranslation("command." + modid + ".client_script.list.enabled_header", "━━━ Enabled Client Scripts (%s) ━━━"); + addTranslation("command." + modid + ".client_script.list.none_available", "No client scripts available"); + addTranslation("command." + modid + ".client_script.list.available_header", "━━━ Available Client Scripts (%s) ━━━"); + + super.generateTranslations(builder); + } + } +} + diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 0b68ed9..24d2aa1 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -21,6 +21,9 @@ ], "client": [ "dev.amble.lib.client.AmbleKitClient" + ], + "fabric-datagen": [ + "dev.amble.lib.datagen.AmbleKitDatagen" ] }, "mixins": [ diff --git a/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java b/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java index fb8d024..1ec3a7e 100644 --- a/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java +++ b/src/test/java/dev/amble/litmus/commands/TestScreenCommand.java @@ -19,12 +19,16 @@ public class TestScreenCommand { + private static String translationKey(String key) { + return "command." + LitmusMod.MOD_ID + ".screen." + key; + } + public static void register(CommandDispatcher dispatcher) { dispatcher.register(ClientCommandManager.literal("ambleScreen").executes(source -> { - source.getSource().sendFeedback(Text.literal("Available screens: ")); + source.getSource().sendFeedback(Text.translatable(translationKey("available"))); for (AmbleContainer container : AmbleGuiRegistry.getInstance().toList()) { - source.getSource().sendFeedback(Text.literal(" - " + container.id().toString())); + source.getSource().sendFeedback(Text.translatable(translationKey("list_item"), container.id().toString())); } MinecraftClient.getInstance().execute(() -> { @@ -35,7 +39,7 @@ public static void register(CommandDispatcher dispatc AmbleButton child3 = AmbleButton.buttonBuilder().layout(new Rectangle(0,0, 75, 40)).horizontalAlign(UIAlign.CENTRE).background(Color.GREEN).hoverDisplay(Color.YELLOW).pressDisplay(Color.RED).onClick(() -> { System.out.println("Button Clicked!"); }).build(); - AmbleText child4 = AmbleText.textBuilder().background(AmbleDisplayType.color(new Color(0,0,0,0))).text(Text.literal("press me")).build(); + AmbleText child4 = AmbleText.textBuilder().background(AmbleDisplayType.color(new Color(0,0,0,0))).text(Text.translatable("gui." + LitmusMod.MOD_ID + ".test_button")).build(); child4.setPreferredLayout(child3.getPreferredLayout()); child3.addChild(child4); container.setPadding(10); @@ -55,7 +59,7 @@ public static void register(CommandDispatcher dispatc Identifier id = source.getArgument("id", Identifier.class); AmbleContainer container = AmbleGuiRegistry.getInstance().get(id); if (container == null) { - source.getSource().sendError(Text.literal("No screen found with id: " + id.toString())); + source.getSource().sendError(Text.translatable(translationKey("not_found"), id.toString())); return 0; } diff --git a/src/test/java/dev/amble/litmus/datagen/LitmusDatagen.java b/src/test/java/dev/amble/litmus/datagen/LitmusDatagen.java new file mode 100644 index 0000000..e08639b --- /dev/null +++ b/src/test/java/dev/amble/litmus/datagen/LitmusDatagen.java @@ -0,0 +1,35 @@ +package dev.amble.litmus.datagen; + +import dev.amble.lib.datagen.lang.AmbleLanguageProvider; +import dev.amble.lib.datagen.lang.LanguageType; +import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint; +import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator; +import net.fabricmc.fabric.api.datagen.v1.FabricDataOutput; + +public class LitmusDatagen implements DataGeneratorEntrypoint { + @Override + public void onInitializeDataGenerator(FabricDataGenerator generator) { + FabricDataGenerator.Pack pack = generator.createPack(); + pack.addProvider(LitmusLanguageProvider::new); + } + + public static class LitmusLanguageProvider extends AmbleLanguageProvider { + public LitmusLanguageProvider(FabricDataOutput output) { + super(output, LanguageType.EN_US); + } + + @Override + public void generateTranslations(TranslationBuilder builder) { + // Test screen command translations + addTranslation("command." + modid + ".screen.available", "Available screens:"); + addTranslation("command." + modid + ".screen.list_item", " - %s"); + addTranslation("command." + modid + ".screen.not_found", "No screen found with id: %s"); + + // GUI translations + addTranslation("gui." + modid + ".test_button", "press me"); + + super.generateTranslations(builder); + } + } +} + diff --git a/src/test/resources/fabric.mod.json b/src/test/resources/fabric.mod.json index be930a6..9690168 100644 --- a/src/test/resources/fabric.mod.json +++ b/src/test/resources/fabric.mod.json @@ -21,7 +21,10 @@ ], "client": [ "dev.amble.litmus.client.LitmusClient" - ] + ], + "fabric-datagen": [ + "dev.amble.litmus.datagen.LitmusDatagen" + ] }, "depends": { "fabricloader": ">=0.16.10", From 82e84b73da5e6c498ddbba8b9966d5f40c70fed1 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 20:10:29 +0000 Subject: [PATCH 25/37] Add generated test files to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index bbb1918..2d0e8ca 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ run/ src/main/generated/.cache src/main/generated +src/test/generated +src/test/generated/.cache + .direnv From 74ecf73ed2c2dd70ad3cc2917da3ad1fd5161857 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 20:11:48 +0000 Subject: [PATCH 26/37] Refactor script suggestion providers for improved clarity and functionality --- .../client/command/ClientScriptCommand.java | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java index 120d005..3fbda58 100644 --- a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java +++ b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java @@ -50,41 +50,55 @@ private static Identifier toFullScriptId(Identifier scriptId) { return scriptId.withPrefixedPath(SCRIPT_PREFIX).withSuffixedPath(SCRIPT_SUFFIX); } - private static final SuggestionProvider SCRIPT_SUGGESTIONS = (context, builder) -> { + private static final SuggestionProvider TICKABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { return CommandSource.suggestIdentifiers( - ScriptManager.getInstance().getCache().keySet().stream() - .map(id -> Identifier.of(id.getNamespace(), getDisplayId(id))), + ScriptManager.getInstance().getCache().entrySet().stream() + .filter(entry -> entry.getValue().onTick() != null && !entry.getValue().onTick().isnil()) + .map(entry -> Identifier.of(entry.getKey().getNamespace(), getDisplayId(entry.getKey()))), builder ); }; - private static final SuggestionProvider ENABLED_SCRIPT_SUGGESTIONS = (context, builder) -> { + private static final SuggestionProvider ENABLED_TICKABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { return CommandSource.suggestIdentifiers( ScriptManager.getInstance().getEnabledScripts().stream() + .filter(id -> { + LuaScript script = ScriptManager.getInstance().getCache().get(id); + return script != null && script.onTick() != null && !script.onTick().isnil(); + }) .map(id -> Identifier.of(id.getNamespace(), getDisplayId(id))), builder ); }; + private static final SuggestionProvider EXECUTABLE_SCRIPT_SUGGESTIONS = (context, builder) -> { + return CommandSource.suggestIdentifiers( + ScriptManager.getInstance().getCache().entrySet().stream() + .filter(entry -> entry.getValue().onExecute() != null && !entry.getValue().onExecute().isnil()) + .map(entry -> Identifier.of(entry.getKey().getNamespace(), getDisplayId(entry.getKey()))), + builder + ); + }; + public static void register(CommandDispatcher dispatcher) { dispatcher.register(literal("amblescript") .then(literal("execute") .then(argument("id", IdentifierArgumentType.identifier()) - .suggests(SCRIPT_SUGGESTIONS) + .suggests(EXECUTABLE_SCRIPT_SUGGESTIONS) .executes(context -> execute(context, "")) .then(argument("args", StringArgumentType.greedyString()) .executes(context -> execute(context, StringArgumentType.getString(context, "args")))))) .then(literal("enable") .then(argument("id", IdentifierArgumentType.identifier()) - .suggests(SCRIPT_SUGGESTIONS) + .suggests(TICKABLE_SCRIPT_SUGGESTIONS) .executes(ClientScriptCommand::enable))) .then(literal("disable") .then(argument("id", IdentifierArgumentType.identifier()) - .suggests(ENABLED_SCRIPT_SUGGESTIONS) + .suggests(ENABLED_TICKABLE_SCRIPT_SUGGESTIONS) .executes(ClientScriptCommand::disable))) .then(literal("toggle") .then(argument("id", IdentifierArgumentType.identifier()) - .suggests(SCRIPT_SUGGESTIONS) + .suggests(TICKABLE_SCRIPT_SUGGESTIONS) .executes(ClientScriptCommand::toggle))) .then(literal("list") .executes(ClientScriptCommand::listEnabled)) From 2196af38e64d5ed56418ce5109c2811b22d4fc65 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 20:20:26 +0000 Subject: [PATCH 27/37] Add cross-script function calling support and enhance Lua scripting documentation --- LUA_SCRIPTING.md | 116 ++++++++++++++++ .../lib/script/lua/ClientMinecraftData.java | 10 +- .../amble/lib/script/lua/MinecraftData.java | 130 ++++++++++++++++++ .../lib/script/lua/ServerMinecraftData.java | 9 ++ 4 files changed, 264 insertions(+), 1 deletion(-) diff --git a/LUA_SCRIPTING.md b/LUA_SCRIPTING.md index a14a91f..8843682 100644 --- a/LUA_SCRIPTING.md +++ b/LUA_SCRIPTING.md @@ -102,6 +102,10 @@ The `mc` parameter provides access to Minecraft data. Methods vary by side: | `mc:runCommand(command)` | Execute a command | | `mc:sendMessage(text, overlay)` | Send message to player (overlay = action bar) | | `mc:log(message)` | Log to console | +| `mc:callScript(scriptId, funcName, ...)` | Call a function from another script | +| `mc:getScriptGlobal(scriptId, varName)` | Get a global variable from another script | +| `mc:setScriptGlobal(scriptId, varName, value)` | Set a global variable in another script | +| `mc:availableScripts()` | Get list of all available script identifiers | ### Client-Only Methods | Method | Description | @@ -431,6 +435,118 @@ function applyNpcSkin(mc) end ``` +### Cross-Script Function Calling + +Scripts can call functions defined in other scripts on the same side. Client scripts can call other client scripts, and server scripts can call other server scripts. + +**Utility Library Script:** +```lua +-- assets/mymod/script/utils.lua (or data/mymod/script/utils.lua for server) +-- A reusable utility library + +-- Format a number with commas (e.g., 1234567 -> "1,234,567") +function formatNumber(num) + local formatted = tostring(num) + local k + while true do + formatted, k = formatted:gsub("^(-?%d+)(%d%d%d)", '%1,%2') + if k == 0 then break end + end + return formatted +end + +-- Calculate distance between two positions +function distance(pos1, pos2) + local dx = pos2.x - pos1.x + local dy = pos2.y - pos1.y + local dz = pos2.z - pos1.z + return math.sqrt(dx*dx + dy*dy + dz*dz) +end + +-- Shared state for other scripts +sharedData = { + lastUpdate = 0, + counter = 0 +} +``` + +**Script Using the Utility Library:** +```lua +-- assets/mymod/script/stats_display.lua +-- Uses utility functions from the utils script + +function onExecute(mc) + local player = mc:player() + local pos = player:position() + + -- Call formatNumber from the utils script + local healthFormatted = mc:callScript("mymod:utils", "formatNumber", math.floor(player:health())) + mc:sendMessage("§aHealth: §f" .. healthFormatted, false) + + -- Read shared state from another script + local sharedData = mc:getScriptGlobal("mymod:utils", "sharedData") + if sharedData then + mc:sendMessage("§7Counter: " .. tostring(sharedData.counter), false) + end + + -- Update shared state in another script + mc:setScriptGlobal("mymod:utils", "sharedData", { + lastUpdate = mc:worldTime(), + counter = (sharedData and sharedData.counter or 0) + 1 + }) +end +``` + +**Server Script Calling Other Server Scripts:** +```lua +-- data/mymod/script/admin_tools.lua +-- Reusable admin utilities + +function warnPlayer(playerName, reason) + return "§c[WARNING] §f" .. reason +end + +function kickMessage(playerName) + return "You have been kicked by an administrator." +end +``` + +```lua +-- data/mymod/script/moderation.lua +-- Uses admin_tools for moderation actions + +function onExecute(mc, args) + if args[1] == nil then + mc:sendMessage("§cUsage: /serverscript execute mymod:moderation [reason]", false) + return + end + + local targetPlayer = args[1] + local reason = args[2] or "No reason provided" + + -- Call warnPlayer from admin_tools + local warning = mc:callScript("mymod:admin_tools", "warnPlayer", targetPlayer, reason) + mc:broadcastToPlayer(targetPlayer, warning, false) + mc:sendMessage("§aWarned " .. targetPlayer, false) +end +``` + +**Listing Available Scripts:** +```lua +-- assets/mymod/script/script_browser.lua +-- List all available scripts + +function onExecute(mc) + local scripts = mc:availableScripts() + + mc:sendMessage("§6=== Available Scripts ===", false) + for i, scriptId in ipairs(scripts) do + mc:sendMessage("§7" .. i .. ". §f" .. scriptId, false) + end + mc:sendMessage("§7Total: §e" .. #scripts .. " scripts", false) +end +``` + --- ## GUI Integration diff --git a/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java b/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java index ffc9bc5..3e563f9 100644 --- a/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java @@ -3,6 +3,8 @@ import dev.amble.lib.AmbleKit; import dev.amble.lib.client.gui.AmbleContainer; import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; +import dev.amble.lib.script.AbstractScriptManager; +import dev.amble.lib.script.ScriptManager; import net.minecraft.SharedConstants; import net.minecraft.client.MinecraftClient; import net.minecraft.client.util.InputUtil; @@ -19,7 +21,6 @@ import net.minecraft.util.math.Direction; import net.minecraft.world.World; -import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; import java.util.stream.StreamSupport; @@ -326,4 +327,11 @@ public int windowWidth() { public int windowHeight() { return mc.getWindow() != null ? mc.getWindow().getScaledHeight() : 0; } + + // ===== Cross-script function calling ===== + + @Override + protected AbstractScriptManager getScriptManager() { + return ScriptManager.getInstance(); + } } diff --git a/src/main/java/dev/amble/lib/script/lua/MinecraftData.java b/src/main/java/dev/amble/lib/script/lua/MinecraftData.java index f3a3d16..b675897 100644 --- a/src/main/java/dev/amble/lib/script/lua/MinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/MinecraftData.java @@ -1,6 +1,8 @@ package dev.amble.lib.script.lua; import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.AbstractScriptManager; +import dev.amble.lib.script.LuaScript; import net.minecraft.entity.Entity; import net.minecraft.registry.Registries; import net.minecraft.server.world.ServerWorld; @@ -9,6 +11,8 @@ import net.minecraft.util.Identifier; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; import java.util.Comparator; import java.util.List; @@ -199,4 +203,130 @@ public void logWarn(String message) { public void logError(String message) { AmbleKit.LOGGER.error("{} {}", getLogPrefix(), message); } + + // ===== Cross-script function calling ===== + + /** + * Gets the script manager for this side. + */ + protected abstract AbstractScriptManager getScriptManager(); + + /** + * Converts a user-friendly script ID to the internal identifier format. + * Handles both "modid:scriptname" and full "modid:script/scriptname.lua" formats. + */ + private Identifier toFullScriptId(String scriptId) { + Identifier id = new Identifier(scriptId); + String path = id.getPath(); + if (!path.startsWith("script/")) { + path = "script/" + path; + } + if (!path.endsWith(".lua")) { + path = path + ".lua"; + } + return new Identifier(id.getNamespace(), path); + } + + /** + * Converts a full internal script identifier to display format. + * Removes the "script/" prefix and ".lua" suffix. + */ + private String toDisplayId(Identifier id) { + String path = id.getPath(); + if (path.startsWith("script/")) { + path = path.substring(7); + } + if (path.endsWith(".lua")) { + path = path.substring(0, path.length() - 4); + } + return id.getNamespace() + ":" + path; + } + + /** + * Gets the identifiers of all available scripts. + * + * @return list of script identifiers in "modid:scriptname" format + */ + @LuaExpose + public List availableScripts() { + return getScriptManager().getCache().keySet().stream() + .map(this::toDisplayId) + .collect(Collectors.toList()); + } + + /** + * Calls a function from another script. + * + * @param scriptId the script identifier (e.g., "modid:scriptname") + * @param functionName the name of the function to call + * @param args the arguments to pass to the function + * @return the result of the function call, or nil if the function doesn't exist + */ + @LuaExpose + public Object callScript(String scriptId, String functionName, Object... args) { + Identifier fullId = toFullScriptId(scriptId); + LuaScript script = getScriptManager().getCache().get(fullId); + if (script == null) { + logWarn("Cannot call function '" + functionName + "': script '" + scriptId + "' not found"); + return null; + } + + LuaValue function = script.globals().get(functionName); + if (function.isnil()) { + logWarn("Function '" + functionName + "' not found in script '" + scriptId + "'"); + return null; + } + + try { + // Convert Java args to Lua values + LuaValue[] luaArgs = new LuaValue[args.length]; + for (int i = 0; i < args.length; i++) { + luaArgs[i] = LuaBinder.coerceResult(args[i]); + } + + Varargs result = function.invoke(LuaValue.varargsOf(luaArgs)); + return result.arg1(); + } catch (Exception e) { + logError("Error calling function '" + functionName + "' in script '" + scriptId + "': " + e.getMessage()); + return null; + } + } + + /** + * Gets a global variable from another script. + * + * @param scriptId the script identifier (e.g., "modid:scriptname") + * @param variableName the name of the global variable to get + * @return the value of the variable, or nil if it doesn't exist + */ + @LuaExpose + public Object getScriptGlobal(String scriptId, String variableName) { + Identifier fullId = toFullScriptId(scriptId); + LuaScript script = getScriptManager().getCache().get(fullId); + if (script == null) { + logWarn("Cannot get global '" + variableName + "': script '" + scriptId + "' not found"); + return null; + } + + return script.globals().get(variableName); + } + + /** + * Sets a global variable in another script. + * + * @param scriptId the script identifier (e.g., "modid:scriptname") + * @param variableName the name of the global variable to set + * @param value the value to set + */ + @LuaExpose + public void setScriptGlobal(String scriptId, String variableName, Object value) { + Identifier fullId = toFullScriptId(scriptId); + LuaScript script = getScriptManager().getCache().get(fullId); + if (script == null) { + logWarn("Cannot set global '" + variableName + "': script '" + scriptId + "' not found"); + return; + } + + script.globals().set(variableName, LuaBinder.coerceResult(value)); + } } diff --git a/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java b/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java index bc82c61..0eba0df 100644 --- a/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java @@ -1,6 +1,8 @@ package dev.amble.lib.script.lua; import dev.amble.lib.AmbleKit; +import dev.amble.lib.script.AbstractScriptManager; +import dev.amble.lib.script.ServerScriptManager; import dev.amble.lib.skin.SkinData; import dev.amble.lib.skin.SkinTracker; import dev.amble.lib.util.ServerLifecycleHooks; @@ -411,4 +413,11 @@ public boolean hasSkinByUuid(String uuidString) { if (uuid == null) return false; return SkinTracker.getInstance().containsKey(uuid); } + + // ===== Cross-script function calling ===== + + @Override + protected AbstractScriptManager getScriptManager() { + return ServerScriptManager.getInstance(); + } } From 2d72265262d7e81e53145d9c118d13bf747ebfe3 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 20:28:50 +0000 Subject: [PATCH 28/37] Implement AmbleElement interface in LuaElement and add findFirstText method for improved Lua scripting support --- LUA_SCRIPTING.md | 14 +- .../amble/lib/client/gui/lua/LuaElement.java | 150 ++++++++++++++++-- .../dev/amble/lib/script/lua/LuaBinder.java | 22 +++ 3 files changed, 164 insertions(+), 22 deletions(-) diff --git a/LUA_SCRIPTING.md b/LUA_SCRIPTING.md index 8843682..9406757 100644 --- a/LUA_SCRIPTING.md +++ b/LUA_SCRIPTING.md @@ -601,7 +601,7 @@ The `self` parameter provides access to the GUI element: | Method | Description | |--------|-------------| -| `self:id()` | Element's identifier | +| `self:id()` | Element's identifier (as string) | | `self:x()`, `self:y()` | Current position | | `self:width()`, `self:height()` | Current dimensions | | `self:setPosition(x, y)` | Update position | @@ -610,6 +610,7 @@ The `self` parameter provides access to the GUI element: | `self:parent()` | Parent LuaElement (or nil) | | `self:child(index)` | Get child at index (0-based) | | `self:childCount()` | Number of children | +| `self:findFirstText()` | Find first text element in tree (or nil) | | `self:getText()` | Get text content (text elements only) | | `self:setText(text)` | Set text content (text elements only) | | `self:closeScreen()` | Close the current screen | @@ -657,13 +658,10 @@ function onClick(self, mouseX, mouseY, button) clickCount = clickCount + 1 local mc = self:minecraft() - -- Update a child text element - for i = 0, self:childCount() - 1 do - local child = self:child(i) - if child:getText() then - child:setText("Clicked: " .. clickCount) - break - end + -- Update the first text element found in this element's tree + local textElement = self:findFirstText() + if textElement then + textElement:setText("Clicked: " .. clickCount) end -- Show player inventory info diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java index b4508bb..0a89136 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java +++ b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java @@ -4,22 +4,28 @@ import dev.amble.lib.script.lua.ClientMinecraftData; import dev.amble.lib.script.lua.LuaExpose; import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; import net.minecraft.text.Text; +import net.minecraft.util.Identifier; import net.minecraft.util.math.Vec2f; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; +import java.util.List; /** * A Lua-friendly wrapper around {@link AmbleElement}. *

- * This class uses the wrapper/facade pattern to expose a simplified API for Lua scripts. - * It intentionally does NOT extend AmbleElement because: + * This class implements AmbleElement by delegating to a wrapped element, + * while also exposing a simplified API for Lua scripts. *

    - *
  • It provides only the methods that make sense for Lua scripting
  • + *
  • It provides only the methods that make sense for Lua scripting via @LuaExpose
  • *
  • It converts Java types to Lua-compatible return values
  • - *
  • It encapsulates the underlying element, preventing direct manipulation from Lua
  • + *
  • It implements AmbleElement by delegating to the wrapped element
  • *
*/ -public final class LuaElement { +public final class LuaElement implements AmbleElement { private final AmbleElement element; private final ClientMinecraftData minecraftData = new ClientMinecraftData(); @@ -28,11 +34,110 @@ public LuaElement(AmbleElement element) { this.element = element; } - @LuaExpose - public String id() { - return element.id().toString(); + // ===== AmbleElement implementation (delegating to wrapped element) ===== + + @Override + public Identifier id() { + return element.id(); + } + + @Override + public boolean isVisible() { + return element.isVisible(); + } + + @Override + public Rectangle getLayout() { + return element.getLayout(); + } + + @Override + public void setLayout(Rectangle layout) { + element.setLayout(layout); + } + + @Override + public Rectangle getPreferredLayout() { + return element.getPreferredLayout(); + } + + @Override + public void setPreferredLayout(Rectangle preferredLayout) { + element.setPreferredLayout(preferredLayout); + } + + @Override + public @Nullable AmbleElement getParent() { + return element.getParent(); + } + + @Override + public void setParent(@Nullable AmbleElement parent) { + element.setParent(parent); + } + + @Override + public int getPadding() { + return element.getPadding(); + } + + @Override + public void setPadding(int padding) { + element.setPadding(padding); + } + + @Override + public int getSpacing() { + return element.getSpacing(); + } + + @Override + public void setSpacing(int spacing) { + element.setSpacing(spacing); } + @Override + public UIAlign getHorizontalAlign() { + return element.getHorizontalAlign(); + } + + @Override + public void setHorizontalAlign(UIAlign align) { + element.setHorizontalAlign(align); + } + + @Override + public UIAlign getVerticalAlign() { + return element.getVerticalAlign(); + } + + @Override + public void setVerticalAlign(UIAlign align) { + element.setVerticalAlign(align); + } + + @Override + public boolean requiresNewRow() { + return element.requiresNewRow(); + } + + @Override + public void setRequiresNewRow(boolean requiresNewRow) { + element.setRequiresNewRow(requiresNewRow); + } + + @Override + public List getChildren() { + return element.getChildren(); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + element.render(context, mouseX, mouseY, delta); + } + + // ===== Lua-exposed methods ===== + @LuaExpose public int x() { return element.getLayout().x; @@ -63,22 +168,21 @@ public void setDimensions(int width, int height) { element.setDimensions(new Vec2f(width, height)); } + @Override @LuaExpose public void setVisible(boolean visible) { element.setVisible(visible); } @LuaExpose - public LuaElement parent() { - return element.getParent() == null - ? null - : new LuaElement(element.getParent()); + public AmbleElement parent() { + return element.getParent(); } @LuaExpose - public LuaElement child(int index) { + public AmbleElement child(int index) { if (index < 0 || index >= element.getChildren().size()) return null; - return new LuaElement(element.getChildren().get(index)); + return element.getChildren().get(index); } @LuaExpose @@ -86,6 +190,24 @@ public int childCount() { return element.getChildren().size(); } + @LuaExpose + public AmbleText findFirstText() { + return findFirstTextRecursive(element); + } + + private static AmbleText findFirstTextRecursive(AmbleElement element) { + if (element instanceof AmbleText text) { + return text; + } + for (AmbleElement child : element.getChildren()) { + AmbleText found = findFirstTextRecursive(child); + if (found != null) { + return found; + } + } + return null; + } + @LuaExpose public void setText(String text) { if (element instanceof AmbleText t) { diff --git a/src/main/java/dev/amble/lib/script/lua/LuaBinder.java b/src/main/java/dev/amble/lib/script/lua/LuaBinder.java index 80285ca..d33ef7c 100644 --- a/src/main/java/dev/amble/lib/script/lua/LuaBinder.java +++ b/src/main/java/dev/amble/lib/script/lua/LuaBinder.java @@ -1,10 +1,13 @@ package dev.amble.lib.script.lua; +import dev.amble.lib.client.gui.AmbleElement; +import dev.amble.lib.client.gui.lua.LuaElement; import net.minecraft.entity.Entity; import net.minecraft.item.ItemStack; import net.minecraft.nbt.NbtCompound; import net.minecraft.nbt.NbtElement; import net.minecraft.nbt.NbtList; +import net.minecraft.util.Identifier; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Vec3d; import org.joml.Vector3f; @@ -167,6 +170,23 @@ public static LuaValue coerceEntity(Entity entity) { return bind(new MinecraftEntity(entity)); } + /** + * Coerces an Identifier to a Lua string. + */ + public static LuaValue coerceIdentifier(Identifier identifier) { + return LuaString.valueOf(identifier.toString()); + } + + /** + * Coerces an AmbleElement to a bound LuaElement. + */ + public static LuaValue coerceAmbleElement(AmbleElement element) { + if (element instanceof LuaElement luaElement) { + return bind(luaElement); + } + return bind(new LuaElement(element)); + } + /** * Coerces an NbtCompound to a Lua table. */ @@ -256,6 +276,8 @@ public static LuaValue coerceResult(Object obj) { if (obj instanceof Entity entity) return coerceEntity(entity); if (obj instanceof NbtCompound nbt) return coerceNbtCompound(nbt); if (obj instanceof NbtElement nbt) return coerceNbtElement(nbt); + if (obj instanceof Identifier id) return coerceIdentifier(id); + if (obj instanceof AmbleElement element) return coerceAmbleElement(element); // Fall back to binding the object return bind(obj); From cd69e617025347ad1cc1aba43ee553ae626571bb Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 20:29:52 +0000 Subject: [PATCH 29/37] Refactor getDisplayId method to improve string manipulation for script identifiers --- .../java/dev/amble/lib/client/command/ClientScriptCommand.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java index 3fbda58..fe5d5f6 100644 --- a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java +++ b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java @@ -40,7 +40,8 @@ private static String translationKey(String key) { * Removes the "script/" prefix and ".lua" suffix. */ private static String getDisplayId(Identifier id) { - return id.getPath().replace(SCRIPT_PREFIX, "").replace(SCRIPT_SUFFIX, ""); + String path = id.getPath(); + return path.substring(SCRIPT_PREFIX.length(), path.length() - SCRIPT_SUFFIX.length()); } /** From 671edfddc388d1eb4925565b69db3670e6a1e6db Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 20:41:25 +0000 Subject: [PATCH 30/37] Add AmbleButton and AmbleText parsers for JSON element handling --- .../dev/amble/lib/client/gui/AmbleButton.java | 95 ++++++++++ .../dev/amble/lib/client/gui/AmbleText.java | 58 +++++++ .../gui/registry/AmbleButtonParser.java | 128 ++++++++++++++ .../gui/registry/AmbleElementParser.java | 74 ++++++++ .../client/gui/registry/AmbleGuiRegistry.java | 163 +++++++----------- 5 files changed, 418 insertions(+), 100 deletions(-) create mode 100644 src/main/java/dev/amble/lib/client/gui/registry/AmbleButtonParser.java create mode 100644 src/main/java/dev/amble/lib/client/gui/registry/AmbleElementParser.java diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java index c77b29a..2fc05b9 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java @@ -1,12 +1,18 @@ package dev.amble.lib.client.gui; +import com.google.gson.JsonObject; import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.registry.AmbleElementParser; import dev.amble.lib.script.lua.LuaBinder; import dev.amble.lib.client.gui.lua.LuaElement; import dev.amble.lib.script.LuaScript; +import dev.amble.lib.script.ScriptManager; import lombok.*; +import net.minecraft.SharedConstants; +import net.minecraft.client.MinecraftClient; import net.minecraft.client.gui.DrawContext; +import net.minecraft.util.Identifier; import org.jetbrains.annotations.Nullable; import org.luaj.vm2.LuaValue; import org.luaj.vm2.Varargs; @@ -178,4 +184,93 @@ public Builder onClick(Runnable onClick) { return this; } } + + /** + * Parser for AmbleButton elements. + *

+ * This parser handles JSON objects that have button-specific properties: + * on_click, script, hover_background, or press_background. + */ + public static class Parser implements AmbleElementParser { + + @Override + public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) { + // Check if this is a button (has button-specific properties) + boolean isButton = json.has("on_click") || json.has("script") || json.has("hover_background") || json.has("press_background"); + + if (!isButton) { + return null; + } + + // Handle text for button - use AmbleText.Parser to create the text child + if (json.has("text")) { + // Create a temporary container to parse text into + AmbleText textChild = (AmbleText) new AmbleText.Parser().parse(json, resourceId, + AmbleText.textBuilder() + .layout(new Rectangle(base.getLayout())) + .background(new Color(0, 0, 0, 0)) + .build()); + if (textChild != null) { + base.addChild(textChild); + } + } + + AmbleButton button = AmbleButton.buttonBuilder().build(); + button.copyFrom(base); + + if (json.has("on_click")) { + // todo run actual java methods via reflection + String clickCommand = json.get("on_click").getAsString(); + button.setOnClick(() -> { + try { + String string2 = SharedConstants.stripInvalidChars(clickCommand); + if (string2.startsWith("/")) { + if (!MinecraftClient.getInstance().player.networkHandler.sendCommand(string2.substring(1))) { + AmbleKit.LOGGER.error("Not allowed to run command with signed argument from click event: '{}'", string2); + } + } else { + AmbleKit.LOGGER.error("Failed to run command without '/' prefix from click event: '{}'", string2); + } + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while running command from click event: '{}'", clickCommand, e); + } + }); + } else { + button.setOnClick(() -> { + }); + } + + if (json.has("script")) { + Identifier scriptId = new Identifier(json.get("script").getAsString()).withPrefixedPath("script/").withSuffixedPath(".lua"); + LuaScript script = ScriptManager.getInstance().load( + scriptId, + MinecraftClient.getInstance().getResourceManager() + ); + + button.setScript(script); + } + + if (json.has("hover_background")) { + AmbleDisplayType hoverBg = AmbleDisplayType.parse(json.get("hover_background")); + button.setHoverDisplay(hoverBg); + } else { + button.setHoverDisplay(button.getBackground()); + } + + if (json.has("press_background")) { + AmbleDisplayType pressBg = AmbleDisplayType.parse(json.get("press_background")); + button.setPressDisplay(pressBg); + } else { + button.setPressDisplay(button.getBackground()); + } + + return button; + } + + @Override + public int priority() { + // Button has higher priority than text since buttons can have text + return 100; + } + } } diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleText.java b/src/main/java/dev/amble/lib/client/gui/AmbleText.java index 63028ad..cb89668 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleText.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleText.java @@ -1,6 +1,9 @@ package dev.amble.lib.client.gui; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.amble.lib.client.gui.registry.AmbleElementParser; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,6 +13,8 @@ import net.minecraft.client.gui.DrawContext; import net.minecraft.text.OrderedText; import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; import java.util.List; @@ -159,4 +164,57 @@ public Builder shadow(boolean shadow) { return this; } } + + /** + * Parser for AmbleText elements. + *

+ * This parser handles JSON objects that have the "text" property but are not buttons. + * Note: This parser has lower priority than AmbleButton.Parser, so buttons with text + * will be handled by the button parser instead. + */ + public static class Parser implements AmbleElementParser { + + @Override + public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) { + if (!json.has("text")) { + return null; + } + + String context = resourceId != null ? " (resource: " + resourceId + ")" : ""; + String text = json.get("text").getAsString(); + + // Parse text alignment + UIAlign textHorizAlign = UIAlign.CENTRE; + UIAlign textVertAlign = UIAlign.CENTRE; + if (json.has("text_alignment")) { + if (!json.get("text_alignment").isJsonArray()) { + throw new IllegalStateException("UI text Alignment must be array [horizontal, vertical]" + context); + } + + JsonArray alignmentArray = json.get("text_alignment").getAsJsonArray(); + if (alignmentArray.size() < 2) { + throw new IllegalStateException("UI text Alignment array must have at least 2 elements" + context); + } + String horizAlignKey = alignmentArray.get(0).getAsString(); + String vertAlignKey = alignmentArray.get(1).getAsString(); + + textHorizAlign = UIAlign.valueOf(horizAlignKey.toUpperCase()); + textVertAlign = UIAlign.valueOf(vertAlignKey.toUpperCase()); + } + + // Convert the container to AmbleText + AmbleText ambleText = AmbleText.textBuilder().text(Text.translatable(text)).build(); + ambleText.copyFrom(base); + ambleText.setTextHorizontalAlign(textHorizAlign); + ambleText.setTextVerticalAlign(textVertAlign); + + return ambleText; + } + + @Override + public int priority() { + // Lower priority than button parser since buttons can have text + return 50; + } + } } diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleButtonParser.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleButtonParser.java new file mode 100644 index 0000000..ddcb594 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleButtonParser.java @@ -0,0 +1,128 @@ +package dev.amble.lib.client.gui.registry; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.*; +import dev.amble.lib.script.LuaScript; +import dev.amble.lib.script.ScriptManager; +import net.minecraft.SharedConstants; +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; + +/** + * Parser for AmbleButton elements. + *

+ * This parser handles JSON objects that have button-specific properties: + * on_click, script, hover_background, or press_background. + */ +public class AmbleButtonParser implements AmbleElementParser { + + @Override + public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) { + // Check if this is a button (has button-specific properties) + boolean isButton = json.has("on_click") || json.has("script") || json.has("hover_background") || json.has("press_background"); + + if (!isButton) { + return null; + } + + String context = resourceId != null ? " (resource: " + resourceId + ")" : ""; + + // Handle text for button - add as child + if (json.has("text")) { + String text = json.get("text").getAsString(); + + // Parse text alignment + UIAlign textHorizAlign = UIAlign.CENTRE; + UIAlign textVertAlign = UIAlign.CENTRE; + if (json.has("text_alignment")) { + if (!json.get("text_alignment").isJsonArray()) { + throw new IllegalStateException("UI text Alignment must be array [horizontal, vertical]" + context); + } + + JsonArray alignmentArray = json.get("text_alignment").getAsJsonArray(); + if (alignmentArray.size() < 2) { + throw new IllegalStateException("UI text Alignment array must have at least 2 elements" + context); + } + String horizAlignKey = alignmentArray.get(0).getAsString(); + String vertAlignKey = alignmentArray.get(1).getAsString(); + + textHorizAlign = UIAlign.valueOf(horizAlignKey.toUpperCase()); + textVertAlign = UIAlign.valueOf(vertAlignKey.toUpperCase()); + } + + // For buttons with text, create a child AmbleText element with transparent background + AmbleText textChild = AmbleText.textBuilder() + .text(Text.translatable(text)) + .textHorizontalAlign(textHorizAlign) + .textVerticalAlign(textVertAlign) + .layout(new Rectangle(base.getLayout())) + .background(new Color(0, 0, 0, 0)) + .build(); + base.addChild(textChild); + } + + AmbleButton button = AmbleButton.buttonBuilder().build(); + button.copyFrom(base); + + if (json.has("on_click")) { + // todo run actual java methods via reflection + String clickCommand = json.get("on_click").getAsString(); + button.setOnClick(() -> { + try { + String string2 = SharedConstants.stripInvalidChars(clickCommand); + if (string2.startsWith("/")) { + if (!MinecraftClient.getInstance().player.networkHandler.sendCommand(string2.substring(1))) { + AmbleKit.LOGGER.error("Not allowed to run command with signed argument from click event: '{}'", string2); + } + } else { + AmbleKit.LOGGER.error("Failed to run command without '/' prefix from click event: '{}'", string2); + } + } catch (Exception e) { + AmbleKit.LOGGER.error("Error occurred while running command from click event: '{}'", clickCommand, e); + } + }); + } else { + button.setOnClick(() -> { + }); + } + + if (json.has("script")) { + Identifier scriptId = new Identifier(json.get("script").getAsString()).withPrefixedPath("script/").withSuffixedPath(".lua"); + LuaScript script = ScriptManager.getInstance().load( + scriptId, + MinecraftClient.getInstance().getResourceManager() + ); + + button.setScript(script); + } + + if (json.has("hover_background")) { + AmbleDisplayType hoverBg = AmbleDisplayType.parse(json.get("hover_background")); + button.setHoverDisplay(hoverBg); + } else { + button.setHoverDisplay(button.getBackground()); + } + + if (json.has("press_background")) { + AmbleDisplayType pressBg = AmbleDisplayType.parse(json.get("press_background")); + button.setPressDisplay(pressBg); + } else { + button.setPressDisplay(button.getBackground()); + } + + return button; + } + + @Override + public int priority() { + // Button has higher priority than text since buttons can have text + return 100; + } +} + diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleElementParser.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleElementParser.java new file mode 100644 index 0000000..80cb35f --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleElementParser.java @@ -0,0 +1,74 @@ +package dev.amble.lib.client.gui.registry; + +import com.google.gson.JsonObject; +import dev.amble.lib.client.gui.AmbleContainer; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +/** + * Interface for custom element parsers that can be registered with {@link AmbleGuiRegistry}. + *

+ * Mods can implement this interface to add support for custom GUI element types. + * When a JSON object is being parsed, all registered parsers are checked in order + * until one returns a non-null result. + *

+ * Example usage: + *

{@code
+ * // Register a custom parser during mod initialization
+ * AmbleGuiRegistry.getInstance().registerParser(new AmbleElementParser() {
+ *     @Override
+ *     public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) {
+ *         if (json.has("my_custom_type") && json.get("my_custom_type").getAsBoolean()) {
+ *             // Create your custom element and copy state from the base container
+ *             MyCustomContainer custom = new MyCustomContainer();
+ *             custom.copyFrom(base);
+ *             // Apply custom properties...
+ *             return custom;
+ *         }
+ *         // Return null to let other parsers handle it
+ *         return null;
+ *     }
+ *
+ *     @Override
+ *     public int priority() {
+ *         return 100; // Higher priority runs first
+ *     }
+ * });
+ * }
+ */ +@FunctionalInterface +public interface AmbleElementParser { + + /** + * Attempts to parse the given JSON object into an AmbleContainer. + *

+ * Implementations should check if the JSON contains properties specific to their + * custom element type. If it does, parse and return the element. If not, return null + * to allow other parsers (or the default parser) to handle it. + *

+ * The base container has already been parsed with all standard properties (layout, + * background, padding, spacing, alignment, children, etc). Custom parsers should + * use {@link AmbleContainer#copyFrom(AmbleContainer)} to copy this state to their + * custom element type. + * + * @param json the JSON object to parse + * @param resourceId the identifier of the resource being parsed (for error context), may be null + * @param base the base AmbleContainer already parsed with standard properties + * @return the parsed AmbleContainer, or null if this parser cannot handle the given JSON + */ + @Nullable + AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base); + + /** + * Returns the priority of this parser. Higher values are checked first. + *

+ * The default parser has a priority of 0. Custom parsers that want to override + * default behavior should return a positive value. + * + * @return the priority of this parser + */ + default int priority() { + return 0; + } +} + diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index 4c111a5..d83ba04 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -5,18 +5,14 @@ import com.google.gson.JsonParser; import dev.amble.lib.AmbleKit; import dev.amble.lib.client.gui.*; -import dev.amble.lib.script.LuaScript; import dev.amble.lib.script.ScriptManager; import dev.amble.lib.register.datapack.DatapackRegistry; import net.fabricmc.fabric.api.resource.ResourceManagerHelper; import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; -import net.minecraft.SharedConstants; -import net.minecraft.client.MinecraftClient; import net.minecraft.network.PacketByteBuf; import net.minecraft.resource.ResourceManager; import net.minecraft.resource.ResourceType; import net.minecraft.server.network.ServerPlayerEntity; -import net.minecraft.text.Text; import net.minecraft.util.Identifier; import org.apache.commons.lang3.NotImplementedException; @@ -24,14 +20,45 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; // TODO: Consider removing dependency on DatapackRegistry - see team discussion public class AmbleGuiRegistry extends DatapackRegistry implements SimpleSynchronousResourceReloadListener { private static final AmbleGuiRegistry INSTANCE = new AmbleGuiRegistry(); + private final List parsers = new CopyOnWriteArrayList<>(); private AmbleGuiRegistry() { ResourceManagerHelper.get(ResourceType.CLIENT_RESOURCES).registerReloadListener(this); + + // Register default parsers + registerParser(new AmbleButton.Parser()); + registerParser(new AmbleText.Parser()); + } + + /** + * Registers a custom element parser. + *

+ * Parsers are called in order of priority (highest first) when parsing JSON. + * If a parser returns null, the next parser is tried. If all custom parsers + * return null, the default parsing logic is used. + * + * @param parser the parser to register + */ + public void registerParser(AmbleElementParser parser) { + parsers.add(parser); + parsers.sort(Comparator.comparingInt(AmbleElementParser::priority).reversed()); + } + + /** + * Unregisters a custom element parser. + * + * @param parser the parser to unregister + * @return true if the parser was found and removed + */ + public boolean unregisterParser(AmbleElementParser parser) { + return parsers.remove(parser); } /** @@ -66,6 +93,10 @@ public static AmbleContainer parse(JsonObject json) { /** * Parses a JSON object into an AmbleContainer. + *

+ * This method first parses the base container properties, then checks all + * registered custom parsers in order of priority. If a parser returns a + * non-null result, that result is returned. Otherwise, the base container is returned. * * @param json the JSON object to parse * @param resourceId the identifier of the resource being parsed (for error context), may be null @@ -73,6 +104,34 @@ public static AmbleContainer parse(JsonObject json) { * @throws IllegalStateException if required fields are missing or invalid */ public static AmbleContainer parse(JsonObject json, Identifier resourceId) { + // First parse the base container with all standard properties + AmbleContainer base = parseBase(json, resourceId); + + // Check custom parsers, passing the base container + for (AmbleElementParser parser : INSTANCE.parsers) { + AmbleContainer result = parser.parse(json, resourceId, base); + if (result != null) { + return result; + } + } + + // No parser handled it, return the base container + return base; + } + + /** + * Parses the base AmbleContainer properties from JSON. + *

+ * This method parses all standard container properties (layout, background, + * padding, spacing, alignment, children, etc.) and returns a base AmbleContainer. + * Custom parsers can then copy this state to their custom element types. + * + * @param json the JSON object to parse + * @param resourceId the identifier of the resource being parsed (for error context), may be null + * @return the parsed base AmbleContainer + * @throws IllegalStateException if required fields are missing or invalid + */ + public static AmbleContainer parseBase(JsonObject json, Identifier resourceId) { String context = resourceId != null ? " (resource: " + resourceId + ")" : ""; // first parse background @@ -176,102 +235,6 @@ public static AmbleContainer parse(JsonObject json, Identifier resourceId) { created.setIdentifier(parsedId); } - // Check if this is a button (has button-specific properties) - boolean isButton = json.has("on_click") || json.has("script") || json.has("hover_background") || json.has("press_background"); - - if (json.has("text")) { - String text = json.get("text").getAsString(); - - // Parse text alignment (used for both standalone text and button text) - UIAlign textHorizAlign = UIAlign.CENTRE; - UIAlign textVertAlign = UIAlign.CENTRE; - if (json.has("text_alignment")) { - if (!json.get("text_alignment").isJsonArray()) { - throw new IllegalStateException("UI text Alignment must be array [horizontal, vertical]" + context); - } - - JsonArray alignmentArray = json.get("text_alignment").getAsJsonArray(); - if (alignmentArray.size() < 2) { - throw new IllegalStateException("UI text Alignment array must have at least 2 elements" + context); - } - String horizAlignKey = alignmentArray.get(0).getAsString(); - String vertAlignKey = alignmentArray.get(1).getAsString(); - - textHorizAlign = UIAlign.valueOf(horizAlignKey.toUpperCase()); - textVertAlign = UIAlign.valueOf(vertAlignKey.toUpperCase()); - } - - if (isButton) { - // For buttons with text, create a child AmbleText element with transparent background - AmbleText textChild = AmbleText.textBuilder() - .text(Text.translatable(text)) - .textHorizontalAlign(textHorizAlign) - .textVerticalAlign(textVertAlign) - .layout(new Rectangle(layout)) - .background(new Color(0, 0, 0, 0)) - .build(); - created.addChild(textChild); - } else { - // For non-buttons, convert the container to AmbleText - AmbleText ambleText = AmbleText.textBuilder().text(Text.translatable(text)).build(); - ambleText.copyFrom(created); - ambleText.setTextHorizontalAlign(textHorizAlign); - ambleText.setTextVerticalAlign(textVertAlign); - created = ambleText; - } - } - - if (isButton) { - AmbleButton button = AmbleButton.buttonBuilder().build(); - button.copyFrom(created); - created = button; - - if (json.has("on_click")) { - // todo run actual java methods via reflection - String clickCommand = json.get("on_click").getAsString(); - button.setOnClick(() -> { - try { - String string2 = SharedConstants.stripInvalidChars(clickCommand); - if (string2.startsWith("/")) { - if (!MinecraftClient.getInstance().player.networkHandler.sendCommand(string2.substring(1))) { - AmbleKit.LOGGER.error("Not allowed to run command with signed argument from click event: '{}'", string2); - } - } else { - AmbleKit.LOGGER.error("Failed to run command without '/' prefix from click event: '{}'", string2); - } - } catch (Exception e) { - AmbleKit.LOGGER.error("Error occurred while running command from click event: '{}'", clickCommand, e); - } - }); - } else { - button.setOnClick(() -> { - }); - } - - if (json.has("script")) { - Identifier scriptId = new Identifier(json.get("script").getAsString()).withPrefixedPath("script/").withSuffixedPath(".lua"); - LuaScript script = ScriptManager.getInstance().load( - scriptId, - MinecraftClient.getInstance().getResourceManager() - ); - - button.setScript(script); - } - - if (json.has("hover_background")) { - AmbleDisplayType hoverBg = AmbleDisplayType.parse(json.get("hover_background")); - button.setHoverDisplay(hoverBg); - } else { - button.setHoverDisplay(button.getBackground()); - } - - if (json.has("press_background")) { - AmbleDisplayType pressBg = AmbleDisplayType.parse(json.get("press_background")); - button.setPressDisplay(pressBg); - } else { - button.setPressDisplay(button.getBackground()); - } - } return created; } From 50a5fec6df659552655b6d98c2613cfc78d69ffe Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 21:15:34 +0000 Subject: [PATCH 31/37] Add AmbleEntityDisplay component for dynamic entity rendering in GUI --- GUI_SYSTEM.md | 105 +++++- LUA_SCRIPTING.md | 13 + .../amble/lib/client/gui/AmbleContainer.java | 3 + .../lib/client/gui/AmbleEntityDisplay.java | 342 ++++++++++++++++++ .../amble/lib/client/gui/lua/LuaElement.java | 87 +++++ .../client/gui/registry/AmbleGuiRegistry.java | 1 + .../resources/assets/litmus/gui/test.json | 241 ++++++++++-- .../resources/assets/litmus/script/test.lua | 160 ++++++-- 8 files changed, 893 insertions(+), 59 deletions(-) create mode 100644 src/main/java/dev/amble/lib/client/gui/AmbleEntityDisplay.java diff --git a/GUI_SYSTEM.md b/GUI_SYSTEM.md index 957944e..04d12ba 100644 --- a/GUI_SYSTEM.md +++ b/GUI_SYSTEM.md @@ -9,6 +9,7 @@ AmbleKit provides a declarative JSON-based GUI system that lets you create Minec - [Properties Reference](#properties-reference) - [Background Types](#background-types) - [Text Elements](#text-elements) +- [Entity Display Elements](#entity-display-elements) - [Buttons & Interactivity](#buttons--interactivity) - [Lua Script Integration](#lua-script-integration) - [Displaying Screens](#displaying-screens) @@ -193,6 +194,95 @@ Control text positioning within the element: --- +## Entity Display Elements + +Display living entities within a GUI element using the `entity_uuid` property. This renders the entity in an inventory-screen style, perfect for character viewers, mob displays, or player previews. + +### Basic Entity Display + +```json +{ + "layout": [60, 80], + "background": [40, 40, 60, 200], + "entity_uuid": "player" +} +``` + +The special value `"player"` automatically uses the local player's UUID. + +### Entity Display Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `entity_uuid` | string | required | Entity UUID or `"player"` for local player | +| `follow_cursor` | boolean | `false` | Entity rotates to follow the mouse cursor | +| `look_at` | [x, y] | center | Fixed position the entity looks at (relative to element) | +| `entity_scale` | float | `1.0` | Scale multiplier for entity rendering | + +### Follow Cursor Mode + +Make the entity rotate to track the mouse cursor: + +```json +{ + "layout": [60, 80], + "background": [40, 40, 60], + "entity_uuid": "player", + "follow_cursor": true, + "entity_scale": 0.9 +} +``` + +### Fixed Look-At Position + +Set a specific point the entity should look at: + +```json +{ + "layout": [60, 80], + "background": [60, 40, 40], + "entity_uuid": "", + "follow_cursor": false, + "look_at": [30, 20] +} +``` + +Coordinates are relative to the element's top-left corner. + +### Dynamic Entity Display + +Set the entity UUID dynamically via Lua scripts: + +```json +{ + "id": "mymod:mob_display", + "layout": [60, 80], + "background": [50, 50, 50], + "entity_uuid": "" +} +``` + +```lua +function onInit(self) + local mc = self:minecraft() + local nearest = mc:nearestEntity(20) + + -- Find the entity display by ID + local display = findChildById(root, "mymod:mob_display") + if display and nearest then + display:setEntityUuid(nearest:uuid()) + end +end +``` + +### Notes + +- Only `LivingEntity` types (players, mobs, animals) can be rendered +- Non-living entities or invalid UUIDs display "N/A" +- The entity is looked up each render frame using a cached approach for efficiency + +--- + ## Buttons & Interactivity Adding any of these properties converts an element into a button: @@ -278,7 +368,7 @@ The `self` parameter provides access to the GUI element: | Method | Description | |--------|-------------| -| `self:id()` | Element's identifier | +| `self:id()` | Element's identifier (as string) | | `self:x()`, `self:y()` | Current position | | `self:width()`, `self:height()` | Current dimensions | | `self:setPosition(x, y)` | Update position | @@ -292,6 +382,19 @@ The `self` parameter provides access to the GUI element: | `self:closeScreen()` | Close the current screen | | `self:minecraft()` | Get MinecraftData for world/player access | +#### Entity Display Methods + +These methods only work on `AmbleEntityDisplay` elements: + +| Method | Description | +|--------|-------------| +| `self:getEntityUuid()` | Get entity UUID as string (or nil) | +| `self:setEntityUuid(uuid)` | Set entity UUID (string or "player") | +| `self:isFollowCursor()` | Check if entity follows cursor | +| `self:setFollowCursor(bool)` | Enable/disable cursor following | +| `self:setLookAt(x, y)` | Set fixed look-at position | +| `self:setEntityScale(scale)` | Set entity scale multiplier | + ### Example GUI Script ```lua diff --git a/LUA_SCRIPTING.md b/LUA_SCRIPTING.md index 9406757..ab907d4 100644 --- a/LUA_SCRIPTING.md +++ b/LUA_SCRIPTING.md @@ -616,6 +616,19 @@ The `self` parameter provides access to the GUI element: | `self:closeScreen()` | Close the current screen | | `self:minecraft()` | Get MinecraftData for full API access | +#### Entity Display Methods + +These methods only work on `AmbleEntityDisplay` elements (elements with `entity_uuid` property): + +| Method | Description | +|--------|-------------| +| `self:getEntityUuid()` | Get entity UUID as string (or nil) | +| `self:setEntityUuid(uuid)` | Set entity UUID (string or "player") | +| `self:isFollowCursor()` | Check if entity follows cursor | +| `self:setFollowCursor(bool)` | Enable/disable cursor following | +| `self:setLookAt(x, y)` | Set fixed look-at position (relative to element) | +| `self:setEntityScale(scale)` | Set entity scale multiplier | + ### Accessing Minecraft Data from GUI Scripts Use `self:minecraft()` to get a MinecraftData object with full API access: diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java index 2165bdd..c6bbf0d 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java @@ -136,6 +136,9 @@ public void copyFrom(AmbleContainer other) { this.verticalAlign = other.verticalAlign; this.requiresNewRow = other.requiresNewRow; this.background = other.background; + this.identifier = other.identifier; + this.title = other.title; + this.shouldPause = other.shouldPause; this.children.forEach(e -> e.setParent(null)); this.children.clear(); other.children.forEach(this::addChild); diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleEntityDisplay.java b/src/main/java/dev/amble/lib/client/gui/AmbleEntityDisplay.java new file mode 100644 index 0000000..e31ef17 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleEntityDisplay.java @@ -0,0 +1,342 @@ +package dev.amble.lib.client.gui; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.amble.lib.client.gui.registry.AmbleElementParser; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.ingame.InventoryScreen; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.Vec2f; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; +import java.util.UUID; + +/** + * A GUI element that displays an entity within a rectangular area. + *

+ * The entity is rendered using Minecraft's inventory-style entity rendering. + * Supports dynamic entity lookup by UUID, cursor-following for entity rotation, + * and fixed look-at positions. + */ +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class AmbleEntityDisplay extends AmbleContainer { + /** + * Special marker UUID for "use local player". All zeros. + */ + private static final UUID PLAYER_MARKER_UUID = new UUID(0L, 0L); + + /** + * The UUID of the entity to display. If null, displays "N/A". + * If set to PLAYER_MARKER_UUID, uses the local player. + */ + private @Nullable UUID entityUuid; + + /** + * Whether the entity should rotate to follow the mouse cursor. + * If false, uses {@link #fixedLookAt} position instead. + */ + @Setter + private boolean followCursor = false; + + /** + * The fixed position the entity should look at when {@link #followCursor} is false. + * Coordinates are relative to the element's position. + * Defaults to center of the element if not set. + */ + @Setter + private @Nullable Vec2f fixedLookAt = null; + + /** + * Scale multiplier for the entity rendering. + */ + @Setter + private float entityScale = 1.0f; + + // Entity cache + private transient @Nullable UUID cachedUuid = null; + private transient @Nullable LivingEntity cachedEntity = null; + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + + Rectangle layout = getLayout(); + + // Try to find the entity (using cache) + LivingEntity livingEntity = findLivingEntity(); + + if (livingEntity == null) { + // Render "N/A" text centered in the rectangle + renderNoEntity(context, layout); + return; + } + + // Calculate rendering parameters + int centerX = layout.x + layout.width / 2; + int bottomY = layout.y + layout.height - getPadding() - 5; // Offset from bottom + + // Calculate the look-at position relative to entity center + float lookAtX, lookAtY; + if (followCursor) { + lookAtX = centerX - mouseX; + lookAtY = (layout.y + layout.height / 3.0f) - mouseY; + } else if (fixedLookAt != null) { + lookAtX = centerX - (layout.x + fixedLookAt.x); + lookAtY = (layout.y + layout.height / 3.0f) - (layout.y + fixedLookAt.y); + } else { + // Default: look straight ahead + lookAtX = 0; + lookAtY = 0; + } + + // Calculate entity size to fit in the rectangle with a small margin + float entityHeight = livingEntity.getHeight(); + int availableHeight = layout.height - getPadding() * 2; + int size = (int) ((availableHeight / entityHeight) * (entityScale - 0.1f)); + + // Use Minecraft's built-in entity rendering with mouse-based rotation + InventoryScreen.drawEntity(context, centerX, bottomY, size, lookAtX, lookAtY, livingEntity); + } + + /** + * Renders "N/A" text when no entity is available. + */ + private void renderNoEntity(DrawContext context, Rectangle layout) { + TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; + String text = "N/A"; + int textWidth = textRenderer.getWidth(text); + int textX = layout.x + (layout.width - textWidth) / 2; + int textY = layout.y + (layout.height - textRenderer.fontHeight) / 2; + context.drawText(textRenderer, text, textX, textY, 0xAAAAAA, false); + } + + /** + * Finds the living entity in the world by UUID, using cache for efficiency. + * + * @return the living entity, or null if not found or not a LivingEntity + */ + private @Nullable LivingEntity findLivingEntity() { + if (entityUuid == null) { + cachedEntity = null; + cachedUuid = null; + return null; + } + + MinecraftClient client = MinecraftClient.getInstance(); + if (client.world == null) { + return null; + } + + // Handle special "player" marker UUID + UUID lookupUuid = entityUuid; + if (PLAYER_MARKER_UUID.equals(entityUuid)) { + if (client.player != null) { + lookupUuid = client.player.getUuid(); + } else { + return null; + } + } + + // Check if cache is valid + if (lookupUuid.equals(cachedUuid) && cachedEntity != null && cachedEntity.isAlive()) { + return cachedEntity; + } + + // Cache miss - look up entity + cachedUuid = lookupUuid; + cachedEntity = null; + + // First try to find as player (more efficient) + Entity player = client.world.getPlayerByUuid(lookupUuid); + if (player instanceof LivingEntity living) { + cachedEntity = living; + return living; + } + + // Search through all entities + for (Entity entity : client.world.getEntities()) { + if (lookupUuid.equals(entity.getUuid()) && entity instanceof LivingEntity living) { + cachedEntity = living; + return living; + } + } + + return null; + } + + /** + * Invalidates the entity cache, forcing a re-lookup on next render. + */ + public void invalidateEntityCache() { + cachedEntity = null; + cachedUuid = null; + } + + /** + * Sets the entity UUID. Also invalidates the cache. + */ + public void setEntityUuid(@Nullable UUID entityUuid) { + if (!java.util.Objects.equals(this.entityUuid, entityUuid)) { + this.entityUuid = entityUuid; + invalidateEntityCache(); + } + } + + /** + * Sets the entity UUID from a string. + * + * @param uuidString the UUID string, or "player" for the local player + */ + public void setEntityUuidFromString(@Nullable String uuidString) { + if (uuidString == null || uuidString.isEmpty()) { + setEntityUuid(null); + return; + } + + if ("player".equalsIgnoreCase(uuidString)) { + // Use special marker that will be resolved at render time + setEntityUuid(PLAYER_MARKER_UUID); + return; + } + + try { + setEntityUuid(UUID.fromString(uuidString)); + } catch (IllegalArgumentException e) { + setEntityUuid(null); + } + } + + /** + * Gets the entity UUID as a string. + * + * @return the UUID string, "player" for local player marker, or null if not set + */ + public @Nullable String getEntityUuidAsString() { + if (entityUuid == null) { + return null; + } + if (PLAYER_MARKER_UUID.equals(entityUuid)) { + return "player"; + } + return entityUuid.toString(); + } + + public static Builder entityDisplayBuilder() { + return new Builder(); + } + + public static class Builder extends AbstractBuilder { + + @Override + protected AmbleEntityDisplay create() { + return new AmbleEntityDisplay(); + } + + @Override + protected Builder self() { + return this; + } + + public Builder entityUuid(UUID uuid) { + container.setEntityUuid(uuid); + return this; + } + + public Builder entityUuid(String uuidString) { + container.setEntityUuidFromString(uuidString); + return this; + } + + public Builder followCursor(boolean followCursor) { + container.setFollowCursor(followCursor); + return this; + } + + public Builder fixedLookAt(Vec2f lookAt) { + container.setFixedLookAt(lookAt); + return this; + } + + public Builder fixedLookAt(float x, float y) { + container.setFixedLookAt(new Vec2f(x, y)); + return this; + } + + public Builder entityScale(float scale) { + container.setEntityScale(scale); + return this; + } + } + + /** + * Parser for AmbleEntityDisplay elements. + *

+ * This parser handles JSON objects that have the "entity_uuid" property. + *

+ * Supported JSON properties: + *

    + *
  • {@code entity_uuid} - String UUID or "player" for local player
  • + *
  • {@code follow_cursor} - Boolean, whether entity follows mouse (default: false)
  • + *
  • {@code look_at} - Array [x, y] for fixed look position relative to element
  • + *
  • {@code entity_scale} - Float scale multiplier (default: 1.0)
  • + *
+ */ + public static class Parser implements AmbleElementParser { + + @Override + public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) { + if (!json.has("entity_uuid")) { + return null; + } + + AmbleEntityDisplay display = AmbleEntityDisplay.entityDisplayBuilder().build(); + display.copyFrom(base); + + // Parse entity UUID + String uuidString = json.get("entity_uuid").getAsString(); + display.setEntityUuidFromString(uuidString); + + // Parse follow_cursor + if (json.has("follow_cursor")) { + display.setFollowCursor(json.get("follow_cursor").getAsBoolean()); + } + + // Parse look_at position + if (json.has("look_at")) { + if (json.get("look_at").isJsonArray()) { + JsonArray lookAtArray = json.get("look_at").getAsJsonArray(); + if (lookAtArray.size() >= 2) { + float x = lookAtArray.get(0).getAsFloat(); + float y = lookAtArray.get(1).getAsFloat(); + display.setFixedLookAt(new Vec2f(x, y)); + } + } + } + + // Parse entity_scale + if (json.has("entity_scale")) { + display.setEntityScale(json.get("entity_scale").getAsFloat()); + } + + return display; + } + + @Override + public int priority() { + // Higher than text (50), lower than button (100) + return 75; + } + } +} + diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java index 0a89136..c1a5d24 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java +++ b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java @@ -36,11 +36,13 @@ public LuaElement(AmbleElement element) { // ===== AmbleElement implementation (delegating to wrapped element) ===== + @LuaExpose @Override public Identifier id() { return element.id(); } + @LuaExpose @Override public boolean isVisible() { return element.isVisible(); @@ -233,6 +235,91 @@ public ClientMinecraftData minecraft() { return minecraftData; } + // ===== AmbleEntityDisplay methods ===== + + /** + * Gets the entity UUID as a string. + * Only works if the underlying element is an AmbleEntityDisplay. + * + * @return the UUID string, or null if not an entity display or no UUID set + */ + @LuaExpose + public String getEntityUuid() { + if (element instanceof AmbleEntityDisplay display) { + return display.getEntityUuidAsString(); + } + return null; + } + + /** + * Sets the entity UUID from a string. + * Only works if the underlying element is an AmbleEntityDisplay. + * Accepts a UUID string or "player" for the local player. + * + * @param uuid the UUID string, or "player" for local player + */ + @LuaExpose + public void setEntityUuid(String uuid) { + if (element instanceof AmbleEntityDisplay display) { + display.setEntityUuidFromString(uuid); + } + } + + /** + * Checks if the entity display follows the cursor. + * Only works if the underlying element is an AmbleEntityDisplay. + * + * @return true if following cursor, false otherwise (or if not an entity display) + */ + @LuaExpose + public boolean isFollowCursor() { + if (element instanceof AmbleEntityDisplay display) { + return display.isFollowCursor(); + } + return false; + } + + /** + * Sets whether the entity display should follow the cursor. + * Only works if the underlying element is an AmbleEntityDisplay. + * + * @param followCursor true to follow cursor, false to use fixed look-at position + */ + @LuaExpose + public void setFollowCursor(boolean followCursor) { + if (element instanceof AmbleEntityDisplay display) { + display.setFollowCursor(followCursor); + } + } + + /** + * Sets the fixed look-at position for the entity display. + * Only works if the underlying element is an AmbleEntityDisplay. + * Coordinates are relative to the element's position. + * + * @param x the x coordinate + * @param y the y coordinate + */ + @LuaExpose + public void setLookAt(int x, int y) { + if (element instanceof AmbleEntityDisplay display) { + display.setFixedLookAt(new Vec2f(x, y)); + } + } + + /** + * Sets the entity scale for the entity display. + * Only works if the underlying element is an AmbleEntityDisplay. + * + * @param scale the scale multiplier (1.0 = normal size) + */ + @LuaExpose + public void setEntityScale(float scale) { + if (element instanceof AmbleEntityDisplay display) { + display.setEntityScale(scale); + } + } + /** * Returns the underlying AmbleElement wrapped by this LuaElement. * This method is for internal use only and should not be called from Lua scripts. diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index d83ba04..690369f 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -35,6 +35,7 @@ private AmbleGuiRegistry() { // Register default parsers registerParser(new AmbleButton.Parser()); registerParser(new AmbleText.Parser()); + registerParser(new AmbleEntityDisplay.Parser()); } /** diff --git a/src/test/resources/assets/litmus/gui/test.json b/src/test/resources/assets/litmus/gui/test.json index 983fc04..58b3e3b 100644 --- a/src/test/resources/assets/litmus/gui/test.json +++ b/src/test/resources/assets/litmus/gui/test.json @@ -1,7 +1,7 @@ { "layout": [ - 216, - 138 + 360, + 200 ], "background": { "texture": "litmus:textures/gui/test_screen.png", @@ -12,61 +12,242 @@ "textureWidth": 256, "textureHeight": 256 }, - "padding": 10, - "spacing": 1, + "padding": 12, + "spacing": 12, "alignment": [ "centre", - "centre" + "start" ], "children": [ { + "id": "litmus:player_display", + "entity_uuid": "player", + "follow_cursor": true, + "entity_scale": 0.9, "layout": [ + 60, + 80 + ], + "background": [ + 40, + 40, + 60, + 200 + ] + }, + { + "layout": [ + 110, + 80 + ], + "background": [ + 30, + 30, 50, - 50 + 180 + ], + "padding": 6, + "spacing": 4, + "alignment": [ + "start", + "start" + ], + "children": [ + { + "id": "litmus:player_name", + "layout": [ + 98, + 14 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Player", + "text_alignment": [ + "start", + "centre" + ] + }, + { + "id": "litmus:player_health", + "layout": [ + 98, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Health: --", + "text_alignment": [ + "start", + "centre" + ] + }, + { + "id": "litmus:player_pos", + "layout": [ + 98, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Pos: --", + "text_alignment": [ + "start", + "centre" + ] + } + ] + }, + { + "requires_new_row": true, + "id": "litmus:nearest_display", + "entity_uuid": "", + "follow_cursor": false, + "look_at": [ + 30, + 20 + ], + "entity_scale": 0.9, + "layout": [ + 60, + 80 ], "background": [ - 0, - 0, - 255 + 60, + 40, + 40, + 200 ] }, { "layout": [ - 25, - 25 + 110, + 80 ], "background": [ - 255, - 200, - 0 + 50, + 30, + 30, + 180 + ], + "padding": 6, + "spacing": 4, + "alignment": [ + "start", + "start" + ], + "children": [ + { + "id": "litmus:nearest_name", + "layout": [ + 98, + 14 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Nearest Entity", + "text_alignment": [ + "start", + "centre" + ] + }, + { + "id": "litmus:nearest_type", + "layout": [ + 98, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Type: --", + "text_alignment": [ + "start", + "centre" + ] + }, + { + "id": "litmus:nearest_health", + "layout": [ + 98, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Health: --", + "text_alignment": [ + "start", + "centre" + ] + }, + { + "id": "litmus:nearest_dist", + "layout": [ + 98, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Dist: --", + "text_alignment": [ + "start", + "centre" + ] + } ] }, { "script": "litmus:test", "hover_background": [ - 255, - 0, - 0 + 80, + 80, + 120 ], "press_background": [ - 0, - 255, - 255 + 60, + 60, + 100 ], "layout": [ - 75, - 40 + 100, + 20 ], "background": [ - 0, - 255, - 0 + 60, + 60, + 90 ], "children": [ { "layout": [ - 75, - 40 + 100, + 20 ], "background": [ 0, @@ -74,7 +255,11 @@ 0, 0 ], - "text": "i been pressed: " + "text": "§f⟳ Refresh", + "text_alignment": [ + "centre", + "centre" + ] } ] } diff --git a/src/test/resources/assets/litmus/script/test.lua b/src/test/resources/assets/litmus/script/test.lua index 4e1e87c..a86aa99 100644 --- a/src/test/resources/assets/litmus/script/test.lua +++ b/src/test/resources/assets/litmus/script/test.lua @@ -1,43 +1,143 @@ --- Test Script: Basic test script for GUI interactions --- This script is designed for GUI element click handlers +-- Test Script: Entity Display Demo +-- This script demonstrates the AmbleEntityDisplay component +-- showing the player and nearest entity with live info updates -- -- Note: GUI scripts receive 'self' (LuaElement) and use self:minecraft() for data -function onClick(self, mouseX, mouseY, button) - local text = self:getText() - if (text == nil) then - --- find first child which is not nil - for i = 0, self:childCount() - 1 do - local child = self:child(i) - local childText = child:getText() - if (childText ~= nil) then - --- set to player username - child:setText("Hello " .. self:minecraft():username() .. "!") - break +-- Helper to find a child element by ID +function findChildById(root, targetId) + if root:id() == targetId then + return root + end + for i = 0, root:childCount() - 1 do + local child = root:child(i) + if child then + local found = findChildById(child, targetId) + if found then + return found end end end + return nil +end - -- search the inventory for an apple and select it if its in the hotbar - local inventory = self:minecraft():player():inventory() -- a table of ItemStacks - for slotIndex, itemStack in pairs(inventory) do - if (itemStack ~= nil and itemStack:id() == "minecraft:apple") then - -- drop apple - self:minecraft():dropStack(slotIndex, true) - break - end +-- Helper to format position +function formatPos(pos) + return string.format("%.0f, %.0f, %.0f", pos.x, pos.y, pos.z) +end + +-- Helper to format health +function formatHealth(current, max) + if current < 0 then + return "--" + end + return string.format("%.1f/%.1f", current, max) +end + +function onInit(self) + local mc = self:minecraft() + local player = mc:player() + + -- Get the root container (parent of parent since we're a child element) + local root = self + while root:parent() do + root = root:parent() end - - -- print all entities - local entities = self:minecraft():entities() - print(entities) - for _, entity in pairs(entities) do - print(entity) - print("Entity: " .. entity:type() .. " at " .. entity:position():toString()) + + -- Update player display - set UUID to local player + local playerDisplay = findChildById(root, "litmus:player_display") + if playerDisplay then + playerDisplay:setEntityUuid("player") + end + + -- Update player info + local playerName = findChildById(root, "litmus:player_name") + if playerName then + playerName:setText("§b" .. mc:username()) + end + + local playerHealth = findChildById(root, "litmus:player_health") + if playerHealth then + local health = player:health() + local maxHealth = player:maxHealth() + playerHealth:setText("§c❤ §f" .. formatHealth(health, maxHealth)) + end + + local playerPos = findChildById(root, "litmus:player_pos") + if playerPos then + local pos = player:position() + playerPos:setText("§7" .. formatPos(pos)) + end + + -- Find nearest entity (excluding player) + local nearest = mc:nearestEntity(20) + + local nearestDisplay = findChildById(root, "litmus:nearest_display") + local nearestName = findChildById(root, "litmus:nearest_name") + local nearestType = findChildById(root, "litmus:nearest_type") + local nearestHealth = findChildById(root, "litmus:nearest_health") + local nearestDist = findChildById(root, "litmus:nearest_dist") + + if nearest then + -- Set entity UUID for display + if nearestDisplay then + nearestDisplay:setEntityUuid(nearest:uuid()) + end + + -- Update info labels + if nearestName then + nearestName:setText("§e" .. nearest:name()) + end + + if nearestType then + local entityType = nearest:type():gsub("minecraft:", "") + nearestType:setText("§7Type: §f" .. entityType) + end + + if nearestHealth then + local health = nearest:health() + local maxHealth = nearest:maxHealth() + if health >= 0 then + nearestHealth:setText("§c❤ §f" .. formatHealth(health, maxHealth)) + else + nearestHealth:setText("§7No health") + end + end + + if nearestDist then + local playerPos = player:position() + local dist = nearest:distanceTo(playerPos.x, playerPos.y, playerPos.z) + nearestDist:setText("§7Dist: §f" .. string.format("%.1f", dist) .. "m") + end + else + -- No entity nearby + if nearestDisplay then + nearestDisplay:setEntityUuid("") + end + if nearestName then + nearestName:setText("§8No entity nearby") + end + if nearestType then + nearestType:setText("§7Type: §8--") + end + if nearestHealth then + nearestHealth:setText("§7Health: §8--") + end + if nearestDist then + nearestDist:setText("§7Dist: §8--") + end end end +function onClick(self, mouseX, mouseY, button) + -- Refresh entity info when clicked + onInit(self) + + local mc = self:minecraft() + mc:playSound("minecraft:ui.button.click", 1.0, 1.0) +end + function onExecute(mc, args) - mc:log("Test script executed via command!") - mc:sendMessage("§aTest script executed!", false) + mc:log("Entity display test script executed!") + mc:sendMessage("§aEntity display demo - open the test GUI to see it!", false) end From 2c722cad01d37b9b189b1c188b88371d237e8624 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 22:13:39 +0000 Subject: [PATCH 32/37] Add AmbleTextInput component and implement focus handling in GUI --- GUI_SYSTEM.md | 175 ++- LUA_SCRIPTING.md | 14 +- .../dev/amble/lib/client/gui/AmbleButton.java | 102 +- .../amble/lib/client/gui/AmbleContainer.java | 133 +++ .../amble/lib/client/gui/AmbleElement.java | 63 ++ .../amble/lib/client/gui/AmbleTextInput.java | 1007 +++++++++++++++++ .../dev/amble/lib/client/gui/Focusable.java | 77 ++ .../amble/lib/client/gui/lua/LuaElement.java | 252 ++++- .../client/gui/registry/AmbleGuiRegistry.java | 1 + .../lib/script/AbstractScriptManager.java | 20 +- .../java/dev/amble/lib/script/LuaScript.java | 69 +- .../dev/amble/lib/script/lua/LuaBinder.java | 3 +- .../assets/litmus/gui/skin_changer.json | 209 ++++ .../assets/litmus/script/skin_changer.lua | 96 ++ .../litmus/script/skin_changer_slim.lua | 79 ++ .../resources/assets/litmus/script/test.lua | 4 +- 16 files changed, 2247 insertions(+), 57 deletions(-) create mode 100644 src/main/java/dev/amble/lib/client/gui/AmbleTextInput.java create mode 100644 src/main/java/dev/amble/lib/client/gui/Focusable.java create mode 100644 src/test/resources/assets/litmus/gui/skin_changer.json create mode 100644 src/test/resources/assets/litmus/script/skin_changer.lua create mode 100644 src/test/resources/assets/litmus/script/skin_changer_slim.lua diff --git a/GUI_SYSTEM.md b/GUI_SYSTEM.md index 04d12ba..2b2f026 100644 --- a/GUI_SYSTEM.md +++ b/GUI_SYSTEM.md @@ -9,6 +9,7 @@ AmbleKit provides a declarative JSON-based GUI system that lets you create Minec - [Properties Reference](#properties-reference) - [Background Types](#background-types) - [Text Elements](#text-elements) +- [Text Input Elements](#text-input-elements) - [Entity Display Elements](#entity-display-elements) - [Buttons & Interactivity](#buttons--interactivity) - [Lua Script Integration](#lua-script-integration) @@ -194,6 +195,139 @@ Control text positioning within the element: --- +## Text Input Elements + +Create interactive text input fields using the `text_input` property. Text inputs support full keyboard navigation, text selection, copy/paste, and horizontal scrolling for long text. + +### Basic Text Input + +```json +{ + "id": "mymod:username_field", + "text_input": true, + "placeholder": "Enter username...", + "layout": [150, 20], + "background": [30, 30, 40] +} +``` + +### Text Input Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `text_input` | boolean | required | Must be `true` to create a text input | +| `placeholder` | string | `""` | Placeholder text shown when empty | +| `max_length` | integer | unlimited | Maximum number of characters allowed | +| `editable` | boolean | `true` | Whether the user can edit the text | +| `text` | string | `""` | Initial text content | +| `text_alignment` | [h, v] | `["start", "centre"]` | Text alignment within the field | + +### Color Customization + +Text inputs support extensive color customization: + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `text_color` | [r,g,b] or [r,g,b,a] | white | Color of the input text | +| `placeholder_color` | [r,g,b] or [r,g,b,a] | gray | Color of placeholder text | +| `selection_color` | [r,g,b,a] | blue | Highlight color for selected text | +| `border_color` | [r,g,b] or [r,g,b,a] | gray | Border color when unfocused | +| `focused_border_color` | [r,g,b] or [r,g,b,a] | light blue | Border color when focused | +| `cursor_color` | [r,g,b] or [r,g,b,a] | white | Color of the text cursor | + +### Styled Text Input Example + +```json +{ + "id": "mymod:styled_input", + "text_input": true, + "placeholder": "Search...", + "max_length": 50, + "layout": [200, 24], + "background": [20, 20, 30], + "border_color": [80, 80, 100], + "focused_border_color": [100, 140, 220], + "selection_color": [80, 120, 200, 128], + "placeholder_color": [100, 100, 120], + "text_color": [255, 255, 255] +} +``` + +### Keyboard Shortcuts + +Text inputs support standard keyboard shortcuts: + +| Shortcut | Action | +|----------|--------| +| `←` / `→` | Move cursor left/right | +| `Ctrl+←` / `Ctrl+→` | Move cursor by word | +| `Shift+←` / `Shift+→` | Select characters | +| `Ctrl+Shift+←` / `Ctrl+Shift+→` | Select words | +| `Home` / `End` | Move to start/end of text | +| `Shift+Home` / `Shift+End` | Select to start/end | +| `Ctrl+A` | Select all text | +| `Ctrl+C` | Copy selected text | +| `Ctrl+X` | Cut selected text | +| `Ctrl+V` | Paste from clipboard | +| `Backspace` | Delete character before cursor | +| `Delete` | Delete character after cursor | +| `Ctrl+Backspace` | Delete word before cursor | +| `Ctrl+Delete` | Delete word after cursor | +| `Tab` | Move focus to next input | +| `Shift+Tab` | Move focus to previous input | + +### Mouse Interactions + +| Action | Result | +|--------|--------| +| Click | Position cursor at click location | +| Click + Drag | Select text range | +| Double-click | Select entire word | +| Shift + Click | Extend selection to click position | + +### Reading Text Input Values in Lua + +```lua +function onClick(self, mouseX, mouseY, button) + local root = getRoot(self) + local usernameInput = findById(root, "mymod:username_field") + + if usernameInput then + local text = usernameInput:getText() + if text and text ~= "" then + -- Use the input value + self:minecraft():sendMessage("You entered: " .. text, false) + end + end +end +``` + +### LuaElement Text Input API + +| Method | Description | +|--------|-------------| +| `self:getText()` | Get the current text content | +| `self:setText(text)` | Set the text content | +| `self:getPlaceholder()` | Get the placeholder text | +| `self:setPlaceholder(text)` | Set the placeholder text | +| `self:getMaxLength()` | Get the maximum length | +| `self:setMaxLength(int)` | Set the maximum length | +| `self:isEditable()` | Check if input is editable | +| `self:setEditable(bool)` | Enable/disable editing | +| `self:isInputFocused()` | Check if input has focus | +| `self:setInputFocused(bool)` | Set focus state | +| `self:getSelectionStart()` | Get selection start index | +| `self:getSelectionEnd()` | Get selection end index | +| `self:setSelection(start, end)` | Set selection range | +| `self:selectAll()` | Select all text | +| `self:setSelectionColor(r,g,b,a)` | Set selection highlight color | +| `self:setBorderColor(r,g,b,a)` | Set border color | +| `self:setFocusedBorderColor(r,g,b,a)` | Set focused border color | +| `self:setTextColor(r,g,b,a)` | Set text color | +| `self:setPlaceholderColor(r,g,b,a)` | Set placeholder color | + +--- + ## Entity Display Elements Display living entities within a GUI element using the `entity_uuid` property. This renders the entity in an inventory-screen style, perfect for character viewers, mob displays, or player previews. @@ -263,7 +397,7 @@ Set the entity UUID dynamically via Lua scripts: ``` ```lua -function onInit(self) +function onDisplay(self) local mc = self:minecraft() local nearest = mc:nearestEntity(20) @@ -357,11 +491,14 @@ GUI scripts use different callbacks than standalone scripts. They receive a `sel | Callback | When Called | Parameters | |----------|-------------|------------| -| `onInit(self)` | When script is attached to element | `self` | +| `onAttached(self)` | When script is attached during JSON parsing (GUI tree not yet built) | `self` | +| `onDisplay(self)` | On first render when GUI tree is fully built | `self` | | `onClick(self, mouseX, mouseY, button)` | Mouse button pressed | `self`, coordinates, button (0=left, 1=right) | | `onRelease(self, mouseX, mouseY, button)` | Mouse button released | `self`, coordinates, button | | `onHover(self, mouseX, mouseY)` | Mouse hovering over element | `self`, coordinates | +**Note:** Use `onDisplay` for operations that require traversing the GUI tree (finding elements by ID, accessing parent/children). Use `onAttached` only for early setup that doesn't depend on other elements. + ### LuaElement API The `self` parameter provides access to the GUI element: @@ -377,11 +514,35 @@ The `self` parameter provides access to the GUI element: | `self:parent()` | Parent LuaElement (or nil) | | `self:child(index)` | Get child at index (0-based) | | `self:childCount()` | Number of children | -| `self:getText()` | Get text content (text elements only) | -| `self:setText(text)` | Set text content (text elements only) | +| `self:getText()` | Get text content (text/text input elements) | +| `self:setText(text)` | Set text content (text/text input elements) | | `self:closeScreen()` | Close the current screen | | `self:minecraft()` | Get MinecraftData for world/player access | +#### Text Input Methods + +These methods only work on `AmbleTextInput` elements: + +| Method | Description | +|--------|-------------| +| `self:getPlaceholder()` | Get placeholder text | +| `self:setPlaceholder(text)` | Set placeholder text | +| `self:getMaxLength()` | Get maximum character limit | +| `self:setMaxLength(int)` | Set maximum character limit | +| `self:isEditable()` | Check if input is editable | +| `self:setEditable(bool)` | Enable/disable editing | +| `self:isInputFocused()` | Check if input has focus | +| `self:setInputFocused(bool)` | Set focus state | +| `self:getSelectionStart()` | Get selection start index | +| `self:getSelectionEnd()` | Get selection end index | +| `self:setSelection(start, end)` | Set selection range | +| `self:selectAll()` | Select all text | +| `self:setSelectionColor(r,g,b,a)` | Set selection highlight color | +| `self:setBorderColor(r,g,b,a)` | Set unfocused border color | +| `self:setFocusedBorderColor(r,g,b,a)` | Set focused border color | +| `self:setTextColor(r,g,b,a)` | Set text color | +| `self:setPlaceholderColor(r,g,b,a)` | Set placeholder text color | + #### Entity Display Methods These methods only work on `AmbleEntityDisplay` elements: @@ -402,9 +563,9 @@ These methods only work on `AmbleEntityDisplay` elements: local clickCount = 0 -function onInit(self) - -- Called when the script is attached to the button - print("Button initialized: " .. self:id()) +function onDisplay(self) + -- Called when the GUI is first displayed (tree is built) + print("Button displayed: " .. self:id()) end function onClick(self, mouseX, mouseY, button) diff --git a/LUA_SCRIPTING.md b/LUA_SCRIPTING.md index ab907d4..b4da54d 100644 --- a/LUA_SCRIPTING.md +++ b/LUA_SCRIPTING.md @@ -71,6 +71,7 @@ Scripts can define the following callback functions. Each receives a `mc` (Minec | Callback | When Called | Use Case | |----------|-------------|----------| +| `onRegister(mc)` | When script is loaded into the ScriptManager | Early initialization, setup globals | | `onExecute(mc, args)` | Via `/amblescript execute` or `/serverscript execute` | One-time actions, parameterized commands | | `onEnable(mc)` | When script is enabled | Initialize state, play sounds | | `onTick(mc)` | Every game tick while enabled | Continuous monitoring, automation | @@ -78,6 +79,8 @@ Scripts can define the following callback functions. Each receives a `mc` (Minec The `args` parameter in `onExecute` is a Lua table containing space-separated arguments from the command (1-indexed, may be empty). +**Note:** `onRegister` is called once when the script is first loaded (during resource pack loading). Use it for one-time setup that doesn't require the game to be fully loaded. + --- ## Minecraft API Reference @@ -590,11 +593,14 @@ GUI scripts use different callbacks than standalone scripts. Instead of `mc`, th | Callback | When Called | Parameters | |----------|-------------|------------| -| `onInit(self)` | When script is attached | `self` | +| `onAttached(self)` | When script is attached during JSON parsing (GUI tree not built yet) | `self` | +| `onDisplay(self)` | On first render when GUI tree is fully built | `self` | | `onClick(self, mouseX, mouseY, button)` | Mouse pressed on element | `self`, coordinates, button (0=left) | | `onRelease(self, mouseX, mouseY, button)` | Mouse released on element | `self`, coordinates, button | | `onHover(self, mouseX, mouseY)` | Mouse hovering over element | `self`, coordinates | +**Note:** Use `onDisplay` for operations that require traversing the GUI tree (finding elements by ID, accessing parent/children). Use `onAttached` only for early setup that doesn't depend on other elements. + ### LuaElement API The `self` parameter provides access to the GUI element: @@ -662,9 +668,9 @@ end local clickCount = 0 -function onInit(self) - -- Initialize when script is attached to the button - print("Button initialized: " .. self:id()) +function onDisplay(self) + -- Called when the GUI is first displayed (tree is fully built) + print("Button displayed: " .. self:id()) end function onClick(self, mouseX, mouseY, button) diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java index 2fc05b9..982f33f 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleButton.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleButton.java @@ -14,9 +14,9 @@ import net.minecraft.client.gui.DrawContext; import net.minecraft.util.Identifier; import org.jetbrains.annotations.Nullable; +import org.lwjgl.glfw.GLFW; import org.luaj.vm2.LuaValue; import org.luaj.vm2.Varargs; -import org.luaj.vm2.lib.jse.CoerceJavaToLua; import java.awt.*; @@ -24,13 +24,14 @@ @AllArgsConstructor @NoArgsConstructor @Setter -public class AmbleButton extends AmbleContainer { +public class AmbleButton extends AmbleContainer implements Focusable { private AmbleDisplayType hoverDisplay; private AmbleDisplayType pressDisplay; private @Nullable Runnable onClick; private @Nullable AmbleDisplayType normalDisplay = null; private boolean isClicked = false; private @Nullable LuaScript script; + private boolean focused = false; @Override public void onRelease(double mouseX, double mouseY, int button) { @@ -96,15 +97,57 @@ public void onHover(double mouseX, double mouseY) { } } + public @Nullable AmbleDisplayType getNormalDisplay() { + if (normalDisplay == null) { + normalDisplay = this.getBackground(); + } + + return normalDisplay; + } + + private boolean displayCalled = false; + + public void setScript(LuaScript script) { + this.script = script; + this.displayCalled = false; + + // Call onAttached immediately when script is attached (during JSON parsing) + if (script.onAttached() != null && !script.onAttached().isnil()) { + try { + script.onAttached().call(LuaBinder.bind(new LuaElement(this))); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onAttached script for AmbleButton {}:", id(), e); + } + } + } + + /** + * Calls onDisplay if it hasn't been called yet. + * This is deferred until first render so the GUI tree is fully built. + */ + private void ensureDisplayCalled() { + if (!displayCalled && script != null && script.onDisplay() != null && !script.onDisplay().isnil()) { + displayCalled = true; + try { + script.onDisplay().call(LuaBinder.bind(new LuaElement(this))); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onDisplay script for AmbleButton {}:", id(), e); + } + } + } + @Override public void render(DrawContext context, int mouseX, int mouseY, float delta) { + // Call onDisplay on first render when GUI tree is fully built + ensureDisplayCalled(); + if (isHovered(mouseX, mouseY)) { onHover(mouseX, mouseY); } if (isClicked) { setBackground(pressDisplay); - } else if (isHovered(mouseX, mouseY)) { + } else if (isHovered(mouseX, mouseY) || focused) { setBackground(hoverDisplay); } else { setBackground(getNormalDisplay()); @@ -113,23 +156,50 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { super.render(context, mouseX, mouseY, delta); } - public @Nullable AmbleDisplayType getNormalDisplay() { - if (normalDisplay == null) { - normalDisplay = this.getBackground(); - } - return normalDisplay; + // ===== Focusable interface implementation ===== + + @Override + public boolean canFocus() { + return isVisible(); } - public void setScript(LuaScript script) { - this.script = script; - if (script.onInit() != null && !script.onInit().isnil()) { - try { - script.onInit().call(CoerceJavaToLua.coerce(new LuaElement(this))); - } catch (Exception e) { - AmbleKit.LOGGER.error("Error invoking onInit script for AmbleButton {}:", id(), e); - } + @Override + public boolean isFocused() { + return focused; + } + + @Override + public void setFocused(boolean focused) { + this.focused = focused; + } + + @Override + public void onFocusChanged(boolean focused) { + this.focused = focused; + } + + @Override + public boolean onKeyPressed(int keyCode, int scanCode, int modifiers) { + if (!focused) return false; + + // Enter or Space to activate the button + if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER || keyCode == GLFW.GLFW_KEY_SPACE) { + // Simulate click + Rectangle layout = getLayout(); + double centerX = layout.x + layout.width / 2.0; + double centerY = layout.y + layout.height / 2.0; + onClick(centerX, centerY, 0); + onRelease(centerX, centerY, 0); + return true; } + + return false; + } + + @Override + public boolean onCharTyped(char chr, int modifiers) { + return false; } diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java index c6bbf0d..443fa85 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleContainer.java @@ -159,6 +159,10 @@ public static AmbleContainer primaryContainer() { public static class AmbleScreen extends Screen { public final AmbleContainer source; + private @Nullable AmbleElement focusedElement = null; + private long lastClickTime = 0; + private double lastClickX = 0; + private double lastClickY = 0; public AmbleScreen(AmbleContainer source) { super(source.getTitle()); @@ -174,6 +178,30 @@ public void render(DrawContext context, int mouseX, int mouseY, float delta) { @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { + // Find if we clicked on a focusable element + AmbleElement clickedFocusable = findFocusableAt(source, mouseX, mouseY); + + // Update focus + if (clickedFocusable != focusedElement) { + if (focusedElement != null) { + if (focusedElement instanceof Focusable focusable) { + focusable.setFocused(false); + focusable.onFocusChanged(false); + } else { + focusedElement.onFocusChanged(false); + } + } + focusedElement = clickedFocusable; + if (focusedElement != null) { + if (focusedElement instanceof Focusable focusable) { + focusable.setFocused(true); + focusable.onFocusChanged(true); + } else { + focusedElement.onFocusChanged(true); + } + } + } + source.onClick((int) mouseX, (int) mouseY, button); return super.mouseClicked(mouseX, mouseY, button); } @@ -184,6 +212,111 @@ public boolean mouseReleased(double mouseX, double mouseY, int button) { return super.mouseReleased(mouseX, mouseY, button); } + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + if (focusedElement instanceof AmbleTextInput textInput) { + textInput.onMouseDragged(mouseX, mouseY, button); + return true; + } + return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + // Handle Tab for focus navigation + if (keyCode == org.lwjgl.glfw.GLFW.GLFW_KEY_TAB) { + cycleFocus(hasShiftDown()); + return true; + } + + // Delegate to focused element - prefer Focusable interface + if (focusedElement != null) { + boolean handled = focusedElement instanceof Focusable focusable + ? focusable.onKeyPressed(keyCode, scanCode, modifiers) + : focusedElement.onKeyPressed(keyCode, scanCode, modifiers); + if (handled) return true; + } + + return super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public boolean charTyped(char chr, int modifiers) { + // Delegate to focused element - prefer Focusable interface + if (focusedElement != null) { + boolean handled = focusedElement instanceof Focusable focusable + ? focusable.onCharTyped(chr, modifiers) + : focusedElement.onCharTyped(chr, modifiers); + if (handled) return true; + } + + return super.charTyped(chr, modifiers); + } + + /** + * Cycles focus to the next/previous focusable element. + */ + private void cycleFocus(boolean reverse) { + java.util.List focusable = new java.util.ArrayList<>(); + source.findFocusableElements(focusable); + + if (focusable.isEmpty()) return; + + int currentIndex = focusedElement != null ? focusable.indexOf(focusedElement) : -1; + + int nextIndex; + if (reverse) { + nextIndex = currentIndex <= 0 ? focusable.size() - 1 : currentIndex - 1; + } else { + nextIndex = currentIndex >= focusable.size() - 1 ? 0 : currentIndex + 1; + } + + // Remove focus from current element + if (focusedElement != null) { + if (focusedElement instanceof Focusable focusableElement) { + focusableElement.setFocused(false); + focusableElement.onFocusChanged(false); + } else { + focusedElement.onFocusChanged(false); + } + } + + // Set focus to new element + focusedElement = focusable.get(nextIndex); + if (focusedElement instanceof Focusable focusableElement) { + focusableElement.setFocused(true); + focusableElement.onFocusChanged(true); + } else { + focusedElement.onFocusChanged(true); + } + } + + /** + * Finds the topmost focusable element at the given coordinates. + */ + private @Nullable AmbleElement findFocusableAt(AmbleElement element, double mouseX, double mouseY) { + // Check children first (reverse order for proper z-order) + java.util.List children = element.getChildren(); + for (int i = children.size() - 1; i >= 0; i--) { + AmbleElement child = children.get(i); + if (child.isVisible() && child.isHovered(mouseX, mouseY)) { + AmbleElement found = findFocusableAt(child, mouseX, mouseY); + if (found != null) return found; + } + } + + // Check this element - prefer Focusable interface + boolean canFocus = element instanceof Focusable focusable + ? focusable.canFocus() + : element.canFocus(); + + if (canFocus && element.isHovered(mouseX, mouseY)) { + return element; + } + + return null; + } + @Override public boolean shouldPause() { return source.isShouldPause(); diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleElement.java b/src/main/java/dev/amble/lib/client/gui/AmbleElement.java index 551cc86..65ed69b 100644 --- a/src/main/java/dev/amble/lib/client/gui/AmbleElement.java +++ b/src/main/java/dev/amble/lib/client/gui/AmbleElement.java @@ -223,6 +223,69 @@ default void onRelease(double mouseX, double mouseY, int button) { } } + /** + * Called when a key is pressed while this element or a child has focus. + * + * @param keyCode the GLFW key code + * @param scanCode the scan code + * @param modifiers modifier keys (shift, ctrl, alt) + * @return true if the event was handled + */ + default boolean onKeyPressed(int keyCode, int scanCode, int modifiers) { + return false; + } + + /** + * Called when a character is typed while this element or a child has focus. + * + * @param chr the typed character + * @param modifiers modifier keys + * @return true if the event was handled + */ + default boolean onCharTyped(char chr, int modifiers) { + return false; + } + + /** + * Called when focus changes for this element. + * + * @param focused true if gaining focus, false if losing focus + */ + default void onFocusChanged(boolean focused) { + // Default: do nothing + } + + /** + * Returns whether this element can receive keyboard focus. + *

+ * Elements implementing {@link Focusable} should override this or + * implement {@link Focusable#canFocus()} instead. + * + * @return true if focusable + */ + default boolean canFocus() { + return false; + } + + /** + * Collects all focusable elements in this subtree. + *

+ * This method checks both the {@link #canFocus()} method and whether + * the element implements {@link Focusable}. + * + * @param result list to add focusable elements to + */ + default void findFocusableElements(java.util.List result) { + if (this instanceof Focusable focusable && focusable.canFocus()) { + result.add(this); + } else if (canFocus()) { + result.add(this); + } + for (AmbleElement child : getChildren()) { + child.findFocusableElements(result); + } + } + default Identifier toMcssFile() { return this.id().withPrefixedPath("gui/").withSuffixedPath(".json"); } diff --git a/src/main/java/dev/amble/lib/client/gui/AmbleTextInput.java b/src/main/java/dev/amble/lib/client/gui/AmbleTextInput.java new file mode 100644 index 0000000..8bc00a2 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/AmbleTextInput.java @@ -0,0 +1,1007 @@ +package dev.amble.lib.client.gui; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.client.gui.lua.LuaElement; +import dev.amble.lib.client.gui.registry.AmbleElementParser; +import dev.amble.lib.script.LuaScript; +import dev.amble.lib.script.ScriptManager; +import dev.amble.lib.script.lua.LuaBinder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; +import org.lwjgl.glfw.GLFW; +import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; + +import java.awt.*; + +/** + * A text input element that allows users to type and edit text. + *

+ * Features: + *

    + *
  • Full keyboard navigation (arrows, home, end, with ctrl for word jump)
  • + *
  • Text selection via shift+arrows or mouse drag
  • + *
  • Double-click to select word
  • + *
  • Copy/Cut/Paste/Select All (Ctrl+C/X/V/A)
  • + *
  • Horizontal scrolling for long text
  • + *
  • Customizable colors for text, placeholder, selection, and borders
  • + *
  • Placeholder text when empty
  • + *
  • Max length limit
  • + *
+ */ +@Getter +@NoArgsConstructor +public class AmbleTextInput extends AmbleContainer implements Focusable { + private static final int CURSOR_BLINK_RATE = 530; // ms + private static final int DOUBLE_CLICK_TIME = 250; // ms + + // Text content + private String text = ""; + + @Setter + private String placeholder = ""; + + @Setter + private int maxLength = Integer.MAX_VALUE; + + @Setter + private boolean editable = true; + + // Cursor and selection + private int cursorPosition = 0; + private int selectionStart = 0; + private int selectionEnd = 0; + + // Focus state + @Setter + private boolean focused = false; + + // Scroll offset for long text + private int scrollOffset = 0; + + // Cursor blink timing + private long lastCursorBlink = 0; + private boolean cursorVisible = true; + + // Double-click detection + private long lastClickTime = 0; + private int lastClickX = 0; + + // Text alignment + @Setter + private UIAlign textHorizontalAlign = UIAlign.START; + @Setter + private UIAlign textVerticalAlign = UIAlign.CENTRE; + + // Customizable colors + @Setter + private Color textColor = new Color(255, 255, 255); + @Setter + private Color placeholderColor = new Color(128, 128, 128); + @Setter + private Color selectionColor = new Color(0, 120, 215, 128); + @Setter + private Color borderColor = new Color(160, 160, 160); + @Setter + private Color focusedBorderColor = new Color(80, 160, 255); + @Setter + private Color cursorColor = new Color(255, 255, 255); + + // Script support + @Setter + private @Nullable LuaScript script; + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + // Render background + getBackground().render(context, getLayout()); + + Rectangle layout = getLayout(); + TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; + + // Draw border + Color border = focused ? focusedBorderColor : borderColor; + drawBorder(context, layout, border); + + // Calculate text area with padding + int textX = layout.x + getPadding() + 2; + int textY = layout.y + (layout.height - textRenderer.fontHeight) / 2; + // Enable scissor to clip text within bounds + context.enableScissor(layout.x + getPadding(), layout.y, layout.x + layout.width - getPadding(), layout.y + layout.height); + + if (text.isEmpty() && !focused) { + // Draw placeholder + context.drawText(textRenderer, placeholder, textX - scrollOffset, textY, placeholderColor.getRGB(), false); + } else { + // Draw selection highlight + if (hasSelection() && focused) { + drawSelection(context, textRenderer, textX, textY); + } + + // Draw text + context.drawText(textRenderer, text, textX - scrollOffset, textY, textColor.getRGB(), false); + + // Draw cursor + if (focused && editable) { + updateCursorBlink(); + if (cursorVisible) { + drawCursor(context, textRenderer, textX, textY); + } + } + } + + context.disableScissor(); + + // Render children (if any) + for (AmbleElement child : getChildren()) { + if (child.isVisible()) { + child.render(context, mouseX, mouseY, delta); + } + } + } + + private void drawBorder(DrawContext context, Rectangle layout, Color color) { + int x = layout.x; + int y = layout.y; + int w = layout.width; + int h = layout.height; + int c = color.getRGB(); + + // Top + context.fill(x, y, x + w, y + 1, c); + // Bottom + context.fill(x, y + h - 1, x + w, y + h, c); + // Left + context.fill(x, y, x + 1, y + h, c); + // Right + context.fill(x + w - 1, y, x + w, y + h, c); + } + + private void drawSelection(DrawContext context, TextRenderer textRenderer, int textX, int textY) { + int selStart = Math.min(selectionStart, selectionEnd); + int selEnd = Math.max(selectionStart, selectionEnd); + + String beforeSelection = text.substring(0, selStart); + String selection = text.substring(selStart, selEnd); + + int startX = textX - scrollOffset + textRenderer.getWidth(beforeSelection); + int endX = startX + textRenderer.getWidth(selection); + + context.fill(startX, textY - 1, endX, textY + textRenderer.fontHeight + 1, selectionColor.getRGB()); + } + + private void drawCursor(DrawContext context, TextRenderer textRenderer, int textX, int textY) { + String beforeCursor = text.substring(0, cursorPosition); + int cursorX = textX - scrollOffset + textRenderer.getWidth(beforeCursor); + + context.fill(cursorX, textY - 1, cursorX + 1, textY + textRenderer.fontHeight + 1, cursorColor.getRGB()); + } + + private void updateCursorBlink() { + long now = System.currentTimeMillis(); + if (now - lastCursorBlink > CURSOR_BLINK_RATE) { + cursorVisible = !cursorVisible; + lastCursorBlink = now; + } + } + + private void resetCursorBlink() { + cursorVisible = true; + lastCursorBlink = System.currentTimeMillis(); + } + + /** + * Ensures the cursor is visible within the text field by adjusting scroll offset. + */ + private void ensureCursorVisible() { + TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; + Rectangle layout = getLayout(); + int availableWidth = layout.width - getPadding() * 2 - 4; + + String beforeCursor = text.substring(0, cursorPosition); + int cursorX = textRenderer.getWidth(beforeCursor); + + // Scroll left if cursor is before visible area + if (cursorX < scrollOffset) { + scrollOffset = cursorX; + } + + // Scroll right if cursor is after visible area + if (cursorX > scrollOffset + availableWidth) { + scrollOffset = cursorX - availableWidth; + } + + // Ensure scroll offset is never negative + scrollOffset = Math.max(0, scrollOffset); + + // Ensure we don't scroll past the end of text + int textWidth = textRenderer.getWidth(text); + if (textWidth <= availableWidth) { + scrollOffset = 0; + } else { + scrollOffset = Math.min(scrollOffset, textWidth - availableWidth); + } + } + + /** + * Sets the text content and clamps cursor/selection positions to valid bounds. + */ + public void setText(String text) { + this.text = text != null ? text : ""; + // Clamp cursor and selection to valid bounds + cursorPosition = Math.max(0, Math.min(cursorPosition, this.text.length())); + selectionStart = Math.max(0, Math.min(selectionStart, this.text.length())); + selectionEnd = Math.max(0, Math.min(selectionEnd, this.text.length())); + } + + // ===== Selection helpers ===== + + public boolean hasSelection() { + return selectionStart != selectionEnd; + } + + public String getSelectedText() { + if (!hasSelection()) return ""; + int start = Math.min(selectionStart, selectionEnd); + int end = Math.max(selectionStart, selectionEnd); + return text.substring(start, end); + } + + public void clearSelection() { + selectionStart = cursorPosition; + selectionEnd = cursorPosition; + } + + public void selectAll() { + selectionStart = 0; + selectionEnd = text.length(); + cursorPosition = text.length(); + } + + public void setSelection(int start, int end) { + selectionStart = Math.max(0, Math.min(start, text.length())); + selectionEnd = Math.max(0, Math.min(end, text.length())); + cursorPosition = selectionEnd; + } + + public void setCursorPosition(int position) { + this.cursorPosition = Math.max(0, Math.min(position, text.length())); + resetCursorBlink(); + ensureCursorVisible(); + } + + // ===== Text manipulation ===== + + public void insertText(String insert) { + if (!editable) return; + + // Delete selection first if any + if (hasSelection()) { + deleteSelection(); + } + + // Check max length + int availableSpace = maxLength - text.length(); + if (availableSpace <= 0) return; + + if (insert.length() > availableSpace) { + insert = insert.substring(0, availableSpace); + } + + // Filter out invalid characters + StringBuilder filtered = new StringBuilder(); + for (char c : insert.toCharArray()) { + if (isValidChar(c)) { + filtered.append(c); + } + } + insert = filtered.toString(); + + if (insert.isEmpty()) return; + + // Insert at cursor + String before = text.substring(0, cursorPosition); + String after = text.substring(cursorPosition); + text = before + insert + after; + cursorPosition += insert.length(); + clearSelection(); + ensureCursorVisible(); + onTextChanged(); + } + + private boolean isValidChar(char c) { + // Allow printable characters + return c >= 32 && c != 127; + } + + public void deleteSelection() { + if (!hasSelection() || !editable) return; + + int start = Math.min(selectionStart, selectionEnd); + int end = Math.max(selectionStart, selectionEnd); + + String before = text.substring(0, start); + String after = text.substring(end); + text = before + after; + cursorPosition = start; + clearSelection(); + ensureCursorVisible(); + onTextChanged(); + } + + public void deleteCharBefore() { + if (!editable) return; + + if (hasSelection()) { + deleteSelection(); + return; + } + + if (cursorPosition > 0) { + String before = text.substring(0, cursorPosition - 1); + String after = text.substring(cursorPosition); + text = before + after; + cursorPosition--; + ensureCursorVisible(); + onTextChanged(); + } + } + + public void deleteCharAfter() { + if (!editable) return; + + if (hasSelection()) { + deleteSelection(); + return; + } + + if (cursorPosition < text.length()) { + String before = text.substring(0, cursorPosition); + String after = text.substring(cursorPosition + 1); + text = before + after; + ensureCursorVisible(); + onTextChanged(); + } + } + + public void deleteWordBefore() { + if (!editable) return; + + if (hasSelection()) { + deleteSelection(); + return; + } + + int wordStart = findWordStart(cursorPosition); + if (wordStart < cursorPosition) { + String before = text.substring(0, wordStart); + String after = text.substring(cursorPosition); + text = before + after; + cursorPosition = wordStart; + ensureCursorVisible(); + onTextChanged(); + } + } + + public void deleteWordAfter() { + if (!editable) return; + + if (hasSelection()) { + deleteSelection(); + return; + } + + int wordEnd = findWordEnd(cursorPosition); + if (wordEnd > cursorPosition) { + String before = text.substring(0, cursorPosition); + String after = text.substring(wordEnd); + text = before + after; + ensureCursorVisible(); + onTextChanged(); + } + } + + // ===== Word navigation helpers ===== + + private int findWordStart(int position) { + if (position <= 0) return 0; + + int i = position - 1; + + // Skip any whitespace before the word + while (i > 0 && Character.isWhitespace(text.charAt(i))) { + i--; + } + + // Find start of word + while (i > 0 && !Character.isWhitespace(text.charAt(i - 1))) { + i--; + } + + return i; + } + + private int findWordEnd(int position) { + if (position >= text.length()) return text.length(); + + int i = position; + + // Skip current word + while (i < text.length() && !Character.isWhitespace(text.charAt(i))) { + i++; + } + + // Skip whitespace after word + while (i < text.length() && Character.isWhitespace(text.charAt(i))) { + i++; + } + + return i; + } + + /** + * Selects the word at the given cursor position. + */ + public void selectWordAt(int position) { + if (text.isEmpty()) return; + + position = Math.max(0, Math.min(position, text.length() - 1)); + + // If position is at whitespace, just position cursor there + if (Character.isWhitespace(text.charAt(position))) { + cursorPosition = position; + clearSelection(); + return; + } + + // Find word boundaries + int start = position; + while (start > 0 && !Character.isWhitespace(text.charAt(start - 1))) { + start--; + } + + int end = position; + while (end < text.length() && !Character.isWhitespace(text.charAt(end))) { + end++; + } + + selectionStart = start; + selectionEnd = end; + cursorPosition = end; + ensureCursorVisible(); + } + + // ===== Clipboard ===== + + public void copy() { + if (hasSelection()) { + MinecraftClient.getInstance().keyboard.setClipboard(getSelectedText()); + } + } + + public void cut() { + if (hasSelection() && editable) { + copy(); + deleteSelection(); + } + } + + public void paste() { + if (!editable) return; + String clipboard = MinecraftClient.getInstance().keyboard.getClipboard(); + if (clipboard != null && !clipboard.isEmpty()) { + // Remove newlines + clipboard = clipboard.replace("\r\n", " ").replace("\n", " ").replace("\r", " "); + insertText(clipboard); + } + } + + // ===== Cursor movement ===== + + public void moveCursorLeft(boolean selecting, boolean wordJump) { + int newPos; + if (wordJump) { + newPos = findWordStart(cursorPosition); + } else { + newPos = Math.max(0, cursorPosition - 1); + } + + if (selecting) { + if (!hasSelection()) { + selectionStart = cursorPosition; + } + selectionEnd = newPos; + } else { + if (hasSelection()) { + newPos = Math.min(selectionStart, selectionEnd); + } + clearSelection(); + } + + cursorPosition = newPos; + resetCursorBlink(); + ensureCursorVisible(); + } + + public void moveCursorRight(boolean selecting, boolean wordJump) { + int newPos; + if (wordJump) { + newPos = findWordEnd(cursorPosition); + } else { + newPos = Math.min(text.length(), cursorPosition + 1); + } + + if (selecting) { + if (!hasSelection()) { + selectionStart = cursorPosition; + } + selectionEnd = newPos; + } else { + if (hasSelection()) { + newPos = Math.max(selectionStart, selectionEnd); + } + clearSelection(); + } + + cursorPosition = newPos; + resetCursorBlink(); + ensureCursorVisible(); + } + + public void moveCursorToStart(boolean selecting) { + if (selecting) { + if (!hasSelection()) { + selectionStart = cursorPosition; + } + selectionEnd = 0; + } else { + clearSelection(); + } + + cursorPosition = 0; + resetCursorBlink(); + ensureCursorVisible(); + } + + public void moveCursorToEnd(boolean selecting) { + if (selecting) { + if (!hasSelection()) { + selectionStart = cursorPosition; + } + selectionEnd = text.length(); + } else { + clearSelection(); + } + + cursorPosition = text.length(); + resetCursorBlink(); + ensureCursorVisible(); + } + + // ===== Focus handling (Focusable interface) ===== + + @Override + public boolean canFocus() { + return editable && isVisible(); + } + + @Override + public void onFocusChanged(boolean focused) { + this.focused = focused; + if (!focused) { + clearSelection(); + } else { + resetCursorBlink(); + } + } + + // ===== Mouse handling ===== + + @Override + public void onClick(double mouseX, double mouseY, int button) { + if (!isHovered(mouseX, mouseY) || button != 0) { + super.onClick(mouseX, mouseY, button); + return; + } + + long now = System.currentTimeMillis(); + int clickX = (int) mouseX; + + // Double-click detection + if (now - lastClickTime < DOUBLE_CLICK_TIME && Math.abs(clickX - lastClickX) < 5) { + // Double-click: select word + int charIndex = getCharIndexAtX((int) mouseX); + selectWordAt(charIndex); + lastClickTime = 0; // Reset to prevent triple-click issues + } else { + // Single click: position cursor + int charIndex = getCharIndexAtX((int) mouseX); + cursorPosition = charIndex; + + boolean shiftHeld = Screen.hasShiftDown(); + if (shiftHeld && focused) { + // Extend selection + if (!hasSelection()) { + selectionStart = cursorPosition; + } + selectionEnd = charIndex; + cursorPosition = charIndex; + } else { + clearSelection(); + } + + resetCursorBlink(); + ensureCursorVisible(); + lastClickTime = now; + lastClickX = clickX; + } + + // Invoke script onClick if present + if (script != null && script.onClick() != null && !script.onClick().isnil()) { + try { + Varargs args = LuaValue.varargsOf(new LuaValue[]{ + LuaBinder.bind(new LuaElement(this)), + LuaValue.valueOf(mouseX), + LuaValue.valueOf(mouseY), + LuaValue.valueOf(button) + }); + script.onClick().invoke(args); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error invoking onClick script for AmbleTextInput {}:", id(), e); + } + } + + super.onClick(mouseX, mouseY, button); + } + + /** + * Handles mouse drag for selection. + */ + public void onMouseDragged(double mouseX, double mouseY, int button) { + if (!focused || button != 0) return; + + int charIndex = getCharIndexAtX((int) mouseX); + + if (!hasSelection()) { + selectionStart = cursorPosition; + } + selectionEnd = charIndex; + cursorPosition = charIndex; + + resetCursorBlink(); + ensureCursorVisible(); + } + + /** + * Gets the character index at the given screen X coordinate. + */ + private int getCharIndexAtX(int screenX) { + TextRenderer textRenderer = MinecraftClient.getInstance().textRenderer; + Rectangle layout = getLayout(); + + int relativeX = screenX - layout.x - getPadding() - 2 + scrollOffset; + + if (relativeX <= 0) return 0; + if (text.isEmpty()) return 0; + + // Binary search for the character position + int textWidth = textRenderer.getWidth(text); + if (relativeX >= textWidth) return text.length(); + + // Linear search (could optimize with binary search for very long text) + for (int i = 0; i <= text.length(); i++) { + int width = textRenderer.getWidth(text.substring(0, i)); + if (width > relativeX) { + // Check if closer to previous or current character + if (i > 0) { + int prevWidth = textRenderer.getWidth(text.substring(0, i - 1)); + if (relativeX - prevWidth < width - relativeX) { + return i - 1; + } + } + return i; + } + } + + return text.length(); + } + + // ===== Keyboard handling ===== + + @Override + public boolean onKeyPressed(int keyCode, int scanCode, int modifiers) { + if (!focused || !editable) return false; + + boolean ctrl = Screen.hasControlDown(); + boolean shift = Screen.hasShiftDown(); + + switch (keyCode) { + case GLFW.GLFW_KEY_LEFT: + moveCursorLeft(shift, ctrl); + return true; + + case GLFW.GLFW_KEY_RIGHT: + moveCursorRight(shift, ctrl); + return true; + + case GLFW.GLFW_KEY_HOME: + moveCursorToStart(shift); + return true; + + case GLFW.GLFW_KEY_END: + moveCursorToEnd(shift); + return true; + + case GLFW.GLFW_KEY_BACKSPACE: + if (ctrl) { + deleteWordBefore(); + } else { + deleteCharBefore(); + } + return true; + + case GLFW.GLFW_KEY_DELETE: + if (ctrl) { + deleteWordAfter(); + } else { + deleteCharAfter(); + } + return true; + + case GLFW.GLFW_KEY_A: + if (ctrl) { + selectAll(); + return true; + } + break; + + case GLFW.GLFW_KEY_C: + if (ctrl) { + copy(); + return true; + } + break; + + case GLFW.GLFW_KEY_X: + if (ctrl) { + cut(); + return true; + } + break; + + case GLFW.GLFW_KEY_V: + if (ctrl) { + paste(); + return true; + } + break; + } + + return false; + } + + @Override + public boolean onCharTyped(char chr, int modifiers) { + if (!focused || !editable) return false; + + if (isValidChar(chr)) { + insertText(String.valueOf(chr)); + return true; + } + + return false; + } + + // ===== Text change callback ===== + + protected void onTextChanged() { + // Future: could invoke an onTextChanged callback in the script + } + + // ===== Builder ===== + + public static Builder textInputBuilder() { + return new Builder(); + } + + public static class Builder extends AbstractBuilder { + + @Override + protected AmbleTextInput create() { + return new AmbleTextInput(); + } + + @Override + protected Builder self() { + return this; + } + + public Builder text(String text) { + container.setText(text); + return this; + } + + public Builder placeholder(String placeholder) { + container.setPlaceholder(placeholder); + return this; + } + + public Builder maxLength(int maxLength) { + container.setMaxLength(maxLength); + return this; + } + + public Builder editable(boolean editable) { + container.setEditable(editable); + return this; + } + + public Builder textColor(Color color) { + container.setTextColor(color); + return this; + } + + public Builder placeholderColor(Color color) { + container.setPlaceholderColor(color); + return this; + } + + public Builder selectionColor(Color color) { + container.setSelectionColor(color); + return this; + } + + public Builder borderColor(Color color) { + container.setBorderColor(color); + return this; + } + + public Builder focusedBorderColor(Color color) { + container.setFocusedBorderColor(color); + return this; + } + + public Builder cursorColor(Color color) { + container.setCursorColor(color); + return this; + } + + public Builder textHorizontalAlign(UIAlign align) { + container.setTextHorizontalAlign(align); + return this; + } + + public Builder textVerticalAlign(UIAlign align) { + container.setTextVerticalAlign(align); + return this; + } + } + + // ===== Parser ===== + + /** + * Parser for AmbleTextInput elements. + *

+ * This parser handles JSON objects that have the "text_input" property set to true. + *

+ * Supported JSON properties: + *

    + *
  • {@code text_input} - Boolean, must be true to create a text input
  • + *
  • {@code placeholder} - String placeholder text when empty
  • + *
  • {@code max_length} - Integer maximum character count
  • + *
  • {@code editable} - Boolean, whether text can be edited (default: true)
  • + *
  • {@code text} - String initial text content
  • + *
  • {@code text_alignment} - Array [horizontal, vertical] alignment
  • + *
  • {@code text_color} - Color array [r,g,b] or [r,g,b,a]
  • + *
  • {@code placeholder_color} - Color array
  • + *
  • {@code selection_color} - Color array
  • + *
  • {@code border_color} - Color array
  • + *
  • {@code focused_border_color} - Color array
  • + *
  • {@code cursor_color} - Color array
  • + *
  • {@code script} - Script ID for event handling
  • + *
+ */ + public static class Parser implements AmbleElementParser { + + @Override + public @Nullable AmbleContainer parse(JsonObject json, @Nullable Identifier resourceId, AmbleContainer base) { + if (!json.has("text_input") || !json.get("text_input").getAsBoolean()) { + return null; + } + + AmbleTextInput input = AmbleTextInput.textInputBuilder().build(); + input.copyFrom(base); + + // Parse placeholder + if (json.has("placeholder")) { + input.setPlaceholder(json.get("placeholder").getAsString()); + } + + // Parse max length + if (json.has("max_length")) { + input.setMaxLength(json.get("max_length").getAsInt()); + } + + // Parse editable + if (json.has("editable")) { + input.setEditable(json.get("editable").getAsBoolean()); + } + + // Parse initial text + if (json.has("text")) { + input.setText(json.get("text").getAsString()); + } + + // Parse text alignment + if (json.has("text_alignment")) { + JsonArray alignArray = json.get("text_alignment").getAsJsonArray(); + if (alignArray.size() >= 2) { + String hAlign = alignArray.get(0).getAsString().toUpperCase(); + String vAlign = alignArray.get(1).getAsString().toUpperCase(); + if (hAlign.equals("CENTER")) hAlign = "CENTRE"; + if (vAlign.equals("CENTER")) vAlign = "CENTRE"; + input.setTextHorizontalAlign(UIAlign.valueOf(hAlign)); + input.setTextVerticalAlign(UIAlign.valueOf(vAlign)); + } + } + + // Parse colors + if (json.has("text_color")) { + input.setTextColor(parseColor(json.get("text_color").getAsJsonArray())); + } + if (json.has("placeholder_color")) { + input.setPlaceholderColor(parseColor(json.get("placeholder_color").getAsJsonArray())); + } + if (json.has("selection_color")) { + input.setSelectionColor(parseColor(json.get("selection_color").getAsJsonArray())); + } + if (json.has("border_color")) { + input.setBorderColor(parseColor(json.get("border_color").getAsJsonArray())); + } + if (json.has("focused_border_color")) { + input.setFocusedBorderColor(parseColor(json.get("focused_border_color").getAsJsonArray())); + } + if (json.has("cursor_color")) { + input.setCursorColor(parseColor(json.get("cursor_color").getAsJsonArray())); + } + + // Parse script + if (json.has("script")) { + Identifier scriptId = new Identifier(json.get("script").getAsString()) + .withPrefixedPath("script/") + .withSuffixedPath(".lua"); + LuaScript script = ScriptManager.getInstance().load( + scriptId, + MinecraftClient.getInstance().getResourceManager() + ); + input.setScript(script); + } + + return input; + } + + private Color parseColor(JsonArray colorArray) { + int r = colorArray.get(0).getAsInt(); + int g = colorArray.get(1).getAsInt(); + int b = colorArray.get(2).getAsInt(); + int a = colorArray.size() > 3 ? colorArray.get(3).getAsInt() : 255; + return new Color(r, g, b, a); + } + + @Override + public int priority() { + // Higher than entity display (75), lower than button (100) + return 80; + } + } +} + diff --git a/src/main/java/dev/amble/lib/client/gui/Focusable.java b/src/main/java/dev/amble/lib/client/gui/Focusable.java new file mode 100644 index 0000000..1395580 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/gui/Focusable.java @@ -0,0 +1,77 @@ +package dev.amble.lib.client.gui; + +/** + * Interface for GUI elements that can receive keyboard focus. + *

+ * Implementing this interface allows elements to: + *

    + *
  • Receive keyboard events when focused
  • + *
  • Participate in Tab navigation
  • + *
  • Respond to focus gain/loss events
  • + *
+ *

+ * Elements that implement this interface should also handle rendering + * visual feedback to indicate their focused state (e.g., highlighted border). + */ +public interface Focusable { + + /** + * Returns whether this element can currently receive keyboard focus. + *

+ * An element may return false if it's disabled, invisible, or otherwise + * not ready to accept input. + * + * @return true if this element can receive focus + */ + boolean canFocus(); + + /** + * Returns whether this element currently has keyboard focus. + * + * @return true if this element is focused + */ + boolean isFocused(); + + /** + * Sets the focused state of this element. + *

+ * This is typically called by the screen's focus management system. + * Implementations should update their visual state accordingly. + * + * @param focused true to give focus, false to remove focus + */ + void setFocused(boolean focused); + + /** + * Called when this element gains or loses focus. + *

+ * This callback allows elements to perform additional actions when + * focus changes, such as starting/stopping cursor blink animations + * or clearing selections. + * + * @param focused true if gaining focus, false if losing focus + */ + void onFocusChanged(boolean focused); + + /** + * Called when a key is pressed while this element has focus. + * + * @param keyCode the GLFW key code + * @param scanCode the platform-specific scan code + * @param modifiers bitfield of modifier keys (shift, ctrl, alt) + * @return true if the key event was handled and should not propagate + */ + boolean onKeyPressed(int keyCode, int scanCode, int modifiers); + + /** + * Called when a character is typed while this element has focus. + *

+ * This is called for printable characters after key press events. + * + * @param chr the typed character + * @param modifiers bitfield of modifier keys + * @return true if the character was handled and should not propagate + */ + boolean onCharTyped(char chr, int modifiers); +} + diff --git a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java index c1a5d24..918a24f 100644 --- a/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java +++ b/src/main/java/dev/amble/lib/client/gui/lua/LuaElement.java @@ -212,14 +212,18 @@ private static AmbleText findFirstTextRecursive(AmbleElement element) { @LuaExpose public void setText(String text) { - if (element instanceof AmbleText t) { + if (element instanceof AmbleTextInput input) { + input.setText(text); + } else if (element instanceof AmbleText t) { t.setText(Text.literal(text)); } } @LuaExpose public String getText() { - if (element instanceof AmbleText t) { + if (element instanceof AmbleTextInput input) { + return input.getText(); + } else if (element instanceof AmbleText t) { return t.getText().getString(); } return null; @@ -235,6 +239,250 @@ public ClientMinecraftData minecraft() { return minecraftData; } + // ===== AmbleTextInput methods ===== + + /** + * Gets the placeholder text. + * Only works if the underlying element is an AmbleTextInput. + * + * @return the placeholder text, or null if not a text input + */ + @LuaExpose + public String getPlaceholder() { + if (element instanceof AmbleTextInput input) { + return input.getPlaceholder(); + } + return null; + } + + /** + * Sets the placeholder text. + * Only works if the underlying element is an AmbleTextInput. + * + * @param placeholder the placeholder text + */ + @LuaExpose + public void setPlaceholder(String placeholder) { + if (element instanceof AmbleTextInput input) { + input.setPlaceholder(placeholder); + } + } + + /** + * Gets the maximum text length. + * Only works if the underlying element is an AmbleTextInput. + * + * @return the max length, or -1 if not a text input + */ + @LuaExpose + public int getMaxLength() { + if (element instanceof AmbleTextInput input) { + return input.getMaxLength(); + } + return -1; + } + + /** + * Sets the maximum text length. + * Only works if the underlying element is an AmbleTextInput. + * + * @param maxLength the max length + */ + @LuaExpose + public void setMaxLength(int maxLength) { + if (element instanceof AmbleTextInput input) { + input.setMaxLength(maxLength); + } + } + + /** + * Checks if the text input is editable. + * Only works if the underlying element is an AmbleTextInput. + * + * @return true if editable, false otherwise + */ + @LuaExpose + public boolean isEditable() { + if (element instanceof AmbleTextInput input) { + return input.isEditable(); + } + return false; + } + + /** + * Sets whether the text input is editable. + * Only works if the underlying element is an AmbleTextInput. + * + * @param editable whether the input is editable + */ + @LuaExpose + public void setEditable(boolean editable) { + if (element instanceof AmbleTextInput input) { + input.setEditable(editable); + } + } + + /** + * Checks if the text input is focused. + * Only works if the underlying element is an AmbleTextInput. + * + * @return true if focused, false otherwise + */ + @LuaExpose + public boolean isInputFocused() { + if (element instanceof AmbleTextInput input) { + return input.isFocused(); + } + return false; + } + + /** + * Sets whether the text input is focused. + * Only works if the underlying element is an AmbleTextInput. + * + * @param focused whether the input should be focused + */ + @LuaExpose + public void setInputFocused(boolean focused) { + if (element instanceof AmbleTextInput input) { + input.setFocused(focused); + input.onFocusChanged(focused); + } + } + + /** + * Gets the selection start position. + * Only works if the underlying element is an AmbleTextInput. + * + * @return the selection start, or -1 if not a text input + */ + @LuaExpose + public int getSelectionStart() { + if (element instanceof AmbleTextInput input) { + return input.getSelectionStart(); + } + return -1; + } + + /** + * Gets the selection end position. + * Only works if the underlying element is an AmbleTextInput. + * + * @return the selection end, or -1 if not a text input + */ + @LuaExpose + public int getSelectionEnd() { + if (element instanceof AmbleTextInput input) { + return input.getSelectionEnd(); + } + return -1; + } + + /** + * Sets the text selection range. + * Only works if the underlying element is an AmbleTextInput. + * + * @param start selection start position + * @param end selection end position + */ + @LuaExpose + public void setSelection(int start, int end) { + if (element instanceof AmbleTextInput input) { + input.setSelection(start, end); + } + } + + /** + * Selects all text in the input. + * Only works if the underlying element is an AmbleTextInput. + */ + @LuaExpose + public void selectAll() { + if (element instanceof AmbleTextInput input) { + input.selectAll(); + } + } + + /** + * Sets the selection color. + * Only works if the underlying element is an AmbleTextInput. + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + */ + @LuaExpose + public void setSelectionColor(int r, int g, int b, int a) { + if (element instanceof AmbleTextInput input) { + input.setSelectionColor(new java.awt.Color(r, g, b, a)); + } + } + + /** + * Sets the border color. + * Only works if the underlying element is an AmbleTextInput. + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + */ + @LuaExpose + public void setBorderColor(int r, int g, int b, int a) { + if (element instanceof AmbleTextInput input) { + input.setBorderColor(new java.awt.Color(r, g, b, a)); + } + } + + /** + * Sets the focused border color. + * Only works if the underlying element is an AmbleTextInput. + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + */ + @LuaExpose + public void setFocusedBorderColor(int r, int g, int b, int a) { + if (element instanceof AmbleTextInput input) { + input.setFocusedBorderColor(new java.awt.Color(r, g, b, a)); + } + } + + /** + * Sets the text color. + * Only works if the underlying element is an AmbleTextInput. + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + */ + @LuaExpose + public void setTextColor(int r, int g, int b, int a) { + if (element instanceof AmbleTextInput input) { + input.setTextColor(new java.awt.Color(r, g, b, a)); + } + } + + /** + * Sets the placeholder color. + * Only works if the underlying element is an AmbleTextInput. + * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + */ + @LuaExpose + public void setPlaceholderColor(int r, int g, int b, int a) { + if (element instanceof AmbleTextInput input) { + input.setPlaceholderColor(new java.awt.Color(r, g, b, a)); + } + } + // ===== AmbleEntityDisplay methods ===== /** diff --git a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java index 690369f..187cafe 100644 --- a/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java +++ b/src/main/java/dev/amble/lib/client/gui/registry/AmbleGuiRegistry.java @@ -36,6 +36,7 @@ private AmbleGuiRegistry() { registerParser(new AmbleButton.Parser()); registerParser(new AmbleText.Parser()); registerParser(new AmbleEntityDisplay.Parser()); + registerParser(new AmbleTextInput.Parser()); } /** diff --git a/src/main/java/dev/amble/lib/script/AbstractScriptManager.java b/src/main/java/dev/amble/lib/script/AbstractScriptManager.java index 879aa89..a683a66 100644 --- a/src/main/java/dev/amble/lib/script/AbstractScriptManager.java +++ b/src/main/java/dev/amble/lib/script/AbstractScriptManager.java @@ -89,14 +89,18 @@ public LuaScript load(Identifier id, ResourceManager manager) { ); chunk.call(); - return new LuaScript( - globals, - globals.get("onInit"), - globals.get("onExecute"), - globals.get("onEnable"), - globals.get("onTick"), - globals.get("onDisable") - ); + LuaScript script = new LuaScript(globals); + + // Call onRegister when script is first loaded into the manager + if (script.onRegister() != null && !script.onRegister().isnil()) { + try { + script.onRegister().call(boundData); + } catch (Exception e) { + AmbleKit.LOGGER.error("Error in onRegister for {} {}", getLogPrefix(), key, e); + } + } + + return script; } catch (Exception e) { throw new RuntimeException("Failed to load " + getLogPrefix() + " " + key, e); } diff --git a/src/main/java/dev/amble/lib/script/LuaScript.java b/src/main/java/dev/amble/lib/script/LuaScript.java index 24f8833..9471d24 100644 --- a/src/main/java/dev/amble/lib/script/LuaScript.java +++ b/src/main/java/dev/amble/lib/script/LuaScript.java @@ -4,37 +4,72 @@ import org.luaj.vm2.LuaValue; /** - * Represents a loaded Lua script with its lifecycle callback functions. + * Represents a loaded Lua script with its globals. *

- * Core lifecycle callbacks (onInit, onExecute, onEnable, onTick, onDisable) are stored directly. - * GUI-specific callbacks (onClick, onRelease, onHover) are looked up from globals on demand - * to keep this record focused on script lifecycle rather than GUI concerns. + * All callbacks are looked up from globals on demand. */ -public record LuaScript( - Globals globals, - LuaValue onInit, - LuaValue onExecute, - LuaValue onEnable, - LuaValue onTick, - LuaValue onDisable -) { +public record LuaScript(Globals globals) { + /** - * Gets a GUI callback by name (onClick, onRelease, onHover). + * Gets a callback by name. * Returns NIL if the callback is not defined. */ - public LuaValue getGuiCallback(String name) { + public LuaValue getCallback(String name) { return globals.get(name); } + // ===== Core lifecycle callbacks ===== + + /** + * Called when the script is registered to the ScriptManager. + */ + public LuaValue onRegister() { + return getCallback("onRegister"); + } + + public LuaValue onExecute() { + return getCallback("onExecute"); + } + + public LuaValue onEnable() { + return getCallback("onEnable"); + } + + public LuaValue onTick() { + return getCallback("onTick"); + } + + public LuaValue onDisable() { + return getCallback("onDisable"); + } + + // ===== GUI-specific callbacks ===== + + /** + * Called when the script is attached to a GUI element (during JSON parsing). + * The GUI tree is NOT fully built at this point. + */ + public LuaValue onAttached() { + return getCallback("onAttached"); + } + + /** + * Called when the GUI is first displayed and the GUI tree is fully built. + * Use this for operations that need to traverse the GUI tree. + */ + public LuaValue onDisplay() { + return getCallback("onDisplay"); + } + public LuaValue onClick() { - return getGuiCallback("onClick"); + return getCallback("onClick"); } public LuaValue onRelease() { - return getGuiCallback("onRelease"); + return getCallback("onRelease"); } public LuaValue onHover() { - return getGuiCallback("onHover"); + return getCallback("onHover"); } } diff --git a/src/main/java/dev/amble/lib/script/lua/LuaBinder.java b/src/main/java/dev/amble/lib/script/lua/LuaBinder.java index d33ef7c..daa93e4 100644 --- a/src/main/java/dev/amble/lib/script/lua/LuaBinder.java +++ b/src/main/java/dev/amble/lib/script/lua/LuaBinder.java @@ -277,7 +277,8 @@ public static LuaValue coerceResult(Object obj) { if (obj instanceof NbtCompound nbt) return coerceNbtCompound(nbt); if (obj instanceof NbtElement nbt) return coerceNbtElement(nbt); if (obj instanceof Identifier id) return coerceIdentifier(id); - if (obj instanceof AmbleElement element) return coerceAmbleElement(element); + if (obj instanceof LuaElement luaElement) return bind(luaElement); + if (obj instanceof AmbleElement element) return bind(new LuaElement(element)); // Fall back to binding the object return bind(obj); diff --git a/src/test/resources/assets/litmus/gui/skin_changer.json b/src/test/resources/assets/litmus/gui/skin_changer.json new file mode 100644 index 0000000..b7ca7d8 --- /dev/null +++ b/src/test/resources/assets/litmus/gui/skin_changer.json @@ -0,0 +1,209 @@ +{ + "layout": [ + 280, + 200 + ], + "background": { + "texture": "litmus:textures/gui/test_screen.png", + "u": 0, + "v": 0, + "regionWidth": 216, + "regionHeight": 138, + "textureWidth": 256, + "textureHeight": 256 + }, + "padding": 12, + "spacing": 10, + "alignment": [ + "centre", + "centre" + ], + "should_pause": true, + "children": [ + { + "id": "litmus:player_display", + "entity_uuid": "player", + "follow_cursor": true, + "entity_scale": 0.9, + "layout": [ + 70, + 160 + ], + "background": [ + 40, + 40, + 60, + 200 + ] + }, + { + "id": "litmus:right_panel", + "layout": [ + 170, + 160 + ], + "background": [ + 30, + 30, + 50, + 180 + ], + "padding": 8, + "spacing": 8, + "alignment": [ + "centre", + "start" + ], + "children": [ + { + "id": "litmus:title_text", + "layout": [ + 154, + 14 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "§lSkin Changer", + "text_alignment": [ + "centre", + "centre" + ] + }, + { + "id": "litmus:player_name_text", + "layout": [ + 154, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "Current: Loading...", + "text_alignment": [ + "centre", + "centre" + ] + }, + { + "id": "litmus:username_input", + "text_input": true, + "placeholder": "Enter username...", + "max_length": 16, + "layout": [ + 154, + 20 + ], + "background": [ + 20, + 20, + 30, + 255 + ], + "border_color": [ + 80, + 80, + 100 + ], + "focused_border_color": [ + 100, + 140, + 220 + ], + "selection_color": [ + 80, + 120, + 200, + 128 + ], + "placeholder_color": [ + 100, + 100, + 120 + ] + }, + { + "id": "litmus:set_skin_btn", + "script": "litmus:skin_changer", + "hover_background": [ + 70, + 100, + 70 + ], + "press_background": [ + 40, + 70, + 40 + ], + "layout": [ + 154, + 20 + ], + "background": [ + 50, + 80, + 50 + ], + "text": "§fSet Skin", + "text_alignment": [ + "centre", + "centre" + ] + }, + { + "id": "litmus:slim_toggle_btn", + "script": "litmus:skin_changer_slim", + "hover_background": [ + 80, + 80, + 120 + ], + "press_background": [ + 50, + 50, + 80 + ], + "layout": [ + 154, + 20 + ], + "background": [ + 60, + 60, + 90 + ], + "text": "§fModel: Classic", + "text_alignment": [ + "centre", + "centre" + ] + }, + { + "id": "litmus:status_text", + "layout": [ + 154, + 12 + ], + "background": [ + 0, + 0, + 0, + 0 + ], + "text": "", + "text_alignment": [ + "centre", + "centre" + ] + } + ] + } + ] +} + diff --git a/src/test/resources/assets/litmus/script/skin_changer.lua b/src/test/resources/assets/litmus/script/skin_changer.lua new file mode 100644 index 0000000..164a130 --- /dev/null +++ b/src/test/resources/assets/litmus/script/skin_changer.lua @@ -0,0 +1,96 @@ +-- Skin Changer Script +-- Handles the "Set Skin" button functionality + +-- Helper function to find an element by ID recursively +local function findById(element, targetId) + local elemId = element:id() + if elemId == targetId then + return element + end + + local count = element:childCount() + for i = 0, count - 1 do + local child = element:child(i) + if child then + local found = findById(child, targetId) + if found then + return found + end + end + end + + return nil +end + +-- Find the root element by traversing up from self +local function getRoot(element) + local current = element + while current:parent() do + current = current:parent() + end + return current +end + +function onDisplay(self) + -- Update the player name display + local root = getRoot(self) + local mc = self:minecraft() + + local playerNameText = findById(root, "litmus:player_name_text") + if playerNameText then + local username = mc:username() + playerNameText:setText("Current: " .. username) + end +end + +function onClick(self, mouseX, mouseY, button) + local root = getRoot(self) + local mc = self:minecraft() + + -- Find the text input + local usernameInput = findById(root, "litmus:username_input") + local statusText = findById(root, "litmus:status_text") + + if not usernameInput then + mc:sendMessage("§cError: Could not find username input!", false) + return + end + + local username = usernameInput:getText() + + -- Validate input + if not username or username == "" then + if statusText then + statusText:setText("§cEnter a username!") + end + mc:playSound("minecraft:block.note_block.bass", 0.5, 0.8) + return + end + + -- Trim whitespace (basic) + username = username:match("^%s*(.-)%s*$") + + if username == "" then + if statusText then + statusText:setText("§cEnter a username!") + end + mc:playSound("minecraft:block.note_block.bass", 0.5, 0.8) + return + end + + -- Run the skin command + local command = "/amblekit skin @p set " .. username + mc:runCommand(command) + + -- Update status + if statusText then + statusText:setText("§aSkin set to: " .. username) + end + + -- Play success sound + mc:playSound("minecraft:ui.button.click", 1.0, 1.2) + + -- Clear the input + usernameInput:setText("") +end + diff --git a/src/test/resources/assets/litmus/script/skin_changer_slim.lua b/src/test/resources/assets/litmus/script/skin_changer_slim.lua new file mode 100644 index 0000000..ef3b568 --- /dev/null +++ b/src/test/resources/assets/litmus/script/skin_changer_slim.lua @@ -0,0 +1,79 @@ +-- Skin Changer Slim Toggle Script +-- Handles the "Toggle Slim" button functionality + +-- Track the current slim mode state +local slimMode = false + +-- Helper function to find an element by ID recursively +local function findById(element, targetId) + if element:id() == targetId then + return element + end + + local count = element:childCount() + for i = 0, count - 1 do + local child = element:child(i) + if child then + local found = findById(child, targetId) + if found then + return found + end + end + end + + return nil +end + +-- Find the root element by traversing up from self +local function getRoot(element) + local current = element + while current:parent() do + current = current:parent() + end + return current +end + +-- Update the button text to reflect current mode +local function updateButtonText(self) + -- Find the text child within the button + local textChild = self:findFirstText() + if textChild then + if slimMode then + textChild:setText("§fModel: Slim") + else + textChild:setText("§fModel: Classic") + end + end +end + +function onDisplay(self) + -- Set initial button text + updateButtonText(self) +end + +function onClick(self, mouseX, mouseY, button) + local root = getRoot(self) + local mc = self:minecraft() + local statusText = findById(root, "litmus:status_text") + + -- Toggle the slim mode + slimMode = not slimMode + + -- Run the command + local slimValue = slimMode and "true" or "false" + local command = "/amblekit skin @p slim " .. slimValue + mc:runCommand(command) + + -- Update button text + updateButtonText(self) + + -- Update status + if statusText then + local modelName = slimMode and "Slim" or "Classic" + statusText:setText("§aModel: " .. modelName) + end + + -- Play sound + mc:playSound("minecraft:ui.button.click", 1.0, 1.0) +end + diff --git a/src/test/resources/assets/litmus/script/test.lua b/src/test/resources/assets/litmus/script/test.lua index a86aa99..5b0f1a1 100644 --- a/src/test/resources/assets/litmus/script/test.lua +++ b/src/test/resources/assets/litmus/script/test.lua @@ -34,7 +34,7 @@ function formatHealth(current, max) return string.format("%.1f/%.1f", current, max) end -function onInit(self) +function onDisplay(self) local mc = self:minecraft() local player = mc:player() @@ -131,7 +131,7 @@ end function onClick(self, mouseX, mouseY, button) -- Refresh entity info when clicked - onInit(self) + onDisplay(self) local mc = self:minecraft() mc:playSound("minecraft:ui.button.click", 1.0, 1.0) From 760825dd3e7c68e02e98c26490e06aae2571f2db Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 22:20:17 +0000 Subject: [PATCH 33/37] Refactor command return values to use Command.SINGLE_SUCCESS for consistency --- .../amble/lib/client/command/ClientScriptCommand.java | 3 ++- .../dev/amble/lib/command/PlayAnimationCommand.java | 3 ++- .../dev/amble/lib/command/ServerScriptCommand.java | 11 +++-------- .../java/dev/amble/lib/command/SetSkinCommand.java | 2 +- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java index fe5d5f6..eb18529 100644 --- a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java +++ b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java @@ -1,5 +1,6 @@ package dev.amble.lib.client.command; +import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; @@ -248,6 +249,6 @@ private static int listAvailable(CommandContext conte statusIcon.copy().append(Text.literal(id.getNamespace() + ":" + displayId).formatted(Formatting.WHITE)) ); } - return 1; + return Command.SINGLE_SUCCESS; } } diff --git a/src/main/java/dev/amble/lib/command/PlayAnimationCommand.java b/src/main/java/dev/amble/lib/command/PlayAnimationCommand.java index 31e2dc1..3c6ab27 100644 --- a/src/main/java/dev/amble/lib/command/PlayAnimationCommand.java +++ b/src/main/java/dev/amble/lib/command/PlayAnimationCommand.java @@ -1,5 +1,6 @@ package dev.amble.lib.command; +import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.BoolArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; @@ -45,6 +46,6 @@ private static int execute(CommandContext context) { String name = target.getEntityName(); context.getSource().sendFeedback(() -> Text.literal("Playing animation "+ animationId +" on "+ name), true); - return 1; + return Command.SINGLE_SUCCESS; } } diff --git a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java index 66aef80..17218b8 100644 --- a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java +++ b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java @@ -33,10 +33,6 @@ public class ServerScriptCommand { private static final String SCRIPT_PREFIX = "script/"; private static final String SCRIPT_SUFFIX = ".lua"; - private static String translationKey(String key) { - return "command." + AmbleKit.MOD_ID + ".script." + key; - } - /** * Converts a full script identifier to a display-friendly format. * Removes the "script/" prefix and ".lua" suffix. @@ -116,9 +112,8 @@ private static int execute(CommandContext context, String a try { LuaScript script = ServerScriptManager.getInstance().getCache().get(fullScriptId); - if (script == null) { - context.getSource().sendError(Text.translatable(translationKey("error.not_found"), scriptId)); - return 0; + if (script == null) context.getSource().sendError(Text.translatable("command.amblekit.script.error.not_found", scriptId)); + context.getSource().sendError(Text.literal("Server script '" + scriptId + "' not found")); } if (script.onExecute() == null || script.onExecute().isnil()) { @@ -252,6 +247,6 @@ private static int listAvailable(CommandContext context) { context.getSource().sendFeedback(() -> statusIcon.copy().append(Text.literal(id.getNamespace() + ":" + displayId).formatted(Formatting.WHITE)), false); } - return 1; + return Command.SINGLE_SUCCESS; } } diff --git a/src/main/java/dev/amble/lib/command/SetSkinCommand.java b/src/main/java/dev/amble/lib/command/SetSkinCommand.java index 9f2a762..a36b003 100644 --- a/src/main/java/dev/amble/lib/command/SetSkinCommand.java +++ b/src/main/java/dev/amble/lib/command/SetSkinCommand.java @@ -164,6 +164,6 @@ private static int executeWithSlim(CommandContext context) String username = entity.getEntityName(); context.getSource().sendFeedback(() -> Text.translatable(translationKey("set"), username, value), true); - return 1; + return Command.SINGLE_SUCCESS; } } From 17a44e4d6873cf6a54a50a54c20eef9e52a021c5 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 22:22:47 +0000 Subject: [PATCH 34/37] fix build --- src/main/java/dev/amble/lib/command/ServerScriptCommand.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java index 17218b8..807e864 100644 --- a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java +++ b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java @@ -112,8 +112,10 @@ private static int execute(CommandContext context, String a try { LuaScript script = ServerScriptManager.getInstance().getCache().get(fullScriptId); - if (script == null) context.getSource().sendError(Text.translatable("command.amblekit.script.error.not_found", scriptId)); + if (script == null) { + context.getSource().sendError(Text.translatable("command.amblekit.script.error.not_found", scriptId)); context.getSource().sendError(Text.literal("Server script '" + scriptId + "' not found")); + return 0; } if (script.onExecute() == null || script.onExecute().isnil()) { From fe0f238db47a0aa99be6faeaa20475d1fc1b4127 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 22:30:28 +0000 Subject: [PATCH 35/37] Refactor command return values to use Command.SINGLE_SUCCESS for consistency Add Testmod Client 2 configuration in build.gradle --- build.gradle | 8 ++++++++ run2/.gitkeep | 0 .../client/command/ClientScriptCommand.java | 14 +++++++------- .../lib/command/ServerScriptCommand.java | 19 ++++++++++++------- .../dev/amble/lib/command/SetSkinCommand.java | 1 + 5 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 run2/.gitkeep diff --git a/build.gradle b/build.gradle index 8d6fc51..6496ae0 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,14 @@ loom { name = "Testmod Client" source sourceSets.test } + testmodClient2 { + client() + ideConfigGenerated project.rootProject == project + name = "Testmod Client 2" + source sourceSets.test + programArgs "--username", "Dev2" + runDir "run2" + } testmodServer { server() ideConfigGenerated project.rootProject == project diff --git a/run2/.gitkeep b/run2/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java index eb18529..c237b3f 100644 --- a/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java +++ b/src/main/java/dev/amble/lib/client/command/ClientScriptCommand.java @@ -136,7 +136,7 @@ private static int execute(CommandContext context, St script.onExecute().call(data, argsTable); context.getSource().sendFeedback(Text.translatable(translationKey("executed"), scriptId)); - return 1; + return Command.SINGLE_SUCCESS; } catch (Exception e) { context.getSource().sendError(Text.translatable(translationKey("error.execute_failed"), scriptId, e.getMessage())); AmbleKit.LOGGER.error("Failed to execute script {}", scriptId, e); @@ -163,7 +163,7 @@ private static int enable(CommandContext context) { if (ScriptManager.getInstance().enable(fullScriptId)) { context.getSource().sendFeedback(Text.translatable(translationKey("enabled"), scriptId).formatted(Formatting.GREEN)); - return 1; + return Command.SINGLE_SUCCESS; } else { context.getSource().sendError(Text.translatable(translationKey("error.enable_failed"), scriptId)); return 0; @@ -181,7 +181,7 @@ private static int disable(CommandContext context) { if (ScriptManager.getInstance().disable(fullScriptId)) { context.getSource().sendFeedback(Text.translatable(translationKey("disabled"), scriptId).formatted(Formatting.RED)); - return 1; + return Command.SINGLE_SUCCESS; } else { context.getSource().sendError(Text.translatable(translationKey("error.disable_failed"), scriptId)); return 0; @@ -208,7 +208,7 @@ private static int toggle(CommandContext context) { } else { context.getSource().sendFeedback(Text.translatable(translationKey("enabled"), scriptId).formatted(Formatting.GREEN)); } - return 1; + return Command.SINGLE_SUCCESS; } private static int listEnabled(CommandContext context) { @@ -216,7 +216,7 @@ private static int listEnabled(CommandContext context if (enabled.isEmpty()) { context.getSource().sendFeedback(Text.translatable(translationKey("list.none_enabled")).formatted(Formatting.GRAY)); - return 1; + return Command.SINGLE_SUCCESS; } context.getSource().sendFeedback(Text.translatable(translationKey("list.enabled_header"), enabled.size()).formatted(Formatting.GOLD, Formatting.BOLD)); @@ -227,7 +227,7 @@ private static int listEnabled(CommandContext context .append(Text.literal(id.getNamespace() + ":" + displayId).formatted(Formatting.WHITE)) ); } - return 1; + return Command.SINGLE_SUCCESS; } private static int listAvailable(CommandContext context) { @@ -236,7 +236,7 @@ private static int listAvailable(CommandContext conte if (available.isEmpty()) { context.getSource().sendFeedback(Text.translatable(translationKey("list.none_available")).formatted(Formatting.GRAY)); - return 1; + return Command.SINGLE_SUCCESS; } context.getSource().sendFeedback(Text.translatable(translationKey("list.available_header"), available.size()).formatted(Formatting.GOLD, Formatting.BOLD)); diff --git a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java index 807e864..bdb5c26 100644 --- a/src/main/java/dev/amble/lib/command/ServerScriptCommand.java +++ b/src/main/java/dev/amble/lib/command/ServerScriptCommand.java @@ -1,5 +1,6 @@ package dev.amble.lib.command; +import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; @@ -33,6 +34,10 @@ public class ServerScriptCommand { private static final String SCRIPT_PREFIX = "script/"; private static final String SCRIPT_SUFFIX = ".lua"; + private static String translationKey(String key) { + return "command." + AmbleKit.MOD_ID + ".script." + key; + } + /** * Converts a full script identifier to a display-friendly format. * Removes the "script/" prefix and ".lua" suffix. @@ -144,7 +149,7 @@ private static int execute(CommandContext context, String a script.onExecute().call(boundData, argsTable); context.getSource().sendFeedback(() -> Text.translatable(translationKey("executed"), scriptId), true); - return 1; + return Command.SINGLE_SUCCESS; } catch (Exception e) { context.getSource().sendError(Text.translatable(translationKey("error.execute_failed"), scriptId, e.getMessage())); AmbleKit.LOGGER.error("Failed to execute server script {}", scriptId, e); @@ -168,7 +173,7 @@ private static int enable(CommandContext context) { if (ServerScriptManager.getInstance().enable(fullScriptId)) { context.getSource().sendFeedback(() -> Text.translatable(translationKey("enabled"), scriptId).formatted(Formatting.GREEN), true); - return 1; + return Command.SINGLE_SUCCESS; } else { context.getSource().sendError(Text.translatable(translationKey("error.enable_failed"), scriptId)); return 0; @@ -186,7 +191,7 @@ private static int disable(CommandContext context) { if (ServerScriptManager.getInstance().disable(fullScriptId)) { context.getSource().sendFeedback(() -> Text.translatable(translationKey("disabled"), scriptId).formatted(Formatting.RED), true); - return 1; + return Command.SINGLE_SUCCESS; } else { context.getSource().sendError(Text.translatable(translationKey("error.disable_failed"), scriptId)); return 0; @@ -210,7 +215,7 @@ private static int toggle(CommandContext context) { } else { context.getSource().sendFeedback(() -> Text.translatable(translationKey("enabled"), scriptId).formatted(Formatting.GREEN), true); } - return 1; + return Command.SINGLE_SUCCESS; } private static int listEnabled(CommandContext context) { @@ -218,7 +223,7 @@ private static int listEnabled(CommandContext context) { if (enabled.isEmpty()) { context.getSource().sendFeedback(() -> Text.translatable(translationKey("list.none_enabled")).formatted(Formatting.GRAY), false); - return 1; + return Command.SINGLE_SUCCESS; } context.getSource().sendFeedback(() -> Text.translatable(translationKey("list.enabled_header"), enabled.size()).formatted(Formatting.GOLD, Formatting.BOLD), false); @@ -228,7 +233,7 @@ private static int listEnabled(CommandContext context) { Text.literal("✓ ").formatted(Formatting.GREEN) .append(Text.literal(id.getNamespace() + ":" + displayId).formatted(Formatting.WHITE)), false); } - return 1; + return Command.SINGLE_SUCCESS; } private static int listAvailable(CommandContext context) { @@ -237,7 +242,7 @@ private static int listAvailable(CommandContext context) { if (available.isEmpty()) { context.getSource().sendFeedback(() -> Text.translatable(translationKey("list.none_available")).formatted(Formatting.GRAY), false); - return 1; + return Command.SINGLE_SUCCESS; } context.getSource().sendFeedback(() -> Text.translatable(translationKey("list.available_header"), available.size()).formatted(Formatting.GOLD, Formatting.BOLD), false); diff --git a/src/main/java/dev/amble/lib/command/SetSkinCommand.java b/src/main/java/dev/amble/lib/command/SetSkinCommand.java index a36b003..f793e93 100644 --- a/src/main/java/dev/amble/lib/command/SetSkinCommand.java +++ b/src/main/java/dev/amble/lib/command/SetSkinCommand.java @@ -1,5 +1,6 @@ package dev.amble.lib.command; +import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.BoolArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; From a12bd5c834582cf13f4f53bfbf964cde8ad77b25 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 22:31:07 +0000 Subject: [PATCH 36/37] Add run2/ to .gitignore to exclude additional run directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2d0e8ca..f38cd86 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ bin/ # fabric run/ +run2/ # java From 4209380cc1310497f6dc26f0eb2a81337609270f Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 22:32:50 +0000 Subject: [PATCH 37/37] name "testmodClient" user "Dev" --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index 6496ae0..b031e28 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,7 @@ loom { ideConfigGenerated project.rootProject == project name = "Testmod Client" source sourceSets.test + programArgs "--username", "Dev" } testmodClient2 { client()