Fabric 1.20.1: server features (commands, LuckPerms, Placeholder API, economy API, client link) and client mod (HUD, editor, shop and market windows); Paper client link; docs and examples

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 19:38:18 +00:00
1 parent 84c9d46892
commit 2bcdf26351
43 files changed
+2112 -42

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
plugins { id 'fabric-loom' version '1.8.13' }
// Client-only companion to the SCOPENET plugin / Fabric server mod. Fabric 1.20.1.
// It needs nothing from `common`: the messages are plain JSON inside a Minecraft UTF string.
java.sourceCompatibility = JavaVersion.VERSION_17
java.targetCompatibility = JavaVersion.VERSION_17
dependencies {
minecraft 'com.mojang:minecraft:1.20.1'
mappings loom.officialMojangMappings()
modImplementation 'net.fabricmc:fabric-loader:0.16.10'
modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1'
}
processResources {
inputs.property 'version', project.version
filesMatching('fabric.mod.json') { expand(version: project.version) }
}
base.archivesName = 'scopenet-client-fabric-1.20.1'
@@ -0,0 +1,105 @@
package net.scopenet.client;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.fabricmc.loader.api.FabricLoader;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;
/** config/scopenet-client.json: what to show and where. Edited in game (K, then Settings) or by hand. */
public final class ClientConfig {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Path FILE = FabricLoader.getInstance().getConfigDir().resolve("scopenet-client.json");
/** Where a widget sits. {@code x}/{@code y} are offsets from the chosen screen anchor. */
public static final class WidgetConfig {
public boolean enabled = true;
public String anchor = "top_left";
public int x = 8;
public int y = 8;
public boolean compact = false;
WidgetConfig(boolean enabled, String anchor, int x, int y) { this.enabled = enabled; this.anchor = anchor; this.x = x; this.y = y; }
public WidgetConfig() { }
}
public static final class Hud {
public float scale = 1.0f;
public float opacity = 0.85f;
public boolean hideInF3 = true;
public boolean hideWhileChatOpen = false;
public boolean claimBorders = true;
public boolean claimBordersOnlyWhenSneaking = false;
public Map<String, WidgetConfig> widgets = defaults();
}
public static final class Notifications {
public boolean levelUp = true;
public boolean achievements = true;
public boolean guild = true;
public boolean errors = true;
}
public boolean enabled = true;
public Hud hud = new Hud();
public Notifications notifications = new Notifications();
public static Map<String, WidgetConfig> defaults() {
Map<String, WidgetConfig> m = new LinkedHashMap<>();
m.put("level", new WidgetConfig(true, "top_left", 8, 8));
m.put("balance", new WidgetConfig(true, "top_right", -8, 8));
m.put("guild", new WidgetConfig(true, "bottom_left", 8, -40));
m.put("claim", new WidgetConfig(true, "top_center", 0, 8));
m.put("quests", new WidgetConfig(false, "middle_right", -8, 0));
return m;
}
public WidgetConfig widget(String id) { return hud.widgets.computeIfAbsent(id, k -> defaults().getOrDefault(k, new WidgetConfig())); }
public static ClientConfig load() {
ClientConfig config = new ClientConfig();
if (Files.exists(FILE)) {
try (Reader in = Files.newBufferedReader(FILE)) {
JsonObject root = JsonParser.parseReader(in).getAsJsonObject();
ClientConfig read = GSON.fromJson(root, ClientConfig.class);
if (read != null) config = read;
} catch (IOException | RuntimeException e) {
System.err.println("[SCOPENET] Could not read " + FILE + ", using defaults: " + e.getMessage());
}
}
// Keep every widget present, and values sane.
Map<String, WidgetConfig> merged = defaults();
if (config.hud == null) config.hud = new Hud();
if (config.hud.widgets != null) merged.putAll(config.hud.widgets);
config.hud.widgets = merged;
if (config.notifications == null) config.notifications = new Notifications();
config.hud.scale = Math.max(0.5f, Math.min(2.0f, config.hud.scale));
config.hud.opacity = Math.max(0.1f, Math.min(1.0f, config.hud.opacity));
return config;
}
public void save() {
try {
Path tmp = FILE.resolveSibling(FILE.getFileName() + ".tmp");
try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(this, w); }
Files.move(tmp, FILE, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
System.err.println("[SCOPENET] Could not save " + FILE + ": " + e.getMessage());
}
}
public void resetHud() {
hud.widgets = defaults();
hud.scale = 1.0f;
hud.opacity = 0.85f;
}
}
@@ -0,0 +1,124 @@
package net.scopenet.client;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.ArrayList;
import java.util.List;
/** What the server last told this client. Everything is optional: before the first message the HUD shows nothing. */
public final class ClientState {
public record Quest(int done, int total) {}
public record Guild(String name, String tag, String role, long claims) {}
public record Owner(String name, String tag, boolean mine) {}
public record Listing(long id, String seller, String guildTag, String itemId, String itemName, int amount, double price) {}
public record ShopItem(String id, String name, double price) {}
public record Held(String id, String name, int count, double unit) {}
public volatile boolean connected;
public volatile String server = "";
public volatile String currency = "$";
public volatile boolean featEconomy, featGuilds, featClaims, featEssentials;
public volatile boolean hasState;
public volatile String name = "";
public volatile long level = 1, xp, xpNow, xpNext, serverLevel = 1;
public volatile double xpPct, serverXpPct, balance;
public volatile String title = "", rank = "";
public volatile Guild guild;
public volatile Quest daily = new Quest(0, 0), weekly = new Quest(0, 0);
public volatile long playtime, kills, deaths;
public volatile String claimDim = "";
public volatile int claimCx, claimCz, claimRadius;
public volatile String claimCells = "";
public volatile Owner claimOwner;
public volatile List<Listing> market = List.of();
public volatile List<ShopItem> shop = List.of();
public volatile Held held;
public volatile long marketAt, shopAt;
/** Called on the client thread with a server message; returns a toast (kind, title, text) or null. */
public String[] handle(String json) {
JsonObject m;
try { m = JsonParser.parseString(json).getAsJsonObject(); } catch (RuntimeException e) { return null; }
switch (str(m, "t")) {
case "hello" -> {
connected = true;
server = str(m, "server");
currency = m.has("currency") ? str(m, "currency") : "$";
JsonObject f = obj(m, "features");
featEconomy = bool(f, "economy"); featGuilds = bool(f, "guilds"); featClaims = bool(f, "claims"); featEssentials = bool(f, "essentials");
}
case "state" -> {
name = str(m, "name");
level = lng(m, "level", 1); xp = lng(m, "xp", 0); xpNow = lng(m, "xpNow", 0); xpNext = lng(m, "xpNext", 0);
xpPct = num(m, "xpPct"); title = str(m, "title"); serverLevel = lng(m, "serverLevel", 1); serverXpPct = num(m, "serverXpPct");
balance = num(m, "balance"); playtime = lng(m, "playtime", 0); kills = lng(m, "kills", 0); deaths = lng(m, "deaths", 0);
rank = str(m, "rank");
JsonObject g = obj(m, "guild");
guild = g == null ? null : new Guild(str(g, "name"), str(g, "tag"), str(g, "role"), lng(g, "claims", 0));
daily = quest(obj(m, "daily"));
weekly = quest(obj(m, "weekly"));
hasState = true;
}
case "claims" -> {
claimDim = str(m, "dim"); claimCx = (int) lng(m, "cx", 0); claimCz = (int) lng(m, "cz", 0);
claimRadius = (int) lng(m, "r", 0); claimCells = str(m, "cells");
JsonObject o = obj(m, "owner");
claimOwner = o == null ? null : new Owner(str(o, "name"), str(o, "tag"), bool(o, "mine"));
}
case "market" -> {
List<Listing> list = new ArrayList<>();
for (JsonElement e : arr(m, "listings")) {
JsonObject l = e.getAsJsonObject();
list.add(new Listing(lng(l, "id", 0), str(l, "seller_name"), l.has("seller_guild") && l.get("seller_guild").isJsonPrimitive() ? str(l, "seller_guild") : null,
str(l, "item_id"), str(l, "item_name"), (int) lng(l, "amount", 1), num(l, "price")));
}
market = list;
if (m.has("balance")) balance = num(m, "balance");
marketAt = System.currentTimeMillis();
}
case "shop" -> {
List<ShopItem> list = new ArrayList<>();
for (JsonElement e : arr(m, "items")) {
JsonObject i = e.getAsJsonObject();
list.add(new ShopItem(str(i, "id"), str(i, "name"), num(i, "price")));
}
shop = list;
JsonObject h = obj(m, "held");
held = h == null ? null : new Held(str(h, "id"), str(h, "name"), (int) lng(h, "count", 0), h.has("unit") ? num(h, "unit") : -1);
shopAt = System.currentTimeMillis();
}
case "notify" -> { return new String[]{str(m, "kind"), str(m, "title"), str(m, "text")}; }
default -> { }
}
return null;
}
/** Sample values so the layout editor can show every widget even with no server data. */
public static ClientState preview() {
ClientState s = new ClientState();
s.connected = true; s.hasState = true; s.featEconomy = true; s.featGuilds = true; s.featClaims = true; s.featEssentials = true;
s.level = 27; s.xpPct = 62; s.title = "Miner"; s.serverLevel = 9; s.balance = 12840.5;
s.guild = new Guild("Iron Wolves", "IRON", "officer", 14);
s.daily = new Quest(2, 3); s.weekly = new Quest(1, 4);
s.claimCells = "x"; s.claimOwner = new Owner("Iron Wolves", "IRON", true);
return s;
}
public void reset() {
connected = false; hasState = false; guild = null; claimOwner = null; claimCells = ""; market = List.of(); shop = List.of(); held = null;
}
private static Quest quest(JsonObject o) { return o == null ? new Quest(0, 0) : new Quest((int) lng(o, "done", 0), (int) lng(o, "total", 0)); }
private static String str(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonPrimitive() ? e.getAsString() : ""; }
private static double num(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsDouble() : 0; }
private static long lng(JsonObject o, String k, long d) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsLong() : d; }
private static boolean bool(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonPrimitive() && e.getAsBoolean(); }
private static JsonObject obj(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonObject() ? e.getAsJsonObject() : null; }
private static JsonArray arr(JsonObject o, String k) { JsonElement e = o == null ? null : o.get(k); return e != null && e.isJsonArray() ? e.getAsJsonArray() : new JsonArray(); }
}
@@ -0,0 +1,59 @@
package net.scopenet.client;
import com.google.gson.JsonObject;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.minecraft.client.Minecraft;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.client.gui.components.toasts.SystemToast;
import net.minecraft.resources.ResourceLocation;
/** The channel to the SCOPENET server mod or plugin. Messages are JSON in a Minecraft UTF string. */
public final class Link {
private static final ResourceLocation TO_CLIENT = new ResourceLocation("scopenet", "s2c");
private static final ResourceLocation TO_SERVER = new ResourceLocation("scopenet", "c2s");
private final ClientState state;
private final ClientConfig config;
Link(ClientState state, ClientConfig config) { this.state = state; this.config = config; }
void register() {
ClientPlayNetworking.registerGlobalReceiver(TO_CLIENT, (client, handler, buf, sender) -> {
String json = buf.readUtf(32767 * 3);
client.execute(() -> toast(state.handle(json)));
});
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> send("hello"));
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> state.reset());
}
public void send(String type) {
JsonObject m = new JsonObject();
m.addProperty("t", type);
if (type.equals("hello")) m.addProperty("mod", "0.1.0");
FriendlyByteBuf buf = PacketByteBufs.create();
buf.writeUtf(m.toString(), 32767 * 3);
try { ClientPlayNetworking.send(TO_SERVER, buf); } catch (RuntimeException ignored) { /* not in a world */ }
}
/** Run a server command as the player, exactly as if typed. */
public static void command(String command) {
Minecraft mc = Minecraft.getInstance();
if (mc.player != null) mc.player.connection.sendCommand(command.startsWith("/") ? command.substring(1) : command);
}
private void toast(String[] t) {
if (t == null) return;
boolean wanted = switch (t[0]) {
case "level_up" -> config.notifications.levelUp;
case "achievement" -> config.notifications.achievements;
case "guild_join", "guild_leave" -> config.notifications.guild;
case "error" -> config.notifications.errors;
default -> true;
};
if (!wanted || !config.enabled) return;
SystemToast.add(Minecraft.getInstance().getToasts(), SystemToast.SystemToastIds.PERIODIC_NOTIFICATION, Component.literal(t[1]), Component.literal(t[2]));
}
}
@@ -0,0 +1,47 @@
package net.scopenet.client;
import com.mojang.blaze3d.platform.InputConstants;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback;
import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderEvents;
import net.minecraft.client.KeyMapping;
import net.scopenet.client.hud.ClaimBorders;
import net.scopenet.client.hud.Hud;
import net.scopenet.client.screen.MenuScreen;
import org.lwjgl.glfw.GLFW;
/**
* SCOPENET client: HUD widgets, claim borders, and the shop and market windows. Everything it shows comes from the
* server over the {@code scopenet:s2c} channel, and everything it does is an ordinary command, so the server stays
* in charge. If the server has no SCOPENET, this does nothing.
*/
public final class ScopenetClient implements ClientModInitializer {
private static ClientConfig config;
private static ClientState state;
private static Link link;
public static ClientConfig config() { return config; }
public static ClientState state() { return state; }
public static Link link() { return link; }
@Override public void onInitializeClient() {
config = ClientConfig.load();
state = new ClientState();
link = new Link(state, config);
link.register();
Hud hud = new Hud(state, config);
HudRenderCallback.EVENT.register((graphics, tickDelta) -> hud.render(graphics, tickDelta));
ClaimBorders borders = new ClaimBorders(state, config);
WorldRenderEvents.LAST.register(borders::render);
KeyMapping menu = KeyBindingHelper.registerKeyBinding(new KeyMapping("key.scopenet.menu", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_K, "key.categories.scopenet"));
KeyMapping toggle = KeyBindingHelper.registerKeyBinding(new KeyMapping("key.scopenet.toggle_hud", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_UNKNOWN, "key.categories.scopenet"));
ClientTickEvents.END_CLIENT_TICK.register(client -> {
while (menu.consumeClick()) if (client.screen == null && client.player != null) client.setScreen(new MenuScreen());
while (toggle.consumeClick()) { config.enabled = !config.enabled; config.save(); }
});
}
}
@@ -0,0 +1,58 @@
package net.scopenet.client;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import java.util.Locale;
/** Shared look: the SCOPENET palette and a few drawing helpers, so every widget and window feels like one app. */
public final class Ui {
public static final int ACCENT = 0xFF8B6CFF, CYAN = 0xFF22D3EE, GOLD = 0xFFFBBF24, GREEN = 0xFF34D399, RED = 0xFFFB7185;
public static final int TEXT = 0xFFE6E8EE, MUTED = 0xFF9AA0AE, PANEL = 0x0E1014, PANEL_2 = 0x1A1D25, LINE = 0xFF2B2F3A;
private Ui() {}
public static int alpha(int rgb, float opacity) {
return (Math.max(0, Math.min(255, (int) (opacity * 255))) << 24) | (rgb & 0xFFFFFF);
}
public static String money(String symbol, double value) { return symbol + String.format(Locale.US, "%,.2f", value); }
/** A card: dark rounded-looking panel with a coloured edge on the left. */
public static void card(GuiGraphics g, int x, int y, int w, int h, int accent, float opacity) {
g.fill(x + 1, y, x + w - 1, y + h, alpha(PANEL, opacity));
g.fill(x, y + 1, x + w, y + h - 1, alpha(PANEL, opacity));
g.fill(x, y + 1, x + 2, y + h - 1, accent);
}
/** A flat panel with a 1px border, for windows. */
public static void window(GuiGraphics g, int x, int y, int w, int h) {
g.fill(x - 1, y - 1, x + w + 1, y + h + 1, LINE);
g.fill(x, y, x + w, y + h, 0xF0101319);
}
public static void bar(GuiGraphics g, int x, int y, int w, int h, double pct, int colour, float opacity) {
g.fill(x, y, x + w, y + h, alpha(0x2B2F3A, Math.max(opacity, 0.6f)));
int filled = (int) Math.round(w * Math.max(0, Math.min(100, pct)) / 100.0);
if (filled > 0) g.fill(x, y, x + filled, y + h, colour);
}
/** The item for a panel id ("DIAMOND" or "modid:item"); barrier if the client doesn't know it. */
public static net.minecraft.world.item.ItemStack stackFor(String panelId, int count) {
String registry = panelId.contains(":") ? panelId : "minecraft:" + panelId.toLowerCase(Locale.ROOT);
net.minecraft.resources.ResourceLocation id = net.minecraft.resources.ResourceLocation.tryParse(registry);
net.minecraft.world.item.Item item = id == null ? net.minecraft.world.item.Items.AIR : net.minecraft.core.registries.BuiltInRegistries.ITEM.get(id);
if (item == net.minecraft.world.item.Items.AIR) item = net.minecraft.world.item.Items.BARRIER;
return new net.minecraft.world.item.ItemStack(item, Math.max(1, count));
}
public static boolean inside(double mx, double my, int x, int y, int w, int h) { return mx >= x && mx < x + w && my >= y && my < y + h; }
public static String clip(Font font, String text, int maxWidth) {
if (font.width(text) <= maxWidth) return text;
String ellipsis = "…";
int end = text.length();
while (end > 0 && font.width(text.substring(0, end) + ellipsis) > maxWidth) end--;
return text.substring(0, end) + ellipsis;
}
}
@@ -0,0 +1,49 @@
package net.scopenet.client.hud;
import net.fabricmc.fabric.api.client.rendering.v1.WorldRenderContext;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.LevelRenderer;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.world.phys.Vec3;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ClientState;
/** Draws the borders of claimed chunks around you in the world: green for your guild's land, red for anyone else's. */
public final class ClaimBorders {
private final ClientState state;
private final ClientConfig config;
public ClaimBorders(ClientState state, ClientConfig config) { this.state = state; this.config = config; }
public void render(WorldRenderContext context) {
Minecraft mc = Minecraft.getInstance();
if (!config.enabled || !config.hud.claimBorders || !state.connected || !state.featClaims || state.claimCells.isEmpty() || mc.player == null) return;
if (config.hud.claimBordersOnlyWhenSneaking && !mc.player.isShiftKeyDown()) return;
if (context.consumers() == null) return;
// Only the world the server sent the map for.
String dim = mc.level == null ? "" : mc.level.dimension().location().toString();
if (!dim.equals(state.claimDim)) return;
int r = state.claimRadius, side = r * 2 + 1;
String cells = state.claimCells;
if (cells.length() != side * side) return;
Vec3 cam = context.camera().getPosition();
PoseStack pose = context.matrixStack();
VertexConsumer lines = context.consumers().getBuffer(RenderType.lines());
double y0 = Math.floor(mc.player.getY()) - 6, y1 = y0 + 14;
pose.pushPose();
pose.translate(-cam.x, -cam.y, -cam.z);
for (int dz = -r; dz <= r; dz++) {
for (int dx = -r; dx <= r; dx++) {
char cell = cells.charAt((dz + r) * side + (dx + r));
if (cell == '0') continue;
float cr = cell == '1' ? 0.2f : 0.98f, cg = cell == '1' ? 0.85f : 0.35f, cb = cell == '1' ? 0.55f : 0.45f;
double x0 = (state.claimCx + dx) * 16.0, z0 = (state.claimCz + dz) * 16.0;
LevelRenderer.renderLineBox(pose, lines, x0 + 0.02, y0, z0 + 0.02, x0 + 15.98, y1, z0 + 15.98, cr, cg, cb, 0.9f);
}
}
pose.popPose();
}
}
@@ -0,0 +1,60 @@
package net.scopenet.client.hud;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.ChatScreen;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ClientConfig.WidgetConfig;
import net.scopenet.client.ClientState;
/** Lays out and draws the widgets. The editor uses the same maths, so what you place is what you get. */
public final class Hud {
public static final String[] ANCHORS = {"top_left", "top_center", "top_right", "middle_left", "middle_center", "middle_right", "bottom_left", "bottom_center", "bottom_right"};
private final ClientState state;
private final ClientConfig config;
public Hud(ClientState state, ClientConfig config) { this.state = state; this.config = config; }
/** Top-left corner for a widget of size (w, h) on a screen of (sw, sh) (in HUD units, i.e. already divided by the scale). */
public static int[] place(WidgetConfig c, int sw, int sh, int w, int h) {
String a = c.anchor == null ? "top_left" : c.anchor;
int x = a.endsWith("right") ? sw - w : a.endsWith("center") ? (sw - w) / 2 : 0;
int y = a.startsWith("bottom") ? sh - h : a.startsWith("middle") ? (sh - h) / 2 : 0;
return new int[]{x + c.x, y + c.y};
}
/** The anchor nearest to a spot, and the offsets from it, so dragged widgets keep their place on any window size. */
public static void anchorTo(WidgetConfig c, int sw, int sh, int w, int h, int left, int top) {
double cx = left + w / 2.0, cy = top + h / 2.0;
String col = cx < sw / 3.0 ? "left" : cx > sw * 2 / 3.0 ? "right" : "center";
String row = cy < sh / 3.0 ? "top" : cy > sh * 2 / 3.0 ? "bottom" : "middle";
String anchor = row + "_" + col;
c.anchor = anchor;
int bx = anchor.endsWith("right") ? sw - w : anchor.endsWith("center") ? (sw - w) / 2 : 0;
int by = anchor.startsWith("bottom") ? sh - h : anchor.startsWith("middle") ? (sh - h) / 2 : 0;
c.x = left - bx;
c.y = top - by;
}
public void render(GuiGraphics g, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!config.enabled || !state.connected || mc.options.hideGui || mc.player == null) return;
if (config.hud.hideInF3 && mc.options.renderDebug) return;
if (config.hud.hideWhileChatOpen && mc.screen instanceof ChatScreen) return;
Font font = mc.font;
float scale = config.hud.scale;
int sw = (int) (g.guiWidth() / scale), sh = (int) (g.guiHeight() / scale);
g.pose().pushPose();
g.pose().scale(scale, scale, 1f);
for (HudWidget widget : Widgets.ALL) {
WidgetConfig c = config.widget(widget.id);
if (!c.enabled || !widget.available(state)) continue;
int w = widget.width(font, state, c), h = widget.height(font, state, c);
int[] at = place(c, sw, sh, w, h);
widget.draw(g, font, state, c, at[0], at[1], config.hud.opacity);
}
g.pose().popPose();
}
}
@@ -0,0 +1,24 @@
package net.scopenet.client.hud;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ClientState;
/** One draggable piece of the HUD. */
public abstract class HudWidget {
public final String id;
public final String label;
protected HudWidget(String id, String label) { this.id = id; this.label = label; }
/** False when the server says this system is off, or there's nothing to show. The editor ignores this. */
public abstract boolean available(ClientState s);
public abstract int width(Font font, ClientState s, ClientConfig.WidgetConfig c);
public abstract int height(Font font, ClientState s, ClientConfig.WidgetConfig c);
/** Draw with the top-left corner at (x, y). */
public abstract void draw(GuiGraphics g, Font font, ClientState s, ClientConfig.WidgetConfig c, int x, int y, float opacity);
}
@@ -0,0 +1,109 @@
package net.scopenet.client.hud;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.scopenet.client.ClientConfig.WidgetConfig;
import net.scopenet.client.ClientState;
import net.scopenet.client.Ui;
import java.util.List;
/** The built-in HUD widgets: level, balance, guild, claim and quests. */
public final class Widgets {
private Widgets() {}
public static final List<HudWidget> ALL = List.of(new Level(), new Balance(), new Guild(), new Claim(), new Quests());
static final class Level extends HudWidget {
Level() { super("level", "Level"); }
@Override public boolean available(ClientState s) { return s.hasState; }
@Override public int width(Font f, ClientState s, WidgetConfig c) { return c.compact ? 64 : 118; }
@Override public int height(Font f, ClientState s, WidgetConfig c) { return c.compact ? 22 : 34; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
int w = width(f, s, c), h = height(f, s, c);
Ui.card(g, x, y, w, h, Ui.ACCENT, o);
g.drawString(f, "Lv " + s.level, x + 7, y + 5, Ui.TEXT, true);
if (!c.compact) {
String title = s.title.isEmpty() ? "" : Ui.clip(f, s.title, w - 52);
g.drawString(f, title, x + w - 6 - f.width(title), y + 5, Ui.MUTED, true);
Ui.bar(g, x + 7, y + 18, w - 14, 5, s.xpPct, Ui.ACCENT, o);
String text = Math.round(s.xpPct) + "% · SMP Lv " + s.serverLevel;
g.drawString(f, text, x + 7, y + 25, Ui.MUTED, false);
} else {
Ui.bar(g, x + 7, y + 15, w - 14, 4, s.xpPct, Ui.ACCENT, o);
}
}
}
static final class Balance extends HudWidget {
Balance() { super("balance", "Balance"); }
@Override public boolean available(ClientState s) { return s.hasState && s.featEconomy; }
private String text(ClientState s) { return Ui.money(s.currency, s.balance); }
@Override public int width(Font f, ClientState s, WidgetConfig c) { return Math.max(c.compact ? 50 : 76, f.width(text(s)) + 18); }
@Override public int height(Font font, ClientState s, WidgetConfig c) { return c.compact ? 18 : 28; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
int w = width(f, s, c), h = height(f, s, c);
Ui.card(g, x, y, w, h, Ui.GOLD, o);
if (!c.compact) {
g.drawString(f, "BALANCE", x + 7, y + 4, Ui.MUTED, false);
g.drawString(f, text(s), x + 7, y + 15, Ui.GOLD, true);
} else {
g.drawString(f, text(s), x + 7, y + 5, Ui.GOLD, true);
}
}
}
static final class Guild extends HudWidget {
Guild() { super("guild", "Guild"); }
@Override public boolean available(ClientState s) { return s.hasState && s.featGuilds && s.guild != null; }
@Override public int width(Font f, ClientState s, WidgetConfig c) { return c.compact ? 86 : 124; }
@Override public int height(Font f, ClientState s, WidgetConfig c) { return c.compact ? 18 : 30; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
ClientState.Guild guild = s.guild;
int w = width(f, s, c), h = height(f, s, c);
Ui.card(g, x, y, w, h, Ui.CYAN, o);
String head = "[" + guild.tag() + "] " + guild.name();
g.drawString(f, Ui.clip(f, head, w - 14), x + 7, y + 4, Ui.TEXT, true);
if (!c.compact) g.drawString(f, guild.role() + " · " + guild.claims() + " chunks", x + 7, y + 17, Ui.MUTED, false);
}
}
static final class Claim extends HudWidget {
Claim() { super("claim", "Claim"); }
@Override public boolean available(ClientState s) { return s.featClaims && !s.claimCells.isEmpty(); }
private String text(ClientState s) {
ClientState.Owner o = s.claimOwner;
if (o == null) return "Wilderness";
String name = o.tag().isEmpty() ? o.name() : "[" + o.tag() + "] " + o.name();
return (o.mine() ? "Your land: " : "Claimed: ") + name;
}
@Override public int width(Font f, ClientState s, WidgetConfig c) { return f.width(text(s)) + 18; }
@Override public int height(Font f, ClientState s, WidgetConfig c) { return 18; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
int w = width(f, s, c);
ClientState.Owner owner = s.claimOwner;
int colour = owner == null ? Ui.MUTED : owner.mine() ? Ui.GREEN : Ui.RED;
Ui.card(g, x, y, w, 18, colour, o);
g.drawString(f, text(s), x + 9, y + 5, colour == Ui.MUTED ? Ui.TEXT : colour, true);
}
}
static final class Quests extends HudWidget {
Quests() { super("quests", "Quests"); }
@Override public boolean available(ClientState s) { return s.hasState && (s.daily.total() + s.weekly.total()) > 0; }
@Override public int width(Font f, ClientState s, WidgetConfig c) { return 96; }
@Override public int height(Font f, ClientState s, WidgetConfig c) { return 38; }
@Override public void draw(GuiGraphics g, Font f, ClientState s, WidgetConfig c, int x, int y, float o) {
int w = width(f, s, c);
Ui.card(g, x, y, w, 38, Ui.GREEN, o);
row(g, f, "Daily", s.daily, x + 7, y + 4, w - 14);
row(g, f, "Weekly", s.weekly, x + 7, y + 21, w - 14);
}
private void row(GuiGraphics g, Font f, String label, ClientState.Quest q, int x, int y, int w) {
g.drawString(f, label, x, y, Ui.MUTED, false);
String n = q.done() + "/" + q.total();
g.drawString(f, n, x + w - f.width(n), y, Ui.TEXT, true);
Ui.bar(g, x, y + 10, w, 3, q.total() == 0 ? 0 : 100.0 * q.done() / q.total(), Ui.GREEN, 1f);
}
}
}
@@ -0,0 +1,120 @@
package net.scopenet.client.screen;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ClientConfig.WidgetConfig;
import net.scopenet.client.ClientState;
import net.scopenet.client.ScopenetClient;
import net.scopenet.client.Ui;
import net.scopenet.client.hud.Hud;
import net.scopenet.client.hud.HudWidget;
import net.scopenet.client.hud.Widgets;
/**
* Drag the widgets where you want them. Right-click a widget to hide or show it, scroll to resize everything.
* Positions snap to the nearest screen anchor, so the layout holds on any window size.
*/
public final class HudEditorScreen extends Screen {
private final Screen parent;
private final ClientConfig config = ScopenetClient.config();
private final ClientState preview = ClientState.preview();
private HudWidget dragging;
private double grabDx, grabDy;
public HudEditorScreen(Screen parent) { super(Component.literal("HUD layout")); this.parent = parent; }
private float scale() { return config.hud.scale; }
private int sw() { return (int) (width / scale()); }
private int sh() { return (int) (height / scale()); }
private int[] size(HudWidget w) {
WidgetConfig c = config.widget(w.id);
return new int[]{w.width(font, preview, c), w.height(font, preview, c)};
}
private int[] origin(HudWidget w) {
int[] s = size(w);
return Hud.place(config.widget(w.id), sw(), sh(), s[0], s[1]);
}
@Override protected void init() {
addRenderableWidget(Button.builder(Component.literal("Reset"), b -> { config.resetHud(); }).bounds(width / 2 - 104, height - 28, 100, 20).build());
addRenderableWidget(Button.builder(Component.literal("Done"), b -> onClose()).bounds(width / 2 + 4, height - 28, 100, 20).build());
}
@Override public void render(GuiGraphics g, int mouseX, int mouseY, float delta) {
g.fill(0, 0, width, height, 0x99000000);
// Faint guides at the thirds, showing where anchors switch.
for (int i = 1; i <= 2; i++) {
g.fill(width * i / 3, 0, width * i / 3 + 1, height, 0x22FFFFFF);
g.fill(0, height * i / 3, width, height * i / 3 + 1, 0x22FFFFFF);
}
g.drawCenteredString(font, "Drag to move · Right-click to show or hide · Scroll to resize", width / 2, 8, Ui.TEXT);
g.pose().pushPose();
g.pose().scale(scale(), scale(), 1f);
for (HudWidget w : Widgets.ALL) {
WidgetConfig c = config.widget(w.id);
int[] s = size(w), at = origin(w);
float opacity = c.enabled ? config.hud.opacity : 0.25f;
w.draw(g, font, preview, c, at[0], at[1], opacity);
boolean hover = Ui.inside(mouseX / scale(), mouseY / scale(), at[0], at[1], s[0], s[1]);
int outline = w == dragging ? Ui.ACCENT : hover ? Ui.TEXT : Ui.alpha(0xFFFFFF, 0.3f);
g.renderOutline(at[0] - 1, at[1] - 1, s[0] + 2, s[1] + 2, outline);
if (!c.enabled) g.drawString(font, "hidden", at[0] + 4, at[1] + s[1] + 2, Ui.RED, false);
}
g.pose().popPose();
super.render(g, mouseX, mouseY, delta);
}
private HudWidget at(double mx, double my) {
HudWidget found = null;
for (HudWidget w : Widgets.ALL) {
int[] s = size(w), o = origin(w);
if (Ui.inside(mx / scale(), my / scale(), o[0], o[1], s[0], s[1])) found = w; // later ones draw on top
}
return found;
}
@Override public boolean mouseClicked(double mx, double my, int button) {
if (super.mouseClicked(mx, my, button)) return true;
HudWidget w = at(mx, my);
if (w == null) return false;
if (button == 1) { WidgetConfig c = config.widget(w.id); c.enabled = !c.enabled; return true; }
if (button == 0) {
int[] o = origin(w);
dragging = w;
grabDx = mx / scale() - o[0];
grabDy = my / scale() - o[1];
return true;
}
return false;
}
@Override public boolean mouseDragged(double mx, double my, int button, double dx, double dy) {
if (dragging == null || button != 0) return super.mouseDragged(mx, my, button, dx, dy);
int[] s = size(dragging);
int left = (int) Math.round(mx / scale() - grabDx), top = (int) Math.round(my / scale() - grabDy);
left = Math.max(0, Math.min(sw() - s[0], left));
top = Math.max(0, Math.min(sh() - s[1], top));
Hud.anchorTo(config.widget(dragging.id), sw(), sh(), s[0], s[1], left, top);
return true;
}
@Override public boolean mouseReleased(double mx, double my, int button) {
dragging = null;
return super.mouseReleased(mx, my, button);
}
@Override public boolean mouseScrolled(double mx, double my, double delta) {
config.hud.scale = Math.max(0.5f, Math.min(2.0f, config.hud.scale + (float) delta * 0.05f));
return true;
}
@Override public void onClose() {
config.save();
minecraft.setScreen(parent);
}
}
@@ -0,0 +1,118 @@
package net.scopenet.client.screen;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.scopenet.client.ClientState;
import net.scopenet.client.Link;
import net.scopenet.client.ScopenetClient;
import net.scopenet.client.Ui;
import java.util.List;
/** The player market: browse what people are selling, buy with one click, and list the item in your hand. */
public final class MarketScreen extends Screen {
private static final int W = 300, ROW = 30, LIST_TOP = 44;
private final ClientState state = ScopenetClient.state();
private int scroll;
private int age;
private int refreshIn = -1;
private EditBox price;
private Button list;
private String notice = "";
public MarketScreen() { super(Component.literal("Market")); }
private int left() { return (width - W) / 2; }
private int top() { return 12; }
private int listHeight() { return Math.max(ROW * 3, height - 24 - LIST_TOP - 64); }
@Override protected void init() {
ScopenetClient.link().send("market");
int y = top() + LIST_TOP + listHeight() + 12;
price = new EditBox(font, left() + 10, y, 70, 18, Component.literal("Price"));
price.setMaxLength(12);
price.setHint(Component.literal("Price"));
price.setFilter(s -> s.matches("[0-9]{0,9}(\\.[0-9]{0,2})?"));
addRenderableWidget(price);
list = Button.builder(Component.literal("List held item"), b -> listHeld()).bounds(left() + 86, y - 1, 100, 20).build();
addRenderableWidget(list);
addRenderableWidget(Button.builder(Component.literal("Refresh"), b -> { ScopenetClient.link().send("market"); notice = ""; }).bounds(left() + W - 70, y - 1, 60, 20).build());
addRenderableWidget(Button.builder(Component.literal("Back"), b -> minecraft.setScreen(new MenuScreen())).bounds(left() + 10, y + 26, 60, 18).build());
}
private void listHeld() {
String text = price.getValue();
if (text.isEmpty() || text.equals(".")) { notice = "Type a price first."; return; }
Link.command("market sell " + text);
price.setValue("");
notice = "Listing sent…";
refreshIn = 30;
}
@Override public void tick() {
if (++age >= 200) { age = 0; ScopenetClient.link().send("market"); }
if (refreshIn >= 0 && --refreshIn < 0) ScopenetClient.link().send("market");
}
@Override public boolean mouseScrolled(double mx, double my, double delta) {
int max = Math.max(0, state.market.size() * ROW - listHeight());
scroll = (int) Math.max(0, Math.min(max, scroll - delta * ROW / 2));
return true;
}
@Override public boolean mouseClicked(double mx, double my, int button) {
int left = left(), top = top(), lh = listHeight();
if (button == 0 && Ui.inside(mx, my, left, top + LIST_TOP, W, lh)) {
List<ClientState.Listing> items = state.market;
for (int i = 0; i < items.size(); i++) {
int y = top + LIST_TOP + i * ROW - scroll;
if (Ui.inside(mx, my, left + W - 60, y + 4, 48, ROW - 10)) {
ClientState.Listing l = items.get(i);
Link.command("market buy " + l.id());
notice = "Buying #" + l.id() + "…";
refreshIn = 30;
return true;
}
}
}
return super.mouseClicked(mx, my, button);
}
@Override public void render(GuiGraphics g, int mouseX, int mouseY, float delta) {
renderBackground(g);
int left = left(), top = top(), lh = listHeight();
Ui.window(g, left, top, W, LIST_TOP + lh + 76);
g.fill(left, top, left + W, top + 3, Ui.CYAN);
g.drawString(font, "Player Market", left + 10, top + 10, Ui.CYAN, true);
String bal = "Balance " + Ui.money(state.currency, state.balance);
g.drawString(font, bal, left + W - 10 - font.width(bal), top + 10, Ui.GOLD, true);
g.drawString(font, state.market.size() + " listing" + (state.market.size() == 1 ? "" : "s"), left + 10, top + 26, Ui.MUTED, false);
List<ClientState.Listing> items = state.market;
g.enableScissor(left + 1, top + LIST_TOP, left + W - 1, top + LIST_TOP + lh);
for (int i = 0; i < items.size(); i++) {
int y = top + LIST_TOP + i * ROW - scroll;
if (y + ROW < top + LIST_TOP || y > top + LIST_TOP + lh) continue;
ClientState.Listing l = items.get(i);
boolean affordable = state.balance >= l.price() || l.guildTag() != null;
g.fill(left + 6, y, left + W - 6, y + ROW - 2, Ui.alpha(Ui.PANEL_2, 0.9f));
g.renderItem(Ui.stackFor(l.itemId(), l.amount()), left + 10, y + 5);
g.renderItemDecorations(font, Ui.stackFor(l.itemId(), l.amount()), left + 10, y + 5);
g.drawString(font, Ui.clip(font, l.itemName() + " x" + l.amount(), 150), left + 32, y + 4, Ui.TEXT, true);
String seller = l.guildTag() != null ? "[" + l.guildTag() + "] guild" : l.seller();
g.drawString(font, Ui.clip(font, seller + " · #" + l.id(), 150), left + 32, y + 15, Ui.MUTED, false);
String p = Ui.money(state.currency, l.price());
g.drawString(font, p, left + W - 68 - font.width(p), y + 9, Ui.GREEN, true);
boolean hover = Ui.inside(mouseX, mouseY, left + W - 60, y + 4, 48, ROW - 10);
g.fill(left + W - 60, y + 4, left + W - 12, y + ROW - 6, affordable ? (hover ? Ui.alpha(Ui.ACCENT, 1f) : Ui.alpha(Ui.ACCENT, 0.75f)) : Ui.alpha(Ui.RED, 0.35f));
g.drawCenteredString(font, "Buy", left + W - 36, y + 10, Ui.TEXT);
}
g.disableScissor();
if (items.isEmpty()) g.drawCenteredString(font, state.marketAt == 0 ? "Loading listings…" : "No listings. Be the first to sell something!", left + W / 2, top + LIST_TOP + 24, Ui.MUTED);
if (!notice.isEmpty()) g.drawString(font, notice, left + 10, top + LIST_TOP + lh + 2, Ui.MUTED, false);
super.render(g, mouseX, mouseY, delta);
}
}
@@ -0,0 +1,55 @@
package net.scopenet.client.screen;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.scopenet.client.ClientState;
import net.scopenet.client.ScopenetClient;
import net.scopenet.client.Ui;
/** The hub (press K): who you are on this server, and the way into the shop, market and settings. */
public final class MenuScreen extends Screen {
private static final int W = 236, H = 176;
private final ClientState state = ScopenetClient.state();
public MenuScreen() { super(Component.literal("SCOPENET")); }
@Override protected void init() {
int left = (width - W) / 2, top = (height - H) / 2;
int bw = 104, gap = 8, x1 = left + 10, x2 = left + W - 10 - bw;
boolean eco = state.connected && state.featEconomy;
Button shop = Button.builder(Component.literal("Shop"), b -> minecraft.setScreen(new ShopScreen())).bounds(x1, top + 86, bw, 20).build();
Button market = Button.builder(Component.literal("Market"), b -> minecraft.setScreen(new MarketScreen())).bounds(x2, top + 86, bw, 20).build();
shop.active = eco;
market.active = eco;
addRenderableWidget(shop);
addRenderableWidget(market);
addRenderableWidget(Button.builder(Component.literal("HUD layout"), b -> minecraft.setScreen(new HudEditorScreen(this))).bounds(x1, top + 86 + 20 + gap, bw, 20).build());
addRenderableWidget(Button.builder(Component.literal("Settings"), b -> minecraft.setScreen(new SettingsScreen(this))).bounds(x2, top + 86 + 20 + gap, bw, 20).build());
addRenderableWidget(Button.builder(Component.literal("Close"), b -> onClose()).bounds(left + (W - 80) / 2, top + H - 26, 80, 18).build());
}
@Override public void render(GuiGraphics g, int mouseX, int mouseY, float delta) {
renderBackground(g);
int left = (width - W) / 2, top = (height - H) / 2;
Ui.window(g, left, top, W, H);
g.fill(left, top, left + W, top + 3, Ui.ACCENT);
g.drawString(font, "SCOPENET", left + 10, top + 10, Ui.ACCENT, true);
if (!state.connected) {
g.drawCenteredString(font, "This server isn't running SCOPENET", left + W / 2, top + 50, Ui.TEXT);
g.drawCenteredString(font, "or its client link is turned off.", left + W / 2, top + 62, Ui.MUTED);
} else {
g.drawString(font, state.server.isEmpty() ? "Connected" : state.server, left + W - 10 - font.width(state.server), top + 10, Ui.MUTED, false);
g.drawString(font, state.name + (state.title.isEmpty() ? "" : " · " + state.title), left + 10, top + 28, Ui.TEXT, true);
g.drawString(font, "Level " + state.level, left + 10, top + 42, Ui.MUTED, false);
Ui.bar(g, left + 10, top + 54, W - 20, 5, state.xpPct, Ui.ACCENT, 1f);
if (state.featEconomy) g.drawString(font, Ui.money(state.currency, state.balance), left + 10, top + 66, Ui.GOLD, true);
if (state.guild != null) {
String guild = "[" + state.guild.tag() + "] " + state.guild.name();
g.drawString(font, guild, left + W - 10 - font.width(guild), top + 66, Ui.CYAN, true);
}
}
super.render(g, mouseX, mouseY, delta);
}
}
@@ -0,0 +1,110 @@
package net.scopenet.client.screen;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.AbstractSliderButton;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.scopenet.client.ClientConfig;
import net.scopenet.client.ScopenetClient;
import net.scopenet.client.Ui;
import net.scopenet.client.hud.Widgets;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
/** Every client option in one place. Changes apply immediately and are saved when you leave. */
public final class SettingsScreen extends Screen {
private final Screen parent;
private final ClientConfig config = ScopenetClient.config();
public SettingsScreen(Screen parent) { super(Component.literal("SCOPENET settings")); this.parent = parent; }
private static Component onOff(boolean on) { return Component.literal(on ? "§aOn" : "§cOff"); }
private Button toggle(int x, int y, int w, String label, BooleanSupplier get, Consumer<Boolean> set) {
return Button.builder(Component.literal(label + ": ").append(onOff(get.getAsBoolean())), b -> {
set.accept(!get.getAsBoolean());
b.setMessage(Component.literal(label + ": ").append(onOff(get.getAsBoolean())));
}).bounds(x, y, w, 20).build();
}
private static final class Slider extends AbstractSliderButton {
private final String label;
private final double min, max;
private final Consumer<Double> apply;
private final boolean percent;
Slider(int x, int y, int w, String label, double min, double max, double value, boolean percent, Consumer<Double> apply) {
super(x, y, w, 20, Component.empty(), (value - min) / (max - min));
this.label = label; this.min = min; this.max = max; this.apply = apply; this.percent = percent;
updateMessage();
}
private double current() { return min + (max - min) * this.value; }
@Override protected void updateMessage() {
setMessage(Component.literal(label + ": " + (percent ? Math.round(current() * 100) + "%" : String.format("%.2f", current()))));
}
@Override protected void applyValue() { apply.accept(current()); }
}
@Override protected void init() {
int colW = 150, gap = 8;
int x1 = width / 2 - colW - gap / 2, x2 = width / 2 + gap / 2;
int y = 34, step = 22;
addRenderableWidget(toggle(x1, y, colW, "HUD", () -> config.enabled, v -> config.enabled = v));
addRenderableWidget(toggle(x2, y, colW, "Hide with F3", () -> config.hud.hideInF3, v -> config.hud.hideInF3 = v));
y += step;
addRenderableWidget(new Slider(x1, y, colW, "Opacity", 0.1, 1.0, config.hud.opacity, true, v -> config.hud.opacity = v.floatValue()));
addRenderableWidget(new Slider(x2, y, colW, "Size", 0.5, 2.0, config.hud.scale, true, v -> config.hud.scale = v.floatValue()));
y += step;
addRenderableWidget(toggle(x1, y, colW, "Claim borders", () -> config.hud.claimBorders, v -> config.hud.claimBorders = v));
addRenderableWidget(toggle(x2, y, colW, "Only when sneaking", () -> config.hud.claimBordersOnlyWhenSneaking, v -> config.hud.claimBordersOnlyWhenSneaking = v));
y += step + 8;
// One button per widget: On -> Compact -> Off.
int i = 0;
for (var widget : Widgets.ALL) {
ClientConfig.WidgetConfig c = config.widget(widget.id);
int x = i % 2 == 0 ? x1 : x2;
addRenderableWidget(Button.builder(widgetLabel(widget.label, c), b -> {
if (!c.enabled) { c.enabled = true; c.compact = false; }
else if (!c.compact) c.compact = true;
else { c.enabled = false; c.compact = false; }
b.setMessage(widgetLabel(widget.label, c));
}).bounds(x, y, colW, 20).build());
if (i % 2 == 1) y += step;
i++;
}
if (i % 2 == 1) y += step;
y += 8;
addRenderableWidget(toggle(x1, y, colW, "Level-up toasts", () -> config.notifications.levelUp, v -> config.notifications.levelUp = v));
addRenderableWidget(toggle(x2, y, colW, "Achievement toasts", () -> config.notifications.achievements, v -> config.notifications.achievements = v));
y += step;
addRenderableWidget(toggle(x1, y, colW, "Guild toasts", () -> config.notifications.guild, v -> config.notifications.guild = v));
addRenderableWidget(toggle(x2, y, colW, "Error toasts", () -> config.notifications.errors, v -> config.notifications.errors = v));
y += step + 8;
addRenderableWidget(Button.builder(Component.literal("Edit HUD layout…"), b -> minecraft.setScreen(new HudEditorScreen(this))).bounds(x1, y, colW, 20).build());
addRenderableWidget(Button.builder(Component.literal("Done"), b -> onClose()).bounds(x2, y, colW, 20).build());
}
private static Component widgetLabel(String label, ClientConfig.WidgetConfig c) {
return Component.literal(label + ": ").append(Component.literal(!c.enabled ? "§cOff" : c.compact ? "§eCompact" : "§aOn"));
}
@Override public void render(GuiGraphics g, int mouseX, int mouseY, float delta) {
renderBackground(g);
g.drawCenteredString(font, "SCOPENET settings", width / 2, 14, Ui.ACCENT);
super.render(g, mouseX, mouseY, delta);
}
@Override public void onClose() {
config.save();
minecraft.setScreen(parent);
}
}
@@ -0,0 +1,92 @@
package net.scopenet.client.screen;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.scopenet.client.ClientState;
import net.scopenet.client.Link;
import net.scopenet.client.ScopenetClient;
import net.scopenet.client.Ui;
import java.util.List;
/** The server shop: what each item sells for, and a button to sell what's in your hand. */
public final class ShopScreen extends Screen {
private static final int W = 280, ROW = 24, LIST_TOP = 44;
private final ClientState state = ScopenetClient.state();
private int scroll;
private int age;
private Button sell;
public ShopScreen() { super(Component.literal("Shop")); }
private int left() { return (width - W) / 2; }
private int top() { return 12; }
private int listHeight() { return Math.max(ROW * 3, height - 24 - LIST_TOP - 54); }
@Override protected void init() {
ScopenetClient.link().send("shop");
sell = Button.builder(Component.literal("Sell held item"), b -> { Link.command("sell hand"); age = -30; })
.bounds(left() + W - 130, top() + LIST_TOP + listHeight() + 10, 120, 20).build();
addRenderableWidget(sell);
addRenderableWidget(Button.builder(Component.literal("Back"), b -> minecraft.setScreen(new MenuScreen())).bounds(left() + 10, top() + LIST_TOP + listHeight() + 34, 60, 18).build());
}
@Override public void tick() {
if (++age >= 40) { age = 0; ScopenetClient.link().send("shop"); }
ClientState.Held held = state.held;
sell.active = held != null && held.unit() > 0;
}
@Override public boolean mouseScrolled(double mx, double my, double delta) {
int max = Math.max(0, state.shop.size() * ROW - listHeight());
scroll = (int) Math.max(0, Math.min(max, scroll - delta * ROW));
return true;
}
@Override public void render(GuiGraphics g, int mouseX, int mouseY, float delta) {
renderBackground(g);
int left = left(), top = top(), lh = listHeight();
Ui.window(g, left, top, W, LIST_TOP + lh + 62);
g.fill(left, top, left + W, top + 3, Ui.GOLD);
g.drawString(font, "Server Shop", left + 10, top + 10, Ui.GOLD, true);
String bal = Ui.money(state.currency, state.balance);
g.drawString(font, bal, left + W - 10 - font.width(bal), top + 10, Ui.TEXT, true);
g.drawString(font, "Hold items and press Sell. Prices are per item.", left + 10, top + 26, Ui.MUTED, false);
List<ClientState.ShopItem> items = state.shop;
ClientState.Held held = state.held;
g.enableScissor(left + 1, top + LIST_TOP, left + W - 1, top + LIST_TOP + lh);
for (int i = 0; i < items.size(); i++) {
int y = top + LIST_TOP + i * ROW - scroll;
if (y + ROW < top + LIST_TOP || y > top + LIST_TOP + lh) continue;
ClientState.ShopItem item = items.get(i);
boolean hover = Ui.inside(mouseX, mouseY, left + 6, y, W - 12, ROW - 2);
boolean match = held != null && held.id().equalsIgnoreCase(item.id());
g.fill(left + 6, y, left + W - 6, y + ROW - 2, match ? Ui.alpha(Ui.GOLD, 0.18f) : hover ? Ui.alpha(0xFFFFFF, 0.06f) : Ui.alpha(Ui.PANEL_2, 0.9f));
g.renderItem(Ui.stackFor(item.id(), 1), left + 10, y + 3);
g.drawString(font, Ui.clip(font, title(item.name()), 130), left + 32, y + 8, Ui.TEXT, true);
String price = Ui.money(state.currency, item.price());
g.drawString(font, price, left + W - 12 - font.width(price), y + 8, Ui.GREEN, true);
}
g.disableScissor();
if (items.isEmpty()) g.drawCenteredString(font, "Loading prices…", left + W / 2, top + LIST_TOP + 20, Ui.MUTED);
int infoY = top + LIST_TOP + lh + 14;
if (held == null) g.drawString(font, "Your hand is empty", left + 10, infoY, Ui.MUTED, false);
else {
g.renderItem(Ui.stackFor(held.id(), held.count()), left + 10, infoY - 4);
g.drawString(font, Ui.clip(font, held.name() + " x" + held.count(), 100), left + 30, infoY - 2, Ui.TEXT, true);
g.drawString(font, held.unit() > 0 ? "worth " + Ui.money(state.currency, held.unit() * held.count()) : "can't be sold here", left + 30, infoY + 9, held.unit() > 0 ? Ui.GREEN : Ui.RED, false);
}
super.render(g, mouseX, mouseY, delta);
}
private static String title(String name) {
if (name.isEmpty()) return name;
StringBuilder out = new StringBuilder();
for (String part : name.split(" ")) out.append(part.isEmpty() ? "" : Character.toUpperCase(part.charAt(0)) + part.substring(1)).append(' ');
return out.toString().trim();
}
}
@@ -0,0 +1,5 @@
{
"key.categories.scopenet": "SCOPENET",
"key.scopenet.menu": "Open SCOPENET menu",
"key.scopenet.toggle_hud": "Show or hide SCOPENET HUD"
}
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"id": "scopenet_client",
"version": "${version}",
"name": "SCOPENET Client",
"description": "HUD widgets, claim borders, and shop and market windows for SCOPENET servers. Display only: the server stays in charge of money, claims and permissions.",
"environment": "client",
"entrypoints": { "client": ["net.scopenet.client.ScopenetClient"] },
"depends": { "fabricloader": ">=0.16.10", "minecraft": "1.20.1", "java": ">=17", "fabric-api": "*" }
}