From 1430d5ecf4ec07f8354505dcbc79bb6b9a8fa5ee Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 11 Jan 2026 23:31:25 +0000 Subject: [PATCH] username changer system & lua scripts can now do `requires` to reference other loaded scripts --- LUA_SCRIPTING.md | 119 +++++++++- SKIN_SYSTEM.md | 116 ++++++++- src/main/java/dev/amble/lib/AmbleKit.java | 4 + .../amble/lib/command/SetUsernameCommand.java | 85 +++++++ .../amble/lib/mixin/PlayerEntityMixin.java | 29 +++ .../lib/mixin/ServerPlayerEntityMixin.java | 30 +++ .../lib/mixin/client/EntityRendererMixin.java | 21 ++ .../mixin/client/PlayerListEntryMixin.java | 76 ++++++ .../lib/script/AbstractScriptManager.java | 120 +++++++++- .../lib/script/lua/ClientMinecraftData.java | 95 ++++++++ .../amble/lib/script/lua/MinecraftData.java | 113 +++++++++ .../lib/script/lua/ServerMinecraftData.java | 182 +++++++++++++-- .../amble/lib/username/UsernameTracker.java | 221 ++++++++++++++++++ src/main/resources/amblekit.mixins.json | 3 + .../assets/litmus/gui/skin_changer.json | 28 +++ .../assets/litmus/script/gui_utils.lua | 80 +++++++ .../assets/litmus/script/skin_changer.lua | 79 +------ .../litmus/script/skin_changer_slim.lua | 36 +-- .../assets/litmus/script/username_changer.lua | 23 ++ 19 files changed, 1324 insertions(+), 136 deletions(-) create mode 100644 src/main/java/dev/amble/lib/command/SetUsernameCommand.java create mode 100644 src/main/java/dev/amble/lib/mixin/ServerPlayerEntityMixin.java create mode 100644 src/main/java/dev/amble/lib/mixin/client/EntityRendererMixin.java create mode 100644 src/main/java/dev/amble/lib/mixin/client/PlayerListEntryMixin.java create mode 100644 src/main/java/dev/amble/lib/username/UsernameTracker.java create mode 100644 src/test/resources/assets/litmus/script/gui_utils.lua create mode 100644 src/test/resources/assets/litmus/script/username_changer.lua diff --git a/LUA_SCRIPTING.md b/LUA_SCRIPTING.md index b4da54d..0527f2e 100644 --- a/LUA_SCRIPTING.md +++ b/LUA_SCRIPTING.md @@ -7,6 +7,7 @@ AmbleKit includes a powerful Lua scripting engine (powered by LuaJ) that allows - [Commands](#commands) - [Lifecycle Callbacks](#lifecycle-callbacks) - [Minecraft API Reference](#minecraft-api-reference) +- [Module System (require)](#module-system-require) - [Entity API](#entity-api) - [ItemStack API](#itemstack-api) - [Example Scripts](#example-scripts) @@ -142,10 +143,14 @@ The `mc` parameter provides access to Minecraft data. Methods vary by side: | `mc:isDedicatedServer()` | True if dedicated server | | `mc:runCommandAs(playerName, command)` | Run command as specific player | -### Skin Management (Server-Only) +### Skin Management (Client & Server) All skin methods return `true` on success, `false` on failure. +On server: Methods directly modify the skin tracker and sync to clients. +On client: Methods run the equivalent `/amblekit skin` command (requires op permissions). + +**By Player Name (Client & Server):** | Method | Description | |--------|-------------| | `mc:setSkin(playerName, skinUsername)` | Set player's skin to another player's skin | @@ -153,10 +158,120 @@ All skin methods return `true` on success, `false` on failure. | `mc:setSkinSlim(playerName, slim)` | Change arm model without changing texture | | `mc:clearSkin(playerName)` | Remove custom skin, restore original | | `mc:hasSkin(playerName)` | Check if player has a custom skin | +| `mc:hasSkinByUuid(uuid)` | Check if entity has custom skin by UUID | + +**By UUID String (Server-Only):** +| Method | Description | +|--------|-------------| | `mc:setSkinByUuid(uuid, skinUsername)` | Set skin by UUID string | | `mc:setSkinUrlByUuid(uuid, url, slim)` | Set skin from URL by UUID string | | `mc:clearSkinByUuid(uuid)` | Clear skin by UUID string | -| `mc:hasSkinByUuid(uuid)` | Check if entity has custom skin by UUID | + +### Username/Nametag Management (Client & Server) + +Custom display names (nametags) can be set for any entity. These override the rendered nametag and sync to all clients. + +On server: Methods directly modify the username tracker and sync to clients. +On client: Methods run the equivalent `/amblekit username` command (requires op permissions). + +All username methods return `true` on success, `false` on failure (except getters). + +**By Player Name (Client & Server):** +| Method | Description | +|--------|-------------| +| `mc:setUsername(playerName, displayName)` | Set player's display name (supports § formatting) | +| `mc:setUsernameJson(playerName, jsonText)` | Set display name using JSON Text component | +| `mc:clearUsername(playerName)` | Remove custom display name, restore original | +| `mc:hasUsername(playerName)` | Check if player has a custom display name | +| `mc:getUsername(playerName)` | Get current custom display name (or nil) | +| `mc:hasUsernameByUuid(uuid)` | Check if entity has custom name by UUID | +| `mc:getUsernameByUuid(uuid)` | Get custom display name by UUID (or nil) | + +**By UUID String (Server-Only):** +| Method | Description | +|--------|-------------| +| `mc:setUsernameByUuid(uuid, displayName)` | Set display name by UUID string | +| `mc:setUsernameJsonByUuid(uuid, jsonText)` | Set JSON display name by UUID string | +| `mc:clearUsernameByUuid(uuid)` | Clear display name by UUID string | + +**Example - Simple colored name:** +```lua +mc:setUsername("Steve", "§c§lRed Steve") -- Bold red name +``` + +**Example - JSON Text with hover:** +```lua +mc:setUsernameJson("Steve", '{"text":"Admin","color":"gold","bold":true}') +``` + +--- + +## Module System (require) + +Scripts can import other scripts as modules using the `require` function. This allows you to share code between scripts. + +### Usage + +```lua +local MyModule = require("namespace:module_name") +``` + +The module path follows the format `namespace:script_name` (without `.lua` extension or `script/` prefix). + +### Creating a Module + +Modules should return a table containing their exported functions and values: + +```lua +-- assets/mymod/script/utils.lua +local Utils = {} + +function Utils.greet(name) + return "Hello, " .. name .. "!" +end + +function Utils.clamp(value, min, max) + return math.max(min, math.min(max, value)) +end + +return Utils +``` + +### Using a Module + +```lua +-- assets/mymod/script/main.lua +local Utils = require("mymod:utils") + +function onExecute(mc) + local message = Utils.greet(mc:username()) + mc:sendMessage(message, false) + + local clamped = Utils.clamp(150, 0, 100) -- Returns 100 +end +``` + +### Module Caching + +Modules are cached after first load - calling `require` multiple times with the same path returns the same cached instance. The cache is cleared when resources are reloaded. + +### Nested Requires + +Modules can require other modules: + +```lua +-- assets/mymod/script/advanced.lua +local Utils = require("mymod:utils") +local Config = require("mymod:config") + +local Advanced = {} + +function Advanced.doSomething() + return Utils.greet(Config.defaultName) +end + +return Advanced +``` --- diff --git a/SKIN_SYSTEM.md b/SKIN_SYSTEM.md index dab5635..96215cf 100644 --- a/SKIN_SYSTEM.md +++ b/SKIN_SYSTEM.md @@ -9,6 +9,7 @@ AmbleKit provides a dynamic player skin system that allows you to change player - [Skin Sources](#skin-sources) - [Persistence](#persistence) - [Integration](#integration) +- [Username/Nametag System](#usernamenametag-system) --- @@ -253,11 +254,14 @@ function onExecute(mc) end ``` -#### Server-Side Skin API Methods +#### Skin API Methods (Client & Server) All skin methods return `true` on success, `false` on failure (player not found, invalid UUID, etc.). -**By Player Name:** +On server: Methods directly modify the skin tracker and sync to clients. +On client: Methods run the equivalent `/amblekit skin` command (requires op permissions). + +**By Player Name (Client & Server):** | Method | Description | |--------|-------------| | `mc:setSkin(playerName, skinUsername)` | Set player's skin to another player's skin | @@ -265,14 +269,14 @@ All skin methods return `true` on success, `false` on failure (player not found, | `mc:setSkinSlim(playerName, slim)` | Change arm model (true = slim/Alex, false = wide/Steve) | | `mc:clearSkin(playerName)` | Remove custom skin, restore original | | `mc:hasSkin(playerName)` | Check if player has a custom skin | +| `mc:hasSkinByUuid(uuid)` | Check if entity has custom skin by UUID | -**By UUID String:** +**By UUID String (Server-Only):** | Method | Description | |--------|-------------| | `mc:setSkinByUuid(uuid, skinUsername)` | Set skin by UUID string | | `mc:setSkinUrlByUuid(uuid, url, slim)` | Set skin from URL by UUID string | | `mc:clearSkinByUuid(uuid)` | Clear skin by UUID string | -| `mc:hasSkinByUuid(uuid)` | Check if entity has custom skin by UUID | #### Complete Example: Disguise System @@ -469,6 +473,110 @@ data = data.withSlim(true); --- +## Username/Nametag System + +AmbleKit includes a dynamic username/nametag system that allows you to change entity display names at runtime. This works similarly to the skin system. + +### Overview + +The Username System provides: +- **Runtime Name Changes** - Change entity nametags without relogging +- **Rich Text Support** - Full Minecraft Text component support (colors, styles, hover events) +- **Server Synchronization** - Names sync automatically to all connected clients +- **Persistent Storage** - Custom names persist across server restarts (saved to `amblekit/usernames.json`) +- **Universal Entity Support** - Works with any entity by UUID + +### Commands + +All username commands require operator permissions (level 2). + +#### Set Display Name + +``` +/amblekit username set +``` + +The name supports Minecraft formatting codes (§) and JSON Text format. + +**Examples:** +``` +/amblekit username @p set §c§lRed Bold Name +/amblekit username @p set {"text":"Admin","color":"gold","bold":true} +``` + +#### Clear Display Name + +``` +/amblekit username clear +``` + +### Java API + +```java +import dev.amble.lib.username.UsernameTracker; +import net.minecraft.text.Text; + +// Get the target entity's UUID +UUID targetUuid = entity.getUuid(); + +// Set simple display name +Text displayName = Text.of("§aGreen Name"); +UsernameTracker.getInstance().putSynced(targetUuid, displayName); + +// Set rich text display name +Text richName = Text.literal("Admin") + .styled(style -> style.withColor(0xFFAA00).withBold(true)); +UsernameTracker.getInstance().putSynced(targetUuid, richName); + +// Clear custom display name +UsernameTracker.getInstance().removeSynced(targetUuid); + +// Check if entity has custom name +boolean hasCustomName = UsernameTracker.getInstance().containsKey(targetUuid); + +// Get custom name (may be null) +Text customName = UsernameTracker.getInstance().get(targetUuid); +``` + +### Lua API (Client & Server) + +See [Lua Scripting System](LUA_SCRIPTING.md) for full documentation. + +On server: Methods directly modify the username tracker and sync to clients. +On client: Methods run the equivalent `/amblekit username` command (requires op permissions). + +```lua +-- Set display name with formatting codes (Client & Server) +mc:setUsername("Steve", "§c§lRed Steve") + +-- Set display name with JSON Text (Client & Server) +mc:setUsernameJson("Steve", '{"text":"Admin","color":"gold"}') + +-- Clear custom name (Client & Server) +mc:clearUsername("Steve") + +-- Check if player has custom name (Client & Server) +if mc:hasUsername("Steve") then + local name = mc:getUsername("Steve") +end + +-- By UUID (Server-Only) +mc:setUsernameByUuid(uuid, "§bCustom Name") +mc:clearUsernameByUuid(uuid) +``` + +### Persistence + +Custom display names are saved to `/amblekit/usernames.json` and automatically loaded when the server starts. The format is: + +```json +{ + "uuid-string": {"text":"Display Name","color":"red"} +} +``` + +--- + ## See Also - [Lua Scripting System](LUA_SCRIPTING.md) - Full skin management API for server scripts diff --git a/src/main/java/dev/amble/lib/AmbleKit.java b/src/main/java/dev/amble/lib/AmbleKit.java index daa4de1..98d3490 100644 --- a/src/main/java/dev/amble/lib/AmbleKit.java +++ b/src/main/java/dev/amble/lib/AmbleKit.java @@ -9,8 +9,10 @@ import dev.amble.lib.command.PlayAnimationCommand; import dev.amble.lib.command.ServerScriptCommand; import dev.amble.lib.command.SetSkinCommand; +import dev.amble.lib.command.SetUsernameCommand; import dev.amble.lib.script.ServerScriptManager; import dev.amble.lib.skin.SkinTracker; +import dev.amble.lib.username.UsernameTracker; import dev.drtheo.multidim.MultiDimMod; import dev.drtheo.scheduler.SchedulerMod; import net.fabricmc.api.ModInitializer; @@ -36,11 +38,13 @@ public void onInitialize() { AmbleRegistries.getInstance(); ServerLifecycleHooks.init(); SkinTracker.init(); + UsernameTracker.init(); AnimationTracker.init(); ServerScriptManager.getInstance().init(); CommandRegistrationCallback.EVENT.register((dispatcher, access, env) -> { SetSkinCommand.register(dispatcher); + SetUsernameCommand.register(dispatcher); PlayAnimationCommand.register(dispatcher); ServerScriptCommand.register(dispatcher); }); diff --git a/src/main/java/dev/amble/lib/command/SetUsernameCommand.java b/src/main/java/dev/amble/lib/command/SetUsernameCommand.java new file mode 100644 index 0000000..2856e24 --- /dev/null +++ b/src/main/java/dev/amble/lib/command/SetUsernameCommand.java @@ -0,0 +1,85 @@ +package dev.amble.lib.command; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.username.UsernameTracker; +import net.minecraft.command.argument.EntityArgumentType; +import net.minecraft.entity.Entity; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.text.Text; + +import static net.minecraft.server.command.CommandManager.argument; +import static net.minecraft.server.command.CommandManager.literal; + +public class SetUsernameCommand { + private static String translationKey(String key) { + return "command." + AmbleKit.MOD_ID + ".username." + key; + } + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(literal(AmbleKit.MOD_ID) + .requires(source -> source.hasPermissionLevel(2)) + .then(literal("username").then(argument("target", EntityArgumentType.entity()) + .then(literal("clear").executes(SetUsernameCommand::executeClear)) + .then(literal("set").then(argument("value", StringArgumentType.greedyString()) + .executes(SetUsernameCommand::execute)))))); + } + + private static int executeClear(CommandContext context) { + Entity entity; + + try { + entity = EntityArgumentType.getEntity(context, "target"); + } catch (CommandSyntaxException e) { + context.getSource().sendError(Text.translatable(translationKey("error.invalid_target"))); + return 0; + } + + UsernameTracker.getInstance().removeSynced(entity.getUuid()); + String entityName = entity.getEntityName(); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("cleared"), entityName), true); + + return Command.SINGLE_SUCCESS; + } + + private static int execute(CommandContext context) { + String value = StringArgumentType.getString(context, "value"); + Entity entity; + + try { + entity = EntityArgumentType.getEntity(context, "target"); + } catch (CommandSyntaxException e) { + context.getSource().sendError(Text.translatable(translationKey("error.invalid_target"))); + return 0; + } + + // Parse the input as Text (supports JSON format or plain string with formatting codes) + Text displayName; + if (value.startsWith("{") || value.startsWith("[")) { + // Try to parse as JSON Text + try { + displayName = Text.Serializer.fromJson(value); + if (displayName == null) { + displayName = Text.of(value); + } + } catch (Exception e) { + displayName = Text.of(value); + } + } else { + // Plain text (supports § formatting codes) + displayName = Text.of(value); + } + + UsernameTracker.getInstance().putSynced(entity.getUuid(), displayName); + + String entityName = entity.getEntityName(); + context.getSource().sendFeedback(() -> Text.translatable(translationKey("set"), entityName, value), true); + + return Command.SINGLE_SUCCESS; + } +} + diff --git a/src/main/java/dev/amble/lib/mixin/PlayerEntityMixin.java b/src/main/java/dev/amble/lib/mixin/PlayerEntityMixin.java index 350b7f0..6bfd7b7 100644 --- a/src/main/java/dev/amble/lib/mixin/PlayerEntityMixin.java +++ b/src/main/java/dev/amble/lib/mixin/PlayerEntityMixin.java @@ -2,12 +2,17 @@ import dev.amble.lib.animation.AnimatedEntity; import dev.amble.lib.skin.PlayerSkinTexturable; +import dev.amble.lib.username.UsernameTracker; import net.minecraft.entity.AnimationState; import net.minecraft.entity.EntityType; import net.minecraft.entity.LivingEntity; import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.text.Text; import net.minecraft.world.World; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import java.util.UUID; @@ -28,4 +33,28 @@ public AnimationState getAnimationState() { public UUID getUuid() { return super.getUuid(); } + + /** + * Injects custom display name from UsernameTracker into getName(). + * This affects chat messages, death messages, and most other places where the player name is displayed. + */ + @Inject(method = "getName", at = @At("HEAD"), cancellable = true) + private void amble$getCustomName(CallbackInfoReturnable cir) { + Text customName = UsernameTracker.getInstance().get(this.getUuid()); + if (customName != null) { + cir.setReturnValue(customName); + } + } + + /** + * Injects custom display name from UsernameTracker into getDisplayName(). + * This affects the tab list and other UI elements that use display name. + */ + @Inject(method = "getDisplayName", at = @At("HEAD"), cancellable = true) + private void amble$getCustomDisplayName(CallbackInfoReturnable cir) { + Text customName = UsernameTracker.getInstance().get(this.getUuid()); + if (customName != null) { + cir.setReturnValue(customName); + } + } } diff --git a/src/main/java/dev/amble/lib/mixin/ServerPlayerEntityMixin.java b/src/main/java/dev/amble/lib/mixin/ServerPlayerEntityMixin.java new file mode 100644 index 0000000..d30bad1 --- /dev/null +++ b/src/main/java/dev/amble/lib/mixin/ServerPlayerEntityMixin.java @@ -0,0 +1,30 @@ +package dev.amble.lib.mixin; + +import dev.amble.lib.username.UsernameTracker; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Mixin to override the player list name (tab list display name) to use custom names from UsernameTracker. + */ +@Mixin(ServerPlayerEntity.class) +public abstract class ServerPlayerEntityMixin { + + /** + * Injects custom display name from UsernameTracker into getPlayerListName(). + * This affects the tab list (player list) display name. + */ + @Inject(method = "getPlayerListName", at = @At("HEAD"), cancellable = true) + private void amble$getCustomPlayerListName(CallbackInfoReturnable cir) { + ServerPlayerEntity self = (ServerPlayerEntity) (Object) this; + Text customName = UsernameTracker.getInstance().get(self.getUuid()); + if (customName != null) { + cir.setReturnValue(customName); + } + } +} + diff --git a/src/main/java/dev/amble/lib/mixin/client/EntityRendererMixin.java b/src/main/java/dev/amble/lib/mixin/client/EntityRendererMixin.java new file mode 100644 index 0000000..5865ba3 --- /dev/null +++ b/src/main/java/dev/amble/lib/mixin/client/EntityRendererMixin.java @@ -0,0 +1,21 @@ +package dev.amble.lib.mixin.client; + +import dev.amble.lib.username.UsernameTracker; +import net.minecraft.client.render.entity.EntityRenderer; +import net.minecraft.entity.Entity; +import net.minecraft.text.Text; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +@Mixin(EntityRenderer.class) +public class EntityRendererMixin { + @ModifyVariable(method = "renderLabelIfPresent", at = @At("HEAD"), ordinal = 0, argsOnly = true) + private Text amble$modifyLabelText(Text original, T entity) { + Text customName = UsernameTracker.getInstance().get(entity.getUuid()); + if (customName == null) return original; + + return customName; + } +} + diff --git a/src/main/java/dev/amble/lib/mixin/client/PlayerListEntryMixin.java b/src/main/java/dev/amble/lib/mixin/client/PlayerListEntryMixin.java new file mode 100644 index 0000000..eee9db1 --- /dev/null +++ b/src/main/java/dev/amble/lib/mixin/client/PlayerListEntryMixin.java @@ -0,0 +1,76 @@ +package dev.amble.lib.mixin.client; + +import dev.amble.lib.skin.SkinData; +import dev.amble.lib.skin.SkinTracker; +import dev.amble.lib.skin.client.SkinGrabber; +import net.minecraft.client.network.PlayerListEntry; +import net.minecraft.util.Identifier; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import com.mojang.authlib.GameProfile; +import org.jetbrains.annotations.Nullable; + +/** + * Mixin to override the player list entry skin texture to use custom skins from SkinTracker. + * This affects the tab list (player list) head icons. + */ +@Mixin(PlayerListEntry.class) +public abstract class PlayerListEntryMixin { + + @Shadow + public abstract GameProfile getProfile(); + + @Unique + @Nullable + private SkinData amblekit$lastSkin = null; + + /** + * Injects custom skin texture from SkinTracker into getSkinTexture(). + * This affects the tab list player head icons. + */ + @Inject(method = "getSkinTexture", at = @At("HEAD"), cancellable = true) + private void amblekit$getCustomSkinTexture(CallbackInfoReturnable cir) { + GameProfile profile = getProfile(); + if (profile == null) return; + + SkinTracker tracker = SkinTracker.getInstance(); + SkinData data = tracker.get(profile.getId()); + if (data == null) return; + + Identifier id = data.get(); + if (id == null) return; + + // Handle missing texture by falling back to last known skin + if (SkinGrabber.isMissingTexture(id) && amblekit$lastSkin != null) { + id = amblekit$lastSkin.get(); + } else { + amblekit$lastSkin = data; + } + + if (id != null) { + cir.setReturnValue(id); + } + } + + /** + * Injects custom model type from SkinTracker into getModel(). + * This affects whether the tab list shows slim or default arm model. + */ + @Inject(method = "getModel", at = @At("HEAD"), cancellable = true) + private void amblekit$getCustomModel(CallbackInfoReturnable cir) { + GameProfile profile = getProfile(); + if (profile == null) return; + + SkinTracker tracker = SkinTracker.getInstance(); + SkinData data = tracker.get(profile.getId()); + if (data == null) return; + + cir.setReturnValue(data.slim() ? "slim" : "default"); + } +} + diff --git a/src/main/java/dev/amble/lib/script/AbstractScriptManager.java b/src/main/java/dev/amble/lib/script/AbstractScriptManager.java index a683a66..ffa11a0 100644 --- a/src/main/java/dev/amble/lib/script/AbstractScriptManager.java +++ b/src/main/java/dev/amble/lib/script/AbstractScriptManager.java @@ -3,12 +3,16 @@ import dev.amble.lib.AmbleKit; import dev.amble.lib.script.lua.LuaBinder; import dev.amble.lib.script.lua.MinecraftData; +import lombok.Getter; import net.fabricmc.fabric.api.resource.SimpleSynchronousResourceReloadListener; import net.minecraft.resource.Resource; import net.minecraft.resource.ResourceManager; import net.minecraft.util.Identifier; import org.luaj.vm2.Globals; +import org.luaj.vm2.LuaError; import org.luaj.vm2.LuaValue; +import org.luaj.vm2.Varargs; +import org.luaj.vm2.lib.VarArgFunction; import org.luaj.vm2.lib.jse.JsePlatform; import java.io.InputStreamReader; @@ -23,9 +27,14 @@ */ public abstract class AbstractScriptManager implements SimpleSynchronousResourceReloadListener { + @Getter protected final Map cache = new HashMap<>(); protected final Map dataCache = new HashMap<>(); + protected final Map moduleCache = new HashMap<>(); + @Getter protected final Set enabledScripts = new HashSet<>(); + @Getter + protected ResourceManager currentResourceManager; /** * Creates the MinecraftData instance for a script. @@ -43,6 +52,8 @@ public abstract class AbstractScriptManager implements SimpleSynchronousResource */ @Override public void reload(ResourceManager manager) { + this.currentResourceManager = manager; + // Disable all scripts before clearing cache for (Identifier id : new HashSet<>(enabledScripts)) { disable(id); @@ -50,6 +61,7 @@ public void reload(ResourceManager manager) { cache.clear(); dataCache.clear(); + moduleCache.clear(); // Discover all script files and populate the cache manager.findResources("script", id -> id.getPath().endsWith(".lua")) @@ -74,6 +86,9 @@ public LuaScript load(Identifier id, ResourceManager manager) { Resource res = manager.getResource(key).orElseThrow(); Globals globals = JsePlatform.standardGlobals(); + // Add custom require function + globals.set("require", createRequireFunction(manager)); + // Create and cache the minecraft data for this script MinecraftData data = createMinecraftData(); data.setScriptName(key.toString()); @@ -107,15 +122,110 @@ public LuaScript load(Identifier id, ResourceManager manager) { }); } - public Map getCache() { - return cache; + /** + * Creates a custom require function for loading scripts by namespaced ID. + * Usage in Lua: require("namespace:script_name") + */ + protected VarArgFunction createRequireFunction(ResourceManager manager) { + return new VarArgFunction() { + @Override + public Varargs invoke(Varargs args) { + String moduleName = args.checkjstring(1); + + // Parse the module name as a namespaced identifier + Identifier moduleId = parseModuleId(moduleName); + + // Check if already loaded + if (moduleCache.containsKey(moduleId)) { + return moduleCache.get(moduleId); + } + + // Try to load the module + try { + LuaValue result = loadModule(moduleId, manager); + moduleCache.put(moduleId, result); + return result; + } catch (Exception e) { + throw new LuaError("Failed to require module '" + moduleName + "': " + e.getMessage()); + } + } + }; } - public Set getEnabledScripts() { - return enabledScripts; + /** + * Parses a module name into an Identifier. + * Supports formats: "namespace:script_name" or just "script_name" (uses default namespace) + */ + protected Identifier parseModuleId(String moduleName) { + // Handle namespaced format (namespace:script_name) + if (moduleName.contains(":")) { + String[] parts = moduleName.split(":", 2); + String namespace = parts[0]; + String path = parts[1]; + + // Ensure .lua extension and script/ prefix + if (!path.endsWith(".lua")) { + path = path + ".lua"; + } + if (!path.startsWith("script/")) { + path = "script/" + path; + } + + return new Identifier(namespace, path); + } else { + // Default namespace (minecraft) + String path = moduleName; + if (!path.endsWith(".lua")) { + path = path + ".lua"; + } + if (!path.startsWith("script/")) { + path = "script/" + path; + } + return new Identifier(path); + } + } + + /** + * Loads a module script and returns its exported value. + * Modules should return a value (typically a table) to be used by requiring scripts. + */ + protected LuaValue loadModule(Identifier moduleId, ResourceManager manager) { + try { + Resource res = manager.getResource(moduleId).orElseThrow(() -> + new RuntimeException("Module not found: " + moduleId)); + + Globals globals = JsePlatform.standardGlobals(); + + // Add require function to module globals too (for nested requires) + globals.set("require", createRequireFunction(manager)); + + // Create minecraft data for the module + MinecraftData data = createMinecraftData(); + data.setScriptName(moduleId.toString()); + LuaValue boundData = LuaBinder.bind(data); + globals.set("minecraft", boundData); + + LuaValue chunk = globals.load( + new InputStreamReader(res.getInputStream()), + moduleId.toString() + ); + + // Execute the chunk and get the return value + LuaValue result = chunk.call(); + + // If the script doesn't return anything, return the globals table + // This allows modules to define functions at global scope + if (result.isnil()) { + return globals; + } + + return result; + } catch (Exception e) { + throw new RuntimeException("Failed to load module " + moduleId, e); + } } - public boolean isEnabled(Identifier id) { + public boolean isEnabled(Identifier id) { return enabledScripts.contains(id); } diff --git a/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java b/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java index 3e563f9..dc78826 100644 --- a/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/ClientMinecraftData.java @@ -5,6 +5,8 @@ import dev.amble.lib.client.gui.registry.AmbleGuiRegistry; import dev.amble.lib.script.AbstractScriptManager; import dev.amble.lib.script.ScriptManager; +import dev.amble.lib.skin.SkinTracker; +import dev.amble.lib.username.UsernameTracker; import net.minecraft.SharedConstants; import net.minecraft.client.MinecraftClient; import net.minecraft.client.util.InputUtil; @@ -22,6 +24,7 @@ import net.minecraft.world.World; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.StreamSupport; @@ -328,6 +331,98 @@ public int windowHeight() { return mc.getWindow() != null ? mc.getWindow().getScaledHeight() : 0; } + // ===== Skin Management (via commands) ===== + + /** + * Gets the UUID for a player by name from the client's world. + */ + private UUID getPlayerUuid(String playerName) { + if (mc.world == null) return null; + for (Entity entity : mc.world.getPlayers()) { + if (entity.getName().getString().equalsIgnoreCase(playerName)) { + return entity.getUuid(); + } + } + return null; + } + + @Override + @LuaExpose + public boolean setSkin(String playerName, String skinUsername) { + runCommand("/amblekit skin " + playerName + " set " + skinUsername); + return true; + } + + @Override + @LuaExpose + public boolean setSkinUrl(String playerName, String url, boolean slim) { + runCommand("/amblekit skin " + playerName + " slim " + slim + " " + url); + return true; + } + + @Override + @LuaExpose + public boolean setSkinSlim(String playerName, boolean slim) { + runCommand("/amblekit skin " + playerName + " slim " + slim); + return true; + } + + @Override + @LuaExpose + public boolean clearSkin(String playerName) { + runCommand("/amblekit skin " + playerName + " clear"); + return true; + } + + @Override + @LuaExpose + public boolean hasSkin(String playerName) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) return false; + return SkinTracker.getInstance().containsKey(uuid); + } + + // ===== Username/Nametag Management (via commands) ===== + + @Override + @LuaExpose + public boolean setUsername(String playerName, String displayName) { + runCommand("/amblekit username " + playerName + " set " + displayName); + return true; + } + + @Override + @LuaExpose + public boolean setUsernameJson(String playerName, String jsonText) { + // JSON text needs to be passed as-is + runCommand("/amblekit username " + playerName + " set " + jsonText); + return true; + } + + @Override + @LuaExpose + public boolean clearUsername(String playerName) { + runCommand("/amblekit username " + playerName + " clear"); + return true; + } + + @Override + @LuaExpose + public boolean hasUsername(String playerName) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) return false; + return UsernameTracker.getInstance().containsKey(uuid); + } + + @Override + @LuaExpose + public String getUsername(String playerName) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) return null; + Text text = UsernameTracker.getInstance().get(uuid); + return text != null ? text.getString() : null; + } + // ===== Cross-script function calling ===== @Override diff --git a/src/main/java/dev/amble/lib/script/lua/MinecraftData.java b/src/main/java/dev/amble/lib/script/lua/MinecraftData.java index b675897..239d596 100644 --- a/src/main/java/dev/amble/lib/script/lua/MinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/MinecraftData.java @@ -3,11 +3,14 @@ import dev.amble.lib.AmbleKit; import dev.amble.lib.script.AbstractScriptManager; import dev.amble.lib.script.LuaScript; +import dev.amble.lib.skin.SkinTracker; +import dev.amble.lib.username.UsernameTracker; import net.minecraft.entity.Entity; import net.minecraft.registry.Registries; import net.minecraft.server.world.ServerWorld; import net.minecraft.sound.SoundCategory; import net.minecraft.sound.SoundEvent; +import net.minecraft.text.Text; import net.minecraft.util.Identifier; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; @@ -16,6 +19,7 @@ import java.util.Comparator; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.StreamSupport; @@ -204,6 +208,115 @@ public void logError(String message) { AmbleKit.LOGGER.error("{} {}", getLogPrefix(), message); } + // ===== Skin Management ===== + + /** + * Parses a UUID string. + * @param uuidString the UUID as a string + * @return the UUID, or null if invalid + */ + protected UUID parseUuid(String uuidString) { + try { + return UUID.fromString(uuidString); + } catch (IllegalArgumentException e) { + AmbleKit.LOGGER.warn("Invalid UUID format: '{}'", uuidString); + return null; + } + } + + /** + * Sets a player's skin to match another player's skin (by username). + */ + @LuaExpose + public abstract boolean setSkin(String playerName, String skinUsername); + + /** + * Sets a player's skin from a direct URL. + */ + @LuaExpose + public abstract boolean setSkinUrl(String playerName, String url, boolean slim); + + /** + * Changes a player's arm model (slim or wide) without changing the skin texture. + */ + @LuaExpose + public abstract boolean setSkinSlim(String playerName, boolean slim); + + /** + * Clears a player's custom skin, restoring their original skin. + */ + @LuaExpose + public abstract boolean clearSkin(String playerName); + + /** + * Checks if a player has a custom skin applied. + */ + @LuaExpose + public abstract boolean hasSkin(String playerName); + + /** + * Checks if an entity has a custom skin applied by UUID string. + */ + @LuaExpose + public boolean hasSkinByUuid(String uuidString) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) return false; + return SkinTracker.getInstance().containsKey(uuid); + } + + // ===== Username/Nametag Management ===== + + /** + * Sets a player's display name (nametag). + */ + @LuaExpose + public abstract boolean setUsername(String playerName, String displayName); + + /** + * Sets a player's display name using a JSON Text component. + */ + @LuaExpose + public abstract boolean setUsernameJson(String playerName, String jsonText); + + /** + * Clears a player's custom display name, restoring their original nametag. + */ + @LuaExpose + public abstract boolean clearUsername(String playerName); + + /** + * Checks if a player has a custom display name applied. + */ + @LuaExpose + public abstract boolean hasUsername(String playerName); + + /** + * Gets a player's current custom display name. + */ + @LuaExpose + public abstract String getUsername(String playerName); + + /** + * Checks if an entity has a custom display name applied by UUID string. + */ + @LuaExpose + public boolean hasUsernameByUuid(String uuidString) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) return false; + return UsernameTracker.getInstance().containsKey(uuid); + } + + /** + * Gets an entity's current custom display name by UUID string. + */ + @LuaExpose + public String getUsernameByUuid(String uuidString) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) return null; + Text text = UsernameTracker.getInstance().get(uuid); + return text != null ? text.getString() : null; + } + // ===== Cross-script function calling ===== /** diff --git a/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java b/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java index 0eba0df..bb6f97f 100644 --- a/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java +++ b/src/main/java/dev/amble/lib/script/lua/ServerMinecraftData.java @@ -5,6 +5,7 @@ import dev.amble.lib.script.ServerScriptManager; import dev.amble.lib.skin.SkinData; import dev.amble.lib.skin.SkinTracker; +import dev.amble.lib.username.UsernameTracker; import dev.amble.lib.util.ServerLifecycleHooks; import net.minecraft.entity.Entity; import net.minecraft.server.MinecraftServer; @@ -244,20 +245,6 @@ private UUID getPlayerUuid(String playerName) { return target != null ? target.getUuid() : null; } - /** - * Parses a UUID string. - * @param uuidString the UUID as a string - * @return the UUID, or null if invalid - */ - private UUID parseUuid(String uuidString) { - try { - return UUID.fromString(uuidString); - } catch (IllegalArgumentException e) { - AmbleKit.LOGGER.warn("Invalid UUID format: '{}'", uuidString); - return null; - } - } - /** * Sets a player's skin to match another player's skin (by username). * This performs an async lookup of the skin and applies it when ready. @@ -266,6 +253,7 @@ private UUID parseUuid(String uuidString) { * @param skinUsername the username to copy the skin from * @return true if the player was found, false otherwise */ + @Override @LuaExpose public boolean setSkin(String playerName, String skinUsername) { UUID uuid = getPlayerUuid(playerName); @@ -285,6 +273,7 @@ public boolean setSkin(String playerName, String skinUsername) { * @param slim true for slim (Alex) arms, false for wide (Steve) arms * @return true if the player was found, false otherwise */ + @Override @LuaExpose public boolean setSkinUrl(String playerName, String url, boolean slim) { UUID uuid = getPlayerUuid(playerName); @@ -303,6 +292,7 @@ public boolean setSkinUrl(String playerName, String url, boolean slim) { * @param slim true for slim (Alex) arms, false for wide (Steve) arms * @return true if successful, false if player not found or has no custom skin */ + @Override @LuaExpose public boolean setSkinSlim(String playerName, boolean slim) { UUID uuid = getPlayerUuid(playerName); @@ -325,6 +315,7 @@ public boolean setSkinSlim(String playerName, boolean slim) { * @param playerName the player whose skin to clear * @return true if the player was found, false otherwise */ + @Override @LuaExpose public boolean clearSkin(String playerName) { UUID uuid = getPlayerUuid(playerName); @@ -342,6 +333,7 @@ public boolean clearSkin(String playerName) { * @param playerName the player to check * @return true if the player has a custom skin, false otherwise */ + @Override @LuaExpose public boolean hasSkin(String playerName) { UUID uuid = getPlayerUuid(playerName); @@ -401,17 +393,165 @@ public boolean clearSkinByUuid(String uuidString) { return true; } + // ===== Username/Nametag Management ===== + /** - * Checks if an entity has a custom skin applied by UUID string. - * - * @param uuidString the UUID to check - * @return true if the entity has a custom skin, false otherwise + * Sets a player's display name (nametag). + * Supports Minecraft formatting codes (§ prefix). + * + * @param playerName the player whose name to change + * @param displayName the new display name (supports § formatting codes) + * @return true if the player was found, false otherwise */ + @Override @LuaExpose - public boolean hasSkinByUuid(String uuidString) { - UUID uuid = parseUuid(uuidString); + public boolean setUsername(String playerName, String displayName) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) { + AmbleKit.LOGGER.warn("Cannot set username: player '{}' not found", playerName); + return false; + } + Text text = Text.of(displayName); + UsernameTracker.getInstance().putSynced(uuid, text); + return true; + } + + /** + * Sets a player's display name using a JSON Text component. + * Allows for rich text formatting (colors, styles, hover events, etc.) + * + * @param playerName the player whose name to change + * @param jsonText the display name as a JSON text component + * @return true if the player was found and JSON was valid, false otherwise + */ + @Override + @LuaExpose + public boolean setUsernameJson(String playerName, String jsonText) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) { + AmbleKit.LOGGER.warn("Cannot set username: player '{}' not found", playerName); + return false; + } + try { + Text text = Text.Serializer.fromJson(jsonText); + if (text == null) { + AmbleKit.LOGGER.warn("Cannot set username: invalid JSON text '{}'", jsonText); + return false; + } + UsernameTracker.getInstance().putSynced(uuid, text); + return true; + } catch (Exception e) { + AmbleKit.LOGGER.warn("Cannot set username: failed to parse JSON text '{}': {}", jsonText, e.getMessage()); + return false; + } + } + + /** + * Clears a player's custom display name, restoring their original nametag. + * + * @param playerName the player whose name to clear + * @return true if the player was found, false otherwise + */ + @Override + @LuaExpose + public boolean clearUsername(String playerName) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) { + AmbleKit.LOGGER.warn("Cannot clear username: player '{}' not found", playerName); + return false; + } + UsernameTracker.getInstance().removeSynced(uuid); + return true; + } + + /** + * Checks if a player has a custom display name applied. + * + * @param playerName the player to check + * @return true if the player has a custom display name, false otherwise + */ + @Override + @LuaExpose + public boolean hasUsername(String playerName) { + UUID uuid = getPlayerUuid(playerName); if (uuid == null) return false; - return SkinTracker.getInstance().containsKey(uuid); + return UsernameTracker.getInstance().containsKey(uuid); + } + + /** + * Gets a player's current custom display name. + * + * @param playerName the player to check + * @return the custom display name as a string, or null if none set + */ + @Override + @LuaExpose + public String getUsername(String playerName) { + UUID uuid = getPlayerUuid(playerName); + if (uuid == null) return null; + Text text = UsernameTracker.getInstance().get(uuid); + return text != null ? text.getString() : null; + } + + /** + * Sets a display name by UUID string. + * + * @param uuidString the UUID of the entity whose name to change + * @param displayName the new display name (supports § formatting codes) + * @return true if the UUID was valid, false otherwise + */ + @LuaExpose + public boolean setUsernameByUuid(String uuidString, String displayName) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) { + return false; + } + Text text = Text.of(displayName); + UsernameTracker.getInstance().putSynced(uuid, text); + return true; + } + + /** + * Sets a display name by UUID string using a JSON Text component. + * + * @param uuidString the UUID of the entity whose name to change + * @param jsonText the display name as a JSON text component + * @return true if the UUID was valid and JSON was valid, false otherwise + */ + @LuaExpose + public boolean setUsernameJsonByUuid(String uuidString, String jsonText) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) { + return false; + } + try { + Text text = Text.Serializer.fromJson(jsonText); + if (text == null) { + AmbleKit.LOGGER.warn("Cannot set username: invalid JSON text '{}'", jsonText); + return false; + } + UsernameTracker.getInstance().putSynced(uuid, text); + return true; + } catch (Exception e) { + AmbleKit.LOGGER.warn("Cannot set username: failed to parse JSON text '{}': {}", jsonText, e.getMessage()); + return false; + } + } + + /** + * Clears a custom display name by UUID string. + * + * @param uuidString the UUID of the entity whose name to clear + * @return true if the UUID was valid, false otherwise + */ + @LuaExpose + public boolean clearUsernameByUuid(String uuidString) { + UUID uuid = parseUuid(uuidString); + if (uuid == null) { + return false; + } + UsernameTracker.getInstance().removeSynced(uuid); + return true; } // ===== Cross-script function calling ===== diff --git a/src/main/java/dev/amble/lib/username/UsernameTracker.java b/src/main/java/dev/amble/lib/username/UsernameTracker.java new file mode 100644 index 0000000..a78fe49 --- /dev/null +++ b/src/main/java/dev/amble/lib/username/UsernameTracker.java @@ -0,0 +1,221 @@ +package dev.amble.lib.username; + +import com.google.gson.*; +import dev.amble.lib.AmbleKit; +import dev.amble.lib.util.ServerLifecycleHooks; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.networking.v1.PacketByteBufs; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.network.PacketByteBuf; +import net.minecraft.network.packet.s2c.play.PlayerListS2CPacket; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; +import net.minecraft.util.WorldSavePath; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +public class UsernameTracker extends HashMap { + public static final Identifier SYNC_KEY = AmbleKit.id("username_sync"); + private static final String CLEAR_KEY = "supersecretcodeword_clear"; + + private static UsernameTracker INSTANCE; + + public static UsernameTracker getInstance() { + if (INSTANCE == null) { + INSTANCE = new UsernameTracker(); + } + return INSTANCE; + } + + public static void init() { + INSTANCE = new UsernameTracker(); + + ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> + getInstance().sync(handler.getPlayer()) + ); + + ServerLifecycleEvents.SERVER_STOPPING.register(server -> + getInstance().write(server) + ); + + ServerLifecycleEvents.SERVER_STARTED.register(UsernameTracker::read); + + if (FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT) { + initClient(); + } + } + + @Environment(EnvType.CLIENT) + private static void initClient() { + ClientPlayNetworking.registerGlobalReceiver(SYNC_KEY, (client, handler, buf, responseSender) -> + getInstance().receive(buf) + ); + } + + @Nullable + public Text putSynced(UUID id, Text text) { + Text previous = this.put(id, text); + sync(toBuf(id, text)); + updatePlayerList(id); + return previous; + } + + @Nullable + public Text removeSynced(UUID id) { + Text previous = this.remove(id); + sync(toBufClear(id)); + updatePlayerList(id); + return previous; + } + + /** + * Sends a player list update packet to all clients to refresh the tab list display name. + */ + private void updatePlayerList(UUID targetId) { + MinecraftServer server = ServerLifecycleHooks.get(); + if (server == null) return; + + ServerPlayerEntity targetPlayer = server.getPlayerManager().getPlayer(targetId); + if (targetPlayer == null) return; + + // Send UPDATE_DISPLAY_NAME action to all players to refresh the tab list + PlayerListS2CPacket packet = new PlayerListS2CPacket( + EnumSet.of(PlayerListS2CPacket.Action.UPDATE_DISPLAY_NAME), + Collections.singletonList(targetPlayer) + ); + + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + player.networkHandler.sendPacket(packet); + } + } + + public Optional getOptional(UUID id) { + return Optional.ofNullable(this.get(id)); + } + + private PacketByteBuf toBuf(UUID id, Text text) { + PacketByteBuf buf = PacketByteBufs.create(); + + buf.writeInt(1); + buf.writeUuid(id); + buf.writeString(Text.Serializer.toJson(text)); + + return buf; + } + + private PacketByteBuf toBufClear(UUID id) { + PacketByteBuf buf = PacketByteBufs.create(); + + buf.writeInt(1); + buf.writeUuid(id); + buf.writeString(CLEAR_KEY); + + return buf; + } + + private PacketByteBuf toBuf() { + return toBuf(this); + } + + private PacketByteBuf toBuf(Map map) { + PacketByteBuf buf = PacketByteBufs.create(); + + buf.writeInt(map.size()); + + for (Map.Entry entry : map.entrySet()) { + buf.writeUuid(entry.getKey()); + buf.writeString(Text.Serializer.toJson(entry.getValue())); + } + + return buf; + } + + private void sync(PacketByteBuf buf) { + ServerLifecycleHooks.get().getPlayerManager().getPlayerList().forEach(p -> this.sync(buf, p)); + } + + private void sync(PacketByteBuf buf, ServerPlayerEntity player) { + ServerPlayNetworking.send(player, SYNC_KEY, buf); + } + + private void receive(PacketByteBuf buf) { + int count = buf.readInt(); + for (int i = 0; i < count; i++) { + UUID id = buf.readUuid(); + String json = buf.readString(); + + if (json.equals(CLEAR_KEY)) { + this.remove(id); + continue; + } + + Text text = Text.Serializer.fromJson(json); + if (text != null) { + this.put(id, text); + } + } + } + + public void sync() { + sync(toBuf()); + } + + public void sync(ServerPlayerEntity target) { + sync(toBuf(), target); + } + + private static Path getSavePath(MinecraftServer server) { + return server.getSavePath(WorldSavePath.ROOT).resolve("amblekit").resolve("usernames.json"); + } + + private void write(MinecraftServer server) { + try { + Path savePath = getSavePath(server); + if (!Files.exists(savePath)) { + Files.createDirectories(savePath.getParent()); + } + + JsonObject json = new JsonObject(); + for (Map.Entry entry : this.entrySet()) { + json.add(entry.getKey().toString(), JsonParser.parseString(Text.Serializer.toJson(entry.getValue()))); + } + + Files.writeString(savePath, AmbleKit.GSON.toJson(json)); + } catch (Exception e) { + AmbleKit.LOGGER.error("Failed to write usernames.json", e); + } + } + + private static void read(MinecraftServer server) { + if (!Files.exists(getSavePath(server))) return; + + try { + String raw = Files.readString(getSavePath(server)); + JsonObject object = JsonParser.parseString(raw).getAsJsonObject(); + + INSTANCE = new UsernameTracker(); + for (Map.Entry entry : object.entrySet()) { + UUID uuid = UUID.fromString(entry.getKey()); + Text text = Text.Serializer.fromJson(entry.getValue().toString()); + if (text != null) { + INSTANCE.put(uuid, text); + } + } + + INSTANCE.sync(); + } catch (Exception e) { + AmbleKit.LOGGER.error("Failed to read usernames.json", e); + } + } +} + diff --git a/src/main/resources/amblekit.mixins.json b/src/main/resources/amblekit.mixins.json index fc47264..d7317fc 100644 --- a/src/main/resources/amblekit.mixins.json +++ b/src/main/resources/amblekit.mixins.json @@ -10,6 +10,7 @@ "ItemMixin", "PlayerEntityMixin", "SaveLoadingMixin", + "ServerPlayerEntityMixin", "ServerPlayerInteractionManagerMixin", "SkeletonEntityMixin", "StructureTemplateMixin", @@ -24,10 +25,12 @@ "client.CameraMixin", "client.ClientPlayerEntityMixin", "client.ClientPlayerInteractionManagerMixin", + "client.EntityRendererMixin", "client.GameRendererMixin", "client.PerspectiveMixin", "client.PlayerEntityModelMixin", "client.PlayerEntityRendererMixin", + "client.PlayerListEntryMixin", "client.WorldRendererMixin" ], "injectors": { diff --git a/src/test/resources/assets/litmus/gui/skin_changer.json b/src/test/resources/assets/litmus/gui/skin_changer.json index b7ca7d8..7e5fc3d 100644 --- a/src/test/resources/assets/litmus/gui/skin_changer.json +++ b/src/test/resources/assets/litmus/gui/skin_changer.json @@ -184,6 +184,34 @@ "centre" ] }, + { + "id": "litmus:set_username_btn", + "script": "litmus:username_changer", + "hover_background": [ + 100, + 80, + 70 + ], + "press_background": [ + 70, + 50, + 40 + ], + "layout": [ + 154, + 20 + ], + "background": [ + 80, + 60, + 50 + ], + "text": "§fSet Username", + "text_alignment": [ + "centre", + "centre" + ] + }, { "id": "litmus:status_text", "layout": [ diff --git a/src/test/resources/assets/litmus/script/gui_utils.lua b/src/test/resources/assets/litmus/script/gui_utils.lua new file mode 100644 index 0000000..d4e56a7 --- /dev/null +++ b/src/test/resources/assets/litmus/script/gui_utils.lua @@ -0,0 +1,80 @@ +-- GUI Utilities Script +-- Shared helper functions for GUI scripts + +local GuiUtils = {} + +-- Helper function to find an element by ID recursively +function GuiUtils.findById(element, targetId) + if element:id() == targetId then + return element + end + + local count = element:childCount() + for i = 0, count - 1 do + local child = element:child(i) + if child then + local found = GuiUtils.findById(child, targetId) + if found then + return found + end + end + end + + return nil +end + +-- Find the root element by traversing up from self +function GuiUtils.getRoot(element) + local current = element + while current:parent() do + current = current:parent() + end + return current +end + +-- Trim whitespace from a string +function GuiUtils.trim(str) + if not str then return "" end + return str:match("^%s*(.-)%s*$") or "" +end + +-- Validate input and show error if empty +-- Returns the trimmed input if valid, nil if invalid +function GuiUtils.validateInput(self, inputId, statusId) + local root = GuiUtils.getRoot(self) + local mc = self:minecraft() + + local input = GuiUtils.findById(root, inputId) + local statusText = statusId and GuiUtils.findById(root, statusId) or nil + + if not input then + mc:sendMessage("§cError: Could not find input element!", false) + return nil, nil, nil + end + + local value = GuiUtils.trim(input:getText()) + + if value == "" then + if statusText then + statusText:setText("§cEnter a value!") + end + mc:playSound("minecraft:block.note_block.bass", 0.5, 0.8) + return nil, input, statusText + end + + return value, input, statusText +end + +-- Play success sound and clear input +function GuiUtils.onSuccess(mc, input, statusText, message) + if statusText then + statusText:setText("§a" .. message) + end + mc:playSound("minecraft:ui.button.click", 1.0, 1.2) + if input then + input:setText("") + end +end + +return GuiUtils + diff --git a/src/test/resources/assets/litmus/script/skin_changer.lua b/src/test/resources/assets/litmus/script/skin_changer.lua index 164a130..e0f3b37 100644 --- a/src/test/resources/assets/litmus/script/skin_changer.lua +++ b/src/test/resources/assets/litmus/script/skin_changer.lua @@ -1,42 +1,14 @@ -- Skin Changer Script -- Handles the "Set Skin" button functionality --- Helper function to find an element by ID recursively -local function findById(element, targetId) - local elemId = element:id() - if elemId == targetId then - return element - end - - local count = element:childCount() - for i = 0, count - 1 do - local child = element:child(i) - if child then - local found = findById(child, targetId) - if found then - return found - end - end - end - - return nil -end - --- Find the root element by traversing up from self -local function getRoot(element) - local current = element - while current:parent() do - current = current:parent() - end - return current -end +local GuiUtils = require("litmus:gui_utils") function onDisplay(self) -- Update the player name display - local root = getRoot(self) + local root = GuiUtils.getRoot(self) local mc = self:minecraft() - local playerNameText = findById(root, "litmus:player_name_text") + local playerNameText = GuiUtils.findById(root, "litmus:player_name_text") if playerNameText then local username = mc:username() playerNameText:setText("Current: " .. username) @@ -44,53 +16,16 @@ function onDisplay(self) end function onClick(self, mouseX, mouseY, button) - local root = getRoot(self) local mc = self:minecraft() - -- Find the text input - local usernameInput = findById(root, "litmus:username_input") - local statusText = findById(root, "litmus:status_text") - - if not usernameInput then - mc:sendMessage("§cError: Could not find username input!", false) - return - end - - local username = usernameInput:getText() - - -- Validate input - if not username or username == "" then - if statusText then - statusText:setText("§cEnter a username!") - end - mc:playSound("minecraft:block.note_block.bass", 0.5, 0.8) - return - end - - -- Trim whitespace (basic) - username = username:match("^%s*(.-)%s*$") - - if username == "" then - if statusText then - statusText:setText("§cEnter a username!") - end - mc:playSound("minecraft:block.note_block.bass", 0.5, 0.8) - return - end + local username, input, statusText = GuiUtils.validateInput(self, "litmus:username_input", "litmus:status_text") + if not username then return end -- Run the skin command local command = "/amblekit skin @p set " .. username mc:runCommand(command) - -- Update status - if statusText then - statusText:setText("§aSkin set to: " .. username) - end - - -- Play success sound - mc:playSound("minecraft:ui.button.click", 1.0, 1.2) - - -- Clear the input - usernameInput:setText("") + -- Success feedback + GuiUtils.onSuccess(mc, input, statusText, "Skin set to: " .. username) end diff --git a/src/test/resources/assets/litmus/script/skin_changer_slim.lua b/src/test/resources/assets/litmus/script/skin_changer_slim.lua index ef3b568..b489237 100644 --- a/src/test/resources/assets/litmus/script/skin_changer_slim.lua +++ b/src/test/resources/assets/litmus/script/skin_changer_slim.lua @@ -1,41 +1,13 @@ -- Skin Changer Slim Toggle Script -- Handles the "Toggle Slim" button functionality +local GuiUtils = require("litmus:gui_utils") + -- Track the current slim mode state local slimMode = false --- Helper function to find an element by ID recursively -local function findById(element, targetId) - if element:id() == targetId then - return element - end - - local count = element:childCount() - for i = 0, count - 1 do - local child = element:child(i) - if child then - local found = findById(child, targetId) - if found then - return found - end - end - end - - return nil -end - --- Find the root element by traversing up from self -local function getRoot(element) - local current = element - while current:parent() do - current = current:parent() - end - return current -end - -- Update the button text to reflect current mode local function updateButtonText(self) - -- Find the text child within the button local textChild = self:findFirstText() if textChild then if slimMode then @@ -52,9 +24,9 @@ function onDisplay(self) end function onClick(self, mouseX, mouseY, button) - local root = getRoot(self) + local root = GuiUtils.getRoot(self) local mc = self:minecraft() - local statusText = findById(root, "litmus:status_text") + local statusText = GuiUtils.findById(root, "litmus:status_text") -- Toggle the slim mode slimMode = not slimMode diff --git a/src/test/resources/assets/litmus/script/username_changer.lua b/src/test/resources/assets/litmus/script/username_changer.lua new file mode 100644 index 0000000..4c1786b --- /dev/null +++ b/src/test/resources/assets/litmus/script/username_changer.lua @@ -0,0 +1,23 @@ +-- Username Changer Script +-- Handles the "Set Username" button functionality + +local GuiUtils = require("litmus:gui_utils") + +function onClick(self, mouseX, mouseY, button) + local mc = self:minecraft() + + local username, input, statusText = GuiUtils.validateInput(self, "litmus:username_input", "litmus:status_text") + if not username then return end + + -- Set the username using the Lua API + if mc:setUsername(mc:username(), username) then + -- Success feedback + GuiUtils.onSuccess(mc, input, statusText, "Username set to: " .. username) + else + if statusText then + statusText:setText("§cFailed to set username!") + end + mc:playSound("minecraft:block.note_block.bass", 0.5, 0.8) + end +end +