Implement mutual exclusives

This commit is contained in:
2025-12-05 14:02:57 +01:00
parent d0755c893f
commit 96ea5977e6
19 changed files with 1040 additions and 176 deletions
@@ -0,0 +1,94 @@
package net.fretux.skillengine.client;
import net.fretux.skillengine.SkillEngine;
import net.fretux.skillengine.network.PacketHandler;
import net.fretux.skillengine.network.ServerboundBindAbilityPacket;
import net.fretux.skillengine.skilltree.AbilityNode;
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;
public class AbilityBindingScreen extends Screen {
private static final int PANEL_WIDTH = 220;
private static final int PANEL_HEIGHT = 160;
private final AbilityNode ability;
public AbilityBindingScreen(AbilityNode ability) {
super(Component.literal("Bind Ability"));
this.ability = ability;
}
@Override
protected void init() {
int x = (width - PANEL_WIDTH) / 2;
int y = (height - PANEL_HEIGHT) / 2;
int buttonX = x + 20;
int buttonY = y + 80;
int boundSlot = SkilltreeClientState.getSlotOfAbility(ability.getId());
Button yBtn = addRenderableWidget(Button.builder(Component.literal("Bind to Y"),
b -> bind(1)).pos(buttonX, buttonY).size(80, 20).build());
if (boundSlot == 1) {
yBtn.active = false;
yBtn.setMessage(Component.literal("Bound to Y"));
}
Button xBtn = addRenderableWidget(Button.builder(Component.literal("Bind to X"),
b -> bind(2)).pos(buttonX + 90, buttonY).size(80, 20).build());
if (boundSlot == 2) {
xBtn.active = false;
xBtn.setMessage(Component.literal("Bound to X"));
}
Button cBtn = addRenderableWidget(Button.builder(Component.literal("Bind to C"),
b -> bind(3)).pos(buttonX, buttonY + 30).size(80, 20).build());
if (boundSlot == 3) {
cBtn.active = false;
cBtn.setMessage(Component.literal("Bound to C"));
}
addRenderableWidget(Button.builder(Component.literal("Cancel"),
b -> onClose()).pos(buttonX + 90, buttonY + 30).size(80, 20).build());
}
private void bind(int slot) {
SkilltreeClientState.bindAbilityLocal(slot, ability.getId());
SkillEngine.LOGGER.debug("[CLIENT] Binding ability {} to slot {} (optimistic)", ability.getId(), slot);
PacketHandler.CHANNEL.sendToServer(new ServerboundBindAbilityPacket(ability.getId(), slot));
onClose();
}
@Override
public void onClose() {
Minecraft.getInstance().setScreen(null);
}
@Override
public boolean keyPressed(int key, int scancode, int mods) {
if (key == 256) {
onClose();
return true;
}
return super.keyPressed(key, scancode, mods);
}
@Override
public void render(GuiGraphics gfx, int mouseX, int mouseY, float partialTick) {
this.renderBackground(gfx);
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);
ResourceLocation icon = ability.getIcon();
if (icon != null) {
gfx.blit(icon, x + PANEL_WIDTH / 2 - 10, y + 10, 0, 0, 20, 20, 20, 20);
}
gfx.drawCenteredString(font, ability.getTitle(), x + PANEL_WIDTH / 2, y + 35, 0xFFFFFF);
if (ability.getDescription() != null) {
gfx.drawWordWrap(font, ability.getDescription(), x + 10, y + 50, PANEL_WIDTH - 20, 0xDDDDDD);
}
super.render(gfx, mouseX, mouseY, partialTick);
}
}
@@ -0,0 +1,120 @@
package net.fretux.skillengine.client;
import net.fretux.skillengine.network.PacketHandler;
import net.fretux.skillengine.network.ServerboundBindAbilityPacket;
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 java.util.ArrayList;
import java.util.List;
public class AbilitySelectScreen extends Screen {
private final Screen parent;
private final int slot; // 1..3
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;
public AbilitySelectScreen(Screen parent, int slot) {
super(Component.literal("Select Ability"));
this.parent = parent;
this.slot = slot;
}
@Override
protected void init() {
unlockedAbilities.clear();
for (AbilityNode node : AbilityNodeRegistry.all()) {
if (SkilltreeClientState.isAbilityUnlocked(node.getId())) {
unlockedAbilities.add(node);
}
}
buildPageButtons();
}
private void buildPageButtons() {
clearWidgets();
int x = (width - PANEL_WIDTH) / 2;
int y = (height - PANEL_HEIGHT) / 2;
int start = page * PAGE_SIZE;
int end = Math.min(unlockedAbilities.size(), start + PAGE_SIZE);
int rowY = y + 40;
for (int i = start; i < end; i++) {
AbilityNode node = unlockedAbilities.get(i);
String label = node.getTitle().getString();
// Disable if already in this slot to avoid redundant bind
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)
.build();
if (alreadyInThisSlot) {
btn.active = false;
btn.setMessage(Component.literal(label + " (bound)"));
}
addRenderableWidget(btn);
rowY += 24;
}
// paging controls
if (page > 0) {
addRenderableWidget(Button.builder(Component.literal("< Prev"), b -> {
page--;
buildPageButtons();
}).pos(x + 15, y + PANEL_HEIGHT - 28).size(60, 20).build());
}
if ((page + 1) * PAGE_SIZE < unlockedAbilities.size()) {
addRenderableWidget(Button.builder(Component.literal("Next >"), b -> {
page++;
buildPageButtons();
}).pos(x + 80, y + PANEL_HEIGHT - 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());
}
private void select(AbilityNode node) {
ResourceLocation id = node.getId();
// optimistic client update, then send to server
SkilltreeClientState.bindAbilityLocal(slot, id);
PacketHandler.CHANNEL.sendToServer(new ServerboundBindAbilityPacket(id, slot));
back();
}
private void back() {
Minecraft.getInstance().setScreen(parent);
}
@Override
public boolean isPauseScreen() {
return false;
}
@Override
public void onClose() {
back();
}
@Override
public void render(GuiGraphics gfx, int mouseX, int mouseY, float partialTick) {
this.renderBackground(gfx);
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);
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);
super.render(gfx, mouseX, mouseY, partialTick);
}
}
@@ -0,0 +1,98 @@
package net.fretux.skillengine.client;
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;
public class AbilitySlotsScreen extends Screen {
private static final int PANEL_WIDTH = 260;
private static final int PANEL_HEIGHT = 180;
public AbilitySlotsScreen() {
super(Component.literal("Abilities"));
}
@Override
protected void init() {
int x = (width - PANEL_WIDTH) / 2;
int y = (height - PANEL_HEIGHT) / 2;
int rowY = y + 40;
int buttonW = 80;
int buttonH = 20;
int buttonX = x + PANEL_WIDTH - buttonW - 15;
// Y slot = 1
addRenderableWidget(Button.builder(Component.literal("Rebind"), b -> openSelect(1))
.pos(buttonX, rowY).size(buttonW, buttonH).build());
// X slot = 2
addRenderableWidget(Button.builder(Component.literal("Rebind"), b -> openSelect(2))
.pos(buttonX, rowY + 35).size(buttonW, buttonH).build());
// C slot = 3
addRenderableWidget(Button.builder(Component.literal("Rebind"), b -> openSelect(3))
.pos(buttonX, rowY + 70).size(buttonW, buttonH).build());
addRenderableWidget(Button.builder(Component.literal("Close"), b -> onClose())
.pos(x + PANEL_WIDTH - 70, y + PANEL_HEIGHT - 28).size(60, 20).build());
}
private void openSelect(int slot) {
Minecraft.getInstance().setScreen(new AbilitySelectScreen(this, slot));
}
@Override
public boolean isPauseScreen() {
return false;
}
@Override
public void onClose() {
Minecraft.getInstance().setScreen(null);
}
@Override
public void render(GuiGraphics gfx, int mouseX, int mouseY, float partialTick) {
this.renderBackground(gfx);
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);
gfx.drawCenteredString(font, Component.literal("Ability Slots"), x + PANEL_WIDTH / 2, y + 12, 0xFFFFFF);
int rowY = y + 40;
drawSlotRow(gfx, x + 15, rowY, 1, "Y");
drawSlotRow(gfx, x + 15, rowY + 35, 2, "X");
drawSlotRow(gfx, x + 15, rowY + 70, 3, "C");
super.render(gfx, mouseX, mouseY, partialTick);
}
private void drawSlotRow(GuiGraphics gfx, int x, int y, int slot, String label) {
gfx.drawString(font, label + ":", x, y + 6, 0xFFFFFF);
ResourceLocation abilityId = SkilltreeClientState.getAbilityInSlot(slot);
String title = "Empty";
ResourceLocation icon = null;
if (abilityId != null) {
AbilityNode node = AbilityNodeRegistry.get(abilityId);
if (node != null) {
title = node.getTitle().getString();
icon = node.getIcon();
} else {
title = abilityId.toString();
}
}
if (icon != null) {
gfx.blit(icon, x + 20, y - 2, 0, 0, 20, 20, 20, 20);
gfx.drawString(font, title, x + 46, y + 6, 0xDDDDDD);
} else {
gfx.drawString(font, title, x + 20, y + 6, 0x888888);
}
}
}
@@ -19,5 +19,9 @@ public class ClientEvents {
Minecraft.getInstance().setScreen(new SkilltreeScreen());
}
if (ClientKeybinds.OPEN_ABILITIES != null &&
ClientKeybinds.OPEN_ABILITIES.consumeClick()) {
Minecraft.getInstance().setScreen(new AbilitySlotsScreen());
}
}
}
@@ -15,6 +15,7 @@ import org.lwjgl.glfw.GLFW;
)
public class ClientKeybinds {
public static KeyMapping OPEN_SKILLTREE;
public static KeyMapping OPEN_ABILITIES;
@SubscribeEvent
public static void registerKeys(RegisterKeyMappingsEvent event) {
@@ -24,5 +25,12 @@ public class ClientKeybinds {
"key.categories.ui"
);
event.register(OPEN_SKILLTREE);
OPEN_ABILITIES = new KeyMapping(
"key.skillengine.openabilities",
GLFW.GLFW_KEY_M,
"key.categories.ui"
);
event.register(OPEN_ABILITIES);
}
}
@@ -12,18 +12,17 @@ import java.util.Set;
public class SkilltreeClientState {
private static final Set<ResourceLocation> unlocked = new HashSet<>();
private static final Set<ResourceLocation> unlockedAbilities = new HashSet<>();
private static int currentSkillPoints = 0;
private static final ResourceLocation[] abilitySlots = new ResourceLocation[3];
public static void unlockNode(ResourceLocation id) {
SkillEngine.LOGGER.info("[CLIENT] Unlocking node: {}", id);
unlocked.add(id);
Minecraft mc = Minecraft.getInstance();
if (mc.player != null) {
mc.player.playSound(SoundEvents.EXPERIENCE_ORB_PICKUP, 1f, 1.2f);
}
mc.getToasts().addToast(
SystemToast.multiline(
mc,
@@ -43,6 +42,11 @@ public class SkilltreeClientState {
unlocked.addAll(ids);
}
public static void setUnlockedAbilities(Set<ResourceLocation> abilities) {
unlockedAbilities.clear();
unlockedAbilities.addAll(abilities);
}
public static void setCurrentSkillPoints(int points) {
currentSkillPoints = points;
}
@@ -50,4 +54,44 @@ public class SkilltreeClientState {
public static int getCurrentSkillPoints() {
return currentSkillPoints;
}
public static boolean isAbilityUnlocked(ResourceLocation id) {
return unlockedAbilities.contains(id);
}
public static void unlockAbility(ResourceLocation id) {
unlockedAbilities.add(id);
}
public static void bindAbilityLocal(int slot, ResourceLocation abilityId) {
int index = slot - 1;
if (index < 0 || index >= abilitySlots.length) return;
if (abilityId != null) {
for (int i = 0; i < abilitySlots.length; i++) {
if (abilityId.equals(abilitySlots[i])) abilitySlots[i] = null;
}
}
abilitySlots[index] = abilityId;
}
public static void setAbilitySlots(ResourceLocation[] slots) {
for (int i = 0; i < abilitySlots.length; i++) {
abilitySlots[i] = (slots != null && i < slots.length) ? slots[i] : null;
}
}
public static ResourceLocation getAbilityInSlot(int slot) {
int index = slot - 1;
if (index < 0 || index >= abilitySlots.length) return null;
return abilitySlots[index];
}
public static int getSlotOfAbility(ResourceLocation abilityId) {
if (abilityId == null) return -1;
for (int i = 0; i < abilitySlots.length; i++) {
if (abilityId.equals(abilitySlots[i])) return i + 1;
}
return -1;
}
}
@@ -4,6 +4,8 @@ import com.mojang.math.Axis;
import net.fretux.ascend.player.PlayerStatsProvider;
import net.fretux.skillengine.network.PacketHandler;
import net.fretux.skillengine.network.ServerboundUnlockNodePacket;
import net.fretux.skillengine.skilltree.AbilityNode;
import net.fretux.skillengine.skilltree.AbilityNodeRegistry;
import net.fretux.skillengine.skilltree.SkillNode;
import net.fretux.skillengine.skilltree.SkillNodeRegistry;
import net.minecraft.ChatFormatting;
@@ -14,7 +16,6 @@ import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.FormattedText;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.FormattedCharSequence;
import net.minecraft.util.Mth;
import java.util.ArrayList;
@@ -22,18 +23,31 @@ 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 OVERLAY_WIDTH = 240;
private static final int OVERLAY_HEIGHT = 180;
private static final int OVERLAY_CONTENT_HEIGHT = 160;
private static final float ZOOM_STEP = 0.1f;
private static final float LINE_THICKNESS = 2.0f;
private float zoom = 1.0f;
private float minZoom = 0.5f;
private float maxZoom = 2.0f;
private final float minZoom = 0.5f;
private final float maxZoom = 2.0f;
private double panX = 0;
private double panY = 0;
private boolean dragging = false;
private double lastMouseX, lastMouseY;
private double lastMouseX;
private double lastMouseY;
private SkillNode hoveredNode = null;
private SkillNode selectedNode = null;
private AbilityNode hoveredAbility = null;
private AbilityNode selectedAbility = null;
public SkilltreeScreen() {
super(Component.literal("Ascend Skilltree"));
@@ -46,34 +60,42 @@ public class SkilltreeScreen extends Screen {
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
zoom += delta * 0.1f;
zoom += delta * ZOOM_STEP;
zoom = Mth.clamp(zoom, minZoom, maxZoom);
return true;
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (selectedNode != null) {
if (selectedNode != null || selectedAbility != null) {
return super.mouseClicked(mouseX, mouseY, button);
}
if (button == 0) {
SkillNode clicked = findNodeAt(mouseX, mouseY);
if (clicked != null) {
boolean lockedByExclusivity = clicked.getExclusiveWith().stream()
.anyMatch(SkilltreeClientState::isUnlocked);
if (lockedByExclusivity) {
SkillNode clickedNode = findNodeAt(mouseX, mouseY);
if (clickedNode != null) {
if (isLockedByExclusivity(clickedNode)) {
Minecraft.getInstance().player.playSound(
net.minecraft.sounds.SoundEvents.VILLAGER_NO, 1f, 1f
);
return true;
}
if (SkilltreeClientState.isUnlocked(clicked.getId())) {
if (SkilltreeClientState.isUnlocked(clickedNode.getId())) {
return true;
}
selectedNode = clicked;
selectedNode = clickedNode;
rebuildOverlayButtons();
return true;
}
AbilityNode clickedAbility = findAbilityAt(mouseX, mouseY);
if (clickedAbility != null) {
if (SkilltreeClientState.isAbilityUnlocked(clickedAbility.getId())) {
Minecraft.getInstance().setScreen(new AbilityBindingScreen(clickedAbility));
} else {
selectedAbility = clickedAbility;
rebuildAbilityOverlayButtons();
}
return true;
}
dragging = true;
lastMouseX = mouseX;
lastMouseY = mouseY;
@@ -83,7 +105,7 @@ public class SkilltreeScreen extends Screen {
@Override
public boolean mouseDragged(double mouseX, double mouseY, int button, double dx, double dy) {
if (selectedNode != null) {
if (selectedNode != null || selectedAbility != null) {
return super.mouseDragged(mouseX, mouseY, button, dx, dy);
}
if (dragging && button == 0) {
@@ -105,48 +127,26 @@ public class SkilltreeScreen extends Screen {
@Override
public void render(GuiGraphics gfx, int mouseX, int mouseY, float partialTicks) {
this.renderBackground(gfx);
renderSkillPointHeader(gfx);
hoveredNode = findNodeAt(mouseX, mouseY);
hoveredAbility = findAbilityAt(mouseX, mouseY);
drawGraph(gfx);
if (hoveredNode != null && selectedNode == null && selectedAbility == null) {
renderNodeTooltip(gfx, hoveredNode, mouseX, mouseY);
}
if (selectedNode != null) {
renderNodeOverlay(gfx, selectedNode);
} else if (selectedAbility != null) {
renderAbilityOverlay(gfx, selectedAbility);
}
super.render(gfx, mouseX, mouseY, partialTicks);
}
private void renderSkillPointHeader(GuiGraphics gfx) {
int remaining = SkilltreeClientState.getCurrentSkillPoints();
Component pointsText = Component.literal(remaining + " skillpoints remaining")
.withStyle(ChatFormatting.YELLOW);
gfx.drawString(font, pointsText, 10, 10, 0xFFFFFF);
hoveredNode = findNodeAt(mouseX, mouseY);
drawGraph(gfx);
if (hoveredNode != null && selectedNode == null) {
List<Component> tooltip = new ArrayList<>();
tooltip.add(hoveredNode.getTitle());
String desc = hoveredNode.getDescription().getString();
List<FormattedText> wrapped = font.getSplitter().splitLines(
desc,
220,
hoveredNode.getDescription().getStyle()
);
for (FormattedText ft : wrapped) {
tooltip.add(Component.literal(ft.getString()).withStyle(ChatFormatting.GRAY));
}
tooltip.add(Component.literal("Cost: " + hoveredNode.getCost())
.withStyle(ChatFormatting.DARK_AQUA));
if (!hoveredNode.getPrereqAttributes().isEmpty()) {
tooltip.add(Component.literal("Requirements:")
.withStyle(ChatFormatting.GOLD));
Minecraft.getInstance().player.getCapability(PlayerStatsProvider.PLAYER_STATS)
.ifPresent(stats -> {
hoveredNode.getPrereqAttributes().forEach((attr, required) -> {
int current = stats.getAttributeLevel(attr);
boolean ok = current >= required;
tooltip.add(
Component.literal(" - " + attr + ": " + current + "/" + required)
.withStyle(ok ? ChatFormatting.GREEN : ChatFormatting.RED)
);
});
});
}
gfx.renderComponentTooltip(font, tooltip, mouseX, mouseY);
}
if (selectedNode != null) {
renderOverlay(gfx, selectedNode);
}
super.render(gfx, mouseX, mouseY, partialTicks);
}
private void drawGraph(GuiGraphics gfx) {
@@ -161,35 +161,212 @@ public class SkilltreeScreen extends Screen {
SkilltreeClientState.isUnlocked(neighbor.getId());
int linkColor = bothUnlocked ? 0xFF88FF88 : 0xFF666666;
drawLine(gfx, p1[0], p1[1], p2[0], p2[1], linkColor);
}
}
for (AbilityNode ability : AbilityNodeRegistry.all()) {
int[] p1 = worldToScreen(ability.getX(), ability.getY());
for (ResourceLocation link : ability.getLinks()) {
SkillNode node = SkillNodeRegistry.get(link);
AbilityNode ability2 = AbilityNodeRegistry.get(link);
if (node != null) {
int[] p2 = worldToScreen(node.getX(), node.getY());
drawLine(gfx, p1[0], p1[1], p2[0], p2[1], 0xFF6666AA);
} else if (ability2 != null) {
int[] p2 = worldToScreen(ability2.getX(), ability2.getY());
drawLine(gfx, p1[0], p1[1], p2[0], p2[1], 0xFF6666AA);
}
}
}
for (AbilityNode ability : AbilityNodeRegistry.all()) {
int[] pos = worldToScreen(ability.getX(), ability.getY());
boolean unlocked = SkilltreeClientState.isAbilityUnlocked(ability.getId());
int color;
if (ability == selectedAbility) {
color = 0xFFAA8833;
} else if (ability == hoveredAbility) {
color = 0xFF8888AA;
} else if (unlocked) {
color = 0xFF3399FF;
} else {
color = 0xFF223355;
}
gfx.fill(
pos[0] - ABILITY_RADIUS,
pos[1] - ABILITY_RADIUS,
pos[0] + ABILITY_RADIUS,
pos[1] + ABILITY_RADIUS,
color
);
ResourceLocation icon = ability.getIcon();
if (icon != null) {
gfx.blit(icon, pos[0] - 8, pos[1] - 8, 0, 0, 16, 16, 16, 16);
}
}
for (SkillNode node : SkillNodeRegistry.all()) {
int[] pos = worldToScreen(node.getX(), node.getY());
int r = 12;
boolean unlocked = SkilltreeClientState.isUnlocked(node.getId());
int color = unlocked ? 0xFF44CC44 : 0xFF444444;
boolean lockedByExclusivity = node.getExclusiveWith().stream()
.anyMatch(SkilltreeClientState::isUnlocked);
if (lockedByExclusivity) {
color = 0xFFCC3333;
}
if (node == hoveredNode) color = 0xFF777777;
if (node == selectedNode) color = 0xFF999933;
gfx.fill(pos[0] - r, pos[1] - r, pos[0] + r, pos[1] + r, color);
int color = getNodeColor(node);
gfx.fill(
pos[0] - NODE_RADIUS,
pos[1] - NODE_RADIUS,
pos[0] + NODE_RADIUS,
pos[1] + NODE_RADIUS,
color
);
ResourceLocation icon = node.getIcons();
if (icon != null) {
int size = 16;
gfx.blit(icon,
gfx.blit(
icon,
pos[0] - size / 2,
pos[1] - size / 2,
0, 0,
size, size,
size, size);
size, size
);
}
}
}
private void renderNodeTooltip(GuiGraphics gfx, SkillNode node, int mouseX, int mouseY) {
List<Component> tooltip = new ArrayList<>();
tooltip.add(node.getTitle());
String desc = node.getDescription().getString();
List<FormattedText> wrapped = font.getSplitter().splitLines(
desc,
220,
node.getDescription().getStyle()
);
for (FormattedText ft : wrapped) {
tooltip.add(Component.literal(ft.getString()).withStyle(ChatFormatting.GRAY));
}
tooltip.add(Component.literal("Cost: " + node.getCost())
.withStyle(ChatFormatting.DARK_AQUA));
if (!node.getPrereqAttributes().isEmpty()) {
tooltip.add(Component.literal("Requirements:")
.withStyle(ChatFormatting.GOLD));
Minecraft.getInstance().player.getCapability(PlayerStatsProvider.PLAYER_STATS)
.ifPresent(stats -> node.getPrereqAttributes().forEach((attr, required) -> {
int current = stats.getAttributeLevel(attr);
boolean ok = current >= required;
tooltip.add(
Component.literal(" - " + attr + ": " + current + "/" + required)
.withStyle(ok ? ChatFormatting.GREEN : ChatFormatting.RED)
);
}));
}
gfx.renderComponentTooltip(font, tooltip, mouseX, mouseY);
}
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);
gfx.fill(x, y, x + OVERLAY_WIDTH, y + OVERLAY_HEIGHT, 0xFF222222);
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};
gfx.drawString(
font,
Component.literal("Cost: " + node.getCost() + " skill points")
.withStyle(ChatFormatting.AQUA),
x + 10,
textY[0],
0xAAAAAA
);
textY[0] += 15;
if (!node.getPrereqAttributes().isEmpty()) {
gfx.drawString(
font,
Component.literal("Requirements:")
.withStyle(ChatFormatting.GOLD),
x + 10,
textY[0],
0xFFFFFF
);
textY[0] += 12;
Minecraft.getInstance().player.getCapability(PlayerStatsProvider.PLAYER_STATS)
.ifPresent(stats -> node.getPrereqAttributes().forEach((attr, required) -> {
int current = stats.getAttributeLevel(attr);
boolean ok = current >= required;
Component line = Component.literal(
" - " + attr + ": " + current + "/" + required
).withStyle(ok ? ChatFormatting.GREEN : ChatFormatting.RED);
gfx.drawString(font, line, x + 10, textY[0], 0xFFFFFF);
textY[0] += 12;
}));
}
}
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);
gfx.fill(x, y, x + OVERLAY_WIDTH, y + OVERLAY_HEIGHT, 0xFF222222);
gfx.drawCenteredString(font, ability.getTitle(), x + OVERLAY_WIDTH / 2, y + 10, 0xFFFFFF);
if (ability.getDescription() != null) {
gfx.drawWordWrap(font, ability.getDescription(), x + 10, y + 30, OVERLAY_WIDTH - 20, 0xDDDDDD);
}
}
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 -> {
PacketHandler.CHANNEL.sendToServer(
new ServerboundUnlockNodePacket(selectedNode.getId())
);
selectedNode = null;
clearWidgets();
}).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();
}).pos(x + OVERLAY_WIDTH - 60, y - 5)
.size(50, 20)
.build());
}
private void rebuildAbilityOverlayButtons() {
clearWidgets();
if (selectedAbility == null) {
return;
}
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 -> {
PacketHandler.CHANNEL.sendToServer(
new ServerboundUnlockNodePacket(selectedAbility.getId())
);
selectedAbility = null;
clearWidgets();
}).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();
}).pos(x + OVERLAY_WIDTH - 60, y - 5)
.size(50, 20)
.build());
}
private int[] worldToScreen(float wx, float wy) {
double sx = width / 2.0 + panX + wx * zoom;
double sy = height / 2.0 + panY - wy * zoom;
@@ -199,83 +376,51 @@ public class SkilltreeScreen extends Screen {
private SkillNode findNodeAt(double mouseX, double mouseY) {
for (SkillNode node : SkillNodeRegistry.all()) {
int[] pos = worldToScreen(node.getX(), node.getY());
int r = 12;
if (mouseX >= pos[0] - r && mouseX <= pos[0] + r &&
mouseY >= pos[1] - r && mouseY <= pos[1] + r) {
if (isPointInCircle(mouseX, mouseY, pos[0], pos[1], NODE_RADIUS)) {
return node;
}
}
return null;
}
private void renderOverlay(GuiGraphics gfx, SkillNode node) {
boolean lockedByExclusivity = node.getExclusiveWith().stream()
.anyMatch(SkilltreeClientState::isUnlocked);
if (lockedByExclusivity) return;
int w = 240;
int h = 180;
int x = (width - w) / 2;
int y = (height - h) / 2;
gfx.fill(0, 0, width, height, 0x88000000);
gfx.fill(x, y, x + w, y + h, 0xFF222222);
gfx.drawCenteredString(font, node.getTitle(), x + w / 2, y + 10, 0xFFFFFF);
gfx.drawWordWrap(font, node.getDescription(), x + 10, y + 30, w - 20, 0xDDDDDD);
final int[] textY = {y + 95};
gfx.drawString(font,
Component.literal("Cost: " + node.getCost() + " skill points")
.withStyle(ChatFormatting.AQUA),
x + 10, textY[0],
0xAAAAAA);
textY[0] += 15;
if (!node.getPrereqAttributes().isEmpty()) {
gfx.drawString(font,
Component.literal("Requirements:")
.withStyle(ChatFormatting.GOLD),
x + 10, textY[0],
0xFFFFFF);
textY[0] += 12;
Minecraft.getInstance().player.getCapability(PlayerStatsProvider.PLAYER_STATS)
.ifPresent(stats -> {
for (var entry : node.getPrereqAttributes().entrySet()) {
String attr = entry.getKey();
int required = entry.getValue();
int current = stats.getAttributeLevel(attr);
boolean ok = current >= required;
Component line = Component.literal(
" - " + attr + ": " + current + "/" + required
).withStyle(ok ? ChatFormatting.GREEN : ChatFormatting.RED);
gfx.drawString(font, line, x + 10, textY[0], 0xFFFFFF);
textY[0] += 12;
}
});
private AbilityNode findAbilityAt(double mouseX, double mouseY) {
for (AbilityNode ability : AbilityNodeRegistry.all()) {
int[] pos = worldToScreen(ability.getX(), ability.getY());
if (isPointInCircle(mouseX, mouseY, pos[0], pos[1], ABILITY_RADIUS)) {
return ability;
}
}
return null;
}
private void rebuildOverlayButtons() {
clearWidgets();
if (selectedNode == null) return;
int w = 240;
int h = 160;
int x = (width - w) / 2;
int y = (height - h) / 2;
boolean alreadyUnlocked = SkilltreeClientState.isUnlocked(selectedNode.getId());
if (!alreadyUnlocked) {
addRenderableWidget(Button.builder(Component.literal("Unlock"), btn -> {
PacketHandler.CHANNEL.sendToServer(
new ServerboundUnlockNodePacket(selectedNode.getId())
);
selectedNode = null;
clearWidgets();
}).pos(x + w / 2 - 40, y + h - 20).size(80, 20).build());
private boolean isPointInCircle(double mx, double my, int cx, int cy, int radius) {
return mx >= cx - radius && mx <= cx + radius &&
my >= cy - radius && my <= cy + radius;
}
private boolean isLockedByExclusivity(SkillNode node) {
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 (lockedByExclusivity) {
color = 0xFFCC3333;
} else if (node == selectedNode) {
color = 0xFF999933;
} else if (node == hoveredNode) {
color = 0xFF777777;
} else if (unlocked) {
color = 0xFF44CC44;
} else {
color = 0xFF444444;
}
addRenderableWidget(Button.builder(Component.literal("Close"), btn -> {
selectedNode = null;
clearWidgets();
}).pos(x + w - 60, y - 5).size(50, 20).build());
return color;
}
private void drawLine(GuiGraphics gfx, int x1, int y1, int x2, int y2, int color) {
float thickness = 2.0f;
float dx = x2 - x1;
float dy = y2 - y1;
float length = (float) Math.sqrt(dx * dx + dy * dy);
@@ -283,11 +428,13 @@ public class SkilltreeScreen extends Screen {
gfx.pose().pushPose();
gfx.pose().translate(x1, y1, 0);
gfx.pose().mulPose(Axis.ZP.rotation(angle));
gfx.fill(0,
(int) (-thickness / 2f),
gfx.fill(
0,
(int) (-LINE_THICKNESS / 2f),
(int) length,
(int) (thickness / 2f),
color);
(int) (LINE_THICKNESS / 2f),
color
);
gfx.pose().popPose();
}