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:
@@ -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 → returns {@code null} (no binding, vanilla behavior).</li>
|
||||
* <li>Key present but {@code {}} → 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user