diff --git a/README.md b/README.md index 42fbfc7..3b120ed 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ Optionally install [Mod Menu](https://modrinth.com/mod/modmenu) to see loaded GU | `/guiapi open ` | Open a GUI for target players | | `/guiapi list` | List all loaded GUI definitions | | `/guiapi reload` | Reload all datapack resources (including GUIs) | +| `/guiapi var get ` | Get a player's runtime variable | +| `/guiapi var set ` | Set a player's runtime variable | +| `/guiapi var clear ` | Clear all runtime variables for a player | | `/guiapi help` | Show command and JSON field reference in-game | **Permission level 2** (OP) required. @@ -90,6 +93,7 @@ Supported in `title`, button `name`, `lore`, `message` values, and `run_command` | `{page1}` | Current page index (1-based) | | `{pages}` | Total page count | | `{score:objective}` | Player's score in the given scoreboard objective | +| `{var:key}` | Player's runtime variable `key` (empty string if unset) | ### `click_type` values @@ -108,6 +112,12 @@ Supported in `title`, button `name`, `lore`, `message` values, and `run_command` | `close` | — | — | Close the GUI. | | `open_gui` | `namespace:name` | — | Close and open another GUI. | | `message` | Text string | — | Send a chat message to the player. Supports placeholders. | +| `sound` | `namespace:sound.id` or `namespace:sound.id:volume:pitch` | — | Play a sound to the player. Volume and pitch default to `1.0`. | +| `set_var` | New value (supports placeholders) | — | Set a runtime variable. Requires `"var": "key"` field. | +| `add_var` | Integer to add | — | Add an integer to a runtime variable (creates it at 0 if unset). Requires `"var": "key"`. | +| `sub_var` | Integer to subtract | — | Subtract an integer from a runtime variable. Requires `"var": "key"`. | +| `reset_var` | — | — | Delete a single runtime variable. Requires `"var": "key"`. | +| `clear_vars` | — | — | Delete all runtime variables for this player. | | `next_page` | — | — | Go to the next page. | | `prev_page` | — | — | Go to the previous page. | | `goto_page` | Page index (string) | — | Jump to a specific page. | @@ -125,6 +135,10 @@ Conditions control button **visibility**. Hidden buttons cannot be clicked. | `score_gt` | `"objective:threshold"` | Player's score > threshold | | `score_lt` | `"objective:threshold"` | Player's score < threshold | | `score_eq` | `"objective:value"` | Player's score == value | +| `var_eq` | `"key:value"` | Runtime variable `key` equals `value` (string compare) | +| `var_gt` | `"key:value"` | Runtime variable `key` (int) > `value` | +| `var_lt` | `"key:value"` | Runtime variable `key` (int) < `value` | +| `var_set` | `key` | Runtime variable `key` is set (any value) | ### Toggle buttons @@ -137,10 +151,10 @@ A toggle button shows different item/name/lore/actions depending on a scoreboard | `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. | +| `actions_on` | action[] | `[tag @s remove ]` | Actions executed when clicking while ON (turning OFF). Default removes the tag. | +| `actions_off` | action[] | `[tag @s add ]` | Actions executed when clicking while OFF (turning ON). 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. +The tag is flipped via Java API before `actions_on`/`actions_off` run, so there is no race condition. Custom actions are executed in order after the flip — use them for sounds, messages, or side-effect commands. The GUI always reopens automatically to show the new state unless an action in the chain closes or navigates away. --- @@ -181,6 +195,54 @@ The default `actions_on`/`actions_off` use `run_with: console` and handle the ta } ``` +### Runtime variables + +Variables are per-player, in-memory, and cleared when the GUI closes. + +```json +{ + "title": "§6Counter: {var:count}", + "rows": 1, + "on_open": [ + { "type": "set_var", "var": "count", "value": "0" } + ], + "buttons": [ + { + "slot": 3, + "item": "minecraft:lime_dye", + "name": "§a+1", + "lore": ["§7Count: §f{var:count}"], + "actions": [ + { "type": "add_var", "var": "count", "value": "1" }, + { "type": "open_gui", "value": "example:counter" } + ] + }, + { + "slot": 4, + "item": "minecraft:red_dye", + "name": "§c-1", + "lore": ["§7Count: §f{var:count}"], + "condition": { "type": "var_gt", "value": "count:0" }, + "actions": [ + { "type": "sub_var", "var": "count", "value": "1" }, + { "type": "open_gui", "value": "example:counter" } + ] + }, + { + "slot": 5, + "item": "minecraft:barrier", + "name": "§7Reset", + "actions": [ + { "type": "reset_var", "var": "count" }, + { "type": "open_gui", "value": "example:counter" } + ] + } + ] +} +``` + +> Variables survive page navigation within the same GUI but are cleared on close. Use `on_open` to initialize them to a known value. + ### on_open and on_close hooks ```json @@ -204,8 +266,16 @@ The default `actions_on`/`actions_off` use `run_with: console` and handle the ta "item_off": "minecraft:barrier", "name_on": "§aNotifications: ON", "name_off": "§cNotifications: OFF", - "lore_on": ["§7Click to disable."], - "lore_off": ["§7Click to enable."] + "lore_on": ["§7Click to disable.", "§8Player: {player}"], + "lore_off": ["§7Click to enable.", "§8Player: {player}"], + "actions_on": [ + { "type": "sound", "value": "minecraft:block.lever.click:1.0:0.8" }, + { "type": "message", "value": "§7Notifications disabled, {player}." } + ], + "actions_off": [ + { "type": "sound", "value": "minecraft:block.lever.click:1.0:1.2" }, + { "type": "message", "value": "§aNotifications enabled, {player}!" } + ] } } ``` diff --git a/example-datapack/data/example/gui/admin_panel.json b/example-datapack/data/example/gui/admin_panel.json index 17533c3..0c0cdd2 100644 --- a/example-datapack/data/example/gui/admin_panel.json +++ b/example-datapack/data/example/gui/admin_panel.json @@ -2,7 +2,8 @@ "title": "§6Admin Panel §8— {player}", "rows": 3, "on_open": [ - { "type": "run_command", "value": "tag @s add in_admin_panel", "run_with": "console" } + { "type": "run_command", "value": "tag @s add in_admin_panel", "run_with": "console" }, + { "type": "sound", "value": "minecraft:block.note_block.pling:1.0:1.2" } ], "on_close": [ { "type": "run_command", "value": "tag @s remove in_admin_panel", "run_with": "console" } @@ -14,6 +15,7 @@ "name": "§aRules", "lore": ["§7Click to see the server rules."], "actions": [ + { "type": "sound", "value": "minecraft:entity.experience_orb.pickup" }, { "type": "message", "value": "§6Rules: §fBe respectful. No griefing. No cheating." } ] }, @@ -25,6 +27,7 @@ "click_type": "left", "actions": [ { "type": "run_command", "value": "tp @s 0 64 0" }, + { "type": "sound", "value": "minecraft:entity.enderman.teleport" }, { "type": "close" } ] }, @@ -46,9 +49,17 @@ "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 + "lore_on": ["§7Click to disable PvP.", "§8Player: {player}"], + "lore_off": ["§7Click to enable PvP.", "§8Player: {player}"], + "glint_on": true, + "actions_on": [ + { "type": "sound", "value": "minecraft:block.anvil.land:0.5:1.0" }, + { "type": "message", "value": "§cPvP disabled, {player}." } + ], + "actions_off": [ + { "type": "sound", "value": "minecraft:entity.player.attack.sweep" }, + { "type": "message", "value": "§aPvP enabled, {player}!" } + ] } }, { @@ -73,7 +84,8 @@ "condition": { "type": "has_tag", "value": "admin" }, "actions": [ { "type": "run_command", "value": "gamemode creative @s", "run_with": "console" }, - { "type": "message", "value": "§aCreative mode enabled." }, + { "type": "sound", "value": "minecraft:ui.toast.challenge_complete:1.0:0.8" }, + { "type": "message", "value": "§aCreative mode enabled, {player}." }, { "type": "close" } ] }, @@ -81,7 +93,10 @@ "slot": 16, "item": "minecraft:barrier", "name": "§cClose", - "actions": [{ "type": "close" }] + "actions": [ + { "type": "sound", "value": "minecraft:block.wooden_button.click_off" }, + { "type": "close" } + ] } ] } diff --git a/example-datapack/data/example/gui/counter.json b/example-datapack/data/example/gui/counter.json new file mode 100644 index 0000000..3fc0847 --- /dev/null +++ b/example-datapack/data/example/gui/counter.json @@ -0,0 +1,63 @@ +{ + "title": "§6Counter: §f{var:count} §8— {player}", + "rows": 1, + "on_open": [ + { "type": "set_var", "var": "count", "value": "0" }, + { "type": "sound", "value": "minecraft:block.note_block.pling:0.8:1.0" } + ], + "buttons": [ + { + "slot": 2, + "item": "minecraft:lime_dye", + "name": "§a+1", + "lore": ["§7Current: §f{var:count}"], + "actions": [ + { "type": "add_var", "var": "count", "value": "1" }, + { "type": "sound", "value": "minecraft:block.note_block.pling:1.0:1.5" }, + { "type": "open_gui", "value": "example:counter" } + ] + }, + { + "slot": 3, + "item": "minecraft:red_dye", + "name": "§c-1", + "lore": ["§7Current: §f{var:count}", "§8Disabled at 0."], + "condition": { "type": "var_gt", "value": "count:0" }, + "actions": [ + { "type": "sub_var", "var": "count", "value": "1" }, + { "type": "sound", "value": "minecraft:block.note_block.bass:1.0:1.0" }, + { "type": "open_gui", "value": "example:counter" } + ] + }, + { + "slot": 4, + "item": "minecraft:paper", + "name": "§fValue: §e{var:count}", + "lore": [ + "§7Player: §f{player}", + "§7GUI: §8{gui}" + ] + }, + { + "slot": 5, + "item": "minecraft:comparator", + "name": "§7Reset", + "lore": ["§8Only visible when count > 0."], + "condition": { "type": "var_set", "value": "count" }, + "actions": [ + { "type": "reset_var", "var": "count" }, + { "type": "sound", "value": "minecraft:block.lever.click:1.0:0.8" }, + { "type": "open_gui", "value": "example:counter" } + ] + }, + { + "slot": 7, + "item": "minecraft:barrier", + "name": "§cClose", + "actions": [ + { "type": "sound", "value": "minecraft:block.wooden_button.click_off" }, + { "type": "close" } + ] + } + ] +} diff --git a/example-datapack/data/example/gui/showcase.json b/example-datapack/data/example/gui/showcase.json index 7a74e12..67cadf4 100644 --- a/example-datapack/data/example/gui/showcase.json +++ b/example-datapack/data/example/gui/showcase.json @@ -1,16 +1,24 @@ { - "title": "§5Showcase §8[Page {page1}/{pages}]", + "title": "§5Showcase §8— {player} §8[{page1}/{pages}]", "rows": 6, - "on_open": [{ "type": "message", "value": "§7Welcome, §f{player}§7! Opening showcase GUI." }], - "on_close": [{ "type": "message", "value": "§7Closed showcase GUI." }], + "on_open": [ + { "type": "sound", "value": "minecraft:block.note_block.chime:1.0:1.5" }, + { "type": "message", "value": "§7Welcome, §f{player}§7! Coins: §e{score:coins}" } + ], + "on_close": [ + { "type": "message", "value": "§7Closed showcase GUI." } + ], "buttons": [ { "slot": 0, "page": 0, "item": "minecraft:lime_stained_glass_pane", "name": "§aLeft Click", - "lore": ["§7Triggers on left-click only."], + "lore": ["§7Triggers on left-click only.", "§8Player: {player}"], "click_type": "left", - "actions": [{ "type": "message", "value": "§aYou left-clicked!" }] + "actions": [ + { "type": "sound", "value": "minecraft:ui.button.click" }, + { "type": "message", "value": "§aYou left-clicked, {player}!" } + ] }, { "slot": 0, "page": 0, @@ -18,7 +26,10 @@ "name": "§eRight Click", "lore": ["§7Triggers on right-click only."], "click_type": "right", - "actions": [{ "type": "message", "value": "§eYou right-clicked!" }] + "actions": [ + { "type": "sound", "value": "minecraft:ui.button.click:1.0:0.8" }, + { "type": "message", "value": "§eYou right-clicked, {player}!" } + ] }, { "slot": 1, "page": 0, @@ -26,7 +37,10 @@ "name": "§bShift Click", "lore": ["§7Triggers on shift+click only."], "click_type": "shift", - "actions": [{ "type": "message", "value": "§bYou shift-clicked!" }] + "actions": [ + { "type": "sound", "value": "minecraft:ui.button.click:1.0:1.5" }, + { "type": "message", "value": "§bYou shift-clicked, {player}!" } + ] }, { "slot": 3, "page": 0, @@ -36,16 +50,17 @@ "§7Coins: §e{score:coins}", "§7Page: §f{page1} / {pages}", "§7GUI: §8{gui}" - ] + ], + "actions": [{ "type": "sound", "value": "minecraft:entity.experience_orb.pickup" }] }, { "slot": 5, "page": 0, "item": "minecraft:nether_star", "name": "§6Admin Only", - "lore": ["§7Visible only with the §cadmin§7 tag."], + "lore": ["§7Visible only with the §cadmin§7 tag.", "§8{player}"], "glint": true, "condition": { "type": "has_tag", "value": "admin" }, - "actions": [{ "type": "message", "value": "§6You are an admin." }] + "actions": [{ "type": "message", "value": "§6{player} is an admin." }] }, { "slot": 6, "page": 0, @@ -53,7 +68,7 @@ "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." }] + "actions": [{ "type": "message", "value": "§c{player} is not an admin." }] }, { "slot": 9, "page": 0, @@ -63,58 +78,66 @@ "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 + "lore_on": ["§7Click to turn off.", "§8{player}"], + "lore_off": ["§7Click to turn on.", "§8{player}"], + "glint_on": true, + "actions_on": [ + { "type": "sound", "value": "minecraft:block.lever.click:1.0:0.8" }, + { "type": "message", "value": "§7Toggle turned OFF, {player}." } + ], + "actions_off": [ + { "type": "sound", "value": "minecraft:block.lever.click:1.0:1.2" }, + { "type": "message", "value": "§aToggle turned ON, {player}!" } + ] } }, { "slot": 11, "page": 0, "item": "minecraft:paper", "name": "§fMulti-Action", - "lore": ["§7Two commands, a message, then close."], + "lore": ["§7Command + message + close.", "§8{player} on page {page1}."], "actions": [ + { "type": "sound", "value": "minecraft:entity.arrow.hit_player:0.5:1.0" }, { "type": "run_command", "value": "say §7{player} ran multi-action." }, - { "type": "message", "value": "§7Both commands ran." }, + { "type": "message", "value": "§7Done, {player}." }, { "type": "close" } ] }, - { - "slot": 13, "page": 0, - "item": "minecraft:command_block", - "name": "§cConsole Command", - "lore": ["§7run_with: console", "§8Only visible to admins."], - "condition": { "type": "has_tag", "value": "admin" }, - "actions": [ - { "type": "run_command", "value": "say {player} used a console action.", "run_with": "console" } - ] - }, { "slot": 53, "page": 0, "item": "minecraft:arrow", "name": "§7Next Page →", - "lore": ["§8Page 1 / 2"], - "actions": [{ "type": "next_page" }] + "lore": ["§8Page {page1} / {pages}"], + "actions": [ + { "type": "sound", "value": "minecraft:item.book.page_turn" }, + { "type": "next_page" } + ] }, { "slot": 4, "page": 1, "item": "minecraft:ender_eye", "name": "§5Page 2", - "lore": ["§7You made it!", "§8{player} is on page {page1}."], - "actions": [{ "type": "message", "value": "§5Welcome to page 2, {player}!" }] + "lore": ["§7{player} is on page {page1} of {pages}."], + "actions": [{ "type": "message", "value": "§5Page 2, {player}!" }] }, { "slot": 45, "page": 1, "item": "minecraft:arrow", "name": "§7← Previous Page", - "lore": ["§8Page 2 / 2"], - "actions": [{ "type": "prev_page" }] + "lore": ["§8Page {page1} / {pages}"], + "actions": [ + { "type": "sound", "value": "minecraft:item.book.page_turn" }, + { "type": "prev_page" } + ] }, { "slot": 49, "page": 1, "item": "minecraft:barrier", "name": "§cClose", - "actions": [{ "type": "close" }] + "actions": [ + { "type": "sound", "value": "minecraft:block.wooden_button.click_off" }, + { "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 2a8cfab..5762134 100644 --- a/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java +++ b/src/main/java/dev/toolkitmc/guiapi/command/GuiCommand.java @@ -1,9 +1,11 @@ package dev.toolkitmc.guiapi.command; import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; import dev.toolkitmc.guiapi.gui.BarrelGuiHandler; import dev.toolkitmc.guiapi.gui.GuiDefinition; +import dev.toolkitmc.guiapi.gui.GuiVarStore; import dev.toolkitmc.guiapi.loader.GuiRegistry; import net.minecraft.command.argument.EntityArgumentType; import net.minecraft.command.argument.IdentifierArgumentType; @@ -15,6 +17,7 @@ import java.util.Collection; import java.util.List; +import java.util.Map; /** * /guiapi open [] @@ -62,11 +65,23 @@ public static void register(CommandDispatcher dispatcher) { .then(CommandManager.literal("list") .executes(GuiCommand::listGuis)) - .then(CommandManager.literal("reload") - .executes(GuiCommand::reloadGuis)) - .then(CommandManager.literal("help") .executes(GuiCommand::showHelp)) + + .then(CommandManager.literal("var") + .then(CommandManager.literal("get") + .then(CommandManager.argument("target", EntityArgumentType.player()) + .then(CommandManager.argument("key", StringArgumentType.word()) + .executes(GuiCommand::varGet)))) + .then(CommandManager.literal("set") + .then(CommandManager.argument("target", EntityArgumentType.player()) + .then(CommandManager.argument("key", StringArgumentType.word()) + .then(CommandManager.argument("value", StringArgumentType.greedyString()) + .executes(GuiCommand::varSet))))) + .then(CommandManager.literal("clear") + .then(CommandManager.argument("target", EntityArgumentType.player()) + .executes(GuiCommand::varClear))) + ) ); } @@ -124,20 +139,63 @@ private static int reloadGuis(CommandContext ctx) { return 1; } + private static int varGet(CommandContext ctx) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity target = EntityArgumentType.getPlayer(ctx, "target"); + String key = StringArgumentType.getString(ctx, "key"); + String val = GuiVarStore.INSTANCE.get(target.getUuid(), key); + if (val == null) { + ctx.getSource().sendFeedback( + () -> Text.literal("[GuiAPI] " + target.getName().getString() + "." + key + " is not set."), false); + } else { + ctx.getSource().sendFeedback( + () -> Text.literal("[GuiAPI] " + target.getName().getString() + "." + key + " = " + val), false); + } + return val != null ? 1 : 0; + } + + private static int varSet(CommandContext ctx) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity target = EntityArgumentType.getPlayer(ctx, "target"); + String key = StringArgumentType.getString(ctx, "key"); + String value = StringArgumentType.getString(ctx, "value"); + GuiVarStore.INSTANCE.set(target.getUuid(), key, value); + ctx.getSource().sendFeedback( + () -> Text.literal("[GuiAPI] Set " + target.getName().getString() + "." + key + " = " + value), false); + return 1; + } + + private static int varClear(CommandContext ctx) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayerEntity target = EntityArgumentType.getPlayer(ctx, "target"); + Map vars = GuiVarStore.INSTANCE.getAll(target.getUuid()); + int count = vars.size(); + GuiVarStore.INSTANCE.clear(target.getUuid()); + ctx.getSource().sendFeedback( + () -> Text.literal("[GuiAPI] Cleared " + count + " var(s) for " + target.getName().getString() + "."), false); + return count; + } + private static int showHelp(CommandContext ctx) { String help = "[GuiAPI] Commands (permission level 2):\n" + " /guiapi open [targets] - Open a GUI for yourself or target players\n" + " /guiapi list - List all loaded GUI definitions\n" + " /guiapi reload - Reload all datapack resources (including GUIs)\n" + + " /guiapi var get - Get a runtime variable\n" + + " /guiapi var set - Set a runtime variable\n" + + " /guiapi var clear - Clear all runtime variables\n" + " /guiapi help - Show this help message\n" + "\n" + + "Variable actions: set_var | add_var | sub_var | reset_var | clear_vars\n" + + "Variable conditions: var_eq | var_gt | var_lt | var_set\n" + + "Variable placeholder: {var:key}\n" + + "\n" + "Button JSON fields:\n" + " slot, page, item, name, lore, glint\n" + - " click_type: any | left | right | shift (default: any)\n" + - " condition: has_tag | score_gt | score_lt | score_eq\n" + - " actions: run_command | close | open_gui | message\n" + - " next_page | prev_page | goto_page"; + " 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" + + " next_page | prev_page | goto_page\n" + + " set_var | add_var | sub_var | reset_var | clear_vars"; ctx.getSource().sendFeedback(() -> Text.literal(help), false); return 1; } diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java b/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java index 1f804f7..6fdf9d1 100644 --- a/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java +++ b/src/main/java/dev/toolkitmc/guiapi/gui/BarrelGuiHandler.java @@ -116,26 +116,21 @@ public static boolean handleClick(ServerPlayerEntity player, GuiDefinition def, boolean isToggle = btn.toggle().isPresent(); List actions = resolveActions(player, btn); - // For toggle buttons: apply the tag flip directly in Java (synchronous) - // before running custom actions and reopening. This avoids the race - // between command dispatcher execution and inventory rebuild. if (isToggle) { GuiDefinition.ToggleDefinition tgl = btn.toggle().get(); + // Resolve the correct action list BEFORE flipping the tag, + // so actionsOn fires when the toggle was ON (turning it OFF), etc. + List toggleActions = resolveActions(player, btn); + + // Flip tag synchronously via Java API — avoids command dispatcher race. boolean wasOn = player.getCommandTags().contains(tgl.tag()); - if (wasOn) { - player.removeCommandTag(tgl.tag()); - } else { - player.addCommandTag(tgl.tag()); - } - // Run any custom side-effect actions (skip default tag commands) + if (wasOn) player.removeCommandTag(tgl.tag()); + else player.addCommandTag(tgl.tag()); + + // Execute all defined actions. Default tag commands were already + // applied above; run everything else (sounds, messages, commands…). boolean chainBroken = false; - for (GuiDefinition.ButtonAction action : actions) { - // Skip the auto-generated tag add/remove defaults - if (action.runWith() == GuiDefinition.RunWith.CONSOLE - && (action.value().startsWith("tag @s add " + tgl.tag()) - || action.value().startsWith("tag @s remove " + tgl.tag()))) { - continue; - } + for (GuiDefinition.ButtonAction action : toggleActions) { boolean shouldBreak = executeAction(player, def, page, action); if (shouldBreak) { chainBroken = true; break; } } @@ -168,6 +163,8 @@ public static void onClose(ServerPlayerEntity player) { for (GuiDefinition.ButtonAction action : state.def().getOnClose()) { executeAction(player, state.def(), state.page(), action); } + // Clear per-player runtime variables when the GUI closes. + GuiVarStore.INSTANCE.clear(player.getUuid()); } // ── Inventory builder ──────────────────────────────────────────────────── @@ -274,6 +271,15 @@ static String resolve(String text, ServerPlayerEntity player, text = text.substring(0, idx) + score + text.substring(end + 1); } + // {var:key} + while ((idx = text.indexOf("{var:")) >= 0) { + int end = text.indexOf('}', idx); + if (end < 0) break; + String key = text.substring(idx + 5, end); + String val = GuiVarStore.INSTANCE.getOrDefault(player.getUuid(), key, ""); + text = text.substring(0, idx) + val + text.substring(end + 1); + } + return text; } @@ -292,9 +298,30 @@ static boolean evaluateCondition(ServerPlayerEntity player, GuiDefinition.Button parseCondInt(cond.value().split(":"), 1); case SCORE_EQ -> getScore(player, cond.value().split(":"), 0) == parseCondInt(cond.value().split(":"), 1); + // var conditions — value format: "varKey:compareValue" + case VAR_EQ -> { + String[] p = cond.value().split(":", 2); + yield p.length == 2 && GuiVarStore.INSTANCE + .getOrDefault(player.getUuid(), p[0], "").equals(p[1]); + } + case VAR_GT -> { + String[] p = cond.value().split(":", 2); + yield p.length == 2 && GuiVarStore.INSTANCE + .getInt(player.getUuid(), p[0]) > parseIntSafe(p[1]); + } + case VAR_LT -> { + String[] p = cond.value().split(":", 2); + yield p.length == 2 && GuiVarStore.INSTANCE + .getInt(player.getUuid(), p[0]) < parseIntSafe(p[1]); + } + case VAR_SET -> GuiVarStore.INSTANCE.get(player.getUuid(), cond.value()) != null; }; } + private static int parseIntSafe(String s) { + try { return Integer.parseInt(s); } catch (NumberFormatException e) { return 0; } + } + // ── Toggle action resolution ───────────────────────────────────────────── /** @@ -366,6 +393,38 @@ static boolean executeAction(ServerPlayerEntity player, GuiDefinition def, } return true; } + case SOUND -> { + // value format: "minecraft:sound.id" (volume=1.0, pitch=1.0) + // or: "minecraft:sound.id:volume:pitch" + String[] parts = action.value().split(":"); + // Reconstruct namespace:path which may itself contain ':' + // Format is always :[:[:]] + // So minimum 2 parts (namespace + path), max 4. + String soundId; + float volume = 1.0f; + float pitch = 1.0f; + if (parts.length >= 4) { + soundId = parts[0] + ":" + parts[1]; + try { volume = Float.parseFloat(parts[2]); } catch (NumberFormatException ignored) {} + try { pitch = Float.parseFloat(parts[3]); } catch (NumberFormatException ignored) {} + } else if (parts.length == 3) { + soundId = parts[0] + ":" + parts[1]; + try { volume = Float.parseFloat(parts[2]); } catch (NumberFormatException ignored) {} + } else { + soundId = action.value(); + } + Identifier soundIdent = Identifier.tryParse(soundId); + if (soundIdent != null) { + net.minecraft.sound.SoundEvent soundEvent = + Registries.SOUND_EVENT.get(soundIdent); + if (soundEvent != null) { + player.playSoundToPlayer(soundEvent, + net.minecraft.sound.SoundCategory.PLAYERS, volume, pitch); + } else { + GuiApiMod.LOGGER.warn("[GuiAPI] Unknown sound '{}' in sound action.", soundId); + } + } + } case GOTO_PAGE -> { try { int target = Integer.parseInt(action.value()); @@ -376,6 +435,34 @@ static boolean executeAction(ServerPlayerEntity player, GuiDefinition def, } catch (NumberFormatException ignored) {} return true; } + case SET_VAR -> { + if (!action.var().isEmpty()) { + String resolved = resolve(action.value(), player, def, currentPage); + GuiVarStore.INSTANCE.set(player.getUuid(), action.var(), resolved); + } + } + case ADD_VAR -> { + if (!action.var().isEmpty()) { + try { + int delta = Integer.parseInt(resolve(action.value(), player, def, currentPage)); + GuiVarStore.INSTANCE.add(player.getUuid(), action.var(), delta); + } catch (NumberFormatException ignored) {} + } + } + case SUB_VAR -> { + if (!action.var().isEmpty()) { + try { + int delta = Integer.parseInt(resolve(action.value(), player, def, currentPage)); + GuiVarStore.INSTANCE.add(player.getUuid(), action.var(), -delta); + } catch (NumberFormatException ignored) {} + } + } + case RESET_VAR -> { + if (!action.var().isEmpty()) { + GuiVarStore.INSTANCE.remove(player.getUuid(), action.var()); + } + } + case CLEAR_VARS -> GuiVarStore.INSTANCE.clear(player.getUuid()); } return false; } diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java b/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java index fa18933..19468aa 100644 --- a/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java +++ b/src/main/java/dev/toolkitmc/guiapi/gui/GuiDefinition.java @@ -99,7 +99,8 @@ public static ClickType fromString(String s) { } public enum ActionType { - RUN_COMMAND, CLOSE, OPEN_GUI, MESSAGE, NEXT_PAGE, PREV_PAGE, GOTO_PAGE; + RUN_COMMAND, CLOSE, OPEN_GUI, MESSAGE, NEXT_PAGE, PREV_PAGE, GOTO_PAGE, SOUND, + SET_VAR, ADD_VAR, SUB_VAR, RESET_VAR, CLEAR_VARS; public static ActionType fromString(String s) { return switch (s.toLowerCase()) { @@ -110,6 +111,12 @@ public static ActionType fromString(String s) { case "next_page" -> NEXT_PAGE; case "prev_page" -> PREV_PAGE; case "goto_page" -> GOTO_PAGE; + case "sound" -> SOUND; + case "set_var" -> SET_VAR; + case "add_var" -> ADD_VAR; + case "sub_var" -> SUB_VAR; + case "reset_var" -> RESET_VAR; + case "clear_vars" -> CLEAR_VARS; default -> CLOSE; }; } @@ -123,7 +130,8 @@ public static RunWith fromString(String s) { } public enum ConditionType { - HAS_TAG, NOT_TAG, SCORE_GT, SCORE_LT, SCORE_EQ; + HAS_TAG, NOT_TAG, SCORE_GT, SCORE_LT, SCORE_EQ, + VAR_EQ, VAR_GT, VAR_LT, VAR_SET; public static ConditionType fromString(String s) { return switch (s.toLowerCase()) { @@ -132,6 +140,10 @@ public static ConditionType fromString(String s) { case "score_gt" -> SCORE_GT; case "score_lt" -> SCORE_LT; case "score_eq" -> SCORE_EQ; + case "var_eq" -> VAR_EQ; + case "var_gt" -> VAR_GT; + case "var_lt" -> VAR_LT; + case "var_set" -> VAR_SET; default -> HAS_TAG; }; } @@ -139,9 +151,18 @@ public static ConditionType fromString(String s) { // ── Records ────────────────────────────────────────────────────────────── - public record ButtonAction(ActionType type, String value, RunWith runWith) { + /** + * @param type Action type + * @param value Primary value (command, message, sound id, var value, page index…) + * @param runWith Execution context for run_command + * @param var Variable key for set_var / add_var / sub_var / reset_var actions + */ + public record ButtonAction(ActionType type, String value, RunWith runWith, String var) { public ButtonAction(ActionType type, String value) { - this(type, value, RunWith.PLAYER); + this(type, value, RunWith.PLAYER, ""); + } + public ButtonAction(ActionType type, String value, RunWith runWith) { + this(type, value, runWith, ""); } } @@ -322,11 +343,12 @@ private static List parseStringList(JsonObject obj, String key) { private static ButtonAction parseAction(JsonObject a) { ActionType type = ActionType.fromString( a.has("type") ? a.get("type").getAsString() : "close"); - String value = a.has("value") ? a.get("value").getAsString() : ""; + String value = a.has("value") ? a.get("value").getAsString() : ""; + String var = a.has("var") ? a.get("var").getAsString() : ""; RunWith runWith = a.has("run_with") ? RunWith.fromString(a.get("run_with").getAsString()) : RunWith.PLAYER; - return new ButtonAction(type, value, runWith); + return new ButtonAction(type, value, runWith, var); } // ── Getters ────────────────────────────────────────────────────────────── diff --git a/src/main/java/dev/toolkitmc/guiapi/gui/GuiVarStore.java b/src/main/java/dev/toolkitmc/guiapi/gui/GuiVarStore.java new file mode 100644 index 0000000..bf04e52 --- /dev/null +++ b/src/main/java/dev/toolkitmc/guiapi/gui/GuiVarStore.java @@ -0,0 +1,86 @@ +package dev.toolkitmc.guiapi.gui; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Per-player runtime variable store. + * + * Variables are: + * - String key → String value + * - Scoped to a player UUID + * - Held in memory only; cleared on GUI close by default (see {@link #clear(UUID)}) + * - Not persisted across server restarts + * + * Numeric operations (add_var, sub_var) parse the current value as int. + * If the current value is not a valid int, it is treated as 0. + */ +public final class GuiVarStore { + + public static final GuiVarStore INSTANCE = new GuiVarStore(); + + /** player UUID → { key → value } */ + private final ConcurrentHashMap> store = new ConcurrentHashMap<>(); + + private GuiVarStore() {} + + // ── Write ──────────────────────────────────────────────────────────────── + + /** Set a variable to a string value. */ + public void set(UUID player, String key, String value) { + store.computeIfAbsent(player, k -> new HashMap<>()).put(key, value); + } + + /** Add an integer delta to a variable (default 0 if unset or non-numeric). */ + public void add(UUID player, String key, int delta) { + int current = getInt(player, key); + set(player, key, String.valueOf(current + delta)); + } + + /** Remove a single variable. */ + public void remove(UUID player, String key) { + Map map = store.get(player); + if (map != null) map.remove(key); + } + + /** Remove all variables for a player. */ + public void clear(UUID player) { + store.remove(player); + } + + // ── Read ───────────────────────────────────────────────────────────────── + + /** Get a variable as String, or {@code null} if unset. */ + public String get(UUID player, String key) { + Map map = store.get(player); + return map != null ? map.get(key) : null; + } + + /** Get a variable as String with a fallback default. */ + public String getOrDefault(UUID player, String key, String def) { + String v = get(player, key); + return v != null ? v : def; + } + + /** Get a variable as int (0 if unset or non-numeric). */ + public int getInt(UUID player, String key) { + String v = get(player, key); + if (v == null) return 0; + try { return Integer.parseInt(v); } + catch (NumberFormatException e) { return 0; } + } + + /** Snapshot of all variables for a player (unmodifiable). */ + public Map getAll(UUID player) { + Map map = store.get(player); + return map != null ? Collections.unmodifiableMap(map) : Collections.emptyMap(); + } + + /** Total number of players with active variable maps. */ + public int playerCount() { + return store.size(); + } +}