diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7aa7375..ccf14fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ cd guiAPI ./gradlew build ``` -The built jar is at `build/libs/guiapi-1.0.5.jar`. +The built jar is at `build/libs/guiapi-1.0.7.jar`. To run in a local Minecraft instance, use Fabric's `runServer` task or drop the jar into a test server's `mods/` folder. diff --git a/README.md b/README.md index 4d044cc..5649abe 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ No client mod required. No macros. No external dependencies beyond Fabric API. ## Installation -1. Drop `guiapi-1.0.6+26.2.jar` into your `mods/` folder. +1. Drop `guiapi-1.0.7+26.2.jar` into your `mods/` folder. 2. Drop your datapack into `world/datapacks/`. 3. Run `/reload` or `/guiapi reload`. @@ -220,7 +220,7 @@ Please refer to the updated `example-datapack` directory in the repository sourc ```bash chmod +x gradlew ./gradlew build -# Output: build/libs/guiapi-1.0.6+26.2.jar +# Output: build/libs/guiapi-1.0.7+26.2.jar ``` Requires **Java 25**. diff --git a/example-datapack/data/example/gui/widgets_demo.json b/example-datapack/data/example/gui/widgets_demo.json new file mode 100644 index 0000000..4cdea7a --- /dev/null +++ b/example-datapack/data/example/gui/widgets_demo.json @@ -0,0 +1,82 @@ +{ + "title": "§bWidgets & Conditional Item Demo", + "rows": 4, + "tick_rate": 20, + "filler": { + "item": "minecraft:black_stained_glass_pane", + "name": " ", + "hide_tooltip": true + }, + + "buttons": [ + { + "slot": 4, + "item": "minecraft:diamond_sword", + "name": "§b1. Conditional Item (else_item)", + "lore": [ + "§7Same button — item flips automatically", + "§7based on whether you have enough gold.", + "§7Try: /scoreboard players set @s gold_nuggets 5" + ], + "condition": { "type": "has_item", "value": "minecraft:gold_nugget:5" }, + "actions": [ + { "type": "take_item", "value": "minecraft:gold_nugget:5" }, + { "type": "run_command", "value": "give @s minecraft:diamond", "run_with": "console" }, + { "type": "sound", "value": "minecraft:entity.villager.yes" }, + { "type": "action_bar", "value": "§aPurchased!" }, + { "type": "refresh" } + ], + "else_item": { + "item": "minecraft:barrier", + "name": "§c1. Conditional Item §7(Locked)", + "lore": [ + "§7Cost: §e5 Gold Nuggets", + "§cYou don't have enough gold!", + "§7This slot is visible but §cnot clickable", + "§7while the condition is false." + ] + } + }, + + { + "slot": 31, + "item": "minecraft:barrier", + "name": "§cClose", + "actions": [{ "type": "close" }] + } + ], + + "progress_bars": [ + { + "start_slot": 10, + "length": 7, + "value_source": "score:coins", + "max_value": 100, + "filled_item": "minecraft:lime_stained_glass_pane", + "empty_item": "minecraft:gray_stained_glass_pane", + "name": "§e2. Coin Progress: {score:coins}/100", + "lore": ["§7Fills automatically — recalculated on every refresh."] + } + ], + + "displays": [ + { + "slot": 22, + "item": "minecraft:paper", + "name": "§b3. Static Display", + "lore": [ + "§7Player: §f{player}", + "§7Coins: §e{score:coins}", + "§7This item is read-only — clicking it does nothing." + ] + }, + { + "slot": 13, + "item": "minecraft:golden_apple", + "name": "§6VIP Status Active", + "lore": ["§7Shown only while you have the vip tag."], + "glint": true, + "condition": { "type": "has_tag", "value": "vip" } + } + ] +} diff --git a/gradle.properties b/gradle.properties index 292fae2..1d353eb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ org.gradle.parallel=true org.gradle.configuration-cache=false # Project -mod_version=1.0.6+26.2 +mod_version=1.0.7+26.2 maven_group=dev.toolkitmc archives_base_name=guiapi diff --git a/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java b/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java index 8bc1b68..ef4459c 100644 --- a/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java +++ b/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java @@ -196,19 +196,48 @@ private static int showHelp(CommandContext ctx) { "\n" + "Macro functions: define reusable action blocks in JSON with \"macros\": {}\n" + " actions: run_function:\n" + + " random: run_random_function:[*weight](,[*weight]...)\n" + + " e.g. run_random_function:common*70,rare*25,legendary*5\n" + "\n" + "Anvil input: anvil_input action saves text to a variable and {input}\n" + " Example: {\"type\": \"anvil_input\", \"var\": \"myVar\", \"value\": \"Enter name|Default\"}\n" + "\n" + + "Item/XP actions:\n" + + " give_item:: - give item(s), overflow drops at feet\n" + + " take_item:: - remove item(s) from inventory\n" + + " add_xp: - add n XP points\n" + + " add_xp:L - add n XP levels (prefix with L)\n" + + "\n" + "Button JSON fields:\n" + " slot, page, item, name, lore, glint\n" + " click_type: any | left | right | shift\n" + " condition: has_tag | not_tag | score_gt | score_lt | score_eq\n" + " var_eq | var_gt | var_lt | var_set\n" + - " actions: run_command | close | open_gui | message | sound\n" + + " has_item | not_item | level_gt | level_lt\n" + + " health_gt | health_lt | food_gt | food_lt\n" + + " permission:<0-4> (checks player's command permission level)\n" + + " actions: run_command | close | open_gui | message | sound | action_bar\n" + " next_page | prev_page | goto_page | run_function\n" + + " run_random_function | give_item | take_item | add_xp\n" + " set_var | add_var | sub_var | reset_var | clear_vars\n" + - " anvil_input" ; + " set_score | add_score | sub_score\n" + + " add_effect | remove_effect | clear_effects\n" + + " anvil_input\n" + + "\n" + + "Conditional item display: add \"else_item\" (same fields as a button)\n" + + " alongside \"condition\" to show an alternate item instead of hiding\n" + + " the button when the condition is false. Button stays inert while\n" + + " showing else_item (clicks are ignored, not the normal actions).\n" + + "\n" + + "Non-button widgets (top-level GUI JSON fields):\n" + + " progress_bars: [{ start_slot, length, page, value_source,\n" + + " max_value, filled_item, empty_item, name, lore }]\n" + + " value_source: \"score:\" or \"var:\"\n" + + " Fills [start_slot, start_slot+length) proportionally, recalculated\n" + + " on every open/refresh (e.g. via tick_rate). Read-only, not clickable.\n" + + " displays: [{ slot, page, item, name, lore, glint, amount, condition }]\n" + + " A single read-only info item. Supports the same condition types as\n" + + " buttons. Clicks on a display slot are always ignored."; ctx.getSource().sendSuccess(() -> Component.literal(help), false); return 1; } diff --git a/src/main/java/dev/toolkitmc/guiapi/config/GuiApiConfig.java b/src/main/java/dev/toolkitmc/guiapi/config/GuiApiConfig.java index 80a57a4..d12c66a 100644 --- a/src/main/java/dev/toolkitmc/guiapi/config/GuiApiConfig.java +++ b/src/main/java/dev/toolkitmc/guiapi/config/GuiApiConfig.java @@ -43,6 +43,7 @@ public final class GuiApiConfig { private String chatPrefix = "§8[§6GuiAPI§8] §f"; // 8. New Config private int soundVolume = 100; // 9. New Config private String commandExecuteMode = "CHAT"; // 10. New Config + private boolean allowGamemodeChange = true; // 11. New Config private GuiApiConfig() {} @@ -92,6 +93,8 @@ public void load() { soundVolume = Math.clamp(obj.get("sound_volume").getAsInt(), 0, 100); if (obj.has("command_execute_mode")) commandExecuteMode = obj.get("command_execute_mode").getAsString(); + if (obj.has("allow_gamemode_change")) + allowGamemodeChange = obj.get("allow_gamemode_change").getAsBoolean(); } catch (IOException e) { GuiApiMod.LOGGER.error("[GuiAPI] Failed to load config: {}", e.getMessage()); @@ -117,6 +120,7 @@ public void save() { obj.addProperty("chat_prefix", chatPrefix); obj.addProperty("sound_volume", soundVolume); obj.addProperty("command_execute_mode", commandExecuteMode); + obj.addProperty("allow_gamemode_change", allowGamemodeChange); try { Files.writeString(CONFIG_PATH, GSON.toJson(obj)); } catch (IOException e) { @@ -174,4 +178,7 @@ public void save() { public String getCommandExecuteMode() { return commandExecuteMode; } public void setCommandExecuteMode(String v) { commandExecuteMode = v; } + + public boolean isAllowGamemodeChange() { return allowGamemodeChange; } + public void setAllowGamemodeChange(boolean v) { allowGamemodeChange = v; } } diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java b/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java index 2626a10..bbd6884 100644 --- a/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java +++ b/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java @@ -228,10 +228,13 @@ public static void refreshCurrentGui(ServerPlayer player) { // Populate according to page buttons and conditions for (GuiDefinition.Button btn : def.getButtonsForPage(page)) { if (btn.slot() < 0 || btn.slot() >= size) continue; - if (!evaluateCondition(player, btn)) continue; + if (!shouldRenderButton(player, btn)) continue; inv.setItem(btn.slot(), buildStack(player, def, page, btn)); } + // Populate non-button widgets (progress bars, static displays) + populateWidgets(player, def, page, inv, size); + // Apply background filler for empty slots if (def.getFiller().isPresent()) { GuiDefinition.FillerConfig fill = def.getFiller().get(); @@ -392,10 +395,13 @@ private static SimpleContainer buildInventory(ServerPlayer player, for (GuiDefinition.Button btn : def.getButtonsForPage(page)) { if (btn.slot() < 0 || btn.slot() >= size) continue; - if (!evaluateCondition(player, btn)) continue; + if (!shouldRenderButton(player, btn)) continue; inv.setItem(btn.slot(), buildStack(player, def, page, btn)); } + // Populate non-button widgets (progress bars, static displays) + populateWidgets(player, def, page, inv, size); + // Apply background filler for empty slots if (def.getFiller().isPresent()) { GuiDefinition.FillerConfig fill = def.getFiller().get(); @@ -410,6 +416,115 @@ private static SimpleContainer buildInventory(ServerPlayer player, return inv; } + // ── Widget rendering (progress bars, static displays) ────────────────────── + + private static void populateWidgets(ServerPlayer player, GuiDefinition def, int page, Container inv, int size) { + for (GuiDefinition.ProgressBarWidget bar : def.getProgressBarsForPage(page)) { + renderProgressBar(player, def, page, bar, inv, size); + } + for (GuiDefinition.StaticDisplayWidget disp : def.getDisplaysForPage(page)) { + if (disp.slot() < 0 || disp.slot() >= size) continue; + if (disp.condition().isPresent() && !evaluateStaticCondition(player, disp.condition().get())) continue; + inv.setItem(disp.slot(), buildStaticDisplayStack(player, def, page, disp)); + } + } + + private static boolean evaluateStaticCondition(ServerPlayer player, GuiDefinition.ButtonCondition cond) { + // Reuses the same condition logic as buttons by wrapping into a throwaway + // Button-shaped evaluation — avoids duplicating the switch in evaluateCondition. + GuiDefinition.Button fake = new GuiDefinition.Button( + 0, 0, "", "", List.of(), false, GuiDefinition.ClickType.ANY, + Optional.of(cond), List.of(), Optional.empty(), Optional.empty(), Optional.empty(), + "1", false, false, Optional.empty(), 0); + return evaluateCondition(player, fake); + } + + private static int readWidgetValue(ServerPlayer player, String valueSource) { + String[] parts = valueSource.split(":", 2); + if (parts.length != 2) return 0; + String kind = parts[0].trim().toLowerCase(); + String key = parts[1].trim(); + return switch (kind) { + case "score" -> getScore(player, key); + case "var" -> GuiVarStore.INSTANCE.getInt(player.getUUID(), key); + default -> 0; + }; + } + + private static void renderProgressBar(ServerPlayer player, GuiDefinition def, int page, + GuiDefinition.ProgressBarWidget bar, Container inv, int size) { + int value = Math.clamp(readWidgetValue(player, bar.valueSource()), 0, bar.maxValue()); + int filledSlots = bar.maxValue() <= 0 ? 0 + : (int) Math.round((value / (double) bar.maxValue()) * bar.length()); + filledSlots = Math.clamp(filledSlots, 0, bar.length()); + + Identifier filledId = Identifier.tryParse(bar.filledItem()); + Identifier emptyId = Identifier.tryParse(bar.emptyItem()); + Identifier defaultFilledId = Identifier.tryParse("minecraft:lime_stained_glass_pane"); + Identifier defaultEmptyId = Identifier.tryParse("minecraft:gray_stained_glass_pane"); + Item filledItem = (filledId != null && BuiltInRegistries.ITEM.containsKey(filledId)) + ? BuiltInRegistries.ITEM.getValue(filledId) : BuiltInRegistries.ITEM.getValue(defaultFilledId); + Item emptyItem = (emptyId != null && BuiltInRegistries.ITEM.containsKey(emptyId)) + ? BuiltInRegistries.ITEM.getValue(emptyId) : BuiltInRegistries.ITEM.getValue(defaultEmptyId); + + for (int i = 0; i < bar.length(); i++) { + int slot = bar.startSlot() + i; + if (slot < 0 || slot >= size) continue; + boolean filled = i < filledSlots; + ItemStack stack = new ItemStack(filled ? filledItem : emptyItem); + String resolvedName = resolve(bar.name(), player, def, page); + if (!resolvedName.isEmpty()) { + stack.set(DataComponents.CUSTOM_NAME, + Component.literal(resolvedName).withStyle(s -> s.withItalic(false))); + } + if (!bar.lore().isEmpty()) { + List loreTexts = new java.util.ArrayList<>(); + for (String l : bar.lore()) { + loreTexts.add(Component.literal(resolve(l, player, def, page)).withStyle(s -> s.withItalic(false))); + } + stack.set(DataComponents.LORE, new ItemLore(loreTexts)); + } + stack.set(DataComponents.CUSTOM_DATA, CustomData.of(new CompoundTag())); + net.minecraft.world.item.component.TooltipDisplay tooltipDisplay = + new net.minecraft.world.item.component.TooltipDisplay(false, new java.util.LinkedHashSet<>()); + if (resolvedName.isEmpty() && bar.lore().isEmpty()) { + stack.set(DataComponents.TOOLTIP_DISPLAY, tooltipDisplay); + } + inv.setItem(slot, stack); + } + } + + private static ItemStack buildStaticDisplayStack(ServerPlayer player, GuiDefinition def, int page, + GuiDefinition.StaticDisplayWidget disp) { + Identifier id = Identifier.tryParse(resolve(disp.item(), player, def, page)); + Item item = (id != null && BuiltInRegistries.ITEM.containsKey(id)) + ? BuiltInRegistries.ITEM.getValue(id) : Items.PAPER; + + int amount = 1; + try { + amount = Math.clamp(Integer.parseInt(resolve(disp.amount(), player, def, page)), 1, 99); + } catch (NumberFormatException ignored) {} + + ItemStack stack = new ItemStack(item, amount); + String resolvedName = resolve(disp.name(), player, def, page); + if (!resolvedName.isEmpty()) { + stack.set(DataComponents.CUSTOM_NAME, + Component.literal(resolvedName).withStyle(s -> s.withItalic(false))); + } + if (!disp.lore().isEmpty()) { + List loreTexts = new java.util.ArrayList<>(); + for (String l : disp.lore()) { + loreTexts.add(Component.literal(resolve(l, player, def, page)).withStyle(s -> s.withItalic(false))); + } + stack.set(DataComponents.LORE, new ItemLore(loreTexts)); + } + if (disp.glint() && dev.toolkitmc.guiapi.config.GuiApiConfig.INSTANCE.isEnableButtonGlint()) { + stack.set(DataComponents.ENCHANTMENT_GLINT_OVERRIDE, true); + } + stack.set(DataComponents.CUSTOM_DATA, CustomData.of(new CompoundTag())); + return stack; + } + private static ItemStack buildFillerStack(GuiDefinition.FillerConfig fill) { Identifier id = Identifier.tryParse(fill.item()); Item item = Items.STONE; @@ -459,6 +574,20 @@ private static ItemStack buildStack(ServerPlayer player, amountStr = on ? tgl.amountOn() : tgl.amountOff(); hideTooltip = on ? tgl.hideTooltipOn() : tgl.hideTooltipOff(); hideAdditionalTooltip = on ? tgl.hideAdditionalTooltipOn() : tgl.hideAdditionalTooltipOff(); + } else if (btn.elseDisplay().isPresent() && btn.condition().isPresent() + && !evaluateCondition(player, btn)) { + // Condition failed but an else_item was defined — show the alternate + // appearance instead of the button's normal item (button stays visible). + GuiDefinition.ConditionalDisplay alt = btn.elseDisplay().get(); + itemId = alt.item(); + name = alt.name(); + lore = alt.lore(); + glint = alt.glint(); + customModelData = alt.customModelData(); + itemModel = alt.itemModel(); + amountStr = alt.amount(); + hideTooltip = alt.hideTooltip(); + hideAdditionalTooltip = alt.hideAdditionalTooltip(); } else { itemId = btn.item(); name = btn.name(); @@ -598,6 +727,20 @@ static String resolve(String text, ServerPlayer player, // ── Condition evaluation ───────────────────────────────────────────────── + /** + * Whether a button should be rendered in the inventory at all. + * Normally identical to {@link #evaluateCondition}, but a button with an + * {@code else_item} defined stays visible (showing the alternate appearance) + * even when its condition is false, instead of being hidden. + * Clickability is NOT affected by this — {@link #handleClick} still gates + * on {@link #evaluateCondition} directly, so an else-item button is visible + * but inert while its condition is false. + */ + static boolean shouldRenderButton(ServerPlayer player, GuiDefinition.Button btn) { + if (btn.elseDisplay().isPresent() && btn.condition().isPresent()) return true; + return evaluateCondition(player, btn); + } + static boolean evaluateCondition(ServerPlayer player, GuiDefinition.Button btn) { if (btn.condition().isEmpty()) return true; @@ -629,15 +772,21 @@ static boolean evaluateCondition(ServerPlayer player, GuiDefinition.Button btn) } case VAR_SET -> GuiVarStore.INSTANCE.get(player.getUUID(), cond.value()) != null; case HAS_ITEM -> { - String[] parts = cond.value().split(":", 2); - String itemId = parts[0]; - int amount = parts.length > 1 ? parseIntSafe(parts[1]) : 1; + int lastColon = cond.value().lastIndexOf(':'); + String itemId = lastColon > 0 && cond.value().indexOf(':') != lastColon + ? cond.value().substring(0, lastColon) : cond.value(); + String amountPart = lastColon > 0 && cond.value().indexOf(':') != lastColon + ? cond.value().substring(lastColon + 1) : null; + int amount = amountPart != null ? parseIntSafe(amountPart) : 1; yield hasItemCount(player, itemId, amount); } case NOT_ITEM -> { - String[] parts = cond.value().split(":", 2); - String itemId = parts[0]; - int amount = parts.length > 1 ? parseIntSafe(parts[1]) : 1; + int lastColon = cond.value().lastIndexOf(':'); + String itemId = lastColon > 0 && cond.value().indexOf(':') != lastColon + ? cond.value().substring(0, lastColon) : cond.value(); + String amountPart = lastColon > 0 && cond.value().indexOf(':') != lastColon + ? cond.value().substring(lastColon + 1) : null; + int amount = amountPart != null ? parseIntSafe(amountPart) : 1; yield !hasItemCount(player, itemId, amount); } case LEVEL_GT -> player.experienceLevel > parseIntSafe(cond.value()); @@ -646,14 +795,25 @@ static boolean evaluateCondition(ServerPlayer player, GuiDefinition.Button btn) case HEALTH_LT -> player.getHealth() < parseFloatSafe(cond.value()); case FOOD_GT -> player.getFoodData().getFoodLevel() > parseIntSafe(cond.value()); case FOOD_LT -> player.getFoodData().getFoodLevel() < parseIntSafe(cond.value()); - // TODO: PERMISSION condition type — needs the exact API for this project's - // Minecraft version (26.2). CommandSourceStack#hasPermission(int) and - // Player#hasPermissions(int) were both removed/changed here; the new - // permission system uses PermissionCheck/PermissionSet objects and I - // couldn't confirm the exact construction from available references. - // Falls back to "always false" (gate always blocks) rather than guessing - // wrong a third time and breaking the build again. - case PERMISSION -> false; + // PERMISSION condition — value is the required permission level (0-4). + // Uses the same Permission.HasCommandLevel API already used by GuiCommand + // for the /guiapi command's own permission gate (confirmed working there). + case PERMISSION -> { + int requiredLevel = Math.clamp(parseIntSafe(cond.value()), 0, 4); + yield player.createCommandSourceStack().permissions().hasPermission( + new net.minecraft.server.permissions.Permission.HasCommandLevel( + net.minecraft.server.permissions.PermissionLevel.byId(requiredLevel))); + } + // GAMEMODE — value is survival | creative | adventure | spectator + case GAMEMODE -> { + net.minecraft.world.level.GameType current = player.gameMode.getGameModeForPlayer(); + yield current.getName().equalsIgnoreCase(cond.value().trim()); + } + // IN_DIMENSION — value is a dimension id, e.g. minecraft:the_nether + case IN_DIMENSION -> { + Identifier dimId = Identifier.tryParse(cond.value().trim()); + yield dimId != null && player.level().dimension().toString().equals(dimId.toString()); + } }; } @@ -696,6 +856,32 @@ private static void takeItemCount(ServerPlayer player, String itemId, int amount player.containerMenu.broadcastChanges(); } + /** + * Gives the player {@code amount} of {@code itemId}, splitting across the item's + * max stack size. Overflow that doesn't fit in the inventory is dropped at the + * player's feet (matches vanilla /give behavior) rather than silently discarded. + */ + private static void giveItemCount(ServerPlayer player, String itemId, int amount) { + Identifier id = Identifier.tryParse(itemId); + if (id == null || !BuiltInRegistries.ITEM.containsKey(id)) { + if (dev.toolkitmc.guiapi.config.GuiApiConfig.INSTANCE.isLogUnknownItems()) + GuiApiMod.LOGGER.warn("[GuiAPI] Unknown item '{}' in give_item action.", itemId); + return; + } + Item targetItem = BuiltInRegistries.ITEM.getValue(id); + int remaining = amount; + while (remaining > 0) { + ItemStack stack = new ItemStack(targetItem); + int give = Math.min(remaining, stack.getMaxStackSize()); + stack.setCount(give); + remaining -= give; + if (!player.getInventory().add(stack)) { + player.drop(stack, false); + } + } + player.containerMenu.broadcastChanges(); + } + private static int parseIntSafe(String s) { try { return Integer.parseInt(s); } catch (NumberFormatException e) { return 0; } } @@ -777,6 +963,57 @@ static boolean executeAction(ServerPlayer player, GuiDefinition def, executeDelayedActionChain(player, def, currentPage, macroActions, 0, false); } } + case RUN_RANDOM_FUNCTION -> { + // value: comma-separated macro names, each optionally weighted "name*weight" + // e.g. "common*70,rare*25,legendary*5" — plain "name" defaults to weight 1. + String resolvedList = resolve(action.value(), player, def, currentPage); + String[] entries = resolvedList.split(","); + java.util.List pool = new java.util.ArrayList<>(); + for (String entry : entries) { + String trimmed = entry.trim(); + if (trimmed.isEmpty()) continue; + String name = trimmed; + int weight = 1; + int star = trimmed.indexOf('*'); + if (star > 0) { + name = trimmed.substring(0, star).trim(); + weight = Math.max(1, parseIntSafe(trimmed.substring(star + 1).trim())); + } + if (!def.getMacros().containsKey(name)) continue; + for (int w = 0; w < weight; w++) pool.add(name); + } + if (!pool.isEmpty()) { + String chosen = pool.get(java.util.concurrent.ThreadLocalRandom.current().nextInt(pool.size())); + java.util.List macroActions = def.getMacros().get(chosen); + debug("random_function: player={} chosen={} pool_size={}", player.getScoreboardName(), chosen, pool.size()); + if (macroActions != null && !macroActions.isEmpty()) { + executeDelayedActionChain(player, def, currentPage, macroActions, 0, false); + } + } else { + GuiApiMod.LOGGER.warn("[GuiAPI] run_random_function had no valid macros to choose from: {}", resolvedList); + } + } + case SET_GAMEMODE -> { + if (!dev.toolkitmc.guiapi.config.GuiApiConfig.INSTANCE.isAllowGamemodeChange()) { + if (!dev.toolkitmc.guiapi.config.GuiApiConfig.INSTANCE.isMuteClickErrors()) { + GuiApiMod.LOGGER.warn("[GuiAPI] set_gamemode is disabled in config. Skipping."); + } + break; + } + String resolvedGm = resolve(action.value(), player, def, currentPage).trim().toLowerCase(); + net.minecraft.world.level.GameType target = switch (resolvedGm) { + case "survival" -> net.minecraft.world.level.GameType.SURVIVAL; + case "creative" -> net.minecraft.world.level.GameType.CREATIVE; + case "adventure" -> net.minecraft.world.level.GameType.ADVENTURE; + case "spectator" -> net.minecraft.world.level.GameType.SPECTATOR; + default -> null; + }; + if (target != null) { + player.setGameMode(target); + } else { + GuiApiMod.LOGGER.warn("[GuiAPI] Unknown gamemode '{}' in set_gamemode action.", resolvedGm); + } + } case CLOSE -> { player.closeContainer(); return true; @@ -893,11 +1130,32 @@ static boolean executeAction(ServerPlayer player, GuiDefinition def, case CLEAR_VARS -> GuiVarStore.INSTANCE.clear(player.getUUID()); case REFRESH -> refreshCurrentGui(player); case TAKE_ITEM -> { - String[] parts = action.value().split(":", 2); + String resolved = resolve(action.value(), player, def, currentPage); + String[] parts = resolved.split(":", 2); String itemId = parts[0]; int amount = parts.length > 1 ? parseIntSafe(parts[1]) : 1; takeItemCount(player, itemId, amount); } + case GIVE_ITEM -> { + String resolved = resolve(action.value(), player, def, currentPage); + String[] parts = resolved.split(":", 2); + String itemId = parts[0]; + int amount = parts.length > 1 ? Math.max(1, parseIntSafe(parts[1])) : 1; + giveItemCount(player, itemId, amount); + } + case ADD_XP -> { + String resolved = resolve(action.value(), player, def, currentPage); + boolean isLevels = resolved.startsWith("L") || resolved.startsWith("l"); + String numeric = isLevels ? resolved.substring(1) : resolved; + try { + int amount = Integer.parseInt(numeric.trim()); + if (isLevels) { + player.giveExperienceLevels(amount); + } else { + player.giveExperiencePoints(amount); + } + } catch (NumberFormatException ignored) {} + } case SET_SCORE -> { String resolved = resolve(action.value(), player, def, currentPage); String[] parts = resolved.split(":", 2); diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java b/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java index cedec16..e8c157c 100644 --- a/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java +++ b/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java @@ -50,9 +50,9 @@ public static ClickType fromString(String s) { public enum ActionType { RUN_COMMAND, CLOSE, OPEN_GUI, MESSAGE, NEXT_PAGE, PREV_PAGE, GOTO_PAGE, SOUND, - SET_VAR, ADD_VAR, SUB_VAR, RESET_VAR, CLEAR_VARS, REFRESH, TAKE_ITEM, - SET_SCORE, ADD_SCORE, SUB_SCORE, ACTION_BAR, - ADD_EFFECT, REMOVE_EFFECT, CLEAR_EFFECTS, NONE, ANVIL_INPUT, RUN_FUNCTION; + SET_VAR, ADD_VAR, SUB_VAR, RESET_VAR, CLEAR_VARS, REFRESH, TAKE_ITEM, GIVE_ITEM, + SET_SCORE, ADD_SCORE, SUB_SCORE, ACTION_BAR, ADD_XP, + ADD_EFFECT, REMOVE_EFFECT, CLEAR_EFFECTS, NONE, ANVIL_INPUT, RUN_FUNCTION, RUN_RANDOM_FUNCTION, SET_GAMEMODE; public static ActionType fromString(String s) { return switch (s.toLowerCase()) { @@ -71,16 +71,20 @@ public static ActionType fromString(String s) { case "clear_vars" -> CLEAR_VARS; case "refresh" -> REFRESH; case "take_item" -> TAKE_ITEM; + case "give_item" -> GIVE_ITEM; case "set_score" -> SET_SCORE; case "add_score" -> ADD_SCORE; case "sub_score" -> SUB_SCORE; case "action_bar" -> ACTION_BAR; + case "add_xp" -> ADD_XP; case "add_effect" -> ADD_EFFECT; case "remove_effect" -> REMOVE_EFFECT; case "clear_effects" -> CLEAR_EFFECTS; case "none" -> NONE; case "anvil_input" -> ANVIL_INPUT; case "run_function" -> RUN_FUNCTION; + case "run_random_function" -> RUN_RANDOM_FUNCTION; + case "set_gamemode" -> SET_GAMEMODE; default -> NONE; }; } @@ -97,7 +101,7 @@ public enum ConditionType { HAS_TAG, NOT_TAG, SCORE_GT, SCORE_LT, SCORE_EQ, VAR_EQ, VAR_GT, VAR_LT, VAR_SET, HAS_ITEM, NOT_ITEM, LEVEL_GT, LEVEL_LT, HEALTH_GT, HEALTH_LT, FOOD_GT, FOOD_LT, - PERMISSION; + PERMISSION, GAMEMODE, IN_DIMENSION; public static ConditionType fromString(String s) { return switch (s.toLowerCase()) { @@ -119,6 +123,8 @@ public static ConditionType fromString(String s) { case "food_gt" -> FOOD_GT; case "food_lt" -> FOOD_LT; case "permission" -> PERMISSION; + case "gamemode" -> GAMEMODE; + case "in_dimension" -> IN_DIMENSION; default -> HAS_TAG; }; } @@ -144,6 +150,63 @@ public ButtonAction(ActionType type, String value, RunWith runWith) { public record ButtonCondition(ConditionType type, String value) {} + /** + * PROGRESS_BAR widget — a horizontal run of slots that visually fills based on + * a runtime value (score or var), recalculated every render (tick_rate refresh + * or GUI open), unlike a static Button. + * + * value_source: "score:" or "var:" — read as an integer. + * The bar fills fractionally across [start_slot, start_slot + length) based on + * value / max_value, clamped to [0, length]. + */ + public record ProgressBarWidget( + int startSlot, + int length, + int page, + String valueSource, + int maxValue, + String filledItem, + String emptyItem, + String name, + List lore + ) {} + + /** + * STATIC_DISPLAY widget — a read-only, non-clickable item purely for showing + * information (e.g. "Your score: {score:coins}"). Unlike a Button it has no + * actions, no click_type, and clicks on its slot are simply ignored (no-op), + * distinguishing it from a Button with an empty action list (which still + * consumes the click and could theoretically be misused as an invisible gate). + */ + public record StaticDisplayWidget( + int slot, + int page, + String item, + String name, + List lore, + boolean glint, + Optional condition, + String amount + ) {} + + /** + * Alternate ("else") appearance shown on a button when its {@code condition} + * evaluates to false, instead of the button being hidden entirely. + * Only display fields are overridden — slot, page, click_type, and actions + * always come from the parent Button. + */ + public record ConditionalDisplay( + String item, + String name, + List lore, + boolean glint, + Optional customModelData, + Optional itemModel, + String amount, + boolean hideTooltip, + boolean hideAdditionalTooltip + ) {} + /** * 1.21.4+ Multi-value Custom Model Data representation */ @@ -208,6 +271,7 @@ public record Button( String amount, boolean hideTooltip, boolean hideAdditionalTooltip, + Optional elseDisplay, int cooldown ) {} @@ -226,6 +290,11 @@ public record Button( private final ContainerType containerType; private final java.util.Map> macros; + // Set post-construction by parse() only — kept off every constructor signature + // so none of the existing GuiDefinition(...) / create(...) overloads break. + private List progressBars = List.of(); + private List displays = List.of(); + // ── Constructor ────────────────────────────────────────────────────────── public GuiDefinition(Identifier id, String title, int rows, @@ -352,7 +421,59 @@ public static GuiDefinition parse(Identifier id, JsonObject obj) { } } - return new GuiDefinition(id, title, rows, buttons, onOpen, onClose, filler, tickRate, closeOnMove, containerType, macros); + GuiDefinition def = new GuiDefinition(id, title, rows, buttons, onOpen, onClose, filler, tickRate, closeOnMove, containerType, macros); + + List progressBars = new ArrayList<>(); + if (obj.has("progress_bars") && obj.get("progress_bars").isJsonArray()) { + for (JsonElement el : obj.getAsJsonArray("progress_bars")) { + progressBars.add(parseProgressBar(el.getAsJsonObject())); + } + } + def.progressBars = progressBars; + + List displays = new ArrayList<>(); + if (obj.has("displays") && obj.get("displays").isJsonArray()) { + for (JsonElement el : obj.getAsJsonArray("displays")) { + displays.add(parseStaticDisplay(el.getAsJsonObject())); + } + } + def.displays = displays; + + return def; + } + + private static ProgressBarWidget parseProgressBar(JsonObject p) { + int startSlot = p.has("start_slot") ? Math.max(0, p.get("start_slot").getAsInt()) : 0; + int length = p.has("length") ? Math.max(1, p.get("length").getAsInt()) : 9; + int page = p.has("page") ? Math.max(0, p.get("page").getAsInt()) : 0; + String valueSource = p.has("value_source") ? p.get("value_source").getAsString() : "var:progress"; + int maxValue = p.has("max_value") ? Math.max(1, p.get("max_value").getAsInt()) : 100; + String filledItem = p.has("filled_item") ? p.get("filled_item").getAsString() : "minecraft:lime_stained_glass_pane"; + String emptyItem = p.has("empty_item") ? p.get("empty_item").getAsString() : "minecraft:gray_stained_glass_pane"; + String name = p.has("name") ? p.get("name").getAsString() : ""; + List lore = parseStringList(p, "lore"); + return new ProgressBarWidget(startSlot, length, page, valueSource, maxValue, filledItem, emptyItem, name, lore); + } + + private static StaticDisplayWidget parseStaticDisplay(JsonObject d) { + int slot = d.has("slot") ? d.get("slot").getAsInt() : 0; + int page = d.has("page") ? Math.max(0, d.get("page").getAsInt()) : 0; + String item = d.has("item") ? d.get("item").getAsString() : "minecraft:paper"; + String name = d.has("name") ? d.get("name").getAsString() : ""; + List lore = parseStringList(d, "lore"); + boolean glint = d.has("glint") && d.get("glint").getAsBoolean(); + String amount = d.has("amount") ? d.get("amount").getAsString() : "1"; + + Optional condition = Optional.empty(); + if (d.has("condition") && d.get("condition").isJsonObject()) { + JsonObject c = d.getAsJsonObject("condition"); + ConditionType ct = ConditionType.fromString( + c.has("type") ? c.get("type").getAsString() : "has_tag"); + String cv = c.has("value") ? c.get("value").getAsString() : ""; + condition = Optional.of(new ButtonCondition(ct, cv)); + } + + return new StaticDisplayWidget(slot, page, item, name, lore, glint, condition, amount); } private static List parseActionList(JsonObject obj, String key) { @@ -383,12 +504,19 @@ private static Button parseButton(JsonObject b) { int cooldown = b.has("cooldown") ? Math.max(0, b.get("cooldown").getAsInt()) : 0; + // Optional "else_item" — alternate appearance shown when the condition is false, + // instead of hiding the button entirely. + Optional elseDisplay = Optional.empty(); + if (b.has("else_item") && b.get("else_item").isJsonObject()) { + elseDisplay = Optional.of(parseConditionalDisplay(b.getAsJsonObject("else_item"))); + } + // Toggle button — item/name/lore/actions come from the toggle definition if (b.has("toggle") && b.get("toggle").isJsonObject()) { ToggleDefinition toggle = parseToggle(b.getAsJsonObject("toggle")); return new Button(slot, page, "", "", List.of(), false, clickType, condition, List.of(), Optional.of(toggle), Optional.empty(), Optional.empty(), - "1", false, false, cooldown); + "1", false, false, elseDisplay, cooldown); } // Standard button @@ -421,7 +549,31 @@ private static Button parseButton(JsonObject b) { boolean hideAdditionalTooltip = b.has("hide_additional_tooltip") && b.get("hide_additional_tooltip").getAsBoolean(); return new Button(slot, page, item, name, lore, glint, clickType, condition, actions, Optional.empty(), - customModelData, itemModel, amount, hideTooltip, hideAdditionalTooltip, cooldown); + customModelData, itemModel, amount, hideTooltip, hideAdditionalTooltip, elseDisplay, cooldown); + } + + /** + * Parses an "else_item" block — same fields as a standard button's visual + * fields, minus slot/page/click_type/actions (those stay on the parent Button). + */ + private static ConditionalDisplay parseConditionalDisplay(JsonObject e) { + String item = e.has("item") ? e.get("item").getAsString() : "minecraft:barrier"; + String name = e.has("name") ? e.get("name").getAsString() : ""; + boolean glint = e.has("glint") && e.get("glint").getAsBoolean(); + + List lore = parseStringList(e, "lore"); + + Optional customModelData = parseCustomModelData(e, "custom_model_data"); + Optional itemModel = e.has("item_model") + ? Optional.of(e.get("item_model").getAsString()) + : Optional.empty(); + + String amount = e.has("amount") ? e.get("amount").getAsString() : "1"; + boolean hideTooltip = e.has("hide_tooltip") && e.get("hide_tooltip").getAsBoolean(); + boolean hideAdditionalTooltip = e.has("hide_additional_tooltip") && e.get("hide_additional_tooltip").getAsBoolean(); + + return new ConditionalDisplay(item, name, lore, glint, customModelData, itemModel, + amount, hideTooltip, hideAdditionalTooltip); } private static Optional parseCustomModelData(JsonObject obj, String key) { @@ -550,6 +702,15 @@ private static ButtonAction parseAction(JsonObject a) { public boolean isCloseOnMove() { return closeOnMove; } public ContainerType getContainerType() { return containerType; } public java.util.Map> getMacros() { return macros; } + public List getProgressBars() { return progressBars; } + public List getDisplays() { return displays; } + + public List getProgressBarsForPage(int page) { + return progressBars.stream().filter(p -> p.page() == page).toList(); + } + public List getDisplaysForPage(int page) { + return displays.stream().filter(d -> d.page() == page).toList(); + } /** Returns only buttons belonging to the given page. */ public List