This commit is contained in:
2026-05-29 13:31:39 -04:00
commit 7f9f425de2
24 changed files with 1158 additions and 0 deletions
+30
View File
@@ -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
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>net.kewwbec</groupId>
<artifactId>coreui</artifactId>
<version>0.1.0</version>
<packaging>jar</packaging>
<name>CoreUI</name>
<description>Shared UI templates + avatar/dynamic-image service for KweebecNet plugins</description>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<hytale.server.jar>${user.home}/AppData/Roaming/Hytale/install/release/package/game/latest/Server/HytaleServer.jar</hytale.server.jar>
<networkcore.jar>${project.basedir}/../Core/target/NetworkCore-0.1.0.jar</networkcore.jar>
</properties>
<dependencies>
<dependency>
<groupId>com.hypixel.hytale</groupId>
<artifactId>hytale-server</artifactId>
<version>local</version>
<scope>system</scope>
<systemPath>${hytale.server.jar}</systemPath>
</dependency>
<dependency>
<groupId>net.kewwbec</groupId>
<artifactId>networkcore</artifactId>
<version>0.1.0</version>
<scope>system</scope>
<systemPath>${networkcore.jar}</systemPath>
</dependency>
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>3.0.2</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}-${project.version}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<minimizeJar>false</minimizeJar>
<artifactSet>
<excludes>
<exclude>com.hypixel.hytale:*</exclude>
<exclude>net.kewwbec:networkcore</exclude>
<exclude>com.google.code.findbugs:*</exclude>
</excludes>
</artifactSet>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>module-info.class</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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; }
}
@@ -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.
*
* <p>Flow:
* <pre>{@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()
* });
* }</pre>
*
* <p>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(...)}.</p>
*/
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.
*
* <p>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<AvatarHandle> 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.
*
* <p>Use this for join-time prefetches so the world thread never blocks on HTTP.</p>
*/
@Nonnull
java.util.concurrent.CompletableFuture<AvatarHandle> 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);
}
@@ -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.
*
* <p>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
* <strong>re-build and re-send the page</strong> on the next world tick after the rebuild
* request goes out.
*
* <p>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<PlayerRef, Deque<Asset>> PENDING_ASSETS = new ConcurrentHashMap<>();
private static final ConcurrentMap<PlayerRef, Boolean> 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<EntityStore> ref = playerRef.getReference();
if (ref == null || !ref.isValid()) return;
if (REBUILD_SCHEDULED.putIfAbsent(playerRef, Boolean.TRUE) != null) return;
Store<EntityStore> store = ref.getStore();
World world = store.getExternalData().getWorld();
world.execute(() -> {
try {
Set<Asset> 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<Asset> drainAssets(@Nonnull PlayerRef playerRef) {
Deque<Asset> stack = PENDING_ASSETS.get(playerRef);
if (stack == null) return Set.of();
Set<Asset> 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);
}
}
@@ -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();
}
}
@@ -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<UUID, PerPlayer> byPlayer = new HashMap<>();
@Override
@Nonnull
public CompletableFuture<AvatarHandle> 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<Map.Entry<String, Integer>> it = p.urlToSlot.entrySet().iterator(); it.hasNext(); ) {
Map.Entry<String, Integer> 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<AvatarHandle> 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<byte[]> 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<Map.Entry<String, Integer>> it = p.urlToSlot.entrySet().iterator();
if (!it.hasNext()) {
throw new IllegalStateException("Avatar slot pool exhausted for " + playerUuid + " (max " + AvatarSlots.SLOT_COUNT + ")");
}
Map.Entry<String, Integer> 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<String, Integer> urlToSlot = new java.util.LinkedHashMap<>();
}
}
@@ -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<byte[]> 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());
}
}
@@ -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");
}
}
@@ -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");
}
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

+17
View File
@@ -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"
}