Clean repo for open source release

Remove build artifacts, dev tool configs, unused dependencies,
and third-party source dumps. Add proper README, update .gitignore,
clean up Makefile.
This commit is contained in:
NotEvil
2026-04-12 00:51:22 +02:00
parent 2e7a1d403b
commit f6466360b6
1947 changed files with 238025 additions and 1 deletions

View File

@@ -0,0 +1,93 @@
package com.tiedup.remake.client.animation.context;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Represents the current player/NPC posture and action state for animation selection.
* Determines which base body posture animation to play.
*
* <p>Each context maps to a GLB animation name via a prefix + variant scheme:
* <ul>
* <li>Prefix: "Sit", "Kneel", "Sneak", "Walk", or "" (standing)</li>
* <li>Variant: "Idle" or "Struggle"</li>
* </ul>
* The {@link GlbAnimationResolver} uses these to build a fallback chain
* (e.g., SitStruggle -> Struggle -> SitIdle -> Idle).</p>
*/
@OnlyIn(Dist.CLIENT)
public enum AnimationContext {
STAND_IDLE("stand_idle", false),
STAND_WALK("stand_walk", false),
STAND_SNEAK("stand_sneak", false),
STAND_STRUGGLE("stand_struggle", true),
SIT_IDLE("sit_idle", false),
SIT_STRUGGLE("sit_struggle", true),
KNEEL_IDLE("kneel_idle", false),
KNEEL_STRUGGLE("kneel_struggle", true),
// Movement style contexts
SHUFFLE_IDLE("shuffle_idle", false),
SHUFFLE_WALK("shuffle_walk", false),
HOP_IDLE("hop_idle", false),
HOP_WALK("hop_walk", false),
WADDLE_IDLE("waddle_idle", false),
WADDLE_WALK("waddle_walk", false),
CRAWL_IDLE("crawl_idle", false),
CRAWL_MOVE("crawl_move", false);
private final String animationSuffix;
private final boolean struggling;
AnimationContext(String animationSuffix, boolean struggling) {
this.animationSuffix = animationSuffix;
this.struggling = struggling;
}
/**
* Suffix used as key for context animation JSON files (e.g., "stand_idle").
*/
public String getAnimationSuffix() {
return animationSuffix;
}
/**
* Whether this context represents an active struggle state.
*/
public boolean isStruggling() {
return struggling;
}
/**
* Get the GLB animation name prefix for this context's posture.
* Used by the fallback chain in {@link GlbAnimationResolver}.
*
* @return "Sit", "Kneel", "Sneak", "Walk", or "" for standing
*/
public String getGlbContextPrefix() {
return switch (this) {
case SIT_IDLE, SIT_STRUGGLE -> "Sit";
case KNEEL_IDLE, KNEEL_STRUGGLE -> "Kneel";
case STAND_SNEAK -> "Sneak";
case STAND_WALK -> "Walk";
case STAND_IDLE, STAND_STRUGGLE -> "";
case SHUFFLE_IDLE, SHUFFLE_WALK -> "Shuffle";
case HOP_IDLE, HOP_WALK -> "Hop";
case WADDLE_IDLE, WADDLE_WALK -> "Waddle";
case CRAWL_IDLE, CRAWL_MOVE -> "Crawl";
};
}
/**
* Get the GLB animation variant name: "Struggle" or "Idle".
*/
public String getGlbVariant() {
return switch (this) {
case STAND_STRUGGLE, SIT_STRUGGLE, KNEEL_STRUGGLE -> "Struggle";
case STAND_WALK, SHUFFLE_WALK, HOP_WALK, WADDLE_WALK -> "Walk";
case CRAWL_MOVE -> "Move";
default -> "Idle";
};
}
}

View File

@@ -0,0 +1,118 @@
package com.tiedup.remake.client.animation.context;
import com.tiedup.remake.client.state.PetBedClientState;
import com.tiedup.remake.entities.AbstractTiedUpNpc;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.jetbrains.annotations.Nullable;
/**
* Resolves the current {@link AnimationContext} for players and NPCs based on their state.
*
* <p>This is a pure function with no side effects -- it reads entity state and returns
* the appropriate animation context. The resolution priority is:
* <ol>
* <li><b>Sitting</b> (pet bed for players, pose for NPCs) -- highest priority posture</li>
* <li><b>Kneeling</b> (NPCs only)</li>
* <li><b>Struggling</b> (standing struggle if not sitting/kneeling)</li>
* <li><b>Sneaking</b> (players only)</li>
* <li><b>Walking</b> (horizontal movement detected)</li>
* <li><b>Standing idle</b> (fallback)</li>
* </ol>
*
* <p>For players, the "sitting" state is determined by the client-side pet bed cache
* ({@link PetBedClientState}) rather than entity data, since pet bed state is not
* synced via entity data accessors.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class AnimationContextResolver {
private AnimationContextResolver() {}
/**
* Resolve the animation context for a player based on their bind state and movement.
*
* <p>Priority chain:
* <ol>
* <li>Sitting (pet bed/furniture) -- highest priority posture</li>
* <li>Struggling -- standing struggle if not sitting</li>
* <li>Movement style -- style-specific idle/walk based on movement</li>
* <li>Sneaking</li>
* <li>Walking</li>
* <li>Standing idle -- fallback</li>
* </ol>
*
* @param player the player entity (must not be null)
* @param state the player's bind state, or null if not bound
* @param activeStyle the active movement style from client state, or null
* @return the resolved animation context, never null
*/
public static AnimationContext resolve(Player player, @Nullable PlayerBindState state,
@Nullable MovementStyle activeStyle) {
boolean sitting = PetBedClientState.get(player.getUUID()) != 0;
boolean struggling = state != null && state.isStruggling();
boolean sneaking = player.isCrouching();
boolean moving = player.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
if (sitting) {
return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE;
}
if (struggling) {
return AnimationContext.STAND_STRUGGLE;
}
if (activeStyle != null) {
return resolveStyleContext(activeStyle, moving);
}
if (sneaking) {
return AnimationContext.STAND_SNEAK;
}
if (moving) {
return AnimationContext.STAND_WALK;
}
return AnimationContext.STAND_IDLE;
}
/**
* Map a movement style + moving flag to the appropriate AnimationContext.
*/
private static AnimationContext resolveStyleContext(MovementStyle style, boolean moving) {
return switch (style) {
case SHUFFLE -> moving ? AnimationContext.SHUFFLE_WALK : AnimationContext.SHUFFLE_IDLE;
case HOP -> moving ? AnimationContext.HOP_WALK : AnimationContext.HOP_IDLE;
case WADDLE -> moving ? AnimationContext.WADDLE_WALK : AnimationContext.WADDLE_IDLE;
case CRAWL -> moving ? AnimationContext.CRAWL_MOVE : AnimationContext.CRAWL_IDLE;
};
}
/**
* Resolve the animation context for a Damsel NPC based on pose and movement.
*
* <p>Unlike players, NPCs support kneeling as a distinct posture and do not sneak.</p>
*
* @param entity the damsel entity (must not be null)
* @return the resolved animation context, never null
*/
public static AnimationContext resolveNpc(AbstractTiedUpNpc entity) {
boolean sitting = entity.isSitting();
boolean kneeling = entity.isKneeling();
boolean struggling = entity.isStruggling();
boolean moving = entity.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
if (sitting) {
return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE;
}
if (kneeling) {
return struggling ? AnimationContext.KNEEL_STRUGGLE : AnimationContext.KNEEL_IDLE;
}
if (struggling) {
return AnimationContext.STAND_STRUGGLE;
}
if (moving) {
return AnimationContext.STAND_WALK;
}
return AnimationContext.STAND_IDLE;
}
}

View File

@@ -0,0 +1,161 @@
package com.tiedup.remake.client.animation.context;
import com.mojang.logging.LogUtils;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.slf4j.Logger;
/**
* Builds context {@link KeyframeAnimation}s with item-owned body parts disabled.
*
* <p>Context animations (loaded from {@code context_*.json} files in the PlayerAnimator
* registry) control the base body posture -- standing, sitting, walking, etc.
* When a V2 bondage item "owns" certain body parts (e.g., handcuffs own rightArm + leftArm),
* those parts must NOT be driven by the context animation because the item's own
* GLB animation controls them instead.</p>
*
* <p>This factory loads the base context animation, creates a mutable copy, disables
* the owned parts, and builds an immutable result. Results are cached by
* {@code contextSuffix|ownedPartsHash} to avoid repeated copies.</p>
*
* <p>Thread safety: the cache uses {@link ConcurrentHashMap}. All methods are
* called from the render thread, but the concurrent map avoids issues if
* resource reload triggers on a different thread.</p>
*
* @see AnimationContext
* @see RegionBoneMapper#computeOwnedParts
*/
@OnlyIn(Dist.CLIENT)
public final class ContextAnimationFactory {
private static final Logger LOGGER = LogUtils.getLogger();
private static final String NAMESPACE = "tiedup";
/**
* Cache keyed by "contextSuffix|ownedPartsHashCode".
* Null values are stored as sentinels for missing animations to avoid repeated lookups.
*/
private static final Map<String, KeyframeAnimation> CACHE = new ConcurrentHashMap<>();
/**
* Sentinel set used to track cache keys where the base animation was not found,
* so we don't log the same warning repeatedly.
*/
private static final Set<String> MISSING_WARNED = ConcurrentHashMap.newKeySet();
private ContextAnimationFactory() {}
/**
* Create (or retrieve from cache) a context animation with the given parts disabled.
*
* <p>If no parts need disabling, the base animation is returned as-is (no copy needed).
* If the base animation is not found in the PlayerAnimator registry, returns null.</p>
*
* @param context the current animation context (determines which context_*.json to load)
* @param disabledParts set of PlayerAnimator part names to disable on the context layer
* (e.g., {"rightArm", "leftArm"}), typically from
* {@link RegionBoneMapper.BoneOwnership#disabledOnContext()}
* @return the context animation with disabled parts suppressed, or null if not found
*/
@Nullable
public static KeyframeAnimation create(AnimationContext context, Set<String> disabledParts) {
String cacheKey = context.getAnimationSuffix() + "|" + String.join(",", new java.util.TreeSet<>(disabledParts));
// computeIfAbsent cannot store null values, so we handle the missing case
// by checking the MISSING_WARNED set to avoid redundant work.
KeyframeAnimation cached = CACHE.get(cacheKey);
if (cached != null) {
return cached;
}
if (MISSING_WARNED.contains(cacheKey)) {
return null;
}
KeyframeAnimation result = buildContextAnimation(context, disabledParts);
if (result != null) {
CACHE.put(cacheKey, result);
} else {
MISSING_WARNED.add(cacheKey);
}
return result;
}
/**
* Build a context animation with the specified parts disabled.
*
* <p>Flow:
* <ol>
* <li>Check {@link ContextGlbRegistry} for a GLB-based context animation (takes priority)</li>
* <li>Fall back to {@code tiedup:context_<suffix>} in PlayerAnimationRegistry (JSON-based)</li>
* <li>If no parts need disabling, return the base animation directly (immutable, shared)</li>
* <li>Otherwise, create a mutable copy via {@link KeyframeAnimation#mutableCopy()}</li>
* <li>Disable each part via {@link KeyframeAnimation.StateCollection#setEnabled(boolean)}</li>
* <li>Build and return the new immutable animation</li>
* </ol>
*/
@Nullable
private static KeyframeAnimation buildContextAnimation(AnimationContext context,
Set<String> disabledParts) {
String suffix = context.getAnimationSuffix();
// Priority 1: GLB-based context animation from ContextGlbRegistry
KeyframeAnimation baseAnim = ContextGlbRegistry.get(suffix);
// Priority 2: JSON-based context animation from PlayerAnimationRegistry
if (baseAnim == null) {
ResourceLocation animId = ResourceLocation.fromNamespaceAndPath(
NAMESPACE, "context_" + suffix
);
baseAnim = PlayerAnimationRegistry.getAnimation(animId);
}
if (baseAnim == null) {
LOGGER.warn("[V2Animation] Context animation not found for suffix: {}", suffix);
return null;
}
if (disabledParts.isEmpty()) {
return baseAnim;
}
// Create mutable copy so we can disable parts without affecting the registry/cache original
KeyframeAnimation.AnimationBuilder builder = baseAnim.mutableCopy();
disableParts(builder, disabledParts);
return builder.build();
}
/**
* Disable all animation axes on the specified parts.
*
* <p>Uses {@link KeyframeAnimation.AnimationBuilder#getPart(String)} to look up parts
* by name, then {@link KeyframeAnimation.StateCollection#setEnabled(boolean)} to disable
* all axes (x, y, z, pitch, yaw, roll, and bend/bendDirection if applicable).</p>
*
* <p>Unknown part names are silently ignored -- this can happen if the disabled parts set
* includes future bone names not present in the current context animation.</p>
*/
private static void disableParts(KeyframeAnimation.AnimationBuilder builder,
Set<String> disabledParts) {
for (String partName : disabledParts) {
KeyframeAnimation.StateCollection part = builder.getPart(partName);
if (part != null) {
part.setEnabled(false);
}
}
}
/**
* Clear all cached animations. Call this on resource reload or when equipped items change
* in a way that might invalidate cached part ownership.
*/
public static void clearCache() {
CACHE.clear();
MISSING_WARNED.clear();
}
}

View File

@@ -0,0 +1,121 @@
package com.tiedup.remake.client.animation.context;
import com.tiedup.remake.client.gltf.GlbParser;
import com.tiedup.remake.client.gltf.GltfData;
import com.tiedup.remake.client.gltf.GltfPoseConverter;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import java.io.InputStream;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Registry for context animations loaded from GLB files.
*
* <p>Scans the {@code tiedup_contexts/} resource directory for {@code .glb} files,
* parses each one via {@link GlbParser}, converts to a {@link KeyframeAnimation}
* via {@link GltfPoseConverter#convert(GltfData)}, and stores the result keyed by
* the file name suffix (e.g., {@code "stand_walk"} from {@code tiedup_contexts/stand_walk.glb}).</p>
*
* <p>GLB context animations take priority over JSON-based PlayerAnimator context
* animations. This allows artists to author posture animations directly in Blender
* instead of hand-editing JSON keyframes.</p>
*
* <p>Reloaded on resource pack reload (F3+T) via the listener registered in
* {@link com.tiedup.remake.client.gltf.GltfClientSetup}.</p>
*
* <p>Thread safety: the registry field is a volatile reference to an unmodifiable map.
* {@link #reload} builds a new map on the reload thread then atomically swaps the
* reference, so the render thread never sees a partially populated registry.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class ContextGlbRegistry {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
/** Resource directory containing context GLB files. */
private static final String DIRECTORY = "tiedup_contexts";
/**
* Registry keyed by context suffix (e.g., "stand_walk", "sit_idle").
* Values are fully converted KeyframeAnimations with all parts enabled.
*
* <p>Volatile reference to an unmodifiable map. Reload builds a new map
* and swaps atomically; the render thread always sees a consistent snapshot.</p>
*/
private static volatile Map<String, KeyframeAnimation> REGISTRY = Map.of();
private ContextGlbRegistry() {}
/**
* Reload all context GLB files from the resource manager.
*
* <p>Scans {@code assets/<namespace>/tiedup_contexts/} for {@code .glb} files.
* Each file is parsed and converted to a full-body KeyframeAnimation.
* The context suffix is extracted from the file path:
* {@code tiedup_contexts/stand_walk.glb} becomes key {@code "stand_walk"}.</p>
*
* <p>GLB files without animation data or with parse errors are logged and skipped.</p>
*
* @param resourceManager the current resource manager (from reload listener)
*/
public static void reload(ResourceManager resourceManager) {
Map<String, KeyframeAnimation> newRegistry = new HashMap<>();
Map<ResourceLocation, Resource> resources = resourceManager.listResources(
DIRECTORY, loc -> loc.getPath().endsWith(".glb"));
for (Map.Entry<ResourceLocation, Resource> entry : resources.entrySet()) {
ResourceLocation loc = entry.getKey();
Resource resource = entry.getValue();
// Extract suffix from path: "tiedup_contexts/stand_walk.glb" -> "stand_walk"
String path = loc.getPath();
String fileName = path.substring(path.lastIndexOf('/') + 1);
String suffix = fileName.substring(0, fileName.length() - 4); // strip ".glb"
try (InputStream is = resource.open()) {
GltfData data = GlbParser.parse(is, loc.toString());
// Convert to a full-body KeyframeAnimation (all parts enabled)
KeyframeAnimation anim = GltfPoseConverter.convert(data);
newRegistry.put(suffix, anim);
LOGGER.info("[GltfPipeline] Loaded context GLB: '{}' -> suffix '{}'", loc, suffix);
} catch (Exception e) {
LOGGER.error("[GltfPipeline] Failed to load context GLB: {}", loc, e);
}
}
// Atomic swap: render thread never sees a partially populated registry
REGISTRY = Collections.unmodifiableMap(newRegistry);
LOGGER.info("[ContextGlb] Loaded {} context GLB animations", newRegistry.size());
}
/**
* Get a context animation by suffix.
*
* @param contextSuffix the context suffix (e.g., "stand_walk", "sit_idle")
* @return the KeyframeAnimation, or null if no GLB was found for this suffix
*/
@Nullable
public static KeyframeAnimation get(String contextSuffix) {
return REGISTRY.get(contextSuffix);
}
/**
* Clear all cached context animations.
* Called on resource reload and world unload.
*/
public static void clear() {
REGISTRY = Map.of();
}
}

View File

@@ -0,0 +1,151 @@
package com.tiedup.remake.client.animation.context;
import com.tiedup.remake.client.gltf.GltfCache;
import com.tiedup.remake.client.gltf.GltfData;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Resolves which named animation to play from a GLB file based on the current
* {@link AnimationContext}. Implements three features:
*
* <ol>
* <li><b>Context-based resolution with fallback chain</b> — tries progressively
* less specific animation names until one is found:
* <pre>SitStruggle -> Struggle -> SitIdle -> Sit -> Idle -> null</pre></li>
* <li><b>Animation variants</b> — if {@code Struggle.1}, {@code Struggle.2},
* {@code Struggle.3} exist in the GLB, one is picked at random each time</li>
* <li><b>Shared animation templates</b> — animations can come from a separate GLB
* file (passed as {@code animationSource} to {@link #resolveAnimationData})</li>
* </ol>
*
* <p>This class is stateless and thread-safe. All methods are static.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class GlbAnimationResolver {
private GlbAnimationResolver() {}
/**
* Resolve the animation data source.
* If {@code animationSource} is non-null, load that GLB for animations
* (shared template). Otherwise use the item's own model GLB.
*
* @param itemModelLoc the item's GLB model resource location
* @param animationSource optional separate GLB containing shared animations
* @return parsed GLB data, or null if loading failed
*/
@Nullable
public static GltfData resolveAnimationData(ResourceLocation itemModelLoc,
@Nullable ResourceLocation animationSource) {
ResourceLocation source = animationSource != null ? animationSource : itemModelLoc;
return GltfCache.get(source);
}
/**
* Resolve the best animation name from a GLB for the given context.
* Supports variant selection ({@code Struggle.1}, {@code Struggle.2} -> random pick)
* and full-body animations ({@code FullWalk}, {@code FullStruggle}).
*
* <p>Fallback chain (Full variants checked first at each step):</p>
* <pre>
* FullSitStruggle -> SitStruggle -> FullStruggle -> Struggle
* -> FullSitIdle -> SitIdle -> FullSit -> Sit
* -> FullIdle -> Idle -> null
* </pre>
*
* @param data the parsed GLB data containing named animations
* @param context the current animation context (posture + action)
* @return the animation name to use, or null to use the default (first) clip
*/
@Nullable
public static String resolve(GltfData data, AnimationContext context) {
String prefix = context.getGlbContextPrefix(); // "Sit", "Kneel", "Sneak", "Walk", ""
String variant = context.getGlbVariant(); // "Idle" or "Struggle"
// 1. Exact match: "FullSitIdle" then "SitIdle" (with variants)
String exact = prefix + variant;
if (!exact.isEmpty()) {
String picked = pickWithVariants(data, "Full" + exact);
if (picked != null) return picked;
picked = pickWithVariants(data, exact);
if (picked != null) return picked;
}
// 2. For struggles: try "FullStruggle" then "Struggle" (with variants)
if (context.isStruggling()) {
String picked = pickWithVariants(data, "FullStruggle");
if (picked != null) return picked;
picked = pickWithVariants(data, "Struggle");
if (picked != null) return picked;
}
// 3. Context-only: "FullSit" then "Sit" (with variants)
if (!prefix.isEmpty()) {
String picked = pickWithVariants(data, "Full" + prefix);
if (picked != null) return picked;
picked = pickWithVariants(data, prefix);
if (picked != null) return picked;
}
// 4. Variant-only: "FullIdle" then "Idle" (with variants)
{
String picked = pickWithVariants(data, "Full" + variant);
if (picked != null) return picked;
picked = pickWithVariants(data, variant);
if (picked != null) return picked;
}
// 5. Default: return null = use first animation clip in GLB
return null;
}
/**
* Look for an animation by base name, including numbered variants.
* <ul>
* <li>If "Struggle" exists alone, return "Struggle"</li>
* <li>If "Struggle.1" and "Struggle.2" exist, pick one randomly</li>
* <li>If both "Struggle" and "Struggle.1" exist, include all in the random pool</li>
* </ul>
*
* <p>Variant numbering starts at 1 and tolerates a missing {@code .1}
* (continues to check {@code .2}). Gaps after index 1 stop the scan.
* For example, {@code Struggle.1, Struggle.3} would only find
* {@code Struggle.1} because the gap at index 2 stops iteration.
* However, if only {@code Struggle.2} exists (no {@code .1}), it will
* still be found because the scan skips the first gap.</p>
*
* @param data the parsed GLB data
* @param baseName the base animation name (e.g., "Struggle", "SitIdle")
* @return the selected animation name, or null if no match found
*/
@Nullable
private static String pickWithVariants(GltfData data, String baseName) {
Map<String, GltfData.AnimationClip> anims = data.namedAnimations();
List<String> candidates = new ArrayList<>();
if (anims.containsKey(baseName)) {
candidates.add(baseName);
}
// Check numbered variants: baseName.1, baseName.2, ...
for (int i = 1; i <= 99; i++) {
String variantName = baseName + "." + i;
if (anims.containsKey(variantName)) {
candidates.add(variantName);
} else if (i > 1) {
break; // Stop at first gap after .1
}
}
if (candidates.isEmpty()) return null;
if (candidates.size() == 1) return candidates.get(0);
return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size()));
}
}

View File

@@ -0,0 +1,344 @@
package com.tiedup.remake.client.animation.context;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import java.util.*;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Maps V2 body regions to PlayerAnimator part names.
* Bridge between gameplay regions and animation bones.
*
* <p>PlayerAnimator uses 6 named parts: head, body, rightArm, leftArm, rightLeg, leftLeg.
* This mapper translates the 14 {@link BodyRegionV2} gameplay regions into those bone names,
* enabling the animation system to know which bones are "owned" by equipped bondage items.</p>
*
* <p>Regions without a direct bone mapping (NECK, FINGERS, TAIL, WINGS) return empty sets.
* These regions still affect gameplay (blocking, escape difficulty) but don't directly
* constrain animation bones.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class RegionBoneMapper {
/** All PlayerAnimator part names for the player model. */
public static final Set<String> ALL_PARTS = Set.of(
"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"
);
/**
* Describes bone ownership for a specific item in the context of all equipped items.
*
* <ul>
* <li>{@code thisParts} — parts owned exclusively by the winning item</li>
* <li>{@code otherParts} — parts owned by other equipped items</li>
* <li>{@link #freeParts()} — parts not owned by any item (available for animation)</li>
* <li>{@link #enabledParts()} — parts the winning item may animate (owned + free)</li>
* </ul>
*
* <p>When both the winning item and another item claim the same bone,
* the other item takes precedence (the bone goes to {@code otherParts}).</p>
*/
public record BoneOwnership(Set<String> thisParts, Set<String> otherParts) {
/**
* Parts not owned by any item. These are "free" and can be animated
* by the winning item IF the GLB contains keyframes for them.
*/
public Set<String> freeParts() {
Set<String> free = new HashSet<>(ALL_PARTS);
free.removeAll(thisParts);
free.removeAll(otherParts);
return Collections.unmodifiableSet(free);
}
/**
* Parts the winning item is allowed to animate: its own parts + free parts.
* Free parts are only actually enabled if the GLB has keyframes for them.
*/
public Set<String> enabledParts() {
Set<String> enabled = new HashSet<>(thisParts);
enabled.addAll(freeParts());
return Collections.unmodifiableSet(enabled);
}
/**
* Parts that must be disabled on the context layer: parts owned by this item
* (handled by item layer) + parts owned by other items (handled by their layer).
* This equals ALL_PARTS minus freeParts.
*/
public Set<String> disabledOnContext() {
Set<String> disabled = new HashSet<>(thisParts);
disabled.addAll(otherParts);
return Collections.unmodifiableSet(disabled);
}
}
private static final Map<BodyRegionV2, Set<String>> REGION_TO_PARTS;
static {
Map<BodyRegionV2, Set<String>> map = new EnumMap<>(BodyRegionV2.class);
map.put(BodyRegionV2.HEAD, Set.of("head"));
map.put(BodyRegionV2.EYES, Set.of("head"));
map.put(BodyRegionV2.EARS, Set.of("head"));
map.put(BodyRegionV2.MOUTH, Set.of("head"));
map.put(BodyRegionV2.NECK, Set.of());
map.put(BodyRegionV2.TORSO, Set.of("body"));
map.put(BodyRegionV2.ARMS, Set.of("rightArm", "leftArm"));
map.put(BodyRegionV2.HANDS, Set.of("rightArm", "leftArm"));
map.put(BodyRegionV2.FINGERS, Set.of());
map.put(BodyRegionV2.WAIST, Set.of("body"));
map.put(BodyRegionV2.LEGS, Set.of("rightLeg", "leftLeg"));
map.put(BodyRegionV2.FEET, Set.of("rightLeg", "leftLeg"));
map.put(BodyRegionV2.TAIL, Set.of());
map.put(BodyRegionV2.WINGS, Set.of());
REGION_TO_PARTS = Collections.unmodifiableMap(map);
}
private RegionBoneMapper() {}
/**
* Get the PlayerAnimator part names affected by a single body region.
*
* @param region the V2 body region
* @return unmodifiable set of part name strings, never null (may be empty)
*/
public static Set<String> getPartsForRegion(BodyRegionV2 region) {
return REGION_TO_PARTS.getOrDefault(region, Set.of());
}
/**
* Compute the union of all PlayerAnimator parts "owned" by equipped bondage items.
*
* <p>Iterates over the equipped map (as returned by
* {@link com.tiedup.remake.v2.bondage.IV2BondageEquipment#getAllEquipped()})
* and collects every bone affected by each item's occupied regions.</p>
*
* @param equipped map from representative region to equipped ItemStack
* @return unmodifiable set of owned part name strings
*/
public static Set<String> computeOwnedParts(Map<BodyRegionV2, ItemStack> equipped) {
Set<String> owned = new HashSet<>();
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem v2Item) {
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
owned.addAll(getPartsForRegion(region));
}
}
}
return Collections.unmodifiableSet(owned);
}
/**
* Compute per-item bone ownership for a specific "winning" item.
*
* <p>Iterates over all equipped items. Parts owned by the winning item
* go to {@code thisParts}; parts owned by other items go to {@code otherParts}.
* If both the winning item and another item claim the same bone, the other
* item takes precedence (conflict resolution: other wins).</p>
*
* <p>Uses ItemStack reference equality ({@code ==}) to identify the winning item
* because the same ItemStack instance is used in the equipped map.</p>
*
* @param equipped map from representative region to equipped ItemStack
* @param winningItemStack the ItemStack of the highest-priority V2 item with a GLB model
* @return BoneOwnership describing this item's parts vs other items' parts
*/
public static BoneOwnership computePerItemParts(Map<BodyRegionV2, ItemStack> equipped,
ItemStack winningItemStack) {
Set<String> thisParts = new HashSet<>();
Set<String> otherParts = new HashSet<>();
// Track which ItemStacks we've already processed to avoid duplicate work
// (multiple regions can map to the same ItemStack)
Set<ItemStack> processed = Collections.newSetFromMap(new IdentityHashMap<>());
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue();
if (processed.contains(stack)) continue;
processed.add(stack);
if (stack.getItem() instanceof IV2BondageItem v2Item) {
Set<String> itemParts = new HashSet<>();
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
itemParts.addAll(getPartsForRegion(region));
}
if (stack == winningItemStack) {
thisParts.addAll(itemParts);
} else {
otherParts.addAll(itemParts);
}
}
}
// Conflict resolution: if both this item and another claim the same bone,
// the other item takes precedence
thisParts.removeAll(otherParts);
return new BoneOwnership(
Collections.unmodifiableSet(thisParts),
Collections.unmodifiableSet(otherParts)
);
}
/**
* Result of resolving the highest-priority V2 item with a GLB model.
* Combines the model location, optional animation source, and the winning ItemStack
* into a single object so callers don't need two separate iteration passes.
*
* @param modelLoc the GLB model ResourceLocation of the winning item
* @param animSource separate GLB for animations (shared template), or null to use modelLoc
* @param winningItem the actual ItemStack reference (for identity comparison in
* {@link #computePerItemParts})
*/
public record GlbModelResult(ResourceLocation modelLoc, @Nullable ResourceLocation animSource,
ItemStack winningItem) {}
/**
* Animation info for a single equipped V2 item.
* Used by the multi-item animation pipeline to process each item independently.
*
* @param modelLoc GLB model location (for rendering + default animation source)
* @param animSource separate animation GLB, or null to use modelLoc
* @param ownedParts parts this item exclusively owns (after conflict resolution)
* @param posePriority the item's pose priority (for free-bone assignment)
* @param animationBones per-animation bone whitelist from the data-driven definition.
* Empty map for hardcoded items (no filtering applied).
*/
public record V2ItemAnimInfo(ResourceLocation modelLoc, @Nullable ResourceLocation animSource,
Set<String> ownedParts, int posePriority,
Map<String, Set<String>> animationBones) {}
/**
* Find the highest-priority V2 item with a GLB model in the equipped map.
*
* <p>Single pass over all equipped items, comparing their
* {@link IV2BondageItem#getPosePriority()} to select the dominant model.
* Returns both the model location and the winning ItemStack reference so
* callers can pass the ItemStack to {@link #computePerItemParts} without
* a second iteration.</p>
*
* @param equipped map of equipped V2 items by body region (may be empty, never null)
* @return the winning item's model info, or null if no V2 item has a GLB model (V1 fallback)
*/
@Nullable
public static GlbModelResult resolveWinningItem(Map<BodyRegionV2, ItemStack> equipped) {
ItemStack bestStack = null;
ResourceLocation bestModel = null;
int bestPriority = Integer.MIN_VALUE;
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem v2Item) {
ResourceLocation model = v2Item.getModelLocation(stack);
if (model != null && v2Item.getPosePriority(stack) > bestPriority) {
bestPriority = v2Item.getPosePriority(stack);
bestModel = model;
bestStack = stack;
}
}
}
if (bestStack == null || bestModel == null) return null;
// Extract animation source from data-driven item definitions.
// For hardcoded IV2BondageItem implementations, animSource stays null
// (the model's own animations are used).
ResourceLocation animSource = null;
if (bestStack.getItem() instanceof DataDrivenBondageItem) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(bestStack);
if (def != null) {
animSource = def.animationSource();
}
}
return new GlbModelResult(bestModel, animSource, bestStack);
}
/**
* Resolve ALL equipped V2 items with GLB models, with per-item bone ownership.
*
* <p>Each item gets ownership of its declared regions' bones. When two items claim
* the same bone, the higher-priority item wins. The highest-priority item is also
* designated as the "free bone donor" — it can animate free bones if its GLB has
* keyframes for them.</p>
*
* @param equipped map from representative region to equipped ItemStack
* @return list of V2ItemAnimInfo, sorted by priority descending. Empty if no V2 items.
* The first element (if any) is the free-bone donor.
*/
public static List<V2ItemAnimInfo> resolveAllV2Items(Map<BodyRegionV2, ItemStack> equipped) {
record ItemEntry(ItemStack stack, IV2BondageItem v2Item, ResourceLocation model,
@Nullable ResourceLocation animSource, Set<String> rawParts, int priority,
Map<String, Set<String>> animationBones) {}
List<ItemEntry> entries = new ArrayList<>();
Set<ItemStack> seen = Collections.newSetFromMap(new IdentityHashMap<>());
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue();
if (seen.contains(stack)) continue;
seen.add(stack);
if (stack.getItem() instanceof IV2BondageItem v2Item) {
ResourceLocation model = v2Item.getModelLocation(stack);
if (model == null) continue;
Set<String> rawParts = new HashSet<>();
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
rawParts.addAll(getPartsForRegion(region));
}
if (rawParts.isEmpty()) continue;
ResourceLocation animSource = null;
Map<String, Set<String>> animBones = Map.of();
if (stack.getItem() instanceof DataDrivenBondageItem) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def != null) {
animSource = def.animationSource();
animBones = def.animationBones();
}
}
entries.add(new ItemEntry(stack, v2Item, model, animSource, rawParts,
v2Item.getPosePriority(stack), animBones));
}
}
if (entries.isEmpty()) return List.of();
entries.sort((a, b) -> Integer.compare(b.priority(), a.priority()));
Set<String> claimed = new HashSet<>();
List<V2ItemAnimInfo> result = new ArrayList<>();
for (ItemEntry e : entries) {
Set<String> ownedParts = new HashSet<>(e.rawParts());
ownedParts.removeAll(claimed);
if (ownedParts.isEmpty()) continue;
claimed.addAll(ownedParts);
result.add(new V2ItemAnimInfo(e.model(), e.animSource(),
Collections.unmodifiableSet(ownedParts), e.priority(), e.animationBones()));
}
return Collections.unmodifiableList(result);
}
/**
* Compute the set of all bone parts owned by any item in the resolved list.
* Used to disable owned parts on the context layer.
*/
public static Set<String> computeAllOwnedParts(List<V2ItemAnimInfo> items) {
Set<String> allOwned = new HashSet<>();
for (V2ItemAnimInfo item : items) {
allOwned.addAll(item.ownedParts());
}
return Collections.unmodifiableSet(allOwned);
}
}