Fix and restyle the emote wheel

The slot index was taken before the middle slot was skipped, so page one
showed emotes 0-4 and 6-8: emote 5 never appeared and emote 8 showed on
two pages. Emotes now fill the eight cells around the middle in order,
and slots past the last emote aren't drawn, so they can't play a null
emote.

Emotes show as EmoteTiles: the preview in its own colors (it was tinted,
almost black on hover), the name under it, an accent outline on hover, and
an icon when an emote has no preview. The middle cell holds the page and
its arrows when there's more than one page. Emote names and cosmetic names
share Utils.nameFromId.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-27 18:43:27 +02:00
co-authored by claude
parent caf472da96
commit 05dbec028a
7 changed files with 256 additions and 89 deletions
@@ -8,6 +8,7 @@ import java.util.Optional;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.emotes.core.Emote;
import org.saturnclient.emotes.core.EmoteRegistry;
import org.saturnclient.ui.Utils;
/**
* Saturn's emotes, loaded from {@code assets/saturnclient/player_animations/<id>.json} (Emotecraft
@@ -23,6 +24,13 @@ public class Emotes {
/** The emotes that loaded, in wheel order. */
public static List<String> availableEmotes = new ArrayList<>();
/**
* The emote's name as shown to players.
*/
public static String getName(String id) {
return id.equals("tpose") ? "T-Pose" : Utils.nameFromId(id);
}
public static void initialize() {
for (String id : IDS) {
String path = "/assets/saturnclient/player_animations/" + id + ".json";
@@ -47,6 +47,24 @@ public class Utils {
};
}
/**
* A display name from an id: each underscore-separated word capitalized, so
* "black_hole_white" becomes "Black Hole White".
*/
public static String nameFromId(String id) {
StringBuilder builder = new StringBuilder();
for (String word : id.split("_")) {
if (word.isEmpty()) {
continue;
}
if (!builder.isEmpty()) {
builder.append(' ');
}
builder.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return builder.toString();
}
public static <T> T getOrNull(List<T> list, int index) {
return (index >= 0 && index < list.size()) ? list.get(index) : null;
}
@@ -0,0 +1,68 @@
package org.saturnclient.ui.components;
import org.saturnclient.common.ref.asset.IdentifierRef;
import org.saturnclient.config.Theme;
import org.saturnclient.cosmetics.Emotes;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
/**
* One emote in the emote wheel: its preview in its own colors and its name.
* An emote without a preview shows an icon instead.
*/
public class EmoteTile extends Element {
public static final int SIZE = 86;
private static final int INSET = 6;
private static final int NAME_AREA = 20;
private static final int PLACEHOLDER = 26;
private static final float NAME_SCALE = 0.56f;
private final String name;
/** The preview, or null when the emote has none */
private final IdentifierRef preview;
private final Runnable onPlay;
public EmoteTile(String emote, Runnable onPlay) {
IdentifierRef texture = Textures.getEmotePreview(emote);
this.name = Emotes.getName(emote);
this.preview = Textures.exists(texture) ? texture : null;
this.onPlay = onPlay;
this.width = SIZE;
this.height = SIZE;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
boolean hovered = ctx.isHovering();
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, hovered ? 2 : 1,
Theme.withAlpha(0.72f, Theme.PRIMARY.value),
hovered ? Theme.ACCENT.value : Theme.withAlpha(0.06f, Theme.FOREGROUND.value));
int imageSize = height - INSET * 2 - NAME_AREA;
if (preview != null) {
renderScope.drawTexture(preview, (width - imageSize) / 2, INSET, 0, 0, imageSize, imageSize);
} else {
int iconY = INSET + (imageSize - PLACEHOLDER) / 2;
renderScope.drawTexture(Textures.ICON_EMOTE, (width - PLACEHOLDER) / 2, iconY, 0, 0, PLACEHOLDER,
PLACEHOLDER, Theme.getMutedFg());
}
String text = Fonts.ellipsize(name, Theme.FONT.value, NAME_SCALE, width - INSET * 2);
int textX = (width - (int) (Fonts.getWidth(text, Theme.FONT.value) * NAME_SCALE)) / 2;
int textY = height - INSET - NAME_AREA + (NAME_AREA - (int) (Fonts.getHeight() * NAME_SCALE)) / 2;
renderScope.drawText(NAME_SCALE, text, textX, textY, Theme.FONT.value,
hovered ? Theme.FOREGROUND.value : Theme.PRIMARY_FG.value);
}
@Override
public void click(int mouseX, int mouseY) {
onPlay.run();
}
}
@@ -1,5 +1,9 @@
package org.saturnclient.ui.resources;
import java.io.IOException;
import java.io.InputStream;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.common.ref.asset.IdentifierRef;
public class Textures {
@@ -43,6 +47,7 @@ public class Textures {
.ofSaturn("textures/gui/components/chevron_right.svg");
public static final IdentifierRef ICON_CHEVRON_DOWN = IdentifierRef
.ofSaturn("textures/gui/components/chevron_down.svg");
public static final IdentifierRef ICON_EMOTE = IdentifierRef.ofSaturn("textures/gui/components/emote.svg");
public static final IdentifierRef ICON_CHECK = IdentifierRef.ofSaturn("textures/gui/components/check.svg");
public static final IdentifierRef LOGO = IdentifierRef.ofSaturn("textures/logo/logo.png");
@@ -61,6 +66,18 @@ public class Textures {
public static final IdentifierRef MOD = IdentifierRef.ofSaturn("textures/gui/components/mod.png");
public static final IdentifierRef MOD_BG = IdentifierRef.ofSaturn("textures/gui/components/mod_bg.png");
/**
* Whether a texture file is in the resources, for falling back when an
* optional one (like an emote's preview) is missing.
*/
public static boolean exists(IdentifierRef texture) {
try (InputStream in = Providers.saturn.getClient().getResource(texture)) {
return in != null;
} catch (IOException e) {
return false;
}
}
public static IdentifierRef getCloakPreview(String cloak) {
return IdentifierRef.ofSaturn("textures/gui/cloak/" + (cloak.isEmpty() ? "none" : cloak) + ".png");
}
@@ -19,6 +19,7 @@ import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.PanelScreen;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.Utils;
import org.saturnclient.ui.anim.SlideFade;
import org.saturnclient.ui.components.CosmeticTile;
import org.saturnclient.ui.components.Sidebar;
@@ -193,18 +194,7 @@ public class CosmeticsMenu extends PanelScreen {
private static String getName(String id) {
String name = NAMES.get(id);
if (name != null) {
return name;
}
StringBuilder builder = new StringBuilder();
for (String word : id.split("_")) {
if (!builder.isEmpty()) {
builder.append(' ');
}
builder.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
return builder.toString();
return name != null ? name : Utils.nameFromId(id);
}
/**
@@ -1,21 +1,43 @@
package org.saturnclient.ui.screens;
import org.saturnclient.cosmetics.Emotes;
import java.util.List;
import org.saturnclient.client.ServiceClient;
import org.saturnclient.common.provider.Providers;
import org.saturnclient.common.ref.asset.IdentifierRef;
import org.saturnclient.config.AnimationConfig;
import org.saturnclient.config.Config;
import org.saturnclient.config.Theme;
import org.saturnclient.cosmetics.Emotes;
import org.saturnclient.ui.Element;
import org.saturnclient.ui.ElementContext;
import org.saturnclient.ui.RenderScope;
import org.saturnclient.ui.SaturnScreen;
import org.saturnclient.ui.Utils;
import org.saturnclient.ui.anim.Fade;
import org.saturnclient.ui.anim.SlideY;
import org.saturnclient.ui.components.EmoteTile;
import org.saturnclient.ui.elements.AnimationStagger;
import org.saturnclient.ui.elements.ImageTexture;
import org.saturnclient.ui.elements.TextureButton;
import org.saturnclient.ui.elements.Label;
import org.saturnclient.ui.resources.Fonts;
import org.saturnclient.ui.resources.Textures;
/**
* A 3x3 wheel of emotes to play, with the page controls in the middle. The
* emote wheel key opens and closes it (in the version's key handler).
*/
public class EmoteWheel extends SaturnScreen {
int page = 1;
private static final int GAP = 10;
private static final int GRID = EmoteTile.SIZE * 3 + GAP * 2;
private static final int LOGO = 48;
/** The grid's cells in the order emotes fill them, around the middle one */
private static final int[][] SLOTS = {
{ 0, 0 }, { 1, 0 }, { 2, 0 },
{ 0, 1 }, { 2, 1 },
{ 0, 2 }, { 1, 2 }, { 2, 2 } };
private int page = 0;
public EmoteWheel() {
super("Emote Wheel");
@@ -23,88 +45,131 @@ public class EmoteWheel extends SaturnScreen {
@Override
public void ui() {
int row = 0;
int col = 0;
// =============================
// Logo
// =============================
draw(new ImageTexture(Textures.LOGO_TEXT)
.dimensions(98, 10)
.center(width, height, 0, -105)
.animation(new Fade(AnimationConfig.logo.duration.value)));
List<String> emotes = Emotes.availableEmotes;
int left = (width - GRID) / 2;
int top = (height - GRID) / 2 + LOGO / 2;
draw(new ImageTexture(Config.getLogo())
.dimensions(98, 98)
.center(width, height, 0, -149)
.animation(new SlideY(AnimationConfig.logo, -20)));
.dimensions(LOGO, LOGO)
.position((width - LOGO) / 2, top - 20 - LOGO)
.animation(new SlideY(AnimationConfig.logo, -12)));
// =============================
// Navigation Buttons
// =============================
draw(new TextureButton(Textures.LEFT, () -> {
if (page > 1) {
page--;
elements.clear();
ui();
}
}).dimensions(30, 30)
.center(width, height, -50, 105)
.animation(new Fade(500)));
draw(new TextureButton(Textures.RIGHT, () -> {
if ((page * 8) < Emotes.availableEmotes.size()) {
page++;
elements.clear();
ui();
}
}).dimensions(30, 30)
.center(width, height, 50, 105)
.animation(new Fade(500)));
// =============================
// Emote Grid (Staggered)
// =============================
AnimationStagger emoteStagger = new AnimationStagger(AnimationConfig.emoteWheel);
while (row < 3) {
int idx = (row * 3 + col) + ((page - 1) * 8);
if (row == 1 && col == 1) {
col++;
}
String emote = Utils.getOrNull(Emotes.availableEmotes, idx);
emoteStagger.draw(
new TextureButton(Textures.getEmotePreview(emote), () -> {
Providers.saturn.playEmote(ServiceClient.uuid, emote);
ServiceClient.emote(emote);
provider.close();
})
.dimensions(70, 70)
.center(width, height,
-80 + (col * 80),
-35 + (row * 80))
.animation(new SlideY(AnimationConfig.emoteWheel, 12)));
if (col < 2) {
col++;
} else {
col = 0;
row++;
}
if (emotes.isEmpty()) {
String message = "No emotes available";
int x = (width - (int) (Fonts.getWidth(message, Theme.getBodyFont()) * 0.7f)) / 2;
draw(new Label(() -> message, 0.7f, Theme::getBodyFont, Theme::getMutedFg).position(x, top + GRID / 2));
return;
}
draw(emoteStagger);
int pages = (emotes.size() + SLOTS.length - 1) / SLOTS.length;
page = Math.min(page, pages - 1);
AnimationStagger grid = new AnimationStagger(AnimationConfig.emoteWheel);
for (int slot = 0; slot < SLOTS.length; slot++) {
int index = page * SLOTS.length + slot;
if (index >= emotes.size()) {
break;
}
String emote = emotes.get(index);
grid.draw(new EmoteTile(emote, () -> play(emote))
.position(SLOTS[slot][0] * (EmoteTile.SIZE + GAP), SLOTS[slot][1] * (EmoteTile.SIZE + GAP))
.animation(new SlideY(AnimationConfig.emoteWheel, 12)));
}
draw(grid.position(left, top));
draw(new PageControl(page, pages, () -> turnPage(-1), () -> turnPage(1))
.position(left + EmoteTile.SIZE + GAP, top + EmoteTile.SIZE + GAP)
.animation(new Fade(AnimationConfig.emoteWheel)));
}
private void play(String emote) {
Providers.saturn.playEmote(ServiceClient.uuid, emote);
ServiceClient.emote(emote);
provider.close();
}
private void turnPage(int direction) {
page += direction;
init();
}
@Override
public boolean shouldPause() {
return false;
}
/**
* The middle of the wheel: "Emotes", and the page with arrows to turn it
* when there's more than one.
*/
private static class PageControl extends Element {
private static final int ARROW = 16;
private static final int ARROW_AREA = 26;
private final int page;
private final int pages;
private final Runnable onPrevious;
private final Runnable onNext;
PageControl(int page, int pages, Runnable onPrevious, Runnable onNext) {
this.page = page;
this.pages = pages;
this.onPrevious = onPrevious;
this.onNext = onNext;
this.width = EmoteTile.SIZE;
this.height = EmoteTile.SIZE;
}
@Override
public void render(RenderScope renderScope, ElementContext ctx) {
renderScope.drawRoundedRectangle(0, 0, width, height, Theme.WIDGET_RADIUS.value, 1,
Theme.getPanelBg(), Theme.getBorder());
float titleScale = 0.54f;
drawCentered(renderScope, "EMOTES", titleScale, pages > 1 ? 24 : (height - 10) / 2, Theme.getMutedFg());
if (pages <= 1) {
return;
}
drawCentered(renderScope, (page + 1) + " / " + pages, 0.66f, 48, Theme.FOREGROUND.value);
int arrowY = 48 + ((int) (Fonts.getHeight() * 0.66f) - ARROW) / 2;
drawArrow(renderScope, ctx, Textures.ICON_CHEVRON_LEFT, 4, arrowY, canGoBack());
drawArrow(renderScope, ctx, Textures.ICON_CHEVRON_RIGHT, width - 4 - ARROW, arrowY, canGoForward());
}
private void drawArrow(RenderScope renderScope, ElementContext ctx, IdentifierRef icon, int x, int y,
boolean enabled) {
boolean hovered = enabled && ctx.isHovering(x - 5, 0, ARROW_AREA, height);
int color = !enabled ? Theme.withAlpha(0.2f, Theme.FOREGROUND.value)
: hovered ? Theme.ACCENT.value : Theme.getMutedFg();
renderScope.drawTexture(icon, x, y, 0, 0, ARROW, ARROW, color);
}
private void drawCentered(RenderScope renderScope, String text, float scale, int y, int color) {
int x = (width - (int) (Fonts.getWidth(text, Theme.FONT.value) * scale)) / 2;
renderScope.drawText(scale, text, x, y, Theme.FONT.value, color);
}
@Override
public void click(int mouseX, int mouseY) {
if (mouseX < ARROW_AREA && canGoBack()) {
onPrevious.run();
} else if (mouseX > width - ARROW_AREA && canGoForward()) {
onNext.run();
}
}
private boolean canGoBack() {
return page > 0;
}
private boolean canGoForward() {
return page < pages - 1;
}
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/></svg>

After

Width:  |  Height:  |  Size: 328 B