Compare commits

52 Commits

Author SHA1 Message Date
Frederik Spirgi 696bb716ee refactor 2026-10-07 09:22:34 +02:00
Fretux 743cb506bc revamp skilltree visuals 2026-09-19 20:43:30 +02:00
Fretux cef5d40796 make nodes smaller when zoomed out 2026-09-18 21:04:12 +02:00
Fretux f578d76142 update skillengine 2026-08-24 20:17:39 +02:00
Fretux bd4f7f6bc6 try to improve UX for dumbasses 2026-07-22 16:25:28 +02:00
Fretux 27d80b099d update ascend version 2026-07-22 14:40:03 +02:00
Fretux dd98336501 update screen visuals 2026-07-22 14:17:10 +02:00
Fretux fe40cdeeb1 implement changes 2026-05-27 11:15:47 +02:00
Fretux 3cd59f0475 update mod version to 1.4.1 2026-05-25 14:18:11 +02:00
Fretux 7f5830d409 extend ability system to support dynamic slot counts, add skill tree validation, new prerequisites API, and extended mod commands 2026-05-25 12:43:13 +02:00
Fretux 77fbada438 extend ability system to support dynamic slot counts, add skill tree validation, new prerequisites API, and extended mod commands 2026-05-25 12:33:49 +02:00
Fretux f7332054ea update Gradle wrapper script for improved compatibility, added SPDX license identifiers, and enhanced error handling 2026-05-24 15:34:13 +02:00
Fretux 40a30c1737 update Gradle wrapper to 8.14.4 2026-05-24 15:27:25 +02:00
Fretux 16ed986576 validate single skill tree provider to ensure compatibility and prevent UI overlap 2026-05-23 20:57:25 +02:00
Fretux 87a0a2177e update mod version 2026-05-21 16:32:26 +02:00
Fretux bf4452f6ac extend skill unlock packet to include ability type and improve logout handling logic 2026-05-21 16:32:11 +02:00
Fretux 30310b6eb9 refactor UI layout and text rendering logic for ability screens 2026-05-20 22:47:18 +02:00
Fretux fda16edcef update mod version to 1.3.4 2026-05-20 11:32:32 +02:00
Fretux 2f93b27e7e add skill definition sync mechanism and update protocol version 2026-05-20 11:31:44 +02:00
Fretux f2aa1e82cc extend unlock logic to support multi-node unlock plans and update version number 2026-05-19 23:58:54 +02:00
Fretux 3d54b3ffc1 extend unlock logic to support multi-node unlock plans and update version number 2026-05-18 19:53:05 +02:00
Fretux c8c2119fb2 try to fix an issue where active abilities can be kept after resetting skilltree 2026-05-18 18:57:46 +02:00
Fretux 66f7f76e01 fix skilltree wiping on death 2026-04-07 08:46:10 +02:00
Fretux 0386027e99 try to fix server-side crashes again 2026-04-01 11:48:18 +02:00
Fretux 089bcb4917 try to fix server-side crashes 2026-04-01 11:35:03 +02:00
Fretux 62325b5b34 try to fix server-side crashes 2026-04-01 11:34:50 +02:00
Fretux 56efa4d814 update version number 2026-03-28 17:55:50 +01:00
Fretux 1a510253c6 update version number 2026-03-28 17:48:39 +01:00
Fretux bffcafeebd fix server-side crashes 2026-03-28 17:47:48 +01:00
Fretux 4e33002ddb version number 2025-12-20 02:04:54 +01:00
Fretux 48eec87343 Small update for skilltree 2025-12-20 02:03:30 +01:00
Fretux a88364d190 Implement a number of small fixes 2025-12-17 11:56:14 +01:00
Fretux ac334ccd6d Small update to dependencies 2025-12-11 22:14:47 +01:00
Fretux 573e505e9f Refactor SkilltreeClientState, PlayerSkillData, SkilltreeScreen, and ServerboundUnlockNodePacket to remove toast notifications and system sounds, and add AbilityNode cooldown display in SkilltreeScreen 2025-12-08 22:47:47 +01:00
Fretux 14e4b246cf Add skill and ability unlock checks and logic 2025-12-08 18:09:17 +01:00
Fretux 4e8a868615 Bump version to 1.2.2 2025-12-08 09:47:13 +01:00
Fretux 882026b4a3 Small update to support custom keybinds 2025-12-08 09:45:02 +01:00
Fretux 8926aa7095 Added translations for new keybinds 2025-12-08 09:44:02 +01:00
Fretux c6cf504f67 Add client-side ability cooldowns, sync, and GUI updates 2025-12-07 22:54:45 +01:00
Fretux d4ad08a35a Update SkillEngine API 2025-12-05 20:24:06 +01:00
Fretux 9ce984a54a Remove json, implement mutual exclusives, adjustments & fix bugs 2025-12-05 18:41:32 +01:00
Fretux bb8bc7b479 Implement slight adjustments 2025-12-05 18:14:04 +01:00
Fretux 555e166225 Removed json because fuck you 2025-12-05 14:03:27 +01:00
Fretux 96ea5977e6 Implement mutual exclusives 2025-12-05 14:02:57 +01:00
Fretux d0755c893f Implement mutual exclusives 2025-12-04 20:46:39 +01:00
Fretux 9ff5d9f828 Fix another small button positioning issue 2025-12-01 19:50:16 +01:00
Fretux 429bac1903 Small fix to close button positioning 2025-12-01 19:41:12 +01:00
Fretux 8bfca217aa fix more bugs 2025-11-27 07:44:07 +01:00
Fretux e510f06d7b fix a dumb bug and minor adjustment to gradle (minors) 2025-11-26 16:00:33 +01:00
Fretux 77de701392 Removed random bullshit 2025-11-26 15:47:10 +01:00
Fretux 32273858de Merge branch 'master' 2025-11-25 22:30:49 +01:00
Fretux caeed7c026 Update and rename README.txt to README.md 2025-11-25 11:18:07 +01:00
58 changed files with 3464 additions and 433 deletions
+139
View File
@@ -0,0 +1,139 @@
# **Skill Engine - A Modular Skill Tree Framework for Forge Mods**
**Skill Engine** is **not** a standalone progression mod.
It does **not** add gameplay, attributes, or abilities by itself.
Instead, **Skill Engine is a framework** designed to let other mods create their own **highly customizable skill trees**, complete with:
* Node unlocking
* Attribute requirements
* Custom icons
* Skill point handling
* Server synchronization
* Requirements & tooltips
* Interaction with other progression systems (like Ascend)
If you're looking for a mod that _adds_ skills, this is **not** it.
If you are a developer who wants to **add skill trees to your mod without reinventing everything**, Skill Engine is exactly what you need.
***
# **Features**
### JSON-driven Skill Tree System
Mods can register their own skill nodes simply by adding JSON files under:
```
data/<yourmodid>/skillnodes/*.json
```
### Automatic Node Rendering
Nodes are positioned, displayed, zoomed, panned, and drawn automatically in the Skill Tree GUI.
### Icons Support
Use **your own textures** or **vanilla item textures** as node icons.
### Requirements System
Nodes can specify:
* Linked nodes
* Prerequisite attributes
* Skill costs
* Custom tags
The UI automatically displays requirements in tooltips and overlays.
### Unlock Logic & Sync
Unlocking is fully handled server-side and synced to the client.
### Full API Support
Skill Engine includes a public API class that allows other mods to:
* Give skill points
* Unlock skill nodes
* Check unlock state
* Access player skill data
* Register or reference nodes
***
# **How to Use Skill Engine in Your Mod**
Skill Engine is designed to be extremely easy to integrate.
## **1\. Add Skill Engine as a Dependency**
In your `mods.toml`:
```toml
[[dependencies.yourmodid]]
modId="skillengine"
type="required"
versionRange="[1.0.0,)"
```
In your Gradle build:
```gradle
implementation fg.deobf("net.fretux.skillengine:SkillEngine:<version>")
```
***
# **2\. Create Your Skill Nodes (JSON)**
Example:
```json
{
"title": "Basic Strength",
"description": "You feel your muscles tighten.",
"cost": 1,
"position": { "x": -150.0, "y": -50.0 },
"links": ["skillengine:welcome"],
"tags": ["yourmodid:strength_1"],
"icons": "minecraft:textures/item/iron_sword.png",
"prerequisites": {
"strength": 10,
"agility": 5
}
}
```
Place this in:
```
data/yourmodid/skillnodes/basic_strength.json
```
Skill Engine will automatically detect it and add it to the tree.
***
# **3\. Use the API in Your Code**
Skill Engine exposes a simple API:
```java
SkillEngineAPI.unlockNode(player, SkillEngineAPI.rl("yourmodid", "basic_strength"));
SkillEngineAPI.addSkillPoints(player, 3);
boolean unlocked = SkillEngineAPI.isUnlocked(player, SkillEngineAPI.id("welcome"));
```
You can interact with the system without touching internal classes.
# **Users: What Does This Mod Do for Me?**
Skill Engine **does nothing by itself**.
It does _not_ add a skill tree or abilities on its own.
You only need this mod if:
* A mod you play **depends** on Skill Engine
* You want to install addons that create skill trees
-46
View File
@@ -1,46 +0,0 @@
Source installation information for modders
-------------------------------------------
This code follows the Minecraft Forge installation methodology. It will apply
some small patches to the vanilla MCP source code, giving you and it access
to some of the data and functions you need to build a successful mod.
Note also that the patches are built against "un-renamed" MCP source code (aka
SRG Names) - this means that you will not be able to read them directly against
normal code.
Setup Process:
==============================
Step 1: Open your command-line and browse to the folder where you extracted the zip file.
Step 2: You're left with a choice.
If you prefer to use Eclipse:
1. Run the following command: `./gradlew genEclipseRuns`
2. Open Eclipse, Import > Existing Gradle Project > Select Folder
or run `gradlew eclipse` to generate the project.
If you prefer to use IntelliJ:
1. Open IDEA, and import project.
2. Select your build.gradle file and have it import.
3. Run the following command: `./gradlew genIntellijRuns`
4. Refresh the Gradle Project in IDEA if required.
If at any point you are missing libraries in your IDE, or you've run into problems you can
run `gradlew --refresh-dependencies` to refresh the local cache. `gradlew clean` to reset everything
(this does not affect your code) and then start the process again.
Mapping Names:
=============================
By default, the MDK is configured to use the official mapping names from Mojang for methods and fields
in the Minecraft codebase. These names are covered by a specific license. All modders should be aware of this
license, if you do not agree with it you can change your mapping names to other crowdsourced names in your
build.gradle. For the latest license text, refer to the mapping file itself, or the reference copy here:
https://github.com/MinecraftForge/MCPConfig/blob/master/Mojang.md
Additional Resources:
=========================
Community Documentation: https://docs.minecraftforge.net/en/1.20.1/gettingstarted/
LexManos' Install Video: https://youtu.be/8VEdtQLuLO0
Forge Forums: https://forums.minecraftforge.net/
Forge Discord: https://discord.minecraftforge.net/
+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:2.0.2")
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.1.0
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
Binary file not shown.
+2 -1
View File
@@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+18 -12
View File
@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -83,7 +85,8 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -111,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -130,10 +133,13 @@ location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
@@ -141,7 +147,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@@ -149,7 +155,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -198,16 +204,16 @@ fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
Vendored
+14 -12
View File
@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@@ -43,11 +45,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
@@ -57,22 +59,22 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
@@ -1,8 +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;
@@ -15,6 +20,11 @@ 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,
() -> () -> net.fretux.skillengine.client.ClientBootstrap.register(modBus)
);
}
}
@@ -0,0 +1,35 @@
package net.fretux.skillengine.abilities;
import net.fretux.skillengine.SkillEngine;
import net.fretux.skillengine.capability.PlayerSkillData;
import net.fretux.skillengine.capability.SkillEngineCapabilities;
import net.fretux.skillengine.network.ClientboundSyncCooldownPacket;
import net.fretux.skillengine.network.PacketHandler;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.network.PacketDistributor;
@Mod.EventBusSubscriber(modid = SkillEngine.MODID)
public class AbilityCooldownHandler {
@SubscribeEvent
public static void onServerTick(TickEvent.PlayerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
if (event.player.level().isClientSide) return;
if (!(event.player instanceof ServerPlayer player)) return;
event.player.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).ifPresent(data -> {
data.tickCooldowns();
data.tickTagCooldowns();
if (player.tickCount % 10 == 0) {
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);
}
}
@@ -3,38 +3,62 @@ package net.fretux.skillengine.api;
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;
import net.fretux.skillengine.skilltree.SkillNode;
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;
import java.util.List;
import java.util.Set;
public final class SkillEngineAPI {
private SkillEngineAPI() {}
public static ResourceLocation id(String path) {
return new ResourceLocation(SkillEngine.MODID, path);
return ResourceLocation.fromNamespaceAndPath(SkillEngine.MODID, path);
}
public static ResourceLocation rl(String namespace, String path) {
return new ResourceLocation(namespace, path);
return ResourceLocation.fromNamespaceAndPath(namespace, path);
}
public static SkillNode getNode(ResourceLocation id) {
public static SkillNode getSkillNode(ResourceLocation id) {
return SkillNodeRegistry.get(id);
}
public static boolean hasNode(ResourceLocation id) {
public static AbilityNode getAbilityNode(ResourceLocation id) {
return AbilityNodeRegistry.get(id);
}
public static boolean hasSkillNode(ResourceLocation id) {
return SkillNodeRegistry.get(id) != null;
}
public static List<SkillNode> getAllNodes() {
public static boolean hasAbilityNode(ResourceLocation id) {
return AbilityNodeRegistry.get(id) != null;
}
public static List<SkillNode> getAllSkillNodes() {
return List.copyOf(SkillNodeRegistry.all());
}
public static List<AbilityNode> getAllAbilityNodes() {
return List.copyOf(AbilityNodeRegistry.all());
}
public static PlayerSkillData getPlayerData(Player player) {
return player.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).orElse(null);
}
public static Set<ResourceLocation> getUnlockedNodes(Player player) {
PlayerSkillData data = getPlayerData(player);
return data != null ? data.getUnlockedNodes() : Set.of();
}
public static int getSkillPoints(Player player) {
PlayerSkillData data = getPlayerData(player);
@@ -45,38 +69,162 @@ public final class SkillEngineAPI {
PlayerSkillData data = getPlayerData(player);
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) {
if (amount <= 0) return false;
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;
}
public static boolean isUnlocked(Player player, ResourceLocation nodeId) {
public static Set<ResourceLocation> getUnlockedSkillNodes(Player player) {
PlayerSkillData data = getPlayerData(player);
return data != null && data.isUnlocked(nodeId);
return data != null ? data.getUnlockedNodes() : Set.of();
}
public static boolean unlockNode(Player player, ResourceLocation nodeId) {
public static boolean isSkillUnlocked(Player player, ResourceLocation id) {
PlayerSkillData data = getPlayerData(player);
return data != null && data.isUnlocked(id);
}
public static boolean unlockSkill(Player player, ResourceLocation id) {
PlayerSkillData data = getPlayerData(player);
if (data == null) return false;
SkillNode node = SkillNodeRegistry.get(nodeId);
SkillNode node = SkillNodeRegistry.get(id);
if (node == null) return false;
if (!net.fretux.skillengine.skilltree.SkillLogic.canUnlock(data, player, node)) {
return false;
List<SkillNode> unlockPlan = SkillLogic.getUnlockPlan(data, player, node).orElse(null);
if (unlockPlan == null) return false;
for (SkillNode plannedNode : unlockPlan) {
data.unlockNode(plannedNode);
int cost = Math.max(0, plannedNode.getCost());
if (cost > 0) {
MinecraftForge.EVENT_BUS.post(new SkillPointsChangedEvent(
player,
cost,
SkillPointsChangedEvent.Reason.SPENT,
plannedNode.getId()
));
}
}
data.unlockNode(node);
return true;
}
public static boolean clientHasNode(ResourceLocation nodeId) {
return net.fretux.skillengine.client.SkilltreeClientState.isUnlocked(nodeId);
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;
int refunded = Math.max(0, node.getCost());
MinecraftForge.EVENT_BUS.post(new SkillPointsChangedEvent(
player,
refunded,
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);
}
public static Set<ResourceLocation> getUnlockedAbilities(Player player) {
PlayerSkillData data = getPlayerData(player);
return data != null ? data.getUnlockedAbilities() : Set.of();
}
public static boolean unlockAbility(Player player, ResourceLocation id) {
PlayerSkillData data = getPlayerData(player);
if (data == null) return false;
AbilityNode ability = AbilityNodeRegistry.get(id);
if (ability == null) return false;
data.unlockAbility(ability);
return true;
}
public static void bindAbility(Player player, int slot, ResourceLocation abilityId) {
PlayerSkillData data = getPlayerData(player);
if (data == null) return;
data.bindAbility(slot, abilityId);
}
public static ResourceLocation getAbilityInSlot(Player player, int slot) {
PlayerSkillData data = getPlayerData(player);
return data != null ? data.getAbilityInSlot(slot) : null;
}
public static ResourceLocation[] getAbilitySlots(Player player) {
PlayerSkillData data = getPlayerData(player);
return data != null ? data.getAbilitySlots() : new ResourceLocation[3];
}
public static boolean clientHasSkill(ResourceLocation id) {
if (FMLEnvironment.dist != Dist.CLIENT) {
return false;
}
return DistExecutor.unsafeCallWhenOn(Dist.CLIENT, () -> () -> ClientSkillEngineBridge.clientHasSkill(id));
}
public static int clientGetAbilitySlot(ResourceLocation id) {
if (FMLEnvironment.dist != Dist.CLIENT) {
return -1;
}
return DistExecutor.unsafeCallWhenOn(Dist.CLIENT, () -> () -> ClientSkillEngineBridge.clientGetAbilitySlot(id));
}
public static ResourceLocation[] clientGetAbilitySlots() {
if (FMLEnvironment.dist != Dist.CLIENT) {
return new ResourceLocation[3];
}
return DistExecutor.unsafeCallWhenOn(Dist.CLIENT, () -> ClientSkillEngineBridge::clientGetAbilitySlots);
}
}
@@ -1,28 +1,68 @@
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;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.Tag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;
public class PlayerSkillData {
private int skillPoints;
private final Set<ResourceLocation> unlockedNodes = new HashSet<>();
private final Set<ResourceLocation> unlockedAbilities = new HashSet<>();
private final Set<ResourceLocation> activeTags = new HashSet<>();
private ResourceLocation[] abilitySlots = new ResourceLocation[getConfiguredSlotCount()];
private int[] abilityCooldowns = new int[abilitySlots.length];
private final Map<ResourceLocation, Integer> tagCooldowns = new HashMap<>();
public int getSkillPoints() { return skillPoints; }
public void addSkillPoints(int amount) { skillPoints += amount; }
public boolean isOnCooldown(ResourceLocation tag) {
return tagCooldowns.getOrDefault(tag, 0) > 0;
}
public void setCooldown(ResourceLocation tag, int ticks) {
tagCooldowns.put(tag, ticks);
}
public void tickTagCooldowns() {
tagCooldowns.replaceAll((k, v) -> Math.max(0, v - 1));
}
public int getSkillPoints() {
return skillPoints;
}
public void addSkillPoints(int amount) {
skillPoints += amount;
}
public boolean isUnlocked(ResourceLocation nodeId) {
return unlockedNodes.contains(nodeId);
}
public void unlockNode(SkillNode node) {
if (node == null) return;
if (unlockedNodes.add(node.getId())) {
activeTags.addAll(node.getTags());
skillPoints -= Math.max(0, node.getCost());
}
}
public Set<ResourceLocation> getUnlockedNodes() {
return unlockedNodes;
}
public boolean hasTag(ResourceLocation tag) {
return activeTags.contains(tag);
}
@@ -31,65 +71,162 @@ public class PlayerSkillData {
return activeTags;
}
public void unlockNode(SkillNode node) {
if (unlockedNodes.add(node.getId())) {
activeTags.addAll(node.getTags());
skillPoints -= node.getCost();
public boolean isAbilityUnlocked(ResourceLocation id) {
return unlockedAbilities.contains(id);
}
public void unlockAbility(AbilityNode ability) {
if (ability == null) return;
unlockedAbilities.add(ability.getId());
}
public Set<ResourceLocation> getUnlockedAbilities() {
return unlockedAbilities;
}
public void bindAbility(int slot, ResourceLocation abilityId) {
ensureSlotCount();
bindHelper(slot, abilityId, abilitySlots);
}
public static void bindHelper(int slot, ResourceLocation abilityId, ResourceLocation[] slots) {
int index = slot - 1;
if (index < 0 || index >= slots.length) return;
if (abilityId != null) {
for (int i = 0; i < slots.length; i++) {
if (abilityId.equals(slots[i])) {
slots[i] = null;
}
}
}
slots[index] = abilityId;
}
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;
}
}
public void setCooldown(int slot, int ticks) {
ensureSlotCount();
int idx = slot - 1;
if (idx < 0 || idx >= abilityCooldowns.length) return;
abilityCooldowns[idx] = Math.max(0, 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]--;
}
}
}
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.putInt("SkillPoints", skillPoints);
ListTag unlockedList = new ListTag();
for (ResourceLocation id : unlockedNodes) {
CompoundTag t = new CompoundTag();
t.putString("Id", id.toString());
unlockedList.add(t);
}
tag.put("Unlocked", unlockedList);
ListTag tagList = new ListTag();
tag.put("UnlockedNodes", unlockedList);
ListTag tagsList = new ListTag();
for (ResourceLocation id : activeTags) {
CompoundTag t = new CompoundTag();
t.putString("Tag", id.toString());
tagList.add(t);
tagsList.add(t);
}
tag.put("Tags", tagList);
tag.put("ActiveTags", tagsList);
ListTag slotList = new ListTag();
ensureSlotCount();
for (ResourceLocation slot : abilitySlots) {
CompoundTag t = new CompoundTag();
t.putString("Id", slot == null ? "" : slot.toString());
slotList.add(t);
}
tag.put("AbilitySlots", slotList);
ListTag abilityList = new ListTag();
for (ResourceLocation id : unlockedAbilities) {
CompoundTag t = new CompoundTag();
t.putString("Id", id.toString());
abilityList.add(t);
}
tag.put("UnlockedAbilities", abilityList);
ListTag cdList = new ListTag();
for (int cd : abilityCooldowns) {
CompoundTag t = new CompoundTag();
t.putInt("CD", cd);
cdList.add(t);
}
tag.put("AbilityCooldowns", cdList);
return tag;
}
public void load(CompoundTag tag) {
skillPoints = tag.getInt("SkillPoints");
unlockedNodes.clear();
unlockedAbilities.clear();
activeTags.clear();
ListTag unlockedList = tag.getList("Unlocked", Tag.TAG_COMPOUND);
for (Tag t : unlockedList) {
CompoundTag c = (CompoundTag) t;
unlockedNodes.add(new ResourceLocation(c.getString("Id")));
abilitySlots = new ResourceLocation[getConfiguredSlotCount()];
abilityCooldowns = new int[abilitySlots.length];
tagCooldowns.clear();
ListTag nodeList = tag.getList("UnlockedNodes", Tag.TAG_COMPOUND);
for (Tag t : nodeList) {
ResourceLocation id = ResourceLocation.parse(((CompoundTag) t).getString("Id"));
unlockedNodes.add(id);
SkillNode node = SkillNodeRegistry.get(id);
if (node != null) activeTags.addAll(node.getTags());
}
ListTag tagList = tag.getList("Tags", Tag.TAG_COMPOUND);
ListTag tagList = tag.getList("ActiveTags", Tag.TAG_COMPOUND);
for (Tag t : tagList) {
CompoundTag c = (CompoundTag) t;
activeTags.add(new ResourceLocation(c.getString("Tag")));
activeTags.add(ResourceLocation.parse(((CompoundTag) t).getString("Tag")));
}
ListTag slots = tag.getList("AbilitySlots", Tag.TAG_COMPOUND);
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);
}
ListTag abilityList = tag.getList("UnlockedAbilities", Tag.TAG_COMPOUND);
for (Tag t : abilityList) {
unlockedAbilities.add(ResourceLocation.parse(((CompoundTag) t).getString("Id")));
}
ListTag cdList = tag.getList("AbilityCooldowns", Tag.TAG_COMPOUND);
for (int i = 0; i < abilityCooldowns.length && i < cdList.size(); i++) {
CompoundTag t = cdList.getCompound(i);
abilityCooldowns[i] = t.getInt("CD");
}
}
public Set<ResourceLocation> getUnlockedNodes() {
return unlockedNodes;
}
public int getTotalSkillCost() {
int total = 0;
for (ResourceLocation id : unlockedNodes) {
SkillNode node = SkillNodeRegistry.get(id);
if (node != null) {
total += node.getCost();
}
if (node != null) total += Math.max(0, node.getCost());
}
return total;
}
@@ -97,5 +234,82 @@ public class PlayerSkillData {
public void clearAllNodes() {
unlockedNodes.clear();
activeTags.clear();
unlockedAbilities.clear();
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 += Math.max(0, 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)) {
int cost = Math.max(0, node.getCost());
skillPoints += cost;
refunded += cost;
}
}
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);
}
}
@@ -9,9 +9,7 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class PlayerSkillProvider implements ICapabilityProvider, ICapabilitySerializable<CompoundTag> {
public static final ResourceLocation ID = new ResourceLocation("skillengine", "player_skill_data");
public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath("skillengine", "player_skill_data");
private final PlayerSkillData backend = new PlayerSkillData();
private final LazyOptional<PlayerSkillData> optional = LazyOptional.of(() -> backend);
@@ -19,10 +19,10 @@ public class SkillEngineCapabilities {
@SubscribeEvent
public static void onAttachCapabilities(AttachCapabilitiesEvent<Entity> event) {
if (event.getObject() instanceof Player player) {
if (event.getObject() instanceof Player) {
SkillEngine.LOGGER.info("Attaching PlayerSkillData capability");
event.addCapability(
new ResourceLocation(SkillEngine.MODID, "player_skill_data"),
ResourceLocation.fromNamespaceAndPath(SkillEngine.MODID, "player_skill_data"),
new PlayerSkillProvider()
);
}
@@ -0,0 +1,117 @@
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.network.chat.FormattedText;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public class AbilityBindingScreen extends Screen {
private static final int PANEL_WIDTH = 280;
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 = 6;
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 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());
for (int slot = 1; slot <= visibleSlots; slot++) {
int currentSlot = 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)
.build());
if (boundSlot == slot) {
button.active = false;
button.setMessage(Component.literal("Bound " + slot));
}
}
addRenderableWidget(SkillUi.button(Component.literal("Cancel"),
b -> onClose()).pos(x + PANEL_WIDTH / 2 - 40, y + PANEL_HEIGHT - 28).size(80, BUTTON_HEIGHT).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(@NotNull 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);
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 + 16, y + 34, 0, 0, 20, 20, 20, 20);
}
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 + 66, y + PANEL_HEIGHT - 60);
}
super.render(gfx, mouseX, mouseY, partialTick);
}
private void drawDescription(GuiGraphics gfx, int x, int y, int bottomY) {
int width = PANEL_WIDTH - PANEL_PADDING * 2;
List<FormattedText> lines = font.getSplitter().splitLines(
ability.getDescription(),
width,
ability.getDescription().getStyle()
);
int lineY = y;
for (FormattedText line : lines) {
if (lineY + font.lineHeight > bottomY) {
break;
}
gfx.drawString(font, line.getString(), x, lineY, SkillUi.MUTED);
lineY += font.lineHeight + 3;
}
}
}
@@ -0,0 +1,38 @@
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.resources.ResourceLocation;
import net.minecraftforge.client.event.RenderGuiEvent;
public class AbilityHudOverlay {
public static void render(RenderGuiEvent.Post event) {
Minecraft mc = Minecraft.getInstance();
if (mc.player == null) return;
int xStart = event.getGuiGraphics().guiWidth() - 124;
int yStart = 10;
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);
if (ability == null) continue;
int cd = SkilltreeClientState.getClientCooldown(slot);
ResourceLocation icon = ability.getIcon();
GuiGraphics gfx = event.getGuiGraphics();
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);
}
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;
}
}
}
@@ -0,0 +1,48 @@
package net.fretux.skillengine.client;
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.client.event.InputEvent;
import net.minecraftforge.client.settings.KeyConflictContext;
import org.lwjgl.glfw.GLFW;
public class AbilityKeybindHandler {
public static final int MAX_REGISTERED_ABILITY_SLOTS = 9;
public static final KeyMapping[] ABILITY_KEYS = new KeyMapping[MAX_REGISTERED_ABILITY_SLOTS];
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) {
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();
};
}
}
@@ -0,0 +1,122 @@
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 org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
public class AbilitySelectScreen extends Screen {
private final Screen parent;
private final int slot;
private final List<AbilityNode> unlockedAbilities = new ArrayList<>();
private int page = 0;
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"));
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);
}
}
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 - panelHeight()) / 2;
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 = 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 += 28;
}
if (page > 0) {
addRenderableWidget(SkillUi.button(Component.literal("< Prev"), b -> {
page--;
buildPageButtons();
}).pos(x + 16, y + panelHeight() - 28).size(60, 20).build());
}
if ((page + 1) * pageSize() < unlockedAbilities.size()) {
addRenderableWidget(SkillUi.button(Component.literal("Next >"), b -> {
page++;
buildPageButtons();
}).pos(x + 80, y + panelHeight() - 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) {
ResourceLocation id = node.getId();
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(@NotNull GuiGraphics gfx, int mouseX, int mouseY, float partialTick) {
this.renderBackground(gfx);
int x = (width - PANEL_WIDTH) / 2;
int y = (height - panelHeight()) / 2;
gfx.fill(0, 0, width, height, 0xAA000000);
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);
}
}
@@ -0,0 +1,116 @@
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.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 = 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 = 62;
public AbilitySlotsScreen() {
super(Component.literal("Abilities"));
}
@Override
protected void init() {
int x = (width - PANEL_WIDTH) / 2;
int panelHeight = panelHeight();
int y = (height - panelHeight) / 2;
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(SkillUi.button(Component.literal("Rebind"), b -> openSelect(currentSlot))
.pos(buttonX, rowY + (slot - 1) * 40).size(REBIND_BUTTON_WIDTH, buttonH).build());
}
addRenderableWidget(SkillUi.button(Component.literal("Close"), b -> onClose())
.pos(x + PANEL_WIDTH - 70, y + panelHeight - 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(@NotNull GuiGraphics gfx, int mouseX, int mouseY, float partialTick) {
this.renderBackground(gfx);
int x = (width - PANEL_WIDTH) / 2;
int panelHeight = panelHeight();
int y = (height - panelHeight) / 2;
gfx.fill(0, 0, width, height, 0xAA000000);
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 + 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) {
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;
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) {
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 + 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 - 16;
int availableWidth = buttonX - SLOT_TEXT_RIGHT_PADDING - textX;
if (availableWidth <= 0) return "";
if (font.width(title) <= availableWidth) {
return title;
}
String suffix = "...";
int suffixWidth = font.width(suffix);
if (availableWidth <= suffixWidth) {
return "";
}
return font.plainSubstrByWidth(title, availableWidth - suffixWidth) + suffix;
}
private int panelHeight() {
int slots = SkilltreeClientState.getAbilitySlots().length;
return Math.max(MIN_PANEL_HEIGHT, 92 + slots * 40);
}
}
@@ -0,0 +1,17 @@
package net.fretux.skillengine.client;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.eventbus.api.IEventBus;
public final class ClientBootstrap {
private ClientBootstrap() {}
public static void register(IEventBus modBus) {
modBus.addListener(ClientKeybinds::registerKeys);
MinecraftForge.EVENT_BUS.addListener(ClientEvents::onKeyInput);
MinecraftForge.EVENT_BUS.addListener(AbilityKeybindHandler::onInput);
MinecraftForge.EVENT_BUS.addListener(ClientCooldownTicker::onClientTick);
MinecraftForge.EVENT_BUS.addListener(AbilityHudOverlay::render);
}
}
@@ -0,0 +1,16 @@
package net.fretux.skillengine.client;
import net.minecraftforge.event.TickEvent;
public class ClientCooldownTicker {
public static void onClientTick(TickEvent.ClientTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
int slots = SkilltreeClientState.getAbilitySlots().length;
for (int slot = 1; slot <= slots; slot++) {
int cd = SkilltreeClientState.getClientCooldown(slot);
if (cd > 0) {
SkilltreeClientState.updateCooldown(slot, cd - 1);
}
}
}
}
@@ -2,22 +2,18 @@ package net.fretux.skillengine.client;
import net.fretux.skillengine.SkillEngine;
import net.minecraft.client.Minecraft;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.InputEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
@Mod.EventBusSubscriber(
modid = SkillEngine.MODID,
value = Dist.CLIENT
)
public class ClientEvents {
@SubscribeEvent
public static void onKeyInput(InputEvent.Key event) {
if (ClientKeybinds.OPEN_SKILLTREE != null &&
ClientKeybinds.OPEN_SKILLTREE.consumeClick()) {
Minecraft.getInstance().setScreen(new SkilltreeScreen());
}
if (ClientKeybinds.OPEN_ABILITIES != null &&
ClientKeybinds.OPEN_ABILITIES.consumeClick()) {
Minecraft.getInstance().setScreen(new AbilitySlotsScreen());
}
}
}
@@ -2,21 +2,13 @@ package net.fretux.skillengine.client;
import net.fretux.skillengine.SkillEngine;
import net.minecraft.client.KeyMapping;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import org.lwjgl.glfw.GLFW;
@Mod.EventBusSubscriber(
modid = SkillEngine.MODID,
bus = Mod.EventBusSubscriber.Bus.MOD,
value = Dist.CLIENT
)
public class ClientKeybinds {
public static KeyMapping OPEN_SKILLTREE;
public static KeyMapping OPEN_ABILITIES;
@SubscribeEvent
public static void registerKeys(RegisterKeyMappingsEvent event) {
OPEN_SKILLTREE = new KeyMapping(
"key.skillengine.openskilltree",
@@ -24,5 +16,16 @@ 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);
for (KeyMapping keyMapping : AbilityKeybindHandler.ABILITY_KEYS) {
event.register(keyMapping);
}
}
}
@@ -0,0 +1,60 @@
package net.fretux.skillengine.client;
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.resources.ResourceLocation;
public final class ClientSkillEngineBridge {
private ClientSkillEngineBridge() {}
public static boolean clientHasSkill(ResourceLocation id) {
return SkilltreeClientState.isUnlocked(id);
}
public static int clientGetAbilitySlot(ResourceLocation id) {
return SkilltreeClientState.getSlotOfAbility(id);
}
public static ResourceLocation[] clientGetAbilitySlots() {
return SkilltreeClientState.getAbilitySlots();
}
public static void handleNodeUnlocked(ResourceLocation id, int newSkillPoints, boolean isAbility) {
if (isAbility) {
SkilltreeClientState.unlockAbility(id);
} else {
SkilltreeClientState.unlockNode(id);
}
SkilltreeClientState.setCurrentSkillPoints(newSkillPoints);
}
public static void handleSkillsSync(Iterable<ResourceLocation> unlocked,
Iterable<ResourceLocation> unlockedAbilities,
int skillPoints,
ResourceLocation[] abilitySlots) {
SkilltreeClientState.setUnlocked(unlocked);
SkilltreeClientState.setUnlockedAbilities(unlockedAbilities);
SkilltreeClientState.setCurrentSkillPoints(skillPoints);
SkilltreeClientState.setAbilitySlots(abilitySlots);
}
public static void handleSkillDefinitionsSync(Iterable<SkillNode> skillNodes,
Iterable<AbilityNode> abilityNodes) {
SkillNodeRegistry.clear();
for (SkillNode node : skillNodes) {
SkillNodeRegistry.put(node);
}
AbilityNodeRegistry.clear();
for (AbilityNode node : abilityNodes) {
AbilityNodeRegistry.put(node);
}
}
public static void handleCooldownSync(int slot, int cooldown) {
SkilltreeClientState.updateCooldown(slot, cooldown);
}
}
@@ -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);
}
}
}
@@ -1,46 +1,61 @@
package net.fretux.skillengine.client;
import net.fretux.skillengine.SkillEngine;
import net.fretux.skillengine.capability.PlayerSkillData;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.toasts.SystemToast;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.sounds.SoundEvents;
import java.util.Arrays;
import java.util.HashSet;
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 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];
}
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,
SystemToast.SystemToastIds.TUTORIAL_HINT,
Component.literal("Node Unlocked"),
Component.literal(id.toString())
)
);
}
public static boolean isUnlocked(ResourceLocation id) {
return unlocked.contains(id);
}
public static void setUnlocked(Set<ResourceLocation> ids) {
public static void setUnlocked(Iterable<ResourceLocation> ids) {
unlocked.clear();
unlocked.addAll(ids);
for (ResourceLocation id : ids) {
unlocked.add(id);
}
}
public static void setUnlockedAbilities(Iterable<ResourceLocation> abilities) {
unlockedAbilities.clear();
for (ResourceLocation ability : abilities) {
unlockedAbilities.add(ability);
}
}
public static void setCurrentSkillPoints(int points) {
@@ -50,4 +65,51 @@ 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) {
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];
}
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;
}
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);
}
}
File diff suppressed because it is too large Load Diff
@@ -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;
}
}
@@ -3,6 +3,7 @@ package net.fretux.skillengine.compat;
import net.fretux.ascend.player.PlayerStatsProvider;
import net.fretux.skillengine.SkillEngine;
import net.fretux.skillengine.capability.SkillEngineCapabilities;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.player.PlayerEvent;
@@ -33,6 +34,12 @@ public class AscendCompatHandler {
initAscendLevel(event.getEntity());
}
@SubscribeEvent
public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity().level().isClientSide) return;
LAST_ASCEND_LEVEL.remove(event.getEntity().getUUID());
}
@SubscribeEvent
public static void onPlayerClone(PlayerEvent.Clone event) {
if (event.getEntity().level().isClientSide) return;
@@ -61,6 +68,7 @@ public class AscendCompatHandler {
int pointsToGive = levelsGained * SKILL_POINTS_PER_ASCEND_LEVEL;
player.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).ifPresent(skillData -> {
skillData.addSkillPoints(pointsToGive);
skillData.sync((ServerPlayer) player);
});
}
LAST_ASCEND_LEVEL.put(player.getUUID(), currentLevel);
@@ -8,6 +8,7 @@ import net.fretux.skillengine.network.PacketHandler;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
@@ -16,9 +17,8 @@ import java.util.*;
@Mod.EventBusSubscriber(modid = SkillEngine.MODID)
public class AscendResetDetector {
private static final String[] ASCEND_ATTRIBUTES = new String[]{
"strength", "agility", "fortitude", "intelligence",
"willpower", "charisma",
"light_scaling", "medium_scaling", "heavy_scaling", "magic_scaling"
"vigor", "mind", "strength", "agility", "intelligence",
"willpower", "charisma", "arcane"
};
private static final Map<UUID, Integer> LAST_ATTRIBUTE_SUM = new HashMap<>();
private static final Map<UUID, Integer> LAST_UNSPENT = new HashMap<>();
@@ -51,6 +51,14 @@ public class AscendResetDetector {
});
}
@SubscribeEvent
public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity().level().isClientSide) return;
UUID id = event.getEntity().getUUID();
LAST_ATTRIBUTE_SUM.remove(id);
LAST_UNSPENT.remove(id);
}
private static void resetSkillEngine(PlayerSkillData data, Player player) {
int refund = data.getTotalSkillCost();
data.addSkillPoints(refund);
@@ -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() {}
}
@@ -14,12 +14,16 @@ import net.minecraftforge.network.PacketDistributor;
public class SkillEnginePlayerEvents {
private static void sync(ServerPlayer player) {
player.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).ifPresent(data -> {
PacketHandler.CHANNEL.send(
PacketDistributor.PLAYER.with(() -> player),
new ClientboundSyncSkillsPacket(data.getUnlockedNodes(), data.getSkillPoints())
);
});
PacketHandler.syncSkillDefinitionsTo(player);
player.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).ifPresent(data -> PacketHandler.CHANNEL.send(
PacketDistributor.PLAYER.with(() -> player),
new ClientboundSyncSkillsPacket(
data.getUnlockedNodes(),
data.getUnlockedAbilities(),
data.getSkillPoints(),
data.getAbilitySlots()
)
));
}
@SubscribeEvent
@@ -29,6 +33,22 @@ public class SkillEnginePlayerEvents {
}
}
@SubscribeEvent
public static void onClone(PlayerEvent.Clone event) {
if (event.getEntity() instanceof ServerPlayer newPlayer && event.getOriginal() instanceof ServerPlayer oldPlayer) {
oldPlayer.reviveCaps();
try {
oldPlayer.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).ifPresent(oldData ->
newPlayer.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).ifPresent(newData ->
newData.load(oldData.save())
)
);
} finally {
oldPlayer.invalidateCaps();
}
}
}
@SubscribeEvent
public static void onRespawn(PlayerEvent.PlayerRespawnEvent event) {
if (event.getEntity() instanceof ServerPlayer player) {
@@ -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;
}
}
@@ -2,30 +2,46 @@ package net.fretux.skillengine.network;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.fml.DistExecutor;
import net.minecraftforge.network.NetworkEvent;
import net.fretux.skillengine.client.SkilltreeClientState;
import java.util.function.Supplier;
public class ClientboundNodeUnlockedPacket {
private final ResourceLocation id;
private final int newSkillPoints;
private final boolean ability;
public ClientboundNodeUnlockedPacket(ResourceLocation id) {
public ClientboundNodeUnlockedPacket(ResourceLocation id, int newSkillPoints, boolean ability) {
this.id = id;
this.newSkillPoints = newSkillPoints;
this.ability = ability;
}
public static void encode(ClientboundNodeUnlockedPacket msg, FriendlyByteBuf buf) {
buf.writeResourceLocation(msg.id);
buf.writeInt(msg.newSkillPoints);
buf.writeBoolean(msg.ability);
}
public static ClientboundNodeUnlockedPacket decode(FriendlyByteBuf buf) {
return new ClientboundNodeUnlockedPacket(buf.readResourceLocation());
ResourceLocation id = buf.readResourceLocation();
int points = buf.readInt();
boolean ability = buf.readBoolean();
return new ClientboundNodeUnlockedPacket(id, points, ability);
}
public static void handle(ClientboundNodeUnlockedPacket msg, Supplier<NetworkEvent.Context> ctx) {
ctx.get().enqueueWork(() -> {
SkilltreeClientState.unlockNode(msg.id);
DistExecutor.unsafeRunWhenOn(
Dist.CLIENT,
() -> () -> net.fretux.skillengine.client.ClientSkillEngineBridge.handleNodeUnlocked(
msg.id,
msg.newSkillPoints,
msg.ability
)
);
});
ctx.get().setPacketHandled(true);
}
@@ -0,0 +1,41 @@
package net.fretux.skillengine.network;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.fml.DistExecutor;
import net.minecraftforge.network.NetworkEvent;
import java.util.function.Supplier;
public class ClientboundSyncCooldownPacket {
private final int slot;
private final int cooldown;
public ClientboundSyncCooldownPacket(int slot, int cooldown) {
this.slot = slot;
this.cooldown = cooldown;
}
public static void encode(ClientboundSyncCooldownPacket msg, FriendlyByteBuf buf) {
buf.writeInt(msg.slot);
buf.writeInt(msg.cooldown);
}
public static ClientboundSyncCooldownPacket decode(FriendlyByteBuf buf) {
return new ClientboundSyncCooldownPacket(buf.readInt(), buf.readInt());
}
public static void handle(ClientboundSyncCooldownPacket msg, Supplier<NetworkEvent.Context> ctx) {
ctx.get().enqueueWork(() ->
DistExecutor.unsafeRunWhenOn(
Dist.CLIENT,
() -> () -> net.fretux.skillengine.client.ClientSkillEngineBridge.handleCooldownSync(
msg.slot,
msg.cooldown
)
)
);
ctx.get().setPacketHandled(true);
}
}
@@ -0,0 +1,175 @@
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 static final int MAX_SYNCED_NODES = 4096;
private static final int MAX_LIST_ENTRIES = 512;
private static final int MAX_PREREQUISITES = 256;
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 = readBoundedCount(buf, MAX_SYNCED_NODES, "skill node definitions");
List<SkillNode> skillNodes = new ArrayList<>(skillNodeCount);
for (int i = 0; i < skillNodeCount; i++) {
skillNodes.add(readSkillNode(buf));
}
int abilityNodeCount = readBoundedCount(buf, MAX_SYNCED_NODES, "ability node definitions");
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 = readBoundedCount(buf, MAX_LIST_ENTRIES, "resource location list");
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 = readBoundedCount(buf, MAX_PREREQUISITES, "prerequisites");
Map<String, Integer> prerequisites = new HashMap<>(size);
for (int i = 0; i < size; i++) {
prerequisites.put(buf.readUtf(), buf.readInt());
}
return prerequisites;
}
private static int readBoundedCount(FriendlyByteBuf buf, int max, String fieldName) {
int count = buf.readInt();
if (count < 0 || count > max) {
throw new IllegalArgumentException("Invalid " + fieldName + " count: " + count);
}
return count;
}
}
@@ -1,8 +1,9 @@
package net.fretux.skillengine.network;
import net.fretux.skillengine.client.SkilltreeClientState;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.fml.DistExecutor;
import net.minecraftforge.network.NetworkEvent;
import java.util.HashSet;
@@ -11,37 +12,79 @@ import java.util.function.Supplier;
public class ClientboundSyncSkillsPacket {
private final Set<ResourceLocation> unlocked;
private final int skillPoints;
private static final int MAX_SYNCED_NODES = 4096;
private static final int MAX_ABILITY_SLOTS = 64;
public ClientboundSyncSkillsPacket(Set<ResourceLocation> unlocked, int skillPoints) {
private final Set<ResourceLocation> unlocked;
private final Set<ResourceLocation> unlockedAbilities;
private final int skillPoints;
private final ResourceLocation[] abilitySlots;
public ClientboundSyncSkillsPacket(Set<ResourceLocation> unlocked,
Set<ResourceLocation> unlockedAbilities,
int skillPoints,
ResourceLocation[] abilitySlots) {
this.unlocked = unlocked;
this.unlockedAbilities = unlockedAbilities;
this.skillPoints = skillPoints;
this.abilitySlots = abilitySlots;
}
public static void encode(ClientboundSyncSkillsPacket msg, FriendlyByteBuf buf) {
buf.writeInt(msg.unlocked.size());
for (ResourceLocation id : msg.unlocked) {
buf.writeResourceLocation(id);
}
for (ResourceLocation id : msg.unlocked) buf.writeResourceLocation(id);
buf.writeInt(msg.unlockedAbilities.size());
for (ResourceLocation id : msg.unlockedAbilities) buf.writeResourceLocation(id);
buf.writeInt(msg.skillPoints);
// ability slots (nullable entries)
int count = msg.abilitySlots != null ? msg.abilitySlots.length : 0;
buf.writeInt(count);
if (count > 0) {
for (int i = 0; i < count; i++) {
ResourceLocation id = msg.abilitySlots[i];
buf.writeBoolean(id != null);
if (id != null) buf.writeResourceLocation(id);
}
}
}
public static ClientboundSyncSkillsPacket decode(FriendlyByteBuf buf) {
int size = buf.readInt();
Set<ResourceLocation> result = new HashSet<>();
for (int i = 0; i < size; i++) {
result.add(buf.readResourceLocation());
}
int size = readBoundedCount(buf, MAX_SYNCED_NODES, "unlocked skill nodes");
Set<ResourceLocation> unlocked = new HashSet<>();
for (int i = 0; i < size; i++) unlocked.add(buf.readResourceLocation());
int abilitySize = readBoundedCount(buf, MAX_SYNCED_NODES, "unlocked abilities");
Set<ResourceLocation> unlockedAbilities = new HashSet<>();
for (int i = 0; i < abilitySize; i++) unlockedAbilities.add(buf.readResourceLocation());
int points = buf.readInt();
return new ClientboundSyncSkillsPacket(result, points);
int slotsCount = readBoundedCount(buf, MAX_ABILITY_SLOTS, "ability slots");
ResourceLocation[] slots = new ResourceLocation[slotsCount];
for (int i = 0; i < slots.length; i++) {
boolean present = buf.readBoolean();
slots[i] = present ? buf.readResourceLocation() : null;
}
return new ClientboundSyncSkillsPacket(unlocked, unlockedAbilities, points, slots);
}
public static void handle(ClientboundSyncSkillsPacket msg, Supplier<NetworkEvent.Context> ctx) {
ctx.get().enqueueWork(() -> {
SkilltreeClientState.setUnlocked(msg.unlocked);
SkilltreeClientState.setCurrentSkillPoints(msg.skillPoints);
DistExecutor.unsafeRunWhenOn(
Dist.CLIENT,
() -> () -> net.fretux.skillengine.client.ClientSkillEngineBridge.handleSkillsSync(
msg.unlocked,
msg.unlockedAbilities,
msg.skillPoints,
msg.abilitySlots
)
);
});
ctx.get().setPacketHandled(true);
}
private static int readBoundedCount(FriendlyByteBuf buf, int max, String fieldName) {
int count = buf.readInt();
if (count < 0 || count > max) {
throw new IllegalArgumentException("Invalid " + fieldName + " count: " + count);
}
return count;
}
}
@@ -1,6 +1,8 @@
package net.fretux.skillengine.network;
import net.fretux.skillengine.capability.SkillEngineCapabilities;
import net.fretux.skillengine.skilltree.AbilityNodeRegistry;
import net.fretux.skillengine.skilltree.SkillNodeRegistry;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.network.NetworkDirection;
@@ -11,9 +13,9 @@ import net.minecraftforge.network.simple.SimpleChannel;
public class PacketHandler {
private static final String PROTOCOL = "1";
private static final String PROTOCOL = "4";
public static final SimpleChannel CHANNEL = NetworkRegistry.ChannelBuilder
.named(new ResourceLocation(SkillEngine.MODID, "network"))
.named(ResourceLocation.fromNamespaceAndPath(SkillEngine.MODID, "network"))
.clientAcceptedVersions(PROTOCOL::equals)
.serverAcceptedVersions(PROTOCOL::equals)
.networkProtocolVersion(() -> PROTOCOL)
@@ -37,13 +39,48 @@ public class PacketHandler {
.decoder(ClientboundSyncSkillsPacket::decode)
.consumerMainThread(ClientboundSyncSkillsPacket::handle)
.add();
CHANNEL.messageBuilder(ClientboundSyncSkillDefinitionsPacket.class, id++, NetworkDirection.PLAY_TO_CLIENT)
.encoder(ClientboundSyncSkillDefinitionsPacket::encode)
.decoder(ClientboundSyncSkillDefinitionsPacket::decode)
.consumerMainThread(ClientboundSyncSkillDefinitionsPacket::handle)
.add();
CHANNEL.messageBuilder(ServerboundBindAbilityPacket.class, id++, NetworkDirection.PLAY_TO_SERVER)
.encoder(ServerboundBindAbilityPacket::encode)
.decoder(ServerboundBindAbilityPacket::decode)
.consumerMainThread(ServerboundBindAbilityPacket::handle)
.add();
CHANNEL.messageBuilder(ClientboundSyncCooldownPacket.class, id++, NetworkDirection.PLAY_TO_CLIENT)
.encoder(ClientboundSyncCooldownPacket::encode)
.decoder(ClientboundSyncCooldownPacket::decode)
.consumerMainThread(ClientboundSyncCooldownPacket::handle)
.add();
CHANNEL.messageBuilder(ServerboundActivateAbilityPacket.class, id++, NetworkDirection.PLAY_TO_SERVER)
.encoder(ServerboundActivateAbilityPacket::encode)
.decoder(ServerboundActivateAbilityPacket::decode)
.consumerMainThread(ServerboundActivateAbilityPacket::handle)
.add();
}
public static void syncSkillsTo(ServerPlayer player) {
player.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).ifPresent(data -> {
ClientboundSyncSkillsPacket packet =
new ClientboundSyncSkillsPacket(data.getUnlockedNodes(), data.getSkillPoints());
new ClientboundSyncSkillsPacket(
data.getUnlockedNodes(),
data.getUnlockedAbilities(),
data.getSkillPoints(),
data.getAbilitySlots()
);
CHANNEL.send(PacketDistributor.PLAYER.with(() -> player), packet);
});
}
public static void syncSkillDefinitionsTo(ServerPlayer player) {
CHANNEL.send(
PacketDistributor.PLAYER.with(() -> player),
new ClientboundSyncSkillDefinitionsPacket(
SkillNodeRegistry.all(),
AbilityNodeRegistry.all()
)
);
}
}
@@ -0,0 +1,59 @@
package net.fretux.skillengine.network;
import net.fretux.skillengine.capability.SkillEngineCapabilities;
import net.fretux.skillengine.skilltree.AbilityNode;
import net.fretux.skillengine.skilltree.AbilityNodeRegistry;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.network.NetworkEvent;
import net.minecraftforge.network.PacketDistributor;
import java.util.function.Supplier;
public class ServerboundActivateAbilityPacket {
private final int slot;
public ServerboundActivateAbilityPacket(int slot) {
this.slot = slot;
}
public static void encode(ServerboundActivateAbilityPacket msg, FriendlyByteBuf buf) {
buf.writeInt(msg.slot);
}
public static ServerboundActivateAbilityPacket decode(FriendlyByteBuf buf) {
return new ServerboundActivateAbilityPacket(buf.readInt());
}
public static void handle(ServerboundActivateAbilityPacket msg, Supplier<NetworkEvent.Context> ctx) {
ctx.get().enqueueWork(() -> {
ServerPlayer player = ctx.get().getSender();
if (player == null) return;
player.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).ifPresent(data -> {
if (msg.slot < 1 || msg.slot > data.getAbilitySlots().length) return;
ResourceLocation abilityId = data.getAbilityInSlot(msg.slot);
if (abilityId == null) return;
if (!data.isAbilityUnlocked(abilityId)) return;
AbilityNode ability = AbilityNodeRegistry.get(abilityId);
if (ability == null) return;
int cd = data.getCooldown(msg.slot);
if (cd > 0) return;
ability.execute(player);
int cooldown = Math.max(0, ability.getCooldown());
data.setCooldown(msg.slot, cooldown);
PacketHandler.CHANNEL.send(
PacketDistributor.PLAYER.with(() -> player),
new ClientboundSyncCooldownPacket(msg.slot, cooldown)
);
});
});
ctx.get().setPacketHandled(true);
}
}
@@ -0,0 +1,46 @@
package net.fretux.skillengine.network;
import net.fretux.skillengine.capability.SkillEngineCapabilities;
import net.fretux.skillengine.skilltree.AbilityNodeRegistry;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.network.NetworkEvent;
import java.util.function.Supplier;
public class ServerboundBindAbilityPacket {
private final ResourceLocation abilityId;
private final int slot;
public ServerboundBindAbilityPacket(ResourceLocation id, int slot) {
this.abilityId = id;
this.slot = slot;
}
public static void encode(ServerboundBindAbilityPacket msg, FriendlyByteBuf buf) {
buf.writeResourceLocation(msg.abilityId);
buf.writeInt(msg.slot);
}
public static ServerboundBindAbilityPacket decode(FriendlyByteBuf buf) {
return new ServerboundBindAbilityPacket(buf.readResourceLocation(), buf.readInt());
}
public static void handle(ServerboundBindAbilityPacket msg, Supplier<NetworkEvent.Context> ctx) {
ctx.get().enqueueWork(() -> {
ServerPlayer player = ctx.get().getSender();
if (player == null) return;
player.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).ifPresent(data -> {
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);
});
PacketHandler.syncSkillsTo(player);
});
ctx.get().setPacketHandled(true);
}
}
@@ -1,20 +1,21 @@
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.skilltree.SkillLogic;
import net.fretux.skillengine.skilltree.SkillNode;
import net.fretux.skillengine.skilltree.SkillNodeRegistry;
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;
import java.util.List;
import java.util.function.Supplier;
public class ServerboundUnlockNodePacket {
private final ResourceLocation nodeId;
public ServerboundUnlockNodePacket(ResourceLocation nodeId) {
@@ -32,33 +33,59 @@ public class ServerboundUnlockNodePacket {
public static void handle(ServerboundUnlockNodePacket msg, Supplier<NetworkEvent.Context> ctx) {
ctx.get().enqueueWork(() -> {
ServerPlayer player = ctx.get().getSender();
if (player == null) {
SkillEngine.LOGGER.warn("Unlock packet received but sender was null");
return;
}
if (player == null) return;
SkillNode node = SkillNodeRegistry.get(msg.nodeId);
if (node == null) {
SkillEngine.LOGGER.warn("Unlock packet for unknown node {}", msg.nodeId);
return;
}
AbilityNode ability = AbilityNodeRegistry.get(msg.nodeId);
player.getCapability(SkillEngineCapabilities.PLAYER_SKILLS).ifPresent(data -> {
SkillEngine.LOGGER.info("Received unlock request for {} from {} (points={})",
msg.nodeId, player.getGameProfile().getName(), data.getSkillPoints());
if (SkillLogic.canUnlock(data, player, node)) {
data.unlockNode(node);
SkillEngine.LOGGER.info("Node {} unlocked for {}", msg.nodeId, player.getGameProfile().getName());
PacketHandler.CHANNEL.send(
PacketDistributor.PLAYER.with(() -> player),
new ClientboundNodeUnlockedPacket(node.getId())
);
} else {
SkillEngine.LOGGER.info("Cannot unlock node {} for {} (canUnlock=false)",
msg.nodeId, player.getGameProfile().getName());
if (node != null) {
List<SkillNode> unlockPlan = SkillLogic.getUnlockPlan(data, player, node).orElse(null);
if (unlockPlan != null) {
for (SkillNode plannedNode : unlockPlan) {
data.unlockNode(plannedNode);
int cost = Math.max(0, plannedNode.getCost());
if (cost > 0) {
MinecraftForge.EVENT_BUS.post(new SkillPointsChangedEvent(
player,
cost,
SkillPointsChangedEvent.Reason.SPENT,
plannedNode.getId()
));
}
}
PacketHandler.syncSkillsTo(player);
PacketHandler.CHANNEL.send(
PacketDistributor.PLAYER.with(() -> player),
new ClientboundNodeUnlockedPacket(node.getId(), data.getSkillPoints(), false)
);
} else {
SkillEngine.LOGGER.info("[SKILLENGINE] Rejecting unlock for {}", node.getId());
}
return;
}
if (ability != null) {
if (canUnlockAbility(player, data, ability)) {
data.unlockAbility(ability);
PacketHandler.syncSkillsTo(player);
PacketHandler.CHANNEL.send(
PacketDistributor.PLAYER.with(() -> player),
new ClientboundNodeUnlockedPacket(ability.getId(), data.getSkillPoints(), true)
);
} else {
SkillEngine.LOGGER.info("[SKILLENGINE] Rejecting ability unlock for {}", ability.getId());
}
}
});
});
ctx.get().setPacketHandled(true);
}
private static boolean canUnlockAbility(ServerPlayer player, PlayerSkillData data, AbilityNode ability) {
if (data.isAbilityUnlocked(ability.getId())) return false;
for (ResourceLocation parent : ability.getLinks()) {
boolean ok = data.isUnlocked(parent) || data.isAbilityUnlocked(parent);
if (!ok) return false;
}
return true;
}
}
@@ -0,0 +1,54 @@
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;
import java.util.List;
public class AbilityNode {
private final ResourceLocation id;
private final Component title;
private final Component description;
private final float x, y;
private final List<ResourceLocation> links;
private final List<ResourceLocation> tags;
private final ResourceLocation icon;
private final int cooldown;
public AbilityNode(ResourceLocation id,
Component title,
Component description,
float x, float y,
List<ResourceLocation> links,
List<ResourceLocation> tags,
ResourceLocation icon,
int cooldown) {
this.id = id;
this.title = title;
this.description = description;
this.x = x;
this.y = y;
this.links = List.copyOf(links);
this.tags = List.copyOf(tags);
this.icon = icon;
this.cooldown = cooldown;
}
public ResourceLocation getId() { return id; }
public Component getTitle() { return title; }
public Component getDescription() { return description; }
public float getX() { return x; }
public float getY() { return y; }
public List<ResourceLocation> getLinks() { return links; }
public List<ResourceLocation> getTags() { return tags; }
public ResourceLocation getIcon() { return icon; }
public int getCooldown() { return cooldown; }
public void execute(ServerPlayer player) {
AbilityHandlerRegistry.execute(player, this);
}
}
@@ -0,0 +1,62 @@
package net.fretux.skillengine.skilltree;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.fretux.skillengine.SkillEngine;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener;
import net.minecraft.util.profiling.ProfilerFiller;
import net.minecraftforge.event.AddReloadListenerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Mod.EventBusSubscriber(modid = SkillEngine.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE)
public class AbilityNodeLoader extends SimpleJsonResourceReloadListener {
private static final Gson GSON = new GsonBuilder().create();
public AbilityNodeLoader() {
super(GSON, "abilitynodes");
}
@SubscribeEvent
public static void onAddReloadListeners(AddReloadListenerEvent event) {
event.addListener(new AbilityNodeLoader());
}
@Override
protected void apply(Map<ResourceLocation, JsonElement> jsons,
@NotNull ResourceManager resourceManager,
@NotNull ProfilerFiller profiler) {
SkillEngine.LOGGER.info("Loading ability nodes...");
AbilityNodeRegistry.clear();
jsons.forEach((id, json) -> {
JsonObject obj = json.getAsJsonObject();
Component title = Component.literal(obj.get("title").getAsString());
Component description = Component.literal(obj.get("description").getAsString());
JsonObject pos = obj.getAsJsonObject("position");
float x = pos.get("x").getAsFloat();
float y = pos.get("y").getAsFloat();
List<ResourceLocation> links = new ArrayList<>();
obj.getAsJsonArray("links").forEach(e ->
links.add(ResourceLocation.parse(e.getAsString())));
List<ResourceLocation> tags = new ArrayList<>();
obj.getAsJsonArray("tags").forEach(e ->
tags.add(ResourceLocation.parse(e.getAsString())));
ResourceLocation icon = ResourceLocation.parse(obj.get("icon").getAsString());
int cooldown = obj.has("cooldown") ? obj.get("cooldown").getAsInt() : 0;
AbilityNode node = new AbilityNode(id, title, description, x, y, links, tags, icon, cooldown);
AbilityNodeRegistry.put(node);
});
SkillEngine.LOGGER.info("Loaded {} active ability nodes", AbilityNodeRegistry.all().size());
}
}
@@ -0,0 +1,35 @@
package net.fretux.skillengine.skilltree;
import net.minecraft.resources.ResourceLocation;
import java.util.Collection;
import java.util.LinkedHashMap;
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) {
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,32 +1,113 @@
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;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
public class SkillLogic {
public static boolean canUnlock(PlayerSkillData data, Player player, SkillNode node) {
if (data.isUnlocked(node.getId())) return false;
if (data.getSkillPoints() < node.getCost()) return false;
if (node.isRoot()) return true;
if (!meetsAscendPrerequisites(player, node)) return false;
for (ResourceLocation neighborId : node.getLinks()) {
if (data.isUnlocked(neighborId)) return true;
return getUnlockPlan(data, player, node).isPresent();
}
public static Optional<List<SkillNode>> getUnlockPlan(PlayerSkillData data, Player player, SkillNode node) {
if (data == null || player == null || node == null || data.isUnlocked(node.getId())) {
return Optional.empty();
}
List<SkillNode> plan = new ArrayList<>();
Set<ResourceLocation> planned = new HashSet<>();
Set<ResourceLocation> visiting = new HashSet<>();
if (!collectUnlockPlan(data, player, node, plan, planned, visiting)) {
return Optional.empty();
}
int totalCost = 0;
for (SkillNode plannedNode : plan) {
totalCost += Math.max(0, plannedNode.getCost());
}
if (data.getSkillPoints() < totalCost) {
return Optional.empty();
}
return Optional.of(List.copyOf(plan));
}
public static boolean meetsAscendPrerequisites(Player player, SkillNode node) {
if (node.getPrereqAttributes().isEmpty()) return true;
if (!(player instanceof ServerPlayer serverPlayer)) return true;
for (Map.Entry<String, Integer> req : node.getPrereqAttributes().entrySet()) {
if (!PrerequisiteProviderRegistry.meets(serverPlayer, node, req.getKey(), req.getValue())) {
return false;
}
}
return true;
}
private static boolean collectUnlockPlan(PlayerSkillData data,
Player player,
SkillNode node,
List<SkillNode> plan,
Set<ResourceLocation> planned,
Set<ResourceLocation> visiting) {
ResourceLocation id = node.getId();
if (data.isUnlocked(id) || planned.contains(id)) {
return true;
}
if (!visiting.add(id)) {
return false;
}
if (!meetsAscendPrerequisites(player, node) || conflictsWithUnlocked(data, node)) {
visiting.remove(id);
return false;
}
for (ResourceLocation parentId : node.getLinks()) {
if (data.isUnlocked(parentId)) {
continue;
}
SkillNode parent = SkillNodeRegistry.get(parentId);
if (parent == null || !collectUnlockPlan(data, player, parent, plan, planned, visiting)) {
visiting.remove(id);
return false;
}
}
if (conflictsWithPlanned(node, planned)) {
visiting.remove(id);
return false;
}
visiting.remove(id);
planned.add(id);
plan.add(node);
return true;
}
private static boolean conflictsWithUnlocked(PlayerSkillData data, SkillNode node) {
for (ResourceLocation excl : node.getExclusiveWith()) {
if (data.isUnlocked(excl)) {
return true;
}
}
return false;
}
public static boolean meetsAscendPrerequisites(Player player, SkillNode node) {
return player.getCapability(PlayerStatsProvider.PLAYER_STATS).map(stats -> {
for (Map.Entry<String, Integer> req : node.getPrereqAttributes().entrySet()) {
int current = stats.getAttributeLevel(req.getKey());
if (current < req.getValue()) return false;
}
return true;
}).orElse(false);
}
private static boolean conflictsWithPlanned(SkillNode node, Set<ResourceLocation> planned) {
for (ResourceLocation excl : node.getExclusiveWith()) {
if (planned.contains(excl)) {
return true;
}
}
return false;
}
}
@@ -16,8 +16,12 @@ 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;
public SkillNode(ResourceLocation id,
Component title,
@@ -26,8 +30,12 @@ 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) {
Map<String, Integer> prereqAttributes,
List<ResourceLocation> exclusiveWith){
this.id = id;
this.title = title;
@@ -37,8 +45,12 @@ 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);
}
public ResourceLocation getId() {
@@ -73,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;
}
@@ -81,6 +105,10 @@ public class SkillNode {
return prereqAttributes;
}
public List<ResourceLocation> getExclusiveWith() {
return exclusiveWith;
}
public boolean isRoot() {
return links.isEmpty();
}
@@ -10,16 +10,22 @@ import net.minecraft.util.profiling.ProfilerFiller;
import net.minecraftforge.event.AddReloadListenerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.HashMap;
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");
@@ -32,25 +38,35 @@ public class SkillNodeLoader extends SimpleJsonResourceReloadListener {
@Override
protected void apply(Map<ResourceLocation, JsonElement> jsons,
ResourceManager resourceManager,
ProfilerFiller profiler) {
@NotNull ResourceManager resourceManager,
@NotNull ProfilerFiller profiler) {
SkillEngine.LOGGER.info("Loading skill nodes...");
SkillNodeRegistry.clear();
jsons.forEach((id, element) -> {
JsonObject obj = element.getAsJsonObject();
String title = obj.get("title").getAsString();
String description = obj.get("description").getAsString();
ResourceLocation nodeId = obj.has("id")
? ResourceLocation.parse(obj.get("id").getAsString())
: id;
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();
float y = pos.get("y").getAsFloat();
List<ResourceLocation> links = new ArrayList<>();
obj.getAsJsonArray("links").forEach(e ->
links.add(new ResourceLocation(e.getAsString())));
links.add(ResourceLocation.parse(e.getAsString())));
List<ResourceLocation> tags = new ArrayList<>();
obj.getAsJsonArray("tags").forEach(e ->
tags.add(new ResourceLocation(e.getAsString())));
ResourceLocation icons = new ResourceLocation(obj.get("icons").getAsString());
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")) {
JsonObject prereqObj = obj.getAsJsonObject("prerequisites");
@@ -58,19 +74,49 @@ public class SkillNodeLoader extends SimpleJsonResourceReloadListener {
prereqAttributes.put(entry.getKey(), entry.getValue().getAsInt());
}
}
List<ResourceLocation> exclusiveWith = new ArrayList<>();
if (obj.has("exclusive_with")) {
obj.getAsJsonArray("exclusive_with").forEach(e ->
exclusiveWith.add(ResourceLocation.parse(e.getAsString())));
}
SkillNode node = new SkillNode(
id,
Component.literal(title),
Component.literal(description),
nodeId,
title,
description,
cost,
x, y,
links,
tags,
tree,
category,
layer,
icons,
prereqAttributes
prereqAttributes,
exclusiveWith
);
SkillNodeRegistry.put(node);
});
SkillEngine.LOGGER.info("Loaded {} skill nodes", SkillNodeRegistry.all().size());
}
private static Component readComponent(JsonObject obj, String key) {
if (!obj.has(key)) {
return Component.empty();
}
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,178 @@
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)");
}
if (node.getCost() < 0) {
issues.add(node.getId() + " has negative cost " + node.getCost());
}
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)");
}
if (ability.getCooldown() < 0) {
issues.add(ability.getId() + " has negative cooldown " + ability.getCooldown());
}
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());
}
}
+2 -2
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
@@ -69,7 +69,7 @@ side = "BOTH"
[[dependencies.${ mod_id }]]
modId = "ascend"
mandatory = true
versionRange = "[2.0.2,)"
versionRange = "[3.0.0,)"
ordering = "AFTER"
side = "BOTH"
@@ -0,0 +1,13 @@
{
"key.skillengine.openskilltree": "Open Skilltree",
"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.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"
}
@@ -1,12 +0,0 @@
{
"title": "Basic Strength",
"description": "You feel your muscles tighten.\nGrants +1 Strength.",
"cost": 1,
"position": { "x": -150.0, "y": -50.0 },
"links": ["skillengine:welcome"],
"tags": ["skillengine:strength_1"],
"icons": "minecraft:textures/item/iron_sword.png",
"prerequisites": {
"strength": 20
}
}
@@ -1,14 +0,0 @@
{
"title": "Way of the Wanderer",
"description": "A flexible beginner skill.\nOpens alternate paths.",
"cost": 1,
"position": { "x": 0.0, "y": 150.0 },
"links": ["skillengine:welcome"],
"tags": [
"skillengine:wanderer"
],
"icons": "minecraft:textures/item/leather_boots.png",
"prerequisites": {
"agility": 20
}
}
@@ -1,4 +1,5 @@
{
"id": "skillengine:welcome",
"title": "Welcome to Ascend",
"description": "This is your first skill node.\nClick Unlock to test the system.",
"cost": 0,