diff --git a/src/main/java/org/saturnclient/cosmetics/Emotes.java b/src/main/java/org/saturnclient/cosmetics/Emotes.java index b4f00dd..f245f64 100644 --- a/src/main/java/org/saturnclient/cosmetics/Emotes.java +++ b/src/main/java/org/saturnclient/cosmetics/Emotes.java @@ -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/.json} (Emotecraft @@ -23,6 +24,13 @@ public class Emotes { /** The emotes that loaded, in wheel order. */ public static List 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"; diff --git a/src/main/java/org/saturnclient/ui/Utils.java b/src/main/java/org/saturnclient/ui/Utils.java index 5be5f4d..b9287dc 100644 --- a/src/main/java/org/saturnclient/ui/Utils.java +++ b/src/main/java/org/saturnclient/ui/Utils.java @@ -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 getOrNull(List list, int index) { return (index >= 0 && index < list.size()) ? list.get(index) : null; } diff --git a/src/main/java/org/saturnclient/ui/components/EmoteTile.java b/src/main/java/org/saturnclient/ui/components/EmoteTile.java new file mode 100644 index 0000000..ede8058 --- /dev/null +++ b/src/main/java/org/saturnclient/ui/components/EmoteTile.java @@ -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(); + } +} diff --git a/src/main/java/org/saturnclient/ui/resources/Textures.java b/src/main/java/org/saturnclient/ui/resources/Textures.java index 8372396..8ecd804 100644 --- a/src/main/java/org/saturnclient/ui/resources/Textures.java +++ b/src/main/java/org/saturnclient/ui/resources/Textures.java @@ -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"); } diff --git a/src/main/java/org/saturnclient/ui/screens/CosmeticsMenu.java b/src/main/java/org/saturnclient/ui/screens/CosmeticsMenu.java index 8fa0556..5e95bef 100644 --- a/src/main/java/org/saturnclient/ui/screens/CosmeticsMenu.java +++ b/src/main/java/org/saturnclient/ui/screens/CosmeticsMenu.java @@ -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); } /** diff --git a/src/main/java/org/saturnclient/ui/screens/EmoteWheel.java b/src/main/java/org/saturnclient/ui/screens/EmoteWheel.java index 2a870ec..771fd40 100644 --- a/src/main/java/org/saturnclient/ui/screens/EmoteWheel.java +++ b/src/main/java/org/saturnclient/ui/screens/EmoteWheel.java @@ -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 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; + } + } } diff --git a/src/main/resources/assets/saturnclient/textures/gui/components/emote.svg b/src/main/resources/assets/saturnclient/textures/gui/components/emote.svg new file mode 100644 index 0000000..912c98c --- /dev/null +++ b/src/main/resources/assets/saturnclient/textures/gui/components/emote.svg @@ -0,0 +1 @@ +