init
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
After Width: | Height: | Size: 244 B |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 118 B |
|
After Width: | Height: | Size: 118 B |
@@ -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"
|
||||
}
|
||||