feat: GuiPresence (chat-service presence backend, gateway route, mod module); restore TODO history

This commit is contained in:
loki5512344 2026-10-02 13:41:49 +02:00
parent 8d9a2ad0d6
commit e82efb74e7
Signed by: boba
GPG key ID: 253067914055423B
44 changed files with 2202 additions and 19 deletions

View file

@ -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)));
}
}

View file

@ -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();

View file

@ -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) {
}

View file

@ -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));
}
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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
}
}
}

View file

@ -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));
}
}

View file

@ -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));
}
}

View file

@ -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"
}

View file

@ -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": "Размер панели"
}

View file

@ -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));
}
}