Option to change icon for maxed out skill tree nodes

This commit is contained in:
Jules 2025-07-12 19:14:56 +02:00
parent 7e6946097b
commit 51d2ee5508
7 changed files with 130 additions and 20 deletions

View File

@ -324,18 +324,19 @@ public class PlayerData extends SynchronizedDataHolder implements OfflinePlayerD
@NotNull
public NodeIncrementResult canIncrementNodeLevel(@NotNull SkillTreeNode node) {
final NodeState nodeState = nodeStates.get(node);
final var nodeState = nodeStates.get(node);
// Check node state
// Node is maxed out
//if (getNodeLevel(node) >= node.getMaxLevel()) return NodeIncrementResult.MAX_LEVEL_REACHED;
if (nodeState == NodeState.MAXED_OUT) return NodeIncrementResult.MAX_LEVEL_REACHED;
// Node is not ACCESSIBLE yet
if (nodeState != NodeState.UNLOCKED && nodeState != NodeState.UNLOCKABLE)
return NodeIncrementResult.LOCKED_NODE;
// Check permission
if (!node.hasPermissionRequirement(this)) return NodeIncrementResult.PERMISSION_DENIED;
// Max node level
if (getNodeLevel(node) >= node.getMaxLevel()) return NodeIncrementResult.MAX_LEVEL_REACHED;
final int skillTreePoints = this.skillTreePoints.getOrDefault(node.getTree().getId(), 0) + this.skillTreePoints.getOrDefault("global", 0);
if (skillTreePoints < node.getPointConsumption()) return NodeIncrementResult.NOT_ENOUGH_POINTS;
@ -352,7 +353,8 @@ public class PlayerData extends SynchronizedDataHolder implements OfflinePlayerD
node.updateAdvancement(this, newLevel); // Claim the node exp table
// Update node state
nodeStates.compute(node, (key, status) -> status == NodeState.UNLOCKABLE ? NodeState.UNLOCKED : status);
// TODO check/remove: USELESS LINE? since node states are updated afterwards
// nodeStates.compute(node, (key, status) -> status == NodeState.UNLOCKABLE ? NodeState.UNLOCKED : status);
// Consume skill tree points
final AtomicInteger cost = new AtomicInteger(node.getPointConsumption());

View File

@ -446,7 +446,7 @@ public class SkillTreeViewer extends EditableInventory {
final int y = container.get(new NamespacedKey(MMOCore.plugin, "coordinates.y"), PersistentDataType.INTEGER);
if (!inv.skillTree.isNode(new IntegerCoordinates(x, y))) return;
// Maximum amount of skill points spent in node
// Higher number of points spent in SKILL TREE (not node)
final SkillTreeNode node = inv.skillTree.getNode(new IntegerCoordinates(x, y));
if (inv.playerData.getPointsSpent(inv.skillTree) >= inv.skillTree.getMaxPointSpent()) {
ConfigMessage.fromKey("max-points-reached").send(inv.playerData);
@ -475,6 +475,7 @@ public class SkillTreeViewer extends EditableInventory {
break;
}
// Max number of points spent in that NODE (not skill tree)
case MAX_LEVEL_REACHED: {
ConfigMessage.fromKey("skill-node-max-level-hit").send(inv.playerData);
MMOCore.plugin.soundManager.getSound(SoundEvent.NOT_ENOUGH_POINTS).playTo(inv.getPlayer());

View File

@ -1,5 +1,13 @@
package net.Indyuce.mmocore.skilltree;
import net.Indyuce.mmocore.api.player.PlayerData;
/**
* State of one skill tree node, or path between nodes.
*
* @see PlayerData#getNodeState(SkillTreeNode)
* @see SkillTreePath#getStatus(PlayerData)
*/
public enum NodeState {
/**
@ -19,8 +27,20 @@ public enum NodeState {
LOCKED,
/**
* The player made a choice making it now impossible to
* reach this node given its skill tree exploration.
* No more skill points can be spent on the node since it has already
* been maxed out. Technically it is a subtype of UNLOCKED since you can't
* be MAXED_OUT without being UNLOCKED.
*/
MAXED_OUT,
/**
* The player made a choice making it now impossible to reach this
* node given its skill tree exploration. The player needs to
* re-spec to unlock that node.
*/
FULLY_LOCKED;
public boolean isUnlocked() {
return this == UNLOCKED || this == MAXED_OUT;
}
}

View File

@ -16,15 +16,25 @@ public class SkillTreePath {
to = skillTreeNode;
}
/**
* Defines the status of a path between two nodes, which is determined
* by the pair of states of the two nodes.
*/
public NodeState getStatus(PlayerData playerData) {
NodeState fromStatus = playerData.getNodeState(from);
NodeState toStatus = playerData.getNodeState(to);
if (fromStatus == NodeState.UNLOCKED && toStatus == NodeState.UNLOCKED)
return NodeState.UNLOCKED;
if ((fromStatus == NodeState.UNLOCKABLE && toStatus == NodeState.UNLOCKED) || (fromStatus == NodeState.UNLOCKED && toStatus == NodeState.UNLOCKABLE))
var from = playerData.getNodeState(this.from);
var to = playerData.getNodeState(this.to);
// Either one is fully locked => gray out path
if (from == NodeState.FULLY_LOCKED || to == NodeState.FULLY_LOCKED) return NodeState.FULLY_LOCKED;
// Both are unlocked => path is taken, unlocked
if (from.isUnlocked() && to.isUnlocked()) return NodeState.UNLOCKED;
// One of them is unlocked, other one is unlockable => path is not taken yet, but can be
if ((from == NodeState.UNLOCKABLE && to.isUnlocked()) || (from.isUnlocked() && to == NodeState.UNLOCKABLE))
return NodeState.UNLOCKABLE;
if (fromStatus == NodeState.FULLY_LOCKED || toStatus == NodeState.FULLY_LOCKED)
return NodeState.FULLY_LOCKED;
// Otherwise, locked path
return NodeState.LOCKED;
}

View File

@ -32,7 +32,6 @@ import java.util.logging.Level;
* - extra attribute pts
* - particle or potion effects
*
* @author Ka0rX
* @see SkillTreeNode
*/
public abstract class SkillTree implements RegisteredObject {
@ -99,14 +98,22 @@ public abstract class SkillTree implements RegisteredObject {
}
// Loads all the nodeDisplayInfo
for (NodeState status : NodeState.values())
for (NodeType nodeType : NodeType.values())
for (var status : NodeState.values()) {
final var anyType = config.get("display.nodes." + MMOCoreUtils.ymlName(status.name()));
// Does not depend on node type.
if (anyType != null) for (var nodeType : NodeType.values())
icons.put(new NodeDisplayInfo(nodeType, status), Icon.from(anyType));
// Depends on node type
else for (var nodeType : NodeType.values())
try {
final String configPath = "display.nodes." + MMOCoreUtils.ymlName(status.name()) + "." + MMOCoreUtils.ymlName(nodeType.name());
final var configPath = "display.nodes." + MMOCoreUtils.ymlName(status.name()) + "." + MMOCoreUtils.ymlName(nodeType.name());
icons.put(new NodeDisplayInfo(nodeType, status), Icon.from(config.get(configPath)));
} catch (Exception exception) {
// Ignore
}
}
// Setup children and parents for each node
for (SkillTreeNode node : nodes.values())
@ -229,6 +236,8 @@ public abstract class SkillTree implements RegisteredObject {
// PASS 3
//
// Propagate unreachability in O(V * C * P)
// Unreachability is transitive, if one node is unreachable, all subsequent
// child nodes are all unreachable.
while (!unreachable.empty()) {
final SkillTreeNode node = unreachable.pop();
@ -276,6 +285,14 @@ public abstract class SkillTree implements RegisteredObject {
// At least one soft parent!
if (!hasSoft || soft) playerData.setNodeState(node, NodeState.UNLOCKABLE);
}
// PASS 5
//
// I'm not sure this is the best place to do that but it works just fine.
// Mark unlocked nodes as maxed out if maximum number of points spent
for (var node : nodes.values())
if (playerData.getNodeState(node) == NodeState.UNLOCKED && playerData.getNodeLevel(node) >= node.getMaxLevel())
playerData.setNodeState(node, NodeState.MAXED_OUT);
}
private boolean isUnreachable(@NotNull SkillTreeNode node, @NotNull PlayerData playerData) {

View File

@ -487,6 +487,23 @@ nodes:
# up: 'WHITE_CONCRETE:0'
# down: 'WHITE_CONCRETE:0'
# no-path: 'WHITE_CONCRETE:0'
# maxed-out:
# up-right-down-left: 'GREEN_CONCRETE:0'
# up-right-down: 'GREEN_CONCRETE:0'
# up-right-left: 'GREEN_CONCRETE:0'
# up-down-left: 'GREEN_CONCRETE:0'
# down-right-left: 'GREEN_CONCRETE:0'
# up-right: 'GREEN_CONCRETE:0'
# up-down: 'GREEN_CONCRETE:0'
# up-left: 'GREEN_CONCRETE:0'
# down-right: 'GREEN_CONCRETE:0'
# down-left: 'GREEN_CONCRETE:0'
# right-left: 'GREEN_CONCRETE:0'
# right: 'GREEN_CONCRETE:0'
# left: 'GREEN_CONCRETE:0'
# up: 'GREEN_CONCRETE:0'
# down: 'GREEN_CONCRETE:0'
# no-path: 'GREEN_CONCRETE:0'
# locked:
# up-right-down-left: 'GRAY_CONCRETE:0'
# up-right-down: 'GRAY_CONCRETE:0'
@ -538,3 +555,16 @@ nodes:
# up: 'BLACK_CONCRETE:0'
# down: 'BLACK_CONCRETE:0'
# no-path: 'BLACK_CONCRETE:0'
# The following syntax works too, it applies the same texture
# no matter the neighboring paths.
#
#display:
# paths:
# .......
# nodes:
# unlocked: 'WHITE_CONCRETE:0'
# maxed-out: 'GREEN_CONCRETE:0'
# locked: 'GRAY_CONCRETE:0'
# unlockable: 'BLUE_CONCRETE:0'
# fully-locked: 'BLACK_CONCRETE:0'

View File

@ -483,6 +483,23 @@ nodes:
# up: 'WHITE_CONCRETE:0'
# down: 'WHITE_CONCRETE:0'
# no-path: 'WHITE_CONCRETE:0'
# maxed-out:
# up-right-down-left: 'GREEN_CONCRETE:0'
# up-right-down: 'GREEN_CONCRETE:0'
# up-right-left: 'GREEN_CONCRETE:0'
# up-down-left: 'GREEN_CONCRETE:0'
# down-right-left: 'GREEN_CONCRETE:0'
# up-right: 'GREEN_CONCRETE:0'
# up-down: 'GREEN_CONCRETE:0'
# up-left: 'GREEN_CONCRETE:0'
# down-right: 'GREEN_CONCRETE:0'
# down-left: 'GREEN_CONCRETE:0'
# right-left: 'GREEN_CONCRETE:0'
# right: 'GREEN_CONCRETE:0'
# left: 'GREEN_CONCRETE:0'
# up: 'GREEN_CONCRETE:0'
# down: 'GREEN_CONCRETE:0'
# no-path: 'GREEN_CONCRETE:0'
# locked:
# up-right-down-left: 'GRAY_CONCRETE:0'
# up-right-down: 'GRAY_CONCRETE:0'
@ -534,3 +551,16 @@ nodes:
# up: 'BLACK_CONCRETE:0'
# down: 'BLACK_CONCRETE:0'
# no-path: 'BLACK_CONCRETE:0'
# The following syntax works too, it applies the same texture
# no matter the neighboring paths.
#
#display:
# paths:
# .......
# nodes:
# unlocked: 'WHITE_CONCRETE:0'
# maxed-out: 'GREEN_CONCRETE:0'
# locked: 'GRAY_CONCRETE:0'
# unlockable: 'BLUE_CONCRETE:0'
# fully-locked: 'BLACK_CONCRETE:0'