commit 7f9f425de2182e8ade6fbc79fbb621fe1775d4ee Author: ehko Date: Fri May 29 13:31:39 2026 -0400 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8254f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Build output +target/ +out/ +build/ +*.class +*.jar +dependency-reduced-pom.xml + +# IDE +.idea/ +*.iml +*.iws +*.ipr +.vscode/ +.project +.classpath +.settings/ + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Local config / secrets +config.json.local +*.env +.env diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..de08d07 --- /dev/null +++ b/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + net.kewwbec + coreui + 0.1.0 + jar + + CoreUI + Shared UI templates + avatar/dynamic-image service for KweebecNet plugins + + + 21 + 21 + UTF-8 + ${user.home}/AppData/Roaming/Hytale/install/release/package/game/latest/Server/HytaleServer.jar + ${project.basedir}/../Core/target/NetworkCore-0.1.0.jar + + + + + com.hypixel.hytale + hytale-server + local + system + ${hytale.server.jar} + + + + net.kewwbec + networkcore + 0.1.0 + system + ${networkcore.jar} + + + + com.google.code.findbugs + jsr305 + 3.0.2 + provided + + + + + ${project.artifactId}-${project.version} + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + false + false + + + com.hypixel.hytale:* + net.kewwbec:networkcore + com.google.code.findbugs:* + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + + + + + + + + + + diff --git a/src/main/java/net/kewwbec/coreui/CoreUIPlugin.java b/src/main/java/net/kewwbec/coreui/CoreUIPlugin.java new file mode 100644 index 0000000..e564162 --- /dev/null +++ b/src/main/java/net/kewwbec/coreui/CoreUIPlugin.java @@ -0,0 +1,69 @@ +package net.kewwbec.coreui; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent; +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import com.hypixel.hytale.server.core.plugin.JavaPluginInit; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.coreui.api.Avatars; +import net.kewwbec.coreui.avatar.AssetRebuildHandler; +import net.kewwbec.coreui.avatar.AvatarsImpl; +import net.kewwbec.networkcore.NetworkCore; + +import javax.annotation.Nonnull; +import java.util.logging.Level; +public final class CoreUIPlugin extends JavaPlugin { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private static volatile CoreUIPlugin instance; + + private AvatarsImpl avatars; + + public CoreUIPlugin(@Nonnull JavaPluginInit init) { + super(init); + } + + @Nonnull + public static CoreUIPlugin getInstance() { + CoreUIPlugin i = instance; + if (i == null) throw new IllegalStateException("CoreUI not initialized"); + return i; + } + + @Override + protected void setup() { + instance = this; + this.avatars = new AvatarsImpl(); + AssetRebuildHandler.install(); + LOGGER.at(Level.INFO).log("CoreUI setup complete"); + } + + @Override + protected void start() { + NetworkCore core = NetworkCore.getInstance(); + core.registerService(Avatars.class, avatars); + + getEventRegistry().registerGlobal(PlayerDisconnectEvent.class, this::onDisconnect); + LOGGER.at(Level.INFO).log("CoreUI started (Avatars service registered)"); + } + + private void onDisconnect(@Nonnull PlayerDisconnectEvent event) { + PlayerRef ref = event.getPlayerRef(); + if (ref == null) return; + if (avatars != null) avatars.releaseAll(ref); + AssetRebuildHandler.clearPlayer(ref); + } + + @Override + protected void shutdown() { + avatars = null; + instance = null; + LOGGER.at(Level.INFO).log("CoreUI shutdown complete"); + } + + @Nonnull + public Avatars getAvatars() { + return avatars; + } +} diff --git a/src/main/java/net/kewwbec/coreui/api/AvatarHandle.java b/src/main/java/net/kewwbec/coreui/api/AvatarHandle.java new file mode 100644 index 0000000..30d251c --- /dev/null +++ b/src/main/java/net/kewwbec/coreui/api/AvatarHandle.java @@ -0,0 +1,23 @@ +package net.kewwbec.coreui.api; + +import javax.annotation.Nonnull; + +public final class AvatarHandle { + + private final String assetPath; + private final int slotIndex; + + public AvatarHandle(@Nonnull String assetPath, int slotIndex) { + this.assetPath = assetPath; + this.slotIndex = slotIndex; + } + + @Nonnull + public String assetPath() { + return assetPath; + } + + public int slotIndex() { + return slotIndex; + } +} diff --git a/src/main/java/net/kewwbec/coreui/api/AvatarRenderType.java b/src/main/java/net/kewwbec/coreui/api/AvatarRenderType.java new file mode 100644 index 0000000..adf09e3 --- /dev/null +++ b/src/main/java/net/kewwbec/coreui/api/AvatarRenderType.java @@ -0,0 +1,17 @@ +package net.kewwbec.coreui.api; + +public enum AvatarRenderType { + HEAD("render"), + FULL("render/full"), + CAPE("render/cape"); + + private final String path; + + AvatarRenderType(String path) { + this.path = path; + } + + public String getPath() { + return path; + } +} diff --git a/src/main/java/net/kewwbec/coreui/api/AvatarRequest.java b/src/main/java/net/kewwbec/coreui/api/AvatarRequest.java new file mode 100644 index 0000000..11582ae --- /dev/null +++ b/src/main/java/net/kewwbec/coreui/api/AvatarRequest.java @@ -0,0 +1,57 @@ +package net.kewwbec.coreui.api; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class AvatarRequest { + + private final String username; + private AvatarRenderType renderType = AvatarRenderType.HEAD; + private Integer size; + private Integer rotate; + private String cape; + + private AvatarRequest(@Nonnull String username) { + this.username = username; + } + + @Nonnull + public static AvatarRequest forUsername(@Nonnull String username) { + return new AvatarRequest(username); + } + + @Nonnull + public AvatarRequest renderType(@Nullable AvatarRenderType renderType) { + if (renderType != null) this.renderType = renderType; + return this; + } + + @Nonnull + public AvatarRequest size(@Nullable Integer size) { + this.size = size; + return this; + } + + @Nonnull + public AvatarRequest rotate(@Nullable Integer rotate) { + this.rotate = rotate; + return this; + } + + @Nonnull + public AvatarRequest cape(@Nullable String cape) { + this.cape = cape; + return this; + } + + @Nonnull + public String username() { return username; } + @Nonnull + public AvatarRenderType renderType() { return renderType; } + @Nullable + public Integer size() { return size; } + @Nullable + public Integer rotate() { return rotate; } + @Nullable + public String cape() { return cape; } +} diff --git a/src/main/java/net/kewwbec/coreui/api/Avatars.java b/src/main/java/net/kewwbec/coreui/api/Avatars.java new file mode 100644 index 0000000..e3c8707 --- /dev/null +++ b/src/main/java/net/kewwbec/coreui/api/Avatars.java @@ -0,0 +1,78 @@ +package net.kewwbec.coreui.api; + +import com.hypixel.hytale.server.core.universe.PlayerRef; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.concurrent.CompletableFuture; + +/** + * Service for streaming live avatar/Hyvatar renders to a specific player and exposing + * them via an asset-pack texture path that can be referenced in {@code .ui} markup. + * + *

Flow: + *

{@code
+ *   Avatars avatars = NetworkCore.getInstance().getService(Avatars.class);
+ *   avatars.fetch(playerRef, AvatarRequest.forUsername(name).size(180))
+ *          .thenAccept(handle -> {
+ *              // open the .ui page, set Image.AssetName -> handle.assetPath()
+ *          });
+ * }
+ * + *

The future completes off the world thread. Any code that touches Hytale entities or + * pages must hop back to the world thread via {@code world.execute(...)}.

+ */ +public interface Avatars { + + /** + * Download the requested avatar PNG, push it to the player's client, and return a + * handle whose {@link AvatarHandle#assetPath()} is safe to reference in {@code .ui} + * markup served to that same player. + * + *

The future is completed on a background thread; if you intend to send the + * resulting handle's path through a page build, prefer {@link #install} instead so + * the asset packets get written on the world/network thread. + */ + @Nonnull + CompletableFuture fetch(@Nonnull PlayerRef playerRef, @Nonnull AvatarRequest request); + + /** + * Synchronous fetch + install. HTTP runs on a background pool (this call blocks up + * to 15s on it), then the asset packets are written on the calling thread. Must be + * called from the world/network thread — e.g. inside + * {@code InteractiveCustomUIPage.build()} or a Hytale event handler. + */ + @Nonnull + AvatarHandle install(@Nonnull PlayerRef playerRef, @Nonnull AvatarRequest request); + + /** + * Fully non-blocking install. HTTP runs on the avatar service's internal pool, then + * the asset packets are written via {@code sendExecutor} (pass {@code world::execute} + * to land on the world thread). Returns immediately with a future that completes when + * the install lands; callers can chain {@code .exceptionally(...)} for error logging. + * + *

Use this for join-time prefetches so the world thread never blocks on HTTP.

+ */ + @Nonnull + java.util.concurrent.CompletableFuture installAsync( + @Nonnull PlayerRef playerRef, + @Nonnull AvatarRequest request, + @Nonnull java.util.concurrent.Executor sendExecutor); + + /** + * If an avatar for the given {@code request} is already cached for the player, + * return its handle synchronously without re-downloading. Returns {@code null} on miss. + */ + @Nullable + AvatarHandle getCached(@Nonnull PlayerRef playerRef, @Nonnull AvatarRequest request); + + /** + * Release the slot owned by {@code handle} for the player so it can be reused. + */ + void release(@Nonnull PlayerRef playerRef, @Nonnull AvatarHandle handle); + + /** + * Release every slot held by the player. Called automatically on disconnect. + */ + void releaseAll(@Nonnull PlayerRef playerRef); +} diff --git a/src/main/java/net/kewwbec/coreui/avatar/AssetRebuildHandler.java b/src/main/java/net/kewwbec/coreui/avatar/AssetRebuildHandler.java new file mode 100644 index 0000000..acd59f1 --- /dev/null +++ b/src/main/java/net/kewwbec/coreui/avatar/AssetRebuildHandler.java @@ -0,0 +1,130 @@ +package net.kewwbec.coreui.avatar; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.protocol.Asset; +import com.hypixel.hytale.protocol.Packet; +import com.hypixel.hytale.protocol.packets.setup.AssetInitialize; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.entity.entities.player.pages.CustomUIPage; +import com.hypixel.hytale.server.core.entity.entities.player.pages.PageManager; +import com.hypixel.hytale.server.core.io.PacketHandler; +import com.hypixel.hytale.server.core.io.adapter.PacketAdapters; +import com.hypixel.hytale.server.core.io.handlers.game.GamePacketHandler; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.lang.reflect.Method; +import java.util.Deque; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.concurrent.ConcurrentMap; +import java.util.logging.Level; + +/** + * Server-side workaround for the asset rebuild closing/staling open custom pages. + * + *

Mirrors HyUI's pattern: when we push a new avatar to the client, the server emits an + * {@code AssetInitialize} sequence followed by {@code RequestCommonAssetsRebuild}. The + * client receives those, rebuilds its asset cache, and any custom page that was open + * renders the previous (cached) texture instead of the new one. To fix it we + * re-build and re-send the page on the next world tick after the rebuild + * request goes out. + * + *

We hook every outbound packet via {@link PacketAdapters#registerOutbound}, queue the + * assets we see, and on {@code RequestCommonAssetsRebuild} schedule a {@code rebuild()} + * call on whatever {@link CustomUIPage} the player currently has open. {@code rebuild()} + * is protected, so we invoke it reflectively. + */ +public final class AssetRebuildHandler { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private static final ConcurrentMap> PENDING_ASSETS = new ConcurrentHashMap<>(); + private static final ConcurrentMap REBUILD_SCHEDULED = new ConcurrentHashMap<>(); + + private static final Method REBUILD_METHOD; + static { + try { + REBUILD_METHOD = CustomUIPage.class.getDeclaredMethod("rebuild"); + REBUILD_METHOD.setAccessible(true); + } catch (NoSuchMethodException e) { + throw new IllegalStateException("CustomUIPage.rebuild() not found", e); + } + } + + private AssetRebuildHandler() {} + + public static void install() { + PacketAdapters.registerOutbound((PacketHandler handler, Packet packet) -> { + if (!(handler instanceof GamePacketHandler h)) return; + String name = packet.getClass().getSimpleName(); + switch (name) { + case "AssetInitialize" -> enqueueAsset(h.getPlayerRef(), ((AssetInitialize) packet).asset); + case "RequestCommonAssetsRebuild" -> scheduleReopenAfterRebuild(h.getPlayerRef()); + default -> { } + } + }); + LOGGER.at(Level.INFO).log("CoreUI asset-rebuild handler installed"); + } + + private static void enqueueAsset(PlayerRef playerRef, Asset asset) { + if (playerRef == null || asset == null) return; + PENDING_ASSETS.computeIfAbsent(playerRef, ref -> new ConcurrentLinkedDeque<>()).push(asset); + } + + private static void scheduleReopenAfterRebuild(PlayerRef playerRef) { + if (playerRef == null || !playerRef.isValid()) return; + Ref ref = playerRef.getReference(); + if (ref == null || !ref.isValid()) return; + if (REBUILD_SCHEDULED.putIfAbsent(playerRef, Boolean.TRUE) != null) return; + + Store store = ref.getStore(); + World world = store.getExternalData().getWorld(); + world.execute(() -> { + try { + Set assets = drainAssets(playerRef); + if (assets.isEmpty()) return; + if (!ref.isValid()) return; + + Player playerComponent = store.getComponent(ref, Player.getComponentType()); + if (playerComponent == null) return; + PageManager manager = playerComponent.getPageManager(); + CustomUIPage currentPage = manager.getCustomPage(); + if (currentPage == null) return; + + try { + REBUILD_METHOD.invoke(currentPage); + } catch (ReflectiveOperationException e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)) + .log("Failed to rebuild current custom page after asset rebuild"); + } + } finally { + REBUILD_SCHEDULED.remove(playerRef); + } + }); + } + + @Nonnull + private static Set drainAssets(@Nonnull PlayerRef playerRef) { + Deque stack = PENDING_ASSETS.get(playerRef); + if (stack == null) return Set.of(); + Set drained = new HashSet<>(); + Asset asset; + while ((asset = stack.pollFirst()) != null) { + drained.add(asset); + } + PENDING_ASSETS.remove(playerRef, stack); + return drained; + } + + public static void clearPlayer(@Nonnull PlayerRef playerRef) { + PENDING_ASSETS.remove(playerRef); + REBUILD_SCHEDULED.remove(playerRef); + } +} diff --git a/src/main/java/net/kewwbec/coreui/avatar/AvatarSlots.java b/src/main/java/net/kewwbec/coreui/avatar/AvatarSlots.java new file mode 100644 index 0000000..3ca64d3 --- /dev/null +++ b/src/main/java/net/kewwbec/coreui/avatar/AvatarSlots.java @@ -0,0 +1,35 @@ +package net.kewwbec.coreui.avatar; + +public final class AvatarSlots { + + public static final int SLOT_COUNT = 10; + + public static final String[] PATHS = new String[SLOT_COUNT]; + public static final String[] HASHES = new String[SLOT_COUNT]; + + static { + for (int i = 0; i < SLOT_COUNT; i++) { + PATHS[i] = "UI/Custom/Pages/Elements/KweebecNetSlot" + (i + 1) + ".png"; + HASHES[i] = makeHash(i + 1); + } + } + + private AvatarSlots() {} + private static String makeHash(int slotId) { + byte[] bytes = new byte[32]; + bytes[0] = 0x00; + bytes[1] = (byte) 'K'; + bytes[2] = (byte) 'w'; + bytes[3] = (byte) 'e'; + bytes[4] = (byte) 'e'; + bytes[5] = (byte) 'b'; + bytes[6] = (byte) 'e'; + bytes[7] = (byte) 'c'; + bytes[8] = (byte) slotId; + StringBuilder sb = new StringBuilder(64); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } +} diff --git a/src/main/java/net/kewwbec/coreui/avatar/AvatarsImpl.java b/src/main/java/net/kewwbec/coreui/avatar/AvatarsImpl.java new file mode 100644 index 0000000..3a6fdd1 --- /dev/null +++ b/src/main/java/net/kewwbec/coreui/avatar/AvatarsImpl.java @@ -0,0 +1,215 @@ +package net.kewwbec.coreui.avatar; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.coreui.api.AvatarHandle; +import net.kewwbec.coreui.api.AvatarRequest; +import net.kewwbec.coreui.api.Avatars; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.logging.Level; + +public final class AvatarsImpl implements Avatars { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + private final Executor pool = Executors.newFixedThreadPool(4, r -> { + Thread t = new Thread(r, "CoreUI-AvatarFetch"); + t.setDaemon(true); + return t; + }); + + private final Object lock = new Object(); + private final Map byPlayer = new HashMap<>(); + + @Override + @Nonnull + public CompletableFuture fetch(@Nonnull PlayerRef playerRef, @Nonnull AvatarRequest request) { + String url = HyvatarUrl.build(request); + AvatarHandle cached; + synchronized (lock) { + cached = getCachedLocked(playerRef.getUuid(), url); + } + if (cached != null) { + return CompletableFuture.completedFuture(cached); + } + return CompletableFuture.supplyAsync(() -> downloadBytes(url), pool) + .thenApply(png -> installOnCallerThread(playerRef, url, png)); + } + + @Override + @Nullable + public AvatarHandle getCached(@Nonnull PlayerRef playerRef, @Nonnull AvatarRequest request) { + String url = HyvatarUrl.build(request); + synchronized (lock) { + return getCachedLocked(playerRef.getUuid(), url); + } + } + + @Override + public void release(@Nonnull PlayerRef playerRef, @Nonnull AvatarHandle handle) { + synchronized (lock) { + PerPlayer p = byPlayer.get(playerRef.getUuid()); + if (p == null) return; + for (Iterator> it = p.urlToSlot.entrySet().iterator(); it.hasNext(); ) { + Map.Entry e = it.next(); + if (e.getValue() == handle.slotIndex()) { + p.slotsUsed[handle.slotIndex()] = false; + it.remove(); + return; + } + } + } + } + + @Override + public void releaseAll(@Nonnull PlayerRef playerRef) { + synchronized (lock) { + byPlayer.remove(playerRef.getUuid()); + } + } + + /** + * Synchronously fetch + install an avatar for the player, blocking the caller for the + * HTTP (background pool) and then sending asset packets on the caller's thread. + * Must be called from the world/network thread (e.g. {@code InteractiveCustomUIPage.build()}). + */ + @Nonnull + public AvatarHandle install(@Nonnull PlayerRef playerRef, @Nonnull AvatarRequest request) { + String url = HyvatarUrl.build(request); + AvatarHandle cached; + synchronized (lock) { + cached = getCachedLocked(playerRef.getUuid(), url); + } + if (cached != null) return cached; + + byte[] png; + try { + png = CompletableFuture.supplyAsync(() -> downloadBytes(url), pool) + .get(15, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Avatar download interrupted: " + url, e); + } catch (ExecutionException e) { + throw new RuntimeException("Avatar download failed: " + url, e.getCause()); + } catch (TimeoutException e) { + throw new RuntimeException("Avatar download timeout: " + url, e); + } + return installOnCallerThread(playerRef, url, png); + } + + @Override + @Nonnull + public CompletableFuture installAsync(@Nonnull PlayerRef playerRef, + @Nonnull AvatarRequest request, + @Nonnull Executor sendExecutor) { + String url = HyvatarUrl.build(request); + AvatarHandle cached; + synchronized (lock) { + cached = getCachedLocked(playerRef.getUuid(), url); + } + if (cached != null) { + return CompletableFuture.completedFuture(cached); + } + return CompletableFuture.supplyAsync(() -> downloadBytes(url), pool) + .thenApplyAsync(png -> installOnCallerThread(playerRef, url, png), sendExecutor); + } + + @Nullable + private AvatarHandle getCachedLocked(@Nonnull UUID playerUuid, @Nonnull String url) { + PerPlayer p = byPlayer.get(playerUuid); + if (p == null) return null; + Integer slot = p.urlToSlot.get(url); + if (slot == null) return null; + return new AvatarHandle(AvatarSlots.PATHS[slot], slot); + } + + @Nonnull + private byte[] downloadBytes(@Nonnull String url) { + try { + HttpRequest req = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(10)) + .header("Accept", "image/png") + .GET() + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray()); + if (resp.statusCode() != 200) { + throw new IOException("Avatar fetch HTTP " + resp.statusCode() + " for " + url); + } + LOGGER.at(Level.INFO).log("Avatar download OK (%d bytes) for %s", resp.body().length, url); + return resp.body(); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + throw new RuntimeException("Failed to download avatar from " + url, e); + } + } + + @Nonnull + private AvatarHandle installOnCallerThread(@Nonnull PlayerRef playerRef, @Nonnull String url, @Nonnull byte[] png) { + int slotIndex; + synchronized (lock) { + PerPlayer p = byPlayer.computeIfAbsent(playerRef.getUuid(), k -> new PerPlayer()); + Integer existing = p.urlToSlot.get(url); + if (existing != null) { + slotIndex = existing; + } else { + slotIndex = claimSlotLocked(p, playerRef.getUuid()); + p.urlToSlot.put(url, slotIndex); + } + } + try { + DynamicAvatarAsset asset = new DynamicAvatarAsset(png, slotIndex); + DynamicAvatarAsset.sendToPlayer(playerRef.getPacketHandler(), DynamicAvatarAsset.emptyAsset(slotIndex)); + DynamicAvatarAsset.sendToPlayer(playerRef.getPacketHandler(), asset); + LOGGER.at(Level.INFO).log("Avatar asset pushed (slot=%d, path=%s) for %s", + slotIndex, AvatarSlots.PATHS[slotIndex], playerRef.getUsername()); + } catch (RuntimeException e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("Failed to send avatar asset (slot=%d, url=%s)", slotIndex, url); + throw e; + } + return new AvatarHandle(AvatarSlots.PATHS[slotIndex], slotIndex); + } + + private int claimSlotLocked(@Nonnull PerPlayer p, @Nonnull UUID playerUuid) { + for (int i = 0; i < AvatarSlots.SLOT_COUNT; i++) { + if (!p.slotsUsed[i]) { + p.slotsUsed[i] = true; + return i; + } + } + Iterator> it = p.urlToSlot.entrySet().iterator(); + if (!it.hasNext()) { + throw new IllegalStateException("Avatar slot pool exhausted for " + playerUuid + " (max " + AvatarSlots.SLOT_COUNT + ")"); + } + Map.Entry oldest = it.next(); + int reclaimed = oldest.getValue(); + it.remove(); + p.slotsUsed[reclaimed] = true; + return reclaimed; + } + + private static final class PerPlayer { + final boolean[] slotsUsed = new boolean[AvatarSlots.SLOT_COUNT]; + final java.util.LinkedHashMap urlToSlot = new java.util.LinkedHashMap<>(); + } +} diff --git a/src/main/java/net/kewwbec/coreui/avatar/DynamicAvatarAsset.java b/src/main/java/net/kewwbec/coreui/avatar/DynamicAvatarAsset.java new file mode 100644 index 0000000..2cc1849 --- /dev/null +++ b/src/main/java/net/kewwbec/coreui/avatar/DynamicAvatarAsset.java @@ -0,0 +1,57 @@ +package net.kewwbec.coreui.avatar; + +import com.hypixel.hytale.common.util.ArrayUtil; +import com.hypixel.hytale.protocol.ToClientPacket; +import com.hypixel.hytale.protocol.packets.setup.AssetFinalize; +import com.hypixel.hytale.protocol.packets.setup.AssetInitialize; +import com.hypixel.hytale.protocol.packets.setup.AssetPart; +import com.hypixel.hytale.protocol.packets.setup.RequestCommonAssetsRebuild; +import com.hypixel.hytale.server.core.asset.common.CommonAsset; +import com.hypixel.hytale.server.core.asset.common.CommonAssetRegistry; +import com.hypixel.hytale.server.core.io.PacketHandler; + +import javax.annotation.Nonnull; +import java.util.concurrent.CompletableFuture; + +public final class DynamicAvatarAsset extends CommonAsset { + + private static final int PACKET_PART_SIZE = 2_621_440; + + private final byte[] data; + private final int slotIndex; + + public DynamicAvatarAsset(@Nonnull byte[] data, int slotIndex) { + super(AvatarSlots.PATHS[slotIndex], AvatarSlots.HASHES[slotIndex], data); + this.data = data; + this.slotIndex = slotIndex; + } + + public int getSlotIndex() { + return slotIndex; + } + + @Override + protected CompletableFuture getBlob0() { + return CompletableFuture.completedFuture(data); + } + + public static CommonAsset emptyAsset(int slotIndex) { + if (slotIndex < 0 || slotIndex >= AvatarSlots.SLOT_COUNT) { + throw new IllegalArgumentException("Invalid avatar slot index: " + slotIndex); + } + return CommonAssetRegistry.getByName(AvatarSlots.PATHS[slotIndex]); + } + + public static void sendToPlayer(@Nonnull PacketHandler handler, @Nonnull CommonAsset asset) { + byte[] allBytes = asset.getBlob().join(); + byte[][] parts = ArrayUtil.split(allBytes, PACKET_PART_SIZE); + + ToClientPacket[] packets = new ToClientPacket[1 + parts.length]; + packets[0] = new AssetInitialize(asset.toPacket(), allBytes.length); + for (int i = 0; i < parts.length; i++) { + packets[1 + i] = new AssetPart(parts[i]); + } + handler.write(packets, new AssetFinalize()); + handler.writeNoCache(new RequestCommonAssetsRebuild()); + } +} diff --git a/src/main/java/net/kewwbec/coreui/avatar/HyvatarUrl.java b/src/main/java/net/kewwbec/coreui/avatar/HyvatarUrl.java new file mode 100644 index 0000000..3efe41b --- /dev/null +++ b/src/main/java/net/kewwbec/coreui/avatar/HyvatarUrl.java @@ -0,0 +1,63 @@ +package net.kewwbec.coreui.avatar; + +import net.kewwbec.coreui.api.AvatarRenderType; +import net.kewwbec.coreui.api.AvatarRequest; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +final class HyvatarUrl { + + static final String BASE_URL = "https://hyvatar.io"; + + private HyvatarUrl() {} + + @Nonnull + static String build(@Nonnull AvatarRequest req) { + String username = req.username().trim(); + if (username.isEmpty()) { + throw new IllegalArgumentException("Username is required to build a Hyvatar URL"); + } + AvatarRenderType type = req.renderType(); + StringBuilder url = new StringBuilder(BASE_URL) + .append('/').append(type.getPath()) + .append('/').append(encodePath(username)); + + boolean hasQuery = false; + Integer size = normalizeSize(req.size()); + if (size != null) hasQuery = appendParam(url, "size", size.toString(), hasQuery); + Integer rotate = normalizeRotate(req.rotate()); + if (rotate != null) hasQuery = appendParam(url, "rotate", rotate.toString(), hasQuery); + if (type == AvatarRenderType.CAPE && req.cape() != null && !req.cape().isBlank()) { + appendParam(url, "cape", encodeQuery(req.cape().trim()), hasQuery); + } + return url.toString(); + } + + @Nullable + private static Integer normalizeSize(@Nullable Integer size) { + if (size == null) return null; + return Math.max(64, Math.min(2048, size)); + } + + @Nullable + private static Integer normalizeRotate(@Nullable Integer rotate) { + if (rotate == null) return null; + return Math.max(0, Math.min(360, rotate)); + } + + private static boolean appendParam(StringBuilder url, String key, String value, boolean hasQuery) { + url.append(hasQuery ? '&' : '?').append(key).append('=').append(value); + return true; + } + + private static String encodePath(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private static String encodeQuery(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } +} diff --git a/src/main/resources/Common/UI/Custom/KweebecNet/Common.ui b/src/main/resources/Common/UI/Custom/KweebecNet/Common.ui new file mode 100644 index 0000000..53b9f47 --- /dev/null +++ b/src/main/resources/Common/UI/Custom/KweebecNet/Common.ui @@ -0,0 +1,279 @@ +// ==================================================================== +// KweebecNet CoreUI — shared template library. +// +// Other plugins consume via: +// $KW = "../../KweebecNet/Common.ui"; +// $KW.@KweebecPanel { ... } +// +// All templates live in this single file so a plugin only needs the one +// alias import. Naming follows the "Kweebec*" prefix convention. +// ==================================================================== + +// -------------------------------------------------------------------- +// Color palette (centralized so every template stays consistent) +// -------------------------------------------------------------------- +@KweebecBgPanel = #1F2A40; +@KweebecBgHeader = #161E33; +@KweebecBgCard = #1F2A40; +@KweebecBgInsetDark = #0E1422; +@KweebecBgComingSoon = #161C2C; +@KweebecBgComingInset = #0B1020; +@KweebecOutlineGold = #D4AF37; +@KweebecOutlineFrame = #2A3A55; +@KweebecOutlineDim = #232E48; +@KweebecTextPrimary = #FFFFFF; +@KweebecTextMuted = #8898B5; +@KweebecTextSecondary = #5A6580; +@KweebecTextAccent = #D4AF37; +@KweebecTextInfo = #55CCFF; +@KweebecTextSuccess = #55FF55; +@KweebecTextWarn = #FFAA55; +@KweebecTextDiscord = #7289DA; +@KweebecPrimaryDefault = #2D8C3A; +@KweebecPrimaryHover = #3FB04F; +@KweebecPrimaryPressed = #226C2C; +@KweebecPrimaryOutline = #1F6428; +@KweebecSecondaryDefault = #2A3A55; +@KweebecSecondaryHover = #3A4D70; +@KweebecSecondaryPressed = #1F2A40; +@KweebecSecondaryOutline = #4A5C7A; +@KweebecDangerDefault = #8C2D2D; +@KweebecDangerHover = #B03F3F; +@KweebecDangerPressed = #6C2222; +@KweebecDangerOutline = #641F1F; + +// -------------------------------------------------------------------- +// Reusable label styles +// -------------------------------------------------------------------- +@KweebecTitleStyle = LabelStyle( + FontSize: 26, + TextColor: @KweebecTextAccent, + HorizontalAlignment: Center, + RenderBold: true, + LetterSpacing: 3 +); + +@KweebecSubtitleStyle = LabelStyle( + FontSize: 13, + TextColor: @KweebecTextMuted, + HorizontalAlignment: Center, + RenderItalics: true +); + +@KweebecStatLabelStyle = LabelStyle( + FontSize: 14, + TextColor: @KweebecTextMuted +); + +@KweebecStatValueStyle = LabelStyle( + FontSize: 14, + TextColor: @KweebecTextPrimary, + HorizontalAlignment: End, + RenderBold: true +); + +// -------------------------------------------------------------------- +// Button styles (TextButton variants) +// -------------------------------------------------------------------- +@KweebecButtonLabelStyle = LabelStyle( + FontSize: 16, + TextColor: @KweebecTextPrimary, + HorizontalAlignment: Center, + VerticalAlignment: Center, + RenderBold: true, + LetterSpacing: 2 +); + +@KweebecPrimaryButtonStyle = TextButtonStyle( + Default: (Background: @KweebecPrimaryDefault, LabelStyle: @KweebecButtonLabelStyle), + Hovered: (Background: @KweebecPrimaryHover, LabelStyle: @KweebecButtonLabelStyle), + Pressed: (Background: @KweebecPrimaryPressed, LabelStyle: @KweebecButtonLabelStyle), + Disabled: (Background: @KweebecPrimaryPressed, LabelStyle: @KweebecButtonLabelStyle) +); + +@KweebecSecondaryButtonStyle = TextButtonStyle( + Default: (Background: @KweebecSecondaryDefault, LabelStyle: @KweebecButtonLabelStyle), + Hovered: (Background: @KweebecSecondaryHover, LabelStyle: @KweebecButtonLabelStyle), + Pressed: (Background: @KweebecSecondaryPressed, LabelStyle: @KweebecButtonLabelStyle), + Disabled: (Background: @KweebecSecondaryPressed, LabelStyle: @KweebecButtonLabelStyle) +); + +@KweebecDangerButtonStyle = TextButtonStyle( + Default: (Background: @KweebecDangerDefault, LabelStyle: @KweebecButtonLabelStyle), + Hovered: (Background: @KweebecDangerHover, LabelStyle: @KweebecButtonLabelStyle), + Pressed: (Background: @KweebecDangerPressed, LabelStyle: @KweebecButtonLabelStyle), + Disabled: (Background: @KweebecDangerPressed, LabelStyle: @KweebecButtonLabelStyle) +); + +// ==================================================================== +// Templates +// ==================================================================== + +// Full-screen dimmed backdrop; instantiate then place page content inside. +@KweebecPageOverlay = Group { + Background: #0A0F1AC0; + LayoutMode: MiddleCenter; +}; + +// Outlined panel with gold-accented header, content slot, and footer hint. +// Override #Title's Text via the @Text parameter or via Java ui.set("#PanelTitle.Text", ...). +// User content goes inside the #Content sub-element when instantiating. +@KweebecPanel = Group { + @Text = ""; + @Anchor = Anchor(); + + LayoutMode: Top; + Anchor: (...@Anchor); + Background: @KweebecBgPanel; + OutlineColor: @KweebecOutlineGold; + OutlineSize: 2; + + Group #Header { + LayoutMode: CenterMiddle; + Anchor: (Height: 56); + Background: @KweebecBgHeader; + OutlineColor: @KweebecOutlineGold; + OutlineSize: 1; + Label #PanelTitle { + Anchor: (Full: 0); + Style: @KweebecTitleStyle; + Text: @Text; + } + } + + Group #Content { + LayoutMode: Top; + FlexWeight: 1; + } + + Group #Footer { + LayoutMode: CenterMiddle; + Anchor: (Height: 22); + Padding: (Bottom: 5); + Label { + Style: (FontSize: 13, TextColor: @KweebecTextSecondary, HorizontalAlignment: Center); + Text: "Press ESC to close"; + } + } +}; + +// Plain framed card. Override the body inline. +@KweebecCard = Group { + @Anchor = Anchor(); + @Background = @KweebecBgCard; + @OutlineColor = @KweebecOutlineFrame; + + LayoutMode: Top; + Anchor: (...@Anchor); + Background: @Background; + OutlineColor: @OutlineColor; + OutlineSize: 1; + Padding: (Full: 20); +}; + +// Primary action button. Use Text: @Text parameter; native TextButton handles its label. +@KweebecPrimaryButton = TextButton { + @Text = ""; + @Anchor = Anchor(); + + Style: @KweebecPrimaryButtonStyle; + OutlineColor: @KweebecPrimaryOutline; + OutlineSize: 1; + Anchor: (...@Anchor, Height: 40); + Padding: (Horizontal: 24); + Text: @Text; +}; + +@KweebecSecondaryButton = TextButton { + @Text = ""; + @Anchor = Anchor(); + + Style: @KweebecSecondaryButtonStyle; + OutlineColor: @KweebecSecondaryOutline; + OutlineSize: 1; + Anchor: (...@Anchor, Height: 36); + Padding: (Horizontal: 24); + Text: @Text; +}; + +@KweebecDangerButton = TextButton { + @Text = ""; + @Anchor = Anchor(); + + Style: @KweebecDangerButtonStyle; + OutlineColor: @KweebecDangerOutline; + OutlineSize: 1; + Anchor: (...@Anchor, Height: 40); + Padding: (Horizontal: 24); + Text: @Text; +}; + +// Thin horizontal divider line. +@KweebecSeparator = Group { + @Anchor = Anchor(); + Anchor: (...@Anchor, Height: 1, Bottom: 14); + Background: @KweebecOutlineFrame; +}; + +// Label-left, value-right stat row. Override @Label / @Value / @ValueColor when instantiating. +@KweebecStatRow = Group { + @Label = ""; + @Value = ""; + @ValueColor = @KweebecTextPrimary; + @Anchor = Anchor(); + + LayoutMode: Left; + Anchor: (...@Anchor, Height: 24, Bottom: 6); + + Label #RowLabel { + FlexWeight: 1; + Style: @KweebecStatLabelStyle; + Text: @Label; + } + Label #RowValue { + Anchor: (Width: 130); + Style: (...@KweebecStatValueStyle, TextColor: @ValueColor); + Text: @Value; + } +}; + +// Standalone title label (centered, gold accent). +@KweebecTitle = Label { + @Text = ""; + @Anchor = Anchor(); + + Anchor: (...@Anchor, Height: 30, Bottom: 6); + Style: (FontSize: 22, TextColor: @KweebecTextPrimary, HorizontalAlignment: Center, RenderBold: true, LetterSpacing: 2); + Text: @Text; +}; + +// Italic subtitle / helper text. +@KweebecSubtitle = Label { + @Text = ""; + @Anchor = Anchor(); + + Anchor: (...@Anchor, Height: 22); + Style: @KweebecSubtitleStyle; + Text: @Text; +}; + +// Hyvatar render slot — pair with CoreUI's Avatars service. The inner #AvatarImage Group +// renders the avatar texture via its Background.TexturePath, which gets hot-swapped on +// the client when Avatars.install(...) pushes new bytes through the AssetInitialize flow. +// (HyUI works the same way: a Group with a Background TexturePath, NOT an AssetImage — +// AssetImage is for static asset references and doesn't reload when its bytes change.) +@KweebecHyvatar = Group { + @Anchor = Anchor(); + @IconSize = 160; + + LayoutMode: CenterMiddle; + Anchor: (...@Anchor); + Background: @KweebecBgInsetDark; + OutlineColor: @KweebecOutlineFrame; + OutlineSize: 1; + + Group { + Anchor: (Width: @IconSize, Height: @IconSize); + Background: (TexturePath: "../Pages/Elements/KweebecNetSlot1.png"); + } +}; diff --git a/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot1.png b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot1.png new file mode 100644 index 0000000..d402367 Binary files /dev/null and b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot1.png differ diff --git a/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot10.png b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot10.png new file mode 100644 index 0000000..9a10177 Binary files /dev/null and b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot10.png differ diff --git a/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot2.png b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot2.png new file mode 100644 index 0000000..9a10177 Binary files /dev/null and b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot2.png differ diff --git a/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot3.png b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot3.png new file mode 100644 index 0000000..9a10177 Binary files /dev/null and b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot3.png differ diff --git a/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot4.png b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot4.png new file mode 100644 index 0000000..9a10177 Binary files /dev/null and b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot4.png differ diff --git a/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot5.png b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot5.png new file mode 100644 index 0000000..9a10177 Binary files /dev/null and b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot5.png differ diff --git a/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot6.png b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot6.png new file mode 100644 index 0000000..9a10177 Binary files /dev/null and b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot6.png differ diff --git a/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot7.png b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot7.png new file mode 100644 index 0000000..9a10177 Binary files /dev/null and b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot7.png differ diff --git a/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot8.png b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot8.png new file mode 100644 index 0000000..9a10177 Binary files /dev/null and b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot8.png differ diff --git a/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot9.png b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot9.png new file mode 100644 index 0000000..9a10177 Binary files /dev/null and b/src/main/resources/Common/UI/Custom/Pages/Elements/KweebecNetSlot9.png differ diff --git a/src/main/resources/manifest.json b/src/main/resources/manifest.json new file mode 100644 index 0000000..83f575f --- /dev/null +++ b/src/main/resources/manifest.json @@ -0,0 +1,17 @@ +{ + "Group": "kewwbec", + "Name": "CoreUI", + "Version": "0.1.0", + "Description": "Shared UI templates + avatar/dynamic-image service for KweebecNet plugins", + "IncludesAssetPack": true, + "Authors": [ + {"Name": "kewwbec"} + ], + "ServerVersion": "*", + "Dependencies": { + "kewwbec:NetworkCore": "*" + }, + "OptionalDependencies": {}, + "DisabledByDefault": false, + "Main": "net.kewwbec.coreui.CoreUIPlugin" +}