extend ability system to support dynamic slot counts, add skill tree validation, new prerequisites API, and extended mod commands

This commit is contained in:
2026-05-25 12:33:49 +02:00
parent f7332054ea
commit 77fbada438
32 changed files with 961 additions and 128 deletions
@@ -1,10 +1,13 @@
package net.fretux.skillengine;
import com.mojang.logging.LogUtils;
import net.fretux.skillengine.config.SkillEngineConfig;
import net.minecraftforge.api.distmarker.Dist;
import net.fretux.skillengine.network.PacketHandler;
import net.minecraftforge.eventbus.api.IEventBus;
import net.minecraftforge.fml.DistExecutor;
import net.minecraftforge.fml.ModLoadingContext;
import net.minecraftforge.fml.config.ModConfig;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
import org.slf4j.Logger;
@@ -17,6 +20,7 @@ public class SkillEngine {
public SkillEngine() {
IEventBus modBus = FMLJavaModLoadingContext.get().getModEventBus();
ModLoadingContext.get().registerConfig(ModConfig.Type.COMMON, SkillEngineConfig.COMMON_SPEC);
PacketHandler.register();
DistExecutor.unsafeRunWhenOn(
Dist.CLIENT,
@@ -23,7 +23,7 @@ public class AbilityCooldownHandler {
data.tickCooldowns();
data.tickTagCooldowns();
if (player.tickCount % 10 == 0) {
for (int i = 1; i <= 3; i++) {
for (int i = 1; i <= data.getAbilitySlots().length; i++) {
PacketHandler.CHANNEL.send(
PacketDistributor.PLAYER.with(() -> player),
new ClientboundSyncCooldownPacket(i, data.getCooldown(i))
@@ -0,0 +1,9 @@
package net.fretux.skillengine.api;
import net.fretux.skillengine.skilltree.AbilityNode;
import net.minecraft.server.level.ServerPlayer;
@FunctionalInterface
public interface AbilityHandler {
boolean execute(ServerPlayer player, AbilityNode ability);
}
@@ -0,0 +1,38 @@
package net.fretux.skillengine.api;
import net.fretux.skillengine.skilltree.AbilityNode;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import java.util.LinkedHashMap;
import java.util.Map;
public final class AbilityHandlerRegistry {
private static final Map<ResourceLocation, AbilityHandler> BY_ID = new LinkedHashMap<>();
private static final Map<ResourceLocation, AbilityHandler> BY_TAG = new LinkedHashMap<>();
private AbilityHandlerRegistry() {}
public static void register(ResourceLocation abilityId, AbilityHandler handler) {
BY_ID.put(abilityId, handler);
}
public static void registerForTag(ResourceLocation tag, AbilityHandler handler) {
BY_TAG.put(tag, handler);
}
public static boolean execute(ServerPlayer player, AbilityNode ability) {
AbilityHandler direct = BY_ID.get(ability.getId());
if (direct != null) {
return direct.execute(player, ability);
}
for (ResourceLocation tag : ability.getTags()) {
AbilityHandler tagged = BY_TAG.get(tag);
if (tagged != null && tagged.execute(player, ability)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,9 @@
package net.fretux.skillengine.api;
import net.fretux.skillengine.skilltree.SkillNode;
import net.minecraft.server.level.ServerPlayer;
@FunctionalInterface
public interface PrerequisiteProvider {
boolean meets(ServerPlayer player, SkillNode node, String key, int requiredValue);
}
@@ -0,0 +1,38 @@
package net.fretux.skillengine.api;
import net.fretux.ascend.player.PlayerStatsProvider;
import net.fretux.skillengine.skilltree.SkillNode;
import net.minecraft.server.level.ServerPlayer;
import java.util.LinkedHashMap;
import java.util.Map;
public final class PrerequisiteProviderRegistry {
private static final Map<String, PrerequisiteProvider> PROVIDERS = new LinkedHashMap<>();
static {
register("ascend", (player, node, key, requiredValue) ->
player.getCapability(PlayerStatsProvider.PLAYER_STATS)
.map(stats -> stats.getAttributeLevel(key) >= requiredValue)
.orElse(false));
}
private PrerequisiteProviderRegistry() {}
public static void register(String namespace, PrerequisiteProvider provider) {
PROVIDERS.put(namespace, provider);
}
public static boolean meets(ServerPlayer player, SkillNode node, String key, int requiredValue) {
int separator = key.indexOf(':');
String namespace = separator > 0 ? key.substring(0, separator) : "ascend";
String localKey = separator > 0 ? key.substring(separator + 1) : key;
PrerequisiteProvider provider = PROVIDERS.get(namespace);
return provider != null && provider.meets(player, node, localKey, requiredValue);
}
public static boolean hasProvider(String namespace) {
return PROVIDERS.containsKey(namespace);
}
}
@@ -4,6 +4,7 @@ import net.fretux.skillengine.SkillEngine;
import net.fretux.skillengine.capability.PlayerSkillData;
import net.fretux.skillengine.capability.SkillEngineCapabilities;
import net.fretux.skillengine.client.ClientSkillEngineBridge;
import net.fretux.skillengine.events.SkillPointsChangedEvent;
import net.fretux.skillengine.skilltree.AbilityNode;
import net.fretux.skillengine.skilltree.AbilityNodeRegistry;
import net.fretux.skillengine.skilltree.SkillLogic;
@@ -12,6 +13,7 @@ import net.fretux.skillengine.skilltree.SkillNodeRegistry;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.fml.DistExecutor;
import net.minecraftforge.fml.loading.FMLEnvironment;
@@ -65,7 +67,17 @@ public final class SkillEngineAPI {
public static void addSkillPoints(Player player, int amount) {
PlayerSkillData data = getPlayerData(player);
if (data != null) data.addSkillPoints(amount);
if (data != null) {
data.addSkillPoints(amount);
if (amount != 0) {
MinecraftForge.EVENT_BUS.post(new SkillPointsChangedEvent(
player,
Math.abs(amount),
amount > 0 ? SkillPointsChangedEvent.Reason.GAINED : SkillPointsChangedEvent.Reason.SPENT,
null
));
}
}
}
public static boolean consumeSkillPoints(Player player, int amount) {
@@ -73,6 +85,12 @@ public final class SkillEngineAPI {
PlayerSkillData data = getPlayerData(player);
if (data != null && data.getSkillPoints() >= amount) {
data.addSkillPoints(-amount);
MinecraftForge.EVENT_BUS.post(new SkillPointsChangedEvent(
player,
amount,
SkillPointsChangedEvent.Reason.SPENT,
null
));
return true;
}
return false;
@@ -97,10 +115,61 @@ public final class SkillEngineAPI {
if (unlockPlan == null) return false;
for (SkillNode plannedNode : unlockPlan) {
data.unlockNode(plannedNode);
if (plannedNode.getCost() > 0) {
MinecraftForge.EVENT_BUS.post(new SkillPointsChangedEvent(
player,
plannedNode.getCost(),
SkillPointsChangedEvent.Reason.SPENT,
plannedNode.getId()
));
}
}
return true;
}
public static boolean refundSkill(Player player, ResourceLocation id) {
PlayerSkillData data = getPlayerData(player);
SkillNode node = SkillNodeRegistry.get(id);
if (data == null || node == null || !data.refundNode(id)) return false;
MinecraftForge.EVENT_BUS.post(new SkillPointsChangedEvent(
player,
node.getCost(),
SkillPointsChangedEvent.Reason.REFUNDED,
id
));
return true;
}
public static int refundBranch(Player player, ResourceLocation rootId) {
PlayerSkillData data = getPlayerData(player);
if (data == null) return 0;
int refunded = data.refundBranch(rootId);
if (refunded > 0) {
MinecraftForge.EVENT_BUS.post(new SkillPointsChangedEvent(
player,
refunded,
SkillPointsChangedEvent.Reason.REFUNDED,
rootId
));
}
return refunded;
}
public static int resetSkills(Player player) {
PlayerSkillData data = getPlayerData(player);
if (data == null) return 0;
int refunded = data.resetAllNodesPreserveEarnedPoints();
if (refunded > 0) {
MinecraftForge.EVENT_BUS.post(new SkillPointsChangedEvent(
player,
refunded,
SkillPointsChangedEvent.Reason.REFUNDED,
null
));
}
return refunded;
}
public static boolean isAbilityUnlocked(Player player, ResourceLocation id) {
PlayerSkillData data = getPlayerData(player);
return data != null && data.isAbilityUnlocked(id);
@@ -1,6 +1,7 @@
package net.fretux.skillengine.capability;
import net.fretux.skillengine.network.PacketHandler;
import net.fretux.skillengine.config.SkillEngineConfig;
import net.fretux.skillengine.skilltree.AbilityNode;
import net.fretux.skillengine.skilltree.SkillNode;
import net.fretux.skillengine.skilltree.SkillNodeRegistry;
@@ -21,8 +22,8 @@ public class PlayerSkillData {
private final Set<ResourceLocation> unlockedNodes = new HashSet<>();
private final Set<ResourceLocation> unlockedAbilities = new HashSet<>();
private final Set<ResourceLocation> activeTags = new HashSet<>();
private final ResourceLocation[] abilitySlots = new ResourceLocation[3];
private final int[] abilityCooldowns = new int[3];
private ResourceLocation[] abilitySlots = new ResourceLocation[getConfiguredSlotCount()];
private int[] abilityCooldowns = new int[abilitySlots.length];
private final Map<ResourceLocation, Integer> tagCooldowns = new HashMap<>();
public boolean isOnCooldown(ResourceLocation tag) {
@@ -84,6 +85,7 @@ public class PlayerSkillData {
}
public void bindAbility(int slot, ResourceLocation abilityId) {
ensureSlotCount();
bindHelper(slot, abilityId, abilitySlots);
}
@@ -101,16 +103,19 @@ public class PlayerSkillData {
}
public ResourceLocation getAbilityInSlot(int slot) {
ensureSlotCount();
int index = slot - 1;
if (index < 0 || index >= abilitySlots.length) return null;
return abilitySlots[index];
}
public ResourceLocation[] getAbilitySlots() {
ensureSlotCount();
return abilitySlots.clone();
}
public void clearAbilitySlots() {
ensureSlotCount();
for (int i = 0; i < abilitySlots.length; i++) {
abilitySlots[i] = null;
abilityCooldowns[i] = 0;
@@ -118,18 +123,21 @@ public class PlayerSkillData {
}
public void setCooldown(int slot, int ticks) {
ensureSlotCount();
int idx = slot - 1;
if (idx < 0 || idx >= abilityCooldowns.length) return;
abilityCooldowns[idx] = ticks;
}
public int getCooldown(int slot) {
ensureSlotCount();
int idx = slot - 1;
if (idx < 0 || idx >= abilityCooldowns.length) return 0;
return abilityCooldowns[idx];
}
public void tickCooldowns() {
ensureSlotCount();
for (int i = 0; i < abilityCooldowns.length; i++) {
if (abilityCooldowns[i] > 0) {
abilityCooldowns[i]--;
@@ -155,6 +163,7 @@ public class PlayerSkillData {
}
tag.put("ActiveTags", tagsList);
ListTag slotList = new ListTag();
ensureSlotCount();
for (ResourceLocation slot : abilitySlots) {
CompoundTag t = new CompoundTag();
t.putString("Id", slot == null ? "" : slot.toString());
@@ -183,8 +192,8 @@ public class PlayerSkillData {
unlockedNodes.clear();
unlockedAbilities.clear();
activeTags.clear();
Arrays.fill(abilitySlots, null);
Arrays.fill(abilityCooldowns, 0);
abilitySlots = new ResourceLocation[getConfiguredSlotCount()];
abilityCooldowns = new int[abilitySlots.length];
tagCooldowns.clear();
ListTag nodeList = tag.getList("UnlockedNodes", Tag.TAG_COMPOUND);
for (Tag t : nodeList) {
@@ -198,7 +207,7 @@ public class PlayerSkillData {
activeTags.add(ResourceLocation.parse(((CompoundTag) t).getString("Tag")));
}
ListTag slots = tag.getList("AbilitySlots", Tag.TAG_COMPOUND);
for (int i = 0; i < 3 && i < slots.size(); i++) {
for (int i = 0; i < abilitySlots.length && i < slots.size(); i++) {
String id = slots.getCompound(i).getString("Id");
abilitySlots[i] = id.isEmpty() ? null : ResourceLocation.parse(id);
}
@@ -229,6 +238,76 @@ public class PlayerSkillData {
clearAbilitySlots();
}
public boolean refundNode(ResourceLocation nodeId) {
SkillNode node = SkillNodeRegistry.get(nodeId);
if (node == null || !unlockedNodes.remove(nodeId)) return false;
for (SkillNode other : SkillNodeRegistry.all()) {
if (unlockedNodes.contains(other.getId()) && other.getLinks().contains(nodeId)) {
unlockedNodes.add(nodeId);
return false;
}
}
skillPoints += node.getCost();
rebuildActiveTags();
return true;
}
public int refundBranch(ResourceLocation rootId) {
int refunded = 0;
Set<ResourceLocation> toRefund = new HashSet<>();
collectChildren(rootId, toRefund);
for (ResourceLocation id : toRefund) {
SkillNode node = SkillNodeRegistry.get(id);
if (node != null && unlockedNodes.remove(id)) {
skillPoints += node.getCost();
refunded += node.getCost();
}
}
rebuildActiveTags();
return refunded;
}
public int resetAllNodesPreserveEarnedPoints() {
int refunded = getTotalSkillCost();
skillPoints += refunded;
clearAllNodes();
return refunded;
}
private void rebuildActiveTags() {
activeTags.clear();
for (ResourceLocation id : unlockedNodes) {
SkillNode node = SkillNodeRegistry.get(id);
if (node != null) activeTags.addAll(node.getTags());
}
}
private void collectChildren(ResourceLocation rootId, Set<ResourceLocation> out) {
if (!out.add(rootId)) return;
for (SkillNode node : SkillNodeRegistry.all()) {
if (node.getLinks().contains(rootId)) {
collectChildren(node.getId(), out);
}
}
}
private static int getConfiguredSlotCount() {
try {
return SkillEngineConfig.ABILITY_SLOT_COUNT.get();
} catch (IllegalStateException ignored) {
return 3;
}
}
private void ensureSlotCount() {
int configured = getConfiguredSlotCount();
if (abilitySlots.length == configured) return;
ResourceLocation[] oldSlots = abilitySlots;
int[] oldCooldowns = abilityCooldowns;
abilitySlots = Arrays.copyOf(oldSlots, configured);
abilityCooldowns = Arrays.copyOf(oldCooldowns, configured);
}
public void sync(ServerPlayer player) {
PacketHandler.syncSkillsTo(player);
}
@@ -35,27 +35,23 @@ public class AbilityBindingScreen extends Screen {
protected void init() {
int x = (width - PANEL_WIDTH) / 2;
int y = (height - PANEL_HEIGHT) / 2;
int buttonRowWidth = SLOT_BUTTON_WIDTH * 3 + BUTTON_GAP * 2;
int slotCount = SkilltreeClientState.getAbilitySlots().length;
int visibleSlots = Math.min(slotCount, 3);
int buttonRowWidth = SLOT_BUTTON_WIDTH * visibleSlots + BUTTON_GAP * Math.max(0, visibleSlots - 1);
int buttonX = x + (PANEL_WIDTH - buttonRowWidth) / 2;
int buttonY = y + PANEL_HEIGHT - 54;
int boundSlot = SkilltreeClientState.getSlotOfAbility(ability.getId());
Button yBtn = addRenderableWidget(Button.builder(Component.literal("Slot 1"),
b -> bind(1)).pos(buttonX, buttonY).size(SLOT_BUTTON_WIDTH, BUTTON_HEIGHT).build());
if (boundSlot == 1) {
yBtn.active = false;
yBtn.setMessage(Component.literal("Bound 1"));
for (int slot = 1; slot <= visibleSlots; slot++) {
int currentSlot = slot;
Button button = addRenderableWidget(Button.builder(Component.literal("Slot " + slot),
b -> bind(currentSlot))
.pos(buttonX + (SLOT_BUTTON_WIDTH + BUTTON_GAP) * (slot - 1), buttonY)
.size(SLOT_BUTTON_WIDTH, BUTTON_HEIGHT)
.build());
if (boundSlot == slot) {
button.active = false;
button.setMessage(Component.literal("Bound " + slot));
}
Button xBtn = addRenderableWidget(Button.builder(Component.literal("Slot 2"),
b -> bind(2)).pos(buttonX + SLOT_BUTTON_WIDTH + BUTTON_GAP, buttonY).size(SLOT_BUTTON_WIDTH, BUTTON_HEIGHT).build());
if (boundSlot == 2) {
xBtn.active = false;
xBtn.setMessage(Component.literal("Bound 2"));
}
Button cBtn = addRenderableWidget(Button.builder(Component.literal("Slot 3"),
b -> bind(3)).pos(buttonX + (SLOT_BUTTON_WIDTH + BUTTON_GAP) * 2, buttonY).size(SLOT_BUTTON_WIDTH, BUTTON_HEIGHT).build());
if (boundSlot == 3) {
cBtn.active = false;
cBtn.setMessage(Component.literal("Bound 3"));
}
addRenderableWidget(Button.builder(Component.literal("Cancel"),
b -> onClose()).pos(x + PANEL_WIDTH / 2 - 40, y + PANEL_HEIGHT - 28).size(80, BUTTON_HEIGHT).build());
@@ -13,7 +13,8 @@ public class AbilityHudOverlay {
if (mc.player == null) return;
int xStart = event.getGuiGraphics().guiWidth() - 80;
int yStart = 10;
for (int slot = 1; slot <= 3; slot++) {
int slots = SkilltreeClientState.getAbilitySlots().length;
for (int slot = 1; slot <= slots; slot++) {
var abilityId = SkilltreeClientState.getAbilityInSlot(slot);
if (abilityId == null) continue;
AbilityNode ability = AbilityNodeRegistry.get(abilityId);
@@ -4,43 +4,45 @@ import com.mojang.blaze3d.platform.InputConstants;
import net.fretux.skillengine.network.PacketHandler;
import net.fretux.skillengine.network.ServerboundActivateAbilityPacket;
import net.minecraft.client.KeyMapping;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.InputEvent;
import net.minecraftforge.client.settings.KeyConflictContext;
import org.lwjgl.glfw.GLFW;
public class AbilityKeybindHandler {
public static final KeyMapping ABILITY_1 = new KeyMapping(
"key.skillengine.ability1", KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_Y, "key.categories.gameplay"
);
public static final int MAX_REGISTERED_ABILITY_SLOTS = 9;
public static final KeyMapping[] ABILITY_KEYS = new KeyMapping[MAX_REGISTERED_ABILITY_SLOTS];
public static final KeyMapping ABILITY_2 = new KeyMapping(
"key.skillengine.ability2", KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_X, "key.categories.gameplay"
);
public static final KeyMapping ABILITY_3 = new KeyMapping(
"key.skillengine.ability3", KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_C, "key.categories.gameplay"
static {
for (int slot = 1; slot <= MAX_REGISTERED_ABILITY_SLOTS; slot++) {
ABILITY_KEYS[slot - 1] = new KeyMapping(
"key.skillengine.ability" + slot,
KeyConflictContext.IN_GAME,
InputConstants.Type.KEYSYM,
defaultKeyForSlot(slot),
"key.categories.gameplay"
);
}
}
public static void onInput(InputEvent.Key event) {
if (ABILITY_1.consumeClick()) {
if (SkilltreeClientState.getClientCooldown(1) <= 0) {
PacketHandler.CHANNEL.sendToServer(new ServerboundActivateAbilityPacket(1));
}
}
if (ABILITY_2.consumeClick()) {
if (SkilltreeClientState.getClientCooldown(2) <= 0) {
PacketHandler.CHANNEL.sendToServer(new ServerboundActivateAbilityPacket(2));
}
}
if (ABILITY_3.consumeClick()) {
if (SkilltreeClientState.getClientCooldown(3) <= 0) {
PacketHandler.CHANNEL.sendToServer(new ServerboundActivateAbilityPacket(3));
int activeSlots = SkilltreeClientState.getAbilitySlots().length;
int handledSlots = Math.min(activeSlots, ABILITY_KEYS.length);
for (int slot = 1; slot <= handledSlots; slot++) {
if (ABILITY_KEYS[slot - 1].consumeClick()) {
if (SkilltreeClientState.getClientCooldown(slot) <= 0) {
PacketHandler.CHANNEL.sendToServer(new ServerboundActivateAbilityPacket(slot));
}
}
}
}
private static int defaultKeyForSlot(int slot) {
return switch (slot) {
case 1 -> GLFW.GLFW_KEY_Z;
case 2 -> GLFW.GLFW_KEY_X;
case 3 -> GLFW.GLFW_KEY_C;
default -> InputConstants.UNKNOWN.getValue();
};
}
}
@@ -12,7 +12,7 @@ import org.jetbrains.annotations.NotNull;
public class AbilitySlotsScreen extends Screen {
private static final int PANEL_WIDTH = 260;
private static final int PANEL_HEIGHT = 180;
private static final int MIN_PANEL_HEIGHT = 180;
private static final int SLOT_TEXT_RIGHT_PADDING = 8;
private static final int REBIND_BUTTON_WIDTH = 80;
public AbilitySlotsScreen() {
@@ -22,21 +22,19 @@ public class AbilitySlotsScreen extends Screen {
@Override
protected void init() {
int x = (width - PANEL_WIDTH) / 2;
int y = (height - PANEL_HEIGHT) / 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;
// Y slot = 1
addRenderableWidget(Button.builder(Component.literal("Rebind"), b -> openSelect(1))
.pos(buttonX, rowY).size(REBIND_BUTTON_WIDTH, buttonH).build());
// X slot = 2
addRenderableWidget(Button.builder(Component.literal("Rebind"), b -> openSelect(2))
.pos(buttonX, rowY + 35).size(REBIND_BUTTON_WIDTH, buttonH).build());
// C slot = 3
addRenderableWidget(Button.builder(Component.literal("Rebind"), b -> openSelect(3))
.pos(buttonX, rowY + 70).size(REBIND_BUTTON_WIDTH, buttonH).build());
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(Button.builder(Component.literal("Close"), b -> onClose())
.pos(x + PANEL_WIDTH - 70, y + PANEL_HEIGHT - 28).size(60, 20).build());
.pos(x + PANEL_WIDTH - 70, y + panelHeight - 28).size(60, 20).build());
}
private void openSelect(int slot) {
@@ -57,14 +55,16 @@ public class AbilitySlotsScreen 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 panelHeight = panelHeight();
int y = (height - panelHeight) / 2;
gfx.fill(0, 0, width, height, 0xAA000000);
gfx.fill(x, y, x + PANEL_WIDTH, y + PANEL_HEIGHT, 0xFF222222);
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;
drawSlotRow(gfx, x + 15, rowY, 1, "Y");
drawSlotRow(gfx, x + 15, rowY + 35, 2, "X");
drawSlotRow(gfx, x + 15, rowY + 70, 3, "C");
int slots = SkilltreeClientState.getAbilitySlots().length;
for (int slot = 1; slot <= slots; slot++) {
drawSlotRow(gfx, x + 15, rowY + (slot - 1) * 35, slot, "Slot " + slot);
}
super.render(gfx, mouseX, mouseY, partialTick);
}
@@ -106,4 +106,9 @@ public class AbilitySlotsScreen extends Screen {
}
return font.plainSubstrByWidth(title, availableWidth - suffixWidth) + suffix;
}
private int panelHeight() {
int slots = SkilltreeClientState.getAbilitySlots().length;
return Math.max(MIN_PANEL_HEIGHT, 80 + slots * 35);
}
}
@@ -5,10 +5,11 @@ import net.minecraftforge.event.TickEvent;
public class ClientCooldownTicker {
public static void onClientTick(TickEvent.ClientTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
for (int i = 0; i < 3; i++) {
int cd = SkilltreeClientState.getClientCooldown(i + 1);
int slots = SkilltreeClientState.getAbilitySlots().length;
for (int slot = 1; slot <= slots; slot++) {
int cd = SkilltreeClientState.getClientCooldown(slot);
if (cd > 0) {
SkilltreeClientState.updateCooldown(i + 1, cd - 1);
SkilltreeClientState.updateCooldown(slot, cd - 1);
}
}
}
@@ -24,8 +24,8 @@ public class ClientKeybinds {
);
event.register(OPEN_ABILITIES);
event.register(AbilityKeybindHandler.ABILITY_1);
event.register(AbilityKeybindHandler.ABILITY_2);
event.register(AbilityKeybindHandler.ABILITY_3);
for (KeyMapping keyMapping : AbilityKeybindHandler.ABILITY_KEYS) {
event.register(keyMapping);
}
}
}
@@ -14,16 +14,18 @@ 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];
private static final int[] clientCooldowns = new int[3];
private static ResourceLocation[] abilitySlots = new ResourceLocation[3];
private static int[] clientCooldowns = new int[3];
public static void updateCooldown(int slot, int cd) {
ensureSlot(slot);
int index = slot - 1;
if (index < 0 || index >= clientCooldowns.length) return;
clientCooldowns[index] = Math.max(0, cd);
}
public static int getClientCooldown(int slot) {
ensureSlot(slot);
int index = slot - 1;
if (index < 0 || index >= clientCooldowns.length) return 0;
return clientCooldowns[index];
@@ -74,16 +76,20 @@ public class SkilltreeClientState {
}
public static void bindAbilityLocal(int slot, ResourceLocation abilityId) {
ensureSlot(slot);
PlayerSkillData.bindHelper(slot, abilityId, abilitySlots);
}
public static void setAbilitySlots(ResourceLocation[] slots) {
abilitySlots = new ResourceLocation[slots != null ? slots.length : 3];
clientCooldowns = Arrays.copyOf(clientCooldowns, abilitySlots.length);
for (int i = 0; i < abilitySlots.length; i++) {
abilitySlots[i] = (slots != null && i < slots.length) ? slots[i] : null;
}
}
public static ResourceLocation getAbilityInSlot(int slot) {
ensureSlot(slot);
int index = slot - 1;
if (index < 0 || index >= abilitySlots.length) return null;
return abilitySlots[index];
@@ -100,4 +106,10 @@ public class SkilltreeClientState {
public static ResourceLocation[] getAbilitySlots() {
return Arrays.copyOf(abilitySlots, abilitySlots.length);
}
private static void ensureSlot(int slot) {
if (slot <= abilitySlots.length) return;
abilitySlots = Arrays.copyOf(abilitySlots, slot);
clientCooldowns = Arrays.copyOf(clientCooldowns, slot);
}
}
@@ -38,6 +38,7 @@ public class SkilltreeScreen extends Screen {
private SkillNode selectedNode = null;
private AbilityNode hoveredAbility = null;
private AbilityNode selectedAbility = null;
private ResourceLocation activeTree = null;
public SkilltreeScreen() {
super(Component.literal("Ascend Skilltree"));
@@ -46,6 +47,11 @@ public class SkilltreeScreen extends Screen {
@Override
protected void init() {
super.init();
if (activeTree == null || !SkillNodeRegistry.trees().contains(activeTree)) {
List<ResourceLocation> trees = SkillNodeRegistry.trees();
activeTree = trees.isEmpty() ? null : trees.get(0);
}
rebuildTreeTabs();
}
@Override
@@ -141,7 +147,7 @@ public class SkilltreeScreen extends Screen {
}
private void drawGraph(GuiGraphics gfx) {
for (SkillNode node : SkillNodeRegistry.all()) {
for (SkillNode node : activeSkillNodes()) {
int[] p1 = worldToScreen(node.getX(), node.getY());
for (ResourceLocation neighborId : node.getLinks()) {
SkillNode neighbor = SkillNodeRegistry.get(neighborId);
@@ -193,7 +199,7 @@ public class SkilltreeScreen extends Screen {
gfx.blit(icon, pos[0] - 8, pos[1] - 8, 0, 0, 16, 16, 16, 16);
}
}
for (SkillNode node : SkillNodeRegistry.all()) {
for (SkillNode node : activeSkillNodes()) {
int[] pos = worldToScreen(node.getX(), node.getY());
int color = getNodeColor(node);
gfx.fill(
@@ -364,6 +370,7 @@ public class SkilltreeScreen extends Screen {
);
selectedNode = null;
clearWidgets();
rebuildTreeTabs();
}).pos(x + OVERLAY_WIDTH / 2 - 40, y + OVERLAY_CONTENT_HEIGHT - 20)
.size(80, 20)
.build());
@@ -371,6 +378,7 @@ public class SkilltreeScreen extends Screen {
addRenderableWidget(Button.builder(Component.literal("Close"), btn -> {
selectedNode = null;
clearWidgets();
rebuildTreeTabs();
}).pos(x + OVERLAY_WIDTH - 60, y - 5)
.size(50, 20)
.build());
@@ -391,6 +399,7 @@ public class SkilltreeScreen extends Screen {
);
selectedAbility = null;
clearWidgets();
rebuildTreeTabs();
}).pos(x + OVERLAY_WIDTH / 2 - 40, y + OVERLAY_CONTENT_HEIGHT - 20)
.size(80, 20)
.build());
@@ -398,6 +407,7 @@ public class SkilltreeScreen extends Screen {
addRenderableWidget(Button.builder(Component.literal("Close"), btn -> {
selectedAbility = null;
clearWidgets();
rebuildTreeTabs();
}).pos(x + OVERLAY_WIDTH - 60, y - 5)
.size(50, 20)
.build());
@@ -410,7 +420,7 @@ public class SkilltreeScreen extends Screen {
}
private SkillNode findNodeAt(double mouseX, double mouseY) {
for (SkillNode node : SkillNodeRegistry.all()) {
for (SkillNode node : activeSkillNodes()) {
int[] pos = worldToScreen(node.getX(), node.getY());
if (isPointInCircle(mouseX, mouseY, pos[0], pos[1], NODE_RADIUS)) {
return node;
@@ -483,6 +493,35 @@ public class SkilltreeScreen extends Screen {
gfx.pose().popPose();
}
private Iterable<SkillNode> activeSkillNodes() {
return activeTree != null ? SkillNodeRegistry.byTree(activeTree) : SkillNodeRegistry.all();
}
private void rebuildTreeTabs() {
List<ResourceLocation> trees = SkillNodeRegistry.trees();
if (trees.size() <= 1 || selectedNode != null || selectedAbility != null) {
return;
}
int x = 10;
int y = 28;
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 -> {
activeTree = tree;
selectedNode = null;
selectedAbility = null;
clearWidgets();
rebuildTreeTabs();
})
.pos(x, y)
.size(buttonWidth, 20)
.build());
button.active = !tree.equals(activeTree);
x += buttonWidth + 4;
}
}
@Override
public boolean isPauseScreen() {
return false;
@@ -0,0 +1,45 @@
package net.fretux.skillengine.commands;
import com.mojang.brigadier.CommandDispatcher;
import net.fretux.skillengine.SkillEngine;
import net.fretux.skillengine.skilltree.SkillTreeValidator;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraftforge.event.RegisterCommandsEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import java.util.List;
@Mod.EventBusSubscriber(modid = SkillEngine.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE)
public final class SkillEngineCommands {
private SkillEngineCommands() {}
@SubscribeEvent
public static void register(RegisterCommandsEvent event) {
register(event.getDispatcher());
}
private static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(Commands.literal("skillengine")
.requires(source -> source.hasPermission(2))
.then(Commands.literal("validate")
.executes(context -> validate(context.getSource()))));
}
private static int validate(CommandSourceStack source) {
List<String> issues = SkillTreeValidator.validate();
if (issues.isEmpty()) {
source.sendSuccess(() -> Component.literal("Skill Engine validation passed."), false);
return 1;
}
source.sendFailure(Component.literal("Skill Engine validation found " + issues.size() + " issue(s):"));
for (String issue : issues) {
SkillEngine.LOGGER.warn("[SkillEngine validate] {}", issue);
source.sendFailure(Component.literal("- " + issue));
}
return 0;
}
}
@@ -0,0 +1,19 @@
package net.fretux.skillengine.config;
import net.minecraftforge.common.ForgeConfigSpec;
public final class SkillEngineConfig {
public static final ForgeConfigSpec COMMON_SPEC;
public static final ForgeConfigSpec.IntValue ABILITY_SLOT_COUNT;
static {
ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder();
ABILITY_SLOT_COUNT = builder
.comment("Number of ability slots available to each player.")
.defineInRange("abilitySlotCount", 3, 1, 9);
COMMON_SPEC = builder.build();
}
private SkillEngineConfig() {}
}
@@ -0,0 +1,42 @@
package net.fretux.skillengine.events;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.eventbus.api.Event;
public class SkillPointsChangedEvent extends Event {
public enum Reason {
GAINED,
SPENT,
REFUNDED
}
private final Player player;
private final int amount;
private final Reason reason;
private final ResourceLocation nodeId;
public SkillPointsChangedEvent(Player player, int amount, Reason reason, ResourceLocation nodeId) {
this.player = player;
this.amount = amount;
this.reason = reason;
this.nodeId = nodeId;
}
public Player getPlayer() {
return player;
}
public int getAmount() {
return amount;
}
public Reason getReason() {
return reason;
}
public ResourceLocation getNodeId() {
return nodeId;
}
}
@@ -0,0 +1,163 @@
package net.fretux.skillengine.network;
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.network.FriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.fml.DistExecutor;
import net.minecraftforge.network.NetworkEvent;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
public class ClientboundSyncSkillDefinitionsPacket {
private final List<SkillNode> skillNodes;
private final List<AbilityNode> abilityNodes;
public ClientboundSyncSkillDefinitionsPacket(Collection<SkillNode> skillNodes,
Collection<AbilityNode> abilityNodes) {
this.skillNodes = List.copyOf(skillNodes);
this.abilityNodes = List.copyOf(abilityNodes);
}
public static void encode(ClientboundSyncSkillDefinitionsPacket msg, FriendlyByteBuf buf) {
buf.writeInt(msg.skillNodes.size());
for (SkillNode node : msg.skillNodes) {
writeSkillNode(buf, node);
}
buf.writeInt(msg.abilityNodes.size());
for (AbilityNode node : msg.abilityNodes) {
writeAbilityNode(buf, node);
}
}
public static ClientboundSyncSkillDefinitionsPacket decode(FriendlyByteBuf buf) {
int skillNodeCount = buf.readInt();
List<SkillNode> skillNodes = new ArrayList<>(skillNodeCount);
for (int i = 0; i < skillNodeCount; i++) {
skillNodes.add(readSkillNode(buf));
}
int abilityNodeCount = buf.readInt();
List<AbilityNode> abilityNodes = new ArrayList<>(abilityNodeCount);
for (int i = 0; i < abilityNodeCount; i++) {
abilityNodes.add(readAbilityNode(buf));
}
return new ClientboundSyncSkillDefinitionsPacket(skillNodes, abilityNodes);
}
public static void handle(ClientboundSyncSkillDefinitionsPacket msg, Supplier<NetworkEvent.Context> ctx) {
ctx.get().enqueueWork(() -> DistExecutor.unsafeRunWhenOn(
Dist.CLIENT,
() -> () -> net.fretux.skillengine.client.ClientSkillEngineBridge.handleSkillDefinitionsSync(
msg.skillNodes,
msg.abilityNodes
)
));
ctx.get().setPacketHandled(true);
}
private static void writeSkillNode(FriendlyByteBuf buf, SkillNode node) {
buf.writeResourceLocation(node.getId());
buf.writeComponent(node.getTitle());
buf.writeComponent(node.getDescription());
buf.writeInt(node.getCost());
buf.writeFloat(node.getX());
buf.writeFloat(node.getY());
writeResourceLocationList(buf, node.getLinks());
writeResourceLocationList(buf, node.getTags());
buf.writeResourceLocation(node.getTree());
buf.writeUtf(node.getCategory());
buf.writeUtf(node.getLayer());
buf.writeResourceLocation(node.getIcons());
writePrerequisites(buf, node.getPrereqAttributes());
writeResourceLocationList(buf, node.getExclusiveWith());
}
private static SkillNode readSkillNode(FriendlyByteBuf buf) {
ResourceLocation id = buf.readResourceLocation();
Component title = buf.readComponent();
Component description = buf.readComponent();
int cost = buf.readInt();
float x = buf.readFloat();
float y = buf.readFloat();
List<ResourceLocation> links = readResourceLocationList(buf);
List<ResourceLocation> tags = readResourceLocationList(buf);
ResourceLocation tree = buf.readResourceLocation();
String category = buf.readUtf();
String layer = buf.readUtf();
ResourceLocation icon = buf.readResourceLocation();
Map<String, Integer> prerequisites = readPrerequisites(buf);
List<ResourceLocation> exclusiveWith = readResourceLocationList(buf);
return new SkillNode(id, title, description, cost, x, y, links, tags, tree, category, layer, icon, prerequisites, exclusiveWith);
}
private static void writeAbilityNode(FriendlyByteBuf buf, AbilityNode node) {
buf.writeResourceLocation(node.getId());
buf.writeComponent(node.getTitle());
buf.writeComponent(node.getDescription());
buf.writeFloat(node.getX());
buf.writeFloat(node.getY());
writeResourceLocationList(buf, node.getLinks());
writeResourceLocationList(buf, node.getTags());
buf.writeResourceLocation(node.getIcon());
buf.writeInt(node.getCooldown());
}
private static AbilityNode readAbilityNode(FriendlyByteBuf buf) {
ResourceLocation id = buf.readResourceLocation();
Component title = buf.readComponent();
Component description = buf.readComponent();
float x = buf.readFloat();
float y = buf.readFloat();
List<ResourceLocation> links = readResourceLocationList(buf);
List<ResourceLocation> tags = readResourceLocationList(buf);
ResourceLocation icon = buf.readResourceLocation();
int cooldown = buf.readInt();
return new AbilityNode(id, title, description, x, y, links, tags, icon, cooldown);
}
private static void writeResourceLocationList(FriendlyByteBuf buf, List<ResourceLocation> ids) {
buf.writeInt(ids.size());
for (ResourceLocation id : ids) {
buf.writeResourceLocation(id);
}
}
private static List<ResourceLocation> readResourceLocationList(FriendlyByteBuf buf) {
int size = buf.readInt();
List<ResourceLocation> ids = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
ids.add(buf.readResourceLocation());
}
return ids;
}
private static void writePrerequisites(FriendlyByteBuf buf, Map<String, Integer> prerequisites) {
buf.writeInt(prerequisites.size());
for (Map.Entry<String, Integer> entry : prerequisites.entrySet()) {
buf.writeUtf(entry.getKey());
buf.writeInt(entry.getValue());
}
}
private static Map<String, Integer> readPrerequisites(FriendlyByteBuf buf) {
int size = buf.readInt();
Map<String, Integer> prerequisites = new HashMap<>(size);
for (int i = 0; i < size; i++) {
prerequisites.put(buf.readUtf(), buf.readInt());
}
return prerequisites;
}
}
@@ -13,7 +13,7 @@ import net.minecraftforge.network.simple.SimpleChannel;
public class PacketHandler {
private static final String PROTOCOL = "3";
private static final String PROTOCOL = "4";
public static final SimpleChannel CHANNEL = NetworkRegistry.ChannelBuilder
.named(ResourceLocation.fromNamespaceAndPath(SkillEngine.MODID, "network"))
.clientAcceptedVersions(PROTOCOL::equals)
@@ -34,7 +34,7 @@ public class ServerboundActivateAbilityPacket {
if (player == null) return;
player.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).ifPresent(data -> {
if (msg.slot < 1 || msg.slot > 3) return;
if (msg.slot < 1 || msg.slot > data.getAbilitySlots().length) return;
ResourceLocation abilityId = data.getAbilityInSlot(msg.slot);
if (abilityId == null) return;
@@ -34,7 +34,7 @@ public class ServerboundBindAbilityPacket {
if (player == null) return;
player.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).ifPresent(data -> {
if (msg.slot < 1 || msg.slot > 3) return;
if (msg.slot < 1 || msg.slot > data.getAbilitySlots().length) return;
if (AbilityNodeRegistry.get(msg.abilityId) == null) return;
if (!data.isAbilityUnlocked(msg.abilityId)) return;
data.bindAbility(msg.slot, msg.abilityId);
@@ -3,10 +3,12 @@ package net.fretux.skillengine.network;
import net.fretux.skillengine.SkillEngine;
import net.fretux.skillengine.capability.PlayerSkillData;
import net.fretux.skillengine.capability.SkillEngineCapabilities;
import net.fretux.skillengine.events.SkillPointsChangedEvent;
import net.fretux.skillengine.skilltree.*;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.network.NetworkEvent;
import net.minecraftforge.network.PacketDistributor;
@@ -40,6 +42,14 @@ public class ServerboundUnlockNodePacket {
if (unlockPlan != null) {
for (SkillNode plannedNode : unlockPlan) {
data.unlockNode(plannedNode);
if (plannedNode.getCost() > 0) {
MinecraftForge.EVENT_BUS.post(new SkillPointsChangedEvent(
player,
plannedNode.getCost(),
SkillPointsChangedEvent.Reason.SPENT,
plannedNode.getId()
));
}
}
PacketHandler.syncSkillsTo(player);
PacketHandler.CHANNEL.send(
@@ -1,5 +1,6 @@
package net.fretux.skillengine.skilltree;
import net.fretux.skillengine.api.AbilityHandlerRegistry;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
@@ -48,6 +49,8 @@ public class AbilityNode {
public int getCooldown() { return cooldown; }
public void execute(ServerPlayer player) {
if (!AbilityHandlerRegistry.execute(player, this)) {
player.sendSystemMessage(Component.literal("Activated ability: " + id));
}
}
}
@@ -8,16 +8,28 @@ import java.util.Map;
public class AbilityNodeRegistry {
private static final Map<ResourceLocation, AbilityNode> ABILITIES = new LinkedHashMap<>();
private static final Map<ResourceLocation, Integer> DUPLICATE_COUNTS = new LinkedHashMap<>();
public static void clear() {
ABILITIES.clear();
DUPLICATE_COUNTS.clear();
}
public static void put(AbilityNode node) {
ABILITIES.put(node.getId(), node);
if (ABILITIES.put(node.getId(), node) != null) {
DUPLICATE_COUNTS.merge(node.getId(), 1, Integer::sum);
}
}
public static AbilityNode get(ResourceLocation id) {
return ABILITIES.get(id);
}
public static Collection<AbilityNode> all() {
return ABILITIES.values();
}
public static Map<ResourceLocation, Integer> duplicateCounts() {
return Map.copyOf(DUPLICATE_COUNTS);
}
}
@@ -1,8 +1,9 @@
package net.fretux.skillengine.skilltree;
import net.fretux.ascend.player.PlayerStatsProvider;
import net.fretux.skillengine.api.PrerequisiteProviderRegistry;
import net.fretux.skillengine.capability.PlayerSkillData;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import java.util.ArrayList;
@@ -43,13 +44,13 @@ public class SkillLogic {
public static boolean meetsAscendPrerequisites(Player player, SkillNode node) {
if (node.getPrereqAttributes().isEmpty()) return true;
return player.getCapability(PlayerStatsProvider.PLAYER_STATS).map(stats -> {
if (!(player instanceof ServerPlayer serverPlayer)) return true;
for (Map.Entry<String, Integer> req : node.getPrereqAttributes().entrySet()) {
int current = stats.getAttributeLevel(req.getKey());
if (current < req.getValue()) return false;
if (!PrerequisiteProviderRegistry.meets(serverPlayer, node, req.getKey(), req.getValue())) {
return false;
}
}
return true;
}).orElse(false);
}
private static boolean collectUnlockPlan(PlayerSkillData data,
@@ -16,6 +16,9 @@ public class SkillNode {
private final float y;
private final List<ResourceLocation> links;
private final List<ResourceLocation> tags;
private final ResourceLocation tree;
private final String category;
private final String layer;
private final ResourceLocation icons;
private final Map<String, Integer> prereqAttributes;
private final List<ResourceLocation> exclusiveWith;
@@ -27,6 +30,9 @@ public class SkillNode {
float x, float y,
List<ResourceLocation> links,
List<ResourceLocation> tags,
ResourceLocation tree,
String category,
String layer,
ResourceLocation icons,
Map<String, Integer> prereqAttributes,
List<ResourceLocation> exclusiveWith){
@@ -39,6 +45,9 @@ public class SkillNode {
this.y = y;
this.links = List.copyOf(links);
this.tags = List.copyOf(tags);
this.tree = tree;
this.category = category;
this.layer = layer;
this.icons = icons;
this.prereqAttributes = Map.copyOf(prereqAttributes);
this.exclusiveWith = List.copyOf(exclusiveWith);
@@ -76,6 +85,18 @@ public class SkillNode {
return tags;
}
public ResourceLocation getTree() {
return tree;
}
public String getCategory() {
return category;
}
public String getLayer() {
return layer;
}
public ResourceLocation getIcons() {
return icons;
}
@@ -14,14 +14,18 @@ import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
@Mod.EventBusSubscriber(modid = SkillEngine.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE)
public class SkillNodeLoader extends SimpleJsonResourceReloadListener {
private static final Gson GSON = new GsonBuilder().create();
private static final ResourceLocation DEFAULT_TREE =
ResourceLocation.fromNamespaceAndPath(SkillEngine.MODID, "main");
private static final Pattern TRANSLATION_KEY =
Pattern.compile("[a-z0-9_.-]+\\.[a-z0-9_.-]+");
public SkillNodeLoader() {
super(GSON, "skillnodes");
@@ -37,15 +41,14 @@ public class SkillNodeLoader extends SimpleJsonResourceReloadListener {
@NotNull ResourceManager resourceManager,
@NotNull ProfilerFiller profiler) {
SkillEngine.LOGGER.info("Loading skill nodes...");
validateSingleSkillTreeProvider(jsons);
SkillNodeRegistry.clear();
jsons.forEach((id, element) -> {
JsonObject obj = element.getAsJsonObject();
ResourceLocation nodeId = obj.has("id")
? ResourceLocation.parse(obj.get("id").getAsString())
: id;
String title = obj.get("title").getAsString();
String description = obj.get("description").getAsString();
Component title = readComponent(obj, "title");
Component description = readComponent(obj, "description");
int cost = obj.get("cost").getAsInt();
JsonObject pos = obj.getAsJsonObject("position");
float x = pos.get("x").getAsFloat();
@@ -54,8 +57,15 @@ public class SkillNodeLoader extends SimpleJsonResourceReloadListener {
obj.getAsJsonArray("links").forEach(e ->
links.add(ResourceLocation.parse(e.getAsString())));
List<ResourceLocation> tags = new ArrayList<>();
if (obj.has("tags")) {
obj.getAsJsonArray("tags").forEach(e ->
tags.add(ResourceLocation.parse(e.getAsString())));
}
ResourceLocation tree = obj.has("tree")
? ResourceLocation.parse(obj.get("tree").getAsString())
: ResourceLocation.fromNamespaceAndPath(nodeId.getNamespace(), DEFAULT_TREE.getPath());
String category = obj.has("category") ? obj.get("category").getAsString() : "";
String layer = obj.has("layer") ? obj.get("layer").getAsString() : "";
ResourceLocation icons = ResourceLocation.parse(obj.get("icons").getAsString());
Map<String, Integer> prereqAttributes = new HashMap<>();
if (obj.has("prerequisites")) {
@@ -71,12 +81,15 @@ public class SkillNodeLoader extends SimpleJsonResourceReloadListener {
}
SkillNode node = new SkillNode(
nodeId,
Component.literal(title),
Component.literal(description),
title,
description,
cost,
x, y,
links,
tags,
tree,
category,
layer,
icons,
prereqAttributes,
exclusiveWith
@@ -87,28 +100,23 @@ public class SkillNodeLoader extends SimpleJsonResourceReloadListener {
SkillEngine.LOGGER.info("Loaded {} skill nodes", SkillNodeRegistry.all().size());
}
private static void validateSingleSkillTreeProvider(Map<ResourceLocation, JsonElement> jsons) {
Map<String, List<ResourceLocation>> providers = new LinkedHashMap<>();
jsons.keySet().stream()
.filter(id -> !SkillEngine.MODID.equals(id.getNamespace()))
.forEach(id -> providers.computeIfAbsent(id.getNamespace(), ignored -> new ArrayList<>()).add(id));
if (providers.size() <= 1) {
return;
private static Component readComponent(JsonObject obj, String key) {
if (!obj.has(key)) {
return Component.empty();
}
StringBuilder message = new StringBuilder();
message.append("Skill Engine detected multiple mods adding skill tree nodes: ");
providers.forEach((namespace, nodeIds) ->
message.append(namespace)
.append(" (")
.append(nodeIds.size())
.append(" nodes), "));
message.setLength(message.length() - 2);
message.append(". Skill Engine supports one skill tree provider at a time because multiple skill trees overlap in the same UI. Remove all but one skill tree addon.");
String error = message.toString();
SkillEngine.LOGGER.error(error);
throw new IllegalStateException(error);
JsonElement element = obj.get(key);
if (element.isJsonObject()) {
JsonObject text = element.getAsJsonObject();
if (text.has("translate")) {
return Component.translatable(text.get("translate").getAsString());
}
if (text.has("text")) {
return Component.literal(text.get("text").getAsString());
}
}
String value = element.getAsString();
return TRANSLATION_KEY.matcher(value).matches()
? Component.translatable(value)
: Component.literal(value);
}
}
@@ -3,19 +3,36 @@ package net.fretux.skillengine.skilltree;
import net.minecraft.resources.ResourceLocation;
import java.util.Collection;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
public class SkillNodeRegistry {
private static final Map<ResourceLocation, SkillNode> NODES = new LinkedHashMap<>();
private static final Map<ResourceLocation, Map<ResourceLocation, SkillNode>> NODES_BY_TREE = new LinkedHashMap<>();
private static final Map<ResourceLocation, Integer> DUPLICATE_COUNTS = new LinkedHashMap<>();
public static void clear() {
NODES.clear();
NODES_BY_TREE.clear();
DUPLICATE_COUNTS.clear();
}
public static void put(SkillNode node) {
NODES.put(node.getId(), node);
SkillNode previous = NODES.put(node.getId(), node);
if (previous != null) {
DUPLICATE_COUNTS.merge(node.getId(), 1, Integer::sum);
Map<ResourceLocation, SkillNode> previousTree = NODES_BY_TREE.get(previous.getTree());
if (previousTree != null) {
previousTree.remove(previous.getId());
if (previousTree.isEmpty()) {
NODES_BY_TREE.remove(previous.getTree());
}
}
}
NODES_BY_TREE.computeIfAbsent(node.getTree(), ignored -> new LinkedHashMap<>())
.put(node.getId(), node);
}
public static SkillNode get(ResourceLocation id) {
@@ -25,4 +42,17 @@ public class SkillNodeRegistry {
public static Collection<SkillNode> all() {
return NODES.values();
}
public static List<ResourceLocation> trees() {
return List.copyOf(NODES_BY_TREE.keySet());
}
public static Collection<SkillNode> byTree(ResourceLocation tree) {
Map<ResourceLocation, SkillNode> nodes = NODES_BY_TREE.get(tree);
return nodes != null ? nodes.values() : List.of();
}
public static Map<ResourceLocation, Integer> duplicateCounts() {
return Map.copyOf(DUPLICATE_COUNTS);
}
}
@@ -0,0 +1,172 @@
package net.fretux.skillengine.skilltree;
import net.fretux.skillengine.api.PrerequisiteProviderRegistry;
import net.minecraft.resources.ResourceLocation;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public final class SkillTreeValidator {
private SkillTreeValidator() {}
public static List<String> validate() {
List<String> issues = new ArrayList<>();
SkillNodeRegistry.duplicateCounts().forEach((id, count) ->
issues.add(id + " was loaded " + (count + 1) + " times; node ids must be unique"));
AbilityNodeRegistry.duplicateCounts().forEach((id, count) ->
issues.add(id + " was loaded " + (count + 1) + " times; ability ids must be unique"));
for (SkillNode node : SkillNodeRegistry.all()) {
validateNode(node, issues);
}
for (AbilityNode ability : AbilityNodeRegistry.all()) {
validateAbility(ability, issues);
}
detectSkillCycles(issues);
detectAbilityCycles(issues);
return issues;
}
private static void validateNode(SkillNode node, List<String> issues) {
if (!isPngTexture(node.getIcons())) {
issues.add(node.getId() + " has invalid icon path " + node.getIcons() + " (expected a .png texture)");
}
for (ResourceLocation parent : node.getLinks()) {
if (SkillNodeRegistry.get(parent) == null) {
issues.add(node.getId() + " links to missing skill node " + parent);
}
}
for (ResourceLocation exclusive : node.getExclusiveWith()) {
SkillNode other = SkillNodeRegistry.get(exclusive);
if (other == null) {
issues.add(node.getId() + " is exclusive with missing node " + exclusive);
} else if (exclusive.equals(node.getId())) {
issues.add(node.getId() + " is exclusive with itself");
} else if (!other.getExclusiveWith().contains(node.getId())) {
issues.add(node.getId() + " is exclusive with " + exclusive + " but the reverse entry is missing");
}
}
Set<ResourceLocation> requiredSkillNodes = collectRequiredSkillNodes(node);
for (ResourceLocation exclusive : node.getExclusiveWith()) {
if (requiredSkillNodes.contains(exclusive)) {
issues.add(node.getId() + " is impossible to unlock because required ancestor " + exclusive + " is mutually exclusive");
}
}
List<ResourceLocation> requiredList = new ArrayList<>(requiredSkillNodes);
for (int i = 0; i < requiredList.size(); i++) {
SkillNode first = SkillNodeRegistry.get(requiredList.get(i));
if (first == null) continue;
for (int j = i + 1; j < requiredList.size(); j++) {
ResourceLocation second = requiredList.get(j);
if (first.getExclusiveWith().contains(second)) {
issues.add(node.getId() + " is impossible to unlock because required nodes "
+ first.getId() + " and " + second + " are mutually exclusive");
}
}
}
for (String key : node.getPrereqAttributes().keySet()) {
int separator = key.indexOf(':');
if (separator >= 0 && (separator == 0 || separator == key.length() - 1)) {
issues.add(node.getId() + " has malformed prerequisite key " + key);
continue;
}
String namespace = separator > 0 ? key.substring(0, separator) : "ascend";
if (!PrerequisiteProviderRegistry.hasProvider(namespace)) {
issues.add(node.getId() + " requires " + key + " but no prerequisite provider is registered for " + namespace);
}
}
}
private static void validateAbility(AbilityNode ability, List<String> issues) {
if (!isPngTexture(ability.getIcon())) {
issues.add(ability.getId() + " has invalid icon path " + ability.getIcon() + " (expected a .png texture)");
}
for (ResourceLocation parent : ability.getLinks()) {
if (SkillNodeRegistry.get(parent) == null && AbilityNodeRegistry.get(parent) == null) {
issues.add(ability.getId() + " links to missing node or ability " + parent);
}
}
}
private static boolean isPngTexture(ResourceLocation id) {
return id != null && id.getPath().endsWith(".png");
}
private static void detectSkillCycles(List<String> issues) {
Set<ResourceLocation> visited = new HashSet<>();
Set<ResourceLocation> visiting = new HashSet<>();
for (SkillNode node : SkillNodeRegistry.all()) {
visit(node, visited, visiting, issues);
}
}
private static void detectAbilityCycles(List<String> issues) {
Set<ResourceLocation> visited = new HashSet<>();
Set<ResourceLocation> visiting = new HashSet<>();
for (AbilityNode ability : AbilityNodeRegistry.all()) {
visitAbility(ability, visited, visiting, issues);
}
}
private static void visit(SkillNode node,
Set<ResourceLocation> visited,
Set<ResourceLocation> visiting,
List<String> issues) {
ResourceLocation id = node.getId();
if (visited.contains(id)) return;
if (!visiting.add(id)) {
issues.add("Cycle detected at skill node " + id);
return;
}
for (ResourceLocation parentId : node.getLinks()) {
SkillNode parent = SkillNodeRegistry.get(parentId);
if (parent != null) {
visit(parent, visited, visiting, issues);
}
}
visiting.remove(id);
visited.add(id);
}
private static void visitAbility(AbilityNode ability,
Set<ResourceLocation> visited,
Set<ResourceLocation> visiting,
List<String> issues) {
ResourceLocation id = ability.getId();
if (visited.contains(id)) return;
if (!visiting.add(id)) {
issues.add("Cycle detected at ability node " + id);
return;
}
for (ResourceLocation parentId : ability.getLinks()) {
AbilityNode parent = AbilityNodeRegistry.get(parentId);
if (parent != null) {
visitAbility(parent, visited, visiting, issues);
}
}
visiting.remove(id);
visited.add(id);
}
private static Set<ResourceLocation> collectRequiredSkillNodes(SkillNode node) {
Set<ResourceLocation> required = new HashSet<>();
collectRequiredSkillNodes(node, required, new HashSet<>());
required.remove(node.getId());
return required;
}
private static void collectRequiredSkillNodes(SkillNode node,
Set<ResourceLocation> required,
Set<ResourceLocation> visiting) {
if (!visiting.add(node.getId())) return;
for (ResourceLocation parentId : node.getLinks()) {
SkillNode parent = SkillNodeRegistry.get(parentId);
if (parent != null && required.add(parentId)) {
collectRequiredSkillNodes(parent, required, visiting);
}
}
visiting.remove(node.getId());
}
}
@@ -3,6 +3,11 @@
"key.skillengine.openabilities": "Open Ability Screen",
"key.skillengine.ability1": "Use Skill in first Skilltree Slot",
"key.skillengine.ability2": "Use Skill in second Skilltree Slot",
"key.skillengine.ability3": "Use Skill in third Skilltree Slot"
"key.skillengine.ability3": "Use Skill in third Skilltree Slot",
"key.skillengine.ability4": "Use Skill in fourth Skilltree Slot",
"key.skillengine.ability5": "Use Skill in fifth Skilltree Slot",
"key.skillengine.ability6": "Use Skill in sixth Skilltree Slot",
"key.skillengine.ability7": "Use Skill in seventh Skilltree Slot",
"key.skillengine.ability8": "Use Skill in eighth Skilltree Slot",
"key.skillengine.ability9": "Use Skill in ninth Skilltree Slot"
}