P3-03 : parse DataDrivenItem JSON animations block

Wire le parser JSON pour le bloc "animations" (living_motions + on_equip
+ on_unequip). Résolution motion cross-enum (LivingMotions vanilla EF +
TiedUpLivingMotions custom). Fuzzy-match Levenshtein pour suggestion
en cas de typo modder (Did you mean 'IDLE'?).

Remplace le null hardcodé à DataDrivenItemParser:352 (P3-02 TODO).

body : tolerance malformed RL, non-string values, unknown motions.
This commit is contained in:
notevil
2026-04-23 16:01:30 +02:00
parent c1ecfd75c7
commit 744aef63b5
2 changed files with 676 additions and 3 deletions

View File

@@ -4,6 +4,9 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.LivingMotions;
import com.tiedup.remake.rig.anim.TiedUpLivingMotions;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.component.ComponentType;
import com.tiedup.remake.v2.bondage.movement.MovementModifier;
@@ -11,11 +14,14 @@ import com.tiedup.remake.v2.bondage.movement.MovementStyle;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.minecraft.resources.ResourceLocation;
@@ -326,6 +332,12 @@ public final class DataDrivenItemParser {
idPath
);
// Optional: animations (living_motions + on_equip + on_unequip)
// P3-03 : replaces the hardcoded null from P3-02.
// When the "animations" key is absent in JSON, we keep null so that
// items authored before P3-03 keep their vanilla animator behavior.
AnimationBindings animations = parseAnimationBindings(root, id);
return new DataDrivenItemDefinition(
id,
displayName,
@@ -347,9 +359,7 @@ public final class DataDrivenItemParser {
movementModifier,
creator,
animationBones,
// P3-02 : animations parsing will be wired in P3-03. For now pass
// null so existing items keep their vanilla animator behavior.
null,
animations,
componentConfigs
);
}
@@ -629,4 +639,238 @@ public final class DataDrivenItemParser {
return Collections.unmodifiableMap(result);
}
// ===== Animations (P3-03) =====
/**
* Parse the optional {@code "animations"} JSON block into an
* {@link AnimationBindings}.
*
* <p>Format :
* <pre>{@code
* "animations": {
* "living_motions": {
* "WALK": "mymod:arms_cuffed_walk",
* "SNEAK": "mymod:arms_cuffed_sneak"
* },
* "on_equip": "mymod:cuffs_equip_oneshot",
* "on_unequip": "mymod:cuffs_unequip_oneshot"
* }
* }</pre>
*
* <p>Tolerance rules :
* <ul>
* <li>Key absent &rarr; returns {@code null} (no binding, vanilla behavior).</li>
* <li>Key present but {@code {}} &rarr; returns {@link AnimationBindings#EMPTY}.</li>
* <li>Unknown motion names are logged with a Levenshtein fuzzy-match suggestion
* and skipped (no crash).</li>
* <li>Malformed ResourceLocation strings are logged and skipped.</li>
* <li>Non-string values in {@code living_motions} are logged and skipped.</li>
* </ul>
*
* @param rootJson the parsed root JSON object of the item definition
* @param itemId the resolved item ID (used in log messages)
* @return the parsed bindings, {@link AnimationBindings#EMPTY} for an empty block,
* or {@code null} if the {@code "animations"} key is absent
*/
@Nullable
static AnimationBindings parseAnimationBindings(
JsonObject rootJson,
ResourceLocation itemId
) {
if (!rootJson.has("animations")) return null;
JsonElement animElem = rootJson.get("animations");
if (!animElem.isJsonObject()) {
LOGGER.warn(
"[DataDrivenItems] In {}: 'animations' is not an object, ignoring",
itemId
);
return null;
}
JsonObject animBlock = animElem.getAsJsonObject();
// Fast-path : present but empty -> EMPTY sentinel (distinguishable from "absent = null").
if (animBlock.size() == 0) return AnimationBindings.EMPTY;
// living_motions : optional map of motionName -> animationResourceLocation
Map<LivingMotion, ResourceLocation> motions = new HashMap<>();
if (animBlock.has("living_motions")) {
JsonElement motionsElem = animBlock.get("living_motions");
if (!motionsElem.isJsonObject()) {
LOGGER.warn(
"[DataDrivenItems] In {}: 'animations.living_motions' is not an object, skipping",
itemId
);
} else {
JsonObject motionsJson = motionsElem.getAsJsonObject();
for (Map.Entry<String, JsonElement> entry : motionsJson.entrySet()) {
String motionName = entry.getKey();
LivingMotion motion = resolveMotionByName(motionName);
if (motion == null) {
String suggest = suggestClosestMotion(motionName);
LOGGER.warn(
"[DataDrivenItemParser] Unknown motion '{}' in item {}, skipping.{}",
motionName,
itemId,
suggest != null ? " Did you mean '" + suggest + "'?" : ""
);
continue;
}
ResourceLocation animId = tryParseAnimationRL(
entry.getValue(),
"living_motions['" + motionName + "']",
itemId
);
if (animId == null) continue;
motions.put(motion, animId);
}
}
}
// on_equip / on_unequip : optional single RL strings
ResourceLocation onEquip = animBlock.has("on_equip")
? tryParseAnimationRL(animBlock.get("on_equip"), "on_equip", itemId)
: null;
ResourceLocation onUnequip = animBlock.has("on_unequip")
? tryParseAnimationRL(animBlock.get("on_unequip"), "on_unequip", itemId)
: null;
return new AnimationBindings(motions, onEquip, onUnequip);
}
/**
* Resolve a motion name string to its {@link LivingMotion} instance.
*
* <p>Both vanilla-EF {@link LivingMotions} and custom {@link TiedUpLivingMotions}
* are standard Java enums, so {@link Enum#valueOf(Class, String)} is used. The
* lookup is case-sensitive (must match the enum constant name exactly).
*
* @param name the motion name from JSON (e.g. "WALK", "STRUGGLE_BOUND")
* @return the resolved {@link LivingMotion}, or {@code null} if unknown in both enums
*/
@Nullable
static LivingMotion resolveMotionByName(String name) {
if (name == null || name.isEmpty()) return null;
// Try vanilla EF motions first (most common case)
try {
return LivingMotions.valueOf(name);
} catch (IllegalArgumentException ignored) {
// fall through
}
// Try TiedUp! custom motions
try {
return TiedUpLivingMotions.valueOf(name);
} catch (IllegalArgumentException ignored) {
// fall through
}
return null;
}
/**
* Suggest the closest known motion name for an unknown input, using
* Levenshtein edit distance with a tolerance of 3 edits.
*
* <p>Cross-enum search (vanilla {@link LivingMotions} + {@link TiedUpLivingMotions}).
*
* @param unknown the unknown name from JSON (case-insensitive comparison)
* @return the closest candidate (enum name), or {@code null} if no candidate
* is within 3 edits
*/
@Nullable
static String suggestClosestMotion(String unknown) {
if (unknown == null || unknown.isEmpty()) return null;
String needle = unknown.toUpperCase();
List<String> all = new ArrayList<>();
for (LivingMotions m : LivingMotions.values()) all.add(m.name());
for (TiedUpLivingMotions m : TiedUpLivingMotions.values()) all.add(m.name());
String best = null;
int bestDist = Integer.MAX_VALUE;
for (String candidate : all) {
int dist = levenshtein(needle, candidate);
if (dist < bestDist && dist <= 3) {
bestDist = dist;
best = candidate;
}
}
return best;
}
/**
* Classic Levenshtein edit distance (2D dynamic programming).
*
* <p>Pure computation, no allocations beyond the DP table. Used for typo
* suggestions on unknown motion names.
*/
static int levenshtein(String a, String b) {
if (a == null) a = "";
if (b == null) b = "";
int la = a.length();
int lb = b.length();
if (la == 0) return lb;
if (lb == 0) return la;
int[][] dp = new int[la + 1][lb + 1];
for (int i = 0; i <= la; i++) dp[i][0] = i;
for (int j = 0; j <= lb; j++) dp[0][j] = j;
for (int i = 1; i <= la; i++) {
for (int j = 1; j <= lb; j++) {
int cost = (a.charAt(i - 1) == b.charAt(j - 1)) ? 0 : 1;
dp[i][j] = Math.min(
Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1),
dp[i - 1][j - 1] + cost
);
}
}
return dp[la][lb];
}
/**
* Parse a JSON element as a ResourceLocation for an animation binding.
* Logs warn + returns null if the element is not a string or the string
* is not a valid RL.
*/
@Nullable
private static ResourceLocation tryParseAnimationRL(
JsonElement elem,
String context,
ResourceLocation itemId
) {
if (elem == null || elem.isJsonNull() || !elem.isJsonPrimitive()
|| !elem.getAsJsonPrimitive().isString()) {
LOGGER.warn(
"[DataDrivenItemParser] {} in item {} is not a string, skipping.",
context,
itemId
);
return null;
}
String s = elem.getAsString();
// Strict RL parsing : animations MUST carry an explicit namespace
// (e.g. "mymod:foo"). ResourceLocation.tryParse defaults bare paths
// to the "minecraft" namespace, which silently masks modder typos —
// we reject strings without ':' here.
if (s.isEmpty() || s.indexOf(':') < 0) {
LOGGER.warn(
"[DataDrivenItemParser] Malformed ResourceLocation '{}' for {} in item {}, skipping.",
s,
context,
itemId
);
return null;
}
ResourceLocation rl = ResourceLocation.tryParse(s);
if (rl == null) {
LOGGER.warn(
"[DataDrivenItemParser] Malformed ResourceLocation '{}' for {} in item {}, skipping.",
s,
context,
itemId
);
}
return rl;
}
}