revamp skilltree visuals

This commit is contained in:
2026-09-19 20:43:30 +02:00
parent cef5d40796
commit 743cb506bc
9 changed files with 635 additions and 291 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ dependencies {
// If the group id is "net.minecraft" and the artifact id is one of ["client", "server", "joined"],
// then special handling is done to allow a setup of a vanilla dependency without the use of an external repository.
minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}"
implementation fg.deobf("net.fretux.ascend:Ascend:3.0.0")
implementation fg.deobf("net.fretux.ascend:Ascend:3.1.1")
// Example mod dependency with JEI - using fg.deobf() ensures the dependency is remapped to your development mappings
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime
+1 -1
View File
@@ -48,7 +48,7 @@ mod_name=Fretux's Skill Engine
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=All Rights Reserved
# The mod version. See https://semver.org/
mod_version=1.4.8
mod_version=1.4.9
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
@@ -18,11 +18,11 @@ import java.util.List;
public class AbilityBindingScreen extends Screen {
private static final int PANEL_WIDTH = 280;
private static final int PANEL_HEIGHT = 180;
private static final int PANEL_PADDING = 10;
private static final int PANEL_HEIGHT = 204;
private static final int PANEL_PADDING = 16;
private static final int SLOT_BUTTON_WIDTH = 76;
private static final int BUTTON_HEIGHT = 20;
private static final int BUTTON_GAP = 8;
private static final int BUTTON_GAP = 6;
private final AbilityNode ability;
@@ -43,7 +43,7 @@ public class AbilityBindingScreen extends Screen {
int boundSlot = SkilltreeClientState.getSlotOfAbility(ability.getId());
for (int slot = 1; slot <= visibleSlots; slot++) {
int currentSlot = slot;
Button button = addRenderableWidget(Button.builder(Component.literal("Slot " + slot),
Button button = addRenderableWidget(SkillUi.button(Component.literal("Slot " + slot),
b -> bind(currentSlot))
.pos(buttonX + (SLOT_BUTTON_WIDTH + BUTTON_GAP) * (slot - 1), buttonY)
.size(SLOT_BUTTON_WIDTH, BUTTON_HEIGHT)
@@ -53,7 +53,7 @@ public class AbilityBindingScreen extends Screen {
button.setMessage(Component.literal("Bound " + slot));
}
}
addRenderableWidget(Button.builder(Component.literal("Cancel"),
addRenderableWidget(SkillUi.button(Component.literal("Cancel"),
b -> onClose()).pos(x + PANEL_WIDTH / 2 - 40, y + PANEL_HEIGHT - 28).size(80, BUTTON_HEIGHT).build());
}
@@ -84,14 +84,15 @@ public class AbilityBindingScreen extends Screen {
int x = (width - PANEL_WIDTH) / 2;
int y = (height - PANEL_HEIGHT) / 2;
gfx.fill(0, 0, width, height, 0xAA000000);
gfx.fill(x, y, x + PANEL_WIDTH, y + PANEL_HEIGHT, 0xFF222222);
SkillUi.panel(gfx, x, y, PANEL_WIDTH, PANEL_HEIGHT);
gfx.drawString(font, "ASSIGN ABILITY", x + 16, y + 14, SkillUi.ACCENT, false);
ResourceLocation icon = ability.getIcon();
if (icon != null) {
gfx.blit(icon, x + PANEL_WIDTH / 2 - 10, y + 10, 0, 0, 20, 20, 20, 20);
gfx.blit(icon, x + 16, y + 34, 0, 0, 20, 20, 20, 20);
}
gfx.drawCenteredString(font, ability.getTitle(), x + PANEL_WIDTH / 2, y + 35, 0xFFFFFF);
gfx.drawString(font, font.plainSubstrByWidth(ability.getTitle().getString(), PANEL_WIDTH - 64), x + 44, y + 40, SkillUi.TEXT, false);
if (ability.getDescription() != null) {
drawDescription(gfx, x + PANEL_PADDING, y + 50, y + PANEL_HEIGHT - 60);
drawDescription(gfx, x + PANEL_PADDING, y + 66, y + PANEL_HEIGHT - 60);
}
super.render(gfx, mouseX, mouseY, partialTick);
@@ -109,8 +110,8 @@ public class AbilityBindingScreen extends Screen {
if (lineY + font.lineHeight > bottomY) {
break;
}
gfx.drawString(font, line.getString(), x, lineY, 0xDDDDDD);
lineY += font.lineHeight;
gfx.drawString(font, line.getString(), x, lineY, SkillUi.MUTED);
lineY += font.lineHeight + 3;
}
}
}
@@ -11,7 +11,7 @@ public class AbilityHudOverlay {
public static void render(RenderGuiEvent.Post event) {
Minecraft mc = Minecraft.getInstance();
if (mc.player == null) return;
int xStart = event.getGuiGraphics().guiWidth() - 80;
int xStart = event.getGuiGraphics().guiWidth() - 124;
int yStart = 10;
int slots = SkilltreeClientState.getAbilitySlots().length;
for (int slot = 1; slot <= slots; slot++) {
@@ -22,13 +22,17 @@ public class AbilityHudOverlay {
int cd = SkilltreeClientState.getClientCooldown(slot);
ResourceLocation icon = ability.getIcon();
GuiGraphics gfx = event.getGuiGraphics();
gfx.blit(icon, xStart, yStart, 0, 0, 16, 16, 16, 16);
if (cd > 0) {
String text = String.valueOf(cd / 20);
gfx.drawString(mc.font, text, xStart + 20, yStart + 4, 0xFF5555);
gfx.fill(xStart, yStart, xStart + 112, yStart + 26, SkillUi.BACKGROUND);
gfx.fill(xStart, yStart, xStart + 2, yStart + 26, cd > 0 ? SkillUi.BORDER : SkillUi.ACCENT);
if (icon != null) {
gfx.blit(icon, xStart + 7, yStart + 5, 0, 0, 16, 16, 16, 16);
}
gfx.drawString(mc.font, "Ability " + slot, xStart + 40, yStart + 4, 0xFFFFFF);
yStart += 20;
if (cd > 0) {
String text = String.valueOf((cd + 19) / 20) + "s";
gfx.drawString(mc.font, text, xStart + 84, yStart + 9, SkillUi.ACCENT);
}
gfx.drawString(mc.font, "Ability " + slot, xStart + 30, yStart + 9, SkillUi.TEXT);
yStart += 30;
}
}
}
@@ -20,9 +20,12 @@ public class AbilitySelectScreen extends Screen {
private final int slot;
private final List<AbilityNode> unlockedAbilities = new ArrayList<>();
private int page = 0;
private static final int PAGE_SIZE = 8;
private static final int PANEL_WIDTH = 260;
private static final int PANEL_HEIGHT = 220;
private int pageSize() {
return Math.max(1, Math.min(8, (height - 108) / 28));
}
private int panelHeight() { return 92 + pageSize() * 28; }
private static final int PANEL_WIDTH = 280;
public AbilitySelectScreen(Screen parent, int slot) {
super(Component.literal("Select Ability"));
@@ -38,46 +41,47 @@ public class AbilitySelectScreen extends Screen {
unlockedAbilities.add(node);
}
}
page = Math.min(page, Math.max(0, (unlockedAbilities.size() - 1) / pageSize()));
buildPageButtons();
}
private void buildPageButtons() {
clearWidgets();
int x = (width - PANEL_WIDTH) / 2;
int y = (height - PANEL_HEIGHT) / 2;
int y = (height - panelHeight()) / 2;
int start = page * PAGE_SIZE;
int end = Math.min(unlockedAbilities.size(), start + PAGE_SIZE);
int rowY = y + 40;
int start = page * pageSize();
int end = Math.min(unlockedAbilities.size(), start + pageSize());
int rowY = y + 52;
for (int i = start; i < end; i++) {
AbilityNode node = unlockedAbilities.get(i);
String label = node.getTitle().getString();
boolean alreadyInThisSlot = SkilltreeClientState.getSlotOfAbility(node.getId()) == slot;
Button btn = Button.builder(Component.literal(label), b -> select(node))
.pos(x + 15, rowY)
.size(PANEL_WIDTH - 30, 20)
Button btn = SkillUi.button(Component.literal(label), b -> select(node))
.pos(x + 16, rowY)
.size(PANEL_WIDTH - 32, 24)
.build();
if (alreadyInThisSlot) {
btn.active = false;
btn.setMessage(Component.literal(label + " (bound)"));
}
addRenderableWidget(btn);
rowY += 24;
rowY += 28;
}
if (page > 0) {
addRenderableWidget(Button.builder(Component.literal("< Prev"), b -> {
addRenderableWidget(SkillUi.button(Component.literal("< Prev"), b -> {
page--;
buildPageButtons();
}).pos(x + 15, y + PANEL_HEIGHT - 28).size(60, 20).build());
}).pos(x + 16, y + panelHeight() - 28).size(60, 20).build());
}
if ((page + 1) * PAGE_SIZE < unlockedAbilities.size()) {
addRenderableWidget(Button.builder(Component.literal("Next >"), b -> {
if ((page + 1) * pageSize() < unlockedAbilities.size()) {
addRenderableWidget(SkillUi.button(Component.literal("Next >"), b -> {
page++;
buildPageButtons();
}).pos(x + 80, y + PANEL_HEIGHT - 28).size(60, 20).build());
}).pos(x + 80, y + panelHeight() - 28).size(60, 20).build());
}
addRenderableWidget(Button.builder(Component.literal("Back"), b -> back())
.pos(x + PANEL_WIDTH - 70, y + PANEL_HEIGHT - 28).size(60, 20).build());
addRenderableWidget(SkillUi.button(Component.literal("Back"), b -> back())
.pos(x + PANEL_WIDTH - 70, y + panelHeight() - 28).size(60, 20).build());
}
private void select(AbilityNode node) {
@@ -105,11 +109,14 @@ public class AbilitySelectScreen extends Screen {
public void render(@NotNull GuiGraphics gfx, int mouseX, int mouseY, float partialTick) {
this.renderBackground(gfx);
int x = (width - PANEL_WIDTH) / 2;
int y = (height - PANEL_HEIGHT) / 2;
int y = (height - panelHeight()) / 2;
gfx.fill(0, 0, width, height, 0xAA000000);
gfx.fill(x, y, x + PANEL_WIDTH, y + PANEL_HEIGHT, 0xFF222222);
String title = "Select for slot " + (slot == 1 ? "Y" : slot == 2 ? "X" : "C");
gfx.drawCenteredString(font, Component.literal(title), x + PANEL_WIDTH / 2, y + 12, 0xFFFFFF);
SkillUi.panel(gfx, x, y, PANEL_WIDTH, panelHeight());
SkillUi.heading(gfx, font, "CHOOSE ABILITY", "Slot " + slot + " / Page " + (page + 1)
+ " of " + Math.max(1, (unlockedAbilities.size() + pageSize() - 1) / pageSize()), x, y);
if (unlockedAbilities.isEmpty()) {
gfx.drawString(font, "Unlock an ability in the skill tree.", x + 16, y + 58, SkillUi.MUTED, false);
}
super.render(gfx, mouseX, mouseY, partialTick);
}
}
@@ -4,17 +4,16 @@ import net.fretux.skillengine.skilltree.AbilityNode;
import net.fretux.skillengine.skilltree.AbilityNodeRegistry;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.NotNull;
public class AbilitySlotsScreen extends Screen {
private static final int PANEL_WIDTH = 260;
private static final int MIN_PANEL_HEIGHT = 180;
private static final int PANEL_WIDTH = 280;
private static final int MIN_PANEL_HEIGHT = 204;
private static final int SLOT_TEXT_RIGHT_PADDING = 8;
private static final int REBIND_BUTTON_WIDTH = 80;
private static final int REBIND_BUTTON_WIDTH = 62;
public AbilitySlotsScreen() {
super(Component.literal("Abilities"));
}
@@ -24,16 +23,16 @@ public class AbilitySlotsScreen extends Screen {
int x = (width - PANEL_WIDTH) / 2;
int panelHeight = panelHeight();
int y = (height - panelHeight) / 2;
int rowY = y + 40;
int buttonH = 20;
int buttonX = x + PANEL_WIDTH - REBIND_BUTTON_WIDTH - 15;
int rowY = y + 52;
int buttonH = 28;
int buttonX = x + PANEL_WIDTH - REBIND_BUTTON_WIDTH - 16;
int slots = SkilltreeClientState.getAbilitySlots().length;
for (int slot = 1; slot <= slots; slot++) {
int currentSlot = slot;
addRenderableWidget(Button.builder(Component.literal("Rebind"), b -> openSelect(currentSlot))
.pos(buttonX, rowY + (slot - 1) * 35).size(REBIND_BUTTON_WIDTH, buttonH).build());
addRenderableWidget(SkillUi.button(Component.literal("Rebind"), b -> openSelect(currentSlot))
.pos(buttonX, rowY + (slot - 1) * 40).size(REBIND_BUTTON_WIDTH, buttonH).build());
}
addRenderableWidget(Button.builder(Component.literal("Close"), b -> onClose())
addRenderableWidget(SkillUi.button(Component.literal("Close"), b -> onClose())
.pos(x + PANEL_WIDTH - 70, y + panelHeight - 28).size(60, 20).build());
}
@@ -58,19 +57,19 @@ public class AbilitySlotsScreen extends Screen {
int panelHeight = panelHeight();
int y = (height - panelHeight) / 2;
gfx.fill(0, 0, width, height, 0xAA000000);
gfx.fill(x, y, x + PANEL_WIDTH, y + panelHeight, 0xFF222222);
gfx.drawCenteredString(font, Component.literal("Ability Slots"), x + PANEL_WIDTH / 2, y + 12, 0xFFFFFF);
int rowY = y + 40;
SkillUi.panel(gfx, x, y, PANEL_WIDTH, panelHeight);
SkillUi.heading(gfx, font, "ABILITY LOADOUT", "Prepare your active skills", x, y);
int rowY = y + 52;
int slots = SkilltreeClientState.getAbilitySlots().length;
for (int slot = 1; slot <= slots; slot++) {
drawSlotRow(gfx, x + 15, rowY + (slot - 1) * 35, slot, "Slot " + slot);
drawSlotRow(gfx, x + 16, rowY + (slot - 1) * 40, slot, "Slot " + slot);
}
super.render(gfx, mouseX, mouseY, partialTick);
}
private void drawSlotRow(GuiGraphics gfx, int x, int y, int slot, String label) {
String slotLabel = label + ":";
gfx.drawString(font, slotLabel, x, y + 6, 0xFFFFFF);
gfx.fill(x, y - 2, x + PANEL_WIDTH - 32, y + 32, SkillUi.SURFACE);
gfx.drawString(font, label.toUpperCase(java.util.Locale.ROOT), x + 8, y + 2, SkillUi.MUTED, false);
ResourceLocation abilityId = SkilltreeClientState.getAbilityInSlot(slot);
String title = "Empty";
ResourceLocation icon = null;
@@ -84,21 +83,22 @@ public class AbilitySlotsScreen extends Screen {
}
}
if (icon != null) {
int iconX = x + font.width(slotLabel) + 6;
gfx.blit(icon, iconX, y - 2, 0, 0, 20, 20, 20, 20);
int textX = iconX + 26;
gfx.drawString(font, fitTitle(title, textX), textX, y + 6, 0xDDDDDD);
int iconX = x + 8;
gfx.blit(icon, iconX, y + 13, 0, 0, 16, 16, 16, 16);
int textX = iconX + 22;
gfx.drawString(font, fitTitle(title, textX), textX, y + 17, SkillUi.TEXT);
} else {
int textX = x + font.width(slotLabel) + 6;
gfx.drawString(font, fitTitle(title, textX), textX, y + 6, 0x888888);
int textX = x + 8;
gfx.drawString(font, fitTitle(title, textX), textX, y + 17, SkillUi.MUTED);
}
}
private String fitTitle(String title, int textX) {
int panelX = (width - PANEL_WIDTH) / 2;
int buttonX = panelX + PANEL_WIDTH - REBIND_BUTTON_WIDTH - 15;
int buttonX = panelX + PANEL_WIDTH - REBIND_BUTTON_WIDTH - 16;
int availableWidth = buttonX - SLOT_TEXT_RIGHT_PADDING - textX;
if (availableWidth <= 0 || font.width(title) <= availableWidth) {
if (availableWidth <= 0) return "";
if (font.width(title) <= availableWidth) {
return title;
}
String suffix = "...";
@@ -111,6 +111,6 @@ public class AbilitySlotsScreen extends Screen {
private int panelHeight() {
int slots = SkilltreeClientState.getAbilitySlots().length;
return Math.max(MIN_PANEL_HEIGHT, 80 + slots * 35);
return Math.max(MIN_PANEL_HEIGHT, 92 + slots * 40);
}
}
@@ -0,0 +1,66 @@
package net.fretux.skillengine.client;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.network.chat.Component;
/** Shared charcoal and iron surfaces for the skill interface. */
final class SkillUi {
static final int BACKGROUND = 0xFF181A1D;
static final int SURFACE = 0xFF22252A;
static final int HEADER = 0xFF292D32;
static final int BORDER = 0xFF555B63;
static final int ACCENT = 0xFFD0A447;
static final int TEXT = 0xFFE7E7E7;
static final int MUTED = 0xFFA9ADB2;
private SkillUi() {}
static void panel(GuiGraphics gfx, int x, int y, int width, int height) {
gfx.fill(x + 3, y + 4, x + width + 3, y + height + 4, 0x66000000);
gfx.fill(x - 1, y - 1, x + width + 1, y + height + 1, BORDER);
gfx.fill(x, y, x + width, y + height, BACKGROUND);
}
static void heading(GuiGraphics gfx, Font font, String title, String subtitle, int x, int y) {
gfx.drawString(font, title, x + 16, y + 14, TEXT, false);
gfx.drawString(font, subtitle, x + 16, y + 29, MUTED, false);
}
static Builder button(Component label, Button.OnPress action) {
return new Builder(label, action);
}
static final class Builder {
private final Component label;
private final Button.OnPress action;
private int x, y, width = 80, height = 20;
Builder(Component label, Button.OnPress action) {
this.label = label;
this.action = action;
}
Builder pos(int x, int y) { this.x = x; this.y = y; return this; }
Builder size(int width, int height) { this.width = width; this.height = height; return this; }
Button build() { return new IronButton(x, y, width, height, label, action); }
}
private static final class IronButton extends Button {
IronButton(int x, int y, int width, int height, Component label, OnPress action) {
super(x, y, width, height, label, action, DEFAULT_NARRATION);
}
@Override
public void renderWidget(GuiGraphics gfx, int mouseX, int mouseY, float partialTick) {
boolean focus = active && isHoveredOrFocused();
int x = getX(), y = getY();
gfx.fill(x, y, x + width, y + height, focus ? MUTED : BORDER);
gfx.fill(x + 1, y + 1, x + width - 1, y + height - 1,
active ? (focus ? HEADER : SURFACE) : BACKGROUND);
renderScrollingString(gfx, Minecraft.getInstance().font, 6, active ? TEXT : MUTED);
}
}
}
@@ -9,9 +9,11 @@ import net.fretux.skillengine.skilltree.AbilityNodeRegistry;
import net.fretux.skillengine.skilltree.SkillNode;
import net.fretux.skillengine.skilltree.SkillNodeRegistry;
import net.minecraft.ChatFormatting;
import net.minecraft.Util;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.FormattedText;
@@ -19,22 +21,39 @@ import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.Mth;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;
import java.util.List;
public class SkilltreeScreen extends Screen {
private static final int NODE_RADIUS = 12;
private static final int ABILITY_RADIUS = 12;
private static final int NODE_ICON_SIZE = 16;
private static final int OVERLAY_WIDTH = 240;
private static final int OVERLAY_HEIGHT = 180;
private static final int OVERLAY_CONTENT_HEIGHT = 160;
private static final int OVERLAY_WIDTH = 280;
private static final int UNAVAILABLE = 0xFF777D85;
private static final int AVAILABLE = 0xFFD0A447;
private static final int UNLOCKED = 0xFF72B77A;
private static final int BLOCKED = 0xFFD16A65;
private static final int LOCKED_PATH = 0xFF383D43;
private record PathNode(ResourceLocation id, boolean ability) {}
private record Connection(int x1, int y1, int x2, int y2, int color, boolean traced) {}
private Button unlockButton;
private Button inspectorCloseButton;
private long inspectorOpenedAt;
private int inspectorSlideOffset;
private int inspectorAnimationStartOffset;
private boolean inspectorClosing;
private static final float INSPECTOR_SLIDE_MS = 180.0f;
private boolean legendOpen;
private static final float ZOOM_STEP = 0.1f;
private static final float LINE_THICKNESS = 2.0f;
private static final int GRID_SIZE = 32;
private static final int PANEL_BORDER = 0xFF53647A;
private static final int PANEL_BACKGROUND = 0xF0181D26;
private float zoom = 1.0f;
private int detailScroll;
private int maxDetailScroll;
private double panX = 0;
private double panY = 0;
private boolean dragging = false;
@@ -59,11 +78,18 @@ public class SkilltreeScreen extends Screen {
List<ResourceLocation> trees = SkillNodeRegistry.trees();
activeTree = trees.isEmpty() ? null : trees.get(0);
}
rebuildTreeTabs();
if (selectedNode != null) rebuildOverlayButtons();
else if (selectedAbility != null) rebuildAbilityOverlayButtons();
else rebuildTreeTabs();
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
if (legendOpen && isOverLegend(mouseX, mouseY)) return true;
if (hasSelection() && isOverInspector(mouseX, mouseY)) {
detailScroll = Mth.clamp(detailScroll - (int) (delta * 12), 0, maxDetailScroll);
return true;
}
zoom += (float) (delta * ZOOM_STEP);
float minZoom = 0.5f;
float maxZoom = 2.0f;
@@ -73,46 +99,49 @@ public class SkilltreeScreen extends Screen {
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (selectedNode != null || selectedAbility != null) {
return super.mouseClicked(mouseX, mouseY, button);
}
if (super.mouseClicked(mouseX, mouseY, button)) return true;
if (legendOpen && isOverLegend(mouseX, mouseY)) return true;
if (hasSelection() && isOverInspector(mouseX, mouseY)) return true;
if (children().stream().anyMatch(child -> child.isMouseOver(mouseX, mouseY))) return true;
if (mouseY < 36 || mouseY >= height - 23) return false;
if (button == 0) {
SkillNode clickedNode = findNodeAt(mouseX, mouseY);
if (clickedNode != null) {
if (isLockedByExclusivity(clickedNode)) {
assert Minecraft.getInstance().player != null;
Minecraft.getInstance().player.playSound(
net.minecraft.sounds.SoundEvents.VILLAGER_NO, 1f, 1f
);
return true;
}
if (SkilltreeClientState.isUnlocked(clickedNode.getId())) {
if (clickedNode == selectedNode) {
closeInspector();
return true;
}
selectedAbility = null;
selectedNode = clickedNode;
rebuildOverlayButtons();
revealSelection(clickedNode.getX(), clickedNode.getY());
return true;
}
AbilityNode clickedAbility = findAbilityAt(mouseX, mouseY);
if (clickedAbility != null) {
if (clickedAbility == selectedAbility) {
closeInspector();
return true;
}
selectedNode = null;
if (SkilltreeClientState.isAbilityUnlocked(clickedAbility.getId())) {
Minecraft.getInstance().setScreen(new AbilityBindingScreen(clickedAbility));
} else {
selectedAbility = clickedAbility;
rebuildAbilityOverlayButtons();
revealSelection(clickedAbility.getX(), clickedAbility.getY());
}
return true;
}
if (hasSelection()) closeInspector();
dragging = true;
return true;
}
return super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double dx, double dy) {
if (selectedNode != null || selectedAbility != null) {
return super.mouseDragged(mouseX, mouseY, button, dx, dy);
}
if (dragging && button == 0) {
panX += dx;
panY += dy;
@@ -131,12 +160,19 @@ public class SkilltreeScreen extends Screen {
@Override
public void render(@NotNull GuiGraphics gfx, int mouseX, int mouseY, float partialTicks) {
updateInspectorAnimation();
this.renderBackground(gfx);
renderTreeBackdrop(gfx);
renderSkillPointHeader(gfx);
hoveredNode = findNodeAt(mouseX, mouseY);
hoveredAbility = findAbilityAt(mouseX, mouseY);
boolean overCanvas = mouseY >= 36 && mouseY < height - 23
&& !(legendOpen && isOverLegend(mouseX, mouseY))
&& !(hasSelection() && isOverInspector(mouseX, mouseY))
&& children().stream().noneMatch(child -> child.isMouseOver(mouseX, mouseY));
hoveredNode = overCanvas ? findNodeAt(mouseX, mouseY) : null;
hoveredAbility = overCanvas ? findAbilityAt(mouseX, mouseY) : null;
gfx.enableScissor(0, 36, width, height - 23);
drawGraph(gfx);
gfx.disableScissor();
renderSkillPointHeader(gfx);
renderUnlockFailure(gfx);
if (hoveredNode != null && selectedNode == null && selectedAbility == null) {
renderNodeTooltip(gfx, hoveredNode, mouseX, mouseY);
@@ -147,43 +183,102 @@ public class SkilltreeScreen extends Screen {
renderAbilityOverlay(gfx, selectedAbility);
}
super.render(gfx, mouseX, mouseY, partialTicks);
if (legendOpen) renderLegend(gfx);
}
private void renderSkillPointHeader(GuiGraphics gfx) {
int remaining = SkilltreeClientState.getCurrentSkillPoints();
Component pointsText = Component.literal("Skill Points ")
.withStyle(ChatFormatting.GRAY)
.append(Component.literal(String.valueOf(remaining)).withStyle(ChatFormatting.GOLD));
int panelWidth = font.width(pointsText) + 16;
gfx.fill(7, 6, 7 + panelWidth, 24, 0xAA080B10);
gfx.fill(7, 6, 9, 24, highlightSkillPoints ? 0xFFFF4545 : 0xFFFFC857);
if (highlightSkillPoints) {
gfx.fill(5, 4, 7 + panelWidth + 2, 6, 0xFFFF4545);
gfx.fill(5, 24, 7 + panelWidth + 2, 26, 0xFFFF4545);
gfx.fill(5, 4, 7, 26, 0xFFFF4545);
gfx.fill(7 + panelWidth, 4, 7 + panelWidth + 2, 26, 0xFFFF4545);
gfx.fill(0, 0, width, 36, SkillUi.BACKGROUND);
gfx.fill(0, 35, width, 36, SkillUi.BORDER);
String points = "Skill Points: " + SkilltreeClientState.getCurrentSkillPoints();
int pointsX = width - font.width(points) - 32;
gfx.fill(pointsX - 1, 7, width - 11, 29, highlightSkillPoints ? BLOCKED : SkillUi.BORDER);
gfx.fill(pointsX, 8, width - 12, 28, SkillUi.SURFACE);
gfx.drawString(font, points, pointsX + 10, 14, SkillUi.TEXT, false);
gfx.fill(0, height - 23, width, height, SkillUi.BACKGROUND);
gfx.drawString(font, "Legend", 38, height - 15, SkillUi.MUTED, false);
String zoomLabel = Math.round(zoom * 100) + "%";
gfx.drawString(font, zoomLabel, width - font.width(zoomLabel) - 14, height - 15, SkillUi.ACCENT, false);
}
private static String displayName(String value) {
StringBuilder name = new StringBuilder();
for (String word : value.replaceAll("[_/:.\\-]+", " ").split(" +")) {
if (word.isEmpty()) continue;
if (!name.isEmpty()) name.append(' ');
name.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
}
gfx.drawString(font, pointsText, 14, 11, 0xFFFFFF, true);
Component hint = Component.literal("Drag to pan • Scroll to zoom")
.withStyle(ChatFormatting.DARK_GRAY);
gfx.drawString(font, hint, width - font.width(hint) - 10, 11, 0xFFFFFF, false);
return name.toString();
}
private void renderInspectorHeading(GuiGraphics gfx, Component title, ResourceLocation icon) {
int x = inspectorX() + 12;
int y = inspectorY();
if (icon != null) {
gfx.blit(icon, x, y + 5, NODE_ICON_SIZE, NODE_ICON_SIZE, 0, 0,
NODE_ICON_SIZE, NODE_ICON_SIZE, NODE_ICON_SIZE, NODE_ICON_SIZE);
x += NODE_ICON_SIZE + 6;
}
int availableWidth = inspectorX() + inspectorWidth() - 30 - x;
gfx.drawString(font, font.plainSubstrByWidth(title.getString(), availableWidth),
x, y + 10, SkillUi.TEXT, false);
}
private Set<PathNode> prerequisiteChain(PathNode focus) {
Set<PathNode> visited = new HashSet<>();
if (focus == null) return visited;
ArrayDeque<PathNode> pending = new ArrayDeque<>();
pending.add(focus);
while (!pending.isEmpty()) {
PathNode current = pending.removeFirst();
if (!visited.add(current)) continue;
SkillNode node = current.ability() ? null : SkillNodeRegistry.get(current.id());
AbilityNode ability = current.ability() ? AbilityNodeRegistry.get(current.id()) : null;
List<ResourceLocation> parents = node != null ? node.getLinks()
: ability != null ? ability.getLinks() : List.of();
for (ResourceLocation id : parents) {
if (SkillNodeRegistry.get(id) != null) pending.add(new PathNode(id, false));
else if (current.ability() && AbilityNodeRegistry.get(id) != null) {
pending.add(new PathNode(id, true));
}
}
}
return visited;
}
private int pathEmphasis(int color, boolean focused, boolean traced) {
if (traced) {
if (color == LOCKED_PATH) return SkillUi.MUTED;
return mixColor(color, SkillUi.TEXT, 0.2f);
}
return focused ? mixColor(color, SkillUi.BACKGROUND, 0.45f) : color;
}
private static int mixColor(int from, int to, float amount) {
int red = (int) (((from >> 16) & 255) * (1 - amount) + ((to >> 16) & 255) * amount);
int green = (int) (((from >> 8) & 255) * (1 - amount) + ((to >> 8) & 255) * amount);
int blue = (int) ((from & 255) * (1 - amount) + (to & 255) * amount);
return 0xFF000000 | red << 16 | green << 8 | blue;
}
private void renderTreeBackdrop(GuiGraphics gfx) {
gfx.fill(0, 0, width, height, 0xD00B0E14);
gfx.fill(0, 0, width, height, SkillUi.BACKGROUND);
int spacing = Math.max(16, (int) (GRID_SIZE * zoom));
int offsetX = Math.floorMod((int) panX + width / 2, spacing);
int offsetY = Math.floorMod((int) panY + height / 2, spacing);
for (int x = offsetX; x < width; x += spacing) {
gfx.fill(x, 0, x + 1, height, 0x182A3442);
for (int y = offsetY; y < height; y += spacing) {
gfx.fill(x, y, x + 1, y + 1, 0x30555B63);
}
}
for (int y = offsetY; y < height; y += spacing) {
gfx.fill(0, y, width, y + 1, 0x182A3442);
}
gfx.fill(0, 0, width, 1, 0x8053647A);
}
private void drawGraph(GuiGraphics gfx) {
PathNode focus = hoveredNode != null ? new PathNode(hoveredNode.getId(), false)
: hoveredAbility != null ? new PathNode(hoveredAbility.getId(), true)
: selectedNode != null ? new PathNode(selectedNode.getId(), false)
: selectedAbility != null ? new PathNode(selectedAbility.getId(), true) : null;
Set<PathNode> chain = prerequisiteChain(focus);
List<Connection> connections = new ArrayList<>();
for (SkillNode node : activeSkillNodes()) {
int[] p1 = worldToScreen(node.getX(), node.getY());
for (ResourceLocation neighborId : node.getLinks()) {
@@ -193,8 +288,13 @@ public class SkilltreeScreen extends Screen {
boolean bothUnlocked =
SkilltreeClientState.isUnlocked(node.getId()) &&
SkilltreeClientState.isUnlocked(neighbor.getId());
int linkColor = bothUnlocked ? 0xFF6EDC8A : 0xFF4A5361;
drawConnection(gfx, p1[0], p1[1], p2[0], p2[1], linkColor);
int linkColor = bothUnlocked ? UNLOCKED
: isLockedByExclusivity(node) ? BLOCKED
: getNodeColor(node) == AVAILABLE && SkilltreeClientState.isUnlocked(neighborId)
? AVAILABLE : LOCKED_PATH;
boolean traced = chain.contains(new PathNode(node.getId(), false));
connections.add(new Connection(p1[0], p1[1], p2[0], p2[1],
pathEmphasis(linkColor, focus != null, traced), traced));
}
}
for (AbilityNode ability : AbilityNodeRegistry.all()) {
@@ -204,31 +304,32 @@ public class SkilltreeScreen extends Screen {
AbilityNode ability2 = AbilityNodeRegistry.get(link);
if (node != null) {
int[] p2 = worldToScreen(node.getX(), node.getY());
drawConnection(gfx, p1[0], p1[1], p2[0], p2[1], 0xFF6579C7);
boolean traced = chain.contains(new PathNode(ability.getId(), true));
connections.add(new Connection(p1[0], p1[1], p2[0], p2[1],
pathEmphasis(abilityPathColor(ability, link), focus != null, traced), traced));
} else if (ability2 != null) {
int[] p2 = worldToScreen(ability2.getX(), ability2.getY());
drawConnection(gfx, p1[0], p1[1], p2[0], p2[1], 0xFF6579C7);
boolean traced = chain.contains(new PathNode(ability.getId(), true));
connections.add(new Connection(p1[0], p1[1], p2[0], p2[1],
pathEmphasis(abilityPathColor(ability, link), focus != null, traced), traced));
}
}
}
// Draw the traced chain last so unrelated crossing lines cannot obscure it.
for (boolean traced : new boolean[]{false, true}) {
for (Connection connection : connections) {
if (connection.traced() != traced) continue;
drawConnection(gfx, connection.x1(), connection.y1(), connection.x2(), connection.y2(), connection.color());
}
}
for (AbilityNode ability : AbilityNodeRegistry.all()) {
int[] pos = worldToScreen(ability.getX(), ability.getY());
int radius = getRenderedNodeRadius(ABILITY_RADIUS);
boolean unlocked = SkilltreeClientState.isAbilityUnlocked(ability.getId());
int color;
if (ability.getId().equals(highlightedAbility)) {
color = 0xFFFF4545;
} else if (ability == selectedAbility) {
color = 0xFFAA8833;
} else if (ability == hoveredAbility) {
color = 0xFF8888AA;
} else if (unlocked) {
color = 0xFF3399FF;
} else {
color = 0xFF223355;
}
int color = SkilltreeClientState.isAbilityUnlocked(ability.getId()) ? UNLOCKED
: abilityRequirementsMet(ability) ? AVAILABLE : UNAVAILABLE;
renderNodeFrame(gfx, pos[0], pos[1], radius, color,
ability == hoveredAbility, ability == selectedAbility, true);
ability == hoveredAbility || ability.getId().equals(highlightedAbility),
ability == selectedAbility);
ResourceLocation icon = ability.getIcon();
if (icon != null) {
int size = getRenderedNodeIconSize();
@@ -242,7 +343,7 @@ public class SkilltreeScreen extends Screen {
int radius = getRenderedNodeRadius(NODE_RADIUS);
int color = getNodeColor(node);
renderNodeFrame(gfx, pos[0], pos[1], radius, color,
node == hoveredNode, node == selectedNode, false);
node == hoveredNode || node.getId().equals(highlightedNode), node == selectedNode);
ResourceLocation icon = node.getIcons();
if (icon != null) {
int size = getRenderedNodeIconSize();
@@ -256,28 +357,43 @@ public class SkilltreeScreen extends Screen {
NODE_ICON_SIZE, NODE_ICON_SIZE
);
}
renderCostBadge(gfx, pos[0], pos[1], radius, node.getCost());
}
}
private void renderCostBadge(GuiGraphics gfx, int cx, int cy, int radius, int cost) {
if (cost <= 1) return;
String label = Integer.toString(cost);
float scale = 0.75f;
gfx.pose().pushPose();
gfx.pose().translate(cx + radius - 3, cy + radius - 3, 0);
gfx.pose().scale(scale, scale, 1);
int badgeWidth = font.width(label);
gfx.fill(-2, -1, badgeWidth + 2, font.lineHeight, SkillUi.BACKGROUND);
gfx.drawString(font, label, 0, 0, SkillUi.TEXT, false);
gfx.pose().popPose();
}
private void renderNodeFrame(GuiGraphics gfx, int cx, int cy, int radius, int color,
boolean hovered, boolean selected, boolean ability) {
int glow = ability ? 0x554B79D8 : 0x554FCB72;
boolean hovered, boolean selected) {
if (hovered || selected) {
int spread = selected ? 5 : 3;
int spread = selected ? 4 : 3;
gfx.fill(cx - radius - spread, cy - radius - spread,
cx + radius + spread, cy + radius + spread, glow);
cx + radius + spread, cy + radius + spread, SkillUi.TEXT);
gfx.fill(cx - radius - spread + 1, cy - radius - spread + 1,
cx + radius + spread - 1, cy + radius + spread - 1, SkillUi.BACKGROUND);
}
gfx.fill(cx - radius - 2, cy - radius, cx + radius + 2, cy + radius, 0xAA05070A);
gfx.fill(cx - radius, cy - radius - 2, cx + radius, cy + radius + 2, 0xAA05070A);
int border = selected ? 0xFFFFD36A : hovered ? 0xFFE4EBF5 : ability ? 0xFF829BEB : 0xFF778394;
gfx.fill(cx - radius - 1, cy - radius - 1, cx + radius + 1, cy + radius + 1, border);
gfx.fill(cx - radius + 1, cy - radius + 1, cx + radius - 1, cy + radius - 1, color);
gfx.fill(cx - radius + 2, cy - radius + 2, cx + radius - 2, cy - radius + 4, 0x35FFFFFF);
gfx.fill(cx - radius - 1, cy - radius - 1, cx + radius + 1, cy + radius + 1, color);
gfx.fill(cx - radius + 1, cy - radius + 1, cx + radius - 1, cy + radius - 1, SkillUi.SURFACE);
}
private void renderNodeTooltip(GuiGraphics gfx, SkillNode node, int mouseX, int mouseY) {
List<Component> tooltip = new ArrayList<>();
tooltip.add(node.getTitle());
String status = skillStatus(node);
if (!status.equals("Not enough skill points")) {
tooltip.add(Component.literal(status).withStyle(ChatFormatting.GRAY));
}
String desc = node.getDescription().getString();
List<FormattedText> wrapped = font.getSplitter().splitLines(
desc,
@@ -288,9 +404,9 @@ public class SkilltreeScreen extends Screen {
tooltip.add(Component.literal(ft.getString()).withStyle(ChatFormatting.GRAY));
}
tooltip.add(Component.literal("Cost: " + node.getCost())
.withStyle(ChatFormatting.DARK_AQUA));
.withStyle(ChatFormatting.GRAY));
if (!node.getPrereqAttributes().isEmpty()) {
tooltip.add(Component.literal("Requirements:")
tooltip.add(Component.literal("Stat Requirements:")
.withStyle(ChatFormatting.GOLD));
assert Minecraft.getInstance().player != null;
Minecraft.getInstance().player.getCapability(PlayerStatsProvider.PLAYER_STATS)
@@ -298,7 +414,7 @@ public class SkilltreeScreen extends Screen {
int current = stats.getAttributeLevel(attr);
boolean ok = current >= required;
tooltip.add(
Component.literal(" - " + attr + ": " + current + "/" + required)
Component.literal(" - " + displayName(attr) + ": " + current + "/" + required)
.withStyle(ok ? ChatFormatting.GREEN : ChatFormatting.RED)
);
}));
@@ -317,21 +433,19 @@ public class SkilltreeScreen extends Screen {
}
private void renderNodeOverlay(GuiGraphics gfx, SkillNode node) {
if (isLockedByExclusivity(node)) {
return;
}
int x = (width - OVERLAY_WIDTH) / 2;
int y = (height - OVERLAY_HEIGHT) / 2;
gfx.fill(0, 0, width, height, 0x88000000);
int x = inspectorX();
int y = inspectorY();
renderOverlayPanel(gfx, x, y);
gfx.drawCenteredString(font, node.getTitle(), x + OVERLAY_WIDTH / 2, y + 10, 0xFFFFFF);
gfx.drawWordWrap(font, node.getDescription(), x + 10, y + 30, OVERLAY_WIDTH - 20, 0xDDDDDD);
final int[] textY = {y + 95};
renderInspectorHeading(gfx, node.getTitle(), node.getIcons());
beginDetailBody(gfx, x, y);
gfx.drawWordWrap(font, node.getDescription(), x + 16, y + 32, inspectorWidth() - 32, SkillUi.MUTED);
int descriptionHeight = font.split(node.getDescription(), inspectorWidth() - 32).size() * font.lineHeight;
final int[] textY = {y + 32 + descriptionHeight + 16};
gfx.drawString(
font,
Component.literal("Cost: " + node.getCost() + " skill points")
.withStyle(ChatFormatting.AQUA),
x + 10,
.withStyle(ChatFormatting.GRAY),
x + 16,
textY[0],
0xAAAAAA
);
@@ -339,9 +453,9 @@ public class SkilltreeScreen extends Screen {
if (!node.getPrereqAttributes().isEmpty()) {
gfx.drawString(
font,
Component.literal("Requirements:")
Component.literal("Stat Requirements:")
.withStyle(ChatFormatting.GOLD),
x + 10,
x + 16,
textY[0],
0xFFFFFF
);
@@ -353,135 +467,276 @@ public class SkilltreeScreen extends Screen {
boolean ok = current >= required;
Component line = Component.literal(
" - " + attr + ": " + current + "/" + required
" - " + displayName(attr) + ": " + current + "/" + required
).withStyle(ok ? ChatFormatting.GREEN : ChatFormatting.RED);
gfx.drawString(font, line, x + 10, textY[0], 0xFFFFFF);
textY[0] += 12;
gfx.drawWordWrap(font, line, x + 16, textY[0], inspectorWidth() - 32, SkillUi.TEXT);
textY[0] += font.split(line, inspectorWidth() - 32).size() * font.lineHeight + 3;
}));
}
textY[0] = renderPrerequisites(gfx, node.getLinks(), x, textY[0], false);
if (!node.getExclusiveWith().isEmpty()) {
gfx.drawString(font, "Mutually exclusive with:", x + 16, textY[0], SkillUi.MUTED, false);
textY[0] += 12;
for (ResourceLocation id : node.getExclusiveWith()) {
SkillNode other = SkillNodeRegistry.get(id);
Component name = other == null ? Component.literal(id.toString()) : other.getTitle();
gfx.drawWordWrap(font, name, x + 16, textY[0], inspectorWidth() - 32,
SkilltreeClientState.isUnlocked(id) ? BLOCKED : SkillUi.MUTED);
textY[0] += font.split(name, inspectorWidth() - 32).size() * font.lineHeight + 3;
}
}
endDetailBody(gfx, y, textY[0]);
updateUnlockButton(skillStatus(node), getNodeColor(node) == AVAILABLE);
}
private void renderAbilityOverlay(GuiGraphics gfx, AbilityNode ability) {
int x = (width - OVERLAY_WIDTH) / 2;
int y = (height - OVERLAY_HEIGHT) / 2;
gfx.fill(0, 0, width, height, 0x88000000);
int x = inspectorX();
int y = inspectorY();
renderOverlayPanel(gfx, x, y);
gfx.drawCenteredString(font, ability.getTitle(), x + OVERLAY_WIDTH / 2, y + 10, 0xFFFFFF);
int textY = y + 30;
renderInspectorHeading(gfx, ability.getTitle(), ability.getIcon());
beginDetailBody(gfx, x, y);
int textY = y + 32;
if (ability.getDescription() != null) {
gfx.drawWordWrap(font, ability.getDescription(), x + 10, textY, OVERLAY_WIDTH - 20, 0xDDDDDD);
textY += 60;
gfx.drawWordWrap(font, ability.getDescription(), x + 16, textY, inspectorWidth() - 32, 0xDDDDDD);
textY += font.split(ability.getDescription(), inspectorWidth() - 32).size() * font.lineHeight + 16;
}
gfx.drawString(font,
Component.literal("Cooldown: " + ability.getCooldown() / 20 + " seconds")
.withStyle(ChatFormatting.AQUA),
x + 10,
.withStyle(ChatFormatting.GRAY),
x + 16,
textY,
0xFFFFFF);
textY += 15;
if (!ability.getLinks().isEmpty()) {
gfx.drawString(font,
Component.literal("Requires:")
.withStyle(ChatFormatting.GOLD),
x + 10,
textY,
0xFFFFFF);
textY += 12;
for (ResourceLocation parent : ability.getLinks()) {
SkillNode parentNode = SkillNodeRegistry.get(parent);
AbilityNode parentAbility = AbilityNodeRegistry.get(parent);
Component name = parentNode != null
? parentNode.getTitle()
: parentAbility != null
? parentAbility.getTitle()
: Component.literal(parent.toString());
gfx.drawString(font,
Component.literal(" - ").append(name),
x + 10,
textY,
0xFFFFFF);
textY += 12;
}
textY = renderPrerequisites(gfx, ability.getLinks(), x, textY, true);
endDetailBody(gfx, y, textY);
boolean unlocked = SkilltreeClientState.isAbilityUnlocked(ability.getId());
updateUnlockButton(unlocked ? "Already unlocked"
: abilityRequirementsMet(ability) ? "Ready to unlock" : "Unlock prerequisites first",
!unlocked && abilityRequirementsMet(ability));
}
private void beginDetailBody(GuiGraphics gfx, int x, int y) {
gfx.enableScissor(x + 16, y + 30, x + inspectorWidth() - 16, y + inspectorHeight() - 40);
gfx.pose().pushPose();
gfx.pose().translate(0, -detailScroll, 0);
}
private void endDetailBody(GuiGraphics gfx, int y, int bottom) {
gfx.pose().popPose();
gfx.disableScissor();
maxDetailScroll = Math.max(0, bottom - (y + inspectorHeight() - 44));
detailScroll = Math.min(detailScroll, maxDetailScroll);
if (maxDetailScroll > 0) {
int trackX = inspectorX() + inspectorWidth() - 9;
int trackHeight = inspectorHeight() - 70;
int thumbY = y + 30 + (trackHeight - 16) * detailScroll / maxDetailScroll;
gfx.fill(trackX, y + 30, trackX + 2, y + 30 + trackHeight, SkillUi.BORDER);
gfx.fill(trackX, thumbY, trackX + 2, thumbY + 16, SkillUi.ACCENT);
}
}
private void renderOverlayPanel(GuiGraphics gfx, int x, int y) {
gfx.fill(x - 2, y - 2, x + OVERLAY_WIDTH + 2, y + OVERLAY_HEIGHT + 2, 0x99000000);
gfx.fill(x - 1, y - 1, x + OVERLAY_WIDTH + 1, y + OVERLAY_HEIGHT + 1, PANEL_BORDER);
gfx.fill(x, y, x + OVERLAY_WIDTH, y + OVERLAY_HEIGHT, PANEL_BACKGROUND);
gfx.fill(x, y, x + OVERLAY_WIDTH, y + 3, 0xFF6C83A3);
gfx.fill(x + 8, y + 23, x + OVERLAY_WIDTH - 8, y + 24, 0x6653647A);
SkillUi.panel(gfx, x, y, inspectorWidth(), inspectorHeight());
gfx.fill(x, y, x + inspectorWidth(), y + 25, SkillUi.HEADER);
gfx.fill(x + 12, y + 29, x + inspectorWidth() - 12, y + inspectorHeight() - 40, SkillUi.SURFACE);
}
private boolean hasSelection() {
return selectedNode != null || selectedAbility != null;
}
private boolean usesSidebar() {
return width >= 560;
}
private int inspectorWidth() {
return Math.min(OVERLAY_WIDTH, width - 24);
}
private int inspectorHeight() {
return Math.min(300, height - 72);
}
private int inspectorX() {
return inspectorRestX() + inspectorSlideOffset;
}
private int inspectorRestX() {
return usesSidebar() ? width - inspectorWidth() - 12 : (width - inspectorWidth()) / 2;
}
private void updateInspectorAnimation() {
if (!hasSelection()) return;
float progress = Mth.clamp((Util.getMillis() - inspectorOpenedAt) / INSPECTOR_SLIDE_MS, 0.0f, 1.0f);
float remaining = 1.0f - progress;
if (inspectorClosing && (progress >= 1.0f || !usesSidebar())) {
finishClosingInspector();
return;
}
float eased = 1.0f - remaining * remaining * remaining;
int targetOffset = inspectorClosing ? inspectorWidth() + 16 : 0;
inspectorSlideOffset = usesSidebar() ? Math.round(inspectorAnimationStartOffset
+ (targetOffset - inspectorAnimationStartOffset) * eased) : 0;
if (unlockButton != null) unlockButton.setX(inspectorX() + inspectorWidth() - 96);
if (inspectorCloseButton != null) inspectorCloseButton.setX(inspectorX() + inspectorWidth() - 23);
}
private int inspectorY() {
return usesSidebar() ? 40 : (height - inspectorHeight()) / 2;
}
private boolean isOverInspector(double x, double y) {
return x >= inspectorX() - 1 && x <= inspectorX() + inspectorWidth() + 1
&& y >= inspectorY() - 1 && y <= inspectorY() + inspectorHeight() + 1;
}
private void revealSelection(float x, float y) {
if (!usesSidebar()) return;
int[] position = worldToScreen(x, y);
// Move only a selection that the actual popup rectangle would cover.
// Nodes above or below it keep their existing position.
int margin = 24;
if (position[0] >= inspectorRestX() - margin
&& position[0] <= inspectorRestX() + inspectorWidth() + margin
&& position[1] >= inspectorY() - margin
&& position[1] <= inspectorY() + inspectorHeight() + margin) {
panX += inspectorRestX() - margin - position[0];
}
}
private void closeInspector() {
if (!hasSelection() || inspectorClosing) return;
if (!usesSidebar()) {
finishClosingInspector();
return;
}
inspectorClosing = true;
inspectorAnimationStartOffset = inspectorSlideOffset;
inspectorOpenedAt = Util.getMillis();
if (unlockButton != null) {
unlockButton.active = false;
unlockButton.setTooltip(null);
}
if (inspectorCloseButton != null) {
inspectorCloseButton.active = false;
inspectorCloseButton.setTooltip(null);
}
}
private void finishClosingInspector() {
selectedNode = null;
selectedAbility = null;
unlockButton = null;
inspectorCloseButton = null;
inspectorSlideOffset = 0;
inspectorAnimationStartOffset = 0;
inspectorClosing = false;
clearWidgets();
rebuildTreeTabs();
}
@Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (keyCode == 256 && legendOpen) {
legendOpen = false;
return true;
}
if (keyCode == 256 && hasSelection()) {
closeInspector();
return true;
}
return super.keyPressed(keyCode, scanCode, modifiers);
}
private int renderPrerequisites(GuiGraphics gfx, List<ResourceLocation> links, int x, int y, boolean ability) {
if (links.isEmpty()) return y;
gfx.drawString(font, "Required Skills:", x + 16, y, SkillUi.ACCENT, false);
y += 12;
for (ResourceLocation id : links) {
SkillNode node = SkillNodeRegistry.get(id);
AbilityNode parentAbility = AbilityNodeRegistry.get(id);
Component name = node != null ? node.getTitle()
: parentAbility != null ? parentAbility.getTitle() : Component.literal(id.toString());
boolean met = SkilltreeClientState.isUnlocked(id)
|| (ability && SkilltreeClientState.isAbilityUnlocked(id));
Component line = Component.literal(met ? "+ " : "- ").append(name);
gfx.drawWordWrap(font, line, x + 16, y, inspectorWidth() - 32, met ? UNLOCKED : BLOCKED);
y += font.split(line, inspectorWidth() - 32).size() * font.lineHeight + 3;
}
return y;
}
private void updateUnlockButton(String status, boolean canUnlock) {
if (unlockButton == null || inspectorClosing) return;
unlockButton.setTooltip(Tooltip.create(Component.literal(status)));
unlockButton.active = canUnlock;
}
private void rebuildOverlayButtons() {
clearWidgets();
if (selectedNode == null) {
return;
}
int x = (width - OVERLAY_WIDTH) / 2;
int y = (height - OVERLAY_CONTENT_HEIGHT) / 2;
boolean alreadyUnlocked = SkilltreeClientState.isUnlocked(selectedNode.getId());
if (!alreadyUnlocked) {
addRenderableWidget(Button.builder(Component.literal("Unlock"), btn -> {
if (!prepareSkillUnlock(selectedNode)) {
selectedNode = null;
clearWidgets();
rebuildTreeTabs();
return;
}
PacketHandler.CHANNEL.sendToServer(
new ServerboundUnlockNodePacket(selectedNode.getId())
);
selectedNode = null;
clearWidgets();
rebuildTreeTabs();
}).pos(x + OVERLAY_WIDTH / 2 - 40, y + OVERLAY_CONTENT_HEIGHT - 20)
.size(80, 20)
.build());
}
addRenderableWidget(Button.builder(Component.literal("Close"), btn -> {
selectedNode = null;
clearWidgets();
rebuildTreeTabs();
}).pos(x + OVERLAY_WIDTH - 60, y - 5)
.size(50, 20)
.build());
rebuildInspectorButtons(false);
}
private void rebuildAbilityOverlayButtons() {
rebuildInspectorButtons(true);
}
private void rebuildInspectorButtons(boolean ability) {
if (unlockButton == null || inspectorClosing) {
inspectorAnimationStartOffset = unlockButton == null
? (usesSidebar() ? inspectorWidth() + 16 : 0) : inspectorSlideOffset;
inspectorOpenedAt = Util.getMillis();
inspectorSlideOffset = inspectorAnimationStartOffset;
}
inspectorClosing = false;
detailScroll = 0;
maxDetailScroll = 0;
dragging = false;
clearUnlockHighlights();
clearWidgets();
if (selectedAbility == null) {
return;
addLegendButton();
int x = inspectorX();
int y = inspectorY();
unlockButton = addRenderableWidget(SkillUi.button(Component.literal("Unlock"), btn -> {
if (inspectorClosing) return;
if (ability ? !prepareAbilityUnlock(selectedAbility) : !prepareSkillUnlock(selectedNode)) return;
ResourceLocation id = ability ? selectedAbility.getId() : selectedNode.getId();
PacketHandler.CHANNEL.sendToServer(new ServerboundUnlockNodePacket(id));
closeInspector();
}).pos(x + inspectorWidth() - 96, y + inspectorHeight() - 28).size(80, 20).build());
unlockButton.active = ability
? !SkilltreeClientState.isAbilityUnlocked(selectedAbility.getId()) && abilityRequirementsMet(selectedAbility)
: getNodeColor(selectedNode) == AVAILABLE;
inspectorCloseButton = addRenderableWidget(SkillUi.button(Component.literal("X"), btn -> closeInspector())
.pos(x + inspectorWidth() - 23, y + 5).size(18, 16).build());
inspectorCloseButton.setTooltip(Tooltip.create(Component.literal("Close details (Esc)")));
}
private void addLegendButton() {
Button help = addRenderableWidget(SkillUi.button(Component.literal("?"), btn -> legendOpen = !legendOpen)
.pos(12, height - 21).size(20, 18).build());
help.setTooltip(Tooltip.create(Component.literal("Show or hide the legend")));
}
private boolean isOverLegend(double x, double y) {
return x >= 12 && x <= 12 + Math.min(300, width - 24)
&& y >= height - 158 && y <= height - 30;
}
private void renderLegend(GuiGraphics gfx) {
int x = 12;
int y = height - 158;
SkillUi.panel(gfx, x, y, Math.min(300, width - 24), 128);
gfx.drawString(font, "Node borders and paths", x + 12, y + 10, SkillUi.TEXT, false);
String[] labels = {"Unlocked", "Available to unlock",
"Unavailable: requirements or points missing", "Blocked: mutually exclusive"};
int[] colors = {UNLOCKED, AVAILABLE, UNAVAILABLE, BLOCKED};
for (int i = 0; i < labels.length; i++) {
int rowY = y + 30 + i * 15;
gfx.fill(x + 12, rowY, x + 20, rowY + 8, colors[i]);
gfx.drawString(font, labels[i], x + 26, rowY, SkillUi.TEXT, false);
}
int x = (width - OVERLAY_WIDTH) / 2;
int y = (height - OVERLAY_CONTENT_HEIGHT) / 2;
boolean unlocked = SkilltreeClientState.isAbilityUnlocked(selectedAbility.getId());
if (!unlocked) {
addRenderableWidget(Button.builder(Component.literal("Unlock"), btn -> {
if (!prepareAbilityUnlock(selectedAbility)) {
selectedAbility = null;
clearWidgets();
rebuildTreeTabs();
return;
}
PacketHandler.CHANNEL.sendToServer(
new ServerboundUnlockNodePacket(selectedAbility.getId())
);
selectedAbility = null;
clearWidgets();
rebuildTreeTabs();
}).pos(x + OVERLAY_WIDTH / 2 - 40, y + OVERLAY_CONTENT_HEIGHT - 20)
.size(80, 20)
.build());
}
addRenderableWidget(Button.builder(Component.literal("Close"), btn -> {
selectedAbility = null;
clearWidgets();
rebuildTreeTabs();
}).pos(x + OVERLAY_WIDTH - 60, y - 5)
.size(50, 20)
.build());
gfx.drawString(font, "White outline: hover or selection", x + 12, y + 110, SkillUi.MUTED, false);
}
private int[] worldToScreen(float wx, float wy) {
@@ -529,37 +784,42 @@ public class SkilltreeScreen extends Screen {
return node.getExclusiveWith().stream().anyMatch(SkilltreeClientState::isUnlocked);
}
private int getNodeColor(SkillNode node) {
boolean unlocked = SkilltreeClientState.isUnlocked(node.getId());
boolean lockedByExclusivity = isLockedByExclusivity(node);
int color;
if (node.getId().equals(highlightedNode)) {
color = 0xFFFF4545;
} else if (lockedByExclusivity) {
color = 0xFFCC3333;
} else if (node == selectedNode) {
color = 0xFF999933;
} else if (node == hoveredNode) {
color = 0xFF777777;
} else if (unlocked) {
color = 0xFF44CC44;
} else {
int cost = node.getCost();
if (cost <= 1) {
color = 0xFF444444;
} else if (cost == 2) {
color = 0xFFFF6633;
} else if (cost == 3) {
color = 0xFF33CCCC;
} else {
color = 0xFFFFD700;
}
private String skillStatus(SkillNode node) {
if (SkilltreeClientState.isUnlocked(node.getId())) return "Already unlocked";
for (ResourceLocation id : node.getExclusiveWith()) {
if (!SkilltreeClientState.isUnlocked(id)) continue;
SkillNode blocker = SkillNodeRegistry.get(id);
return "Blocked by: " + (blocker == null ? id.toString() : blocker.getTitle().getString());
}
return color;
if (SkilltreeClientState.getCurrentSkillPoints() < Math.max(0, node.getCost())) return "Not enough skill points";
if (!node.getLinks().stream().allMatch(SkilltreeClientState::isUnlocked)) return "Unlock prerequisites first";
if (!meetsClientAttributes(node)) return "Attribute requirements not met";
return "Ready to unlock";
}
private int getNodeColor(SkillNode node) {
if (SkilltreeClientState.isUnlocked(node.getId())) return UNLOCKED;
if (isLockedByExclusivity(node)) return BLOCKED;
return node.getLinks().stream().allMatch(SkilltreeClientState::isUnlocked)
&& meetsClientAttributes(node)
&& SkilltreeClientState.getCurrentSkillPoints() >= Math.max(0, node.getCost())
? AVAILABLE : UNAVAILABLE;
}
private boolean abilityRequirementsMet(AbilityNode ability) {
return ability.getLinks().stream().allMatch(id -> SkilltreeClientState.isUnlocked(id)
|| SkilltreeClientState.isAbilityUnlocked(id));
}
private int abilityPathColor(AbilityNode ability, ResourceLocation parent) {
boolean parentUnlocked = SkilltreeClientState.isUnlocked(parent) || SkilltreeClientState.isAbilityUnlocked(parent);
if (parentUnlocked && SkilltreeClientState.isAbilityUnlocked(ability.getId())) return UNLOCKED;
return parentUnlocked && abilityRequirementsMet(ability) ? AVAILABLE : LOCKED_PATH;
}
private boolean prepareSkillUnlock(SkillNode target) {
clearUnlockHighlights();
if (SkilltreeClientState.isUnlocked(target.getId()) || isLockedByExclusivity(target)) return false;
for (ResourceLocation parentId : target.getLinks()) {
if (SkilltreeClientState.isUnlocked(parentId)) continue;
SkillNode parent = SkillNodeRegistry.get(parentId);
@@ -598,6 +858,7 @@ public class SkilltreeScreen extends Screen {
private boolean prepareAbilityUnlock(AbilityNode ability) {
clearUnlockHighlights();
if (SkilltreeClientState.isAbilityUnlocked(ability.getId())) return false;
for (ResourceLocation parentId : ability.getLinks()) {
if (SkilltreeClientState.isUnlocked(parentId)
|| SkilltreeClientState.isAbilityUnlocked(parentId)) continue;
@@ -632,7 +893,7 @@ public class SkilltreeScreen extends Screen {
if (unlockFailureMessage == null) return;
int messageWidth = Math.min(width - 40, font.width(unlockFailureMessage) + 20);
int x = (width - messageWidth) / 2;
int y = height - 34;
int y = height - 46;
gfx.fill(x - 2, y - 2, x + messageWidth + 2, y + 18, 0xCC000000);
gfx.fill(x, y, x + messageWidth, y + 16, 0xE0421717);
gfx.fill(x, y, x + 3, y + 16, 0xFFFF4545);
@@ -667,16 +928,21 @@ public class SkilltreeScreen extends Screen {
}
private void rebuildTreeTabs() {
addLegendButton();
List<ResourceLocation> trees = SkillNodeRegistry.trees();
if (trees.size() <= 1 || selectedNode != null || selectedAbility != null) {
return;
}
int x = 10;
int y = 28;
int x = 14;
int y = 44;
for (ResourceLocation tree : trees) {
String label = tree.toString();
int buttonWidth = Math.min(140, Math.max(60, font.width(label) + 16));
Button button = addRenderableWidget(Button.builder(Component.literal(label), btn -> {
if (x + buttonWidth > width - 14 && x > 14) {
x = 14;
y += 26;
}
Button button = addRenderableWidget(SkillUi.button(Component.literal(label), btn -> {
activeTree = tree;
selectedNode = null;
selectedAbility = null;
@@ -687,7 +953,7 @@ public class SkilltreeScreen extends Screen {
.size(buttonWidth, 20)
.build());
button.active = !tree.equals(activeTree);
x += buttonWidth + 4;
x += buttonWidth + 6;
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ displayName = "${mod_name}" #mandatory
# A URL for the "homepage" for this mod, displayed in the mod UI
#displayURL="https://change.me.to.your.mods.homepage.example.invalid/" #optional
# A file name (in the root of the mod JAR) containing a logo for display
#logoFile="examplemod.png" #optional
logoFile="skillengine.png" #optional
# A text field displayed in the mod UI
#credits="" #optional
# A text field displayed in the mod UI