feat: GuiPresence (chat-service presence backend, gateway route, mod module); restore TODO history
This commit is contained in:
parent
8d9a2ad0d6
commit
e82efb74e7
44 changed files with 2202 additions and 19 deletions
|
|
@ -0,0 +1,53 @@
|
|||
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules;
|
||||
|
||||
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.MenuPresence;
|
||||
import dev.loki.lovisual.features.gui.clickgui.tabs.ClickGuiSearch;
|
||||
|
||||
/** Builds the {@link MenuPresence} snapshot of the modules menu for the given mouse position. */
|
||||
final class ModulesMenuPresence {
|
||||
|
||||
private static final int NO_MODULE = 255;
|
||||
|
||||
private ModulesMenuPresence() {
|
||||
}
|
||||
|
||||
static MenuPresence snapshot(ModulesMenuScreen screen, float mx, float my, boolean pressed) {
|
||||
float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE, maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE;
|
||||
for (ModulesMenuPanel p : screen.panels) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x + p.w);
|
||||
maxY = Math.max(maxY, p.y + p.h);
|
||||
}
|
||||
if (maxX <= minX || maxY <= minY) return null;
|
||||
int category = -1;
|
||||
int module = NO_MODULE;
|
||||
boolean settings = false;
|
||||
int scroll = 0;
|
||||
for (ModulesMenuPanel p : screen.panels) {
|
||||
if (!ModulesMenuScreen.inside(mx, my, p.x, p.y, p.w, p.h)) continue;
|
||||
category = p.index;
|
||||
settings = p.selected != null;
|
||||
if (settings) {
|
||||
scroll = ratio(p.settingsSmoothScroll, p.maxSettingsScroll);
|
||||
break;
|
||||
}
|
||||
scroll = ratio(p.modulesSmoothScroll, p.maxModulesScroll);
|
||||
for (int i = 0; i < p.hits.size() && i < NO_MODULE; i++) {
|
||||
ModulesMenuPanel.ModuleHit h = p.hits.get(i);
|
||||
if (ModulesMenuScreen.inside(mx, my, h.x(), h.y(), h.w(), h.h())) {
|
||||
module = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
float x = Math.max(0.0f, Math.min(1.0f, (mx - minX) / (maxX - minX)));
|
||||
float y = Math.max(0.0f, Math.min(1.0f, (my - minY) / (maxY - minY)));
|
||||
return new MenuPresence(category, module, settings, ClickGuiSearch.isActive(), x, y, pressed, scroll);
|
||||
}
|
||||
|
||||
private static int ratio(float value, float max) {
|
||||
return max <= 0.0f ? 0 : Math.max(0, Math.min(255, Math.round(Math.abs(value) / max * 255.0f)));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules;
|
||||
|
||||
import dev.loki.lovisual.config.i18n.MainConfig;
|
||||
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.MenuPresence;
|
||||
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.ModulesMenuCategory;
|
||||
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.ModulesMenuStyle;
|
||||
import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer;
|
||||
|
|
@ -32,6 +33,8 @@ public final class ModulesMenuScreen {
|
|||
float searchAnim = 0.0f; float searchX; float searchY; float searchW; float searchH;
|
||||
float searchBtnX; float searchBtnY; float searchBtnW; float searchBtnH; float searchBtnHoverAnim;
|
||||
float descAnim; String descTitle; String descText;
|
||||
private static ModulesMenuScreen active;
|
||||
private float lastMouseX; private float lastMouseY;
|
||||
|
||||
public ModulesMenuScreen() {
|
||||
for (ModulesMenuCategory c : ModulesMenuCategory.values()) panels.add(new ModulesMenuPanel(c, c.ordinal()));
|
||||
|
|
@ -48,12 +51,20 @@ public final class ModulesMenuScreen {
|
|||
}
|
||||
public boolean isVisible() { return openTarget || screenAnim > 0.001f; }
|
||||
|
||||
/** State of the currently shown modules menu for GUI presence, or null when no menu is open. */
|
||||
public static MenuPresence presenceSnapshot(boolean mouseDown) {
|
||||
ModulesMenuScreen s = active;
|
||||
if (s == null || !s.openTarget || s.screenAnim < 0.6f) return null;
|
||||
return ModulesMenuPresence.snapshot(s, s.lastMouseX, s.lastMouseY, mouseDown);
|
||||
}
|
||||
|
||||
public void layout(float x, float y, float w, float h) {
|
||||
this.areaX = x; this.areaY = y; this.areaW = Math.max(1.0f, w); this.areaH = Math.max(1.0f, h);
|
||||
ModulesMenuLayout.layout(this, panels, areaX, areaY, areaW, areaH);
|
||||
}
|
||||
|
||||
public void render(float mouseX, float mouseY) {
|
||||
lastMouseX = mouseX; lastMouseY = mouseY; active = this;
|
||||
ModulesMenuChromeRenderer.ensureFonts();
|
||||
ModulesMenuStyle.syncTheme();
|
||||
float dt = AnimationUtility.deltaTime();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model;
|
||||
|
||||
/**
|
||||
* Snapshot of the local ClickGUI for GUI presence: hovered column, module row under the cursor (255 = none),
|
||||
* whether that column shows a module's settings, search state, cursor position normalised to the panels' bounds
|
||||
* (0..1), left button and list scroll (0..255).
|
||||
*/
|
||||
public record MenuPresence(int category, int moduleIndex, boolean settingsOpen, boolean searching,
|
||||
float x, float y, boolean pressed, int scroll) {
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.presence;
|
||||
|
||||
import dev.loki.lovisual.config.values.primitive.BooleanValue;
|
||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||
import dev.loki.lovisual.features.module.core.Module;
|
||||
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||
import dev.loki.lovisual.features.module.phase.WorldPhase;
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceClient;
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceTimeline;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ServerData;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Shows other LoVisual users' open ClickGUI as a small mirror held in front of them, and shares your own cursor the
|
||||
* same way. Needs a linked LoVisual account (device token) and a multiplayer server; data goes through the
|
||||
* chat-service presence endpoint and contains only the cursor position and a few menu flags.
|
||||
*/
|
||||
@ModuleInfo(
|
||||
id = "guipresence",
|
||||
displayName = "GuiPresence",
|
||||
category = ModuleCategory.VISUALS,
|
||||
description = "Shows the ClickGUI other LoVisual users have open, and shares yours"
|
||||
)
|
||||
public class GuiPresence extends Module {
|
||||
|
||||
private static final float FADE_RANGE = 6.0f;
|
||||
private static final int MAX_WANT = 40;
|
||||
|
||||
private final PresenceClient client = new PresenceClient();
|
||||
private final PresenceSampler sampler = new PresenceSampler();
|
||||
private final PresencePanelRenderer panel = new PresencePanelRenderer();
|
||||
private final BooleanValue share = bool("guiPresenceShare", "share", true);
|
||||
private final BooleanValue showOthers = bool("guiPresenceShowOthers", "show_others", true);
|
||||
private final NumberValue<Integer> distance = visibleWhen(num("guiPresenceDistance", "distance", 32, 8, 64), showOthers::get);
|
||||
private final NumberValue<Float> scale = visibleWhen(num("guiPresenceScale", "scale", 1.0f, 0.5f, 2.0f), showOthers::get);
|
||||
|
||||
@Override
|
||||
public WorldPhase getWorldPhase() {
|
||||
return WorldPhase.END_MAIN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
client.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTick() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
ServerData server = mc.getCurrentServer();
|
||||
if (!isEnabled() || mc.level == null || mc.player == null || server == null) return;
|
||||
if (!share.get() && !showOthers.get()) return;
|
||||
long now = System.currentTimeMillis();
|
||||
boolean open = share.get() && sampler.sample(now);
|
||||
if (!client.due(now, open)) return;
|
||||
client.sync(now, server.ip, mc.player.getUUID(), open ? sampler.drain() : null, share.get(), wanted(mc));
|
||||
}
|
||||
|
||||
private List<UUID> wanted(Minecraft mc) {
|
||||
List<UUID> want = new ArrayList<>();
|
||||
if (!showOthers.get()) return want;
|
||||
double max = distance.get() + FADE_RANGE;
|
||||
for (Player p : mc.level.players()) {
|
||||
if (p == mc.player || mc.player.distanceTo(p) > max) continue;
|
||||
want.add(p.getUUID());
|
||||
if (want.size() >= MAX_WANT) break;
|
||||
}
|
||||
return want;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (!isEnabled() || !showOthers.get() || mc.level == null || mc.player == null) return;
|
||||
long now = System.currentTimeMillis();
|
||||
for (Map.Entry<UUID, PresenceTimeline> e : client.remote().entrySet()) {
|
||||
Player p = mc.level.getPlayerByUUID(e.getKey());
|
||||
if (p == null || p == mc.player || !e.getValue().live(now)) continue;
|
||||
float d = mc.player.distanceTo(p);
|
||||
float fade = Math.min(1.0f, (distance.get() - d) / FADE_RANGE + 1.0f);
|
||||
panel.draw(renderer, p, tickDelta, e.getValue(), now, scale.get(), Math.max(0.0f, fade));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.presence;
|
||||
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceCodec;
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceTimeline;
|
||||
import dev.loki.lovisual.features.theme.Theme;
|
||||
import dev.loki.lovisual.render.engine.color.RenderColor;
|
||||
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/**
|
||||
* Draws a remote player's ClickGUI as a small flat mirror held in front of them: four columns, the one under their
|
||||
* cursor lit, the row under the cursor highlighted, the cursor itself and a ring while the button is pressed.
|
||||
* Built from coloured quads in a plane spanned by the player's facing, so it needs no text or textures.
|
||||
*/
|
||||
final class PresencePanelRenderer {
|
||||
|
||||
private static final float ASPECT = 65.0f / 104.0f;
|
||||
private static final float FORWARD = 0.85f;
|
||||
private static final float HEIGHT_RATIO = 0.6f;
|
||||
private static final int COLUMNS = 4;
|
||||
private static final int ROWS = 7;
|
||||
private static final float GAP = 0.018f;
|
||||
private static final float LAYER = 0.0025f;
|
||||
|
||||
private final float[] a = new float[3];
|
||||
private final float[] up = {0.0f, 1.0f, 0.0f};
|
||||
private final float[] f = new float[3];
|
||||
private final float[] c = new float[3];
|
||||
private float width;
|
||||
private float height;
|
||||
|
||||
void draw(Renderer3D renderer, Player player, float tickDelta, PresenceTimeline timeline, long now,
|
||||
float scale, float alpha) {
|
||||
PresenceCodec.Gui gui = timeline.meta();
|
||||
PresenceTimeline.Point cursor = timeline.at(now);
|
||||
if (gui == null || gui.closed() || cursor == null || alpha <= 0.01f) return;
|
||||
MeshBuilder m = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN);
|
||||
if (m == null) return;
|
||||
place(player, tickDelta, scale);
|
||||
int accent = Theme.theme().accent() & 0x00FFFFFF;
|
||||
int bg = Theme.theme().windowBg() & 0x00FFFFFF;
|
||||
quad(m, 0.0f, 0.0f, 1.0f, 1.0f, 0, rgba(bg, 0.82f * alpha));
|
||||
float colW = (1.0f - GAP * (COLUMNS + 1)) / COLUMNS;
|
||||
float cx = cursor.x() / 255.0f;
|
||||
float cy = cursor.y() / 255.0f;
|
||||
for (int i = 0; i < COLUMNS; i++) {
|
||||
float x0 = GAP + i * (colW + GAP);
|
||||
boolean active = i == gui.category();
|
||||
quad(m, x0, 0.04f, x0 + colW, 0.96f, 1, rgba(active ? accent : bg, (active ? 0.28f : 0.5f) * alpha));
|
||||
quad(m, x0, 0.04f, x0 + colW, 0.13f, 2, rgba(accent, (active ? 0.7f : 0.3f) * alpha));
|
||||
rows(m, i, x0, colW, active, gui, cy, accent, alpha);
|
||||
}
|
||||
if (gui.searching()) quad(m, 0.3f, -0.1f, 0.7f, -0.03f, 2, rgba(accent, 0.85f * alpha));
|
||||
cursor(m, cx, cy, cursor.pressed(), accent, alpha);
|
||||
}
|
||||
|
||||
private void rows(MeshBuilder m, int column, float x0, float colW, boolean active, PresenceCodec.Gui gui,
|
||||
float cy, int accent, float alpha) {
|
||||
float top = 0.17f;
|
||||
float step = (0.95f - top) / ROWS;
|
||||
int hot = active && !gui.settings() ? (int) Math.floor((cy - top) / step) : -1;
|
||||
for (int r = 0; r < ROWS; r++) {
|
||||
float y0 = top + r * step;
|
||||
float w = active && gui.settings() ? colW * (0.35f + 0.55f * (((r * 37 + column * 11) % 10) / 10.0f)) : colW * 0.82f;
|
||||
boolean lit = r == hot && gui.moduleIndex() != PresenceCodec.NO_MODULE;
|
||||
quad(m, x0 + colW * 0.09f, y0, x0 + colW * 0.09f + w, y0 + step * 0.7f, 2,
|
||||
rgba(lit ? accent : 0xFFFFFF, (lit ? 0.75f : 0.1f) * alpha));
|
||||
}
|
||||
}
|
||||
|
||||
private void cursor(MeshBuilder m, float cx, float cy, boolean pressed, int accent, float alpha) {
|
||||
float s = 0.014f;
|
||||
quad(m, cx - s, cy - s, cx + s, cy + s, 4, rgba(0xFFFFFF, alpha));
|
||||
if (!pressed) return;
|
||||
float r = 0.035f;
|
||||
float t = 0.006f;
|
||||
int col = rgba(accent, 0.9f * alpha);
|
||||
quad(m, cx - r, cy - r, cx + r, cy - r + t, 3, col);
|
||||
quad(m, cx - r, cy + r - t, cx + r, cy + r, 3, col);
|
||||
quad(m, cx - r, cy - r, cx - r + t, cy + r, 3, col);
|
||||
quad(m, cx + r - t, cy - r, cx + r, cy + r, 3, col);
|
||||
}
|
||||
|
||||
private void place(Player p, float tickDelta, float scale) {
|
||||
double yaw = Math.toRadians(p.yBodyRot);
|
||||
a[0] = (float) Math.cos(yaw);
|
||||
a[1] = 0.0f;
|
||||
a[2] = (float) Math.sin(yaw);
|
||||
f[0] = (float) -Math.sin(yaw);
|
||||
f[1] = 0.0f;
|
||||
f[2] = (float) Math.cos(yaw);
|
||||
Vec3 pos = p.getPosition(tickDelta);
|
||||
float h = p.getBbHeight() * HEIGHT_RATIO;
|
||||
c[0] = (float) pos.x + f[0] * FORWARD;
|
||||
c[1] = (float) pos.y + h;
|
||||
c[2] = (float) pos.z + f[2] * FORWARD;
|
||||
width = scale;
|
||||
height = scale * ASPECT;
|
||||
}
|
||||
|
||||
/** Quad in panel space: u to the right, v downward, both 0..1; {@code layer} lifts it toward the viewer. */
|
||||
private void quad(MeshBuilder m, float u0, float v0, float u1, float v1, int layer, int argb) {
|
||||
m.ensureQuadCapacity();
|
||||
RenderColor col = new RenderColor(argb);
|
||||
int i1 = vertex(m, u0, v0, layer, col);
|
||||
int i2 = vertex(m, u1, v0, layer, col);
|
||||
int i3 = vertex(m, u1, v1, layer, col);
|
||||
int i4 = vertex(m, u0, v1, layer, col);
|
||||
m.quad(i1, i2, i3, i4);
|
||||
}
|
||||
|
||||
private int vertex(MeshBuilder m, float u, float v, int layer, RenderColor col) {
|
||||
float du = (u - 0.5f) * width;
|
||||
float dv = (0.5f - v) * height;
|
||||
float off = layer * LAYER;
|
||||
return m.vec3(c[0] + a[0] * du + up[0] * dv + f[0] * off,
|
||||
c[1] + a[1] * du + up[1] * dv + f[1] * off,
|
||||
c[2] + a[2] * du + up[2] * dv + f[2] * off).color(col).next();
|
||||
}
|
||||
|
||||
private static int rgba(int rgb, float alpha) {
|
||||
int al = Math.max(0, Math.min(255, Math.round(alpha * 255.0f)));
|
||||
return (al << 24) | (rgb & 0x00FFFFFF);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.presence;
|
||||
|
||||
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.ModulesMenuScreen;
|
||||
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.MenuPresence;
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceCodec;
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceTimeline;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Samples the local ClickGUI cursor every 100 ms into a short queue that is flushed with the next sync. */
|
||||
final class PresenceSampler {
|
||||
|
||||
private final List<PresenceCodec.Sample> queue = new ArrayList<>();
|
||||
private MenuPresence last;
|
||||
private long lastSample;
|
||||
|
||||
/** @return true while a menu is open (and sampled) */
|
||||
boolean sample(long now) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
boolean down = mc.getWindow() != null
|
||||
&& GLFW.glfwGetMouseButton(mc.getWindow().handle(), GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS;
|
||||
MenuPresence snap = ModulesMenuScreen.presenceSnapshot(down);
|
||||
if (snap == null) {
|
||||
last = null;
|
||||
queue.clear();
|
||||
return false;
|
||||
}
|
||||
last = snap;
|
||||
if (now - lastSample >= PresenceTimeline.SAMPLE_MS) {
|
||||
lastSample = now;
|
||||
queue.add(new PresenceCodec.Sample(Math.round(snap.x() * 255.0f), Math.round(snap.y() * 255.0f),
|
||||
snap.pressed(), snap.scroll()));
|
||||
while (queue.size() > PresenceCodec.MAX_SAMPLES) queue.remove(0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Payload for the current menu state with the queued samples, or null when no menu is open. */
|
||||
PresenceCodec.Gui drain() {
|
||||
if (last == null) return null;
|
||||
PresenceCodec.Gui gui = new PresenceCodec.Gui(last.category(), last.settingsOpen(), last.searching(), false,
|
||||
last.moduleIndex(), new ArrayList<>(queue));
|
||||
queue.clear();
|
||||
return gui;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package dev.loki.lovisual.features.platform.presence;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import dev.loki.lovisual.features.platform.PlatformSession;
|
||||
import dev.loki.lovisual.features.platform.http.ApiResult;
|
||||
import dev.loki.lovisual.features.platform.http.ClientThread;
|
||||
import dev.loki.lovisual.features.platform.http.PlatformHttpClient;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Poll loop of GUI presence ({@code POST /presence/gui}): one request publishes the local menu state and returns the
|
||||
* state of nearby players. Must be driven from the client thread; responses hop back onto it, so {@link #remote()}
|
||||
* is only touched there. Adaptive cadence: fast while a menu (local or remote) is live, slow heartbeat otherwise.
|
||||
*/
|
||||
public final class PresenceClient {
|
||||
|
||||
private static final long FAST_MS = 350L;
|
||||
private static final long IDLE_MS = 2000L;
|
||||
private static final long ERROR_BACKOFF_MS = 8000L;
|
||||
private static final long AUTH_BACKOFF_MS = 30000L;
|
||||
private static final long FORGET_MS = 15000L;
|
||||
|
||||
private final Map<UUID, PresenceTimeline> remote = new HashMap<>();
|
||||
private boolean inFlight;
|
||||
private boolean anyLive;
|
||||
private long nextAt;
|
||||
|
||||
public Map<UUID, PresenceTimeline> remote() {
|
||||
return remote;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
remote.clear();
|
||||
anyLive = false;
|
||||
nextAt = 0L;
|
||||
}
|
||||
|
||||
public boolean due(long now, boolean localOpen) {
|
||||
return !inFlight && now >= nextAt && PlatformSession.get().isLinked();
|
||||
}
|
||||
|
||||
/** Next send interval for the current state; exposed so the caller can decide how often to sample. */
|
||||
public long interval(boolean localOpen) {
|
||||
return localOpen || anyLive ? FAST_MS : IDLE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param local local menu state to publish, or null for a "LoVisual is running" heartbeat (ignored when
|
||||
* {@code publish} is false)
|
||||
* @param publish false for a read-only poll
|
||||
*/
|
||||
public void sync(long now, String server, UUID self, PresenceCodec.Gui local, boolean publish, List<UUID> want) {
|
||||
inFlight = true;
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("server", server);
|
||||
body.addProperty("mc", self.toString());
|
||||
body.addProperty("publish", publish);
|
||||
if (publish && local != null) body.addProperty("gui", PresenceCodec.encode(local));
|
||||
JsonArray wanted = new JsonArray();
|
||||
want.forEach(id -> wanted.add(id.toString()));
|
||||
body.add("want", wanted);
|
||||
boolean localOpen = local != null && !local.closed();
|
||||
PlatformHttpClient.post("/presence/gui", body, PlatformSession.get().getDeviceToken())
|
||||
.thenAccept(result -> ClientThread.run(() -> onResult(result, localOpen)));
|
||||
}
|
||||
|
||||
private void onResult(ApiResult result, boolean localOpen) {
|
||||
inFlight = false;
|
||||
long now = System.currentTimeMillis();
|
||||
if (!result.success()) {
|
||||
nextAt = now + (result.statusCode() == 401 || result.statusCode() == 409 ? AUTH_BACKOFF_MS : ERROR_BACKOFF_MS);
|
||||
return;
|
||||
}
|
||||
JsonObject obj = result.asObject();
|
||||
JsonElement players = obj == null ? null : obj.get("players");
|
||||
if (players != null && players.isJsonArray()) {
|
||||
for (JsonElement e : players.getAsJsonArray()) apply(e, now);
|
||||
}
|
||||
for (Iterator<PresenceTimeline> it = remote.values().iterator(); it.hasNext(); ) {
|
||||
if (!it.next().present(now - FORGET_MS + PresenceTimeline.STALE_MS * 4)) it.remove();
|
||||
}
|
||||
anyLive = remote.values().stream().anyMatch(t -> t.live(now));
|
||||
nextAt = now + interval(localOpen);
|
||||
}
|
||||
|
||||
private void apply(JsonElement e, long now) {
|
||||
if (!e.isJsonObject()) return;
|
||||
JsonObject o = e.getAsJsonObject();
|
||||
try {
|
||||
UUID id = UUID.fromString(o.get("mc").getAsString());
|
||||
long age = o.has("age") ? o.get("age").getAsLong() : 0L;
|
||||
JsonElement g = o.get("gui");
|
||||
PresenceCodec.Gui gui = g == null || g.isJsonNull() ? null : PresenceCodec.decode(g.getAsString());
|
||||
if (gui == null) gui = new PresenceCodec.Gui(-1, false, false, true, PresenceCodec.NO_MODULE, List.of());
|
||||
remote.computeIfAbsent(id, k -> new PresenceTimeline()).add(now - age, gui);
|
||||
} catch (RuntimeException ignored) {
|
||||
// malformed entry from the server: skip it, the rest of the response is still valid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package dev.loki.lovisual.features.platform.presence;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Wire format of the GUI presence payload (see {@code backend/chat-service/PLAN.md}): 4 header bytes and up to
|
||||
* 8 cursor samples of 4 bytes. Pure and symmetric, so it is unit-tested without Minecraft.
|
||||
*/
|
||||
public final class PresenceCodec {
|
||||
|
||||
public static final int VERSION = 1;
|
||||
public static final int MAX_SAMPLES = 8;
|
||||
public static final int NO_MODULE = 255;
|
||||
private static final int HEADER = 4;
|
||||
|
||||
/** Cursor position inside the menu (0..255 on each axis), left button state and list scroll (0..255). */
|
||||
public record Sample(int x, int y, boolean pressed, int scroll) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param category menu column 0..15 (-1 is stored as 15 by {@link #encode} and decoded back to -1)
|
||||
* @param moduleIndex index of the module row under the cursor, {@link #NO_MODULE} when none
|
||||
*/
|
||||
public record Gui(int category, boolean settings, boolean searching, boolean closed, int moduleIndex,
|
||||
List<Sample> samples) {
|
||||
}
|
||||
|
||||
private PresenceCodec() {
|
||||
}
|
||||
|
||||
public static String encode(Gui gui) {
|
||||
int n = Math.min(MAX_SAMPLES, gui.samples().size());
|
||||
byte[] out = new byte[HEADER + n * 4];
|
||||
out[0] = VERSION;
|
||||
int cat = gui.category() < 0 ? 15 : gui.category() & 15;
|
||||
out[1] = (byte) (cat | (gui.settings() ? 16 : 0) | (gui.searching() ? 32 : 0) | (gui.closed() ? 128 : 0));
|
||||
out[2] = (byte) gui.moduleIndex();
|
||||
out[3] = (byte) n;
|
||||
for (int i = 0; i < n; i++) {
|
||||
Sample s = gui.samples().get(i);
|
||||
out[HEADER + i * 4] = (byte) clamp(s.x());
|
||||
out[HEADER + i * 4 + 1] = (byte) clamp(s.y());
|
||||
out[HEADER + i * 4 + 2] = (byte) (s.pressed() ? 1 : 0);
|
||||
out[HEADER + i * 4 + 3] = (byte) clamp(s.scroll());
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(out);
|
||||
}
|
||||
|
||||
/** Returns null for anything malformed or from another protocol version. */
|
||||
public static Gui decode(String base64) {
|
||||
if (base64 == null) return null;
|
||||
byte[] in;
|
||||
try {
|
||||
in = Base64.getDecoder().decode(base64);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
if (in.length < HEADER || in[0] != VERSION) return null;
|
||||
int n = in[3] & 0xFF;
|
||||
if (n > MAX_SAMPLES || in.length < HEADER + n * 4) return null;
|
||||
int flags = in[1] & 0xFF;
|
||||
int cat = flags & 15;
|
||||
List<Sample> samples = new ArrayList<>(n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
int o = HEADER + i * 4;
|
||||
samples.add(new Sample(in[o] & 0xFF, in[o + 1] & 0xFF, (in[o + 2] & 1) != 0, in[o + 3] & 0xFF));
|
||||
}
|
||||
return new Gui(cat == 15 ? -1 : cat, (flags & 16) != 0, (flags & 32) != 0, (flags & 128) != 0,
|
||||
in[2] & 0xFF, samples);
|
||||
}
|
||||
|
||||
private static int clamp(int v) {
|
||||
return Math.max(0, Math.min(255, v));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package dev.loki.lovisual.features.platform.presence;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
|
||||
/**
|
||||
* Playback buffer of one remote player's cursor. Payloads arrive in batches (about 3 per second) carrying samples
|
||||
* spaced {@link #SAMPLE_MS} apart; the renderer plays them back {@link #DELAY_MS} late and interpolates, so the
|
||||
* remote cursor moves smoothly instead of in steps.
|
||||
*/
|
||||
public final class PresenceTimeline {
|
||||
|
||||
public static final long SAMPLE_MS = 100L;
|
||||
public static final long DELAY_MS = 300L;
|
||||
/** A remote menu counts as gone after this long without a fresh payload. */
|
||||
public static final long STALE_MS = 2500L;
|
||||
private static final long MAX_BUFFER_MS = 1500L;
|
||||
/** The same server entry polled twice differs only by clock jitter; payloads closer than this are repeats. */
|
||||
private static final long MIN_GAP_MS = 40L;
|
||||
|
||||
public record Point(float x, float y, boolean pressed, int scroll) {
|
||||
}
|
||||
|
||||
private record Timed(long t, PresenceCodec.Sample sample) {
|
||||
}
|
||||
|
||||
private final ArrayDeque<Timed> buffer = new ArrayDeque<>();
|
||||
private long lastSend = Long.MIN_VALUE;
|
||||
private long lastSeen = Long.MIN_VALUE;
|
||||
private PresenceCodec.Gui meta;
|
||||
|
||||
/**
|
||||
* @param sendMs local-clock time the sender pushed this payload (now minus the server-reported age)
|
||||
* @return false when this payload is a repeat of one already consumed
|
||||
*/
|
||||
public boolean add(long sendMs, PresenceCodec.Gui gui) {
|
||||
if (sendMs < lastSend + MIN_GAP_MS) return false;
|
||||
lastSend = sendMs;
|
||||
lastSeen = sendMs;
|
||||
meta = gui;
|
||||
int n = gui.samples().size();
|
||||
for (int i = 0; i < n; i++) {
|
||||
long t = sendMs - (long) (n - 1 - i) * SAMPLE_MS;
|
||||
if (buffer.isEmpty() || t > buffer.peekLast().t()) buffer.addLast(new Timed(t, gui.samples().get(i)));
|
||||
}
|
||||
while (!buffer.isEmpty() && sendMs - buffer.peekFirst().t() > MAX_BUFFER_MS) buffer.pollFirst();
|
||||
return true;
|
||||
}
|
||||
|
||||
public PresenceCodec.Gui meta() {
|
||||
return meta;
|
||||
}
|
||||
|
||||
/** True while the remote menu is open (a payload not marked closed arrived recently). */
|
||||
public boolean live(long nowMs) {
|
||||
return meta != null && !meta.closed() && nowMs - lastSeen < STALE_MS;
|
||||
}
|
||||
|
||||
/** True when any payload (menu open or not) arrived recently, i.e. the player runs LoVisual. */
|
||||
public boolean present(long nowMs) {
|
||||
return meta != null && nowMs - lastSeen < STALE_MS * 4;
|
||||
}
|
||||
|
||||
/** Interpolated cursor at {@code nowMs - DELAY_MS}, or null with nothing buffered. */
|
||||
public Point at(long nowMs) {
|
||||
if (buffer.isEmpty()) return null;
|
||||
long t = nowMs - DELAY_MS;
|
||||
Timed prev = null;
|
||||
for (Timed next : buffer) {
|
||||
if (next.t() >= t) {
|
||||
if (prev == null) return point(next.sample());
|
||||
float k = (float) (t - prev.t()) / Math.max(1L, next.t() - prev.t());
|
||||
return new Point(lerp(prev.sample().x(), next.sample().x(), k), lerp(prev.sample().y(), next.sample().y(), k),
|
||||
next.sample().pressed(), next.sample().scroll());
|
||||
}
|
||||
prev = next;
|
||||
}
|
||||
return point(prev.sample());
|
||||
}
|
||||
|
||||
private static Point point(PresenceCodec.Sample s) {
|
||||
return new Point(s.x(), s.y(), s.pressed(), s.scroll());
|
||||
}
|
||||
|
||||
private static float lerp(float a, float b, float k) {
|
||||
return a + (b - a) * Math.max(0.0f, Math.min(1.0f, k));
|
||||
}
|
||||
}
|
||||
|
|
@ -3679,5 +3679,10 @@
|
|||
"setting.killeffect.blast_color": "Blast color",
|
||||
"setting.killeffect.blast_color.option.Natural": "Natural",
|
||||
"setting.killeffect.blast_color.option.Client": "Client theme",
|
||||
"setting.killeffect.blast_color.option.Custom": "Custom"
|
||||
"setting.killeffect.blast_color.option.Custom": "Custom",
|
||||
"module.guipresence.description": "Shows the ClickGUI other LoVisual users have open, and shares yours.",
|
||||
"setting.guipresence.share": "Share my menu",
|
||||
"setting.guipresence.show_others": "Show other players' menus",
|
||||
"setting.guipresence.distance": "Distance",
|
||||
"setting.guipresence.scale": "Panel size"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3470,5 +3470,10 @@
|
|||
"setting.killeffect.blast_color": "Цвет взрыва",
|
||||
"setting.killeffect.blast_color.option.Natural": "Природный",
|
||||
"setting.killeffect.blast_color.option.Client": "Цвет темы",
|
||||
"setting.killeffect.blast_color.option.Custom": "Свой"
|
||||
"setting.killeffect.blast_color.option.Custom": "Свой",
|
||||
"module.guipresence.description": "Показывает открытый ClickGUI других пользователей LoVisual и делится вашим.",
|
||||
"setting.guipresence.share": "Показывать моё меню",
|
||||
"setting.guipresence.show_others": "Показывать меню других игроков",
|
||||
"setting.guipresence.distance": "Дистанция",
|
||||
"setting.guipresence.scale": "Размер панели"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
package dev.loki.lovisual.features.platform.presence;
|
||||
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceCodec.Gui;
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceCodec.Sample;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PresenceTest {
|
||||
|
||||
private static Gui gui(int n) {
|
||||
List<Sample> samples = new ArrayList<>();
|
||||
for (int i = 0; i < n; i++) samples.add(new Sample(i * 10, 255 - i, i % 2 == 0, i));
|
||||
return new Gui(2, true, false, false, 7, samples);
|
||||
}
|
||||
|
||||
@Test
|
||||
void codecRoundTrips() {
|
||||
Gui in = gui(5);
|
||||
Gui out = PresenceCodec.decode(PresenceCodec.encode(in));
|
||||
assertEquals(in, out);
|
||||
}
|
||||
|
||||
@Test
|
||||
void codecKeepsClosedFlagAndNoCategory() {
|
||||
Gui in = new Gui(-1, false, true, true, PresenceCodec.NO_MODULE, List.of());
|
||||
assertEquals(in, PresenceCodec.decode(PresenceCodec.encode(in)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodedPayloadFitsServerLimit() {
|
||||
assertTrue(PresenceCodec.encode(gui(20)).length() <= 48);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeRejectsGarbage() {
|
||||
assertNull(PresenceCodec.decode(null));
|
||||
assertNull(PresenceCodec.decode("!!!"));
|
||||
assertNull(PresenceCodec.decode(Base64.getEncoder().encodeToString(new byte[]{9, 0, 0, 0})));
|
||||
assertNull(PresenceCodec.decode(Base64.getEncoder().encodeToString(new byte[]{1, 0, 0, 5})));
|
||||
}
|
||||
|
||||
@Test
|
||||
void timelineInterpolatesBetweenSamples() {
|
||||
PresenceTimeline tl = new PresenceTimeline();
|
||||
Gui g = new Gui(0, false, false, false, 255, List.of(new Sample(0, 0, false, 0), new Sample(100, 200, true, 0)));
|
||||
assertTrue(tl.add(1000, g));
|
||||
PresenceTimeline.Point p = tl.at(1000 + PresenceTimeline.DELAY_MS - 50);
|
||||
assertEquals(50.0f, p.x(), 1e-3f);
|
||||
assertEquals(100.0f, p.y(), 1e-3f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void timelineIgnoresRepeatedPayloadsAndGoesStale() {
|
||||
PresenceTimeline tl = new PresenceTimeline();
|
||||
Gui g = gui(2);
|
||||
assertTrue(tl.add(5000, g));
|
||||
assertFalse(tl.add(5000, g));
|
||||
assertFalse(tl.add(4000, g));
|
||||
assertTrue(tl.live(5000 + 100));
|
||||
assertFalse(tl.live(5000 + PresenceTimeline.STALE_MS + 1));
|
||||
assertTrue(tl.present(5000 + PresenceTimeline.STALE_MS + 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void closedMenuIsNotLiveButStillPresent() {
|
||||
PresenceTimeline tl = new PresenceTimeline();
|
||||
tl.add(100, new Gui(-1, false, false, true, 255, List.of()));
|
||||
assertFalse(tl.live(200));
|
||||
assertTrue(tl.present(200));
|
||||
assertNull(tl.at(200));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue