From 8bb1fe066dd161512677aa77f86276e4c03b3d68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 07:38:36 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20toggle=20buttons,=20placeholders,=20on?= =?UTF-8?q?=5Fopen/on=5Fclose=20hooks,=20not=5Ftag,=20/guiapi=20=E2=86=92?= =?UTF-8?q?=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle buttons: - New 'toggle' field on buttons (replaces item/actions) - State backed by a scoreboard tag on the player - item_on/off, name_on/off, lore_on/off, glint_on/off, actions_on/off - Default actions_on/off auto-add/remove the tag (run_with: console) - Render picks the correct face based on player.getCommandTags() Placeholders (resolved per-player at render + action time): - {player}, {gui}, {page}, {page1}, {pages}, {score:objective} - Supported in: name, lore, message value, run_command value, title on_open / on_close action hooks: - Top-level 'on_open' / 'on_close' arrays in the GUI JSON - on_open fires after the screen packet is sent - on_close fires in GuiScreenHandler.onClosed() via BarrelGuiHandler.onClose(ServerPlayerEntity) - Useful for tagging players, running scoreboard ops, logging, etc. not_tag condition: - Visible when player does NOT have the given tag - Complement to has_tag for cleaner conditional layouts /guiapi → help: - Bare /guiapi (no subcommand) now executes showHelp instead of doing nothing Example datapack: - showcase.json: toggle, placeholders in title/lore, on_open/on_close messages, not_tag condition example - admin_panel.json: placeholder in title, on_open/on_close tag hooks, PvP toggle button --- README.md | 140 ++++++++---- .../data/example/gui/admin_panel.json | 38 +++- .../data/example/gui/showcase.json | 128 +++++------ .../toolkitmc/guiapi/command/GuiCommand.java | 1 + .../guiapi/gui/BarrelGuiHandler.java | 194 ++++++++++++----- .../toolkitmc/guiapi/gui/GuiDefinition.java | 204 +++++++++++++----- .../guiapi/gui/GuiScreenHandler.java | 6 +- 7 files changed, 485 insertions(+), 226 deletions(-) diff --git a/README.md b/README.md index a2077b9..42fbfc7 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Optionally install [Mod Menu](https://modrinth.com/mod/modmenu) to see loaded GU | Command | Description | |---------|-------------| +| `/guiapi` | Show help (same as `/guiapi help`) | | `/guiapi open ` | Open a GUI for yourself | | `/guiapi open ` | Open a GUI for target players | | `/guiapi list` | List all loaded GUI definitions | @@ -54,9 +55,11 @@ The GUI ID used in commands is `:` — matching the file path u | Field | Type | Default | Description | |-------|------|---------|-------------| -| `title` | string | `"GUI"` | Inventory title. Supports `§` color codes. | +| `title` | string | `"GUI"` | Inventory title. Supports `§` color codes and placeholders. | | `rows` | int 1–6 | `3` | Number of rows (9 slots each). | -| `buttons` | array | `[]` | List of button definitions. | +| `on_open` | action[] | `[]` | Actions executed when the GUI is opened. | +| `on_close` | action[] | `[]` | Actions executed when the GUI is closed (any reason). | +| `buttons` | button[] | `[]` | List of button definitions. | ### Button fields @@ -64,15 +67,29 @@ The GUI ID used in commands is `:` — matching the file path u |-------|------|---------|-------------| | `slot` | int | `0` | Zero-based slot index (0–`rows*9-1`). | | `page` | int | `0` | Which page this button appears on. | -| `item` | string | `"minecraft:stone"` | Item ID, e.g. `minecraft:diamond`. | -| `name` | string | `""` | Display name. Supports `§` color codes. | -| `lore` | string[] | `[]` | Lore lines shown below the name. | +| `item` | string | `"minecraft:stone"` | Item ID. | +| `name` | string | `""` | Display name. Supports color codes and placeholders. | +| `lore` | string[] | `[]` | Lore lines. Supports placeholders. | | `glint` | boolean | `false` | Apply enchantment glint effect. | | `click_type` | string | `"any"` | Which click triggers actions: `any` · `left` · `right` · `shift` | | `condition` | object | — | Visibility condition (see below). | -| `actions` | array | `[close]` | Actions executed in order on click (see below). | +| `actions` | action[] | `[close]` | Actions executed in order on click. | +| `toggle` | object | — | Toggle definition — replaces `item`/`actions` (see below). | -> **Legacy:** A single `"action": {}` object is still accepted for backwards compatibility. +> **Legacy:** A single `"action": {}` object is still accepted. + +### Placeholders + +Supported in `title`, button `name`, `lore`, `message` values, and `run_command` values. + +| Placeholder | Resolves to | +|-------------|-------------| +| `{player}` | Player's display name | +| `{gui}` | GUI ID (`namespace:name`) | +| `{page}` | Current page index (0-based) | +| `{page1}` | Current page index (1-based) | +| `{pages}` | Total page count | +| `{score:objective}` | Player's score in the given scoreboard objective | ### `click_type` values @@ -87,10 +104,10 @@ The GUI ID used in commands is `:` — matching the file path u | Type | `value` field | `run_with` | Description | |------|--------------|------------|-------------| -| `run_command` | Command string (with or without leading `/`) | `player` · `console` | Run a command. Default executor: player. | +| `run_command` | Command string | `player` · `console` | Run a command. Default: player. Supports placeholders. | | `close` | — | — | Close the GUI. | | `open_gui` | `namespace:name` | — | Close and open another GUI. | -| `message` | Text string | — | Send a chat message to the player. | +| `message` | Text string | — | Send a chat message to the player. Supports placeholders. | | `next_page` | — | — | Go to the next page. | | `prev_page` | — | — | Go to the previous page. | | `goto_page` | Page index (string) | — | Jump to a specific page. | @@ -104,10 +121,27 @@ Conditions control button **visibility**. Hidden buttons cannot be clicked. | Type | `value` format | Visible when | |------|---------------|--------------| | `has_tag` | Tag name | Player has the scoreboard tag | +| `not_tag` | Tag name | Player does **not** have the scoreboard tag | | `score_gt` | `"objective:threshold"` | Player's score > threshold | | `score_lt` | `"objective:threshold"` | Player's score < threshold | | `score_eq` | `"objective:value"` | Player's score == value | +### Toggle buttons + +A toggle button shows different item/name/lore/actions depending on a scoreboard tag on the player. Replace the `item` and `actions` fields with a `toggle` object. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `tag` | string | `""` | Scoreboard tag that stores the on/off state. | +| `item_on` / `item_off` | string | lime/gray dye | Item shown in each state. | +| `name_on` / `name_off` | string | `§aEnabled` / `§7Disabled` | Display name in each state. | +| `lore_on` / `lore_off` | string[] | `[]` | Lore in each state. | +| `glint_on` / `glint_off` | boolean | `false` | Glint in each state. | +| `actions_on` | action[] | `[tag @s remove ]` | Actions when clicking while ON. Default removes the tag. | +| `actions_off` | action[] | `[tag @s add ]` | Actions when clicking while OFF. Default adds the tag. | + +The default `actions_on`/`actions_off` use `run_with: console` and handle the tag automatically — you only need to specify them if you want additional side effects. + --- ## Examples @@ -133,26 +167,65 @@ Conditions control button **visibility**. Hidden buttons cannot be clicked. } ``` -### Right-click vs left-click on the same slot +### Placeholder in name and lore + +```json +{ + "slot": 4, + "item": "minecraft:player_head", + "name": "§6{player}", + "lore": [ + "§7Page {page1}/{pages}", + "§7Coins: §e{score:coins}" + ] +} +``` + +### on_open and on_close hooks ```json { - "slot": 13, - "item": "minecraft:paper", - "name": "§7Left or Right Click", + "title": "§aShop", + "rows": 3, + "on_open": [{ "type": "run_command", "value": "tag @s add in_shop", "run_with": "console" }], + "on_close": [{ "type": "run_command", "value": "tag @s remove in_shop", "run_with": "console" }], + "buttons": [] +} +``` + +### Toggle button + +```json +{ + "slot": 4, + "toggle": { + "tag": "notifications_on", + "item_on": "minecraft:bell", + "item_off": "minecraft:barrier", + "name_on": "§aNotifications: ON", + "name_off": "§cNotifications: OFF", + "lore_on": ["§7Click to disable."], + "lore_off": ["§7Click to enable."] + } +} +``` + +### Left-click vs right-click on the same slot + +```json +{ + "slot": 13, "item": "minecraft:paper", "name": "§fInteract", "click_type": "left", - "actions": [{ "type": "message", "value": "§aYou left-clicked!" }] + "actions": [{ "type": "message", "value": "§aLeft!" }] }, { - "slot": 13, - "item": "minecraft:paper", - "name": "§7Left or Right Click", + "slot": 13, "item": "minecraft:paper", "name": "§fInteract", "click_type": "right", - "actions": [{ "type": "message", "value": "§eYou right-clicked!" }] + "actions": [{ "type": "message", "value": "§eRight!" }] } ``` -> Two buttons can share a slot if they have different `click_type` values. The first matching one wins. +> Two buttons can share a slot with different `click_type` values. The first matching one wins. ### Conditional button (tag-gated) @@ -166,23 +239,7 @@ Conditions control button **visibility**. Hidden buttons cannot be clicked. } ``` -### Multi-page GUI - -```json -{ - "title": "§9Item Shop", - "rows": 4, - "buttons": [ - { "slot": 0, "page": 0, "item": "minecraft:apple", "name": "§aApple", "actions": [{ "type": "run_command", "value": "give @s minecraft:apple" }] }, - { "slot": 1, "page": 0, "item": "minecraft:bread", "name": "§aBread", "actions": [{ "type": "run_command", "value": "give @s minecraft:bread" }] }, - { "slot": 26, "page": 0, "item": "minecraft:arrow", "name": "§7Next →", "actions": [{ "type": "next_page" }] }, - { "slot": 0, "page": 1, "item": "minecraft:diamond", "name": "§bDiamond", "actions": [{ "type": "run_command", "value": "give @s minecraft:diamond" }] }, - { "slot": 27, "page": 1, "item": "minecraft:arrow", "name": "§7← Back", "actions": [{ "type": "prev_page" }] } - ] -} -``` - -### Console command (elevated execution) +### Console command ```json { @@ -216,15 +273,16 @@ Requires **Java 21**. src/main/java/dev/toolkitmc/guiapi/ ├── GuiApiMod.java ModInitializer — registers reload listener and command ├── command/ - │ └── GuiCommand.java /guiapi open|list|reload|help + │ └── GuiCommand.java /guiapi open|list|reload|help (bare /guiapi → help) ├── gui/ - │ ├── GuiDefinition.java JSON data model (title, rows, buttons, actions, conditions) - │ ├── GuiScreenHandler.java Extends GenericContainerScreenHandler; blocks slot interaction - │ └── BarrelGuiHandler.java Opens inventory screens; evaluates conditions; dispatches actions + │ ├── GuiDefinition.java JSON model: title, rows, buttons, toggle, on_open/close + │ ├── GuiScreenHandler.java Extends GenericContainerScreenHandler; blocks interaction + │ └── BarrelGuiHandler.java Opens screens; evaluates conditions; resolves placeholders; + │ dispatches actions; fires on_open/on_close hooks ├── loader/ │ └── GuiRegistry.java Loads data//gui/*.json on resource reload └── modmenu/ - └── GuiApiModMenuEntry.java Optional Mod Menu info screen (compile-only dependency) + └── GuiApiModMenuEntry.java Optional Mod Menu info screen ``` --- diff --git a/example-datapack/data/example/gui/admin_panel.json b/example-datapack/data/example/gui/admin_panel.json index 293b78d..17533c3 100644 --- a/example-datapack/data/example/gui/admin_panel.json +++ b/example-datapack/data/example/gui/admin_panel.json @@ -1,6 +1,12 @@ { - "title": "§6Admin Panel", + "title": "§6Admin Panel §8— {player}", "rows": 3, + "on_open": [ + { "type": "run_command", "value": "tag @s add in_admin_panel", "run_with": "console" } + ], + "on_close": [ + { "type": "run_command", "value": "tag @s remove in_admin_panel", "run_with": "console" } + ], "buttons": [ { "slot": 10, @@ -14,7 +20,7 @@ { "slot": 12, "item": "minecraft:compass", - "name": "§bSpawn", + "name": "§bSpawn §8(Left)", "lore": ["§7Teleport to spawn.", "§8Left-click only."], "click_type": "left", "actions": [ @@ -25,13 +31,26 @@ { "slot": 12, "item": "minecraft:compass", - "name": "§bSpawn", - "lore": ["§7See your coordinates.", "§8Right-click only."], + "name": "§bCoords §8(Right)", + "lore": ["§7Show your coordinates.", "§8Right-click only."], "click_type": "right", "actions": [ { "type": "run_command", "value": "execute as @s run data get entity @s Pos" } ] }, + { + "slot": 13, + "toggle": { + "tag": "pvp_enabled", + "item_on": "minecraft:iron_sword", + "item_off": "minecraft:shield", + "name_on": "§cPvP: ON", + "name_off": "§aPvP: OFF", + "lore_on": ["§7Click to disable PvP."], + "lore_off": ["§7Click to enable PvP."], + "glint_on": true + } + }, { "slot": 14, "item": "minecraft:ender_pearl", @@ -45,12 +64,16 @@ "slot": 15, "item": "minecraft:nether_star", "name": "§cAdmin Tools", - "lore": ["§7Only visible with the §cadmin§7 tag.", "§8Shift-click to open."], + "lore": [ + "§7Only visible with the §cadmin§7 tag.", + "§8Shift-click to activate." + ], "glint": true, "click_type": "shift", "condition": { "type": "has_tag", "value": "admin" }, "actions": [ { "type": "run_command", "value": "gamemode creative @s", "run_with": "console" }, + { "type": "message", "value": "§aCreative mode enabled." }, { "type": "close" } ] }, @@ -58,10 +81,7 @@ "slot": 16, "item": "minecraft:barrier", "name": "§cClose", - "lore": [], - "actions": [ - { "type": "close" } - ] + "actions": [{ "type": "close" }] } ] } diff --git a/example-datapack/data/example/gui/showcase.json b/example-datapack/data/example/gui/showcase.json index 93c3964..7a74e12 100644 --- a/example-datapack/data/example/gui/showcase.json +++ b/example-datapack/data/example/gui/showcase.json @@ -1,144 +1,120 @@ { - "title": "§5Feature Showcase", + "title": "§5Showcase §8[Page {page1}/{pages}]", "rows": 6, + "on_open": [{ "type": "message", "value": "§7Welcome, §f{player}§7! Opening showcase GUI." }], + "on_close": [{ "type": "message", "value": "§7Closed showcase GUI." }], "buttons": [ { - "slot": 0, - "page": 0, + "slot": 0, "page": 0, "item": "minecraft:lime_stained_glass_pane", "name": "§aLeft Click", "lore": ["§7Triggers on left-click only."], "click_type": "left", - "actions": [ - { "type": "message", "value": "§aYou left-clicked!" } - ] + "actions": [{ "type": "message", "value": "§aYou left-clicked!" }] }, { - "slot": 0, - "page": 0, + "slot": 0, "page": 0, "item": "minecraft:yellow_stained_glass_pane", "name": "§eRight Click", "lore": ["§7Triggers on right-click only."], "click_type": "right", - "actions": [ - { "type": "message", "value": "§eYou right-clicked!" } - ] + "actions": [{ "type": "message", "value": "§eYou right-clicked!" }] }, { - "slot": 1, - "page": 0, + "slot": 1, "page": 0, "item": "minecraft:cyan_stained_glass_pane", "name": "§bShift Click", "lore": ["§7Triggers on shift+click only."], "click_type": "shift", - "actions": [ - { "type": "message", "value": "§bYou shift-clicked!" } - ] + "actions": [{ "type": "message", "value": "§bYou shift-clicked!" }] }, { - "slot": 3, - "page": 0, - "item": "minecraft:enchanted_book", - "name": "§dGlint Item", - "lore": ["§7This item has enchantment glint."], - "glint": true, - "actions": [ - { "type": "message", "value": "§dShiny!" } + "slot": 3, "page": 0, + "item": "minecraft:name_tag", + "name": "§6{player}", + "lore": [ + "§7Coins: §e{score:coins}", + "§7Page: §f{page1} / {pages}", + "§7GUI: §8{gui}" ] }, { - "slot": 5, - "page": 0, + "slot": 5, "page": 0, "item": "minecraft:nether_star", "name": "§6Admin Only", "lore": ["§7Visible only with the §cadmin§7 tag."], "glint": true, "condition": { "type": "has_tag", "value": "admin" }, - "actions": [ - { "type": "message", "value": "§6You are an admin." } - ] + "actions": [{ "type": "message", "value": "§6You are an admin." }] }, { - "slot": 7, - "page": 0, - "item": "minecraft:gold_ingot", - "name": "§6Score Check", - "lore": ["§7Visible only if §6coins§7 score > 10."], - "condition": { "type": "score_gt", "value": "coins:10" }, - "actions": [ - { "type": "message", "value": "§6You have enough coins." } - ] + "slot": 6, "page": 0, + "item": "minecraft:barrier", + "name": "§cGuest Only", + "lore": ["§7Visible only WITHOUT the §cadmin§7 tag."], + "condition": { "type": "not_tag", "value": "admin" }, + "actions": [{ "type": "message", "value": "§cYou are not an admin." }] }, { - "slot": 9, - "page": 0, + "slot": 9, "page": 0, + "toggle": { + "tag": "showcase_toggle", + "item_on": "minecraft:lime_dye", + "item_off": "minecraft:gray_dye", + "name_on": "§aToggle: ON", + "name_off": "§7Toggle: OFF", + "lore_on": ["§7Click to turn off.", "§8Tag: showcase_toggle"], + "lore_off": ["§7Click to turn on.", "§8Tag: showcase_toggle"], + "glint_on": true + } + }, + { + "slot": 11, "page": 0, "item": "minecraft:paper", "name": "§fMulti-Action", - "lore": [ - "§7Runs two commands,", - "§7sends a message, then closes." - ], + "lore": ["§7Two commands, a message, then close."], "actions": [ - { "type": "run_command", "value": "say Action 1" }, - { "type": "run_command", "value": "say Action 2" }, + { "type": "run_command", "value": "say §7{player} ran multi-action." }, { "type": "message", "value": "§7Both commands ran." }, { "type": "close" } ] }, { - "slot": 11, - "page": 0, + "slot": 13, "page": 0, "item": "minecraft:command_block", "name": "§cConsole Command", - "lore": [ - "§7Runs with console permission.", - "§8(run_with: console)" - ], + "lore": ["§7run_with: console", "§8Only visible to admins."], "condition": { "type": "has_tag", "value": "admin" }, "actions": [ - { "type": "run_command", "value": "op @s", "run_with": "console" }, - { "type": "close" } + { "type": "run_command", "value": "say {player} used a console action.", "run_with": "console" } ] }, { - "slot": 53, - "page": 0, + "slot": 53, "page": 0, "item": "minecraft:arrow", "name": "§7Next Page →", "lore": ["§8Page 1 / 2"], - "actions": [ - { "type": "next_page" } - ] + "actions": [{ "type": "next_page" }] }, { - "slot": 4, - "page": 1, + "slot": 4, "page": 1, "item": "minecraft:ender_eye", "name": "§5Page 2", - "lore": ["§7You made it to page 2!"], - "actions": [ - { "type": "message", "value": "§5Welcome to page 2." } - ] + "lore": ["§7You made it!", "§8{player} is on page {page1}."], + "actions": [{ "type": "message", "value": "§5Welcome to page 2, {player}!" }] }, { - "slot": 45, - "page": 1, + "slot": 45, "page": 1, "item": "minecraft:arrow", "name": "§7← Previous Page", "lore": ["§8Page 2 / 2"], - "actions": [ - { "type": "prev_page" } - ] + "actions": [{ "type": "prev_page" }] }, { - "slot": 49, - "page": 1, + "slot": 49, "page": 1, "item": "minecraft:barrier", "name": "§cClose", - "lore": [], - "actions": [ - { "type": "close" } - ] + "actions": [{ "type": "close" }] } ] } diff --git a/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java b/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java index 4086111..2a8cfab 100644 --- a/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java +++ b/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java @@ -30,6 +30,7 @@ public static void register(CommandDispatcher dispatcher) { dispatcher.register( CommandManager.literal("guiapi") .requires(src -> src.hasPermissionLevel(2)) + .executes(GuiCommand::showHelp) .then(CommandManager.literal("open") .then(CommandManager.argument("id", IdentifierArgumentType.identifier()) diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java b/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java index 888d0b7..e49e6f1 100644 --- a/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java +++ b/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java @@ -4,7 +4,6 @@ import net.minecraft.component.DataComponentTypes; import net.minecraft.component.type.LoreComponent; import net.minecraft.component.type.NbtComponent; -import net.minecraft.enchantment.Enchantments; import net.minecraft.entity.player.PlayerEntity; import net.minecraft.entity.player.PlayerInventory; import net.minecraft.inventory.SimpleInventory; @@ -35,8 +34,11 @@ * * Features: * - Multi-page support (page tracked per player) - * - Conditional buttons (has_tag, score_gt/lt/eq) + * - Toggle buttons (tag-backed on/off state) + * - Conditional buttons (has_tag, not_tag, score_gt/lt/eq) * - Multiple actions per button (executed in order) + * - on_open / on_close action hooks + * - Placeholder substitution in text fields * - Enchantment glint on items * - run_command with run_with: player|console */ @@ -81,26 +83,28 @@ public net.minecraft.screen.ScreenHandler createMenu( return new GuiScreenHandler(rowsToType(rows), syncId, playerInv, inv, rows, def, finalPage); } }); + + // Fire on_open actions after the screen is sent + for (GuiDefinition.ButtonAction action : def.getOnOpen()) { + executeAction(player, def, page, action); + } } public static boolean handleClick(ServerPlayerEntity player, GuiDefinition def, int page, int slot, int mouseButton, SlotActionType actionType) { - // Resolve what kind of click this actually is. // mouseButton: 0 = left, 1 = right (Minecraft protocol) - // actionType QUICK_MOVE = shift+click final boolean isShift = actionType == SlotActionType.QUICK_MOVE; final boolean isLeft = !isShift && mouseButton == 0 && actionType == SlotActionType.PICKUP; final boolean isRight = !isShift && mouseButton == 1 && actionType == SlotActionType.PICKUP; // Consume every action type to block item manipulation. - // Only PICKUP and QUICK_MOVE can actually trigger button actions. if (!isLeft && !isRight && !isShift) return true; for (GuiDefinition.Button btn : def.getButtonsForPage(page)) { if (btn.slot() != slot) continue; if (!evaluateCondition(player, btn)) continue; - // Check click_type filter + // click_type filter boolean matches = switch (btn.clickType()) { case LEFT -> isLeft; case RIGHT -> isRight; @@ -109,7 +113,8 @@ public static boolean handleClick(ServerPlayerEntity player, GuiDefinition def, }; if (!matches) continue; - for (GuiDefinition.ButtonAction action : btn.actions()) { + List actions = resolveActions(player, btn); + for (GuiDefinition.ButtonAction action : actions) { boolean shouldBreak = executeAction(player, def, page, action); if (shouldBreak) break; } @@ -119,7 +124,18 @@ public static boolean handleClick(ServerPlayerEntity player, GuiDefinition def, } public static void onClose(UUID playerUuid) { - OPEN_GUIS.remove(playerUuid); + OpenState state = OPEN_GUIS.remove(playerUuid); + // on_close hooks — need the server player from somewhere; we don't have it here. + // Handled in GuiScreenHandler.onClosed() which passes the player directly. + // This overload is kept for callers that only have the UUID. + } + + public static void onClose(ServerPlayerEntity player) { + OpenState state = OPEN_GUIS.remove(player.getUuid()); + if (state == null) return; + for (GuiDefinition.ButtonAction action : state.def().getOnClose()) { + executeAction(player, state.def(), state.page(), action); + } } // ── Inventory builder ──────────────────────────────────────────────────── @@ -132,57 +148,112 @@ private static SimpleInventory buildInventory(ServerPlayerEntity player, for (GuiDefinition.Button btn : def.getButtonsForPage(page)) { if (btn.slot() < 0 || btn.slot() >= size) continue; - if (!evaluateCondition(player, btn)) continue; // hide button - inv.setStack(btn.slot(), buildStack(btn)); + if (!evaluateCondition(player, btn)) continue; + inv.setStack(btn.slot(), buildStack(player, def, page, btn)); } return inv; } - private static ItemStack buildStack(GuiDefinition.Button btn) { - Identifier itemId = Identifier.tryParse(btn.item()); + private static ItemStack buildStack(ServerPlayerEntity player, + GuiDefinition def, int page, + GuiDefinition.Button btn) { + // Resolve toggle state to concrete display values + final String itemId; + final String name; + final List lore; + final boolean glint; + + if (btn.toggle().isPresent()) { + GuiDefinition.ToggleDefinition tgl = btn.toggle().get(); + boolean on = player.getCommandTags().contains(tgl.tag()); + itemId = on ? tgl.itemOn() : tgl.itemOff(); + name = on ? tgl.nameOn() : tgl.nameOff(); + lore = on ? tgl.loreOn() : tgl.loreOff(); + glint = on ? tgl.glintOn() : tgl.glintOff(); + } else { + itemId = btn.item(); + name = btn.name(); + lore = btn.lore(); + glint = btn.glint(); + } + + Identifier id = Identifier.tryParse(itemId); Item item; - if (itemId != null && Registries.ITEM.containsId(itemId)) { - item = Registries.ITEM.get(itemId); + if (id != null && Registries.ITEM.containsId(id)) { + item = Registries.ITEM.get(id); } else { - GuiApiMod.LOGGER.warn("[GuiAPI] Unknown item '{}' in slot {}, falling back to stone.", btn.item(), btn.slot()); + GuiApiMod.LOGGER.warn("[GuiAPI] Unknown item '{}' in slot {}, falling back to stone.", itemId, btn.slot()); item = Items.STONE; } ItemStack stack = new ItemStack(item); - if (!btn.name().isEmpty()) { + String resolvedName = resolve(name, player, def, page); + if (!resolvedName.isEmpty()) { stack.set(DataComponentTypes.CUSTOM_NAME, - Text.literal(btn.name()).styled(s -> s.withItalic(false))); + Text.literal(resolvedName).styled(s -> s.withItalic(false))); } - if (!btn.lore().isEmpty()) { - List loreTexts = btn.lore().stream() - .map(l -> (Text) Text.literal(l).styled(s -> s.withItalic(false))) + if (!lore.isEmpty()) { + List loreTexts = lore.stream() + .map(l -> (Text) Text.literal(resolve(l, player, def, page)) + .styled(s -> s.withItalic(false))) .toList(); stack.set(DataComponentTypes.LORE, new LoreComponent(loreTexts)); } - // Enchantment glint — add a hidden glint flag via custom_data - if (btn.glint()) { - stack.set(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE, true); - } + if (glint) stack.set(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE, true); - // Mark as GUI item (blocks extraction) + // Mark as GUI item to block extraction stack.set(DataComponentTypes.CUSTOM_DATA, NbtComponent.of(new NbtCompound())); return stack; } + // ── Placeholder resolution ──────────────────────────────────────────────── + + /** + * Resolves placeholders in a string: + * {player} — player name + * {gui} — GUI id + * {page} — page index (0-based) + * {page1} — page index (1-based) + * {pages} — total page count + * {score:obj} — player score in objective "obj" + */ + static String resolve(String text, ServerPlayerEntity player, + GuiDefinition def, int page) { + if (text == null || text.isEmpty() || !text.contains("{")) return text; + + text = text.replace("{player}", player.getDisplayName().getString()); + text = text.replace("{gui}", def.getId().toString()); + text = text.replace("{page}", String.valueOf(page)); + text = text.replace("{page1}", String.valueOf(page + 1)); + text = text.replace("{pages}", String.valueOf(def.getPageCount())); + + // {score:objective} + int idx; + while ((idx = text.indexOf("{score:")) >= 0) { + int end = text.indexOf('}', idx); + if (end < 0) break; + String obj = text.substring(idx + 7, end); + int score = getScore(player, obj); + text = text.substring(0, idx) + score + text.substring(end + 1); + } + + return text; + } + // ── Condition evaluation ───────────────────────────────────────────────── - private static boolean evaluateCondition(ServerPlayerEntity player, - GuiDefinition.Button btn) { + static boolean evaluateCondition(ServerPlayerEntity player, GuiDefinition.Button btn) { if (btn.condition().isEmpty()) return true; GuiDefinition.ButtonCondition cond = btn.condition().get(); return switch (cond.type()) { case HAS_TAG -> player.getCommandTags().contains(cond.value()); + case NOT_TAG -> !player.getCommandTags().contains(cond.value()); case SCORE_GT -> getScore(player, cond.value().split(":"), 0) > parseCondInt(cond.value().split(":"), 1); case SCORE_LT -> getScore(player, cond.value().split(":"), 0) < @@ -192,24 +263,19 @@ private static boolean evaluateCondition(ServerPlayerEntity player, }; } - /** value format: "objective:threshold" */ - private static int getScore(ServerPlayerEntity player, String[] parts, int objIndex) { - if (parts.length < 1) return 0; - try { - Scoreboard sb = player.getServer().getScoreboard(); - ScoreboardObjective obj = sb.getNullableObjective(parts[objIndex]); - if (obj == null) return 0; - var score = sb.getScore(ScoreHolder.fromName(player.getNameForScoreboard()), obj); - return score != null ? score.getScore() : 0; - } catch (Exception e) { - return 0; - } - } + // ── Toggle action resolution ───────────────────────────────────────────── - private static int parseCondInt(String[] parts, int index) { - if (parts.length <= index) return 0; - try { return Integer.parseInt(parts[index]); } - catch (NumberFormatException e) { return 0; } + /** + * Returns the actions to execute for a click, accounting for toggle state. + */ + private static List resolveActions( + ServerPlayerEntity player, GuiDefinition.Button btn) { + if (btn.toggle().isPresent()) { + GuiDefinition.ToggleDefinition tgl = btn.toggle().get(); + boolean on = player.getCommandTags().contains(tgl.tag()); + return on ? tgl.actionsOn() : tgl.actionsOff(); + } + return btn.actions(); } // ── Action execution ───────────────────────────────────────────────────── @@ -218,19 +284,19 @@ private static int parseCondInt(String[] parts, int index) { * Execute a single action. * @return true if the action chain should stop (screen was closed/changed) */ - private static boolean executeAction(ServerPlayerEntity player, GuiDefinition def, - int currentPage, GuiDefinition.ButtonAction action) { + static boolean executeAction(ServerPlayerEntity player, GuiDefinition def, + int currentPage, GuiDefinition.ButtonAction action) { MinecraftServer server = player.getServer(); switch (action.type()) { case RUN_COMMAND -> { String cmd = action.value().startsWith("/") ? action.value().substring(1) : action.value(); + // Resolve placeholders in command value too + cmd = resolve(cmd, player, def, currentPage); if (action.runWith() == GuiDefinition.RunWith.CONSOLE) { - server.getCommandManager().executeWithPrefix( - server.getCommandSource(), cmd); + server.getCommandManager().executeWithPrefix(server.getCommandSource(), cmd); } else { - server.getCommandManager().executeWithPrefix( - player.getCommandSource(), cmd); + server.getCommandManager().executeWithPrefix(player.getCommandSource(), cmd); } } case CLOSE -> { @@ -250,7 +316,8 @@ private static boolean executeAction(ServerPlayerEntity player, GuiDefinition de } return true; } - case MESSAGE -> player.sendMessage(Text.literal(action.value()), false); + case MESSAGE -> player.sendMessage( + Text.literal(resolve(action.value(), player, def, currentPage)), false); case NEXT_PAGE -> { int next = currentPage + 1; if (next < def.getPageCount()) { @@ -281,6 +348,31 @@ private static boolean executeAction(ServerPlayerEntity player, GuiDefinition de return false; } + // ── Score helpers ───────────────────────────────────────────────────────── + + private static int getScore(ServerPlayerEntity player, String[] parts, int objIndex) { + if (parts.length <= objIndex) return 0; + return getScore(player, parts[objIndex]); + } + + private static int getScore(ServerPlayerEntity player, String objective) { + try { + Scoreboard sb = player.getServer().getScoreboard(); + ScoreboardObjective obj = sb.getNullableObjective(objective); + if (obj == null) return 0; + var score = sb.getScore(ScoreHolder.fromName(player.getNameForScoreboard()), obj); + return score != null ? score.getScore() : 0; + } catch (Exception e) { + return 0; + } + } + + private static int parseCondInt(String[] parts, int index) { + if (parts.length <= index) return 0; + try { return Integer.parseInt(parts[index]); } + catch (NumberFormatException e) { return 0; } + } + // ── Helpers ─────────────────────────────────────────────────────────────── private static ScreenHandlerType rowsToType(int rows) { diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java b/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java index 84d1069..fa18933 100644 --- a/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java +++ b/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java @@ -15,31 +15,53 @@ * { * "title": "My GUI", * "rows": 3, + * "on_open": [ { "type": "run_command", "value": "..." } ], // optional + * "on_close": [ { "type": "run_command", "value": "..." } ], // optional * "buttons": [ * { * "slot": 4, * "page": 0, // optional, default 0 * "item": "minecraft:diamond", - * "name": "§bClick Me", - * "lore": ["§7Line 1"], - * "glint": true, // enchantment glint effect + * "name": "§bClick Me", // supports {player}, {gui}, {page} + * "lore": ["§7Score: {score:coins}"], + * "glint": true, * "click_type": "any", // any (default) | left | right | shift - * "condition": { // optional visibility condition - * "type": "has_tag", // has_tag | score_gt | score_lt | score_eq - * "value": "my_tag" // tag name OR "objective:min:max" for score + * "condition": { + * "type": "has_tag", // has_tag | not_tag | score_gt | score_lt | score_eq + * "value": "my_tag" * }, - * "actions": [ // list of actions (executed in order) - * { - * "type": "run_command", - * "value": "/say hi", - * "run_with": "player" // "player" (default) | "console" - * }, + * "actions": [ + * { "type": "run_command", "value": "/say hi", "run_with": "player" }, * { "type": "close" } * ] + * }, + * { + * "slot": 8, + * "toggle": { + * "tag": "my_toggle_tag", // scoreboard tag used as toggle state + * "item_on": "minecraft:lime_dye", + * "item_off": "minecraft:gray_dye", + * "name_on": "§aEnabled", + * "name_off": "§7Disabled", + * "lore_on": ["§7Click to disable."], + * "lore_off": ["§7Click to enable."], + * "glint_on": false, + * "glint_off": false, + * "actions_on": [ { "type": "run_command", "value": "tag @s remove my_toggle_tag" } ], + * "actions_off": [ { "type": "run_command", "value": "tag @s add my_toggle_tag" } ] + * } * } * ] * } * + * Placeholders (resolved at render time per-player): + * {player} — player's display name + * {gui} — GUI ID (namespace:name) + * {page} — current page index (0-based) + * {page1} — current page index (1-based) + * {pages} — total page count + * {score:obj} — player's score in objective "obj" + * * Action types: * run_command — run a command (run_with: player|console) * close — close the GUI @@ -50,10 +72,11 @@ * goto_page — go to specific page (value: page index as string) * * Condition types: - * has_tag — value: tag name. Button visible only if player has tag. - * score_gt — value: "objective:min". Visible if score > min. - * score_lt — value: "objective:max". Visible if score < max. - * score_eq — value: "objective:val". Visible if score == val. + * has_tag — value: tag name. Visible if player has tag. + * not_tag — value: tag name. Visible if player does NOT have tag. + * score_gt — value: "objective:threshold". Visible if score > threshold. + * score_lt — value: "objective:threshold". Visible if score < threshold. + * score_eq — value: "objective:value". Visible if score == value. */ public class GuiDefinition { @@ -61,10 +84,6 @@ public class GuiDefinition { /** * Which mouse button triggers this button's actions. - * ANY — left or right click (default, original behaviour) - * LEFT — only left click - * RIGHT — only right click - * SHIFT — only shift+left click (QUICK_MOVE) */ public enum ClickType { ANY, LEFT, RIGHT, SHIFT; @@ -96,18 +115,20 @@ public static ActionType fromString(String s) { } } - public enum RunWith { PLAYER, CONSOLE; + public enum RunWith { + PLAYER, CONSOLE; public static RunWith fromString(String s) { return "console".equalsIgnoreCase(s) ? CONSOLE : PLAYER; } } public enum ConditionType { - HAS_TAG, SCORE_GT, SCORE_LT, SCORE_EQ; + HAS_TAG, NOT_TAG, SCORE_GT, SCORE_LT, SCORE_EQ; public static ConditionType fromString(String s) { return switch (s.toLowerCase()) { case "has_tag" -> HAS_TAG; + case "not_tag" -> NOT_TAG; case "score_gt" -> SCORE_GT; case "score_lt" -> SCORE_LT; case "score_eq" -> SCORE_EQ; @@ -126,6 +147,24 @@ public ButtonAction(ActionType type, String value) { public record ButtonCondition(ConditionType type, String value) {} + /** + * Toggle definition — stored on a button instead of a fixed item/actions. + * State is tracked via a scoreboard tag on the player. + */ + public record ToggleDefinition( + String tag, + String itemOn, String itemOff, + String nameOn, String nameOff, + List loreOn, List loreOff, + boolean glintOn, boolean glintOff, + List actionsOn, + List actionsOff + ) {} + + /** + * A button in the GUI. + * Either {@code toggle} is present (toggle button) or {@code item}/{@code actions} are used. + */ public record Button( int slot, int page, @@ -135,7 +174,8 @@ public record Button( boolean glint, ClickType clickType, Optional condition, - List actions + List actions, + Optional toggle ) {} // ── Fields ─────────────────────────────────────────────────────────────── @@ -145,14 +185,21 @@ public record Button( private final int rows; private final int pageCount; private final List