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:
43 files changed
+2112
-42
No files matched your search
@@ -0,0 +1,56 @@
|
|||||||
|
# Fabric: server mod features and the client mod
|
||||||
|
|
||||||
|
Two jars, both for **Fabric 1.20.1**:
|
||||||
|
|
||||||
|
| Jar | Install on | Needs |
|
||||||
|
|---|---|---|
|
||||||
|
| `scopenet-fabric-1.20.1.jar` (server mod) | the server (and a single-player world if you want the commands there) | Fabric API. Optional: LuckPerms, Placeholder API |
|
||||||
|
| `scopenet-client-fabric-1.20.1.jar` (client mod) | players who want the HUD, claim borders and shop/market windows | Fabric API |
|
||||||
|
|
||||||
|
The client mod is optional and display-only. The server stays in charge of claims, money and permissions; every button in the client is an ordinary command. A player without it loses nothing. You can install both jars on the same machine (for example a single-player world), because the client mod shares no code with the server mod.
|
||||||
|
|
||||||
|
Other Minecraft versions keep the original behaviour (sign-in, stats, claims, Live Map) and do not get the commands yet.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gradle -p integrations -Ploader=fabric -PmcVersion=1.20.1 :fabric:build # server mod
|
||||||
|
gradle -p integrations -Ploader=fabric-client :fabric-client:build # client mod
|
||||||
|
```
|
||||||
|
|
||||||
|
The core logic (`integrations/common`, package `net.scopenet.core`) is platform-neutral and unit-tested with `gradle -p integrations :common:test`. The Fabric classes are thin adapters over it.
|
||||||
|
|
||||||
|
## What the server mod adds (same behaviour as the Paper plugin)
|
||||||
|
|
||||||
|
* **Essentials:** `/spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime` (homes and warps saved in `config/scopenet/essentials.json`). `/back` remembers where you were before SCOPENET teleports and where you died.
|
||||||
|
* **Economy:** `/balance /pay /baltop /shop /sell hand /market (sell|buy) /orders /transactions`. Selling and buying go through an escrow file (`economy-pending.json`) so a crash or a panel outage never loses an item or a payment.
|
||||||
|
* **Guilds:** `/guild` (info, create, leave, claim, unclaim, map, chat, sethome, home, members, bank, pay, sell, market), `/claim`, `/unclaim`. Claim checks use the same local index as the rest of the mod.
|
||||||
|
* **Admin:** `/scopenet help | status | reload` (alias `/sn`).
|
||||||
|
* **Permissions:** the same `scopenet.command.*` nodes as Paper. With the LuckPerms mod installed its answer wins; otherwise `permissions.default_level` decides, and admin nodes need operator level 2. `scopenet.claims.bypass` lets staff build in other guilds' claims.
|
||||||
|
* **Placeholder API:** `%scopenet:level%`, `%scopenet:guild%`, `%scopenet:balance%`, `%scopenet:playtime%`, `%scopenet:quest_progress%` and the rest of the Paper list (see `Placeholders.KEYS`).
|
||||||
|
* **Economy for other mods:** `net.scopenet.fabric.api.ScopenetEconomy` (Fabric's stand-in for Vault): `balance`, `deposit`, `withdraw`, all asynchronous and safe to retry.
|
||||||
|
|
||||||
|
Not on Fabric yet: `/trade` (needs a chest window), item sorting of homes/warps into a menu (they are listed in chat), and teleports by other mods are not remembered for `/back`.
|
||||||
|
|
||||||
|
### Economy mods
|
||||||
|
|
||||||
|
SCOPENET keeps the ledger in the panel, so its money is one number per player across your network. Other Fabric mods can use it through `ScopenetEconomy`. Adapters that register SCOPENET as a provider inside third-party economy mods (Impactor, Common Economy API and similar) are **not included**: their APIs change between versions and I could not build against them, so shipping guesses would risk silent money bugs. They are small to add once the exact versions are chosen; the extension point is `ScopenetEconomy`.
|
||||||
|
|
||||||
|
## The client mod
|
||||||
|
|
||||||
|
* **HUD widgets:** Level (with XP bar and server level), Balance, Guild, Claim (who owns the chunk you are in) and Quests. Each can be on, compact or off.
|
||||||
|
* **Claim borders:** green boxes around your guild's land and red around other guilds', within 4 chunks.
|
||||||
|
* **Windows:** press **K** for the menu, then **Shop** (every price, your held item and what it is worth, one-click sell), **Market** (browse, one-click buy, list your held item) or **Settings**.
|
||||||
|
* **HUD layout editor:** Settings → *Edit HUD layout*. Drag widgets, right-click to hide or show, scroll to resize. Positions snap to screen anchors, so they hold on any window size.
|
||||||
|
* **Toasts:** level-ups, achievements and guild changes, each switchable.
|
||||||
|
* **Keys:** *Open SCOPENET menu* (K) and *Show or hide SCOPENET HUD* (unbound) in Controls → SCOPENET.
|
||||||
|
|
||||||
|
Settings are saved to `config/scopenet-client.json` (see `examples/fabric-client/`).
|
||||||
|
|
||||||
|
### How it talks to the server
|
||||||
|
|
||||||
|
Two plugin-message channels, `scopenet:s2c` and `scopenet:c2s`, carrying JSON in a Minecraft UTF string (`net.scopenet.core.Wire`). The client says hello; the server then sends its state, the claims around the player, and answers market and shop requests. The same channels work on the Paper plugin (`clientlink.enabled` in `config.yml`), so the client mod works on both. Turn the link off with `clientlink.enabled=false`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
The shared core, the escrow flow, the client-link protocol and the placeholders are covered by unit tests that run in CI (`CommandsTest`, `LinkTest`, `EssentialsServiceTest`). **The Fabric server mod, the client mod and the Paper additions have not been compiled or run**, because the build environment they were written in cannot download Minecraft, Fabric or Paper. Build them, then report any compile errors.
|
||||||
@@ -8,7 +8,7 @@ Use **online-mode=true**. The server's authlib-injector agent redirects Minecraf
|
|||||||
2. Create a server under **Servers**, choose **Members** or **Groups** for a private community, and copy its `sn_…` token. Give each server its own token.
|
2. Create a server under **Servers**, choose **Members** or **Groups** for a private community, and copy its `sn_…` token. Give each server its own token.
|
||||||
3. Install one server integration from Releases:
|
3. Install one server integration from Releases:
|
||||||
- Paper/Purpur/Spigot: `scopenet-paper-VERSION.jar` in `plugins/`. Built against the Bukkit 1.20.1 API; intended for 1.20.1, 1.21.1 and 26.x. No NMS dependency. Folia is not supported.
|
- Paper/Purpur/Spigot: `scopenet-paper-VERSION.jar` in `plugins/`. Built against the Bukkit 1.20.1 API; intended for 1.20.1, 1.21.1 and 26.x. No NMS dependency. Folia is not supported.
|
||||||
- Fabric: `scopenet-fabric-MC-VERSION.jar` in `mods/`. No Fabric API or client mod required.
|
- Fabric: `scopenet-fabric-MC-VERSION.jar` in `mods/`. Sign-in, stats, claims and the Live Map need no Fabric API or client mod. The 1.20.1 build adds commands, permissions, placeholders and the client link, and needs Fabric API; see [fabric.md](fabric.md).
|
||||||
- Forge: `scopenet-forge-MC-VERSION.jar` in `mods/`. Server-only installation; clients do not need SCOPENET.
|
- Forge: `scopenet-forge-MC-VERSION.jar` in `mods/`. Server-only installation; clients do not need SCOPENET.
|
||||||
- Mod builds are **exact-version** artifacts for **1.20.1, 1.21.1, 26.1.2, 26.2 and 26.3**. Do not install one on a different Minecraft version. Future 26.x releases need their own verified builds.
|
- Mod builds are **exact-version** artifacts for **1.20.1, 1.21.1, 26.1.2, 26.2 and 26.3**. Do not install one on a different Minecraft version. Future 26.x releases need their own verified builds.
|
||||||
4. Configure Paper at `plugins/SCOPENET/config.yml`:
|
4. Configure Paper at `plugins/SCOPENET/config.yml`:
|
||||||
@@ -71,6 +71,6 @@ gradle -p integrations -Ploader=forge -PmcVersion=1.21.1 :forge:build
|
|||||||
gradle -p integrations -Ploader=fabric -PmcVersion=26.3 :fabric:build
|
gradle -p integrations -Ploader=fabric -PmcVersion=26.3 :fabric:build
|
||||||
```
|
```
|
||||||
|
|
||||||
Substitute `fabric`/`forge` and the exact version as required. JARs are in the selected module's `build/libs/`. `-PreleaseVersion=X.Y.Z` stamps the artifact and metadata. The locally built 0.4.0 matrix for 1.20.1, 1.21.1, and 26.3 plus Paper is in [`release-artifacts/0.4.0`](../release-artifacts/0.4.0/README.md), with SHA-256 checksums. The **Server integrations** workflow can rebuild the matrix when Actions usage is available again.
|
The optional Fabric client mod builds with `-Ploader=fabric-client :fabric-client:build`. Substitute `fabric`/`forge` and the exact version as required. JARs are in the selected module's `build/libs/`. `-PreleaseVersion=X.Y.Z` stamps the artifact and metadata. The locally built 0.4.0 matrix for 1.20.1, 1.21.1, and 26.3 plus Paper is in [`release-artifacts/0.4.0`](../release-artifacts/0.4.0/README.md), with SHA-256 checksums. The **Server integrations** workflow can rebuild the matrix when Actions usage is available again.
|
||||||
|
|
||||||
Before production, test a valid login, wrong password, disabled account, denied group, missing launcher launch, panel outage, name-change/reconnect with the same inventory, chat count, heartbeat retry, and restart on a copy of your server world. Build/API tests do not replace a live Minecraft client/server compatibility check with your modpack.
|
Before production, test a valid login, wrong password, disabled account, denied group, missing launcher launch, panel outage, name-change/reconnect with the same inventory, chat count, heartbeat retry, and restart on a copy of your server world. Build/API tests do not replace a live Minecraft client/server compatibility check with your modpack.
|
||||||
@@ -1,27 +1,24 @@
|
|||||||
{
|
{
|
||||||
"_about": "SCOPENET client mod (Fabric 1.20.1) -> config/scopenet-client.json. The in-game config screen edits this file; you rarely need to change it by hand. The mod is in development and this format may grow.",
|
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"hud": {
|
"hud": {
|
||||||
"scale": 1.0,
|
"scale": 1.0,
|
||||||
"opacity": 0.85,
|
"opacity": 0.85,
|
||||||
"widgets": {
|
|
||||||
"balance": { "enabled": true, "anchor": "top_right", "x": -8, "y": 8, "compact": false },
|
|
||||||
"level": { "enabled": true, "anchor": "top_left", "x": 8, "y": 8, "showXpBar": true },
|
|
||||||
"guild": { "enabled": true, "anchor": "bottom_left", "x": 8, "y": -8, "showClaimOwner": true },
|
|
||||||
"quests": { "enabled": false, "anchor": "middle_right", "x": -8, "y": 0, "maxShown": 3 },
|
|
||||||
"claimBorders": { "enabled": true, "showOnlyWhenSneaking": false }
|
|
||||||
},
|
|
||||||
"hideInF3": true,
|
"hideInF3": true,
|
||||||
"hideWhileChatOpen": false
|
"hideWhileChatOpen": false,
|
||||||
|
"claimBorders": true,
|
||||||
|
"claimBordersOnlyWhenSneaking": false,
|
||||||
|
"widgets": {
|
||||||
|
"level": { "enabled": true, "anchor": "top_left", "x": 8, "y": 8, "compact": false },
|
||||||
|
"balance": { "enabled": true, "anchor": "top_right", "x": -8, "y": 8, "compact": false },
|
||||||
|
"guild": { "enabled": true, "anchor": "bottom_left", "x": 8, "y": -40, "compact": false },
|
||||||
|
"claim": { "enabled": true, "anchor": "top_center", "x": 0, "y": 8, "compact": false },
|
||||||
|
"quests": { "enabled": false, "anchor": "middle_right", "x": -8, "y": 0, "compact": false }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"levelUp": true,
|
"levelUp": true,
|
||||||
"achievements": true,
|
"achievements": true,
|
||||||
"friendRequests": true,
|
"guild": true,
|
||||||
"gameInvites": true
|
"errors": true
|
||||||
},
|
|
||||||
"keybinds": {
|
|
||||||
"openMenu": "key.keyboard.k",
|
|
||||||
"toggleHud": "key.keyboard.h"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# SCOPENET for Fabric (server side) -> config/scopenet.properties
|
# SCOPENET for Fabric (server side) -> config/scopenet.properties
|
||||||
# Works with the same panel as the Paper plugin. Keys marked [planned] are read once the Fabric
|
# Same panel as the Paper plugin. Sign-in, stats, claims and the Live Map work on every supported Fabric version.
|
||||||
# feature-parity work lands; unknown keys are ignored, so it is safe to keep them here.
|
# Commands, permissions, placeholders and the client link need Fabric API and are in the Fabric 1.20.1 build.
|
||||||
|
# Extra files the mod creates: config/scopenet/essentials.json (homes, warps), guild-homes.json, economy-pending.json.
|
||||||
|
|
||||||
# --- Connection (required) ---------------------------------------------------
|
# --- Connection (required) ---------------------------------------------------
|
||||||
# Your panel address and the token from Servers -> this server in the panel.
|
# Your panel address and the token from Servers -> this server in the panel.
|
||||||
@@ -24,29 +25,30 @@ economy.enabled=true
|
|||||||
# (also needs Live Map switched on for this server in the panel).
|
# (also needs Live Map switched on for this server in the panel).
|
||||||
livemap.enabled=true
|
livemap.enabled=true
|
||||||
|
|
||||||
# --- Essentials [planned] ----------------------------------------------------
|
# --- Essentials (1.20.1) -----------------------------------------------------
|
||||||
# Homes, warps, /back, /tpa and /rtp. Same defaults as Paper.
|
# /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime
|
||||||
essentials.max_homes=5
|
essentials.max_homes=5
|
||||||
essentials.rtp_radius=2500
|
essentials.rtp_radius=2500
|
||||||
essentials.tpa_timeout_seconds=60
|
essentials.tpa_timeout_seconds=60
|
||||||
|
|
||||||
# --- Permissions [planned] ---------------------------------------------------
|
# --- Economy (1.20.1) --------------------------------------------------------
|
||||||
# Uses the LuckPerms mod when it is installed (nodes are scopenet.command.*, same as Paper).
|
# Shown next to amounts.
|
||||||
# Without it, these defaults apply: "all" lets every player use the command, "op" limits to operators.
|
|
||||||
permissions.default_level=all
|
|
||||||
permissions.admin_commands=op
|
|
||||||
|
|
||||||
# --- Economy mods [planned] --------------------------------------------------
|
|
||||||
# auto : use the first supported economy mod found (Impactor, Common Economy API), else SCOPENET's own balances
|
|
||||||
# scopenet: always use SCOPENET balances (other mods can still read them through the SCOPENET API)
|
|
||||||
economy.provider=auto
|
|
||||||
economy.currency_symbol=$
|
economy.currency_symbol=$
|
||||||
|
|
||||||
# --- Placeholder API [planned] -----------------------------------------------
|
# --- Permissions (1.20.1) ----------------------------------------------------
|
||||||
# Registers %scopenet:level%, %scopenet:guild%, %scopenet:balance%, %scopenet:playtime%,
|
# With the LuckPerms mod installed, its answer wins. Use the same nodes as the Paper plugin
|
||||||
# %scopenet:quest_progress% when Placeholder API (Fabric) is installed.
|
# (scopenet.command.home, scopenet.command.guild.bank.withdraw, scopenet.claims.bypass ...).
|
||||||
|
# Without LuckPerms, or when LuckPerms has no setting for a node:
|
||||||
|
# all : every player may use ordinary commands
|
||||||
|
# op : only operators may
|
||||||
|
# Admin nodes (/scopenet status and reload, scopenet.claims.bypass) always need operator level 2 by default.
|
||||||
|
permissions.default_level=all
|
||||||
|
|
||||||
|
# --- Placeholder API (1.20.1) ------------------------------------------------
|
||||||
|
# With Placeholder API installed, registers %scopenet:level%, %scopenet:guild%, %scopenet:balance%,
|
||||||
|
# %scopenet:playtime%, %scopenet:quest_progress% and the rest of the Paper placeholder list.
|
||||||
placeholders.enabled=true
|
placeholders.enabled=true
|
||||||
|
|
||||||
# --- Client mod link [planned] -----------------------------------------------
|
# --- Client mod link (1.20.1) ------------------------------------------------
|
||||||
# Send balance, level and guild info to players who have the SCOPENET client mod.
|
# Send balance, level, guild and claim info to players who have the SCOPENET client mod.
|
||||||
clientlink.enabled=true
|
clientlink.enabled=true
|
||||||
@@ -114,6 +114,15 @@ integrations:
|
|||||||
enabled: true
|
enabled: true
|
||||||
report-interval-seconds: 30
|
report-interval-seconds: 30
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Client mod link
|
||||||
|
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
|
||||||
|
# client mod (HUD widgets, claim overlay, shop and market windows). Players without it
|
||||||
|
# are unaffected. Display only: everything the client does is an ordinary command.
|
||||||
|
# ==============================================================================
|
||||||
|
clientlink:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Permissions (set these in LuckPerms, not here)
|
# Permissions (set these in LuckPerms, not here)
|
||||||
# scopenet.command.<command> e.g. scopenet.command.home
|
# scopenet.command.<command> e.g. scopenet.command.home
|
||||||
|
|||||||
@@ -194,6 +194,36 @@ public final class ClientLink {
|
|||||||
send(p, m);
|
send(p, m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events the panel queued for this server (level-ups, achievements, guild changes). Safe to call from any thread:
|
||||||
|
* each becomes a toast on the player's client and forces a fresh state.
|
||||||
|
*/
|
||||||
|
public void onPanelEvents(JsonArray events) {
|
||||||
|
for (JsonElement element : events) {
|
||||||
|
if (!element.isJsonObject()) continue;
|
||||||
|
JsonObject n = element.getAsJsonObject();
|
||||||
|
String kind = PlayerCache.str(n, "kind", "");
|
||||||
|
String uuidText = PlayerCache.str(n, "uuid", "");
|
||||||
|
JsonObject d = PlayerCache.obj(n, "data");
|
||||||
|
UUID uuid;
|
||||||
|
try { uuid = UUID.fromString(uuidText); } catch (IllegalArgumentException e) { continue; }
|
||||||
|
String title, text;
|
||||||
|
switch (kind) {
|
||||||
|
case "level_up" -> { title = "Level up!"; text = ("server".equals(PlayerCache.str(d, "scope", "")) ? "Server level " : "Level ") + (long) PlayerCache.num(d, "level", 0); }
|
||||||
|
case "achievement" -> { title = "Achievement unlocked"; text = PlayerCache.str(d, "title", ""); }
|
||||||
|
case "guild_join" -> { title = "Guild"; text = "Joined " + PlayerCache.str(d, "guild", "a guild"); }
|
||||||
|
case "guild_leave" -> { title = "Guild"; text = "Left " + PlayerCache.str(d, "guild", "your guild"); }
|
||||||
|
default -> { continue; }
|
||||||
|
}
|
||||||
|
final String t = title, x = text, k = kind;
|
||||||
|
env.platform.runMain(() -> env.platform.player(uuid).ifPresent(p -> {
|
||||||
|
notify(p, k, t, x);
|
||||||
|
lastState.remove(uuid);
|
||||||
|
cache.refresh(p, info -> pushState(p));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** About once a second: new chunk, new claims, or new state for every client. */
|
/** About once a second: new chunk, new claims, or new state for every client. */
|
||||||
public void tick() {
|
public void tick() {
|
||||||
for (UUID id : new ArrayList<>(clients)) {
|
for (UUID id : new ArrayList<>(clients)) {
|
||||||
|
|||||||
@@ -114,6 +114,17 @@ class LinkTest {
|
|||||||
assertEquals(35.0, shop.getAsJsonObject("held").get("unit").getAsDouble());
|
assertEquals(35.0, shop.getAsJsonObject("held").get("unit").getAsDouble());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void panelEventsBecomeToasts() {
|
||||||
|
link.receive(alex, msg("{\"t\":\"hello\"}"));
|
||||||
|
link.onPanelEvents(JsonParser.parseString("[{\"kind\":\"level_up\",\"uuid\":\"" + alex.id + "\",\"data\":{\"scope\":\"global\",\"level\":13}},"
|
||||||
|
+ "{\"kind\":\"achievement\",\"uuid\":\"" + alex.id + "\",\"data\":{\"title\":\"Deep Diver\"}},"
|
||||||
|
+ "{\"kind\":\"unknown\",\"uuid\":\"" + alex.id + "\",\"data\":{}},{\"kind\":\"level_up\",\"uuid\":\"nope\"}]").getAsJsonArray());
|
||||||
|
List<JsonObject> toasts = sent.stream().filter(m -> m.get("t").getAsString().equals("notify")).toList();
|
||||||
|
assertEquals(2, toasts.size());
|
||||||
|
assertEquals("Level 13", toasts.get(0).get("text").getAsString());
|
||||||
|
assertEquals("Deep Diver", toasts.get(1).get("text").getAsString());
|
||||||
|
}
|
||||||
|
|
||||||
@Test void disabledClientLinkIsSilent() {
|
@Test void disabledClientLinkIsSilent() {
|
||||||
kit.env.features = new Features(true, true, true, true, true, false, "$");
|
kit.env.features = new Features(true, true, true, true, true, false, "$");
|
||||||
link.receive(alex, msg("{\"t\":\"hello\"}"));
|
link.receive(alex, msg("{\"t\":\"hello\"}"));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -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": "*" }
|
||||||
|
}
|
||||||
@@ -8,12 +8,19 @@ if (providers.gradleProperty('verifyMixins').isPresent()) {
|
|||||||
}
|
}
|
||||||
java.sourceCompatibility = JavaVersion.toVersion(targetJava)
|
java.sourceCompatibility = JavaVersion.toVersion(targetJava)
|
||||||
java.targetCompatibility = JavaVersion.toVersion(targetJava)
|
java.targetCompatibility = JavaVersion.toVersion(targetJava)
|
||||||
|
def features = mc == '1.20.1' // commands, permissions, economy, client link: Fabric API based, 1.20.1 only for now
|
||||||
dependencies {
|
dependencies {
|
||||||
minecraft "com.mojang:minecraft:${mc}"
|
minecraft "com.mojang:minecraft:${mc}"
|
||||||
mappings loom.officialMojangMappings()
|
mappings loom.officialMojangMappings()
|
||||||
modImplementation 'net.fabricmc:fabric-loader:0.16.10'
|
modImplementation 'net.fabricmc:fabric-loader:0.16.10'
|
||||||
implementation project(':common')
|
implementation project(':common')
|
||||||
|
if (features) {
|
||||||
|
modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1'
|
||||||
|
// Optional at runtime: present only when the LuckPerms mod is installed.
|
||||||
|
compileOnly 'net.luckperms:api:5.4'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (features) sourceSets.main.java.srcDirs += ['src/mc1.20.1/java']
|
||||||
sourceSets.main.java.srcDirs += ['../minecraft/src/main/java', "../minecraft/src/${mc}/java"]
|
sourceSets.main.java.srcDirs += ['../minecraft/src/main/java', "../minecraft/src/${mc}/java"]
|
||||||
sourceSets.main.resources.srcDirs += ['../minecraft/src/main/resources']
|
sourceSets.main.resources.srcDirs += ['../minecraft/src/main/resources']
|
||||||
apply from: '../minecraft/build-info.gradle'
|
apply from: '../minecraft/build-info.gradle'
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
package net.scopenet.fabric;
|
package net.scopenet.fabric;
|
||||||
|
|
||||||
import net.fabricmc.api.DedicatedServerModInitializer;
|
import net.fabricmc.api.DedicatedServerModInitializer;
|
||||||
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
|
|
||||||
/** Server-only; lifecycle and admission hooks are shared mixins. */
|
/** Server-only; lifecycle and admission hooks are shared mixins. Commands and extras need Fabric API (1.20.1 build). */
|
||||||
public final class ScopenetFabric implements DedicatedServerModInitializer {
|
public final class ScopenetFabric implements DedicatedServerModInitializer {
|
||||||
@Override public void onInitializeServer() {
|
@Override public void onInitializeServer() {
|
||||||
if (Boolean.getBoolean("scopenet.verifyMixins"))
|
if (Boolean.getBoolean("scopenet.verifyMixins"))
|
||||||
org.spongepowered.asm.mixin.MixinEnvironment.getCurrentEnvironment().audit();
|
org.spongepowered.asm.mixin.MixinEnvironment.getCurrentEnvironment().audit();
|
||||||
|
if (!FabricLoader.getInstance().isModLoaded("fabric-api")) return;
|
||||||
|
try {
|
||||||
|
Class<?> features = Class.forName("net.scopenet.fabric.features.FabricFeatures");
|
||||||
|
((Runnable) features.getDeclaredConstructor().newInstance()).run();
|
||||||
|
} catch (ClassNotFoundException e) {
|
||||||
|
// This build (another Minecraft version) only has the core integration.
|
||||||
|
} catch (Throwable t) {
|
||||||
|
System.err.println("[SCOPENET] Commands and extras could not start: " + t);
|
||||||
|
t.printStackTrace();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,5 +6,6 @@
|
|||||||
"environment": "server",
|
"environment": "server",
|
||||||
"entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] },
|
"entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] },
|
||||||
"mixins": ["scopenet.mixins.json"],
|
"mixins": ["scopenet.mixins.json"],
|
||||||
"depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" }
|
"depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" },
|
||||||
|
"suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*" }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package net.scopenet.fabric.api;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import net.scopenet.core.Panel;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCOPENET's economy for other mods (the Fabric stand-in for Vault). Balances live in the panel, so every call is
|
||||||
|
* asynchronous. Every change carries an operation id, so a retried call never pays twice.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* ScopenetEconomy.get().ifPresent(eco -> eco.balance(uuid).thenAccept(b -> ...));
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public final class ScopenetEconomy {
|
||||||
|
private static final AtomicReference<ScopenetEconomy> INSTANCE = new AtomicReference<>();
|
||||||
|
|
||||||
|
private final Panel panel;
|
||||||
|
private final java.util.concurrent.Executor io;
|
||||||
|
|
||||||
|
public ScopenetEconomy(Panel panel, java.util.concurrent.Executor io) { this.panel = panel; this.io = io; }
|
||||||
|
|
||||||
|
/** Empty until the server has started and SCOPENET economy is enabled. */
|
||||||
|
public static java.util.Optional<ScopenetEconomy> get() { return java.util.Optional.ofNullable(INSTANCE.get()); }
|
||||||
|
|
||||||
|
public static void install(ScopenetEconomy economy) { INSTANCE.set(economy); }
|
||||||
|
|
||||||
|
public static void uninstall() { INSTANCE.set(null); }
|
||||||
|
|
||||||
|
public CompletableFuture<Double> balance(UUID player, String name) {
|
||||||
|
JsonObject body = new JsonObject();
|
||||||
|
body.addProperty("uuid", player.toString());
|
||||||
|
body.addProperty("username", name);
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
try { return panel.call("economy/balance", body).getAsJsonObject().get("balance").getAsDouble(); }
|
||||||
|
catch (Exception e) { throw new java.util.concurrent.CompletionException(e); }
|
||||||
|
}, io);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add (positive) or remove (negative) money. Fails, without changing anything, if the player can't afford it. Returns the new balance. */
|
||||||
|
public CompletableFuture<Double> change(UUID player, String name, double delta, String reason) {
|
||||||
|
JsonObject body = new JsonObject();
|
||||||
|
body.addProperty("operation_id", UUID.randomUUID().toString());
|
||||||
|
body.addProperty("uuid", player.toString());
|
||||||
|
body.addProperty("username", name);
|
||||||
|
body.addProperty("delta", delta);
|
||||||
|
body.addProperty("description", reason == null || reason.isBlank() ? "Other mod" : reason);
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
try { return panel.call("economy/adjust", body).getAsJsonObject().get("balance").getAsDouble(); }
|
||||||
|
catch (Exception e) { throw new java.util.concurrent.CompletionException(e); }
|
||||||
|
}, io);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Double> deposit(UUID player, String name, double amount, String reason) { return change(player, name, Math.abs(amount), reason); }
|
||||||
|
|
||||||
|
public CompletableFuture<Double> withdraw(UUID player, String name, double amount, String reason) { return change(player, name, -Math.abs(amount), reason); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package net.scopenet.fabric.features;
|
||||||
|
|
||||||
|
import net.scopenet.core.EssentialsConfig;
|
||||||
|
import net.scopenet.core.Features;
|
||||||
|
import net.scopenet.integration.Settings;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/** The Fabric-only keys in config/scopenet.properties (the shared keys are read by {@link Settings}). */
|
||||||
|
final class FabricConfig {
|
||||||
|
static final Path FILE = Path.of("config", "scopenet.properties");
|
||||||
|
private final Properties p = new Properties();
|
||||||
|
|
||||||
|
static FabricConfig load() {
|
||||||
|
FabricConfig c = new FabricConfig();
|
||||||
|
if (Files.exists(FILE)) {
|
||||||
|
try (Reader in = Files.newBufferedReader(FILE, StandardCharsets.UTF_8)) { c.p.load(in); }
|
||||||
|
catch (IOException e) { System.err.println("[SCOPENET] Could not read " + FILE + ": " + e.getMessage()); }
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int integer(String key, int fallback) {
|
||||||
|
try { return Integer.parseInt(p.getProperty(key, String.valueOf(fallback)).trim()); } catch (NumberFormatException e) { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean bool(String key, boolean fallback) { return Boolean.parseBoolean(p.getProperty(key, String.valueOf(fallback)).trim()); }
|
||||||
|
String text(String key, String fallback) { return p.getProperty(key, fallback).trim(); }
|
||||||
|
|
||||||
|
EssentialsConfig essentials() {
|
||||||
|
return new EssentialsConfig(Math.max(0, integer("essentials.max_homes", 5)), Math.max(10, integer("essentials.rtp_radius", 2500)),
|
||||||
|
Math.max(5, integer("essentials.tpa_timeout_seconds", 60)) * 1000L, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
Features features(Settings s) {
|
||||||
|
return new Features(s.essentialsEnabled(), s.economyEnabled(), s.guildsEnabled(), s.socialEnabled(), s.landClaimingEnabled(),
|
||||||
|
bool("clientlink.enabled", true), text("economy.currency_symbol", "$"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code permissions.default_level}: "all" lets everyone use commands unless LuckPerms says otherwise, "op" limits them to operators. */
|
||||||
|
boolean everyoneByDefault() { return !text("permissions.default_level", "all").equalsIgnoreCase("op"); }
|
||||||
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
package net.scopenet.fabric.features;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.mojang.brigadier.CommandDispatcher;
|
||||||
|
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||||
|
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||||
|
import com.mojang.brigadier.context.CommandContext;
|
||||||
|
import com.mojang.brigadier.suggestion.Suggestions;
|
||||||
|
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||||
|
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
|
||||||
|
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
|
||||||
|
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
|
||||||
|
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
|
||||||
|
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
|
||||||
|
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
|
||||||
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
import net.minecraft.commands.Commands;
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.scopenet.core.*;
|
||||||
|
import net.scopenet.fabric.api.ScopenetEconomy;
|
||||||
|
import net.scopenet.integration.Integration;
|
||||||
|
import net.scopenet.integration.Settings;
|
||||||
|
import net.scopenet.minecraft.Bridge;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything beyond sign-in on Fabric 1.20.1: the shared commands (homes, warps, TPA, RTP, economy, guilds…), LuckPerms
|
||||||
|
* permissions, Placeholder API, the economy API for other mods, and the link to the optional client mod.
|
||||||
|
* Runs when Fabric API is installed; all behaviour comes from {@code net.scopenet.core}.
|
||||||
|
*/
|
||||||
|
public final class FabricFeatures implements Runnable {
|
||||||
|
private static final Logger LOG = Logger.getLogger("SCOPENET");
|
||||||
|
private static final ResourceLocation TO_CLIENT = new ResourceLocation(Wire.S2C);
|
||||||
|
private static final ResourceLocation FROM_CLIENT = new ResourceLocation(Wire.C2S);
|
||||||
|
|
||||||
|
private MinecraftServer server;
|
||||||
|
private FabricPlatform platform;
|
||||||
|
private Env env;
|
||||||
|
private CommandSet commands;
|
||||||
|
private PlayerCache cache;
|
||||||
|
private ClientLink link;
|
||||||
|
private Perms perms;
|
||||||
|
private FabricConfig config;
|
||||||
|
private int ticks;
|
||||||
|
private String placeholderStatus = "off";
|
||||||
|
|
||||||
|
CommandSet commandSet() { return commands; }
|
||||||
|
|
||||||
|
@Override public void run() {
|
||||||
|
ServerLifecycleEvents.SERVER_STARTED.register(this::started);
|
||||||
|
ServerLifecycleEvents.SERVER_STOPPING.register(s -> stopping());
|
||||||
|
ServerTickEvents.END_SERVER_TICK.register(s -> tick());
|
||||||
|
ServerPlayConnectionEvents.DISCONNECT.register((handler, s) -> { if (link != null) link.disconnected(handler.getPlayer().getUUID()); });
|
||||||
|
// /back also returns you to where you died.
|
||||||
|
ServerLivingEntityEvents.ALLOW_DEATH.register((entity, source, amount) -> {
|
||||||
|
if (entity instanceof ServerPlayer p && commands != null) commands.essentials.remember(p.getUUID(), platform.wrap(p).pos());
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
ServerPlayNetworking.registerGlobalReceiver(FROM_CLIENT, (s, player, handler, buf, sender) -> {
|
||||||
|
byte[] data = new byte[buf.readableBytes()];
|
||||||
|
buf.readBytes(data);
|
||||||
|
s.execute(() -> { if (link != null) link.receive(platform.wrap(player), data); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- lifecycle -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void started(MinecraftServer s) {
|
||||||
|
Integration integration = Bridge.integration();
|
||||||
|
if (integration == null) { LOG.warning("SCOPENET is not connected to a panel; commands are off."); return; }
|
||||||
|
server = s;
|
||||||
|
config = FabricConfig.load();
|
||||||
|
perms = new Perms(config.everyoneByDefault());
|
||||||
|
platform = new FabricPlatform(s, perms);
|
||||||
|
Path dir = Path.of("config", "scopenet");
|
||||||
|
env = new Env(platform, new PanelAdapter(integration), config.features(integration.settings()), dir, System::currentTimeMillis, new Random(), LOG);
|
||||||
|
commands = new CommandSet(env, config.essentials());
|
||||||
|
cache = new PlayerCache(env);
|
||||||
|
link = new ClientLink(env, cache, s.getMotd(), (player, bytes) -> {
|
||||||
|
FriendlyByteBuf buf = PacketByteBufs.create();
|
||||||
|
buf.writeBytes(bytes);
|
||||||
|
ServerPlayer target = s.getPlayerList().getPlayer(player.uuid());
|
||||||
|
if (target != null && ServerPlayNetworking.canSend(target, TO_CLIENT)) ServerPlayNetworking.send(target, TO_CLIENT, buf);
|
||||||
|
});
|
||||||
|
integration.onNotifications(events -> link.onPanelEvents(events));
|
||||||
|
Bridge.claimBypass = p -> perms.has(p, "scopenet.claims.bypass");
|
||||||
|
|
||||||
|
registerCommands(s.getCommands().getDispatcher());
|
||||||
|
registerPlaceholders();
|
||||||
|
ScopenetEconomy.install(new ScopenetEconomy(env.panel, r -> platform.runAsync(r)));
|
||||||
|
LOG.info("SCOPENET commands ready (" + commands.all().size() + " commands, "
|
||||||
|
+ (Perms.luckPermsPresent() ? "LuckPerms" : "default permissions") + ").");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopping() {
|
||||||
|
ScopenetEconomy.uninstall();
|
||||||
|
Bridge.claimBypass = p -> false;
|
||||||
|
if (platform != null) platform.shutdown();
|
||||||
|
commands = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerPlaceholders() {
|
||||||
|
if (!config.bool("placeholders.enabled", true)) { placeholderStatus = "off (config)"; return; }
|
||||||
|
if (!net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("placeholder-api")) { placeholderStatus = "Placeholder API not installed"; return; }
|
||||||
|
try {
|
||||||
|
int n = PlaceholderApiModule.register(new Placeholders(env, cache));
|
||||||
|
placeholderStatus = "active (" + n + ")";
|
||||||
|
} catch (Throwable t) {
|
||||||
|
placeholderStatus = "failed: " + t;
|
||||||
|
LOG.warning("Placeholder API could not be used: " + t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void tick() {
|
||||||
|
if (commands == null) return;
|
||||||
|
ticks++;
|
||||||
|
if (ticks % 20 == 0) {
|
||||||
|
cache.tick(p -> link.pushState(p));
|
||||||
|
link.tick();
|
||||||
|
}
|
||||||
|
if (ticks % 40 == 0) commands.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- commands --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void registerCommands(CommandDispatcher<CommandSourceStack> dispatcher) {
|
||||||
|
List<CoreCommand> all = new ArrayList<>(commands.all());
|
||||||
|
all.add(new ScopenetCommand(this));
|
||||||
|
for (CoreCommand command : all) {
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
names.add(command.name());
|
||||||
|
names.addAll(command.aliases());
|
||||||
|
for (String name : names) {
|
||||||
|
LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal(name)
|
||||||
|
.executes(ctx -> execute(ctx, command, ""))
|
||||||
|
.then(Commands.argument("args", StringArgumentType.greedyString())
|
||||||
|
.suggests((ctx, builder) -> suggest(ctx, command, builder))
|
||||||
|
.executes(ctx -> execute(ctx, command, StringArgumentType.getString(ctx, "args"))));
|
||||||
|
dispatcher.register(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int execute(CommandContext<CommandSourceStack> ctx, CoreCommand command, String argText) {
|
||||||
|
ServerPlayer sp = ctx.getSource().getPlayer();
|
||||||
|
if (sp == null) { ctx.getSource().sendFailure(Component.literal("Only players can use this command.")); return 0; }
|
||||||
|
String[] args = argText.isBlank() ? new String[0] : argText.trim().split("\\s+");
|
||||||
|
CorePlayer player = platform.wrap(sp);
|
||||||
|
try {
|
||||||
|
command.run(player, args);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.warning("/" + command.name() + " failed: " + e);
|
||||||
|
player.send(Format.RED + "Something went wrong running that command.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Suggestions> suggest(CommandContext<CommandSourceStack> ctx, CoreCommand command, SuggestionsBuilder builder) {
|
||||||
|
ServerPlayer sp = ctx.getSource().getPlayer();
|
||||||
|
if (sp != null) {
|
||||||
|
String before = builder.getInput().substring(0, builder.getStart()).stripLeading();
|
||||||
|
if (before.startsWith("/")) before = before.substring(1);
|
||||||
|
String[] tokens = before.isBlank() ? new String[0] : before.split(" ");
|
||||||
|
List<String> typed = new ArrayList<>(Arrays.asList(tokens).subList(Math.min(1, tokens.length), tokens.length));
|
||||||
|
typed.add(builder.getRemaining());
|
||||||
|
String partial = builder.getRemaining().toLowerCase(Locale.ROOT);
|
||||||
|
for (String candidate : command.complete(platform.wrap(sp), typed.toArray(new String[0]))) {
|
||||||
|
if (candidate.toLowerCase(Locale.ROOT).startsWith(partial)) builder.suggest(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.buildFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- /scopenet -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void sendStatus(CorePlayer p) {
|
||||||
|
Integration integration = Bridge.integration();
|
||||||
|
Settings s = integration.settings();
|
||||||
|
p.send(Format.GOLD + "=== SCOPENET Status ===");
|
||||||
|
p.send(Format.GRAY + "Panel: " + Format.WHITE + s.panel() + Format.GRAY + " Token: " + (s.token().isEmpty() ? Format.RED + "missing" : Format.GREEN + "set"));
|
||||||
|
Features f = env.features;
|
||||||
|
p.send(Format.GRAY + "Essentials: " + flag(f.essentials()) + Format.GRAY + " Economy: " + flag(f.economy()) + Format.GRAY + " Guilds: " + flag(f.guilds())
|
||||||
|
+ Format.GRAY + " Claims: " + flag(f.landClaiming()) + Format.GRAY + " Client link: " + flag(f.clientLink()));
|
||||||
|
p.send(Format.GRAY + "Claim index: " + (integration.client().claims().loaded() ? Format.GREEN + integration.client().claims().claimCount() + " claims" : Format.YELLOW + "loading"));
|
||||||
|
p.send(Format.GRAY + "Permissions: " + Format.WHITE + (Perms.luckPermsPresent() ? "LuckPerms" : "defaults (" + (config.everyoneByDefault() ? "everyone" : "operators") + ")"));
|
||||||
|
p.send(Format.GRAY + "Placeholder API: " + Format.WHITE + placeholderStatus);
|
||||||
|
p.send(Format.GRAY + "Pending transactions: " + Format.WHITE + commands.jobs.pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String flag(boolean on) { return on ? Format.GREEN + "on" : Format.RED + "off"; }
|
||||||
|
|
||||||
|
void reload(CorePlayer p) {
|
||||||
|
try {
|
||||||
|
Settings next = Settings.load(FabricConfig.FILE);
|
||||||
|
Bridge.integration().reload(next);
|
||||||
|
config = FabricConfig.load();
|
||||||
|
perms = new Perms(config.everyoneByDefault());
|
||||||
|
env.features = config.features(next);
|
||||||
|
commands.essentials.configure(config.essentials());
|
||||||
|
p.send(Format.GREEN + "SCOPENET config reloaded.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
p.send(Format.RED + "Reload failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package net.scopenet.fabric.features;
|
||||||
|
|
||||||
|
import net.minecraft.core.registries.BuiltInRegistries;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.nbt.TagParser;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.scopenet.core.Item;
|
||||||
|
|
||||||
|
/** ItemStack <-> the shared {@link Item}. The exact stack (enchantments, names, NBT) travels as SNBT in {@code data}. */
|
||||||
|
final class FabricItems {
|
||||||
|
private FabricItems() {}
|
||||||
|
|
||||||
|
static Item toCore(ItemStack stack) {
|
||||||
|
String registryId = BuiltInRegistries.ITEM.getKey(stack.getItem()).toString();
|
||||||
|
return new Item(Item.panelId(registryId), stack.getHoverName().getString(), stack.getCount(), stack.save(new CompoundTag()).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stack for an item, restoring the exact NBT when it came from this platform; otherwise rebuilt from id and count. */
|
||||||
|
static ItemStack fromCore(Item item) {
|
||||||
|
if (!item.data().isEmpty()) {
|
||||||
|
try {
|
||||||
|
ItemStack stack = ItemStack.of(TagParser.parseTag(item.data()));
|
||||||
|
if (!stack.isEmpty()) return stack;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Another platform's serialisation: fall through to id and count.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResourceLocation id = ResourceLocation.tryParse(Item.registryId(item.id()));
|
||||||
|
net.minecraft.world.item.Item type = id == null ? null : BuiltInRegistries.ITEM.get(id);
|
||||||
|
if (type == null || type == net.minecraft.world.item.Items.AIR) return ItemStack.EMPTY;
|
||||||
|
return new ItemStack(type, Math.max(1, item.count()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
package net.scopenet.fabric.features;
|
||||||
|
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.registries.Registries;
|
||||||
|
import net.minecraft.resources.ResourceKey;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.levelgen.Heightmap;
|
||||||
|
import net.scopenet.core.CorePlayer;
|
||||||
|
import net.scopenet.core.Platform;
|
||||||
|
import net.scopenet.core.Pos;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/** The shared logic's view of a running Fabric server. */
|
||||||
|
final class FabricPlatform implements Platform {
|
||||||
|
private final MinecraftServer server;
|
||||||
|
private final Perms perms;
|
||||||
|
private final ExecutorService io = Executors.newFixedThreadPool(4, r -> {
|
||||||
|
Thread t = new Thread(r, "scopenet-io");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
FabricPlatform(MinecraftServer server, Perms perms) { this.server = server; this.perms = perms; }
|
||||||
|
|
||||||
|
void shutdown() { io.shutdownNow(); }
|
||||||
|
|
||||||
|
CorePlayer wrap(ServerPlayer p) { return new FabricPlayer(p, this, perms); }
|
||||||
|
|
||||||
|
ServerLevel level(String id) {
|
||||||
|
ResourceLocation location = ResourceLocation.tryParse(id);
|
||||||
|
return location == null ? null : server.getLevel(ResourceKey.create(Registries.DIMENSION, location));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public Optional<CorePlayer> player(UUID uuid) { return Optional.ofNullable(server.getPlayerList().getPlayer(uuid)).map(this::wrap); }
|
||||||
|
|
||||||
|
@Override public Optional<CorePlayer> playerByName(String name) { return Optional.ofNullable(server.getPlayerList().getPlayerByName(name)).map(this::wrap); }
|
||||||
|
|
||||||
|
@Override public Collection<? extends CorePlayer> online() {
|
||||||
|
List<CorePlayer> out = new ArrayList<>();
|
||||||
|
for (ServerPlayer p : server.getPlayerList().getPlayers()) out.add(wrap(p));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public Optional<Pos> safeSurface(String world, int x, int z) {
|
||||||
|
ServerLevel level = level(world);
|
||||||
|
if (level == null || level.dimensionType().hasCeiling()) return Optional.empty(); // the Nether roof is bedrock
|
||||||
|
int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z);
|
||||||
|
if (y <= level.getMinBuildHeight() + 1) return Optional.empty();
|
||||||
|
BlockState ground = level.getBlockState(new BlockPos(x, y - 1, z));
|
||||||
|
if (!ground.blocksMotion() || !ground.getFluidState().isEmpty()) return Optional.empty();
|
||||||
|
return Optional.of(new Pos(world, x + 0.5, y, z + 0.5, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public Pos worldSpawn(String world) {
|
||||||
|
ServerLevel level = level(world);
|
||||||
|
if (level == null) level = server.overworld();
|
||||||
|
BlockPos spawn = level.getSharedSpawnPos();
|
||||||
|
return new Pos(level.dimension().location().toString(), spawn.getX(), spawn.getY(), spawn.getZ(), 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void runMain(Runnable task) { server.execute(task); }
|
||||||
|
|
||||||
|
@Override public void runAsync(Runnable task) { io.execute(task); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package net.scopenet.fabric.features;
|
||||||
|
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.stats.Stats;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.scopenet.core.CorePlayer;
|
||||||
|
import net.scopenet.core.Item;
|
||||||
|
import net.scopenet.core.Pos;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** A {@link CorePlayer} over a Fabric ServerPlayer. */
|
||||||
|
final class FabricPlayer implements CorePlayer {
|
||||||
|
private final ServerPlayer player;
|
||||||
|
private final FabricPlatform platform;
|
||||||
|
private final Perms perms;
|
||||||
|
|
||||||
|
FabricPlayer(ServerPlayer player, FabricPlatform platform, Perms perms) {
|
||||||
|
this.player = player; this.platform = platform; this.perms = perms;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerPlayer handle() { return player; }
|
||||||
|
|
||||||
|
@Override public UUID uuid() { return player.getUUID(); }
|
||||||
|
@Override public String name() { return player.getGameProfile().getName(); }
|
||||||
|
|
||||||
|
@Override public Pos pos() {
|
||||||
|
ServerLevel level = player.serverLevel();
|
||||||
|
return new Pos(level.dimension().location().toString(), player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void send(String message) { player.sendSystemMessage(Component.literal(message)); }
|
||||||
|
|
||||||
|
@Override public void teleport(Pos destination) {
|
||||||
|
ServerLevel level = platform.level(destination.world());
|
||||||
|
if (level == null) { send("§cThat dimension is not loaded."); return; }
|
||||||
|
player.teleportTo(level, destination.x(), destination.y(), destination.z(), destination.yaw(), destination.pitch());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean hasPermission(String node) { return perms.has(player, node); }
|
||||||
|
|
||||||
|
@Override public long playtimeSeconds() {
|
||||||
|
return player.getStats().getValue(Stats.CUSTOM.get(Stats.PLAY_TIME)) / 20L;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public Optional<Item> heldItem() {
|
||||||
|
ItemStack stack = player.getMainHandItem();
|
||||||
|
return stack.isEmpty() ? Optional.empty() : Optional.of(FabricItems.toCore(stack));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void clearHeld() { player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); }
|
||||||
|
|
||||||
|
@Override public void give(Item item) {
|
||||||
|
ItemStack stack = FabricItems.fromCore(item);
|
||||||
|
if (stack.isEmpty()) { send("§cCould not deliver " + item.id() + " x" + item.count() + ". Ask an admin."); return; }
|
||||||
|
player.getInventory().add(stack);
|
||||||
|
if (!stack.isEmpty()) player.drop(stack, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package net.scopenet.fabric.features;
|
||||||
|
|
||||||
|
import net.luckperms.api.LuckPerms;
|
||||||
|
import net.luckperms.api.LuckPermsProvider;
|
||||||
|
import net.luckperms.api.model.user.User;
|
||||||
|
import net.luckperms.api.util.Tristate;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
|
||||||
|
/** Only loaded when the LuckPerms mod is present (see {@link Perms}). */
|
||||||
|
final class LuckPermsLookup {
|
||||||
|
private LuckPermsLookup() {}
|
||||||
|
|
||||||
|
/** True or false when LuckPerms has the node set for this player, null when it has no opinion. */
|
||||||
|
static Boolean check(ServerPlayer player, String node) {
|
||||||
|
LuckPerms api = LuckPermsProvider.get();
|
||||||
|
User user = api.getUserManager().getUser(player.getUUID());
|
||||||
|
if (user == null) return null;
|
||||||
|
Tristate result = user.getCachedData().getPermissionData().checkPermission(node);
|
||||||
|
return result == Tristate.UNDEFINED ? null : result.asBoolean();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package net.scopenet.fabric.features;
|
||||||
|
|
||||||
|
import net.fabricmc.loader.api.FabricLoader;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission checks. With the LuckPerms mod installed its answer wins whenever the node is set; otherwise (or when
|
||||||
|
* LuckPerms has no opinion) the defaults apply: ordinary commands follow {@code permissions.default_level}, and
|
||||||
|
* admin nodes need operator level 2. Same node names as the Paper plugin, so one LuckPerms setup serves both.
|
||||||
|
*/
|
||||||
|
final class Perms {
|
||||||
|
private static final boolean LUCKPERMS = FabricLoader.getInstance().isModLoaded("luckperms");
|
||||||
|
private final boolean everyoneByDefault;
|
||||||
|
|
||||||
|
Perms(boolean everyoneByDefault) { this.everyoneByDefault = everyoneByDefault; }
|
||||||
|
|
||||||
|
static boolean luckPermsPresent() { return LUCKPERMS; }
|
||||||
|
|
||||||
|
static boolean isAdminNode(String node) {
|
||||||
|
return node.equals("scopenet.claims.bypass") || node.startsWith("scopenet.admin")
|
||||||
|
|| node.equals("scopenet.command.scopenet.status") || node.equals("scopenet.command.scopenet.reload");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean has(ServerPlayer player, String node) {
|
||||||
|
if (LUCKPERMS) {
|
||||||
|
try {
|
||||||
|
Boolean answer = LuckPermsLookup.check(player, node);
|
||||||
|
if (answer != null) return answer;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
// LuckPerms not ready yet or an API change: fall back to the defaults below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isAdminNode(node)) return player.hasPermissions(2);
|
||||||
|
return everyoneByDefault || player.hasPermissions(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package net.scopenet.fabric.features;
|
||||||
|
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.scopenet.core.Placeholders;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers %scopenet:level%, %scopenet:balance%… with Placeholder API (Fabric) when it is installed. Done through
|
||||||
|
* reflection so SCOPENET builds and runs without it and tolerates small API differences; any problem just leaves
|
||||||
|
* the placeholders off and is reported in /scopenet status.
|
||||||
|
*/
|
||||||
|
final class PlaceholderApiModule {
|
||||||
|
private PlaceholderApiModule() {}
|
||||||
|
|
||||||
|
/** Returns how many placeholders were registered; throws if the API isn't usable. */
|
||||||
|
static int register(Placeholders values) throws ReflectiveOperationException {
|
||||||
|
Class<?> placeholders = Class.forName("eu.pb4.placeholders.api.Placeholders");
|
||||||
|
Class<?> handlerType = Class.forName("eu.pb4.placeholders.api.PlaceholderHandler");
|
||||||
|
Class<?> resultType = Class.forName("eu.pb4.placeholders.api.PlaceholderResult");
|
||||||
|
Method register = placeholders.getMethod("register", ResourceLocation.class, handlerType);
|
||||||
|
Method value = resultType.getMethod("value", String.class);
|
||||||
|
Method invalid = resultType.getMethod("invalid", String.class);
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
for (String key : Placeholders.KEYS) {
|
||||||
|
Object handler = Proxy.newProxyInstance(handlerType.getClassLoader(), new Class<?>[]{handlerType}, (proxy, method, args) -> {
|
||||||
|
if (method.getDeclaringClass() == Object.class) return method.invoke(new Object(), args);
|
||||||
|
Object context = args[0];
|
||||||
|
ServerPlayer player = (ServerPlayer) context.getClass().getMethod("player").invoke(context);
|
||||||
|
if (player == null) return invalid.invoke(null, "No player");
|
||||||
|
Optional<String> text = values.resolve(player.getUUID(), key);
|
||||||
|
return text.isPresent() ? value.invoke(null, text.get()) : invalid.invoke(null, "Unknown placeholder");
|
||||||
|
});
|
||||||
|
register.invoke(null, new ResourceLocation("scopenet", key), handler);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package net.scopenet.fabric.features;
|
||||||
|
|
||||||
|
import net.scopenet.core.CommandSet;
|
||||||
|
import net.scopenet.core.CoreCommand;
|
||||||
|
import net.scopenet.core.CorePlayer;
|
||||||
|
import net.scopenet.core.Format;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/** /scopenet help | status | reload (alias /sn), with the same nodes as the Paper plugin. */
|
||||||
|
final class ScopenetCommand implements CoreCommand {
|
||||||
|
private final FabricFeatures features;
|
||||||
|
|
||||||
|
ScopenetCommand(FabricFeatures features) { this.features = features; }
|
||||||
|
|
||||||
|
@Override public String name() { return "scopenet"; }
|
||||||
|
@Override public List<String> aliases() { return List.of("sn"); }
|
||||||
|
@Override public String permission() { return "scopenet.command.scopenet"; }
|
||||||
|
|
||||||
|
@Override public List<String> complete(CorePlayer player, String[] args) {
|
||||||
|
return args.length <= 1 ? List.of("help", "status", "reload") : List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void run(CorePlayer p, String[] args) {
|
||||||
|
String sub = args.length == 0 ? "help" : args[0].toLowerCase(Locale.ROOT);
|
||||||
|
switch (sub) {
|
||||||
|
case "help" -> {
|
||||||
|
if (!p.hasPermission("scopenet.command.scopenet.help")) { p.send(Format.RED + "You do not have permission to view SCOPENET help."); return; }
|
||||||
|
help(p);
|
||||||
|
}
|
||||||
|
case "status" -> {
|
||||||
|
if (!p.hasPermission("scopenet.command.scopenet.status")) { p.send(Format.RED + "You do not have permission to use /scopenet status."); return; }
|
||||||
|
features.sendStatus(p);
|
||||||
|
}
|
||||||
|
case "reload" -> {
|
||||||
|
if (!p.hasPermission("scopenet.command.scopenet.reload")) { p.send(Format.RED + "You do not have permission to reload SCOPENET."); return; }
|
||||||
|
features.reload(p);
|
||||||
|
}
|
||||||
|
default -> p.send(Format.RED + "Unknown command. Use " + Format.YELLOW + "/scopenet help");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void help(CorePlayer p) {
|
||||||
|
p.send(Format.GOLD + "=================== " + Format.YELLOW + "SCOPENET Commands" + Format.GOLD + " ===================");
|
||||||
|
CommandSet set = features.commandSet();
|
||||||
|
if (set == null) return;
|
||||||
|
for (CoreCommand c : set.all()) {
|
||||||
|
if (!p.hasPermission(c.permission())) continue;
|
||||||
|
String aliases = c.aliases().isEmpty() ? "" : Format.DARK_GRAY + " (" + String.join(", ", c.aliases().stream().map(a -> "/" + a).toList()) + ")";
|
||||||
|
p.send(Format.YELLOW + " /" + c.name() + aliases);
|
||||||
|
}
|
||||||
|
p.send(Format.GRAY + "Open the launcher's Command guide for what each one does.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,12 @@ public final class Bridge {
|
|||||||
private static int mapTicks;
|
private static int mapTicks;
|
||||||
private Bridge() {}
|
private Bridge() {}
|
||||||
|
|
||||||
|
/** The running integration, or null before the server has started. */
|
||||||
|
public static Integration integration() { return integration; }
|
||||||
|
|
||||||
|
/** Lets a permissions system (LuckPerms) say who may build in other guilds' claims. Default: nobody. */
|
||||||
|
public static volatile java.util.function.Predicate<ServerPlayer> claimBypass = player -> false;
|
||||||
|
|
||||||
public static void start(MinecraftServer server) {
|
public static void start(MinecraftServer server) {
|
||||||
try {
|
try {
|
||||||
integration = new Integration(Settings.load(Path.of("config/scopenet.properties")),
|
integration = new Integration(Settings.load(Path.of("config/scopenet.properties")),
|
||||||
@@ -103,6 +109,7 @@ public final class Bridge {
|
|||||||
Integration current = integration;
|
Integration current = integration;
|
||||||
if (current == null) return false;
|
if (current == null) return false;
|
||||||
if (!current.settings().guildsEnabled() || !current.settings().landClaimingEnabled()) return true;
|
if (!current.settings().guildsEnabled() || !current.settings().landClaimingEnabled()) return true;
|
||||||
|
if (claimBypass.test(player)) return true;
|
||||||
String dimension = Compat.dimension(player);
|
String dimension = Compat.dimension(player);
|
||||||
ChunkCheckResult check = current.checkChunk(dimension, pos.getX() >> 4, pos.getZ() >> 4, player.getUUID());
|
ChunkCheckResult check = current.checkChunk(dimension, pos.getX() >> 4, pos.getZ() >> 4, player.getUUID());
|
||||||
if (check.allowed()) return true;
|
if (check.allowed()) return true;
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package net.scopenet.paper;
|
||||||
|
|
||||||
|
import net.scopenet.core.*;
|
||||||
|
import net.scopenet.integration.Integration;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.plugin.messaging.PluginMessageListener;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talks to the optional SCOPENET client mod (HUD, claim overlay, shop and market windows) through plugin messaging,
|
||||||
|
* using the same shared logic the Fabric server mod uses. Display only: the client's actions are ordinary commands.
|
||||||
|
*/
|
||||||
|
final class ClientLinkBridge implements PluginMessageListener {
|
||||||
|
private final ScopenetPlugin plugin;
|
||||||
|
private final Env env;
|
||||||
|
private final PlayerCache cache;
|
||||||
|
private final ClientLink link;
|
||||||
|
private final Placeholders placeholders;
|
||||||
|
|
||||||
|
ClientLinkBridge(ScopenetPlugin plugin, Integration integration) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
PaperPlatform platform = new PaperPlatform(plugin);
|
||||||
|
Features features = new Features(true, plugin.getConfig().getBoolean("economy.enabled", true), integration.settings().guildsEnabled(),
|
||||||
|
integration.settings().socialEnabled(), integration.settings().landClaimingEnabled(),
|
||||||
|
plugin.getConfig().getBoolean("clientlink.enabled", true), plugin.getConfig().getString("economy.currency-symbol", "$"));
|
||||||
|
Logger log = plugin.getLogger();
|
||||||
|
env = new Env(platform, new PanelAdapter(integration), features, Path.of(plugin.getDataFolder().getPath()), System::currentTimeMillis, new Random(), log);
|
||||||
|
cache = new PlayerCache(env);
|
||||||
|
placeholders = new Placeholders(env, cache);
|
||||||
|
link = new ClientLink(env, cache, Bukkit.getServer().getName(), (p, bytes) -> {
|
||||||
|
Player target = Bukkit.getPlayer(p.uuid());
|
||||||
|
if (target != null) target.sendPluginMessage(plugin, Wire.S2C, bytes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientLink link() { return link; }
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, Wire.S2C);
|
||||||
|
Bukkit.getMessenger().registerIncomingPluginChannel(plugin, Wire.C2S, this);
|
||||||
|
Bukkit.getScheduler().runTaskTimer(plugin, () -> {
|
||||||
|
cache.tick(p -> link.pushState(p));
|
||||||
|
link.tick();
|
||||||
|
}, 40, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, Wire.S2C);
|
||||||
|
Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, Wire.C2S, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
void quit(Player player) { link.disconnected(player.getUniqueId()); }
|
||||||
|
|
||||||
|
@Override public void onPluginMessageReceived(String channel, Player player, byte[] message) {
|
||||||
|
if (!Wire.C2S.equals(channel)) return;
|
||||||
|
link.receive(new PaperPlatform.PaperPlayer(player), message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Panel events (level-ups, achievements, guild changes) become toasts on clients. Any thread. */
|
||||||
|
void panelEvents(com.google.gson.JsonArray events) { link.onPanelEvents(events); }
|
||||||
|
|
||||||
|
/** Bukkit adapters for the shared logic. Only what the client link needs is real. */
|
||||||
|
static final class PaperPlatform implements Platform {
|
||||||
|
private final ScopenetPlugin plugin;
|
||||||
|
PaperPlatform(ScopenetPlugin plugin) { this.plugin = plugin; }
|
||||||
|
|
||||||
|
@Override public Optional<CorePlayer> player(UUID uuid) { return Optional.ofNullable(Bukkit.getPlayer(uuid)).map(PaperPlayer::new); }
|
||||||
|
@Override public Optional<CorePlayer> playerByName(String name) { return Optional.ofNullable(Bukkit.getPlayerExact(name)).map(PaperPlayer::new); }
|
||||||
|
@Override public Collection<? extends CorePlayer> online() {
|
||||||
|
List<CorePlayer> out = new ArrayList<>();
|
||||||
|
for (Player p : Bukkit.getOnlinePlayers()) out.add(new PaperPlayer(p));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
@Override public Optional<Pos> safeSurface(String world, int x, int z) { return Optional.empty(); }
|
||||||
|
@Override public Pos worldSpawn(String world) { return new Pos(world, 0, 64, 0, 0, 0); }
|
||||||
|
@Override public void runMain(Runnable task) {
|
||||||
|
if (Bukkit.isPrimaryThread()) task.run(); else Bukkit.getScheduler().runTask(plugin, task);
|
||||||
|
}
|
||||||
|
@Override public void runAsync(Runnable task) { Bukkit.getScheduler().runTaskAsynchronously(plugin, task); }
|
||||||
|
|
||||||
|
record PaperPlayer(Player p) implements CorePlayer {
|
||||||
|
@Override public UUID uuid() { return p.getUniqueId(); }
|
||||||
|
@Override public String name() { return p.getName(); }
|
||||||
|
@Override public Pos pos() {
|
||||||
|
org.bukkit.Location l = p.getLocation();
|
||||||
|
return new Pos(ScopenetPlugin.dimension(l.getWorld()), l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch());
|
||||||
|
}
|
||||||
|
@Override public void send(String message) { p.sendMessage(message); }
|
||||||
|
@Override public void teleport(Pos destination) { }
|
||||||
|
@Override public boolean hasPermission(String node) { return p.hasPermission(node); }
|
||||||
|
@Override public long playtimeSeconds() { return p.getStatistic(org.bukkit.Statistic.PLAY_ONE_MINUTE) / 20L; }
|
||||||
|
@Override public Optional<Item> heldItem() {
|
||||||
|
ItemStack hand = p.getInventory().getItemInMainHand();
|
||||||
|
if (hand.getType() == Material.AIR) return Optional.empty();
|
||||||
|
String name = hand.hasItemMeta() && hand.getItemMeta().hasDisplayName() ? hand.getItemMeta().getDisplayName() : hand.getType().name().replace('_', ' ');
|
||||||
|
return Optional.of(new Item(hand.getType().name(), name, hand.getAmount(), ""));
|
||||||
|
}
|
||||||
|
@Override public void clearHeld() { }
|
||||||
|
@Override public void give(Item item) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
|||||||
private EconomyHandler economy;
|
private EconomyHandler economy;
|
||||||
private ScopenetApiImpl api;
|
private ScopenetApiImpl api;
|
||||||
private CompatManager compat;
|
private CompatManager compat;
|
||||||
|
private ClientLinkBridge clientLink;
|
||||||
|
|
||||||
@Override public void onEnable() {
|
@Override public void onEnable() {
|
||||||
// Install the deny-by-default listener even if configuration is invalid.
|
// Install the deny-by-default listener even if configuration is invalid.
|
||||||
@@ -58,7 +59,11 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
|||||||
api = new ScopenetApiImpl(integration.client());
|
api = new ScopenetApiImpl(integration.client());
|
||||||
ScopenetApiProvider.register(api);
|
ScopenetApiProvider.register(api);
|
||||||
getServer().getServicesManager().register(ScopenetApi.class, api, this, org.bukkit.plugin.ServicePriority.Normal);
|
getServer().getServicesManager().register(ScopenetApi.class, api, this, org.bukkit.plugin.ServicePriority.Normal);
|
||||||
integration.onNotifications(new EventBridge(this, api, getLogger())::accept);
|
// Optional client mod (HUD, claim overlay, shop and market windows).
|
||||||
|
clientLink = new ClientLinkBridge(this, integration);
|
||||||
|
clientLink.start();
|
||||||
|
EventBridge events = new EventBridge(this, api, getLogger());
|
||||||
|
integration.onNotifications(list -> { events.accept(list); clientLink.panelEvents(list); });
|
||||||
Bukkit.getScheduler().runTaskTimer(this, this::warmProfiles, 100, 20 * 20);
|
Bukkit.getScheduler().runTaskTimer(this, this::warmProfiles, 100, 20 * 20);
|
||||||
|
|
||||||
// Optional integrations (LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, Spark) are found and
|
// Optional integrations (LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, Spark) are found and
|
||||||
@@ -180,6 +185,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
|||||||
|
|
||||||
@Override public void onDisable() {
|
@Override public void onDisable() {
|
||||||
if (compat != null) compat.stop();
|
if (compat != null) compat.stop();
|
||||||
|
if (clientLink != null) clientLink.stop();
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
getServer().getServicesManager().unregister(ScopenetApi.class, api);
|
getServer().getServicesManager().unregister(ScopenetApi.class, api);
|
||||||
ScopenetApiProvider.unregister(api);
|
ScopenetApiProvider.unregister(api);
|
||||||
@@ -213,6 +219,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
|||||||
public void leave(PlayerQuitEvent event) {
|
public void leave(PlayerQuitEvent event) {
|
||||||
deniedAt.remove(event.getPlayer().getUniqueId());
|
deniedAt.remove(event.getPlayer().getUniqueId());
|
||||||
if (api != null) api.forget(event.getPlayer().getUniqueId());
|
if (api != null) api.forget(event.getPlayer().getUniqueId());
|
||||||
|
if (clientLink != null) clientLink.quit(event.getPlayer());
|
||||||
if (integration != null) integration.activity.leave(event.getPlayer().getUniqueId(), event.getPlayer().getName());
|
if (integration != null) integration.activity.leave(event.getPlayer().getUniqueId(), event.getPlayer().getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -269,17 +269,21 @@ public final class EconomyHandler implements CommandExecutor, Listener {
|
|||||||
|
|
||||||
/** {@code asGuild}: list on behalf of the player's guild, or buy with its bank (see /guild market). */
|
/** {@code asGuild}: list on behalf of the player's guild, or buy with its bank (see /guild market). */
|
||||||
void handleMarket(Player player, String[] args, boolean asGuild) {
|
void handleMarket(Player player, String[] args, boolean asGuild) {
|
||||||
if (asGuild && args.length >= 2 && args[0].equalsIgnoreCase("buy")) {
|
if (args.length >= 2 && args[0].equalsIgnoreCase("buy")) {
|
||||||
|
if (!asGuild && !player.hasPermission("scopenet.command.market.buy")) {
|
||||||
|
player.sendMessage(ChatColor.RED + "You do not have permission to buy from the market.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
long listing;
|
long listing;
|
||||||
try { listing = Long.parseLong(args[1].replace("#", "")); } catch (NumberFormatException e) {
|
try { listing = Long.parseLong(args[1].replace("#", "")); } catch (NumberFormatException e) {
|
||||||
player.sendMessage(ChatColor.RED + "Usage: /guild market buy <listing number> (shown as Listing #n in /market)");
|
player.sendMessage(ChatColor.RED + "Usage: /" + (asGuild ? "guild market" : "market") + " buy <listing number> (shown as Listing #n in /market)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
JsonObject payload = new JsonObject();
|
JsonObject payload = new JsonObject();
|
||||||
payload.addProperty("listing_id", listing);
|
payload.addProperty("listing_id", listing);
|
||||||
payload.addProperty("buyer_uuid", player.getUniqueId().toString());
|
payload.addProperty("buyer_uuid", player.getUniqueId().toString());
|
||||||
payload.addProperty("buyer_name", player.getName());
|
payload.addProperty("buyer_name", player.getName());
|
||||||
payload.addProperty("as_guild", true);
|
if (asGuild) payload.addProperty("as_guild", true);
|
||||||
operations.enqueue(player, "economy/market/buy", payload, Collections.emptyList(), null);
|
operations.enqueue(player, "economy/market/buy", payload, Collections.emptyList(), null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,3 +93,12 @@ integrations:
|
|||||||
spark:
|
spark:
|
||||||
enabled: true
|
enabled: true
|
||||||
report-interval-seconds: 30
|
report-interval-seconds: 30
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Client mod link
|
||||||
|
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
|
||||||
|
# client mod (HUD widgets, claim overlay, shop and market windows). Players without it
|
||||||
|
# are unaffected. Display only: everything the client does is an ordinary command.
|
||||||
|
# ==============================================================================
|
||||||
|
clientlink:
|
||||||
|
enabled: true
|
||||||
@@ -10,6 +10,8 @@ include 'common', 'api', 'paper'
|
|||||||
// Build loaders separately: Forge and Loom use different Gradle generations.
|
// Build loaders separately: Forge and Loom use different Gradle generations.
|
||||||
if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric'
|
if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric'
|
||||||
if (providers.gradleProperty('loader').orNull == 'forge') include 'forge'
|
if (providers.gradleProperty('loader').orNull == 'forge') include 'forge'
|
||||||
|
// The optional client mod (HUD, claim overlay, shop and market windows). Fabric 1.20.1; build with -Ploader=fabric-client.
|
||||||
|
if (providers.gradleProperty('loader').orNull == 'fabric-client') include 'fabric-client'
|
||||||
def mcVer = (providers.gradleProperty('mcVersion').orNull ?: startParameter.projectProperties['mcVersion'] ?: '').toString()
|
def mcVer = (providers.gradleProperty('mcVersion').orNull ?: startParameter.projectProperties['mcVersion'] ?: '').toString()
|
||||||
if (mcVer.startsWith('26.') || mcVer == '26.x') {
|
if (mcVer.startsWith('26.') || mcVer == '26.x') {
|
||||||
if (findProject(':fabric') != null) project(':fabric').buildFileName = 'modern.gradle'
|
if (findProject(':fabric') != null) project(':fabric').buildFileName = 'modern.gradle'
|
||||||
|
|||||||
Reference in new issue
Block a user