73 Commits

Author SHA1 Message Date
notevil
23b249dcd2 Fix SMELL-API-01 + log levels : dispatchCodec API hygiene + INFO armature loaded
Reviewer P3 review convergent findings (LOW severity).

SMELL-API-01 : CodecDispatchRegistry.dispatchCodec() is public (legitimate
for cross-package interface CODEC fields) but consumers might inadvertently
call it twice and reason about identity. Added @apiNote stating canonical
single-call usage + memoization to guarantee idempotent identity.

ArmatureReloadListener log level : promoted 'Registered armature: X' from
DEBUG to INFO. Datapack-loaded custom armatures are visible at default log
level — gameday smoke test for D6 wiring. Added summary line
'Datapack armature reload : N custom armatures registered (builtin BIPED active)'
at end of apply().
2026-04-27 00:41:28 +02:00
notevil
25a9251959 Fix SMELL-CODEC-01 : extract CodecDispatchRegistry<T> base
Reviewer P3 review convergent finding (LOW severity).

4 dispatch registries (AnimationActionRegistry, PoseModifierRegistry,
PlaybackSpeedModifierRegistry, PlaybackTimeModifierRegistry) had
structurally identical implementations — register/getCodec/types/
dispatchCodec + dedup IAE — ~50 LOC each, ~200 LOC total duplicated.

Extract abstract CodecDispatchRegistry<T> base. Each subclass becomes
a singleton with just static init + registryName() override. Future
registries (Phase 4 conditional actions, etc.) inherit the contract
trivially.

Net : -150 LOC (200 dup → 50 base + 4×~10 subclass scaffolding).
2026-04-27 00:36:00 +02:00
notevil
d90ff14668 RISK-003 self-heal coverage : extract shouldRebindIdle helper + tests
Le reviewer flagait une race entre PlayerPatch.initAnimator (bind IDLE
au capability attach time) et AnimationManager.apply (datapack reload).
En investigant : la race existe architecturalement mais le self-heal
est deja en place via maybePlayIdle — quand resolveWithFallback retourne
EMPTY pre-apply, le bind EMPTY est mis dans la map (EMPTY_ANIMATION a
isPresent()==true donc passe checkNull). Au tick suivant, une fois
apply tourne et le registry populé, maybePlayIdle detecte
currentIdleBind == EMPTY et rebind sur la vraie anim.

Le bind EMPTY est intentionnel : ClientAnimator.postInit appelle
playAnimationInstantly(livingAnimations.get(IDLE)) immediatement, et
skipper le bind crasherait postInit (NPE sur null.get()). EMPTY a une
pose identity (no-op visuel) → bootstrap propre, self-heal post-reload.

Le symptome runtime "F6 bindings: 1 mais idle silent" decrit dans la
review vient d'un cause differente : AnimationManager n'est pas encore
register comme reload listener (Phase 2 wiring pending, tracked sous
BUG-RACE-01 dans AnimationManager.apply javadoc). Une fois cable, le
self-heal fonctionne — verrouille par les 5 nouveaux tests.

Changements :
- RigAnimationTickHandler : extract shouldRebindIdle(currentBind, target)
  pure helper package-private. La logique inline dans maybePlayIdle
  reste identique, juste deleguee au helper pour testabilite.
- PlayerPatch.initAnimator : javadoc enrichi (race + raison du bind
  EMPTY + reference test coverage).
- RigAnimationTickHandlerTest : +5 cases sur shouldRebindIdle (null+real,
  empty+real RACE, real+real, two real instances, *+empty).

Tests : 459 → 464 (+5), tous green.
2026-04-27 00:20:01 +02:00
notevil
7c994a9ffa Fix SMELL-LOG-01 : dedup SpawnParticleAction joint-not-found WARN
Reviewer P3 review convergent finding (MEDIUM severity).

Period events fire at 20 Hz across the period duration. A typo in
'at: "wrongJoint"' would log WARN every tick × duration — log flood
that pollutes latest.log and masks real issues.

Fix : ConcurrentHashMap.newKeySet() keyed by armature+joint, log WARN
only once per unique miss. Reset hook wired into
TiedUpRigRegistryReloadListener.apply() so /reload re-enables warn (in
case modder fixes their JSON between reloads).

Pattern identical to TiedUpAnimationRegistry.WARNED_MISSING_ANIMS.
2026-04-27 00:09:19 +02:00
notevil
9171ff0def Fix RISK-001 : PlaySoundAction SoundSource codec via comapFlatMap
Reviewer P3 review convergent finding (HIGH severity).

Codec.xmap doesn't wrap IllegalArgumentException in DataResult.error.
A typo like 'category: ambiant' (instead of 'ambient') would propagate
the IAE up to AnimationManager.apply which catches it but silently
skips the entire animation without clear field-level error.

Fix : Codec.STRING.comapFlatMap with explicit try/catch + descriptive
error message listing valid SoundSource values. Helps modders debug
typos at parse-time instead of mysterious silent skips.

Two surprises during implementation that required deeper changes :

1. SoundSource.name() != getName() — RECORDS/BLOCKS/PLAYERS have
   getName() == record/block/player (singular). The previous encoder
   used getName().toUpperCase() which produced 'BLOCK' but the decoder
   needed 'BLOCKS'. The new codec is roundtrip-safe via getName() on
   both sides.

2. DFU 6.0.8 OptionalFieldCodec.decode is lenient by default — a
   present-but-malformed field is silently mapped to Optional.empty()
   instead of propagating DataResult.error. The strict 'lenient=false'
   flag was added in a later DFU release. To surface the error at
   parse-time the optional category field is now decoded as
   Codec.STRING.optionalFieldOf().flatXmap(parseSoundSource), which
   propagates errors correctly.

+4 tests : invalid SoundSource returns error, uppercase input accepted,
error message lists valid values, encode/roundtrip uses lowercase
getName form.
2026-04-27 00:03:08 +02:00
notevil
fdf7330523 BUG-RACE-01 : document Phase 2 listener registration ordering
Reviewer flagged race between AnimationManager parsing (LIVING_MOTION_CODEC.getOrThrow)
and LivingMotionReloadListener.apply(). Investigation confirmed :
- AnimationManager is NOT currently registered as a reload listener (Phase 2 pending)
- Forge 1.20.1 has no PreparableReloadListener.getDependencies() — that's a Fabric/NeoForge API
- 1.20.1 ordering mechanism = registration list order via SimpleReloadInstance.barrier.wait()

Fix : preventive comment in AnimationManager.apply() documenting that
when Phase 2 wires this listener, it MUST be registered AFTER
LivingMotionReloadListener / ArmatureReloadListener / PoseTypeReloadListener
in TiedUpMod.ForgeEvents.onAddReloadListeners + V2ClientSetup.onRegisterReloadListeners.

Drive-by : add docs.tar.gz to .gitignore (artist export bundle).

No actionable race today — pure documentation hardening.
2026-04-26 22:07:18 +02:00
notevil
227cf5f346 D1 remaining property codecs : bondage-relevant subset serialized
Audit of 29 remaining properties without name+codec :
- Category A (trivial data types) : 7 properties — direct Codec added
  (fixed_head_rotation, reset_living_motion, no_gravity_time, move_time,
  coord_update_time, coord_start_keyframe_index, coord_dest_keyframe_index).
  Two new shared codecs : LIVING_MOTION_CODEC (ExtendableEnum string lookup)
  and TIME_PAIR_LIST_CODEC (even-length float list, odd-count = codec error).
- Category B (functional/lambda) : 3 properties — dispatch registry pattern
  (like D2 AnimationAction) with base impls for immediate artist use.
  pose_modifier, play_speed_modifier, elapsed_time_modifier. The property
  static type stays the wider functional interface for backward compat with
  existing consumers (AnimationPlayer.tick, StaticAnimation.modifyPose) via
  a subinterface + xmap upcast.
- Category C (combat-only, EF legacy stripped) : 14 properties skipped with
  explicit TODO markers referencing V3-REW-11. Covers all 4 AttackPhase
  properties (their super-constructor is already commented out) plus
  ON_ITEM_CHANGE_EVENT, COORD, COORD_SET_{BEGIN,TICK}, COORD_GET,
  DEST_LOCATION_PROVIDER, ENTITY_YROT_PROVIDER, DEST_COORD_YROT_PROVIDER.
- Category D (already sub-file parsed / baked at load) : 4 properties
  skipped to avoid duplication : TRANSITION_ANIMATIONS_FROM/TO (need
  AnimationAccessor resolution, chicken-and-egg), IK_DEFINITION,
  BAKED_IK_DEFINITION.

After this commit : ~31 / 47 properties serializable (baseline 21 pre-D1).
Non-serialized remainder is combat-legacy or intentionally sub-file-only.

Registries created :
- PoseModifierRegistry (3 impls : joint_rotation_offset,
  joint_translation_offset, chain)
- PlaybackSpeedModifierRegistry (2 impls : constant_factor, linear_ramp)
- PlaybackTimeModifierRegistry (1 impl : loop_section)

Design note : ChainedPoseModifier.CODEC uses a hand-rolled Codec.of(...)
with inlined Encoder/Decoder bodies rather than RecordCodecBuilder, to
break a static-init cycle between the chain codec and the pose modifier
dispatch codec. Mojang DFU 6.0.8 has no Codec.recursive() — a lazy closure
inside encode/decode is the cleanest workaround.

Artist impact : locomotion tweaks (pose_modifier, play_speed_modifier,
elapsed_time_modifier, reset_living_motion, no_gravity_time and the coord
keyframe indexes) now controllable from JSON. Per-joint rotation /
translation nudges unlock bondage pose constraints without recompiling.

Tests : +41 (411 → 452 GREEN).
2026-04-24 23:48:34 +02:00
notevil
86d35c4b5d D2 Animation events data-driven : actions registry + JSON codecs
Biggest artist unlock Phase 3 — modders can now trigger actions at
animation begin/end/time-frame from JSON datapack, zero Java needed.

Infrastructure :
- AnimationAction interface + dispatch codec via 'type' field
- AnimationActionRegistry with 4 core actions registered at init
- SerializedEvent records (simple/time/period) with bidirectional
  adapters to runtime SimpleEvent/InTimeEvent/InPeriodEvent
- ON_BEGIN_EVENTS / ON_END_EVENTS / TICK_EVENTS now serializable
  via name+codec (previously threw IllegalStateException)

4 core actions :
- play_sound (sound, volume, pitch, category)
- spawn_particle (particle, at joint, count, speed, offset_xyz)
- apply_effect (effect, duration_ticks, amplifier, ambient, show_*)
- damage_entity (amount, source: 15 vanilla source whitelist)

Out of scope for this commit (follow-up) :
- set_animation_variable (coupled with D1 properties)
- swap_item_visibility (needs render layer integration)
- Conditional actions ('when': ...)
- Joint-world-position resolution for spawn_particle 'at' field
  (needs partialTick plumb through AnimationAction.execute)
2026-04-24 21:20:31 +02:00
notevil
a7a1c774f7 D6 Custom armatures via datapack
Modders can now define custom armatures (quadruped, centaur, neko, etc.)
via data/<ns>/tiedup/armatures/*.json — zero Java code required.

Format :
- root_joint : name of root joint
- joints : map<name, JointDefinition> with id, parent, translation,
  rotation quaternion (xyzw), children array

Validation enforces :
- unique joint IDs, contiguous from 0
- all children/parent references exist in the map
- DAG structure (no cycles, no duplicate reachability)
- bidirectional parent/child coherence (A.children lists B <=> B.parent == A)
- max 128 joints (MAX_JOINTS limit)
- exactly one root (the declared root_joint has parent = null, all others
  have a non-null parent)

TiedUpArmatures.get() now delegates to ArmatureReloadListener for
unknown IDs — builtin BIPED remains hardcoded for performance +
VanillaModelTransformer compat. InstantiateInvoker.getArmature()
automatically resolves datapack armatures via the same path, no change
needed there.

Listener registered server-side via AddReloadListenerEvent and
client-side via RegisterClientReloadListenersEvent (same pattern as
LivingMotionReloadListener).

Tests : 21 new tests (13 for ArmatureDefinition validation + runtime
conversion, 8 for the reload listener + TiedUpArmatures delegation).
342 -> 363 GREEN.
2026-04-24 15:16:25 +02:00
notevil
c9d5271102 Wave B data-driven : LivingMotion + PoseType datapack extensions
D4 — LivingMotion custom via datapack :
- DataDrivenLivingMotion implements LivingMotion interface
- LivingMotionReloadListener scans data/<ns>/tiedup/living_motions/*.json
- Stable ordinals cross-reload via PERSISTENT_REGISTRY map
- Parser resolveMotionByName now falls back to registry lookup
- Modders can add custom LivingMotions purely via JSON

D11 — PoseType registry additive :
- PoseTypeRegistry maps canonical IDs to builtin enum values
- DataDrivenPoseType allows datapack extensions without touching
  the 17 V1 legacy call-sites (MixinCamera, DogPoseRenderHandler, etc.)
- PoseTypeReloadListener scans data/<ns>/tiedup/pose_types/*.json
- Builtin enum semantics preserved — modder custom types coexist

Artist impact : new LivingMotions + pose types addable without any
Java code. Phase 3 pipeline fully consumes both paths.
2026-04-24 14:42:58 +02:00
notevil
76587c0393 Wave A data-driven : JSON-serializable properties + equip_sound + metadata
Unlocks 3 artist-freedom quick wins :

1. ClientAnimationProperties.LAYER_TYPE / PRIORITY / JOINT_MASK now
   serializable via name + Codec. Modders can override these in their
   anim JSON 'properties' block (previously threw IllegalStateException).

2. AnimationBindings.equipSound / unequipSound — per-item JSON override
   for on_equip/on_unequip audio feedback. ClientRigEquipmentHandler
   resolves via ForgeRegistries.SOUND_EVENTS, falls back to vanilla
   ARMOR_EQUIP_LEATHER if unknown.

3. AnimationBindings.restraintLevel / tooltipOverride — metadata fields
   for UX consumers (HUD severity indicator, tooltip customization).
   Parsed but not yet rendered — available for Phase 4 features without
   schema change.

All additive. Existing items without these fields work unchanged.
2026-04-24 14:02:02 +02:00
notevil
ed0fb49792 Full data-driven : migrate CONTEXT_STAND_IDLE to datapack path
Remove the last Java-hardcoded StaticAnimation registration. context_stand_idle.json
now has the EF-native 'constructor' block so AnimationManager picks it up at
datapack reload, same as the 5 Phase 3 placeholders.

Consumers (PlayerPatch, LivingEntityPatch, RigAnimationTickHandler, TiedUpMod,
tests) refactored to lookup via resolveWithFallback(CONTEXT_STAND_IDLE_ID)
instead of direct static field access. resolveWithFallback handles the pre-
reload window by returning EMPTY_ANIMATION — no more null-checks in consumers.

TiedUpAnimationRegistry keeps only :
- CONTEXT_STAND_IDLE_ID constant (canonical ID)
- resolveWithFallback() helper
- WARNED_MISSING_ANIMS dedup + reset hook

Design goal : zero Java code required for any modder to add bondage anims.
Drop a JSON in assets/<modid>/animmodels/animations/, reference it from an
item JSON binding, /reload, see it fire.
2026-04-24 13:27:45 +02:00
notevil
a0b6ac5b04 Implement datapack armature registry — enable auto-register of anim JSONs
Replace InstantiateInvoker.getArmature() throw with real lookup in
TiedUpArmatures registry. This unblocks the datapack auto-register
path for anim JSONs placed in assets/tiedup/animmodels/animations/.

5 Phase 3 placeholder JSONs updated with EF-native 'constructor' block
— they now auto-register via AnimationManager.readResourcepackAnimation
without any Java-side hardcoding.

Data-driven pipeline now fully working end-to-end : modder drops a
JSON in their datapack's animmodels folder, it's automatically picked
up at reload, and referenced via resolveWithFallback() from item
bindings. No recompile needed for new items/anims.
2026-04-24 13:01:30 +02:00
notevil
efba00d005 Bondage test items : placeholder assets for Phase 3 pipeline validation
Adds minimum viable assets to exercise the Phase 3 anim pipeline
in-game :

- 5 placeholder anim JSON files in assets/tiedup/animmodels/animations/
  (identity keyframes matching context_stand_idle template) :
  armbinder_idle, armbinder_walk, armbinder_struggle,
  armbinder_equip_oneshot, classic_collar_idle
- armbinder.json enriched with 'animations' block binding IDLE + WALK
  + WALK_BOUND + STRUGGLE_BOUND living_motions + on_equip oneshot
- classic_collar.json enriched with IDLE binding for multi-item
  composition test

Purpose : validate end-to-end that the data-driven pipeline fires
correctly (rebuild, motion switch, oneshot sound, rehydrate) via F6
debug overlay. Visual pose does not change (identity anims) — body
keyframe authoring remains blocked on Blender artist assets.

Runtime note : the placeholder JSONs lack the EF 'constructor' block,
so readResourcepackAnimation does not register them in
AnimationManager.animationByName. resolveWithFallback will return
EMPTY_ANIMATION with a dedup WARN per missing ID — this exercises
the binding resolution path without needing a real StaticAnimation
(the map livingAnimations still receives the accessor, F6 overlay
still reports bindings:N). Real registration will land when either
InstantiateInvoker.getArmature() is implemented (Phase 2 armature
registry) or a dedicated AnimationRegistryEvent handler is added.

Gameday verification steps documented in docs/plans/rig/GAMEDAY_CHECKLIST.md
(gitignored, local only).
2026-04-24 11:57:15 +02:00
notevil
dd30a8d4f9 P3 cross-check fixes : composite motion + dead code
HIGH fix : currentCompositeMotion was initialized to IDLE and never
written — any COMPOSITE_LAYER overlay bound to a non-IDLE motion
would never fire (e.g. cuffs_arms_idle overlay on WALK_BOUND). Set
composite = currentLivingMotion in updateMotion, matching EF pattern
AbstractClientPlayerPatch:129-141.

MEDIUM cleanup : triggerOneshot<T> was 85% duplicate of triggerOneshotById
and had 0 prod callers (tests only). Removed + migrated 6 tests to
triggerOneshotById.

LOW doc fix : ExtendableEnumManager.synchronized javadoc over-sold the
risk as 'BUG-001 review fix' — it's actually conservative defense
against a theoretical cross-classloader race. EF upstream runs without
sync since 2020. Kept synchronized (no cost, no harm) but documented
honestly.
2026-04-24 10:48:57 +02:00
notevil
4648107ebe Revert Option B — IDLE bindings are first-class
User decision 2026-04-24 : IDLE bindings must be supported because
modders need to define idle poses for their items (armbinder can't
work without arms-behind-back idle pose). TiedUp will also ship
default IDLE bindings for its core items once assets are authored.

Technical : EF ClientAnimator has 2 separate maps (livingAnimations
for BASE layer + compositeLivingAnimations for COMPOSITE layers).
Both support IDLE bindings without race — last-put-wins for base
override, or automatic composition via JointMask for partial overlay.
The previous WARN was a reviewer over-correction based on misread
of the 2-map structure.

- Removed WARN parse-time in DataDrivenItemParser.parseAnimationBindings
- Updated test : IDLE binding is accepted silently (no warn)
- Cleaned 'Option B' mentions in javadoc comments
- Design doc (gitignored) updated with 2-path explanation.
2026-04-24 03:46:12 +02:00
notevil
e969131ad2 P3-12 review fixes : reorder preconditions + document 400ms decision
HIGH BUG-001 + MEDIUM RISK-001 : spam timestamp was burned BEFORE
preconditions were validated. Null registryName meant server played
but clients got no broadcast (state desync). Null animator meant
400ms lockout for an entity that never played. Reorder : validate
registryName + animator FIRST, record spam only after preconditions
pass, then server play + broadcast.

MEDIUM RISK-002 : document 400ms global guard as INTENTIONAL design
for cinematic one-shots. Edge case hit+fall-damage same-tick drops
the 2nd — acceptable trade-off. Per-category rate limiting is Phase 4
if gameplay needs it.

LOW SMELL-001 : merge duplicate javadoc on LAST_SYNC_PLAY_BY_ENTITY.
2026-04-24 03:10:23 +02:00
notevil
d39a9d5ebc P3-12 : PacketPlayRigAnim handler réel + LivingEntityPatch.playAnimationSync
Client handler : resolve entity via Minecraft.getInstance().level,
cast to LivingEntity, lookup LivingEntityPatch via TiedUpCapabilities,
call animator.playAnimation avec l'accessor résolu. Null-cascade guards
(level / entity / patch / animator) + debug logs. Logic pure extraite en
resolveAndPlay(level, id, animId, transition, patchLookup) — testable
sans bootstrap MC grâce au patchLookup injectable (évite
TiedUpCapabilities.<clinit> qui cascade sur Registries).

Server-side playAnimationSync : local play sur l'animator (ServerAnimator
track l'état pour StateSpectrum/EntityState même sans rendu visuel) +
broadcast via PacketDistributor TRACKING_ENTITY. Anti-spam guard 400ms
par entity pour prévenir le master-click-spam flood (hit/slap). Cleanup
automatique via WeakHashMap (weak ref sur l'entity → auto-removed au GC
post-despawn, pas de leak sur long-running server).

priorityOrdinal reste informationnel côté client (ClientAnimator.playAnimation
n'accepte pas de priority — intrinsèque au StaticAnimation JSON via
ClientAnimationProperties.PRIORITY). Encodé dans le packet pour future
use / logging.

Débloque les 3 triggers non-fonctionnels : NPC capture cinematic,
hit/slap feedback, unconscious animation.

Tests +7 (277 → 284 GREEN) :
- PacketPlayRigAnimHandlerTest : nullLevel + signature reachability
  (les autres branches requièrent mock ClientLevel/LivingEntity qui
  triggers Bootstrap — gameday-only, documenté)
- LivingEntityPatchSpamGuardTest : first pass, within-window drop,
  boundary 400ms exact pass, cross-entity isolation, constant sanity
2026-04-24 02:44:43 +02:00
notevil
7281548a6a P3 Wave α hardening : snapshot consistency + parse warn + reload hook
HIGH RISK-001 : snapshot update moved between reset+applyDefinitions
and equip oneshots. If an equip oneshot throws, animator state matches
snapshot (currentKeys already applied). Prevents next-tick desync.

MEDIUM Option B warn : parser now logs WARN if modder binds IDLE.
Points to BONDAGE_ANIMATION_DESIGN §5.1 convention. Binding still
accepted (functional), just non-idiomatic.

MEDIUM WARNED_MISSING_ANIMS reload reset : new
TiedUpRigRegistryReloadListener wires resetWarnedMissing() on
AddReloadListenerEvent + RegisterClientReloadListenersEvent. Prevents
unbounded set growth in long-running sessions with datapack swaps.

LOW naming : unified MOD_ID in Wave α files (BondageRehydrateListener
switched from TiedUpRigConstants.MODID to TiedUpMod.MOD_ID for
consistency with sibling BondageEquipmentChangeListener ; both
constants hold the literal "tiedup" so no behavioral change).
2026-04-24 02:16:28 +02:00
notevil
5428f13f98 P3-07 review fixes : key-based diff + first-rebuild guard
CRITICAL BUG-001 reviewer : identity-based diff triggered sound 2x per
sync because V2BondageEquipment.deserializeNBT regenerates ItemStack
instances. 19 call-sites (respawn, dialogue, lock toggle, whip) do full
resync → every interaction = sound cacophony. Switch LAST_EQUIPPED from
Set<ItemStack> (identity) to Map<BodyRegionV2, ResourceLocation> keyed
by (region, itemId). Same region+item = no change, ignored.

HIGH RISK-001 reviewer : first rebuild per player had LAST_EQUIPPED
empty → all items detected as newly equipped → sound spam at login
(5 items = 5 sounds). Add FIRST_REBUILD_DONE WeakHashMap flag to
skip oneshots+sounds on very first rebuild per player.

MEDIUM SMELL-001/002 : javadoc of diffBy removed (assumed identity
sharing which never happens). Padlock NBT patch detection promise
removed — would require NBT hash in snapshot key (Phase 4).

Tests refactored : key-based (region, itemId) semantics.
2026-04-24 01:13:00 +02:00
notevil
e15ab7b831 P3-19 review polish : drop redundant distinct + reorder keybind guard
MEDIUM SMELL-01 reviewer : V2EquipmentHelper.getAllEquipped already
identity-dedupes (IdentityHashMap upstream in V2BondageEquipment:84).
The extra .mapToInt(identityHashCode).distinct() was dead defensive
code. Simplified to direct .count().

MEDIUM SMELL-02 reviewer : keybind guard 'mc.player == null || mc.level
== null' fired BEFORE RIG_DEBUG_KEY consumeClick loop. Main menu clicks
were queued and flushed on world-join (phantom toggle). Move toggle
consumption BEFORE the guard — it's a client-side boolean flip, needs
no world context.

LOW RISK-01 skipped : pin test on Layer.toString() format would require
either registry init (TiedUpRigRegistry.EMPTY_ANIMATION in AnimationPlayer
ctor) or Mockito ; test env doesn't currently init the RIG registry and
layerPriorityName() already has a defensive "?" fallback. Gameday check
is a tighter feedback loop than a flaky unit test here.
2026-04-24 01:05:33 +02:00
notevil
cae3572488 P3-20 : respawn/dim-change rehydrate (UX P0)
Wire ClientRigEquipmentHandler.rebuildBondageAnimations on :
- ClientPlayerNetworkEvent.LoggingIn (client connects)
- EntityJoinLevelEvent (spawn + dim change, filtered to LocalPlayer)

PlayerRespawnEvent path already covered server-side via
PlayerStateEventHandler.onPlayerRespawn -> V2EquipmentHelper.sync ->
PacketSyncV2Equipment -> rebuild (hook P3-06). This listener fills the
client-only gaps.

Without this, a player who logs in with bondage items equipped (or
teleports cross-dimension) sees their livingAnimations map empty until
the next V2 capability sync or armor slot change arrives. Visible
incoherence : item still equipped, bondage anim dropped, player plays
vanilla WALK. Demo-killing regression.

Handlers are idempotent and fire 2-3x at startup (login + spawn + first
chunk load) — rebuildBondageAnimations is designed for that :
resetLivingAnimations + re-apply current bindings, no state drift.

Pure filter extracted to shouldRehydrate(entity, level, ePred, lPred)
generic overload — mirrors the isBondageItem pattern in the sibling
BondageEquipmentChangeListener. Lets tests bypass the MC bootstrap
crash on Mockito.mock(LocalPlayer.class).

Removed the TODO(P3-20) block in BondageEquipmentChangeListener that
pointed to this task.

5 tests, all paths green (shouldRehydrate short-circuits, production
predicate wiring + null-safety).
2026-04-24 00:33:35 +02:00
notevil
de691e24ec P3-19 : RIG debug overlay (F6 toggle)
Scope reduced to overlay only — E2E integration test deferred.

Overlay displays current living motion + composite motion, equipped bondage
count, livingAnimations bindings count, active layers w/ priority + anim.
Critical for dev-visible feedback when placeholder identity anims don't
change the visual rig but the pipeline is running correctly.

Keybind : F6 (configurable in Controls menu, category TiedUp!). F6 chosen
because unused by vanilla and preserves F3 for debug screen.

No new accessor needed on Animator — getLivingAnimations() already exists
(returns ImmutableMap.copyOf, safe for debug read). Layer priority reading
falls back on Layer.toString() parsing for composite layers where the
priority field is protected ; base layer uses the public getBaseLayerPriority().

Tests : 6 pure unit tests (toggle flag, null-player branch, motionName on
vanilla+tiedup enums+null). Real render path is MC-runtime-bound and
validated gameday only.
2026-04-24 00:31:26 +02:00
notevil
a2bcfe2dda P3-07 : on_equip/on_unequip oneshot triggers + audio feedback
Diff detection tick-to-tick via WeakHashMap<Player, Set<ItemStack>>
snapshot captured at start of rebuildBondageAnimations. Identity-based
set (IdentityHashMap) respects convention V2 "un stack = une
occurrence" : deux stacks content-equals mais refs distinctes restent
distincts.

Unequip oneshots fire BEFORE rebuild (play sur l'old state,
livingAnimations encore config precedente), equip oneshots AFTER (play
sur new state, bindings de l'item pushes dans la map). Vanilla
SoundEvents.ARMOR_EQUIP_LEATHER fallback pour MVP — future per-item
custom via field equip_sound dans DataDrivenItemDefinition.

Helpers pure + generiques <T> :
  - uniqueByIdentity(Iterable<T>) : dedup identity + skip nulls
  - diffBy(Set<T>, Set<T>) : a \ b par ==, ordre stable
  - triggerOneshot(...) : no-op si def/bindings/oneshotId null ; sinon
    resolve via animResolver + play avec transition 0.15s constant

Decision actee : priority param non forwarde a playAnimation. La
priority vient de StaticAnimation.getPriority() intrinseque (property
JSON du fichier anim). PacketPlayRigAnim.priorityOrdinal (P3-11)
reste informational / loggable, priority override = TODO Phase 4.

Sound defaultEquipSound() resolu lazy via method (pas static final
field) — SoundEvents.<clinit> exige MC Bootstrap, un field eager
bloquait tous les unit tests de la classe (ExceptionInInitializerError).

Tests : +11 unit tests (28 total dans le fichier handler) :
  - diffBy empty / identity / a==b
  - uniqueByIdentity dedup + nulls skipped
  - triggerOneshot no-def / null-bindings / null-onEquip / happy
    onEquip / happy onUnequip / null-stack

Suite complete 257 GREEN, 0 failed.
2026-04-24 00:28:12 +02:00
notevil
13b0f8f590 P3-06 : wire ClientRigEquipmentHandler from 2 event sources
Trigger rebuildBondageAnimations from :
1. PacketSyncV2Equipment.handleOnClient -- after deserializeNBT capability
2. LivingEquipmentChangeEvent -- filtered to bondage items, client only

Third source (LoggingIn / dimension change) scoped to P3-20 with TODO.

body : idempotent si double-fire, dedup via isBondageRelevant filter.
Caveat Forge doc LivingEquipmentChangeEvent = server-side only : le
handler est neanmoins enregistre @Dist.CLIENT comme filet defensif
pour les edge cases creative/admin. Le path canonique reste
PacketSyncV2Equipment qui couvre 100%% des changements V2 via la
capability dediee (V2 n'utilise pas les slots armor vanilla).

isBondageItem pattern : DataDrivenItemRegistry.get(stack) != null
(coherent avec ClientRigEquipmentHandler.extractSortedDefinitions).
Refactor generic <T> pour testabilite sans MC bootstrap (Mockito
ItemStack crash la static init registries).
2026-04-23 22:41:44 +02:00
notevil
aebc7f3868 P3-08 : PlayerPatch.updateMotion override — route currentLivingMotion per state
Branche decision tree :
- vehicle furniture → POSE_FURNITURE_SEAT
- sleeping bound → POSE_SLEEP_BOUND (UX P0)
- dog pose → POSE_DOG
- struggling → STRUGGLE_BOUND
- falling bound → FALL_BOUND (UX P1)
- in water → SWIM vanilla
- walking/sneaking bound → WALK_BOUND / SNEAK_BOUND
- idle → LivingMotions.IDLE vanilla (Option B)

Pure function resolveMotion extracted for testability — 14 tests cover
every branch + priority ordering edge cases. Combined with P3-05 + P3-06,
TRUE first animation visible milestone reached.

Override lives in abstract PlayerPatch (not subclasses) — la logique lit
purement Player state, pas side-specific. ServerPlayerPatch /
ClientPlayerPatch / LocalPlayerPatch héritent.

considerInaction respecté via animator.getEntityState().inaction() —
aligné sur le pattern EF ClientAnimator.tick().
2026-04-23 22:24:38 +02:00
notevil
e37dad18aa P3-05 review fixes : deterministic tiebreaker + reset contract doc
HIGH RISK-001 : secondary comparator on def.id() prevents UB on
equal-priority items — no longer depends on BodyRegionV2 enum order.
New test asserts lexicographic ordering.

HIGH RISK-002 : resetLivingAnimations restores defaultLivingAnimations
snapshot. Javadoc now warns against calling setCurrentMotionsAsDefault
anywhere in the TiedUp pipeline (would leak custom bindings into
defaults, breaking unbind). Grep confirms no call site exists today.

Polish : rename tautological null test, precise null-safety doc.
2026-04-23 22:11:33 +02:00
notevil
9a31f21b55 P3-05 JALON : ClientRigEquipmentHandler.rebuildBondageAnimations
First visible bondage animation pipeline orchestrator. Consumes the
primitives added in P3-01..P3-04 + P3-09 to rebuild the livingAnimations
map of a player based on currently equipped bondage items.

Design decisions :
- IdentityHashMap dedup (armbinder covers N regions -> 1 unique stack).
  Defensive even though the capability already dedupes (so we don't
  depend on an upstream invariant that might regress).
- Sort by posePriority ASC so highest-priority iterates last -> wins
  conflicts (Map.put last-write-wins semantics in livingAnimations).
- Option B : JSON items don't bind IDLE, let EF defaults flow through
  after resetLivingAnimations() which re-pushes default motions.
- null-check on animations() : 99% of V2 legacy items lack the JSON
  block and return null from the parser, must be skipped silently.
- @OnlyIn(Dist.CLIENT) at class level : ClientAnimator is client-only,
  server class-loader must never touch this handler.
- Extracted testable methods (extractSortedDefinitions + applyDefinitions)
  with functional callbacks (Runnable + LivingAnimationAdder + Function
  resolvers). Generic <T> on extractSortedDefinitions lets tests pass
  Object dummies without MC ItemStack bootstrap.

Tests (15) covering :
- rebuildBondageAnimations null-safety
- extractSortedDefinitions : empty input, null resolver result, identity
  dedup (multi-region), ASC priority sort, null entries skipped
- applyDefinitions : reset-only on empty, null/empty animations skipped,
  multi-binding single item, two-item conflict last-write-wins, reset
  ordering before any adder, end-to-end multi-region dedup, animResolver
  contract (no internal dereference)

Note : this commit provides the HANDLER. The first visible bondage
animation still requires P3-06 (hook it to PacketSyncV2Equipment +
LivingEquipmentChangeEvent) and P3-08 (updateMotion state machine) to
fully light up. Jalon = pipeline viable, not yet wired.

212 tests GREEN (197 baseline + 15 new).
2026-04-23 17:16:19 +02:00
notevil
921a028a53 P3-03 + P3-09 review fixes : tighten RL check + correct thread-safety doc
HIGH P3-03 : strict RL check laissait passer ':path', 'mod:', ':'
silencieusement (tryParse les accepte en 1.20.1). Fix combo check
(isEmpty || colonIdx<=0 || colonIdx>=length-1) rejette tous les cas
edge. 3 nouveaux tests couvrent les malformed variants.

MEDIUM P3-09 : javadoc de BondageStateHelpers affirmait ConcurrentHashMap
alors que le backing V2EquipmentHelper utilise EnumMap via pattern MC
main-thread + packet sync. Correction doc pour refléter la réalité.
2026-04-23 17:03:42 +02:00
notevil
744aef63b5 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.
2026-04-23 16:01:30 +02:00
notevil
c1ecfd75c7 P3-09 : add BondageStateHelpers (isBound, isDogPose, isStruggling, etc.)
5 static helpers pour que PlayerPatch.updateMotion (P3-08) puisse
router vers les TiedUpLivingMotions appropriées.

Null-safe partout. Pas d'état interne, pas de cache : toute fraicheur
est déléguée aux sources backing existantes (capability V2 /
PlayerBindState).

API backing :
- isBound            -> V2EquipmentHelper.getAllEquipped + instanceof IV2BondageItem
- isDogPoseActive    -> PlayerBindState.getEquipment(ARMS) + PoseTypeHelper.getPoseType == DOG
                        (convention partagée par MixinCamera, DogPoseRenderHandler,
                        LeashProxyClientHandler, FirstPersonMittensRenderer)
- isStrugglingClient -> PlayerBindState.isStruggling() (StruggleSnapshot volatile,
                        sync client par PacketSyncStruggleState)
- isSleepingBound    -> player.isSleeping() && isBound (UX P0 POSE_SLEEP_BOUND)
- isFallingBound     -> !onGround() && deltaMovement.y < 0 && isBound (UX P1 FALL_BOUND)

Les helpers fonctionnent aussi server-side par construction (même APIs),
le naming "Client" reflète l'intention d'appel (patch client Epic Fight)
sans side-gating strict.

Tests : 6 null-safety tests, 194/194 GREEN (suite full). Les branches
happy-path nécessitent MC bootstrap (Player réel + capability attachée)
donc couvertes implicitement par les tests d'intégration / runClient.
2026-04-23 15:56:51 +02:00
notevil
639e9e94f7 P3-11 review followup : isolate client-only handler behind @OnlyIn
Prepare P3-12 consumption of Minecraft.getInstance() — refactor
handleOnClient() to delegate into a private ClientHandler inner class
annotated @OnlyIn(Dist.CLIENT). Server-side class-loader will not
resolve Minecraft.* references since the inner class isn't touched.

Direction guard inchangé. Import NetworkDirection passé top-level.

Issue : review transversale Phase 3 (architect agent), recommendation #3.
2026-04-23 15:49:46 +02:00
notevil
ab93dc80be P3-11 review fixes : direction guard + warn on priority out-of-range
HIGH RISK-001 : ModNetwork.reg() helper n enforce pas NetworkDirection,
guard défensif ajouté dans handleOnClient (reject non-S→C + WARN).
Fix systémique du helper tracé en backlog séparé.

LOW SMELL-001 + SMELL-002 : priority() fallback silencieux + idx<0 dead
après masking. WARN log ajouté sur out-of-range. Condition nettoyée.
2026-04-23 15:28:28 +02:00
notevil
2a4ec170ef P3-11 : add PacketPlayRigAnim + ModNetwork registration (handler stub)
Record packet S->C pour animations cinematic one-shot (capture NPC, hit
stun, death). Carries entityId, animId (ResourceLocation), transitionTime
(float), et priorityOrdinal (byte = Layer.Priority.ordinal()).

Encode via writeVarInt/writeResourceLocation/writeFloat/writeByte ; decode
symétrique. Factory PacketPlayRigAnim.of() pour build lisible avec
Layer.Priority, et getter priority() avec fallback LOWEST si ordinal
hors-range (masking & 0xFF pour gérer byte négatif).

Handler client = stub qui LOGGER.debug + setPacketHandled. Le corps
complet (resolve entity, lookup LivingEntityPatch, animator.playAnimation)
viendra en P3-12 avec la méthode serveur playAnimationSync().

Registration ajoutée en fin de ModNetwork.register() — ID sequential 75
(76 packets total).

7 tests unitaires PacketPlayRigAnimTest : roundtrip nominal, entityId
boundary (0/MAX/MIN), transitionTime zero+negative (pas de validation
silencieuse), loop sur les 5 Layer.Priority, fallback LOWEST sur ordinal
malformé (99, -1, PRIORITY_COUNT), ResourceLocation avec underscores et
slashes, et sanity check equals.

Rig tests : 65 -> 72 GREEN. Full suite : 162 GREEN, 0 failure.
2026-04-23 15:11:08 +02:00
notevil
5d108f51b4 P3-02 : add AnimationBindings record + DataDrivenItemDefinition.animations field
New record AnimationBindings (v2/bondage/datadriven/) carries the JSON
item -> rig animation bindings that P3-03 (parser) will populate and
P3-05 (ClientRigEquipmentHandler.rebuildBondageAnimations) will consume
to rebuild the player's livingAnimations map on equip/unequip.

Fields :
- livingMotions : Map<LivingMotion, ResourceLocation> (immutable, never null,
  Map.copyOf defensive copy in compact ctor, null input collapsed to emptyMap)
- onEquip / onUnequip : optional nullable one-shot triggers
- EMPTY constant + isEmpty() for the "no binding" fast path

DataDrivenItemDefinition gains a nullable "animations" field (null = vanilla
behavior, preserves backward compat for items without rig bindings).
DataDrivenItemParser call-site updated to pass null for now — wiring happens
in P3-03. Single call-site updated (parser only), no factories or tests
constructed the record directly.

7 unit tests cover construction paths, null tolerance, defensive map copy,
isEmpty semantics and the EMPTY constant. Full rig suite still 65 GREEN.
2026-04-23 13:22:25 +02:00
notevil
ddaa25b971 P3-01 review fixes : thread-safe assign() + enforce enum load order
BUG-001 reviewer: ExtendableEnumManager.assign() non thread-safe,
synchronize la méthode. Pas d impact perf (N×11 calls au bootstrap).

RISK-001 reviewer: load-order LivingMotions vanilla vs TiedUpLivingMotions
custom pouvait faire coller les ordinals si vanilla pas pre-loadé.
Force explicit LivingMotions.values() avant TiedUpLivingMotions.values()
dans commonSetup.

Test renommé pour refléter honnêtement ce qui est testé (vs load order
implicitement correct dans le test).
2026-04-23 13:16:47 +02:00
notevil
cef589aac1 P3-04 : add TiedUpAnimationRegistry.resolveWithFallback + EMPTY_ANIMATION stub
Nouveau helper statique consomme par ClientRigEquipmentHandler.rebuildBondageAnimations
(P3-05) et PacketPlayRigAnim.handleOnClient (P3-12) pour resoudre un anim ID avec
fallback safe si miss.

Design :
- Lookup delegue a AnimationManager.byKey(ResourceLocation) — l'API existante
  pour la resolution par registry name.
- Fallback = TiedUpRigRegistry.EMPTY_ANIMATION (singleton canonique) plutot
  qu'un stub empty_fallback separe. Les sites runtime (Layer#off, AnimationPlayer#isEmpty,
  LayerOffAnimation#getNextAnimation) comparent via == EMPTY_ANIMATION — retourner
  une autre instance provoquerait des false-negatives sur ces checks d'identite.
- Dedup WARN via ConcurrentHashMap.newKeySet() : un ID donne ne log qu'une fois
  par session, evite le spam si le miss vient d'un item data-driven appele tick
  apres tick. Pattern inspire de RigAnimationTickHandler.LOGGED_ERRORS.
- resetWarnedMissing() expose pour tests + runtime reload (F3+T datapack).
- Branche defensive : id=null swallow + log (cas pathologique caller).

4 tests unitaires :
- happy path (ID enregistre via AnimationManager.AnimationBuilder → assertSame)
- fallback safe (ID inconnu → EMPTY_ANIMATION, non-null)
- no-throw (ID inconnu + null swallow sans exception)
- dedup observable (reset puis re-call sur meme ID re-warn, sanity check fake
  accessor distinct de EMPTY_ANIMATION)

65 tests rig GREEN (57 baseline + 4 nouveaux + autres).
2026-04-23 13:04:31 +02:00
notevil
15e405f5b0 P3-01 : add TiedUpLivingMotions enum (11 motions + UX additions)
Nouvelle enum custom etendant LivingMotion — partage le meme ENUM_MANAGER
que LivingMotions (vanilla EF), ordinals assignes a la suite sans collision.

8 motions design RIG :
- POSE_DOG
- POSE_PET_BED_SIT
- POSE_PET_BED_SLEEP
- POSE_FURNITURE_SEAT
- POSE_KNEEL_BOUND
- STRUGGLE_BOUND
- WALK_BOUND
- SNEAK_BOUND

3 ajouts UX (P0/P1) :
- POSE_SLEEP_BOUND  — sleep avec restraints (P0)
- POSE_UNCONSCIOUS  — steady-state post-capture (P0)
- FALL_BOUND        — no flailing en chute (P1)

Class-load force dans TiedUpMod.commonSetup via values() — sans ca, les
ordinals ne sont pas assignes tant que l'enum n'est pas touche (init lazy
JLS). LivingMotions (vanilla) est class-loaded naturellement par les
patches rig, pas besoin de force.

Tests : 3 cas (11 entries, ordinals uniques intra-enum, pas de collision
avec LivingMotions apres class-load croise).
2026-04-23 13:02:44 +02:00
notevil
1fa291563c Audit-10 : add rig/ test coverage (37 new tests)
Added:
- TransformSheetTest (10 tests) : binary search getInterpolationInfo edge
  cases (empty, negative-wrap, exact boundary, multi-keyframe segment),
  maxFrameTime sentinel, copyAll/copy subrange
- PoseTest (10 tests) : interpolatePose merge + lerp correctness at 0/0.5/1,
  orElseEmpty fallback, load() all three LoadOperation modes, disableAllJoints
- LivingMotionIsSameTest (7 tests) : IDLE==INACTION symmetry, uniqueness of
  universalOrdinal across all LivingMotions values
- TimePairListTest (7 tests) : odd-arg rejection, empty list, inclusive begin /
  exclusive end boundary, multi-pair gap
- RigAnimationTickHandlerTest (2 tests) : resetLoggedErrors idempotency

Skipped (MC runtime dep):
- TiedUpCapabilityEventsTest : AttachCapabilitiesEvent + live ForgeEventBus
- EntityPatchProviderInvalidateTest : LazyOptional is a Forge runtime class
- LivingEntityPatch.onConstructed : requires real LivingEntity hierarchy
- RigAnimationTickHandler.tickPlayer/maybePlayIdle : require
  TiedUpCapabilities.getEntityPatch + ClientAnimator + LivingEntityPatch

Bug flagged (no fix) :
- TransformSheet.getFirstFrame() calls copy(0,2) without guarding size >= 2;
  a single-keyframe sheet would throw ArrayIndexOutOfBoundsException
2026-04-23 09:11:32 +02:00
notevil
05cc07a97d Audit-9 review : drop MovementAnimation from phase0 extract script
Reviewer flagged scripts/rig-extract-phase0.sh:49 as stale after Audit-9
deleted rig/anim/types/MovementAnimation.java. The script is one-shot
tooling, but keep it coherent with the actual fork.
2026-04-23 06:04:38 +02:00
notevil
d3a3b400aa Audit-9 : strip Vec2i + exceptions + HitEntityList + MovementAnimation, document datapack-surface keeps
Deletes (5 files, 132 lines) :
- rig/anim/types/MovementAnimation.java (zero callers outside self-decl)
- rig/exception/DatapackException.java (dead)
- rig/exception/ShaderParsingException.java (dead)
- rig/math/Vec2i.java (dead)
- rig/util/HitEntityList.java (only consumer was HIT_PRIORITY, retiré)

Modifs :
- rig/anim/property/AnimationProperty.java : retire la constante
  AttackPhaseProperty<Priority> HIT_PRIORITY et l'import HitEntityList.Priority.

Docs (3 Javadocs retenues pour surface datapack / Phase 3) :
- CapabilityItem : précise l'usage Phase 3 (data-driven item anims,
  ON_ITEM_CHANGE_EVENT + HumanoidMobPatch.modifyLivingMotionByCurrentItem).
- AnimationProperty.ON_ITEM_CHANGE_EVENT : documenté comme hook datapack
  pour réagir au changement d'item porté.
- MoveCoordFunctions : doc class-level clarifie que les 8 constantes
  MoveCoord* sont consommées par réflection (StaticFieldArgument) côté
  datapack EF tiers — ne pas purger individuellement.

Compile GREEN, 20/20 rig tests GREEN.
2026-04-23 06:02:36 +02:00
notevil
687b810a0e Audit-8 : strip anim core dead methods (-222 LOC)
TransformSheet (-53 LOC, 4 methods): getStartTransform,
getInterpolatedRotation, getCorrectedModelCoord, extendsZCoord.
Skipped: extend (live internal caller in transformToWorldCoordOriginAsDest),
getInterpolationInfo (live internal callers in getInterpolatedTranslation /
getInterpolatedTransform).

ClientAnimator (-44 LOC, 6 methods): getAllLayers, iterVisibleLayers,
isAiming, getOwner, getJumpAnimation, getPriorityFor. Also removed now-unused
java.util.Collection import. Skipped: getCompositeLivingMotion (live callers
in tick/forceResetBeforeAction/resetCompositeMotion), applyBindModifier (live
internal callers in getPose/getComposedLayerPoseBelow/recursion), compareMotion
(live caller in tick), compareCompositeMotion (live caller in tick), iterAllLayers
(live callers in setSoftPause/renderDebuggingInfoForAllLayers), getLivingMotion
(live internal callers in tick/forceResetBeforeAction).

StaticAnimation (-125 LOC, 17 methods): getFileHash instance, idBetween,
in(StaticAnimation[]), in(AnimationAccessor[]), setResourceLocation,
invalidate, isInvalid, removeProperty, addEvents (both overloads),
newTimePair, newConditionalTimePair, addState, removeState,
addConditionalState, addStateRemoveOld, addStateIfNotExist. Also removed
now-unused imports (Collection, Function, Stream).
Skipped @Override/contract methods: getState, getStatesMap, modifyPose,
doesHeadRotFollowEntityHead, getId, equals, getRegistryName, getCoord,
getAccessor (all @Override DynamicAnimation/Object — removing breaks
polymorphism contract). Skipped methods with live callers: getFileHash static
(called in ctor line 116), addProperty (15+ external callers in registry and
AnimationSubFileReader), getModifiedLinkState (3 callers in LinkAnimation),
getSubAnimations (callers in AnimationManager and 5 internal self-calls),
setAccessor (called by AnimationManager.apply line 352), loadAnimation
(called by kept getAnimationClip), createSimulationData (implements
InverseKinematicsProvider interface contract — hot-path risk skip).

20/20 rig tests GREEN.
2026-04-23 05:53:01 +02:00
notevil
06ec7c7c5f Audit-7 : strip ParseUtil + TiedUpRenderTypes dead utilities (-682 LOC)
ParseUtil.java (-232 LOC) :
  Removed 18 zero-caller helpers inherited from Epic Fight :
  toAttributeModifier, nullOrToString, nullOrApply, nvl,
  snakeToSpacedCamel, compareNullables, nullParam, getRegistryName,
  getOrSupply, isParsableAllowingMinus, isParsable, valueOfOmittingType,
  parseOrGet, mapEntryToPair, remove, convertToJsonObject,
  parseCharacterToNumber, parseTagOrThrow.
  Kept toVector3f/toVector3d (unused but reserved for forked callers
  type TrailInfo), toLowerCase, toUpperCase, getBytesSHA256Hash,
  enumValueOfOrNull, orElse, getOrDefaultTag, and the array helpers
  actually wired into JsonAssetLoader / SkinnedMesh / StaticMesh.
  Pruned imports : AttributeModifier, IForgeRegistry, TagParser, Pair,
  JsonOps, ByteTag, CommandSyntaxException, Collectors, Nullable, UUID,
  Function, ArrayList, Collection, Set, Map.

TiedUpRenderTypes.java (-450 LOC) :
  Removed 16 zero-caller render types :
  entityUIColor, entityUITexture, debugCollider, guiTriangle,
  entityAfterimageStencil/Translucent/White, itemAfterimageStencil/
  Translucent/White, blockHighlight, coloredGlintWorldRendertype
  (both overloads), freeUnusedWorldRenderTypes, clearWorldRenderTypes,
  addRenderType(String,ResourceLocation,RenderType), makeTriangulated.
  Also dropped the private fields feeding them : ENTITY_UI_COLORED,
  ENTITY_UI_TEXTURE, OBB, GUI_TRIANGLE, ENTITY_AFTERIMAGE_WHITE,
  ITEM_AFTERIMAGE_WHITE, ENTITY_PARTICLE, ITEM_PARTICLE,
  ENTITY_PARTICLE_STENCIL, ITEM_PARTICLE_STENCIL, BLOCK_HIGHLIGHT,
  WORLD_RENDERTYPES_COLORED_GLINT, plus the newly orphaned
  PARTICLE_SHADER / ShaderColorStateShard / MutableCompositeState
  (only ever used by coloredGlint).
  Kept getTriangulated + replaceTexture (SkinnedMesh / MeshPart / Mesh),
  plus their backing TRIANGULATED_OUTLINE / TRIANGULATED_RENDER_TYPES /
  TRIANGLED_RENDERTYPES_BY_NAME_TEXTURE infra.

debugQuads : DELETED. Only StaticAnimation.renderDebugging /
DynamicAnimation.renderDebugging reference the debug path, and both
were already stripped to empty stubs in Phase 2 ('RIG : debug render
des targets IK strippé. Pas d'IK en TiedUp').

overlayModel : DELETED. PatchedLivingEntityRenderer.getOverlayCoord
explicitly comments out the modifyOverlay path ('RIG Phase 2.5 :
EntityDecorations.modifyOverlay strippé — utilise les defaults
vanilla'), so OVERLAY_MODEL has no reachable caller.

Compile GREEN. 20/20 rig tests GREEN.
2026-04-23 05:38:00 +02:00
notevil
8530671a49 Audit-6 : strip dead math utilities (-300 LOC)
Confirmed-dead method cleanup in rig/math/ following AUDIT_2026-04-22
essentiality cross-check. All removals verified via grep across src/
showing zero live callers.

- MathUtils.java (-242 LOC) : bezierCurve (both overloads), rotWrap,
  lerpDegree, greatest/least (all 6 numeric overloads), translateStack,
  rotateStack, scaleStack (+ their OPEN_MATRIX_BUFFER field),
  lerpMojangVector, setQuaternion, mulQuaternion, getLeastAngleVector
  (idx variant kept, used by ClothSimulator), packColor, unpackColor,
  normalIntValue, wrapClamp, worldToScreenCoord. Unused imports
  (Vector4i, Vector4f, Axis, Camera, Vec2) stripped.

- OpenMatrix4f.java (-56 LOC net) : 3-arg add, mulAsOrigin, createScale,
  ofScale. mulAsOriginInverse (task-KEEP) inlined to no longer depend
  on mulAsOrigin.

- Vec3f.java (-2 LOC net) : toMojangVector, fromMojangVector.

ValueModifier.java NOT deleted despite task instruction : grep found
live references in rig/anim/property/AnimationProperty.java
(4 AttackPhaseProperty constants + import). Task explicitly says
"STOP and report" in this case. The 4 constants have no downstream
readers and could be purged in a follow-up, but that is outside this
task's scope.

20/20 rig tests GREEN (--rerun-tasks verified), ./gradlew compileJava
GREEN after each file group.
2026-04-23 05:26:34 +02:00
notevil
03c28e3332 Audit-3 : align H-04/H-05 signatures vs EF upstream
Deux signatures avaient dérivé du fork EF pendant Phase 0. Les réaligner
maintenant évite du frottement quand on re-porte des fixes EF upstream.

H-04 — ActionAnimation.correctRootJoint
  Avant : correctRootJoint(LinkAnimation linkAnimation, ...)
  Après : correctRootJoint(DynamicAnimation animation, ...)
  LinkAnimation extends DynamicAnimation → widening safe, le call-site
  dans LinkAnimation.modifyPose (ligne 118) passe toujours `this` qui
  type-matche DynamicAnimation sans édition.

H-05 — AttackAnimation.phases
  Avant : public final List<Phase> phases = Collections.emptyList();
  Après : public final Phase[] phases = new Phase[0];
  Le seul caller (JsonAssetLoader.java:477) est un for-each, donc
  array-compatible sans édition. Aucun .size()/.get()/.stream() nulle
  part. Imports java.util.{List,Collections} retirés.

Compile GREEN. 20/20 tests rig GREEN.
2026-04-23 05:03:35 +02:00
notevil
4152f9fc71 Audit-2 : strip EF extension chain + AnimationConfig dead fields (-334 LOC)
H-02 (EF extension chain DEAD) :
- Delete 5 unreferenced EF-derived files :
  - event/EntityPatchRegistryEvent (ModLoader event for third-party patch
    registration — no third-party EF mods to host).
  - event/RegisterResourceLayersEvent (extra renderer layers registration
    event — no consumers).
  - render/layer/LayerUtil (+ empty package) — sole LayerProvider interface
    only used by the deleted RegisterResourceLayersEvent.
  - render/item/RenderItemBase (+ empty package) — abstract weapon renderer
    stub, TiedUp does not host weapon trails.
  - asset/SelfAccessor — AssetAccessor variant never created.
- PatchedRenderersEvent : drop inner classes RegisterItemRenderer (tied to
  RenderItemBase) and Modify (never posted). Keep Add, used by
  TiedUpRenderEngine.
- JsonAssetLoader : remove loadArmature(ArmatureContructor) + its private
  getJoint helper. Armatures are loaded via the GLB path in Phase 1 — this
  EF JSON route has no callers.
- TiedUpRigRegistry : drop inner interface ArmatureContructor (EF-style
  typo preserved) now that loadArmature is gone.
- EntityPatchProvider : drop CUSTOM_CAPABILITIES map + putCustomEntityPatch
  + clearCustom. No third-party extension surface needed ; inline the
  provider lookup to CAPABILITIES only.

H-03 (TiedUpAnimationConfig dead fields) :
- Remove enableOriginalModel, enableAnimatedFirstPersonModel,
  enableCosmetics, enablePovAction, autoSwitchCamera, preferenceWork,
  combatPreferredItems, miningPreferredItems. None had callers — they were
  inherited from EF ClientConfig without a TiedUp consumer.
- Keep activateComputeShader — read at SkinnedMesh.java:249 to toggle the
  GPU skinning path.

Compile green, 20/20 rig tests still green.
2026-04-23 04:55:07 +02:00
notevil
647894508d Audit-1 review : fix stale '6 ticks' comment + track NONE_MASK null
Reviewer flagged two MEDIUM cosmetic items :
- SMELL-001 : TiedUpAnimationRegistry:97 comment still said '6 ticks' —
  updated to '0.15s = 3 ticks' to match the new constant.
- SMELL-002 : JointMaskReloadListener.getNoneMask() can still return
  null if a resource pack omits tiedup:none. Call sites tolerate null
  today; logged in PHASE0_DEGRADATIONS for a Phase 3 fallback JSON +
  warn log.
2026-04-23 04:46:22 +02:00
notevil
f4aae9adb7 Audit-1 : fix transition time + JointMaskReloadListener
C-01 : GENERAL_ANIMATION_TRANSITION_TIME was int 6 while all consumers
expect a float transitionTime in seconds — 6s gave inter-animation
transitions of 120 ticks. Change to float 0.15F (3 ticks).

C-02 : JointMaskReloadListener was never registered, leaving
AnimationSubFileReader callers at lines 170/182/184 to receive null
from getNoneMask()/getJointMaskEntry() with no data loaded. Register
it in V2ClientSetup at LOW priority so it fires after the GLB cache
clear and alongside the other bondage client reload listeners.

M-03 : ASSETS_NEEDED.md JSON example already corrected in the earlier
doc-keeper pass (Torso=7, Chest=8, etc.).

Drive-by: add logs/ to .gitignore — the runClient logs were
accidentally tracked.
2026-04-23 04:41:48 +02:00
notevil
f80dc68c0b Phase 2.8 review fixes : strip player fallback + backlog V3-REW-12-14 + QA edge cases
- BondageAnimationManager : strip total du path joueur, tous les call sites
  'player' retournent null/false avec WARN once-per-UUID. getOrCreateLayer
  court-circuite sur Player direct. Retire dead code factory/furniture
  (FACTORY_ID, FURNITURE_*, npcFurnitureLayers, getPlayerLayer, etc.).
  Javadoc init() reflete la semantique NPC-only.
- DogPoseHelper.applyHeadCompensationClamped : @Deprecated(since=2.8)
  pointant vers V3-REW-07 (dead apres retrait MixinPlayerModel).
- DogPoseRenderHandler.getAppliedRotationDelta + isDogPoseMoving :
  @Deprecated(since=2.8) meme raison.
- Docs (gitignored) : V3_REWORK_BACKLOG.md ajoute V3-REW-12/13/14 (pet bed
  body-lock, human chair yaw clamp, context layer sit/kneel/sneak), tableau
  recap 14 -> 17 items. PHASE2_QA.md ajoute sec 2.5 edge cases + corrige
  le grep pattern 4 -> >=4 lignes. PHASE0_DEGRADATIONS.md ajoute la section
  Phase 2.8 findings.

Compile GREEN. 20/20 tests rig GREEN. Net LOC src : -239 (strip dead code
+ guards player).

Call sites player no-op (attendu par design) :
- PacketSyncPetBedState.playAnimation -> V3-REW-12
- PacketPlayTestAnimation.playAnimation (debug) -> V3-REW-14
- FurnitureClientAnimator.playFurniture -> furniture seat rework V3
2026-04-23 01:10:02 +02:00
notevil
5a39fb0c1c Phase 2.8 : V2 player rendering cleanup + QA checklist doc
Cut wholesale du pipeline V2 player-anim suite au bascule RIG (pas d'opt-in,
pas de rollback — accepté par le mainteneur).

- rm MixinPlayerModel : le renderer RIG patched ne passe plus par
  PlayerModel.setupAnim, donc l'injection @TAIL devenait dead code.
  Les features dog pose head compensation seront ré-exprimées en
  StaticAnimation pose_dog.json (V3-REW-07).
- Strip tiedup.mixins.json : retiré client/MixinPlayerModel, restent
  MixinCamera + MixinLivingEntitySleeping.
- BondageAnimationManager.init() : retiré les 3 PlayerAnimationFactory
  registrations (context / item / furniture), le path joueur n'en
  dépend plus. Factory IDs conservés car les getPlayerLayer*() sont
  tolérantes au null retour via try/catch existants — et restent
  utilisées par le cache fallback remote. Les NPCs continuent
  d'utiliser cette classe via l'accès direct animation stack
  (IAnimatedPlayer.getAnimationStack().addAnimLayer), inchangé.
- TiedUpMod.onClientSetup : suppression de l'appel BondageAnimationManager.init()
  (la méthode est maintenant un log no-op, conservée pour la signature
  publique + doc du changement).
- AnimationTickHandler.onClientTick : retrait de la boucle
  mc.level.players() + updatePlayerAnimation + tickFurnitureSafety +
  cold-cache furniture retry. Les joueurs sont ticked par
  RigAnimationTickHandler (Phase 2.7). Conservé : le cleanup
  périodique ClothesClientCache (hygiène mémoire indépendante), le
  hook onPlayerLogout (cleanup per-UUID des caches NPC restants), et
  le hook onWorldUnload (caches V2 encore utilisés par NpcAnimationTickHandler).
  Imports unused strippés.
- DogPoseHelper : mis à jour la javadoc pour refléter le retrait
  du path player (NPCs only désormais).

Compile GREEN. 20/20 tests rig GREEN.

QA runtime : cf. docs/plans/rig/PHASE2_QA.md (non commit — fichier
working doc sous docs/plans/ gitignored par convention repo).

Net LOC : -276.
2026-04-23 00:42:10 +02:00
notevil
b494b60d60 Phase 2.7 review fixes : resetLoggedErrors wiring + IDLE self-heal + JSON cleanup + tests 2026-04-23 00:27:26 +02:00
notevil
08808dbcc1 Phase 2.7 : animation registry + tick handler + placeholder assets
TiedUpAnimationRegistry.CONTEXT_STAND_IDLE instancié au FMLCommonSetupEvent
via DirectStaticAnimation + armature BIPED. Placeholder JSON 2-keyframes
identity (joueur statique) à assets/tiedup/animmodels/animations/ jusqu'à
ce qu'un export Blender authored remplace. biped.json de même (hiérarchie
identity) placé dans assets/tiedup/armatures/ — parse via JsonAssetLoader
mais pas encore chargé au runtime (l'armature reste procédurale côté Java).

RigAnimationTickHandler tick chaque player visible côté client (phase END) :
- patch.getAnimator().tick() → avance les layers EF
- trigger playAnimation(CONTEXT_STAND_IDLE, 0.2f) quand motion=IDLE et anim
  courante ≠ CONTEXT_STAND_IDLE (idempotent)
- try/catch per-entity avec dedup des erreurs par UUID (pattern
  TiedUpRenderEngine.loggedRenderErrors)
- skip si level null / paused

PlayerPatch.initAnimator bind désormais IDLE → CONTEXT_STAND_IDLE quand le
registry est prêt (fallback EMPTY_ANIMATION si patch construit avant setup).

Voir docs/plans/rig/ASSETS_NEEDED.md pour la spec des assets authored
définitifs (anim idle swing respiration 3 keyframes + offsets biped
anatomiques).
2026-04-23 00:07:32 +02:00
notevil
73264db3c6 Phase 2.6 review fixes : level guard + thread-safety + priority revert + V3-REW-11 track
RISK-001 : early-return si entity.level() == null dans onRenderLiving
  (NPE guard sur detach race despawn / dim teleport). Aligne sur EF.

RISK-004 : clear loggedRenderErrors en tête de onAddLayers() — reset
  du set anti-spam sur reload resources (F3+T), borne aussi la taille
  en session longue.

RISK-005 : remplace HashSet par ConcurrentHashMap.newKeySet() pour
  loggedRenderErrors — render thread + loader thread + test runner
  muteront le set.

RISK-002 : retire priority = EventPriority.HIGH sur onRenderLiving,
  revert au défaut NORMAL aligné EF upstream. Aucun cas concret ne
  justifie HIGH aujourd'hui ; on réévaluera si conflit d'interception
  apparaît Phase 3+. Import EventPriority supprimé.

RISK-003 : tracké V3-REW-11 dans V3_REWORK_BACKLOG.md (inventory GUI
  special-case EF pas porté — rotation/scale/pose preview inventaire).

Docs locaux (gitignored) mis à jour :
  - PHASE0_DEGRADATIONS.md : nouvelle section Phase 2.6 findings
  - V3_REWORK_BACKLOG.md : V3-REW-11 ajouté section MAJEURE

Tests : compileJava GREEN, 18/18 rig tests GREEN.
2026-04-22 23:41:07 +02:00
notevil
987efde86b Phase 2.6 : TiedUpRenderEngine dispatch (RenderLivingEvent hook)
Câble le dispatch renderer RIG via deux subscribers auto-registered (split
MOD bus pour AddLayers, FORGE bus pour RenderLivingEvent.Pre) — pas besoin
de wiring explicite dans TiedUpMod.

onAddLayers (MOD bus) : construit la map entityRendererProvider avec
EntityType.PLAYER → TiedUpPlayerRenderer (Phase 2) ; poste
PatchedRenderersEvent.Add pour extensions tierces.

onRenderLiving (FORGE bus, priority HIGH) : filtre strict instanceof
Player || AbstractTiedUpNpc (protège MCA villagers cf. V3-REW-10) ;
vérifie patch.overrideRender() ; dispatche vers PatchedEntityRenderer et
cancel l'event. Try/catch robuste : log WARN une seule fois par UUID sur
exception, fallback vanilla (event non-canceled).

3 tests unitaires (pure-logic, sans MC runtime) : null-safety du filtre
et idempotence du reset. Le dispatch complet sera validé Phase 2.8
runClient smoke test.

Le biped armature étant identity (Phase 2.4 stub), le hook rendra le
player effondré à l'origine dès qu'il s'active — attendu, warn déjà en
place depuis Phase 2.5.
2026-04-22 23:28:45 +02:00
notevil
d129983eb7 Phase 2.5 review fixes : P0 biped warn + CME guard + double-draw prevention
P0-BUG-001 : LOGGER.warn dans TiedUpArmatures.Holder static init quand les
joints biped sont en identity (Phase 2.4 stub). Sans ça, Phase 2.6 renderer
afficherait un mesh effondré sans signal dev. Warn apparaît une seule fois
(class-init lock JVM).

P0-BUG-002 : Lists.newArrayList(renderer.layers) copie défensive dans
PatchedLivingEntityRenderer.renderLayer. Mods tiers (Wildfire, SkinLayers3D,
Cosmetic Armor) peuvent muter renderer.layers runtime via AddLayers event
-> CME garantie sans copie. TiedUp va au-dela d'EF upstream.

P1-RISK-001 : Pose.orElseEmpty(name) utilise Map.getOrDefault qui retourne
un JointTransform.empty() detache non stocke dans la map -> toute mutation
.frontResult est perdue si le joint est absent. En pratique l'Animator peuple
toujours Head via getComposedLayerPose, mais defensif : on recupere l'instance,
mute, puis pose.putJointData(Head, mutatedTransform). Meme bug latent en EF
upstream.

P1-RISK-003 : PlayerItemInHandLayer extends ItemInHandLayer mais le dispatch
patchedLayers.containsKey(layer.getClass()) est strict -> layer player-specifique
passe au travers. Ajout du mapping explicite dans TiedUpPlayerRenderer
constructor avec le meme stub PatchedItemInHandLayer. Preventif Phase 3.

P2-RISK-004 : @SuppressWarnings scope resserre des 2 methodes aux 2 call sites
dispatch raw PatchedLayer qui en ont reellement besoin. Commentaire pointant
vers l'invariant runtime addPatchedLayer.

Tests : 15/15 rig tests GREEN, compile GREEN.
Doc : docs/plans/rig/PHASE0_DEGRADATIONS.md section Phase 2.5 findings ajoutee
(fichier gitignore, changements locaux pour future session).
2026-04-22 23:16:35 +02:00
notevil
8dff4c0e03 WIP Phase 2.5 : fork render pipeline (PatchedEntityRenderer family)
Fork EF client renderer patched/entity/ → rig/render/, imports rewrités.

Nouveaux fichiers :
 - render/PatchedEntityRenderer.java   (~96 LOC, était stub 16 LOC)  — base abstraite,
   mulPoseStack + setArmaturePose + nameplate via mixin invoker
 - render/PatchedLivingEntityRenderer.java  (~230 LOC, EF 294 → stripped)  — hot path
   render, strippé du JSON customLayers loading + EntityDecorations (pas impl. côté
   LivingEntityPatch TiedUp)
 - render/PHumanoidRenderer.java       (~95 LOC, EF 53 → adapté)  — intermediate
   biped, baby head scale, enregistre PatchedArmorLayer + PatchedItemInHandLayer
 - render/TiedUpPlayerRenderer.java    (~95 LOC, EF PPlayerRenderer 60 → adapté)  —
   dispatch slim/default (Meshes.ALEX vs BIPED), propage modelParts visible flags,
   skip cape/bee/arrow layers (V3-REW-08/09)
 - render/PatchedLayer.java            (~55 LOC)  — base no-op layer
 - render/PatchedArmorLayer.java       (~45 LOC)  — stub no-op (V3-REW-04)
 - render/PatchedItemInHandLayer.java  (~45 LOC)  — stub no-op (Phase 3)
 - mixin/client/MixinEntityRenderer.java       (~35 LOC)  — invoker shouldShowName +
   renderNameTag (EF verbatim)
 - mixin/client/MixinLivingEntityRenderer.java (~30 LOC)  — invoker isBodyVisible,
   getRenderType, getBob

Modifs :
 - resources/tiedup-rig.mixins.json : enregistre les 2 nouveaux mixins client

Dépendances stubbées :
 - PatchedArmorLayer / PatchedItemInHandLayer no-op — "mangent" le layer vanilla
   équivalent pour éviter double-rendu, mais ne dessinent rien. À implémenter
   Phase 3 (armor : V3-REW-04 ; item-in-hand : tool joints GLB).
 - EntityDecorations (color/light/overlay modifiers) strippées du render path —
   le patch TiedUp n'expose pas encore cette API. Hurt/death overlay natifs
   conservés. À rework si feature premium glow/tint NPC.

TODOs Phase 2.6 / Phase 3 :
 - animated_layers/*.json datapack-driven loading (strippé du constructor) si
   besoin futur
 - PlayerItemInHandLayer (version player-spécifique, distincte de ItemInHandLayer
   générique) pas encore mangée côté player → layer vanilla continue de draw
 - ElytraLayer / CustomHeadLayer pas mangés (cohérent "on mange juste les layers
   qui entrent en conflit avec le mesh skinné")

Surprise pendant le fork :
 - ItemInHandLayer vanilla MC 1.20.1 exige M extends EntityModel<E> & ArmedModel,
   pas HeadedModel comme PatchedItemInHandLayer EF 1.20.1 pouvait le suggérer
   via ses anciennes versions. Alignement forcé sur ArmedModel.
 - EF utilise 2 mixins @Invoker (MixinEntityRenderer / MixinLivingEntityRenderer)
   pour accéder aux méthodes protected de vanilla — forkés verbatim dans
   rig/mixin/client/.

Tests :
 - ./gradlew compileJava : BUILD SUCCESSFUL
 - ./gradlew test --tests "com.tiedup.remake.rig.*" : 15/15 GREEN
   (TiedUpArmaturesTest 4 + GlbJointAliasTableTest 4 + GltfToSkinnedMeshTest 6 =
   14 rig + 1 utilitaire)
2026-04-22 22:39:41 +02:00
notevil
39f6177595 Phase 2.4 review fixes : P0-BUG-001 joint order + P0-BUG-002 singleton + tests
P0-BUG-001 — Ordre IDs joints Elbow/Hand/Tool ≠ EF
  TiedUpArmatures.buildBiped() assignait Arm_R=11, Elbow_R=12, Hand_R=13,
  Tool_R=14 (idem gauche 16/17/18/19) alors que VanillaModelTransformer.RIGHT_ARM
  encode upperJoint=11, lowerJoint=12, middleJoint=14 — donc Arm_R=11, Hand_R=12,
  Elbow_R=14. Résultat : lowerJoint=17 (SimpleTransformer qui attache les
  vertices à Hand) pointait en fait vers Elbow_L → bras tordus au rendu.
  Fix : réassigner les IDs dans buildBiped() pour matcher le layout EF
  (Arm_R=11, Hand_R=12, Tool_R=13, Elbow_R=14 ; symétrique 16-19).
  VanillaModelTransformer non touché (source de vérité EF).

P0-BUG-002 — Singleton BIPED non thread-safe
  if (BIPED_INSTANCE == null) BIPED_INSTANCE = buildBiped() est un double-init
  race. En SP intégré (client + server threads sur la même JVM), deux threads
  peuvent entrer simultanément dans le if null et créer deux HumanoidArmature
  distincts — pose matrices incohérentes selon les call sites.
  Fix : Holder idiome (static inner class). Le class-init lock JVM garantit
  (JLS §12.4.1) qu'une seule init, visible à tous les threads, sans
  synchronized ni volatile. Zero overhead après init.

P1 — Nouveau TiedUpArmaturesTest (4 tests)
  - bipedHas20Joints : BIPED.get().getJointNumber() == 20
  - searchBipedJointByNameReturnsNonNull : vérifie les 20 noms EF
  - jointIdsMatchEfLayout : verrou P0-BUG-001 (id=12→Hand_R, id=14→Elbow_R,
    etc.) — aurait attrapé le bug en review initiale
  - bipedSingleton : BIPED.get() == BIPED.get() (verrou P0-BUG-002)

P2 backlog tracé dans docs/plans/rig/PHASE0_DEGRADATIONS.md :
  biped collapsed visuellement jusqu'à Phase 2.7, PlayerPatch.yBodyRot sans
  lerp, ridingEntity non géré, isFirstPersonHidden nommage ambigu,
  ServerPlayerPatch hérite méthodes client-only sans @OnlyIn.

Tests : 15 GREEN (11 bridge pré-existants + 4 nouveaux biped).
Compile clean.
2026-04-22 22:27:01 +02:00
notevil
79fc470aa0 Phase 2.4 : étoffer PlayerPatch stubs + biped armature procédurale
PlayerPatch : getArmature→TiedUpArmatures.BIPED, overrideRender→true,
getModelMatrix avec PLAYER_SCALE=15/16, initAnimator bind IDLE→EMPTY.
ServerPlayerPatch : ré-override overrideRender→false (le serveur ne rend pas).
ClientPlayerPatch : stub isFirstPersonHidden() pour Phase 3 (V3-REW-01).
LocalPlayerPatch : override render→false si on est soi-même en first-person
(laisse rendre les bras vanilla), true sinon.

TiedUpArmatures.BIPED : HumanoidArmature procédurale 20 joints identity
(Root → Thigh/Leg/Knee_R/L, Torso → Chest → Head + Shoulder/Arm/Elbow/
Hand/Tool_R/L). Phase 2.7 remplacera par JSON Blender-authored.

Fixe P2-RISK-01 (InitAnimatorEvent listeners NPE si getArmature()=null).

Tests : 11 bridge tests GREEN, full suite GREEN, compile clean.
2026-04-22 22:12:44 +02:00
notevil
ccec6bd87e Phase 2.3 review fixes : P2-BUG-01/02/03 + RISK-02
Résout les findings P0 remontés par la review post-Phase 2.3 (@4780c96).

P2-BUG-01 — Pipeline RIG dormant au runtime
  EntityPatchProvider.registerEntityPatches() et registerEntityPatchesClient()
  étaient définis mais JAMAIS appelés depuis TiedUpMod setups → CAPABILITIES
  map vide → aucun patch ne se crée, pipeline entier dormant.
  Fix :
  - commonSetup : event.enqueueWork(EntityPatchProvider::registerEntityPatches)
  - ClientModEvents.onClientSetup : event.enqueueWork(
    EntityPatchProvider::registerEntityPatchesClient) avant BondageAnimationManager.init

P2-BUG-02 — Memory leak LazyOptional non invalidée
  EntityPatchProvider.optional jamais invalidated → chaque respawn/dim change
  fuit patch + animator + armature.
  Fix :
  - EntityPatchProvider.invalidate() public méthode qui appelle optional.invalidate()
  - TiedUpCapabilityEvents.attachEntityCapability : event.addListener(provider::invalidate)
    après addCapability. Pattern aligné sur V2BondageEquipmentProvider existant.

P2-BUG-03 — NPE latent HumanoidModelBaker.bakeArmor
  Ligne 88 retournait entityMesh.getHumanoidArmorModel(slot).get() mais le
  getter retourne null (Phase 0 strip S-03 : Meshes.HELMET/CHESTPLATE/LEGGINS/BOOTS
  strippés). Null-check + fallback null + commentaire pointant vers V3-REW-04.

P2-RISK-02 — @OnlyIn(Dist.CLIENT) sur transformers
  VanillaModelTransformer, HumanoidModelTransformer, HumanoidModelBaker
  importent HumanoidModel/PoseStack (client-only). Risque NoClassDefFoundError
  si code serveur touche HumanoidModelBaker.VANILLA_TRANSFORMER static field.
  Fix : @OnlyIn(Dist.CLIENT) sur les 3 classes.

P2-RISK-01/03 + SMELL-01/02/03 tracés dans docs/plans/rig/PHASE0_DEGRADATIONS.md
Phase 2.1-2.3 findings section.

Compile BUILD SUCCESSFUL + 11 tests bridge GREEN maintenus.
2026-04-22 21:42:22 +02:00
notevil
faad0ced0f Phase 2.3 : capability system RIG (EntityPatchProvider + events)
5 classes ajoutées dans rig/patch/ :

- TiedUpCapabilities.java
  Holder du Capability<EntityPatch> CAPABILITY_ENTITY (CapabilityToken
  auto-register) + helpers getEntityPatch / getPlayerPatch /
  getPlayerPatchAsOptional. Simplifié de EF (pas de ITEM/PROJECTILE/SKILL
  caps, combat only).

- EntityPatchProvider.java
  ICapabilityProvider + Map<EntityType, Function<Entity, Supplier<EntityPatch<?>>>>.
  registerEntityPatches() pour commonSetup (EntityType.PLAYER seul Phase 2),
  registerEntityPatchesClient() pour clientSetup (dispatch LocalPlayerPatch vs
  ClientPlayerPatch<RemotePlayer> vs ServerPlayerPatch). CUSTOM_CAPABILITIES
  pour extensions futures. Pas de GlobalMobPatch combat fallback.
  IMPORTANT : n'enregistre PAS EntityType.VILLAGER (MCA conflict V3-REW-10).

- TiedUpCapabilityEvents.java
  @Mod.EventBusSubscriber sur AttachCapabilitiesEvent<Entity>. Check oldPatch
  pour éviter double-attach, construit provider, appelle onConstructed eager
  (D-01 pattern EF), addCapability. Priority NORMAL (order d'attachement
  ne matière pas, c'est les runtime cross-cap reads qui importent et ceux-là
  sont déjà lazy dans onConstructed).

3 stubs PlayerPatch subclasses (placeholders Phase 2.4) :

- ServerPlayerPatch : overrideRender=false, getArmature=null stub, updateMotion no-op
- ClientPlayerPatch<T extends AbstractClientPlayer> : overrideRender=true, @OnlyIn CLIENT
- LocalPlayerPatch extends ClientPlayerPatch<LocalPlayer> : vide pour l'instant

Ces stubs satisfont le compile de EntityPatchProvider.registerEntityPatchesClient().
Le getArmature() null est non-bloquant Phase 2.3 mais devra être fixé Phase 2.4
pour le vrai rendering (lien avec TiedUpRigRegistry.BIPED à créer Phase 2.7).

Compile BUILD SUCCESSFUL + 11 tests bridge GREEN maintenus.
2026-04-22 21:23:01 +02:00
notevil
3aec681436 Phase 2.2 : fork mesh/transformer/ (vanilla PlayerModel → SkinnedMesh)
COPY verbatim EF + rewrite imports :
- rig/mesh/transformer/VanillaModelTransformer.java (~700 LOC)
  Transformer principal : bake PlayerModel vanilla / HumanoidModel → SkinnedMesh biped
  EF. Utilise les PartTransformer (HEAD/ARM/LEG/CHEST) avec AABB cover area.
- rig/mesh/transformer/HumanoidModelTransformer.java (~70 LOC)
  Base abstract commune aux transformers humanoïdes.
- rig/mesh/transformer/HumanoidModelBaker.java (~115 LOC)
  Entry point bake() + export JSON + registry MODEL_TRANSFORMERS.

L'ancienne stub de VanillaMeshPartDefinition (record 55 LOC) est remplacée par
la vraie record dans le fork — API identique (of(partName), of(partName, path,
invertedParentTransform, root)).

Ajouté mixin accessor :
- rig/mixin/client/MixinAgeableListModel.java (@Invoker pour headParts/bodyParts
  sur AgeableListModel).
- src/main/resources/tiedup-rig.mixins.json (nouveau mixin config, package
  com.tiedup.remake.rig.mixin).
- build.gradle : args '-mixin.config=tiedup-rig.mixins.json' dans client+server
  run configs.
- META-INF/mods.toml : [[mixins]] config="tiedup-rig.mixins.json"

Logger EpicFightMod.LOGGER → TiedUpRigConstants.LOGGER dans HumanoidModelBaker.
Packages correctement rewrités par scripts/rig-rewrite-imports.sh. Compile
BUILD SUCCESSFUL maintenu.
2026-04-22 20:59:32 +02:00
notevil
4a587b7478 Phase 2.1 : D-01 fix — LivingEntityPatch animator field + eager init
Close le trap CRITIQUE tracé dans docs/plans/rig/PHASE0_DEGRADATIONS.md D-01
(getAnimator()=null → NPE garanti sur premier appel à MoveCoordFunctions:202,263).

Pattern EF-style conforme LivingEntityPatch EF:146-156 :
- protected Animator animator field
- Override onConstructed(T) : super + factory apply + initAnimator + postInit
- initAnimator(Animator) hook no-op pour subclasses (bind LivingMotion→Anim)
- getAnimator() retourne le field (non-null après onConstructed)
- getClientAnimator() : cast conditionnel instanceof ClientAnimator

Factory TiedUpRigConstants.ANIMATOR_PROVIDER (déjà en place, pattern lazy
method-ref client/server split) fournit la bonne instance selon Dist.

Compile + tests GREEN maintenus (11 tests bridge).
2026-04-22 20:31:00 +02:00
notevil
4d90a87b48 Phase 1 polish : SMELL-001, DOC-001, TEST-001 fixes
Résout les 3 items remontés par la review globale pré-Phase 2 :

SMELL-001 — TiedUpRigConstants.ANIMATOR_PROVIDER
  Le ternaire retournait ServerAnimator::getAnimator dans les 2 branches
  alors que ClientAnimator est maintenant forké (présent dans rig/anim/client/).
  Switch vers ClientAnimator::getAnimator côté client (pattern lazy method-ref
  préserve la non-chargement sur serveur dédié).

DOC-001 — AnimationManager:211
  Commentaire ambigu "SkillManager.reloadAllSkillsAnimations() strippé"
  clarifié : préciser que l'appel upstream EF venait de yesman.epicfight.skill.*
  et que le combat system est hors scope TiedUp.

TEST-001 — GltfToSkinnedMeshTest coverage gaps
  Tests précédents utilisaient [1,0,0,0] → drop trivial, renorm no-op.
  Ajoute 3 tests :
  - convertDropsLowestWeightAndRenormalizes : poids [0.5, 0.3, 0.15, 0.05]
    force le drop du plus faible (0.05) + renorm des 3 restants.
  - convertHandlesZeroWeightVertex : weights tous-zéro → fallback Root w=1.
  - convertFallsBackToRootForUnknownJointName : joint GLB inconnu ("TentacleJoint42")
    → log WARN + fallback Root id=0 sans crash.

11 tests bridge GREEN (5 alias + 6 convert). Compile BUILD SUCCESSFUL.
2026-04-22 19:58:51 +02:00
notevil
29c4fddb90 Phase 1.4 : tests unitaires bridge GLB→SkinnedMesh
8 tests GREEN :

GlbJointAliasTableTest (5) :
- mapLegacyPlayerAnimatorNames : body→Chest, leftUpperArm→Arm_L,
  leftLowerArm→Elbow_L, leftUpperLeg→Thigh_L, leftLowerLeg→Knee_L, etc.
- isCaseInsensitive : BODY/LeftUpperArm/leftupperarm tous remappés
- bypassBipedNames : Arm_L, Elbow_R, Head, Chest, Torso, Root non transformés
- unknownReturnsNull : null pour nom inconnu / vide / null
- isBipedJointNameDetection : _R/_L suffix + Root/Torso/Chest/Head

GltfToSkinnedMeshTest (3) :
- convertSyntheticGltfDoesNotThrow : 3 vertices + armature biped minimale (4
  joints manuels Root→Chest→{Arm_L,Arm_R}) → SkinnedMesh non null
- convertSyntheticGltfHasExpectedParts : partName dérivé du materialName de la
  primitive glTF
- convertThrowsOnNullArmature : IllegalStateException si armature null

Fixture : buildMinimalArmature() construit une hiérarchie 4 joints via Joint()
+ addSubJoints() + Armature(name, count, root, jointMap).bakeOriginMatrices().
buildSyntheticGltf() produit un triangle 3-vertices avec jointNames
(body, leftUpperArm, rightUpperArm) pour tester le mapping PlayerAnimator→EF.
2026-04-22 19:08:05 +02:00
notevil
94fcece05a Phase 1.1-1.3 : bridge GLB → SkinnedMesh
Premier jalon Phase 1 : conversion d'un GltfData (format legacy 11-joints
PlayerAnimator) vers SkinnedMesh Epic Fight (biped ~20 joints).

Files :
- rig/bridge/GlbJointAliasTable.java : table mapping statique PlayerAnimator
  → biped EF (body/torso→Chest, leftUpperArm→Arm_L, leftLowerArm→Elbow_L, etc).
  Fallback Root pour inconnus. Bypass si nom déjà biped (Root/Torso/Chest/Head
  ou suffixe _R/_L).
- rig/bridge/GltfToSkinnedMesh.java : convert(GltfData, AssetAccessor<Armature>)
  → SkinnedMesh. Pré-calcule jointIdMap, boucle vertices (pos/normal/uv + drop 4th
  joint à plus faible poids + renormalise 3 restants), groupe indices par
  primitive (material) en VanillaMeshPartDefinition.

Note : animations GLB ignorées (scope Phase 4 JSON EF authored).

Compile BUILD SUCCESSFUL maintenu.
2026-04-22 14:28:37 +02:00
notevil
4a615368df Phase 0 audit findings : fixes D-07 + D-08 post-verification
Cross-check de l'audit Phase 0 contre les sources EF (4 agents) a remonté :

D-08 RÉFUTÉ partiellement :
  - EF LivingEntityPatch.getTarget() = getLastHurtMob() — identique à notre stub.
  - MAIS MobPatch.getTarget() override avec Mob.getTarget() manquait chez nous.
  - Fix : override ajouté, ref commentée à EF MobPatch.java:171-174.
  - Sans ça, MoveCoordFunctions.MOB_ATTACK_TARGET_LOOK aurait la mauvaise
    sémantique (dernier mob qui m'a frappé vs cible AI courante) → NPC ne
    tourne pas vers sa cible pendant attack anim.

D-07 VALIDÉ :
  - correctRootJoint zero-out X/Z de la Root en espace monde pour éviter
    sliding visuel pendant LinkAnimation vers ActionAnimation.
  - Safe Phase 1 (idle/walk = StaticAnimation, pas ActionAnimation).
  - Critical Phase 2+ dès qu'une vraie ActionAnimation bondage est jouée.
  - Fix : dev assertion LOGGER.warn en IS_DEV_ENV pointant vers
    PHASE0_DEGRADATIONS.md D-07. Empêche découverte tardive.

Autres findings post-vérification (traçés en doc gitignorée) :
  D-01 getAnimator()=null : fix Phase 2 (pas Phase 1) — field protected EF-style
  D-02 sync() stripped : FAUX-POSITIF partiel — BEGINNING_LOCATION non affecté
       (IndependentVariableKey non-synced), seul DESTINATION en MP dédié
  D-03 InstantiateInvoker throws : swallowed par try/catch, silent no-op
  D-04 Patch suppression : doc EXTRACTION.md §3.12/§10 corrigée (Option A)
  D-05 reloadAllSkillsAnimations : était déjà dans SkillManager (commentaire OK)
  D-06 playAnimationAt : ARCHITECTURE.md §5.5.1 pseudocode (signature fantôme)
       → notes ajoutées pointant vers D-06
  D-09 AnimationBegin/EndEvent : listeners EF uniquement skill system interne,
       ON_BEGIN/END_EVENTS data-driven continuent de fonctionner
  D-10 AT 127 lignes : ~50% utile (GUI TiedUp existant), ne pas fix maintenant

IK stack (S-05 pas dans les docs) : section R12 ajoutée à ARCHITECTURE.md §11.

Compile BUILD SUCCESSFUL maintenu (0 errors).
2026-04-22 03:39:44 +02:00
notevil
1cef57a472 Phase 0 : compile SUCCESS (464 -> 0 errors)
Core data model du rig EF extractible compile désormais cleanly.

Changements clé :

1. AccessTransformer wiring (-80 errors)
   - Copie EF accesstransformer.cfg dans resources/META-INF/
   - Uncomment accessTransformer = file(...) dans build.gradle
   - Débloque l'héritage des package-private RenderType.CompositeState +
     RenderType.CompositeRenderType + RenderType.OutlineProperty nécessaires
     à TiedUpRenderTypes.

2. Stubs compat rendering Phase 2
   - PatchedEntityRenderer<E,T,M,R> : type param 4 pour PrepareModelEvent
   - RenderItemBase : type marker pour PatchedRenderersEvent.RegisterItemRenderer
   - LayerUtil + LayerProvider : interface fonctionnelle 5-params pour RegisterResourceLayersEvent
   - PlayerPatch<T extends Player> : extends LivingEntityPatch
   - ToolHolderArmature interface : leftTool/rightTool/backToolJoint()

3. Stubs compat combat Phase 2+
   - AttackResult + ResultType enum : utilisé comme type pour StateFactor ATTACK_RESULT
   - TrailInfo record : stubbé avec playable=false → particle trail jamais émis
   - AttackAnimation.Phase.hand = InteractionHand.MAIN_HAND
   - AttackAnimation.JointColliderPair : stub pour instanceof check
   - AttackAnimation.getPhaseByTime(float) : retourne Phase neutre
   - ActionAnimation.correctRootJoint() : no-op Phase 0
   - ActionAnimation.BEGINNING_LOCATION + INITIAL_LOOK_VEC_DOT re-exposés comme AnimationVariables

4. Physics types alignés
   - InverseKinematicsProvider extends SimulationProvider<...>
   - InverseKinematicsSimulator implements PhysicsSimulator<Joint, ...>
   - InverseKinematicsObject implements SimulationObject<...>
   - InverseKinematicsBuilder extends SimulationObject.SimulationObjectBuilder
   - ik.bake() signature : (Object, Object, boolean, boolean) conforme StaticAnimation usage

5. Mesh/compute stubs
   - ComputeShaderSetup.TOTAL_POSES + TOTAL_NORMALS : OpenMatrix4f[MAX_JOINTS] pool
   - ComputeShaderSetup.MeshPartBuffer inner class + destroyBuffers()
   - ComputeShaderProvider.supportComputeShader() = false
   - VanillaModelTransformer.VanillaMeshPartDefinition record minimal
   - HumanoidMesh.getHumanoidArmorModel() : return null (armor rendering Phase 2)

6. Fixes typage / API
   - TiedUpRenderTypes.prefix("x").toString() x15 : ResourceLocation -> String
   - AnimationManager Logger : log4j -> slf4j
   - TiedUpRigConstants.logAndStacktraceIfDevSide 4-arg overload + Throwable instead of RuntimeException
   - LivingEntityPatch.getReach(InteractionHand) overload
   - StaticAnimation(boolean, String, AssetAccessor) 3-arg overload

Result : compileJava -> BUILD SUCCESSFUL
Prochain jalon : runClient + verify rig se charge sans crash.
2026-04-22 03:16:14 +02:00
notevil
bdbd429bdf WIP: fork patch/collider/codec stubs, 464->135 compile errors
Phase 0 compile progress (70% reduction). Core data model compile :

Refs yesman.epicfight strippées (hors 4 javadocs) :
- AnimationProperty : combat properties EXTRA_DAMAGE, STUN_TYPE, PARTICLE
- ClientAnimator : playAnimationAt(..., AnimatorControlPacket.Layer, Priority)
- ClothSimulator : OBBCollider -> fork geometry-only dans rig/collider/
- InstantiateInvoker : Collider, ColliderPreset, Armatures, DatapackEditScreen
- MoveCoordFunctions : GrapplingAttackAnimation
- SimulationTypes : InverseKinematicsSimulator (path rewrite)

Stubs patch/ :
- EntityPatch<T> abstract — getOriginal, isLogicalClient, getMatrix, getAngleTo
- LivingEntityPatch<T> abstract — getAnimator, getArmature, getTarget, getYRot*
- MobPatch<T extends Mob> — instanceof check only
- item/CapabilityItem — type marker

Forks utilitaires :
- rig/collider/OBBCollider — geometry only (strip Entity collision, drawInternal)
- anim/types/StateSpectrum — identique EF, imports rewrités
- util/PacketBufferCodec — StreamCodec backport
- util/TimePairList — identique EF
- util/HitEntityList — shell pour Priority enum uniquement
- util/ExtendableEnum + ExtendableEnumManager — register/assign enum

Fix package declarations :
- armature/Joint.java + JointTransform.java : package rig.anim -> rig.armature
- Imports JointTransform ajoutés dans anim/{Pose,Keyframe,TransformSheet}

Residu 135 errors = cluster rendering (Phase 2) :
- render/TiedUpRenderTypes (17) : CompositeState package-private MC
- event/PatchedRenderersEvent (11) : missing PatchedEntityRenderer
- mesh/SkinnedMesh (13) : VanillaMeshPartDefinition, compute shader fields
- asset/JsonAssetLoader (6), anim/LivingMotion (5)
2026-04-22 02:45:18 +02:00
notevil
f0d8408384 WIP: stub ClientConfig + gameasset registries, strip Meshes mobs
Nouveaux stubs core :
- TiedUpAnimationConfig     — remplace yesman.epicfight.config.ClientConfig.
                              Flags animation/rendu, no-op pour combat.
- TiedUpRigRegistry         — remplace gameasset.Animations.EMPTY_ANIMATION +
                              gameasset.Armatures.ArmatureContructor.

Fichiers forkés additionnels (dépendances transitives découvertes) :
- anim/types/DirectStaticAnimation.java   (EMPTY_ANIMATION est un DirectStaticAnimation)
- event/InitAnimatorEvent.java            (postInit() forge event)
- event/EntityPatchRegistryEvent.java     (mod bus event pour register patches)

Strip combat :
- Meshes.java : retiré les 11 mob meshes (CreeperMesh, DragonMesh, VexMesh,
                WitherMesh, etc.) + armor + particle + cape. Garde BIPED
                et ALEX / BIPED_OLD_TEX / BIPED_OUTLAYER (variants joueur).
- Animator.playDeathAnimation : Animations.BIPED_DEATH (ARR asset) →
                                EMPTY_ANIMATION fallback.
- AnimationManager.apply : Armatures.reload() stripped (no-op, à rebrancher
                           Phase 2 sur TiedUpArmatures).
- ClientPlayerPatch.entityPairing : body entier strippé (combat skills
                                    Technician/Adrenaline/Emergency Escape).

sed global : ClientConfig.* → TiedUpAnimationConfig.*
sed global : Animations.EMPTY_ANIMATION → TiedUpRigRegistry.EMPTY_ANIMATION
sed global : Armatures.ArmatureContructor → TiedUpRigRegistry.ArmatureContructor

Résidus yesman.epicfight : 86 → 74 (-12)
Reste : physics (16) + network (13) + world combat (10) + particle (3) +
collider (2) + client misc (2) + skill (2). Tous combat-entangled,
demandent strip méthode par méthode.
2026-04-22 00:53:42 +02:00
notevil
324e7fb984 WIP: create TiedUpRigConstants, replace EpicFightMod/SharedConstants refs
- Nouveau TiedUpRigConstants.java : centralise MODID/LOGGER/identifier/prefix,
  constantes runtime (IS_DEV_ENV, A_TICK, GENERAL_ANIMATION_TRANSITION_TIME,
  MAX_JOINTS), factory ANIMATOR_PROVIDER (client/server split) + helpers
  stacktraceIfDevSide/logAndStacktraceIfDevSide.

- sed global : EpicFightMod.* → TiedUpRigConstants.*
- sed global : EpicFightSharedConstants.* → TiedUpRigConstants.*
- sed global : EpicFightRenderTypes → TiedUpRenderTypes (class rename upstream)
- Fix package declarations : Armature.java + TiedUpRenderTypes.java

Résidus yesman.epicfight : 115 → 86 (-29)
Reste : gameasset/physics/network/world/config/skill (combat deps à strip) +
combat mode refs dans patch/LocalPlayerPatch + ClientPlayerPatch (Phase 2).
2026-04-22 00:33:39 +02:00
notevil
cbf61906e0 WIP: initial epic fight core extraction (Phase 0)
83 files forkés d'Epic Fight (~18k LOC). Base non-compilable en l'état.

Contenu extrait :
- math/ — OpenMatrix4f, Vec3f/4f/2f, QuaternionUtils, MathUtils, ...
- armature/ — Armature, Joint, JointTransform, HumanoidArmature
- anim/ — Animator, ServerAnimator, ClientAnimator, LivingMotion, ...
- anim/types/ — StaticAnimation, DynamicAnimation, MovementAnimation, LinkAnimation,
                ConcurrentLinkAnimation, LayerOffAnimation, EntityState
- anim/client/ — Layer, ClientAnimator, JointMask
- mesh/ — SkinnedMesh, SingleGroupVertexBuilder, Mesh, HumanoidMesh, ...
- cloth/ — AbstractSimulator, ClothSimulator (dépendance transitive de StaticMesh)
- asset/ — JsonAssetLoader, AssetAccessor
- patch/ — EntityPatch, LivingEntityPatch, PlayerPatch, ClientPlayerPatch
- util/ — ParseUtil, TypeFlexibleHashMap
- exception/ — AssetLoadingException
- event/ — PatchedRenderersEvent, PrepareModelEvent, RegisterResourceLayersEvent
- render/ — TiedUpRenderTypes

Headers GPLv3 + attribution injectés sur tous les .java.
Package declarations fixées sur Armature.java et TiedUpRenderTypes.java.

115 imports résiduels à résoudre manuellement :
- yesman.epicfight.main (EpicFightMod, EpicFightSharedConstants) — 30
- yesman.epicfight.gameasset (Animations, Armatures, EpicFightSounds) — 12
- yesman.epicfight.api.physics + physics.ik (combat physics) — 16
- yesman.epicfight.network.* (combat packets) — 13
- yesman.epicfight.world.* (combat entity logic) — 10
- yesman.epicfight.config.ClientConfig — 3
- yesman.epicfight.skill, .client.gui, .particle, .collider — divers combat/UI

Stratégie fix (2-3 sem manuel) : strip usage combat, stubs pour refs
core (EpicFightMod → TiedUpMod, SharedConstants → TiedUpRigConstants,
ClientConfig → TiedUpAnimationConfig).
2026-04-22 00:26:29 +02:00
notevil
b141e137e7 add rig extraction scripts
scripts/rig-rewrite-imports.sh  — package rewrites yesman.epicfight.* → com.tiedup.remake.rig.*
scripts/rig-headers.sh          — GPLv3 + attribution Epic Fight header injection
scripts/rig-extract-phase0.sh   — master script Phase 0 (copy + rewrite + headers)

Cf. docs/plans/rig/EXTRACTION.md §9 (docs restent locales).
2026-04-22 00:26:09 +02:00
notevil
b0b719b3dd relicense to GPL-3.0-or-later
Drop Commons-Clause and monetization restrictions to enable
incorporating third-party GPLv3 code in upcoming rig system work.

Prior versions (0.1.0–0.5.x) remain under GPL-3.0 WITH Commons-Clause.
From 0.6.0-ALPHA, GPL-3.0-or-later pure.
2026-04-22 00:13:50 +02:00
243 changed files with 37123 additions and 767 deletions

2
.gitignore vendored
View File

@@ -40,9 +40,11 @@ package-lock.json
# Build logs # Build logs
build_output.log build_output.log
logs/
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
desktop.ini desktop.ini
docs/ docs/
docs.tar.gz

102
LICENSE
View File

@@ -1,66 +1,21 @@
# TiedUp! Remake - License # TiedUp! Remake - License
**Effective Date:** January 2025 **Effective Date:** April 2026 (license change from GPL-3.0 + Commons-Clause to GPL-3.0 pure)
**Applies to:** All versions of TiedUp! Remake (past, present, and future) **Applies to:** All versions from 0.6.0-ALPHA onwards. Prior versions (0.1.0 through 0.5.x) were distributed under GPL-3.0 WITH Commons-Clause.
--- ---
## Summary ## Summary
This software is licensed under **GPL-3.0 with Commons Clause** and additional restrictions. This software is licensed under **GPL-3.0-or-later** (GNU General Public License, version 3 or any later version).
**You CAN:** The Commons-Clause restriction and additional monetization restrictions present in prior versions are **removed** effective this release, to enable incorporating third-party GPLv3 code (notably the Epic Fight animation/skeleton/mesh subsystem — see `docs/plans/rig/`).
- Use the mod for free
- Modify the source code
- Distribute the mod (with source code)
- Create derivative works (must be open source under the same license)
**You CANNOT:**
- Sell this software
- Put this software behind a paywall, subscription, or any form of monetization
- Distribute without providing source code
- Use a more restrictive license for derivative works
--- ---
## Full License Terms ## GNU General Public License v3.0-or-later
### Part 1: Commons Clause Restriction Copyright (C) 2024-2026 TiedUp! Remake Contributors
"Commons Clause" License Condition v1.0
The Software is provided to you by the Licensor under the License, as defined
below, subject to the following condition.
Without limiting other conditions in the License, the grant of rights under the
License will not include, and the License does not grant to you, the right to
Sell the Software.
For purposes of the foregoing, "Sell" means practicing any or all of the rights
granted to you under the License to provide to third parties, for a fee or other
consideration (including without limitation fees for hosting or consulting/
support services related to the Software), a product or service whose value
derives, entirely or substantially, from the functionality of the Software.
**Additional Monetization Restrictions:**
The following are explicitly prohibited:
1. Selling the Software or any derivative work
2. Requiring payment, subscription, or donation to access or download the Software
3. Placing the Software behind a paywall of any kind (Patreon, Ko-fi, etc.)
4. Bundling the Software with paid products or services
5. Using the Software as an incentive for paid memberships or subscriptions
6. Early access monetization (charging for early access to updates)
**Permitted:**
- Accepting voluntary donations (as long as the Software remains freely accessible)
- Using the Software on monetized content platforms (YouTube, Twitch, etc.)
---
### Part 2: GNU General Public License v3.0
Copyright (C) 2024-2025 TiedUp! Remake Contributors
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
@@ -80,14 +35,24 @@ https://www.gnu.org/licenses/gpl-3.0.txt
--- ---
### Part 3: Asset Exclusions ## Derived Work — Epic Fight
The following assets are NOT covered by this license and remain property of Portions of this project (everything under `com.tiedup.remake.v3.*`, starting with 0.6.0-ALPHA) are derived from **Epic Fight** by the Epic Fight Team, licensed under GPLv3. See:
their respective owners:
- Upstream repository: https://github.com/Epic-Fight/epicfight
- Upstream license: GPLv3 (identical to this project)
Each derived file in `com.tiedup.remake.rig.*` carries a header attribution to Epic Fight. No Epic Fight assets (textures, 3D models, animations) are reused — only Java source code for the animation/skeleton/mesh infrastructure.
---
## Asset Exclusions
The following assets are NOT covered by this license and remain property of their respective owners:
1. **Original kdnp mod Assets** (textures, models, sounds from the 1.12.2 version) 1. **Original kdnp mod Assets** (textures, models, sounds from the 1.12.2 version)
- Original creators: Yuti & Marl Velius - Original creators: Yuti & Marl Velius
- These assets are used under fair use for preservation/educational purposes - Used under fair use for preservation/educational purposes
- Contact original authors for commercial use - Contact original authors for commercial use
2. **Minecraft Assets** 2. **Minecraft Assets**
@@ -95,28 +60,15 @@ their respective owners:
- Subject to Minecraft EULA: https://www.minecraft.net/en-us/eula - Subject to Minecraft EULA: https://www.minecraft.net/en-us/eula
3. **Third-Party Libraries** 3. **Third-Party Libraries**
- PlayerAnimator: Subject to its own license (dev.kosmx.player-anim) - Forge: subject to Forge license (MinecraftForge)
- Forge: Subject to Forge license (MinecraftForge) - Other dependencies: subject to their respective licenses
- Other dependencies: Subject to their respective licenses - (Prior to 0.6.0, PlayerAnimator and bendy-lib were used; removed in the RIG system.)
**Code written for this remake** (files in `src/main/java/com/tiedup/remake/`) Code written for this project (files in `src/main/java/com/tiedup/remake/`) is fully covered by GPL-3.0-or-later.
is fully covered by this GPL-3.0 + Commons Clause license.
--- ---
### Part 4: Derivative Works ## Disclaimer
Any derivative work based on this Software MUST:
1. Be distributed under this same license (GPL-3.0 + Commons Clause)
2. Provide complete source code
3. Maintain all copyright notices
4. Not be sold or monetized in any way
5. Credit the original TiedUp! Remake project
---
### Part 5: Disclaimer
THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
@@ -131,9 +83,9 @@ SOFTWARE.
## SPDX Identifier ## SPDX Identifier
``` ```
SPDX-License-Identifier: GPL-3.0-only WITH Commons-Clause-1.0 SPDX-License-Identifier: GPL-3.0-or-later
``` ```
## Contact ## Contact
For licensing questions or permission requests, open an issue on the project repository. For licensing questions, open an issue on the project repository.

View File

@@ -66,7 +66,7 @@ minecraft {
// However, it must be at "META-INF/accesstransformer.cfg" in the final mod jar to be loaded by Forge. // However, it must be at "META-INF/accesstransformer.cfg" in the final mod jar to be loaded by Forge.
// This default location is a best practice to automatically put the file in the right place in the final jar. // This default location is a best practice to automatically put the file in the right place in the final jar.
// See https://docs.minecraftforge.net/en/latest/advanced/accesstransformers/ for more information. // See https://docs.minecraftforge.net/en/latest/advanced/accesstransformers/ for more information.
// accessTransformer = file('src/main/resources/META-INF/accesstransformer.cfg') accessTransformer = file('src/main/resources/META-INF/accesstransformer.cfg')
// Default run configurations. // Default run configurations.
// These can be tweaked, removed, or duplicated as needed. // These can be tweaked, removed, or duplicated as needed.
@@ -105,6 +105,7 @@ minecraft {
// Mixin config arg // Mixin config arg
args '-mixin.config=tiedup.mixins.json' args '-mixin.config=tiedup.mixins.json'
args '-mixin.config=tiedup-compat.mixins.json' args '-mixin.config=tiedup-compat.mixins.json'
args '-mixin.config=tiedup-rig.mixins.json'
} }
server { server {
@@ -118,6 +119,7 @@ minecraft {
// Mixin config arg // Mixin config arg
args '-mixin.config=tiedup.mixins.json' args '-mixin.config=tiedup.mixins.json'
args '-mixin.config=tiedup-compat.mixins.json' args '-mixin.config=tiedup-compat.mixins.json'
args '-mixin.config=tiedup-rig.mixins.json'
} }
// Additional client instances for multiplayer testing // Additional client instances for multiplayer testing

View File

@@ -49,7 +49,7 @@ mod_id=tiedup
# The human-readable display name for the mod. # The human-readable display name for the mod.
mod_name=TiedUp mod_name=TiedUp
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=GPL-3.0 WITH Commons-Clause (No Sale/Paywall) mod_license=GPL-3.0-or-later
# The mod version. See https://semver.org/ # The mod version. See https://semver.org/
mod_version=0.5.6-ALPHA mod_version=0.5.6-ALPHA
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.

137
scripts/rig-extract-phase0.sh Executable file
View File

@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# rig-extract-phase0.sh
# Extrait le core Epic Fight nécessaire pour le RIG system TiedUp.
# Voir docs/plans/rig/EXTRACTION.md §9 pour détails.
#
# NE PAS utiliser "set -e" — certains cp peuvent échouer (fichiers non
# critiques, 2>/dev/null || true) et on veut continuer.
#
# Lancer depuis la racine du projet.
set -u
SRC="docs/ModSources/epicfight-1.20.1/src/main/java/yesman/epicfight"
DST="src/main/java/com/tiedup/remake/rig"
if [ ! -d "$SRC" ]; then
echo "ERROR: Epic Fight source not found at $SRC"
exit 1
fi
echo "=== rig-extract-phase0.sh ==="
echo "Source : $SRC"
echo "Cible : $DST"
echo ""
echo "=== 0. Structure des packages ==="
mkdir -p "$DST"/{math,armature,armature/types,anim,anim/types,anim/property,anim/client,anim/client/property,mesh,mesh/transformer,cloth,asset,event,patch,render,render/compute,registry,bridge,tick,mixin,util,util/datastruct,exception}
echo "=== 1. Math utils ==="
cp -v "$SRC"/api/utils/math/*.java "$DST/math/"
echo ""
echo "=== 2. Armature (Armature + Joint + JointTransform) ==="
cp -v "$SRC"/api/model/Armature.java "$DST/armature/"
cp -v "$SRC"/api/animation/Joint.java "$DST/armature/"
cp -v "$SRC"/api/animation/JointTransform.java "$DST/armature/"
echo ""
echo "=== 3. Animation core + LivingMotion + ServerAnimator ==="
for f in Animator AnimationPlayer AnimationClip AnimationManager AnimationVariables \
SynchedAnimationVariableKey SynchedAnimationVariableKeys Keyframe Pose TransformSheet \
LivingMotion LivingMotions ServerAnimator; do
cp -v "$SRC/api/animation/$f.java" "$DST/anim/" 2>/dev/null || echo " (skip : $f.java non trouvé)"
done
cp -v "$SRC"/api/animation/property/*.java "$DST/anim/property/" 2>/dev/null
echo ""
echo "=== 4. Animation types (filtrés + stubs combat pour JsonAssetLoader) ==="
for f in DynamicAnimation StaticAnimation LinkAnimation \
ConcurrentLinkAnimation LayerOffAnimation EntityState \
ActionAnimation AttackAnimation MainFrameAnimation; do
# ActionAnimation/AttackAnimation/MainFrameAnimation seront simplifiés
# manuellement en stubs (retirer le combat, garder signatures).
cp -v "$SRC/api/animation/types/$f.java" "$DST/anim/types/" 2>/dev/null || echo " (skip : $f.java non trouvé)"
done
echo ""
echo "=== 5. Animation client ==="
cp -v "$SRC"/api/client/animation/*.java "$DST/anim/client/" 2>/dev/null
cp -v "$SRC"/api/client/animation/property/*.java "$DST/anim/client/property/" 2>/dev/null
# TrailInfo hors scope
rm -f "$DST/anim/client/property/TrailInfo.java"
echo ""
echo "=== 6. Mesh ==="
cp -v "$SRC"/api/client/model/*.java "$DST/mesh/" 2>/dev/null
# Retirer ItemSkinsReloadListener (cosmetics combat)
rm -f "$DST/mesh/ItemSkinsReloadListener.java"
echo ""
echo "=== 7. Cloth (absorbé Phase 0 — StaticMesh en dépend) ==="
cp -v "$SRC"/api/client/physics/AbstractSimulator.java "$DST/cloth/" 2>/dev/null
cp -v "$SRC"/api/client/physics/cloth/*.java "$DST/cloth/" 2>/dev/null
echo ""
echo "=== 8. Asset loader ==="
cp -v "$SRC"/api/asset/*.java "$DST/asset/" 2>/dev/null
echo ""
echo "=== 9. Forge events ==="
for f in PatchedRenderersEvent PrepareModelEvent RegisterResourceLayersEvent; do
cp -v "$SRC/api/client/forgeevent/$f.java" "$DST/event/" 2>/dev/null || echo " (skip : $f.java non trouvé)"
done
echo ""
echo "=== 10. RenderTypes ==="
cp -v "$SRC"/client/renderer/EpicFightRenderTypes.java "$DST/render/TiedUpRenderTypes.java" 2>/dev/null
echo " NOTE: RenderEngine.Events à extraire manuellement dans render/TiedUpRenderEngine.java"
echo " NOTE: TiedUpRigConstants.java à créer manuellement (factory ANIMATOR_PROVIDER + isPhysicalClient)"
echo ""
echo "=== 11. ComputeShader stubs (à créer manuellement, no-op) ==="
echo " NOTE: créer render/compute/ComputeShaderSetup.java et ComputeShaderProvider.java (stubs vides)"
echo ""
echo "=== 12. Transitives oubliées (découvertes par review) ==="
cp -v "$SRC"/api/utils/ParseUtil.java "$DST/util/" 2>/dev/null
cp -v "$SRC"/api/utils/datastruct/*.java "$DST/util/datastruct/" 2>/dev/null
cp -v "$SRC"/api/exception/*.java "$DST/exception/" 2>/dev/null
cp -v "$SRC"/model/armature/HumanoidArmature.java "$DST/armature/" 2>/dev/null
cp -v "$SRC"/model/armature/types/HumanLikeArmature.java "$DST/armature/types/" 2>/dev/null
cp -v "$SRC"/client/mesh/HumanoidMesh.java "$DST/mesh/" 2>/dev/null
echo ""
echo "=== 13. ClientPlayerPatch + LocalPlayerPatch (typo upstream 'capabilites') ==="
cp -v "$SRC"/client/world/capabilites/entitypatch/player/AbstractClientPlayerPatch.java "$DST/patch/ClientPlayerPatch.java" 2>/dev/null
cp -v "$SRC"/client/world/capabilites/entitypatch/player/LocalPlayerPatch.java "$DST/patch/" 2>/dev/null
echo ""
echo "=== 14. Rewrite imports ==="
bash scripts/rig-rewrite-imports.sh "$DST"
echo ""
echo "=== 15. License headers ==="
bash scripts/rig-headers.sh "$DST"
echo ""
echo "--- Phase 0 extraction done ---"
echo ""
echo "Fichiers copiés :"
find "$DST" -type f -name "*.java" | wc -l
echo "LOC totales :"
find "$DST" -type f -name "*.java" -exec cat {} + | wc -l
echo ""
echo "Next steps (manuel, cf. EXTRACTION.md §9) :"
echo " 1. Fixer compile errors (strip combat from types, stub refs EpicFightMod/ClientConfig/ClientEngine/SkillManager)"
echo " 2. Strip valeurs combat de LivingMotion/LivingMotions enum, ajouter valeurs TiedUp"
echo " 3. Extraire RenderEngine.Events → render/TiedUpRenderEngine.java"
echo " 4. Créer ComputeShader stubs (render/compute/ComputeShaderSetup.java + ComputeShaderProvider.java no-op)"
echo " 5. Adapter TiedUpCapabilities.java depuis EpicFightCapabilities.java (retirer combat caps)"
echo " 6. Écrire TiedUpCapabilityEvents.java from scratch (~50L : RegisterCapabilitiesEvent + AttachCapabilitiesEvent)"
echo " 7. Fork mixins (MixinEntity, MixinLivingEntity, MixinLivingEntityRenderer — @Invoker only pour renderer)"
echo " 8. Simplifier LivingEntityPatch.java (1213L → ~400L, strip combat)"
echo " 9. Créer TiedUpRigConstants.java (factory ANIMATOR_PROVIDER)"
echo ""
echo "Budget total post-script : 2 à 3 semaines pour vert-compile."

38
scripts/rig-headers.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# rig-headers.sh
# Ajoute un header attribution Epic Fight + GPLv3 à chaque fichier .java
# forké dans v3/rig qui n'en a pas déjà un.
set -u
TARGET="${1:-src/main/java/com/tiedup/remake/rig}"
if [ ! -d "$TARGET" ]; then
echo "ERROR: target dir not found: $TARGET"
exit 1
fi
HEADER='/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
'
count=0
skipped=0
find "$TARGET" -type f -name "*.java" | while read f; do
if head -5 "$f" | grep -q "Derived from Epic Fight"; then
skipped=$((skipped + 1))
else
tmp=$(mktemp)
printf '%s' "$HEADER" > "$tmp"
cat "$f" >> "$tmp"
mv "$tmp" "$f"
count=$((count + 1))
fi
done
echo "Headers injected in files (check count via grep)."
echo "Run: grep -l 'Derived from Epic Fight' $TARGET -r | wc -l"

75
scripts/rig-rewrite-imports.sh Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# rig-rewrite-imports.sh
# Renomme les imports yesman.epicfight.* vers com.tiedup.remake.rig.*
# dans tous les fichiers Java fraîchement copiés dans v3/rig.
#
# IMPORTANT : ordre du plus spécifique au plus général.
# Si on fait api.animation avant api.client.animation, la première rule
# mange la seconde. Chaque règle utilise un pattern qui matche exactement
# le chemin complet jusqu'au séparateur suivant.
#
# Portabilité : sed -i non-portable BSD (macOS) — utiliser "sed -i.bak"
# si besoin support Mac.
set -u
TARGET="${1:-src/main/java/com/tiedup/remake/rig}"
if [ ! -d "$TARGET" ]; then
echo "ERROR: target dir not found: $TARGET"
exit 1
fi
echo "Rewriting imports in $TARGET..."
find "$TARGET" -type f -name "*.java" -exec sed -i \
-e 's|yesman\.epicfight\.api\.utils\.math|com.tiedup.remake.rig.math|g' \
-e 's|yesman\.epicfight\.api\.utils\.datastruct|com.tiedup.remake.rig.util.datastruct|g' \
-e 's|yesman\.epicfight\.api\.utils\.ParseUtil|com.tiedup.remake.rig.util.ParseUtil|g' \
-e 's|yesman\.epicfight\.api\.utils|com.tiedup.remake.rig.util|g' \
-e 's|yesman\.epicfight\.api\.exception|com.tiedup.remake.rig.exception|g' \
-e 's|yesman\.epicfight\.api\.forgeevent|com.tiedup.remake.rig.event|g' \
-e 's|yesman\.epicfight\.api\.model\.Armature|com.tiedup.remake.rig.armature.Armature|g' \
-e 's|yesman\.epicfight\.api\.animation\.Joint|com.tiedup.remake.rig.armature.Joint|g' \
-e 's|yesman\.epicfight\.api\.animation\.JointTransform|com.tiedup.remake.rig.armature.JointTransform|g' \
-e 's|yesman\.epicfight\.api\.animation\.types\.datapack|com.tiedup.remake.rig.anim.types.datapack|g' \
-e 's|yesman\.epicfight\.api\.animation\.types\.grappling|com.tiedup.remake.rig.anim.types.grappling|g' \
-e 's|yesman\.epicfight\.api\.animation\.types\.procedural|com.tiedup.remake.rig.anim.types.procedural|g' \
-e 's|yesman\.epicfight\.api\.animation\.types|com.tiedup.remake.rig.anim.types|g' \
-e 's|yesman\.epicfight\.api\.animation\.property|com.tiedup.remake.rig.anim.property|g' \
-e 's|yesman\.epicfight\.api\.animation|com.tiedup.remake.rig.anim|g' \
-e 's|yesman\.epicfight\.api\.client\.animation\.property|com.tiedup.remake.rig.anim.client.property|g' \
-e 's|yesman\.epicfight\.api\.client\.animation|com.tiedup.remake.rig.anim.client|g' \
-e 's|yesman\.epicfight\.api\.client\.model\.transformer|com.tiedup.remake.rig.mesh.transformer|g' \
-e 's|yesman\.epicfight\.api\.client\.model|com.tiedup.remake.rig.mesh|g' \
-e 's|yesman\.epicfight\.api\.client\.physics\.cloth|com.tiedup.remake.rig.cloth|g' \
-e 's|yesman\.epicfight\.api\.client\.physics|com.tiedup.remake.rig.cloth|g' \
-e 's|yesman\.epicfight\.api\.client\.forgeevent|com.tiedup.remake.rig.event|g' \
-e 's|yesman\.epicfight\.api\.asset|com.tiedup.remake.rig.asset|g' \
-e 's|yesman\.epicfight\.model\.armature\.types|com.tiedup.remake.rig.armature.types|g' \
-e 's|yesman\.epicfight\.model\.armature|com.tiedup.remake.rig.armature|g' \
-e 's|yesman\.epicfight\.world\.capabilities\.provider|com.tiedup.remake.rig.patch|g' \
-e 's|yesman\.epicfight\.world\.capabilities\.entitypatch\.player|com.tiedup.remake.rig.patch|g' \
-e 's|yesman\.epicfight\.world\.capabilities\.entitypatch|com.tiedup.remake.rig.patch|g' \
-e 's|yesman\.epicfight\.world\.capabilities\.EpicFightCapabilities|com.tiedup.remake.rig.patch.TiedUpCapabilities|g' \
-e 's|yesman\.epicfight\.world\.capabilities|com.tiedup.remake.rig.patch|g' \
-e 's|yesman\.epicfight\.client\.world\.capabilites\.entitypatch\.player|com.tiedup.remake.rig.patch|g' \
-e 's|yesman\.epicfight\.client\.world\.capabilites\.entitypatch|com.tiedup.remake.rig.patch|g' \
-e 's|yesman\.epicfight\.client\.world\.capabilites|com.tiedup.remake.rig.patch|g' \
-e 's|yesman\.epicfight\.client\.renderer\.patched\.entity|com.tiedup.remake.rig.render|g' \
-e 's|yesman\.epicfight\.client\.renderer\.patched|com.tiedup.remake.rig.render|g' \
-e 's|yesman\.epicfight\.client\.renderer\.EpicFightRenderTypes|com.tiedup.remake.rig.render.TiedUpRenderTypes|g' \
-e 's|yesman\.epicfight\.client\.mesh|com.tiedup.remake.rig.mesh|g' \
{} +
echo ""
echo "Verifying no yesman.epicfight references remain..."
remaining=$(grep -r "yesman\.epicfight" "$TARGET" 2>/dev/null | wc -l)
if [ "$remaining" -eq 0 ]; then
echo "OK - all imports rewritten."
else
echo "WARN - $remaining residual refs found:"
grep -rn "yesman\.epicfight" "$TARGET" | head -20
echo "..."
echo "(affichage limité aux 20 premiers)"
fi

View File

@@ -125,6 +125,18 @@ public class ModKeybindings {
CATEGORY CATEGORY
); );
/**
* RIG debug overlay keybinding (P3-19) - Toggle the F3+B-style debug
* overlay that displays rig state in real-time.
* Default: F6 (unused by vanilla Minecraft).
*/
public static final KeyMapping RIG_DEBUG_KEY = new KeyMapping(
"key.tiedup.rig_debug",
InputConstants.Type.KEYSYM,
org.lwjgl.glfw.GLFW.GLFW_KEY_F6, // Default key: F6
CATEGORY
);
/** Track last sent state to avoid spamming packets */ /** Track last sent state to avoid spamming packets */
private static boolean lastForceSeatState = false; private static boolean lastForceSeatState = false;
@@ -149,7 +161,8 @@ public class ModKeybindings {
event.register(BOUNTY_KEY); event.register(BOUNTY_KEY);
event.register(FORCE_SEAT_KEY); event.register(FORCE_SEAT_KEY);
event.register(TIGHTEN_KEY); event.register(TIGHTEN_KEY);
TiedUpMod.LOGGER.info("Registered {} keybindings", 7); event.register(RIG_DEBUG_KEY);
TiedUpMod.LOGGER.info("Registered {} keybindings", 8);
} }
// ==================== STRUGGLE MINI-GAME (uses vanilla movement keys) ==================== // ==================== STRUGGLE MINI-GAME (uses vanilla movement keys) ====================
@@ -212,6 +225,19 @@ public class ModKeybindings {
return; return;
} }
// RIG debug overlay toggle (P3-19) — F6 by default.
// Consumed BEFORE the player/level guard: it's a pure client-side
// boolean flip, needs no world context. Otherwise clicks queued on
// main menu / loading screen would flush on world-join → phantom
// toggle (reviewer SMELL-02).
while (RIG_DEBUG_KEY.consumeClick()) {
boolean nowOn = com.tiedup.remake.rig.debug.RigDebugOverlay.toggle();
TiedUpMod.LOGGER.debug(
"[CLIENT] RIG debug overlay: {}",
nowOn ? "ENABLED" : "DISABLED"
);
}
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
if (mc.player == null || mc.level == null) { if (mc.player == null || mc.level == null) {
return; return;

View File

@@ -1,13 +1,11 @@
package com.tiedup.remake.client.animation; package com.tiedup.remake.client.animation;
import com.mojang.logging.LogUtils; import com.mojang.logging.LogUtils;
import com.tiedup.remake.v2.furniture.ISeatProvider;
import dev.kosmx.playerAnim.api.layered.IAnimation; import dev.kosmx.playerAnim.api.layered.IAnimation;
import dev.kosmx.playerAnim.api.layered.KeyframeAnimationPlayer; import dev.kosmx.playerAnim.api.layered.KeyframeAnimationPlayer;
import dev.kosmx.playerAnim.api.layered.ModifierLayer; import dev.kosmx.playerAnim.api.layered.ModifierLayer;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation; import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import dev.kosmx.playerAnim.impl.IAnimatedPlayer; import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationAccess;
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationFactory; import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationFactory;
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry; import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
import java.util.Map; import java.util.Map;
@@ -15,7 +13,6 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.Dist;
@@ -39,92 +36,42 @@ public class BondageAnimationManager {
private static final Logger LOGGER = LogUtils.getLogger(); private static final Logger LOGGER = LogUtils.getLogger();
/** Cache of ModifierLayers for NPC entities (players use PlayerAnimationAccess) */ /** Cache of item-layer ModifierLayers for NPC entities. */
private static final Map<UUID, ModifierLayer<IAnimation>> npcLayers = private static final Map<UUID, ModifierLayer<IAnimation>> npcLayers =
new ConcurrentHashMap<>(); new ConcurrentHashMap<>();
/** Cache of context ModifierLayers for NPC entities */ /** Cache of context-layer ModifierLayers for NPC entities. */
private static final Map<UUID, ModifierLayer<IAnimation>> npcContextLayers = private static final Map<UUID, ModifierLayer<IAnimation>> npcContextLayers =
new ConcurrentHashMap<>(); new ConcurrentHashMap<>();
/** Cache of furniture ModifierLayers for NPC entities */
private static final Map<
UUID,
ModifierLayer<IAnimation>
> npcFurnitureLayers = new ConcurrentHashMap<>();
/** Factory ID for PlayerAnimator item layer (players only) */
private static final ResourceLocation FACTORY_ID =
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage");
/** Factory ID for PlayerAnimator context layer (players only) */
private static final ResourceLocation CONTEXT_FACTORY_ID =
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_context");
/** Factory ID for PlayerAnimator furniture layer (players only) */
private static final ResourceLocation FURNITURE_FACTORY_ID =
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_furniture");
/** Priority for context animation layer (lower = overridable by item layer) */ /** Priority for context animation layer (lower = overridable by item layer) */
private static final int CONTEXT_LAYER_PRIORITY = 40; private static final int CONTEXT_LAYER_PRIORITY = 40;
/** Priority for item animation layer (higher = overrides context layer) */ /** Priority for item animation layer (higher = overrides context layer) */
private static final int ITEM_LAYER_PRIORITY = 42; private static final int ITEM_LAYER_PRIORITY = 42;
/**
* Priority for furniture animation layer (highest = overrides item layer on blocked bones).
* Non-blocked bones are disabled so items can still animate them via the item layer.
*/
private static final int FURNITURE_LAYER_PRIORITY = 43;
/** Number of ticks to wait before removing a stale furniture animation. */
private static final int FURNITURE_GRACE_TICKS = 3;
/**
* Tracks ticks since a player with an active furniture animation stopped riding
* an ISeatProvider. After {@link #FURNITURE_GRACE_TICKS}, the animation is removed
* to prevent stuck poses from entity death or network issues.
*
* <p>Uses ConcurrentHashMap for safe access from both client tick and render thread.</p>
*/
private static final Map<UUID, Integer> furnitureGraceTicks =
new ConcurrentHashMap<>();
/** /**
* Initialize the animation system. * Initialize the animation system.
* Must be called during client setup to register the player animation factory. *
* <p><b>Pipeline NPC-only</b> — depuis Phase 2.7, les joueurs sont tickés par
* {@code RigAnimationTickHandler} via le renderer RIG patched. Aucune
* {@link PlayerAnimationFactory} n'est enregistrée pour le joueur et tous
* les chemins joueur dans cette classe sont de short-circuits logués.</p>
*
* <p>Cette classe reste active <b>uniquement pour les NPCs</b>
* (entités implémentant {@link IAnimatedPlayer} qui ne sont pas un
* {@link Player}) : {@link #getOrCreateLayer} leur crée un {@link ModifierLayer}
* via accès direct au stack d'animation
* ({@code animated.getAnimationStack().addAnimLayer(...)}) — ce path ne dépend
* d'aucune factory. Consumer principal : {@code NpcAnimationTickHandler}.</p>
*
* <p>Conservé comme méthode publique pour ne pas casser les call sites
* externes. Rework V3 (player anim natives RIG) : voir V3-REW-01 dans
* {@code docs/plans/rig/V3_REWORK_BACKLOG.md}.</p>
*/ */
public static void init() { public static void init() {
LOGGER.info("BondageAnimationManager initializing...");
// Context layer: lower priority = evaluated first, overridable by item layer.
// In AnimationStack, layers are sorted ascending by priority and evaluated in order.
// Higher priority layers override lower ones.
PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
CONTEXT_FACTORY_ID,
CONTEXT_LAYER_PRIORITY,
player -> new ModifierLayer<>()
);
// Item layer: higher priority = evaluated last, overrides context layer
PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
FACTORY_ID,
ITEM_LAYER_PRIORITY,
player -> new ModifierLayer<>()
);
// Furniture layer: highest priority = overrides item layer on blocked bones.
// Non-blocked bones are disabled via FurnitureAnimationContext so items
// can still animate free regions (gag, blindfold, etc.).
PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
FURNITURE_FACTORY_ID,
FURNITURE_LAYER_PRIORITY,
player -> new ModifierLayer<>()
);
LOGGER.info( LOGGER.info(
"BondageAnimationManager: Factories registered — context (pri {}), item (pri {}), furniture (pri {})", "BondageAnimationManager: NPC-only pipeline (Phase 2.8 RIG cleanup). " +
CONTEXT_LAYER_PRIORITY, "Players handled by RigAnimationTickHandler; all player call sites no-op."
ITEM_LAYER_PRIORITY,
FURNITURE_LAYER_PRIORITY
); );
} }
@@ -151,6 +98,11 @@ public class BondageAnimationManager {
* <p>If the animation layer is not available (e.g., remote player not fully * <p>If the animation layer is not available (e.g., remote player not fully
* initialized), the animation will be queued for retry via PendingAnimationManager. * initialized), the animation will be queued for retry via PendingAnimationManager.
* *
* <p><b>Phase 2.8</b> — les appels sur un {@link Player} sont no-op : le pipeline
* joueur est désormais RIG-native (voir {@link #init} Javadoc). Un WARN est logué
* une fois par UUID pour signaler les call sites stale qui devraient être purgés
* lors du rework V3.</p>
*
* @param entity The entity to animate * @param entity The entity to animate
* @param animId Full ResourceLocation of the animation * @param animId Full ResourceLocation of the animation
* @return true if animation started successfully, false if layer not available * @return true if animation started successfully, false if layer not available
@@ -163,6 +115,12 @@ public class BondageAnimationManager {
return false; return false;
} }
// Phase 2.8 : player path is dead. Log once per UUID and no-op.
if (entity instanceof Player player) {
logPlayerCallOnce(player, "playAnimation(" + animId + ")");
return false;
}
KeyframeAnimation anim = PlayerAnimationRegistry.getAnimation(animId); KeyframeAnimation anim = PlayerAnimationRegistry.getAnimation(animId);
if (anim == null) { if (anim == null) {
// Try fallback: remove _sneak_ suffix if present // Try fallback: remove _sneak_ suffix if present
@@ -199,7 +157,7 @@ public class BondageAnimationManager {
} }
layer.setAnimation(new KeyframeAnimationPlayer(anim)); layer.setAnimation(new KeyframeAnimationPlayer(anim));
// Remove from pending queue if it was waiting // Remove from pending queue if it was waiting (legacy, may still hold NPC entries)
PendingAnimationManager.remove(entity.getUUID()); PendingAnimationManager.remove(entity.getUUID());
LOGGER.debug( LOGGER.debug(
@@ -209,23 +167,11 @@ public class BondageAnimationManager {
); );
return true; return true;
} else { } else {
// Layer not available - queue for retry if it's a player LOGGER.warn(
if (entity instanceof AbstractClientPlayer) { "Animation layer is NULL for NPC: {} (type: {})",
PendingAnimationManager.queueForRetry( entity.getName().getString(),
entity.getUUID(), entity.getClass().getSimpleName()
animId.getPath() );
);
LOGGER.debug(
"Animation layer not ready for {}, queued for retry",
entity.getName().getString()
);
} else {
LOGGER.warn(
"Animation layer is NULL for NPC: {} (type: {})",
entity.getName().getString(),
entity.getClass().getSimpleName()
);
}
return false; return false;
} }
} }
@@ -246,6 +192,12 @@ public class BondageAnimationManager {
return false; return false;
} }
// Phase 2.8 : player path is dead.
if (entity instanceof Player player) {
logPlayerCallOnce(player, "playDirect");
return false;
}
ModifierLayer<IAnimation> layer = getOrCreateLayer(entity); ModifierLayer<IAnimation> layer = getOrCreateLayer(entity);
if (layer != null) { if (layer != null) {
IAnimation current = layer.getAnimation(); IAnimation current = layer.getAnimation();
@@ -273,6 +225,11 @@ public class BondageAnimationManager {
return; return;
} }
// Phase 2.8 : player path is dead — no layer to clear.
if (entity instanceof Player) {
return;
}
ModifierLayer<IAnimation> layer = getLayer(entity); ModifierLayer<IAnimation> layer = getLayer(entity);
if (layer != null) { if (layer != null) {
layer.setAnimation(null); layer.setAnimation(null);
@@ -284,56 +241,36 @@ public class BondageAnimationManager {
/** /**
* Get the ModifierLayer for an entity (without creating). * Get the ModifierLayer for an entity (without creating).
*
* <p>Phase 2.8 : returns {@code null} directly for any {@link Player} — the
* player animation pipeline is RIG-native, this manager only tracks NPCs.</p>
*/ */
private static ModifierLayer<IAnimation> getLayer(LivingEntity entity) { private static ModifierLayer<IAnimation> getLayer(LivingEntity entity) {
// Players: try PlayerAnimationAccess first, then cache if (entity instanceof Player) {
if (entity instanceof AbstractClientPlayer player) { return null;
ModifierLayer<IAnimation> factoryLayer = getPlayerLayer(player);
if (factoryLayer != null) {
return factoryLayer;
}
// Check cache (for remote players using fallback)
return npcLayers.get(entity.getUUID());
} }
// NPCs: use cache
return npcLayers.get(entity.getUUID()); return npcLayers.get(entity.getUUID());
} }
/** /**
* Get or create the ModifierLayer for an entity. * Get or create the ModifierLayer for an entity.
*
* <p>Phase 2.8 : returns {@code null} directly for any {@link Player} — the
* player fallback via {@code IAnimatedPlayer.getAnimationStack()} has been
* retired because it was partially alive (FP vanilla render consumed it,
* TP RIG override bypassed it), producing a confusing behavior split. All
* player anim needs are now handled by {@code RigAnimationTickHandler}.</p>
*/ */
@SuppressWarnings("unchecked")
private static ModifierLayer<IAnimation> getOrCreateLayer( private static ModifierLayer<IAnimation> getOrCreateLayer(
LivingEntity entity LivingEntity entity
) { ) {
UUID uuid = entity.getUUID(); // Phase 2.8 : strip player path entirely (no partially-alive fallback).
if (entity instanceof Player) {
// Players: try factory-based access first, fallback to direct stack access return null;
if (entity instanceof AbstractClientPlayer player) {
// Try the registered factory first (works for local player)
ModifierLayer<IAnimation> factoryLayer = getPlayerLayer(player);
if (factoryLayer != null) {
return factoryLayer;
}
// Fallback for remote players: use direct stack access like NPCs
// This handles cases where the factory data isn't available
if (player instanceof IAnimatedPlayer animated) {
return npcLayers.computeIfAbsent(uuid, k -> {
ModifierLayer<IAnimation> newLayer = new ModifierLayer<>();
animated
.getAnimationStack()
.addAnimLayer(ITEM_LAYER_PRIORITY, newLayer);
LOGGER.info(
"Created animation layer for remote player via stack: {}",
player.getName().getString()
);
return newLayer;
});
}
} }
UUID uuid = entity.getUUID();
// NPCs implementing IAnimatedPlayer: create/cache layer // NPCs implementing IAnimatedPlayer: create/cache layer
if (entity instanceof IAnimatedPlayer animated) { if (entity instanceof IAnimatedPlayer animated) {
return npcLayers.computeIfAbsent(uuid, k -> { return npcLayers.computeIfAbsent(uuid, k -> {
@@ -353,87 +290,49 @@ public class BondageAnimationManager {
return null; return null;
} }
/** Per-player dedup set so we log the factory-access failure at most once per UUID. */ /** Per-player-UUID dedup so stale call sites log at most once per session. */
private static final java.util.Set<UUID> layerFailureLogged = private static final java.util.Set<UUID> playerCallLogged =
java.util.concurrent.ConcurrentHashMap.newKeySet(); java.util.concurrent.ConcurrentHashMap.newKeySet();
/** /**
* Get the animation layer for a player from PlayerAnimationAccess. * Log once per player UUID that a stale call site is invoking this manager.
* * Used by the player no-op short-circuits ({@link #playAnimation},
* <p>Throws during the factory-race window for remote players (the factory * {@link #playDirect}) to surface call sites that should be migrated to the
* hasn't yet initialized their associated data). This is the expected path * RIG pipeline (tracked in V3_REWORK_BACKLOG).
* for the {@link PendingAnimationManager} retry loop, so we log at DEBUG
* and at most once per UUID — a per-tick log would flood during busy
* multiplayer.</p>
*/ */
@SuppressWarnings("unchecked") private static void logPlayerCallOnce(Player player, String op) {
private static ModifierLayer<IAnimation> getPlayerLayer( if (playerCallLogged.add(player.getUUID())) {
AbstractClientPlayer player LOGGER.warn(
) { "BondageAnimationManager.{} called on player {} — no-op " +
try { "(RIG owns player anims since Phase 2.7). " +
return (ModifierLayer< "Migrate call site to RigAnimationTickHandler (V3 rework).",
IAnimation op,
>) PlayerAnimationAccess.getPlayerAssociatedData(player).get( player.getName().getString()
FACTORY_ID
); );
} catch (Exception e) {
if (layerFailureLogged.add(player.getUUID())) {
LOGGER.debug(
"Animation layer not yet available for player {} (will retry): {}",
player.getName().getString(),
e.toString()
);
}
return null;
} }
} }
/** /**
* Safely get the animation layer for a player. * Safely get the animation layer for a player.
* Returns null if the layer is not yet initialized.
* *
* <p>Public method for PendingAnimationManager to access. * <p>Phase 2.8 : always returns {@code null}. The player pipeline is
* Checks both the factory-based layer and the NPC cache fallback. * RIG-native; the {@link PendingAnimationManager} retry loop is no
* longer fed (player calls to {@link #playAnimation} short-circuit
* before queueing), so this getter is maintained only to preserve the
* public signature for external call sites.</p>
* *
* @param player The player * @param player The player (unused)
* @return The animation layer, or null if not available * @return always null in Phase 2.8+
*/ */
@javax.annotation.Nullable @javax.annotation.Nullable
public static ModifierLayer<IAnimation> getPlayerLayerSafe( public static ModifierLayer<IAnimation> getPlayerLayerSafe(
AbstractClientPlayer player AbstractClientPlayer player
) { ) {
// Try factory first return null;
ModifierLayer<IAnimation> factoryLayer = getPlayerLayer(player);
if (factoryLayer != null) {
return factoryLayer;
}
// Check NPC cache (for remote players using fallback path)
return npcLayers.get(player.getUUID());
} }
// CONTEXT LAYER (lower priority, for sit/kneel/sneak) // CONTEXT LAYER (lower priority, for sit/kneel/sneak)
/**
* Get the context animation layer for a player from PlayerAnimationAccess.
* Returns null if the layer is not yet initialized.
*/
@SuppressWarnings("unchecked")
@javax.annotation.Nullable
private static ModifierLayer<IAnimation> getPlayerContextLayer(
AbstractClientPlayer player
) {
try {
return (ModifierLayer<
IAnimation
>) PlayerAnimationAccess.getPlayerAssociatedData(player).get(
CONTEXT_FACTORY_ID
);
} catch (Exception e) {
return null;
}
}
/** /**
* Get or create the context animation layer for an NPC entity. * Get or create the context animation layer for an NPC entity.
* Uses CONTEXT_LAYER_PRIORITY, below the item layer at ITEM_LAYER_PRIORITY. * Uses CONTEXT_LAYER_PRIORITY, below the item layer at ITEM_LAYER_PRIORITY.
@@ -471,13 +370,14 @@ public class BondageAnimationManager {
return false; return false;
} }
ModifierLayer<IAnimation> layer; // Phase 2.8 : player context layer is dead (sit/kneel/sneak visuals
if (entity instanceof AbstractClientPlayer player) { // will be re-expressed as RIG StaticAnimations — cf. V3-REW-14).
layer = getPlayerContextLayer(player); if (entity instanceof Player player) {
} else { logPlayerCallOnce(player, "playContext");
layer = getOrCreateNpcContextLayer(entity); return false;
} }
ModifierLayer<IAnimation> layer = getOrCreateNpcContextLayer(entity);
if (layer != null) { if (layer != null) {
layer.setAnimation(new KeyframeAnimationPlayer(anim)); layer.setAnimation(new KeyframeAnimationPlayer(anim));
return true; return true;
@@ -495,13 +395,12 @@ public class BondageAnimationManager {
return; return;
} }
ModifierLayer<IAnimation> layer; // Phase 2.8 : player path is dead — no layer to clear.
if (entity instanceof AbstractClientPlayer player) { if (entity instanceof Player) {
layer = getPlayerContextLayer(player); return;
} else {
layer = npcContextLayers.get(entity.getUUID());
} }
ModifierLayer<IAnimation> layer = npcContextLayers.get(entity.getUUID());
if (layer != null) { if (layer != null) {
layer.setAnimation(null); layer.setAnimation(null);
} }
@@ -533,194 +432,46 @@ public class BondageAnimationManager {
return false; return false;
} }
ModifierLayer<IAnimation> layer = getOrCreateFurnitureLayer(player); // Phase 2.8 : player furniture seat pose is dead (will be ported to
if (layer != null) { // RIG StaticAnimations — cf. V3_REWORK_BACKLOG furniture seat entry).
layer.setAnimation(new KeyframeAnimationPlayer(animation)); logPlayerCallOnce(player, "playFurniture");
// Reset grace ticks since we just started/refreshed the animation
furnitureGraceTicks.remove(player.getUUID());
LOGGER.debug(
"Playing furniture animation on player: {}",
player.getName().getString()
);
return true;
}
LOGGER.warn(
"Furniture layer not available for player: {}",
player.getName().getString()
);
return false; return false;
} }
/** /**
* Stop the furniture layer animation for a player. * Stop the furniture layer animation for a player.
* *
* <p>Phase 2.8 : no-op — the player furniture layer is dead. Kept for
* signature compatibility with {@code EntityFurniture} cleanup call site.</p>
*
* @param player the player whose furniture animation should stop * @param player the player whose furniture animation should stop
*/ */
public static void stopFurniture(Player player) { public static void stopFurniture(Player player) {
if (player == null || !player.level().isClientSide()) { // Phase 2.8 : dead path. Retained signature for backward-compat.
return;
}
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
if (layer != null) {
layer.setAnimation(null);
}
furnitureGraceTicks.remove(player.getUUID());
LOGGER.debug(
"Stopped furniture animation on player: {}",
player.getName().getString()
);
} }
/** /**
* Check whether a player currently has an active furniture animation. * Check whether a player currently has an active furniture animation.
* *
* <p>Phase 2.8 : always returns {@code false} — player furniture layer is dead.</p>
*
* @param player the player to check * @param player the player to check
* @return true if the furniture layer has an active animation * @return always false in Phase 2.8+
*/ */
public static boolean hasFurnitureAnimation(Player player) { public static boolean hasFurnitureAnimation(Player player) {
if (player == null || !player.level().isClientSide()) { return false;
return false;
}
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
return layer != null && layer.getAnimation() != null;
} }
/** /**
* Get the furniture ModifierLayer for a player (READ-ONLY). * Safety tick for furniture animations.
* Uses PlayerAnimationAccess for local/factory-registered players,
* falls back to NPC cache for remote players. Returns null if no layer
* has been created yet — callers that need to guarantee a layer should use
* {@link #getOrCreateFurnitureLayer}.
*/
@SuppressWarnings("unchecked")
@javax.annotation.Nullable
private static ModifierLayer<IAnimation> getFurnitureLayer(Player player) {
if (player instanceof AbstractClientPlayer clientPlayer) {
try {
ModifierLayer<IAnimation> layer = (ModifierLayer<
IAnimation
>) PlayerAnimationAccess.getPlayerAssociatedData(
clientPlayer
).get(FURNITURE_FACTORY_ID);
if (layer != null) {
return layer;
}
} catch (Exception e) {
// Fall through to NPC cache
}
// Fallback for remote players: check NPC furniture cache
return npcFurnitureLayers.get(player.getUUID());
}
// Non-player entities: use NPC cache
return npcFurnitureLayers.get(player.getUUID());
}
/**
* Get or create the furniture ModifierLayer for a player. Mirrors
* {@link #getOrCreateLayer} but for the FURNITURE layer priority.
* *
* <p>For the local player (factory-registered), returns the factory layer. * <p>Phase 2.8 : no-op — the player furniture layer is dead, nothing to
* For remote players, creates a new layer on first call and caches it in * guard. Kept as an empty stub in case older call sites remain.</p>
* {@link #npcFurnitureLayers} — remote players don't own a factory layer,
* so without a fallback they can't receive any furniture seat pose.</p>
*/
@SuppressWarnings("unchecked")
@javax.annotation.Nullable
private static ModifierLayer<IAnimation> getOrCreateFurnitureLayer(
Player player
) {
if (player instanceof AbstractClientPlayer clientPlayer) {
try {
ModifierLayer<IAnimation> layer = (ModifierLayer<
IAnimation
>) PlayerAnimationAccess.getPlayerAssociatedData(
clientPlayer
).get(FURNITURE_FACTORY_ID);
if (layer != null) {
return layer;
}
} catch (Exception e) {
// Fall through to fallback-create below.
}
// Remote players: fallback-create via the animation stack.
if (clientPlayer instanceof IAnimatedPlayer animated) {
return npcFurnitureLayers.computeIfAbsent(
clientPlayer.getUUID(),
k -> {
ModifierLayer<IAnimation> newLayer =
new ModifierLayer<>();
animated
.getAnimationStack()
.addAnimLayer(FURNITURE_LAYER_PRIORITY, newLayer);
LOGGER.debug(
"Created furniture animation layer for remote player via stack: {}",
clientPlayer.getName().getString()
);
return newLayer;
}
);
}
return npcFurnitureLayers.get(clientPlayer.getUUID());
}
// Non-player entities: use NPC cache (read-only; NPC furniture animation
// is not currently produced by this codebase).
return npcFurnitureLayers.get(player.getUUID());
}
/**
* Safety tick for furniture animations. Call once per client tick per player.
* *
* <p>If a player has an active furniture animation but is NOT riding an * @param player the player to check (unused)
* {@link ISeatProvider}, increment a grace counter. After
* {@link #FURNITURE_GRACE_TICKS} consecutive ticks without a seat, the
* animation is removed to prevent stuck poses from entity death, network
* desync, or teleportation.</p>
*
* <p>If the player IS riding an ISeatProvider, the counter is reset.</p>
*
* @param player the player to check
*/ */
public static void tickFurnitureSafety(Player player) { public static void tickFurnitureSafety(Player player) {
if (player == null || !player.level().isClientSide()) { // Phase 2.8 : dead path. Retained signature for backward-compat.
return;
}
if (!hasFurnitureAnimation(player)) {
// No furniture animation active, nothing to guard
furnitureGraceTicks.remove(player.getUUID());
return;
}
UUID uuid = player.getUUID();
// Check if the player is riding an ISeatProvider
Entity vehicle = player.getVehicle();
boolean ridingSeat = vehicle instanceof ISeatProvider;
if (ridingSeat) {
// Player is properly seated, reset grace counter
furnitureGraceTicks.remove(uuid);
} else {
// Player has furniture anim but no seat -- increment grace
int ticks = furnitureGraceTicks.merge(uuid, 1, Integer::sum);
if (ticks >= FURNITURE_GRACE_TICKS) {
LOGGER.info(
"Removing stale furniture animation for player {} " +
"(not riding ISeatProvider for {} ticks)",
player.getName().getString(),
ticks
);
stopFurniture(player);
}
}
} }
// FALLBACK ANIMATION HANDLING // FALLBACK ANIMATION HANDLING
@@ -789,8 +540,9 @@ public class BondageAnimationManager {
* @param entityId UUID of the removed entity * @param entityId UUID of the removed entity
*/ */
/** All NPC layer caches, for bulk cleanup operations. */ /** All NPC layer caches, for bulk cleanup operations. */
@SuppressWarnings({ "unchecked", "rawtypes" })
private static final Map<UUID, ModifierLayer<IAnimation>>[] ALL_NPC_CACHES = private static final Map<UUID, ModifierLayer<IAnimation>>[] ALL_NPC_CACHES =
new Map[] { npcLayers, npcContextLayers, npcFurnitureLayers }; new Map[] { npcLayers, npcContextLayers };
public static void cleanup(UUID entityId) { public static void cleanup(UUID entityId) {
for (Map<UUID, ModifierLayer<IAnimation>> cache : ALL_NPC_CACHES) { for (Map<UUID, ModifierLayer<IAnimation>> cache : ALL_NPC_CACHES) {
@@ -799,8 +551,7 @@ public class BondageAnimationManager {
layer.setAnimation(null); layer.setAnimation(null);
} }
} }
furnitureGraceTicks.remove(entityId); playerCallLogged.remove(entityId);
layerFailureLogged.remove(entityId);
LOGGER.debug("Cleaned up animation layers for entity: {}", entityId); LOGGER.debug("Cleaned up animation layers for entity: {}", entityId);
} }
@@ -813,8 +564,7 @@ public class BondageAnimationManager {
cache.values().forEach(layer -> layer.setAnimation(null)); cache.values().forEach(layer -> layer.setAnimation(null));
cache.clear(); cache.clear();
} }
furnitureGraceTicks.clear(); playerCallLogged.clear();
layerFailureLogged.clear();
LOGGER.info("Cleared all NPC animation layers"); LOGGER.info("Cleared all NPC animation layers");
} }
} }

View File

@@ -52,8 +52,15 @@ public class DogPoseRenderHandler {
/** /**
* Get the rotation delta applied to a player's render for DOG pose. * Get the rotation delta applied to a player's render for DOG pose.
* Used by MixinPlayerModel to compensate head rotation. *
* @deprecated since Phase 2.8 — this getter fed {@code MixinPlayerModel}
* (removed Phase 2.8 RIG cleanup) so head rotation could be compensated
* against the body's -90° pitch. No remaining reader. To be deleted
* when V3-REW-07 re-expresses dog pose head compensation as a RIG
* {@code StaticAnimation pose_dog.json}. See
* {@code docs/plans/rig/V3_REWORK_BACKLOG.md#V3-REW-07}.
*/ */
@Deprecated(since = "2.8")
public static float getAppliedRotationDelta(UUID playerUuid) { public static float getAppliedRotationDelta(UUID playerUuid) {
float[] state = dogPoseState.get(playerUuid); float[] state = dogPoseState.get(playerUuid);
return state != null ? state[IDX_DELTA] : 0f; return state != null ? state[IDX_DELTA] : 0f;
@@ -61,7 +68,14 @@ public class DogPoseRenderHandler {
/** /**
* Check if a player is currently moving in DOG pose. * Check if a player is currently moving in DOG pose.
*
* @deprecated since Phase 2.8 — same cause as {@link #getAppliedRotationDelta}
* (fed {@code MixinPlayerModel}, now removed). To be deleted alongside
* V3-REW-07 when dog pose head compensation is re-expressed as a RIG
* {@code StaticAnimation pose_dog.json}. See
* {@code docs/plans/rig/V3_REWORK_BACKLOG.md#V3-REW-07}.
*/ */
@Deprecated(since = "2.8")
public static boolean isDogPoseMoving(UUID playerUuid) { public static boolean isDogPoseMoving(UUID playerUuid) {
float[] state = dogPoseState.get(playerUuid); float[] state = dogPoseState.get(playerUuid);
return state != null && state[IDX_MOVING] > 0.5f; return state != null && state[IDX_MOVING] > 0.5f;

View File

@@ -3,29 +3,17 @@ package com.tiedup.remake.client.animation.tick;
import com.mojang.logging.LogUtils; import com.mojang.logging.LogUtils;
import com.tiedup.remake.client.animation.AnimationStateRegistry; import com.tiedup.remake.client.animation.AnimationStateRegistry;
import com.tiedup.remake.client.animation.BondageAnimationManager; import com.tiedup.remake.client.animation.BondageAnimationManager;
import com.tiedup.remake.client.animation.PendingAnimationManager;
import com.tiedup.remake.client.animation.context.AnimationContext;
import com.tiedup.remake.client.animation.context.AnimationContextResolver;
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
import com.tiedup.remake.client.events.CellHighlightHandler; import com.tiedup.remake.client.events.CellHighlightHandler;
import com.tiedup.remake.client.events.LeashProxyClientHandler; import com.tiedup.remake.client.events.LeashProxyClientHandler;
import com.tiedup.remake.client.gltf.GltfAnimationApplier; import com.tiedup.remake.client.gltf.GltfAnimationApplier;
import com.tiedup.remake.client.state.ClothesClientCache; import com.tiedup.remake.client.state.ClothesClientCache;
import com.tiedup.remake.client.state.MovementStyleClientState; import com.tiedup.remake.client.state.MovementStyleClientState;
import com.tiedup.remake.client.state.PetBedClientState; import com.tiedup.remake.client.state.PetBedClientState;
import com.tiedup.remake.util.HumanChairHelper;
import com.tiedup.remake.state.PlayerBindState; import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn; import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.event.TickEvent; import net.minecraftforge.event.TickEvent;
@@ -35,16 +23,29 @@ import net.minecraftforge.fml.common.Mod;
import org.slf4j.Logger; import org.slf4j.Logger;
/** /**
* Event handler for player animation tick updates. * Event handler for animation tick updates.
* *
* <p>Simplified handler that: * <p><b>Phase 2.8 RIG cleanup</b> : le ticking <i>player</i> V2 (boucle
* {@code mc.level.players()} + {@code updatePlayerAnimation}) est entièrement
* désactivé. Les joueurs sont désormais pilotés par
* {@link com.tiedup.remake.rig.tick.RigAnimationTickHandler} via le pipeline
* RIG (capability {@code LivingEntityPatch} + {@code Animator} natif EF).
* Les features V2 qui dépendaient du tick player sont trackées dans
* {@code docs/plans/rig/V3_REWORK_BACKLOG.md} (V3-REW-01/02/03/07).</p>
*
* <p>Restent actifs ici :
* <ul> * <ul>
* <li>Tracks tied/struggling/sneaking state for players</li> * <li>Nettoyage périodique de {@code ClothesClientCache} (cache remote
* <li>Plays animations via BondageAnimationManager when state changes</li> * players, hygiène mémoire indépendante du pipeline de rendu)</li>
* <li>Handles cleanup on logout/world unload</li> * <li>Cleanup logout / world unload (caches V2 encore utilisés par les
* NPCs ticked par {@link NpcAnimationTickHandler})</li>
* </ul> * </ul>
* *
* <p>Registered on the FORGE event bus (not MOD bus). * <p>Le ticking NPC est assuré par {@link NpcAnimationTickHandler}. Ce
* handler ne tick plus les NPCs directement — il ne gère que les hooks
* lifecycle globaux (logout + world unload).</p>
*
* <p>Registered on the FORGE event bus (not MOD bus).</p>
*/ */
@Mod.EventBusSubscriber( @Mod.EventBusSubscriber(
modid = "tiedup", modid = "tiedup",
@@ -83,8 +84,20 @@ public class AnimationTickHandler {
} }
/** /**
* Client tick event - called every tick on the client. * Client tick event called every tick on the client.
* Updates animations for all players when their bondage state changes. *
* <p>Phase 2.8 : la boucle {@code mc.level.players()} qui appelait
* {@code updatePlayerAnimation}, {@code tickFurnitureSafety} et le
* cold-cache retry furniture a été entièrement supprimée. Les joueurs
* sont désormais ticked par {@link com.tiedup.remake.rig.tick.RigAnimationTickHandler}
* via le pipeline RIG (capability {@code LivingEntityPatch} +
* {@code Animator}). Les régressions visuelles (V2 bondage layer cassé,
* furniture seat pose sur joueur cassée, pet bed pose cassée) sont
* listées dans {@code docs/plans/rig/V3_REWORK_BACKLOG.md}.</p>
*
* <p>Seul le nettoyage périodique de {@link ClothesClientCache} reste
* — c'est de l'hygiène mémoire sur un cache indexé UUID joueur,
* indépendant du pipeline de rendu.</p>
*/ */
@SubscribeEvent @SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) { public static void onClientTick(TickEvent.ClientTickEvent event) {
@@ -97,193 +110,17 @@ public class AnimationTickHandler {
return; return;
} }
// Process pending animations first (retry failed animations for remote players) // Periodic cleanup of stale clothes cache entries (every 60 seconds = 1200 ticks).
PendingAnimationManager.processPending(mc.level); // Indépendant du rendu V2/RIG — c'est juste un cache UUID→ClothesData qui
// doit libérer la mémoire des joueurs déconnectés depuis >5min.
// Periodic cleanup of stale cache entries (every 60 seconds = 1200 ticks)
if (++cleanupTickCounter >= 1200) { if (++cleanupTickCounter >= 1200) {
cleanupTickCounter = 0; cleanupTickCounter = 0;
ClothesClientCache.cleanupStale(); ClothesClientCache.cleanupStale();
} }
// Then update all player animations // Le tick per-player V2 (updatePlayerAnimation, tickFurnitureSafety,
for (Player player : mc.level.players()) { // cold-cache furniture retry) est délégué à RigAnimationTickHandler
if (player instanceof AbstractClientPlayer clientPlayer) { // Phase 2.7+. Rien à faire ici.
updatePlayerAnimation(clientPlayer);
}
// Safety: remove stale furniture animations for players no longer on seats
BondageAnimationManager.tickFurnitureSafety(player);
// Cold-cache retry: if the player is seated on furniture but has no
// active pose (GLB was not yet loaded at mount time, or the GLB cache
// entry was a transient failure), retry until the cache warms.
// FurnitureGltfCache memoizes failures via Optional.empty(), so
// retries after a genuine parse failure return instantly with no
// reparse. Bounded at MAX_FURNITURE_RETRIES so a legacy V1-only
// GLB (no Player_* armature → seatSkeleton==null → no animation
// ever possible) doesn't spam retries at 20 Hz forever.
// Single read of getVehicle() — avoids a re-read where the
// vehicle could change between instanceof and cast.
com.tiedup.remake.v2.furniture.EntityFurniture furniture =
player.getVehicle() instanceof
com.tiedup.remake.v2.furniture.EntityFurniture f ? f : null;
boolean hasAnim = BondageAnimationManager.hasFurnitureAnimation(
player
);
UUID playerUuid = player.getUUID();
if (furniture != null && !hasAnim) {
int retries = furnitureRetryCounters.getOrDefault(
playerUuid,
0
);
if (retries < MAX_FURNITURE_RETRIES) {
furnitureRetryCounters.put(playerUuid, retries + 1);
com.tiedup.remake.v2.furniture.client.FurnitureClientAnimator
.start(furniture, player);
if (retries + 1 == MAX_FURNITURE_RETRIES) {
LOGGER.debug(
"[FurnitureAnim] Giving up on furniture animation retry for {} after {} attempts — GLB likely has no Player_* armature.",
player.getName().getString(),
MAX_FURNITURE_RETRIES
);
}
}
} else {
// Dismounted or successfully applied — drop the counter so a
// later re-mount starts fresh.
furnitureRetryCounters.remove(playerUuid);
}
}
}
/**
* Update animation for a single player.
*/
private static void updatePlayerAnimation(AbstractClientPlayer player) {
// Safety check: skip for removed/dead players
if (player.isRemoved() || !player.isAlive()) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
UUID uuid = player.getUUID();
// Check if player has ANY V2 bondage item equipped (not just ARMS).
// isTiedUp() only checks ARMS, but items on LEGS, HEAD, etc. also need animation.
boolean isTied =
state != null &&
(state.isTiedUp() || V2EquipmentHelper.hasAnyEquipment(player));
boolean wasTied =
AnimationStateRegistry.getLastTiedState().getOrDefault(uuid, false);
// Pet bed animations take priority over bondage animations
if (PetBedClientState.get(uuid) != 0) {
// Lock body rotation to bed facing (prevents camera from rotating the model)
float lockedRot = PetBedClientState.getFacing(uuid);
player.yBodyRot = lockedRot;
player.yBodyRotO = lockedRot;
// Clamp head rotation to ±50° from body (like vehicle)
float headRot = player.getYHeadRot();
float clamped =
lockedRot +
net.minecraft.util.Mth.clamp(
net.minecraft.util.Mth.wrapDegrees(headRot - lockedRot),
-50f,
50f
);
player.setYHeadRot(clamped);
player.yHeadRotO = clamped;
AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
return;
}
// Human chair: clamp 1st-person camera only (body lock handled by MixinLivingEntityBodyRot)
// NO return — animation HUMAN_CHAIR must continue playing below
if (isTied && state != null) {
ItemStack chairBind = state.getEquipment(BodyRegionV2.ARMS);
if (HumanChairHelper.isActive(chairBind)) {
// 1st person only: clamp yRot so player can't look behind
// 3rd person: yRot untouched → camera orbits freely 360°
if (
player == Minecraft.getInstance().player &&
Minecraft.getInstance().options.getCameraType() ==
net.minecraft.client.CameraType.FIRST_PERSON
) {
float lockedRot = HumanChairHelper.getFacing(chairBind);
float camClamped =
lockedRot +
net.minecraft.util.Mth.clamp(
net.minecraft.util.Mth.wrapDegrees(
player.getYRot() - lockedRot
),
-90f,
90f
);
player.setYRot(camClamped);
player.yRotO =
lockedRot +
net.minecraft.util.Mth.clamp(
net.minecraft.util.Mth.wrapDegrees(
player.yRotO - lockedRot
),
-90f,
90f
);
}
}
}
if (isTied) {
// Resolve V2 equipped items
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(
player
);
Map<BodyRegionV2, ItemStack> equipped =
equipment != null ? equipment.getAllEquipped() : Map.of();
// Resolve ALL V2 items with GLB models and per-item bone ownership
java.util.List<RegionBoneMapper.V2ItemAnimInfo> v2Items =
RegionBoneMapper.resolveAllV2Items(equipped);
if (!v2Items.isEmpty()) {
// V2 path: multi-item composite animation
java.util.Set<String> allOwnedParts =
RegionBoneMapper.computeAllOwnedParts(v2Items);
MovementStyle activeStyle = MovementStyleClientState.get(
player.getUUID()
);
AnimationContext context = AnimationContextResolver.resolve(
player,
state,
activeStyle
);
GltfAnimationApplier.applyMultiItemV2Animation(
player,
v2Items,
context,
allOwnedParts
);
} else if (GltfAnimationApplier.hasActiveState(player)) {
// Clear any residual V2 composite animation when the player
// is still isTiedUp() but has no GLB-bearing items — e.g.
// a non-GLB item keeps the tied state, or a GLB item was
// removed while another V2 item remains on a non-animated
// region. Leaving the composite in place locks the arms in
// the pose of an item the player no longer wears.
GltfAnimationApplier.clearV2Animation(player);
}
} else if (wasTied) {
// Was tied, now free - stop all animations
if (GltfAnimationApplier.hasActiveState(player)) {
GltfAnimationApplier.clearV2Animation(player);
} else {
BondageAnimationManager.stopAnimation(player);
}
}
AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
} }
/** /**

View File

@@ -15,22 +15,14 @@ import net.minecraftforge.api.distmarker.OnlyIn;
* <li>Head yaw: convert to zRot (roll) since yRot axis is sideways</li> * <li>Head yaw: convert to zRot (roll) since yRot axis is sideways</li>
* </ul> * </ul>
* *
* <h2>Architecture: Players vs NPCs</h2> * <h2>Architecture — NPCs only (Phase 2.8 RIG cleanup)</h2>
* <pre> * <p>Le path PLAYER (ex-{@code MixinPlayerModel.setupAnim @TAIL}) a été retiré
* ┌─────────────────────────────────────────────────────────────────┐ * Phase 2.8 : le renderer RIG patched ne passe plus par {@code PlayerModel.setupAnim},
* │ PLAYERS │ * donc le mixin devenait dead code. La compensation head dog pose sera ré-exprimée
* ├─────────────────────────────────────────────────────────────────┤ * nativement en StaticAnimation {@code pose_dog.json} (cf. V3-REW-07 dans
* │ 1. PlayerArmHideEventHandler.onRenderPlayerPre() │ * {@code docs/plans/rig/V3_REWORK_BACKLOG.md}).</p>
* │ - Offset vertical (-6 model units) │
* │ - Rotation Y lissée (dogPoseState tracking) │
* │ │
* │ 2. Animation (PlayerAnimator) │
* │ - body.pitch = -90° → appliqué au PoseStack automatiquement │
* │ │
* │ 3. MixinPlayerModel.setupAnim() @TAIL │
* │ - Uses DogPoseHelper.applyHeadCompensationClamped() │
* └─────────────────────────────────────────────────────────────────┘
* *
* <pre>
* ┌─────────────────────────────────────────────────────────────────┐ * ┌─────────────────────────────────────────────────────────────────┐
* │ NPCs │ * │ NPCs │
* ├─────────────────────────────────────────────────────────────────┤ * ├─────────────────────────────────────────────────────────────────┤
@@ -48,25 +40,13 @@ import net.minecraftforge.api.distmarker.OnlyIn;
* └─────────────────────────────────────────────────────────────────┘ * └─────────────────────────────────────────────────────────────────┘
* </pre> * </pre>
* *
* <h2>Key Differences</h2>
* <table>
* <tr><th>Aspect</th><th>Players</th><th>NPCs</th></tr>
* <tr><td>Rotation X application</td><td>Auto by PlayerAnimator</td><td>Manual in setupRotations()</td></tr>
* <tr><td>Rotation Y smoothing</td><td>PlayerArmHideEventHandler</td><td>EntityDamsel.tick() via RotationSmoother</td></tr>
* <tr><td>Head compensation</td><td>MixinPlayerModel</td><td>DamselModel.setupAnim()</td></tr>
* <tr><td>Reset body.xRot</td><td>Not needed</td><td>Yes (prevents double rotation)</td></tr>
* <tr><td>Vertical offset</td><td>-6 model units</td><td>-7 model units</td></tr>
* </table>
*
* <h2>Usage</h2> * <h2>Usage</h2>
* <p>Used by: * <p>Used by:
* <ul> * <ul>
* <li>MixinPlayerModel - for player head compensation</li>
* <li>DamselModel - for NPC head compensation</li> * <li>DamselModel - for NPC head compensation</li>
* </ul> * </ul>
* *
* @see RotationSmoother for Y rotation smoothing * @see RotationSmoother for Y rotation smoothing
* @see com.tiedup.remake.mixin.client.MixinPlayerModel
* @see com.tiedup.remake.client.model.DamselModel * @see com.tiedup.remake.client.model.DamselModel
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
@@ -130,7 +110,14 @@ public final class DogPoseHelper {
* @param headPitch Player's up/down look angle in degrees * @param headPitch Player's up/down look angle in degrees
* @param headYaw Head yaw relative to body in degrees * @param headYaw Head yaw relative to body in degrees
* @param maxYaw Maximum allowed yaw angle in degrees * @param maxYaw Maximum allowed yaw angle in degrees
* @deprecated since Phase 2.8 — player dog pose head compensation was
* previously applied via {@code MixinPlayerModel.setupAnim @TAIL}
* (removed Phase 2.8 RIG cleanup). No remaining call site; retained
* only to preserve the API until V3-REW-07 re-expresses the behavior
* as a RIG {@code StaticAnimation pose_dog.json}. See
* {@code docs/plans/rig/V3_REWORK_BACKLOG.md#V3-REW-07}.
*/ */
@Deprecated(since = "2.8")
public static void applyHeadCompensationClamped( public static void applyHeadCompensationClamped(
ModelPart head, ModelPart head,
ModelPart hat, ModelPart hat,

View File

@@ -137,6 +137,27 @@ public class TiedUpMod {
// Register dispenser behaviors (must be on main thread) // Register dispenser behaviors (must be on main thread)
event.enqueueWork(DispenserBehaviors::register); event.enqueueWork(DispenserBehaviors::register);
// RIG Phase 3 — force class-load des motions pour assigner les
// universalOrdinal() via ExtendableEnumManager (init lazy JLS sinon).
//
// ORDER MATTERS (review RISK-001 on P3-01 commit 15e405f) : LivingMotions
// vanilla EF doit etre class-loaded AVANT TiedUpLivingMotions, sinon nos
// ordinals prennent 0..N, puis les vanilla arriveraient plus tard avec
// les memes ordinals -> collision silencieuse. Les patches EF ne
// garantissent pas que LivingMotions soit touche avant ce hook, donc on
// le force explicitement ici.
com.tiedup.remake.rig.anim.LivingMotions.values();
com.tiedup.remake.rig.anim.TiedUpLivingMotions.values();
// RIG Phase 2 — dispatcher EntityType → EntityPatch (PLAYER Phase 2, NPCs Phase 5)
event.enqueueWork(com.tiedup.remake.rig.patch.EntityPatchProvider::registerEntityPatches);
// RIG — zero Java-side init pour les StaticAnimation. Toutes les anims
// (y compris CONTEXT_STAND_IDLE) sont auto-registered via le bloc
// "constructor" de leur JSON respectif, parsé par
// AnimationManager.readResourcepackAnimation au datapack/resource-pack
// reload. Voir TiedUpAnimationRegistry Javadoc.
} }
/** /**
@@ -185,9 +206,14 @@ public class TiedUpMod {
// Initialize animation system // Initialize animation system
event.enqueueWork(() -> { event.enqueueWork(() -> {
// Initialize unified BondageAnimationManager // RIG Phase 2 — override client dispatch PLAYER → Local/Client/ServerPlayerPatch
com.tiedup.remake.client.animation.BondageAnimationManager.init(); com.tiedup.remake.rig.patch.EntityPatchProvider.registerEntityPatchesClient();
LOGGER.info("BondageAnimationManager initialized");
// Phase 2.8 RIG cleanup : BondageAnimationManager.init() (factory
// registrations PlayerAnimator côté joueur) a été supprimé — le RIG
// prend le relai pour les joueurs via RigAnimationTickHandler.
// Les NPCs continuent d'être animés via BondageAnimationManager en
// accès direct animation stack (cf. NpcAnimationTickHandler).
// Initialize OBJ model registry for 3D bondage items // Initialize OBJ model registry for 3D bondage items
com.tiedup.remake.client.renderer.obj.ObjModelRegistry.init(); com.tiedup.remake.client.renderer.obj.ObjModelRegistry.init();
@@ -589,6 +615,39 @@ public class TiedUpMod {
LOGGER.info( LOGGER.info(
"Registered RoomThemeReloadListener for data-driven room themes" "Registered RoomThemeReloadListener for data-driven room themes"
); );
// Data-driven LivingMotion additions (server-side, from data/<namespace>/tiedup/living_motions/)
// Enables modders to add new LivingMotion values via datapack JSON,
// without writing Java enum extensions. Ordinals remain stable for
// the lifetime of the JVM (see LivingMotionReloadListener javadoc).
event.addListener(
new com.tiedup.remake.rig.anim.LivingMotionReloadListener()
);
LOGGER.info(
"Registered LivingMotionReloadListener for data-driven motion additions"
);
// Data-driven custom armatures (server-side, from data/<namespace>/tiedup/armatures/)
// Enables modders to ship custom armatures (quadruped, centaur, neko, ...)
// via JSON — resolved by TiedUpArmatures.get() for any ID that is not
// the builtin tiedup:biped. See ArmatureReloadListener javadoc (D6).
event.addListener(
new com.tiedup.remake.rig.armature.datapack.ArmatureReloadListener()
);
LOGGER.info(
"Registered ArmatureReloadListener for data-driven custom armatures"
);
// Data-driven PoseType additions (server-side, from data/<namespace>/tiedup/pose_types/)
// Additive registry — the 6 builtin PoseType enum values remain the
// only poses consumable by legacy V1 call-sites. Datapack types are
// visible only to Phase 3 consumers (DataDrivenItemParser, etc.).
event.addListener(
new com.tiedup.remake.v2.bondage.PoseTypeReloadListener()
);
LOGGER.info(
"Registered PoseTypeReloadListener for data-driven pose_type additions"
);
} }
} }
} }

View File

@@ -1,83 +0,0 @@
package com.tiedup.remake.mixin.client;
import com.tiedup.remake.client.animation.render.DogPoseRenderHandler;
import com.tiedup.remake.client.animation.util.DogPoseHelper;
import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.v2.bondage.PoseTypeHelper;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import net.minecraft.client.model.PlayerModel;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Mixin for PlayerModel to handle DOG pose head adjustments.
*
* When in DOG pose (body horizontal):
* - Head pitch offset so player looks forward
* - Head yaw converted to zRot (roll) since yRot axis is sideways when body is horizontal
*/
@Mixin(PlayerModel.class)
public class MixinPlayerModel {
@Inject(method = "setupAnim", at = @At("TAIL"))
private void tiedup$adjustDogPose(
LivingEntity entity,
float limbSwing,
float limbSwingAmount,
float ageInTicks,
float netHeadYaw,
float headPitch,
CallbackInfo ci
) {
if (!(entity instanceof AbstractClientPlayer player)) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) {
return;
}
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
if (bind.isEmpty()) {
return;
}
if (PoseTypeHelper.getPoseType(bind) != PoseType.DOG) {
return;
}
PlayerModel<?> model = (PlayerModel<?>) (Object) this;
// === HEAD ROTATION FOR HORIZONTAL BODY ===
// Body is at -90° pitch (horizontal, face down)
// We apply a rotation delta to the poseStack in PlayerArmHideEventHandler
// The head needs to compensate for this transformation
float rotationDelta = DogPoseRenderHandler.getAppliedRotationDelta(
player.getUUID()
);
boolean moving = DogPoseRenderHandler.isDogPoseMoving(player.getUUID());
// netHeadYaw is head relative to vanilla body (yHeadRot - yBodyRot)
// We rotated the model by rotationDelta, so compensate:
// effectiveHeadYaw = netHeadYaw + rotationDelta
float headYaw = netHeadYaw + rotationDelta;
// Clamp based on movement state and apply head compensation
float maxYaw = moving ? 60f : 90f;
DogPoseHelper.applyHeadCompensationClamped(
model.head,
model.hat,
headPitch,
headYaw,
maxYaw
);
}
}

View File

@@ -64,6 +64,7 @@ import com.tiedup.remake.network.sync.PacketSyncPetBedState;
import com.tiedup.remake.network.sync.PacketSyncStruggleState; import com.tiedup.remake.network.sync.PacketSyncStruggleState;
import com.tiedup.remake.network.trader.PacketBuyCaptive; import com.tiedup.remake.network.trader.PacketBuyCaptive;
import com.tiedup.remake.network.trader.PacketOpenTraderScreen; import com.tiedup.remake.network.trader.PacketOpenTraderScreen;
import com.tiedup.remake.rig.network.PacketPlayRigAnim;
import com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment; import com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment;
import com.tiedup.remake.v2.bondage.network.PacketV2LockToggle; import com.tiedup.remake.v2.bondage.network.PacketV2LockToggle;
import com.tiedup.remake.v2.bondage.network.PacketV2SelfEquip; import com.tiedup.remake.v2.bondage.network.PacketV2SelfEquip;
@@ -592,6 +593,14 @@ public class ModNetwork {
PacketSyncMovementStyle::handle PacketSyncMovementStyle::handle
); );
// RIG animation system (S2C cinematic one-shot)
reg(
PacketPlayRigAnim.class,
PacketPlayRigAnim::encode,
PacketPlayRigAnim::decode,
PacketPlayRigAnim::handleOnClient
);
TiedUpMod.LOGGER.info("Registered {} network packets", packetId); TiedUpMod.LOGGER.info("Registered {} network packets", packetId);
} }

View File

@@ -0,0 +1,29 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig;
/**
* Remplace {@code yesman.epicfight.config.ClientConfig} du fork upstream.
* Expose uniquement les flags pertinents au pipeline animation/rendu RIG.
*
* <p><b>Note Phase 0</b> : les flags sont des {@code static final} avec
* valeurs par défaut hardcodées. À convertir en {@code ForgeConfigSpec} réel
* (TOML config file) Phase 2 ou plus tard si on veut permettre la
* configuration utilisateur.</p>
*/
public final class TiedUpAnimationConfig {
private TiedUpAnimationConfig() {}
/**
* Toggle pour le chemin "compute shader" de {@code SkinnedMesh.draw} —
* quand true et qu'un {@code ComputeShaderSetup} est disponible, la mesh
* est skinnée côté GPU (plus rapide sur modèles lourds). False (défaut)
* = skin CPU comme vanilla.
*/
public static final boolean activateComputeShader = false;
}

View File

@@ -0,0 +1,172 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.anim.AnimationManager;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
/**
* Registry helper (lookup + fallback) pour les {@link StaticAnimation} TiedUp.
*
* <h2>Data-driven — zéro Java hardcoding</h2>
* <p>Depuis la migration {@code CONTEXT_STAND_IDLE} full data-driven, ce
* registry n'instancie plus aucune {@code StaticAnimation} côté Java. Toutes
* les anims TiedUp (y compris l'idle par défaut) sont enregistrées via le bloc
* {@code "constructor"} de leur JSON respectif dans
* {@code assets/tiedup/animmodels/animations/*.json}, parsé par
* {@link AnimationManager#readResourcepackAnimation} à chaque reload de resource
* pack / datapack. Voir {@code armbinder_idle.json} ou
* {@code context_stand_idle.json} pour la forme attendue.</p>
*
* <p>Goal : un modder peut ajouter une anim TiedUp compatible sans écrire UNE
* ligne de Java — il drop un JSON dans
* {@code assets/<modid>/animmodels/animations/}, le référence depuis un item
* JSON binding, {@code /reload}, l'anim fire.</p>
*
* <h2>Ce que le registry expose</h2>
* <ul>
* <li>{@link #CONTEXT_STAND_IDLE_ID} — ID canonique de l'anim idle par
* défaut (résolue en
* {@code assets/tiedup/animmodels/animations/context_stand_idle.json}).
* Consommée par {@code PlayerPatch.initAnimator},
* {@code RigAnimationTickHandler.maybePlayIdle}, etc.</li>
* <li>{@link #resolveWithFallback(ResourceLocation)} — lookup
* {@link AnimationManager#byKey} avec fallback safe sur
* {@link TiedUpRigRegistry#EMPTY_ANIMATION} si l'ID est inconnu (datapack
* pas encore rechargé, typo modder, etc.). Jamais null.</li>
* <li>{@link #resetWarnedMissing()} — hook tests / hot-reload pour purger le
* set dedup des WARN de miss.</li>
* </ul>
*
* <h2>Placeholder assets</h2>
* <p>Les JSON actuels sont des <b>placeholders procéduraux</b> (2 keyframes
* identity) à remplacer par des assets Blender-authored. Voir
* {@code docs/plans/rig/ASSETS_NEEDED.md} section 2 pour la spec de l'anim
* idle définitive (swing respiration subtle 3 keyframes, 2s boucle).</p>
*/
public final class TiedUpAnimationRegistry {
private TiedUpAnimationRegistry() {}
/** Registry name de l'anim idle par défaut (résolue en
* {@code assets/tiedup/animmodels/animations/context_stand_idle.json}).
* L'anim elle-même est auto-registered au datapack reload via le bloc
* {@code "constructor"} du JSON — pas d'init Java. */
public static final ResourceLocation CONTEXT_STAND_IDLE_ID =
ResourceLocation.fromNamespaceAndPath(TiedUpRigConstants.MODID, "context_stand_idle");
/**
* Set (thread-safe) des IDs pour lesquels un WARN de fallback a déjà été
* émis. Évite le spam log si un consumer appelle
* {@link #resolveWithFallback(ResourceLocation)} tick après tick avec un ID
* invalide (ex. un item bondage data-driven qui référence un anim ID cassé,
* appelé dans la boucle de rendu). Un seul WARN par ID unique sur toute la
* durée de vie du process (jusqu'à {@link #resetWarnedMissing()}).
*
* <p>Pattern inspiré de
* {@code RigAnimationTickHandler.LOGGED_ERRORS} — {@code ConcurrentHashMap.newKeySet()}
* pour être safe en cas d'appels concurrents (client tick thread + network
* handler thread pour {@code PacketPlayRigAnim.handleOnClient}).</p>
*/
private static final Set<ResourceLocation> WARNED_MISSING_ANIMS = ConcurrentHashMap.newKeySet();
/**
* Résout une animation par {@link ResourceLocation} avec fallback safe
* si le registry ne la connaît pas.
*
* <p>Utilisé par le pipeline d'équipement
* ({@code ClientRigEquipmentHandler.rebuildBondageAnimations}, P3-05), le
* packet cinematic ({@code PacketPlayRigAnim.handleOnClient}, P3-12), et
* désormais le path idle ({@code PlayerPatch.initAnimator} +
* {@code RigAnimationTickHandler.maybePlayIdle}). Un miss dans le registry
* peut survenir dans plusieurs scénarios :</p>
* <ul>
* <li>Typo modder dans un JSON data-driven bondage item</li>
* <li>Datapack/resource-pack pas encore rechargé (pré-{@code apply} au
* bootstrap, entre deux {@code /reload})</li>
* <li>Animation supprimée entre deux versions du mod</li>
* <li>Race entre packet réception et
* {@code AnimationManager.apply()} en début de session</li>
* </ul>
*
* <p>Dans tous ces cas, on retourne {@link TiedUpRigRegistry#EMPTY_ANIMATION}
* — un singleton qui ne joue rien visuellement (pas de keyframe, pose
* identity). Mieux qu'un NPE pour la robustesse du pipeline : l'anim
* équipement continue de tourner avec les anim connues, et l'anim inconnue
* est juste silencieusement un no-op.</p>
*
* <p><b>Dedup WARN</b> : un miss donné ne log qu'une fois par session
* ({@link #WARNED_MISSING_ANIMS}). Ça évite le spam dans la console quand
* l'ID invalide est consommé tick après tick (ex. équipement resté en place).
* Le set peut être reset via {@link #resetWarnedMissing()} (hot-reload
* F3+T, runtime datapack reload).</p>
*
* <p><b>Pourquoi réutiliser {@link TiedUpRigRegistry#EMPTY_ANIMATION}</b> vs
* créer un {@code empty_fallback} séparé : plusieurs sites du runtime
* ({@code Layer#off}, {@code AnimationPlayer#isEmpty}, {@code LayerOffAnimation#getNextAnimation})
* testent l'identité via {@code == EMPTY_ANIMATION}. Retourner une autre
* instance d'empty provoquerait des false-negatives sur ces checks — le
* runtime penserait qu'une anim réelle joue alors qu'en fait c'est un
* empty différent. Le singleton canonique évite ce piège.</p>
*
* @param id l'ID registry à résoudre (ex. {@code tiedup:context_stand_idle})
* @return l'{@link AnimationAccessor} enregistré, ou
* {@link TiedUpRigRegistry#EMPTY_ANIMATION} si l'ID est inconnu.
* Jamais null.
*/
public static AnimationAccessor<? extends StaticAnimation> resolveWithFallback(
ResourceLocation id
) {
if (id == null) {
// null ID : log + fallback. Pas de dedup (cas pathologique — le
// caller a un bug, pas un miss de datapack).
TiedUpRigConstants.LOGGER.warn(
"[TiedUpAnimationRegistry] resolveWithFallback appelé avec id=null, "
+ "using EMPTY_ANIMATION fallback."
);
return TiedUpRigRegistry.EMPTY_ANIMATION;
}
AnimationAccessor<? extends StaticAnimation> anim = AnimationManager.byKey(id);
if (anim != null) {
return anim;
}
// Miss — fallback + dedup warn. Set.add retourne true si l'ID n'était
// pas déjà dans le set → premier miss, on log. Sinon, silent no-op.
if (WARNED_MISSING_ANIMS.add(id)) {
TiedUpRigConstants.LOGGER.warn(
"[TiedUpAnimationRegistry] Animation not found: '{}', using EMPTY_ANIMATION fallback. "
+ "Check datapack JSON or run /reload.",
id
);
}
return TiedUpRigRegistry.EMPTY_ANIMATION;
}
/**
* Reset le set dedup des WARN missing. Utilisé dans deux contextes :
* <ul>
* <li>Tests unitaires — pour réinitialiser l'état statique entre test
* cases (sinon un test qui warn sur un ID pollue les suivants).</li>
* <li>Runtime reload (F3+T / datapack reload) — après un reload, des
* anims précédemment missing peuvent être dispo maintenant ; on
* veut pouvoir re-warn si elles retombent en miss après un autre
* reload.</li>
* </ul>
*
* <p>Thread-safe via {@link ConcurrentHashMap#newKeySet()} — pas de
* synchronisation externe nécessaire.</p>
*/
public static void resetWarnedMissing() {
WARNED_MISSING_ANIMS.clear();
}
}

View File

@@ -0,0 +1,324 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig;
import java.util.LinkedHashMap;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.armature.HumanoidArmature;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.armature.datapack.ArmatureReloadListener;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.math.OpenMatrix4f;
/**
* Registry des armatures TiedUp exposé via {@link AssetAccessor} constants.
*
* <h2>Phase 2.4 — version procédurale</h2>
*
* <p>Cette classe construit le biped TiedUp <b>from scratch en Java</b>
* (hiérarchie + offsets identity). Suffisant pour débloquer le rendering RIG
* Phase 2.4 : les joints existent dans le map, {@code searchJointByName}
* fonctionne, le GLB → SkinnedMesh bridge a un mapping valide, etc.</p>
*
* <p><b>Phase 2.7 remplacera par un JSON Blender-authored hot-reloadable</b>.
* Pour l'instant, les joints sont tous à l'identité (offset/rotation nuls).
* Visuellement ça donnera un biped "effondré" sur le point d'origine si on
* rend sans animation — c'est acceptable car :</p>
* <ul>
* <li>Phase 2.4 n'a pas encore de renderer player patched complet (Phase 2.5)</li>
* <li>Phase 2.7 rechargera des offsets depuis {@code assets/tiedup/armatures/biped.json}
* co-authored via addon Blender (cf. MIGRATION.md §2.2.1)</li>
* <li>Les tests existants `GltfToSkinnedMeshTest` utilisent déjà le même pattern
* (Armature identity, {@code bakeOriginMatrices}) et sont verts</li>
* </ul>
*
* <h2>Hiérarchie biped EF (20 joints)</h2>
*
* <pre>
* Root id=0
* ├─ Thigh_R ── Leg_R ── Knee_R id=1,2,3
* ├─ Thigh_L ── Leg_L ── Knee_L id=4,5,6
* └─ Torso id=7
* └─ Chest id=8
* ├─ Head id=9
* ├─ Shoulder_R ── Arm_R ── Elbow_R ── Hand_R ── Tool_R ids=10,11,14,12,13
* └─ Shoulder_L ── Arm_L ── Elbow_L ── Hand_L ── Tool_L ids=15,16,19,17,18
* </pre>
*
* <p>Les IDs des bras ne suivent pas l'ordre hiérarchique parent→enfant : c'est
* voulu pour rester aligné avec le layout attendu par {@code VanillaModelTransformer}
* (upperJoint=Arm, lowerJoint=Hand, middleJoint=Elbow). Voir {@link #buildBiped()}.</p>
*
* <p><b>Noms conservés verbatim EF</b> (pas renommés en TiedUp style) car :</p>
* <ul>
* <li>Le {@code VanillaModelTransformer} forké EF (Phase 2.2) référence ces
* noms dans ses AABB / {@code WEIGHT_ALONG_Y} / {@code yClipCoord}</li>
* <li>Le bridge GLB ({@code LegacyJointNameMapper}) mappe déjà les joints
* PlayerAnimator legacy sur ces noms-là</li>
* <li>Re-authored serait un risque régression sans gain fonctionnel</li>
* </ul>
*/
public final class TiedUpArmatures {
private TiedUpArmatures() {}
/** ResourceLocation registry pour l'accessor (même path que EF pour cohérence doc). */
private static final ResourceLocation BIPED_REGISTRY_NAME =
ResourceLocation.fromNamespaceAndPath(TiedUpRigConstants.MODID, "armature/biped");
/** Short-form ID utilisé par les invocation_commands data-driven (ex.
* {@code "tiedup:biped"} dans le bloc "constructor" des anim JSONs).
* C'est ce que l'utilisateur écrit dans les datapacks — plus concis que
* {@code tiedup:armature/biped} et aligné sur la convention EF
* (cf. {@code assets/epicfight/armatures/biped.json} référencé comme
* {@code "epicfight:biped"} dans les exports DatapackEditScreen).
*
* <p>Tant que Phase 2.7 n'a pas livré un vrai registry JSON
* {@link TiedUpRigRegistry}, {@link #get(ResourceLocation)} ne reconnaît
* que cet ID — tout autre ID retombe sur le fallback
* {@code InstantiateInvoker.getArmature} qui warn + renvoie BIPED.</p>
*/
private static final ResourceLocation BIPED_SHORT_ID =
ResourceLocation.fromNamespaceAndPath(TiedUpRigConstants.MODID, "biped");
/**
* Holder idiome pour init lazy + thread-safe sans synchronized.
*
* <p>Le class loader JVM garantit qu'une classe est initialisée au plus une
* fois, et que l'init est visible à tous les threads (JLS §12.4.1 — the class
* initialization lock est acquis automatiquement). Deux threads qui touchent
* {@code Holder.INSTANCE} simultanément ne peuvent pas observer l'instance
* non-initialisée ni en créer deux exemplaires. Intégré SP (client + server
* threads concurrents sur la même JVM) safe.</p>
*
* <p>Raison du fix (review Phase 2.4, P0-BUG-002) : le pattern précédent
* {@code if (BIPED_INSTANCE == null) BIPED_INSTANCE = buildBiped();} est
* un double-init race — deux threads entrent tous les deux dans le if,
* les deux créent un HumanoidArmature distinct, dernier gagne et pollue
* le cache.</p>
*/
private static final class Holder {
static final HumanoidArmature INSTANCE;
static {
// Signal visible au dev que les joints sont en identity transform.
// Sans ça, Phase 2.6+ câblera le renderer et le mesh apparaîtra
// "effondré à l'origine" sans signal — debug cauchemar. Le warn
// n'apparaît qu'une fois (class-init lock JVM).
TiedUpRigConstants.LOGGER.warn(
"TiedUpArmatures.BIPED initialized with IDENTITY joint transforms (Phase 2.4 stub). "
+ "Mesh will render collapsed-to-origin until Phase 2.7 provides biped.json "
+ "Blender-authored offsets. See docs/plans/rig/PHASE0_DEGRADATIONS.md "
+ "Phase 2.4 backlog entry #1."
);
INSTANCE = buildBiped();
}
private Holder() {}
}
/**
* AssetAccessor biped TiedUp. L'instance est construite lazy à la première
* référence {@code Holder.INSTANCE} (thread-safe via class-init lock JVM).
*
* <p>Utilisé par {@link com.tiedup.remake.rig.patch.PlayerPatch#getArmature()}
* et par les futurs {@code StaticAnimation(… , BIPED)} Phase 2.7+.</p>
*/
public static final AssetAccessor<HumanoidArmature> BIPED = new AssetAccessor<>() {
@Override
public HumanoidArmature get() {
return Holder.INSTANCE;
}
@Override
public ResourceLocation registryName() {
return BIPED_REGISTRY_NAME;
}
@Override
public boolean inRegistry() {
// Pas dans un JsonAssetLoader registry tant que Phase 2.7 n'a pas
// posé le biped.json. Une fois fait, ce flag repassera à true via
// un nouveau registry layer.
return false;
}
};
/**
* Build procédural de la hiérarchie biped EF. 20 joints, IDs 0..19 assignés
* explicitement pour matcher le layout attendu par
* {@link com.tiedup.remake.rig.mesh.transformer.VanillaModelTransformer}
* (cf. constantes {@code RIGHT_ARM}/{@code LEFT_ARM} — upperJoint/lowerJoint/middleJoint).
*
* <p>Ordre d'insertion LinkedHashMap ≠ ordre des IDs pour les bras (hand
* vient AVANT elbow dans les IDs, pour aligner sur la sémantique EF où
* {@code middleJoint = elbow}). Voir commentaires inline.</p>
*
* <p>Toutes les transforms sont identity — Phase 2.7 remplacera par les
* offsets Blender mesurés (cf. doc header).</p>
*/
private static HumanoidArmature buildBiped() {
// ID Joint assigné explicitement au constructeur (pas par position dans
// la map) — Armature.jointById est construit depuis joint.getId().
// On utilise LinkedHashMap malgré tout pour garantir un ordre d'itération
// stable (utile pour le debug et pour OpenMatrix4f.allocateMatrixArray
// qui dimensionne sur jointCount).
Map<String, Joint> joints = new LinkedHashMap<>(20);
// Pattern EF : tous les joints démarrent avec une localTransform identity.
// bakeOriginMatrices() calcule ensuite les toOrigin relatives à la
// hiérarchie parent→enfant.
Joint root = joint(joints, "Root", 0);
// Jambes
Joint thighR = joint(joints, "Thigh_R", 1);
Joint legR = joint(joints, "Leg_R", 2);
Joint kneeR = joint(joints, "Knee_R", 3);
Joint thighL = joint(joints, "Thigh_L", 4);
Joint legL = joint(joints, "Leg_L", 5);
Joint kneeL = joint(joints, "Knee_L", 6);
// Tronc
Joint torso = joint(joints, "Torso", 7);
Joint chest = joint(joints, "Chest", 8);
Joint head = joint(joints, "Head", 9);
// Bras droit — IDs alignés sur le layout EF (VanillaModelTransformer.RIGHT_ARM
// encode upperJoint=11, lowerJoint=12, middleJoint=14, cf.
// VanillaModelTransformer:50). L'insertion dans le LinkedHashMap reste
// dans l'ordre hiérarchique (shoulder → arm → elbow → hand → tool) pour
// préserver la lisibilité de l'iteration ; les IDs déterminent le
// mapping jointById utilisé par VanillaModelTransformer + SimpleTransformer.
Joint shoulderR = joint(joints, "Shoulder_R", 10);
Joint armR = joint(joints, "Arm_R", 11);
Joint handR = joint(joints, "Hand_R", 12);
Joint toolR = joint(joints, "Tool_R", 13);
Joint elbowR = joint(joints, "Elbow_R", 14);
// Bras gauche — symétrique : Arm_L=16, Hand_L=17, Tool_L=18, Elbow_L=19
// (VanillaModelTransformer.LEFT_ARM upperJoint=16, lowerJoint=17, middleJoint=19).
Joint shoulderL = joint(joints, "Shoulder_L", 15);
Joint armL = joint(joints, "Arm_L", 16);
Joint handL = joint(joints, "Hand_L", 17);
Joint toolL = joint(joints, "Tool_L", 18);
Joint elbowL = joint(joints, "Elbow_L", 19);
// Hiérarchie. addSubJoints est idempotent (skip si déjà présent) — safe
// de le réappeler, utile si on étend plus tard.
root.addSubJoints(thighR, thighL, torso);
thighR.addSubJoints(legR);
legR.addSubJoints(kneeR);
thighL.addSubJoints(legL);
legL.addSubJoints(kneeL);
torso.addSubJoints(chest);
chest.addSubJoints(head, shoulderR, shoulderL);
shoulderR.addSubJoints(armR);
armR.addSubJoints(elbowR);
elbowR.addSubJoints(handR);
handR.addSubJoints(toolR);
shoulderL.addSubJoints(armL);
armL.addSubJoints(elbowL);
elbowL.addSubJoints(handL);
handL.addSubJoints(toolL);
HumanoidArmature arm = new HumanoidArmature("biped", joints.size(), root, joints);
// Calcule les toOrigin relatifs — obligatoire après la construction
// sinon Pose.orElseEmpty retournerait des matrices non initialisées.
arm.bakeOriginMatrices();
return arm;
}
private static Joint joint(Map<String, Joint> target, String name, int id) {
Joint j = new Joint(name, id, new OpenMatrix4f());
target.put(name, j);
return j;
}
/**
* Lookup d'un {@link AssetAccessor} d'armature par ID datapack. Point
* d'entrée du data-driven : quand {@code AnimationManager.readResourcepackAnimation}
* parse le bloc {@code "constructor"} d'un JSON anim utilisateur et que
* {@link com.tiedup.remake.rig.util.InstantiateInvoker} rencontre un arg de
* type {@code Armature}, il appelle cette méthode via {@code getArmature(id)}.
*
* <p><b>Builtin</b> : seul l'ID canonique {@code tiedup:biped} (et son
* équivalent long-form {@code tiedup:armature/biped}) est résolu en dur —
* {@link HumanoidArmature} procédurale construite par {@link #buildBiped()}.
* Hardcodé pour performance + compat backward avec le
* {@link com.tiedup.remake.rig.mesh.transformer.VanillaModelTransformer}
* qui référence les IDs de joint verbatim.</p>
*
* <p><b>Datapack</b> : pour tout autre ID, on delegate au
* {@link ArmatureReloadListener} — chargement depuis
* {@code data/<ns>/tiedup/armatures/<name>.json} (cf. D6). Permet aux
* modders de définir quadruped / centaure / neko / ... sans coder Java.</p>
*
* <p>Retourne {@code null} si l'ID n'est ni le biped builtin ni présent
* dans le registry datapack — le caller décide du fallback
* (InstantiateInvoker log WARN + BIPED, cf. sa Javadoc).</p>
*
* @param id l'ID à résoudre (ex. {@code tiedup:biped},
* {@code tiedup:armature/biped} ou
* {@code mymod:quadruped}). Jamais null — caller check.
* @return l'{@link AssetAccessor} correspondant, ou {@code null} si l'ID
* n'est pas connu du registry. Pas de fallback automatique ici —
* c'est au caller de décider (InstantiateInvoker log+BIPED,
* test strict fail, etc.).
*/
public static AssetAccessor<? extends Armature> get(ResourceLocation id) {
if (BIPED_SHORT_ID.equals(id) || BIPED_REGISTRY_NAME.equals(id)) {
return BIPED;
}
Armature datapackArmature = ArmatureReloadListener.get(id);
if (datapackArmature != null) {
return wrapDatapackArmature(id, datapackArmature);
}
return null;
}
/**
* Enveloppe une {@link Armature} datapack en {@link AssetAccessor}
* conformément à l'API de {@link #get(ResourceLocation)}. Le
* {@code AssetAccessor} porte l'{@link Armature} directement (pas de
* re-lookup à chaque {@code get()}) et {@code inRegistry()} retourne
* {@code true} pour signaler que l'armature provient d'un registry JSON.
*
* <p>Chaque appel crée une nouvelle instance de l'accessor — c'est un
* wrapper léger, non-cache. Si un caller veut dé-dupliquer il peut le
* faire lui-même. Cache en interne serait prématuré : les datapack
* armatures sont remplacées complètement à chaque {@code /reload}, donc
* un cache devrait se purger — préférable de laisser les callers
* re-résoudre.</p>
*/
private static AssetAccessor<? extends Armature> wrapDatapackArmature(
ResourceLocation id, Armature armature
) {
return new AssetAccessor<Armature>() {
@Override
public Armature get() {
return armature;
}
@Override
public ResourceLocation registryName() {
return id;
}
@Override
public boolean inRegistry() {
return true;
}
};
}
}

View File

@@ -0,0 +1,126 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig;
import com.mojang.logging.LogUtils;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.rig.anim.Animator;
import com.tiedup.remake.rig.anim.ServerAnimator;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.fml.loading.FMLEnvironment;
import org.slf4j.Logger;
import java.util.function.Function;
/**
* Remplace {@code yesman.epicfight.main.EpicFightMod} + {@code EpicFightSharedConstants}
* du fork upstream. Expose les singletons nécessaires au runtime RIG :
*
* <ul>
* <li>{@link #MODID} — ID du mod TiedUp (tiedup)</li>
* <li>{@link #LOGGER} — logger commun RIG</li>
* <li>{@link #identifier(String)} — helper ResourceLocation</li>
* <li>{@link #ANIMATOR_PROVIDER} — factory client/server split pour instancier l'Animator</li>
* <li>{@link #isPhysicalClient()} — détection side runtime</li>
* </ul>
*
* <p>Pattern lazy method-ref : {@code ClientAnimator::getAnimator} n'est chargé
* que si {@link #isPhysicalClient()} est true. Sur serveur dedié, la classe
* client n'est jamais référencée, donc jamais chargée → pas de
* {@code NoClassDefFoundError}.</p>
*/
public final class TiedUpRigConstants {
public static final String MODID = TiedUpMod.MOD_ID;
public static final Logger LOGGER = LogUtils.getLogger();
/** Détection dev env (Gradle runClient) — utilisé pour les logs debug EF. */
public static final boolean IS_DEV_ENV = !FMLEnvironment.production;
/** Durée d'un tick MC en secondes (20 TPS). */
public static final float A_TICK = 1.0F / 20.0F;
/** Durée de transition inter-animation par défaut (en secondes — 0.15s = 3 ticks). */
public static final float GENERAL_ANIMATION_TRANSITION_TIME = 0.15F;
/** Nombre max de joints supportés par une armature (limite matrice pool). */
public static final int MAX_JOINTS = 128;
/**
* Factory lazy : crée un Animator approprié au side runtime courant.
* Client → {@link com.tiedup.remake.rig.anim.client.ClientAnimator#getAnimator}
* Server → {@link ServerAnimator#getAnimator} (forké verbatim EF)
*
* <p>Pattern lazy method-ref : {@code ClientAnimator::getAnimator} n'est
* chargé que si {@link #isPhysicalClient()} est true. Sur serveur dédié,
* la classe client n'est jamais référencée, donc jamais chargée → pas de
* {@code NoClassDefFoundError}.</p>
*/
public static final Function<LivingEntityPatch<?>, Animator> ANIMATOR_PROVIDER =
isPhysicalClient()
? com.tiedup.remake.rig.anim.client.ClientAnimator::getAnimator
: ServerAnimator::getAnimator;
private TiedUpRigConstants() {}
public static ResourceLocation identifier(String path) {
return ResourceLocation.fromNamespaceAndPath(MODID, path);
}
/** Alias d'{@link #identifier(String)} — équivalent TiedUpRigConstants.prefix upstream. */
public static ResourceLocation prefix(String path) {
return identifier(path);
}
public static boolean isPhysicalClient() {
return FMLEnvironment.dist == Dist.CLIENT;
}
/**
* En dev env : log un message + throw l'exception fournie.
* En prod : log WARN seulement. Équivalent EF {@code TiedUpRigConstants.stacktraceIfDevSide}.
*/
public static <E extends RuntimeException> void stacktraceIfDevSide(
String message,
java.util.function.Function<String, E> exceptionFactory
) {
if (IS_DEV_ENV) {
throw exceptionFactory.apply(message);
} else {
LOGGER.warn(message);
}
}
/**
* En dev env : log via le consumer + throw l'exception.
* En prod : log seulement. Équivalent EF {@code EpicFightMod.logAndStacktraceIfDevSide}.
*/
public static void logAndStacktraceIfDevSide(
java.util.function.BiConsumer<Logger, String> logAction,
String message,
java.util.function.Function<String, ? extends Throwable> exceptionFactory
) {
logAndStacktraceIfDevSide(logAction, message, exceptionFactory, message);
}
public static void logAndStacktraceIfDevSide(
java.util.function.BiConsumer<Logger, String> logAction,
String message,
java.util.function.Function<String, ? extends Throwable> exceptionFactory,
String stackTraceMessage
) {
logAction.accept(LOGGER, message);
if (IS_DEV_ENV) {
Throwable t = exceptionFactory.apply(stackTraceMessage);
if (t instanceof RuntimeException re) throw re;
if (t instanceof Error err) throw err;
throw new RuntimeException(t);
}
}
}

View File

@@ -0,0 +1,50 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.anim.types.DirectStaticAnimation;
/**
* Remplace les registries {@code yesman.epicfight.gameasset.Animations} et
* {@code yesman.epicfight.gameasset.Armatures} du fork upstream. TiedUp
* n'utilise PAS les animations combat EF (BIPED_IDLE, BIPED_WALK, etc. —
* ARR assets) — on authore les nôtres en Phase 4 via addon Blender.
*
* <p>Ce registry expose juste {@link #EMPTY_ANIMATION} — animation singleton
* "ne fait rien", référencée par LayerOffAnimation et StaticAnimation pour
* le défaut.</p>
*
* <p>Les vrais registries TiedUp (TiedUpAnimationRegistry, TiedUpArmatures,
* TiedUpMeshRegistry) sont prévus en Phase 2-3 et gèreront le scan resource
* pack + lookup par ResourceLocation.</p>
*/
public final class TiedUpRigRegistry {
private TiedUpRigRegistry() {}
/**
* Animation singleton "ne fait rien". Utilisée par le runtime comme
* fallback quand aucune animation n'est active sur une layer.
*
* <p>Équivalent de {@code TiedUpRigRegistry.EMPTY_ANIMATION} du fork upstream
* (cf. Animations.java:27 EF).</p>
*/
public static final DirectStaticAnimation EMPTY_ANIMATION = new DirectStaticAnimation() {
public static final ResourceLocation EMPTY_ANIMATION_REGISTRY_NAME =
ResourceLocation.fromNamespaceAndPath(TiedUpRigConstants.MODID, "empty");
@Override
public void loadAnimation() {
}
@Override
public ResourceLocation registryName() {
return EMPTY_ANIMATION_REGISTRY_NAME;
}
};
}

View File

@@ -0,0 +1,154 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
import net.minecraft.util.profiling.ProfilerFiller;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
import net.minecraftforge.event.AddReloadListenerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.rig.anim.action.impl.SpawnParticleAction;
/**
* Hook {@code /reload} (serveur) + {@code F3+T} (client) pour reset le set dedup
* {@link TiedUpAnimationRegistry#resetWarnedMissing()}.
*
* <h2>Pourquoi ?</h2>
* <p>Le set statique {@code WARNED_MISSING_ANIMS} de
* {@link TiedUpAnimationRegistry} accumule les IDs d'animations introuvables
* pour éviter le spam log (un miss reloggué tick après tick). Sans reset, le
* set grandit sans borne en prod (N items cassés × K sessions × long uptime)
* et empêche de re-signaler un miss qui redevient cassé après un reload.</p>
*
* <h2>Scénario typique</h2>
* <ol>
* <li>Session N : un item data-driven référence {@code tiedup:broken_anim} →
* WARN log, ID ajouté au set.</li>
* <li>Modder corrige l'asset, {@code /reload}.</li>
* <li>Session N+1 : resolveWithFallback dispose de l'anim → pas de fallback,
* pas de WARN. Le set contient toujours l'ID de la session N (résiduel).</li>
* <li>Modder casse à nouveau l'anim, {@code /reload}.</li>
* <li>Session N+2 : miss de nouveau, <b>mais l'ID est déjà dans le set</b> →
* silencieux, aucun feedback modder.</li>
* </ol>
*
* <p>Avec ce listener, chaque reload clear le set — un miss sur une anim
* précédemment warnée redéclenche le WARN (comportement attendu : le modder
* doit voir l'erreur s'il re-casse l'asset).</p>
*
* <h2>Les deux sides</h2>
* <ul>
* <li>{@link AddReloadListenerEvent} fire côté serveur à chaque
* {@code /reload} datapack.
* {@code resolveWithFallback} peut être appelé côté serveur (validation
* parse-time des items, checks administratifs). Les entrées set accumulées
* côté serveur sont cleared là.</li>
* <li>{@link RegisterClientReloadListenersEvent} fire une fois au setup client
* pour enregistrer un listener qui tourne à chaque resource reload client
* (F3+T ou resource pack swap).
* {@code resolveWithFallback} est majoritairement appelé côté client
* (pipeline rebuild animations, packet handlers). Les entrées set accumulées
* côté client sont cleared là.</li>
* </ul>
*
* <p>Le set statique dans {@link TiedUpAnimationRegistry} est le MÊME sur les
* deux sides (même JVM pour un serveur intégré / solo) — les deux hooks
* clearent le même set, pas de double comptabilité. Sur serveur dédié
* seul le hook server fire, côté client pur (rare — lobby / fast reconnect)
* seul le hook client.</p>
*
* <h2>Threading</h2>
* <p>{@link TiedUpAnimationRegistry#resetWarnedMissing()} utilise
* {@link java.util.concurrent.ConcurrentHashMap#newKeySet()} donc safe à
* l'appel concurrent. Les reload events peuvent fire depuis le main thread
* (server tick) ou le render thread (client reload) — aucune contrainte
* supplémentaire.</p>
*/
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID)
public final class TiedUpRigRegistryReloadListener
extends SimplePreparableReloadListener<Void> {
private TiedUpRigRegistryReloadListener() {
// Instancié par les event handlers ci-dessous — pas d'API publique
// à part comme reload listener.
}
@Override
protected Void prepare(ResourceManager mgr, ProfilerFiller profiler) {
// No-op — tout se fait dans apply(). Pas de I/O à faire off-thread,
// l'opération est juste un Set.clear() en O(1) amortisé.
return null;
}
@Override
protected void apply(Void nothing, ResourceManager mgr, ProfilerFiller profiler) {
TiedUpAnimationRegistry.resetWarnedMissing();
SpawnParticleAction.resetWarnedMissing();
TiedUpRigConstants.LOGGER.debug(
"[TiedUpRigRegistryReloadListener] WARNED_MISSING_ANIMS + WARNED_MISSING_JOINTS reset on reload"
);
}
/**
* Hook {@link AddReloadListenerEvent} — fire à chaque {@code /reload}
* datapack côté serveur (et aussi au server start). Enregistre un
* listener qui clear le set dedup WARN.
*
* <p>Note : {@code AddReloadListenerEvent} est sur le FORGE bus (pas MOD bus),
* mais le {@code @Mod.EventBusSubscriber} par défaut cible le MOD bus. On
* spécifie {@link Mod.EventBusSubscriber.Bus#FORGE} sur le subscriber ci-dessus ?
* Non : en 1.20.1, {@code AddReloadListenerEvent extends Event} bus-inferred
* automatiquement — le subscriber static-registered via annotation
* fonctionne sur le FORGE bus par défaut tant qu'on n'indique pas
* {@code Bus.MOD}. Par souci de clarté, on s'aligne sur le pattern du mod
* ({@code TiedUpMod.ForgeEvents} utilise le FORGE bus).</p>
*/
@SubscribeEvent
public static void onAddReloadListeners(AddReloadListenerEvent event) {
event.addListener(new TiedUpRigRegistryReloadListener());
TiedUpRigConstants.LOGGER.debug(
"[TiedUpRigRegistryReloadListener] Registered server-side reload listener for WARN dedup reset"
);
}
/**
* Hook {@link RegisterClientReloadListenersEvent} — fire une fois côté client
* pendant le setup pour enregistrer des listeners qui tourneront à chaque
* resource reload client (F3+T, resource pack swap).
*
* <p>Gardé {@link Dist#CLIENT} : sur serveur dédié, l'event n'existe pas
* (event client), le subscriber static est skip sans class-load.</p>
*
* <p>Ce subscriber est un inner static class pour isoler la gate de Dist —
* {@code @Mod.EventBusSubscriber(value = Dist.CLIENT)} sur la classe entière
* exclurait aussi le hook {@link #onAddReloadListeners} server-side.</p>
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.MOD,
value = Dist.CLIENT
)
public static final class ClientReloadHook {
private ClientReloadHook() {
// holder class for @SubscribeEvent static
}
@SubscribeEvent
public static void onRegisterClientReloadListeners(
RegisterClientReloadListenersEvent event
) {
event.registerReloadListener(new TiedUpRigRegistryReloadListener());
TiedUpRigConstants.LOGGER.debug(
"[TiedUpRigRegistryReloadListener] Registered client-side reload listener for WARN dedup reset"
);
}
}
}

View File

@@ -0,0 +1,140 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.mutable.MutableInt;
import net.minecraft.util.Mth;
public class AnimationClip {
public static final AnimationClip EMPTY_CLIP = new AnimationClip();
protected Map<String, TransformSheet> jointTransforms = new HashMap<> ();
protected float clipTime;
protected float[] bakedTimes;
/// To modify existing keyframes in runtime and keep the baked state, call [#setBaked] again
/// after finishing clip modification. (Frequent calls of this method will cause a performance issue)
public void addJointTransform(String jointName, TransformSheet sheet) {
this.jointTransforms.put(jointName, sheet);
this.bakedTimes = null;
}
public boolean hasJointTransform(String jointName) {
return this.jointTransforms.containsKey(jointName);
}
/// Bakes all keyframes to optimize calculating current pose,
public void bakeKeyframes() {
Set<Float> timestamps = new HashSet<> ();
this.jointTransforms.values().forEach(transformSheet -> {
transformSheet.forEach((i, keyframe) -> {
timestamps.add(keyframe.time());
});
});
float[] bakedTimestamps = new float[timestamps.size()];
MutableInt mi = new MutableInt(0);
timestamps.stream().sorted().toList().forEach(f -> {
bakedTimestamps[mi.getAndAdd(1)] = f;
});
Map<String, TransformSheet> bakedJointTransforms = new HashMap<> ();
this.jointTransforms.forEach((jointName, transformSheet) -> {
bakedJointTransforms.put(jointName, transformSheet.createInterpolated(bakedTimestamps));
});
this.jointTransforms = bakedJointTransforms;
this.bakedTimes = bakedTimestamps;
}
/// Bake keyframes supposing all keyframes are aligned (mainly used when creating link animations)
public void setBaked() {
TransformSheet transformSheet = this.jointTransforms.get("Root");
if (transformSheet != null) {
this.bakedTimes = new float[transformSheet.getKeyframes().length];
for (int i = 0; i < transformSheet.getKeyframes().length; i++) {
this.bakedTimes[i] = transformSheet.getKeyframes()[i].time();
}
}
}
public TransformSheet getJointTransform(String jointName) {
return this.jointTransforms.get(jointName);
}
public final Pose getPoseInTime(float time) {
Pose pose = new Pose();
if (time < 0.0F) {
time = this.clipTime + time;
}
if (this.bakedTimes != null && this.bakedTimes.length > 0) {
// Binary search
int begin = 0, end = this.bakedTimes.length - 1;
while (end - begin > 1) {
int i = begin + (end - begin) / 2;
if (this.bakedTimes[i] <= time && this.bakedTimes[i+1] > time) {
begin = i;
end = i+1;
break;
} else {
if (this.bakedTimes[i] > time) {
end = i;
} else if (this.bakedTimes[i+1] <= time) {
begin = i;
}
}
}
float delta = Mth.clamp((time - this.bakedTimes[begin]) / (this.bakedTimes[end] - this.bakedTimes[begin]), 0.0F, 1.0F);
TransformSheet.InterpolationInfo iInfo = new TransformSheet.InterpolationInfo(begin, end, delta);
for (String jointName : this.jointTransforms.keySet()) {
pose.putJointData(jointName, this.jointTransforms.get(jointName).getInterpolatedTransform(iInfo));
}
} else {
for (String jointName : this.jointTransforms.keySet()) {
pose.putJointData(jointName, this.jointTransforms.get(jointName).getInterpolatedTransform(time));
}
}
return pose;
}
/// @return returns protected keyframes of each joint to keep the baked state of keyframes.
public Map<String, TransformSheet> getJointTransforms() {
return Collections.unmodifiableMap(this.jointTransforms);
}
public void reset() {
this.jointTransforms.clear();
this.bakedTimes = null;
}
public void setClipTime(float clipTime) {
this.clipTime = clipTime;
}
public float getClipTime() {
return this.clipTime;
}
}

View File

@@ -0,0 +1,450 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.slf4j.Logger;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import net.minecraft.client.Minecraft;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.FileToIdConverter;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.network.ServerGamePacketListenerImpl;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
import net.minecraft.util.GsonHelper;
import net.minecraft.util.profiling.ProfilerFiller;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.eventbus.api.Event;
import net.minecraftforge.fml.event.IModBusEvent;
import com.tiedup.remake.rig.anim.property.AnimationProperty;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.asset.JsonAssetLoader;
import com.tiedup.remake.rig.anim.client.AnimationSubFileReader;
import com.tiedup.remake.rig.exception.AssetLoadingException;
import com.tiedup.remake.rig.util.InstantiateInvoker;
import com.tiedup.remake.rig.util.MutableBoolean;
import com.tiedup.remake.rig.TiedUpRigRegistry;
import com.tiedup.remake.rig.TiedUpRigConstants;
@SuppressWarnings("unchecked")
public class AnimationManager extends SimplePreparableReloadListener<List<ResourceLocation>> {
private static final AnimationManager INSTANCE = new AnimationManager();
private static ResourceManager serverResourceManager = null;
private static final Gson GSON = new GsonBuilder().create();
private static final String DIRECTORY = "animmodels/animations";
public static AnimationManager getInstance() {
return INSTANCE;
}
private final Map<Integer, AnimationAccessor<? extends StaticAnimation>> animationById = Maps.newHashMap();
private final Map<ResourceLocation, AnimationAccessor<? extends StaticAnimation>> animationByName = Maps.newHashMap();
private final Map<AnimationAccessor<? extends StaticAnimation>, StaticAnimation> animations = Maps.newHashMap();
private final Map<AnimationAccessor<? extends StaticAnimation>, String> resourcepackAnimationCommands = Maps.newHashMap();
public static boolean checkNull(AssetAccessor<? extends StaticAnimation> animation) {
if (animation == null || animation.isEmpty()) {
if (animation != null) {
TiedUpRigConstants.stacktraceIfDevSide("Empty animation accessor: " + animation.registryName(), NoSuchElementException::new);
} else {
TiedUpRigConstants.stacktraceIfDevSide("Null animation accessor", NoSuchElementException::new);
}
return true;
}
return false;
}
public static <T extends StaticAnimation> AnimationAccessor<T> byKey(String registryName) {
return byKey(ResourceLocation.parse(registryName));
}
public static <T extends StaticAnimation> AnimationAccessor<T> byKey(ResourceLocation registryName) {
return (AnimationAccessor<T>)getInstance().animationByName.get(registryName);
}
public static <T extends StaticAnimation> AnimationAccessor<T> byId(int animationId) {
return (AnimationAccessor<T>)getInstance().animationById.get(animationId);
}
public Map<ResourceLocation, AnimationAccessor<? extends StaticAnimation>> getAnimations(Predicate<AssetAccessor<? extends StaticAnimation>> filter) {
Map<ResourceLocation, AnimationAccessor<? extends StaticAnimation>> filteredItems =
this.animationByName.entrySet().stream()
.filter(entry -> {
return filter.test(entry.getValue());
})
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
return ImmutableMap.copyOf(filteredItems);
}
public AnimationClip loadAnimationClip(StaticAnimation animation, BiFunction<JsonAssetLoader, StaticAnimation, AnimationClip> clipLoader) {
try {
if (getAnimationResourceManager() == null) {
return null;
}
JsonAssetLoader modelLoader = new JsonAssetLoader(getAnimationResourceManager(), animation.getLocation());
AnimationClip loadedClip = clipLoader.apply(modelLoader, animation);
return loadedClip;
} catch (AssetLoadingException e) {
throw new AssetLoadingException("Failed to load animation clip from: " + animation, e);
}
}
public static void readAnimationProperties(StaticAnimation animation) {
ResourceLocation dataLocation = getSubAnimationFileLocation(animation.getLocation(), AnimationSubFileReader.SUBFILE_CLIENT_PROPERTY);
ResourceLocation povLocation = getSubAnimationFileLocation(animation.getLocation(), AnimationSubFileReader.SUBFILE_POV_ANIMATION);
getAnimationResourceManager().getResource(dataLocation).ifPresent((rs) -> {
AnimationSubFileReader.readAndApply(animation, rs, AnimationSubFileReader.SUBFILE_CLIENT_PROPERTY);
});
getAnimationResourceManager().getResource(povLocation).ifPresent((rs) -> {
AnimationSubFileReader.readAndApply(animation, rs, AnimationSubFileReader.SUBFILE_POV_ANIMATION);
});
}
@Override
protected List<ResourceLocation> prepare(ResourceManager resourceManager, ProfilerFiller profilerIn) {
if (!TiedUpRigConstants.isPhysicalClient() && serverResourceManager == null) {
serverResourceManager = resourceManager;
}
this.animations.clear();
this.animationById.entrySet().removeIf(entry -> !entry.getValue().inRegistry());
this.animationByName.entrySet().removeIf(entry -> !entry.getValue().inRegistry());
this.resourcepackAnimationCommands.clear();
List<ResourceLocation> directories = new ArrayList<> ();
scanDirectoryNames(resourceManager, directories);
return directories;
}
private static void scanDirectoryNames(ResourceManager resourceManager, List<ResourceLocation> output) {
FileToIdConverter filetoidconverter = FileToIdConverter.json(DIRECTORY);
filetoidconverter.listMatchingResources(resourceManager).keySet().stream().map(AnimationManager::pathToId).forEach(output::add);
}
@Override
protected void apply(List<ResourceLocation> objects, ResourceManager resourceManager, ProfilerFiller profilerIn) {
// RIG : Armatures.reload() (EF gameasset registry) retiré.
// TiedUpArmatures.reload() sera appelé ici en Phase 2 quand le registry
// sera créé. En Phase 0, no-op.
//
// PHASE 2 LISTENER ORDERING NOTE (BUG-RACE-01 prevention) :
// Quand cette classe sera enregistrée comme PreparableReloadListener via
// AddReloadListenerEvent (server) + RegisterClientReloadListenersEvent (client),
// elle DOIT être enregistrée APRÈS ces 3 listeners qui peuplent les registries
// consommés par les codecs de propriétés d'animation :
// - LivingMotionReloadListener (pour LIVING_MOTION_CODEC dans AnimationProperty)
// - ArmatureReloadListener (pour InstantiateInvoker.getArmature)
// - PoseTypeReloadListener (pour les bindings pose type)
// MC 1.20.1 SimpleReloadInstance sérialise les apply() dans l'ordre
// d'enregistrement de la liste — c'est notre seul levier d'ordering en
// Forge 1.20.1 (PreparableReloadListener.getDependencies() n'existe pas
// en cette version, c'est une API Fabric/NeoForge).
// Sites concernés : TiedUpMod.ForgeEvents.onAddReloadListeners + V2ClientSetup.onRegisterReloadListeners.
Set<ResourceLocation> registeredAnimation =
this.animationById.values().stream()
.reduce(
new HashSet<> (),
(set, accessor) -> {
set.add(accessor.registryName());
for (AssetAccessor<? extends StaticAnimation> subAnimAccessor : accessor.get().getSubAnimations()) {
set.add(subAnimAccessor.registryName());
}
return set;
},
(set1, set2) -> {
set1.addAll(set2);
return set1;
}
);
// Load animations that are not registered by AnimationRegistryEvent
// Reads from /assets folder in physical client, /datapack in physical server.
objects.stream()
.filter(animId -> !registeredAnimation.contains(animId) && !animId.getPath().contains("/data/") && !animId.getPath().contains("/pov/"))
.sorted(Comparator.comparing(ResourceLocation::toString))
.forEach(animId -> {
Optional<Resource> resource = resourceManager.getResource(idToPath(animId));
try (Reader reader = resource.orElseThrow().openAsReader()) {
JsonElement jsonelement = GsonHelper.fromJson(GSON, reader, JsonElement.class);
this.readResourcepackAnimation(animId, jsonelement.getAsJsonObject());
} catch (IOException | JsonParseException | IllegalArgumentException resourceReadException) {
TiedUpRigConstants.LOGGER.error("Couldn't parse animation data from {}", animId, resourceReadException);
} catch (Exception e) {
TiedUpRigConstants.LOGGER.error("Failed at constructing {}", animId, e);
}
});
// RIG : upstream EF appelait ici yesman.epicfight.skill.SkillManager.reloadAllSkillsAnimations()
// (re-link des animations aux Skills Java). Combat system hors scope TiedUp → appel strippé.
this.animations.entrySet().stream()
.reduce(
new ArrayList<AssetAccessor<? extends StaticAnimation>>(),
(list, entry) -> {
MutableBoolean init = new MutableBoolean(true);
if (entry.getValue() == null || entry.getValue().getAccessor() == null) {
TiedUpRigConstants.logAndStacktraceIfDevSide(Logger::error, "Invalid animation implementation: " + entry.getKey(), AssetLoadingException::new);
init.set(false);
}
entry.getValue().getSubAnimations().forEach((subAnimation) -> {
if (subAnimation == null || subAnimation.get() == null) {
TiedUpRigConstants.logAndStacktraceIfDevSide(Logger::error, "Invalid sub animation implementation: " + entry.getKey(), AssetLoadingException::new);
init.set(false);
}
});
if (init.value()) {
list.add(entry.getValue().getAccessor());
list.addAll(entry.getValue().getSubAnimations());
}
return list;
},
(list1, list2) -> {
list1.addAll(list2);
return list1;
}
)
.forEach(accessor -> {
accessor.doOrThrow(StaticAnimation::postInit);
if (TiedUpRigConstants.isPhysicalClient()) {
AnimationManager.readAnimationProperties(accessor.get());
}
});
}
public static ResourceLocation getSubAnimationFileLocation(ResourceLocation location, AnimationSubFileReader.SubFileType<?> subFileType) {
int splitIdx = location.getPath().lastIndexOf('/');
if (splitIdx < 0) {
splitIdx = 0;
}
return ResourceLocation.fromNamespaceAndPath(location.getNamespace(), String.format("%s/" + subFileType.getDirectory() + "%s", location.getPath().substring(0, splitIdx), location.getPath().substring(splitIdx)));
}
/// Converts animation id, acquired by [StaticAnimation#getRegistryName], to animation resource path acquired by [StaticAnimation#getLocation]
public static ResourceLocation idToPath(ResourceLocation rl) {
return rl.getPath().matches(DIRECTORY + "/.*\\.json") ? rl : ResourceLocation.fromNamespaceAndPath(rl.getNamespace(), DIRECTORY + "/" + rl.getPath() + ".json");
}
/// Converts animation resource path, acquired by [StaticAnimation#getLocation], to animation id acquired by [StaticAnimation#getRegistryName]
public static ResourceLocation pathToId(ResourceLocation rl) {
return ResourceLocation.fromNamespaceAndPath(rl.getNamespace(), rl.getPath().replace(DIRECTORY + "/", "").replace(".json", ""));
}
public static void setServerResourceManager(ResourceManager pResourceManager) {
serverResourceManager = pResourceManager;
}
public static ResourceManager getAnimationResourceManager() {
return TiedUpRigConstants.isPhysicalClient() ? Minecraft.getInstance().getResourceManager() : serverResourceManager;
}
public int getResourcepackAnimationCount() {
return this.resourcepackAnimationCommands.size();
}
public Stream<CompoundTag> getResourcepackAnimationStream() {
return this.resourcepackAnimationCommands.entrySet().stream().map((entry) -> {
CompoundTag compTag = new CompoundTag();
compTag.putString("registry_name", entry.getKey().registryName().toString());
compTag.putInt("id", entry.getKey().id());
compTag.putString("invoke_command", entry.getValue());
return compTag;
});
}
/**
* @param mandatoryPack : creates dummy animations for animations from the server without animation clips when the server has mandatory resource pack.
* custom weapon types & mob capabilities won't be created because they won't be able to find the animations from the server
* dummy animations will be automatically removed right after reloading resourced as the server forces using resource pack
*/
// RIG : processServerPacket + validateClientAnimationRegistry strippés.
// C'était le protocole datapack-sync EF pour valider que le client a les
// mêmes animations que le serveur au login (important pour les animations
// combat stockées en data/). TiedUp utilise resource pack uniquement
// (assets/) côté client, pas de sync datapack nécessaire.
// Ré-introduire Phase 2+ si on veut un warning quand un pack d'animations
// custom diverge.
private static final Set<String> NO_WARNING_MODID = Sets.newHashSet();
public static void addNoWarningModId(String modid) {
NO_WARNING_MODID.add(modid);
}
/**************************************************
* User-animation loader
**************************************************/
@SuppressWarnings({ "deprecation" })
private void readResourcepackAnimation(ResourceLocation rl, JsonObject json) throws Exception {
JsonElement constructorElement = json.get("constructor");
if (constructorElement == null) {
if (NO_WARNING_MODID.contains(rl.getNamespace())) {
return;
} else {
TiedUpRigConstants.logAndStacktraceIfDevSide(
Logger::error
, "Datapack animation reading failed: No constructor information has provided: " + rl
, IllegalStateException::new
, "No constructor information has provided in User animation, " + rl + "\nPlease remove this resource if it's unnecessary to optimize your project."
);
return;
}
}
JsonObject constructorObject = constructorElement.getAsJsonObject();
String invocationCommand = constructorObject.get("invocation_command").getAsString();
StaticAnimation animation = InstantiateInvoker.invoke(invocationCommand, StaticAnimation.class).getResult();
JsonElement propertiesElement = json.getAsJsonObject().get("properties");
if (propertiesElement != null) {
JsonObject propertiesObject = propertiesElement.getAsJsonObject();
for (Map.Entry<String, JsonElement> entry : propertiesObject.entrySet()) {
AnimationProperty<?> propertyKey = AnimationProperty.getSerializableProperty(entry.getKey());
Object value = propertyKey.parseFrom(entry.getValue());
animation.addPropertyUnsafe(propertyKey, value);
}
}
AnimationAccessor<StaticAnimation> accessor = AnimationAccessorImpl.create(rl, this.animations.size() + 1, false, null);
animation.setAccessor(accessor);
this.resourcepackAnimationCommands.put(accessor, invocationCommand);
this.animationById.put(accessor.id(), accessor);
this.animationByName.put(accessor.registryName(), accessor);
this.animations.put(accessor, animation);
}
public interface AnimationAccessor<A extends DynamicAnimation> extends AssetAccessor<A> {
int id();
default boolean idBetween(AnimationAccessor<? extends StaticAnimation> a1, AnimationAccessor<? extends StaticAnimation> a2) {
return a1.id() <= this.id() && a2.id() >= this.id();
}
}
public static record AnimationAccessorImpl<A extends StaticAnimation> (ResourceLocation registryName, int id, boolean inRegistry, Function<AnimationAccessor<A>, A> onLoad) implements AnimationAccessor<A> {
private static <A extends StaticAnimation> AnimationAccessor<A> create(ResourceLocation registryName, int id, boolean inRegistry, Function<AnimationAccessor<A>, A> onLoad) {
return new AnimationAccessorImpl<A> (registryName, id, inRegistry, onLoad);
}
@Override
public A get() {
if (!INSTANCE.animations.containsKey(this)) {
INSTANCE.animations.put(this, this.onLoad.apply(this));
}
return (A)INSTANCE.animations.get(this);
}
public String toString() {
return this.registryName.toString();
}
public int hashCode() {
return this.registryName.hashCode();
}
public boolean equals(Object obj) {
if (this == obj) {
return true;
} else if (obj instanceof AnimationAccessor armatureAccessor) {
return this.registryName.equals(armatureAccessor.registryName());
} else if (obj instanceof ResourceLocation rl) {
return this.registryName.equals(rl);
} else if (obj instanceof String name) {
return this.registryName.toString().equals(name);
} else {
return false;
}
}
}
public static class AnimationRegistryEvent extends Event implements IModBusEvent {
private List<AnimationBuilder> builders = Lists.newArrayList();
private Set<String> namespaces = Sets.newHashSet();
public void newBuilder(String namespace, Consumer<AnimationBuilder> build) {
if (this.namespaces.contains(namespace)) {
throw new IllegalArgumentException("Animation builder namespace '" + namespace + "' already exists!");
}
this.namespaces.add(namespace);
this.builders.add(new AnimationBuilder(namespace, build));
}
public List<AnimationBuilder> getBuilders() {
return this.builders;
}
}
public static record AnimationBuilder(String namespace, Consumer<AnimationBuilder> task) {
public <T extends StaticAnimation> AnimationManager.AnimationAccessor<T> nextAccessor(String id, Function<AnimationManager.AnimationAccessor<T>, T> onLoad) {
AnimationAccessor<T> accessor = AnimationAccessorImpl.create(ResourceLocation.fromNamespaceAndPath(this.namespace, id), INSTANCE.animations.size() + 1, true, onLoad);
INSTANCE.animationById.put(accessor.id(), accessor);
INSTANCE.animationByName.put(accessor.registryName(), accessor);
INSTANCE.animations.put(accessor, null);
return accessor;
}
}
}

View File

@@ -0,0 +1,162 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import com.mojang.datafixers.util.Pair;
import com.tiedup.remake.rig.anim.property.AnimationProperty.PlaybackSpeedModifier;
import com.tiedup.remake.rig.anim.property.AnimationProperty.PlaybackTimeModifier;
import com.tiedup.remake.rig.anim.property.AnimationProperty.StaticAnimationProperty;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.TiedUpRigRegistry;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public class AnimationPlayer {
protected float elapsedTime;
protected float prevElapsedTime;
protected boolean isEnd;
protected boolean doNotResetTime;
protected boolean reversed;
protected AssetAccessor<? extends DynamicAnimation> play;
public AnimationPlayer() {
this.setPlayAnimation(TiedUpRigRegistry.EMPTY_ANIMATION);
}
public void tick(LivingEntityPatch<?> entitypatch) {
DynamicAnimation currentPlay = this.getAnimation().get();
DynamicAnimation currentPlayStatic = currentPlay.getRealAnimation().get();
this.prevElapsedTime = this.elapsedTime;
float playbackSpeed = currentPlay.getPlaySpeed(entitypatch, currentPlay);
PlaybackSpeedModifier playSpeedModifier = currentPlayStatic.getProperty(StaticAnimationProperty.PLAY_SPEED_MODIFIER).orElse(null);
if (playSpeedModifier != null) {
playbackSpeed = playSpeedModifier.modify(currentPlay, entitypatch, playbackSpeed, this.prevElapsedTime, this.elapsedTime);
}
this.elapsedTime += TiedUpRigConstants.A_TICK * playbackSpeed * (this.isReversed() && currentPlay.canBePlayedReverse() ? -1.0F : 1.0F);
PlaybackTimeModifier playTimeModifier = currentPlayStatic.getProperty(StaticAnimationProperty.ELAPSED_TIME_MODIFIER).orElse(null);
if (playTimeModifier != null) {
Pair<Float, Float> time = playTimeModifier.modify(currentPlay, entitypatch, playbackSpeed, this.prevElapsedTime, this.elapsedTime);
this.prevElapsedTime = time.getFirst();
this.elapsedTime = time.getSecond();
}
if (this.elapsedTime > currentPlay.getTotalTime()) {
if (currentPlay.isRepeat()) {
this.prevElapsedTime = this.prevElapsedTime - currentPlay.getTotalTime();
this.elapsedTime %= currentPlay.getTotalTime();
} else {
this.elapsedTime = currentPlay.getTotalTime();
currentPlay.end(entitypatch, null, true);
this.isEnd = true;
}
} else if (this.elapsedTime < 0) {
if (currentPlay.isRepeat()) {
this.prevElapsedTime = currentPlay.getTotalTime() - this.elapsedTime;
this.elapsedTime = currentPlay.getTotalTime() + this.elapsedTime;
} else {
this.elapsedTime = 0.0F;
currentPlay.end(entitypatch, null, true);
this.isEnd = true;
}
}
}
public void reset() {
this.elapsedTime = 0;
this.prevElapsedTime = 0;
this.isEnd = false;
}
public void setPlayAnimation(AssetAccessor<? extends DynamicAnimation> animation) {
if (this.doNotResetTime) {
this.doNotResetTime = false;
this.isEnd = false;
} else {
this.reset();
}
this.play = animation;
}
public Pose getCurrentPose(LivingEntityPatch<?> entitypatch, float partialTicks) {
return this.play.get().getPoseByTime(entitypatch, this.prevElapsedTime + (this.elapsedTime - this.prevElapsedTime) * partialTicks, partialTicks);
}
public float getElapsedTime() {
return this.elapsedTime;
}
public float getPrevElapsedTime() {
return this.prevElapsedTime;
}
public void setElapsedTimeCurrent(float elapsedTime) {
this.elapsedTime = elapsedTime;
this.isEnd = false;
}
public void setElapsedTime(float elapsedTime) {
this.elapsedTime = elapsedTime;
this.prevElapsedTime = elapsedTime;
this.isEnd = false;
}
public void setElapsedTime(float prevElapsedTime, float elapsedTime) {
this.elapsedTime = elapsedTime;
this.prevElapsedTime = prevElapsedTime;
this.isEnd = false;
}
public void begin(AssetAccessor<? extends DynamicAnimation> animation, LivingEntityPatch<?> entitypatch) {
animation.get().tick(entitypatch);
}
public AssetAccessor<? extends DynamicAnimation> getAnimation() {
return this.play;
}
public AssetAccessor<? extends StaticAnimation> getRealAnimation() {
return this.play.get().getRealAnimation();
}
public void markDoNotResetTime() {
this.doNotResetTime = true;
}
public boolean isEnd() {
return this.isEnd;
}
public void terminate(LivingEntityPatch<?> entitypatch) {
this.play.get().end(entitypatch, this.play, true);
this.isEnd = true;
}
public boolean isReversed() {
return this.reversed;
}
public void setReversed(boolean reversed) {
this.reversed = reversed;
}
public boolean isEmpty() {
return this.play == TiedUpRigRegistry.EMPTY_ANIMATION;
}
@Override
public String toString() {
return this.getAnimation() + " " + this.prevElapsedTime + " " + this.elapsedTime;
}
}

View File

@@ -0,0 +1,31 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
/**
* RIG stub. Upstream EF : packet commun client/serveur pour sync des
* animation variables (variables partagées entre deux sides pendant une
* animation combat).
*
* <p>TiedUp Phase 0 : la classe est conservée en stub juste pour l'enum
* {@link Action} utilisé par {@code AnimationVariables.put/remove} et
* {@code SynchedAnimationVariableKey.sync}. Le vrai packet réseau n'est
* pas implémenté — les `sync()` calls sont no-ops côté runtime pour
* l'instant (cf. {@code SynchedAnimationVariableKey.sync}).</p>
*
* <p>Phase 2+ : si on a besoin de sync d'animation variables entre
* serveur et client (cas d'usage non identifié en TiedUp), implémenter
* un vrai packet. Sinon garder le stub et stripper {@code sync()} plus
* tard.</p>
*/
public class AnimationVariablePacket {
public enum Action {
PUT,
REMOVE,
}
}

View File

@@ -0,0 +1,267 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import org.checkerframework.checker.nullness.qual.NonNull;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.util.ParseUtil;
import com.tiedup.remake.rig.util.datastruct.TypeFlexibleHashMap;
import com.tiedup.remake.rig.util.datastruct.TypeFlexibleHashMap.TypeKey;
import com.tiedup.remake.rig.TiedUpRigRegistry;
import com.tiedup.remake.rig.anim.AnimationVariablePacket;
public class AnimationVariables {
protected final Animator animator;
protected final TypeFlexibleHashMap<AnimationVariableKey<?>> animationVariables = new TypeFlexibleHashMap<> (false);
public AnimationVariables(Animator animator) {
this.animator = animator;
}
public <T> Optional<T> getSharedVariable(SharedAnimationVariableKey<T> key) {
return Optional.ofNullable(this.animationVariables.get(key));
}
@SuppressWarnings("unchecked")
public <T> T getOrDefaultSharedVariable(SharedAnimationVariableKey<T> key) {
return ParseUtil.orElse((T)this.animationVariables.get(key), () -> key.defaultValue(this.animator));
}
@SuppressWarnings("unchecked")
public <T> Optional<T> get(IndependentAnimationVariableKey<T> key, AssetAccessor<? extends StaticAnimation> animation) {
if (animation == null) {
return Optional.empty();
}
Map<ResourceLocation, Object> subMap = this.animationVariables.get(key);
if (subMap == null) {
return Optional.empty();
} else {
return Optional.ofNullable((T)subMap.get(animation.registryName()));
}
}
@SuppressWarnings("unchecked")
public <T> T getOrDefault(IndependentAnimationVariableKey<T> key, AssetAccessor<? extends StaticAnimation> animation) {
if (animation == null) {
return Objects.requireNonNull(key.defaultValue(this.animator), "Null value returned by default provider.");
}
Map<ResourceLocation, Object> subMap = this.animationVariables.get(key);
if (subMap == null) {
return Objects.requireNonNull(key.defaultValue(this.animator), "Null value returned by default provider.");
} else {
return ParseUtil.orElse((T)subMap.get(animation.registryName()), () -> key.defaultValue(this.animator));
}
}
public <T> void putDefaultSharedVariable(SharedAnimationVariableKey<T> key) {
T value = key.defaultValue(this.animator);
Objects.requireNonNull(value, "Null value returned by default provider.");
this.putSharedVariable(key, value);
}
public <T> void putSharedVariable(SharedAnimationVariableKey<T> key, T value) {
this.putSharedVariable(key, value, true);
}
@SuppressWarnings("unchecked")
@Deprecated // Avoid direct use
public <T> void putSharedVariable(SharedAnimationVariableKey<T> key, T value, boolean synchronize) {
if (this.animationVariables.containsKey(key) && !key.mutable()) {
throw new UnsupportedOperationException("Can't modify a const variable");
}
this.animationVariables.put((AnimationVariableKey<?>)key, value);
if (synchronize && key instanceof SynchedAnimationVariableKey) {
SynchedAnimationVariableKey<T> synchedanimationvariablekey = (SynchedAnimationVariableKey<T>)key;
synchedanimationvariablekey.sync(this.animator.entitypatch, (AssetAccessor<? extends StaticAnimation>)null, value, AnimationVariablePacket.Action.PUT);
}
}
public <T> void putDefaultValue(IndependentAnimationVariableKey<T> key, AssetAccessor<? extends StaticAnimation> animation) {
T value = key.defaultValue(this.animator);
Objects.requireNonNull(value, "Null value returned by default provider.");
this.put(key, animation, value);
}
public <T> void put(IndependentAnimationVariableKey<T> key, AssetAccessor<? extends StaticAnimation> animation, T value) {
this.put(key, animation, value, true);
}
@SuppressWarnings("unchecked")
@Deprecated // Avoid direct use
public <T> void put(IndependentAnimationVariableKey<T> key, AssetAccessor<? extends StaticAnimation> animation, T value, boolean synchronize) {
if (animation == TiedUpRigRegistry.EMPTY_ANIMATION) {
return;
}
this.animationVariables.computeIfPresent(key, (k, v) -> {
Map<ResourceLocation, Object> variablesByAnimations = ((Map<ResourceLocation, Object>)v);
if (!key.mutable() && variablesByAnimations.containsKey(animation.registryName())) {
throw new UnsupportedOperationException("Can't modify a const variable");
}
variablesByAnimations.put(animation.registryName(), value);
return v;
});
this.animationVariables.computeIfAbsent(key, (k) -> {
return new HashMap<> (Map.of(animation.registryName(), value));
});
if (synchronize && key instanceof SynchedAnimationVariableKey) {
SynchedAnimationVariableKey<T> synchedanimationvariablekey = (SynchedAnimationVariableKey<T>)key;
synchedanimationvariablekey.sync(this.animator.entitypatch, animation, value, AnimationVariablePacket.Action.PUT);
}
}
public <T> T removeSharedVariable(SharedAnimationVariableKey<T> key) {
return this.removeSharedVariable(key, true);
}
@SuppressWarnings("unchecked")
@Deprecated // Avoid direct use
public <T> T removeSharedVariable(SharedAnimationVariableKey<T> key, boolean synchronize) {
if (!key.mutable()) {
throw new UnsupportedOperationException("Can't remove a const variable");
}
if (synchronize && key instanceof SynchedAnimationVariableKey) {
SynchedAnimationVariableKey<T> synchedanimationvariablekey = (SynchedAnimationVariableKey<T>)key;
synchedanimationvariablekey.sync(this.animator.entitypatch, null, null, AnimationVariablePacket.Action.REMOVE);
}
return (T)this.animationVariables.remove(key);
}
@SuppressWarnings("unchecked")
public void removeAll(AnimationAccessor<? extends StaticAnimation> animation) {
if (animation == TiedUpRigRegistry.EMPTY_ANIMATION) {
return;
}
for (Map.Entry<AnimationVariableKey<?>, Object> entry : this.animationVariables.entrySet()) {
if (entry.getKey().isSharedKey()) {
continue;
}
Map<ResourceLocation, Object> map = (Map<ResourceLocation, Object>)entry.getValue();
if (map != null) {
map.remove(animation.registryName());
}
}
}
public void remove(IndependentAnimationVariableKey<?> key, AssetAccessor<? extends StaticAnimation> animation) {
this.remove(key, animation, true);
}
@SuppressWarnings("unchecked")
@Deprecated // Avoid direct use
public void remove(IndependentAnimationVariableKey<?> key, AssetAccessor<? extends StaticAnimation> animation, boolean synchronize) {
if (animation == TiedUpRigRegistry.EMPTY_ANIMATION) {
return;
}
Map<ResourceLocation, Object> map = (Map<ResourceLocation, Object>)this.animationVariables.get(key);
if (map != null) {
map.remove(animation.registryName());
}
if (synchronize && key instanceof SynchedAnimationVariableKey) {
SynchedAnimationVariableKey<?> synchedanimationvariablekey = (SynchedAnimationVariableKey<?>)key;
synchedanimationvariablekey.sync(this.animator.entitypatch, null, null, AnimationVariablePacket.Action.REMOVE);
}
}
public static <T> SharedAnimationVariableKey<T> shared(Function<Animator, T> defaultValueSupplier, boolean mutable) {
return new SharedAnimationVariableKey<> (defaultValueSupplier, mutable);
}
public static <T> IndependentAnimationVariableKey<T> independent(Function<Animator, T> defaultValueSupplier, boolean mutable) {
return new IndependentAnimationVariableKey<> (defaultValueSupplier, mutable);
}
protected abstract static class AnimationVariableKey<T> implements TypeKey<T> {
protected final Function<Animator, T> defaultValueSupplier;
protected final boolean mutable;
protected AnimationVariableKey(Function<Animator, T> defaultValueSupplier, boolean mutable) {
this.defaultValueSupplier = defaultValueSupplier;
this.mutable = mutable;
}
@NonNull
public T defaultValue(Animator animator) {
return this.defaultValueSupplier.apply(animator);
}
public boolean mutable() {
return this.mutable;
}
@Override
public T defaultValue() {
throw new UnsupportedOperationException("Use defaultValue(Animator animator) to get default value of animation variable key");
}
public abstract boolean isSharedKey();
public abstract boolean isSynched();
}
public static class SharedAnimationVariableKey<T> extends AnimationVariableKey<T> {
protected SharedAnimationVariableKey(Function<Animator, T> initValueSupplier, boolean mutable) {
super(initValueSupplier, mutable);
}
@Override
public boolean isSharedKey() {
return true;
}
@Override
public boolean isSynched() {
return false;
}
}
public static class IndependentAnimationVariableKey<T> extends AnimationVariableKey<T> {
protected IndependentAnimationVariableKey(Function<Animator, T> initValueSupplier, boolean mutable) {
super(initValueSupplier, mutable);
}
@Override
public boolean isSharedKey() {
return false;
}
@Override
public boolean isSynched() {
return false;
}
}
}

View File

@@ -0,0 +1,148 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nullable;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import com.mojang.datafixers.util.Pair;
import net.minecraftforge.common.MinecraftForge;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.anim.types.EntityState;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.event.InitAnimatorEvent;
import com.tiedup.remake.rig.TiedUpRigRegistry;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public abstract class Animator {
protected final Map<LivingMotion, AssetAccessor<? extends StaticAnimation>> livingAnimations = Maps.newHashMap();
protected final AnimationVariables animationVariables = new AnimationVariables(this);
protected final LivingEntityPatch<?> entitypatch;
public Animator(LivingEntityPatch<?> entitypatch) {
this.entitypatch = entitypatch;
}
/**
* Play an animation
*
* @param nextAnimation the animation that is meant to be played.
* @param transitionTimeModifier extends the transition time if positive value provided, or starts in time as an amount of time (e.g. -0.1F starts in 0.1F frame time)
*/
public abstract void playAnimation(AssetAccessor<? extends StaticAnimation> nextAnimation, float transitionTimeModifier);
public final void playAnimation(int id, float transitionTimeModifier) {
this.playAnimation(AnimationManager.byId(id), transitionTimeModifier);
}
/**
* Play a given animation without transition animation.
* @param nextAnimation
*/
public abstract void playAnimationInstantly(AssetAccessor<? extends StaticAnimation> nextAnimation);
public final void playAnimationInstantly(int id) {
this.playAnimationInstantly(AnimationManager.byId(id));
}
/**
* Reserve a given animation until the current animation ends.
* If the given animation has a higher priority than current animation, it terminates the current animation by force and play the next animation
* @param nextAnimation
*/
public abstract void reserveAnimation(AssetAccessor<? extends StaticAnimation> nextAnimation);
public final void reserveAnimation(int id) {
this.reserveAnimation(AnimationManager.byId(id));
}
/**
* Stop playing given animation if exist
* @param targetAnimation
* @return true when found and successfully stop the target animation
*/
public abstract boolean stopPlaying(AssetAccessor<? extends StaticAnimation> targetAnimation);
/**
* Play an shooting animation to end aiming pose
*/
public abstract void playShootingAnimation();
public final boolean stopPlaying(int id) {
return this.stopPlaying(AnimationManager.byId(id));
}
public abstract void setSoftPause(boolean paused);
public abstract void setHardPause(boolean paused);
public abstract void tick();
public abstract EntityState getEntityState();
/**
* Searches an animation player playing the given animation parameter or return base layer if it's null
* Secure non-null but returned animation player won't match with a given animation
*/
@Nullable
public abstract AnimationPlayer getPlayerFor(@Nullable AssetAccessor<? extends DynamicAnimation> playingAnimation);
/**
* Searches an animation player playing the given animation parameter
*/
@Nullable
public abstract Optional<AnimationPlayer> getPlayer(AssetAccessor<? extends DynamicAnimation> playingAnimation);
public abstract <T> Pair<AnimationPlayer, T> findFor(Class<T> animationType);
public abstract Pose getPose(float partialTicks);
public void postInit() {
InitAnimatorEvent initAnimatorEvent = new InitAnimatorEvent(this.entitypatch, this);
MinecraftForge.EVENT_BUS.post(initAnimatorEvent);
}
public void playDeathAnimation() {
// RIG : Animations.BIPED_DEATH (EF combat asset ARR) retiré.
// Fallback sur EMPTY_ANIMATION — TiedUp authored ses propres
// anims de mort Phase 4 et les binde via addLivingAnimation(DEATH, ...).
this.playAnimation(this.livingAnimations.getOrDefault(LivingMotions.DEATH, TiedUpRigRegistry.EMPTY_ANIMATION), 0);
}
public void addLivingAnimation(LivingMotion livingMotion, AssetAccessor<? extends StaticAnimation> animation) {
if (AnimationManager.checkNull(animation)) {
TiedUpRigConstants.LOGGER.warn("Unable to put an empty animation for " + livingMotion);
return;
}
this.livingAnimations.put(livingMotion, animation);
}
public AssetAccessor<? extends StaticAnimation> getLivingAnimation(LivingMotion livingMotion, AssetAccessor<? extends StaticAnimation> defaultGetter) {
return this.livingAnimations.getOrDefault(livingMotion, defaultGetter);
}
public Map<LivingMotion, AssetAccessor<? extends StaticAnimation>> getLivingAnimations() {
return ImmutableMap.copyOf(this.livingAnimations);
}
public AnimationVariables getVariables() {
return this.animationVariables;
}
public LivingEntityPatch<?> getEntityPatch() {
return this.entitypatch;
}
public void resetLivingAnimations() {
this.livingAnimations.clear();
}
}

View File

@@ -0,0 +1,131 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import java.util.Objects;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.Nullable;
/**
* Motion {@link LivingMotion} ajoute via datapack, sans code Java.
*
* <p>Modder path : deposer un fichier JSON dans
* {@code data/<ns>/tiedup/living_motions/<name>.json}. Le {@link
* LivingMotionReloadListener} le detecte au chargement des datapacks, l'enregistre
* dans {@link LivingMotion#ENUM_MANAGER} (meme ordinal pool que les enums Java
* builtin {@link LivingMotions} et {@link TiedUpLivingMotions}), et le rend
* resolvable via {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemParser}.</p>
*
* <p>L'id de la motion est derive du path du fichier :
* {@code data/mymod/tiedup/living_motions/orgasm_shake.json} devient
* {@code mymod:orgasm_shake}. Le {@code toString()} renvoie la ResourceLocation
* complete — {@link com.tiedup.remake.rig.util.ExtendableEnumManager#assign}
* utilise {@code toString()} lowercased comme cle unique, donc deux motions
* de namespace differents coexistent sans collision (p.ex.
* {@code mymod:orgasm_shake} vs {@code tiedup:orgasm_shake}).</p>
*
* <h2>Choix de design : classe, pas record</h2>
* <p>Le {@code universalOrdinal()} doit etre assigne par
* {@link com.tiedup.remake.rig.util.ExtendableEnumManager#assign} APRES
* construction de l'instance (l'{@code ExtendableEnumManager} prend le
* {@link LivingMotion} en parametre, lit sa cle via {@code toString()}, et
* retourne l'ordinal a posteriori). Un {@code record} Java a tous ses champs
* immuables — il faudrait donc construire deux instances (placeholder +
* final), ce qui laisse un {@code placeholder.universalOrdinal() == -1} stocke
* dans les maps internes du {@code ExtendableEnumManager}. Une classe
* mutable-at-first-call (pattern identique a {@link LivingMotions#id}) evite
* ce double-hop et garantit que l'instance stockee dans
* {@code ENUM_MANAGER.enumMapByName} porte le bon ordinal.</p>
*/
public final class DataDrivenLivingMotion implements LivingMotion {
private final ResourceLocation id;
private final String description;
@Nullable
private final String category;
/**
* Ordinal attribue par {@link LivingMotion#ENUM_MANAGER}. Non-final : set
* une seule fois par {@link LivingMotionReloadListener} juste apres
* {@code assign()}. {@code volatile} garantit visibilite cross-thread si
* un client lit {@code universalOrdinal()} pendant que le server thread
* est en train de finir l'assignation (peu probable en pratique — assign
* est sous {@code synchronized}).
*/
private volatile int ordinal = -1;
public DataDrivenLivingMotion(
ResourceLocation id,
String description,
@Nullable String category
) {
this.id = Objects.requireNonNull(id, "id");
this.description = Objects.requireNonNull(description, "description");
this.category = category;
}
/**
* Appele exactement une fois par {@link LivingMotionReloadListener} pour
* poser l'ordinal retourne par {@code ExtendableEnumManager.assign}.
*
* @param ordinal ordinal >= 0
* @throws IllegalStateException si deja assigne
*/
void setOrdinal(int ordinal) {
if (this.ordinal != -1) {
throw new IllegalStateException(
"DataDrivenLivingMotion " + this.id + " ordinal already set to "
+ this.ordinal + " (tried to set " + ordinal + ")"
);
}
if (ordinal < 0) {
throw new IllegalArgumentException(
"Negative ordinal " + ordinal + " for " + this.id
);
}
this.ordinal = ordinal;
}
public ResourceLocation id() {
return this.id;
}
public String description() {
return this.description;
}
@Nullable
public String category() {
return this.category;
}
@Override
public int universalOrdinal() {
return this.ordinal;
}
/**
* La cle de dedup dans {@link com.tiedup.remake.rig.util.ExtendableEnumManager}
* est {@code toString().toLowerCase()}. On expose la RL complete pour garantir
* l'unicite cross-namespace ({@code mymod:foo} != {@code tiedup:foo}).
*/
@Override
public String toString() {
return this.id.toString();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof DataDrivenLivingMotion other)) return false;
return this.id.equals(other.id);
}
@Override
public int hashCode() {
return this.id.hashCode();
}
}

View File

@@ -0,0 +1,49 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import com.tiedup.remake.rig.armature.JointTransform;
public class Keyframe {
private float timeStamp;
private final JointTransform transform;
public Keyframe(float timeStamp, JointTransform trasnform) {
this.timeStamp = timeStamp;
this.transform = trasnform;
}
public Keyframe(Keyframe original) {
this.transform = JointTransform.empty();
this.copyFrom(original);
}
public void copyFrom(Keyframe target) {
this.timeStamp = target.timeStamp;
this.transform.copyFrom(target.transform);
}
public float time() {
return this.timeStamp;
}
public void setTime(float time) {
this.timeStamp = time;
}
public JointTransform transform() {
return this.transform;
}
public String toString() {
return "Keyframe[Time: " + this.timeStamp + ", " + (this.transform == null ? "null" : this.transform.toString()) + "]";
}
public static Keyframe empty() {
return new Keyframe(0.0F, JointTransform.empty());
}
}

View File

@@ -0,0 +1,24 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import com.tiedup.remake.rig.util.ExtendableEnum;
import com.tiedup.remake.rig.util.ExtendableEnumManager;
public interface LivingMotion extends ExtendableEnum {
ExtendableEnumManager<LivingMotion> ENUM_MANAGER = new ExtendableEnumManager<> ("living_motion");
default boolean isSame(LivingMotion livingMotion) {
if (this == LivingMotions.IDLE && livingMotion == LivingMotions.INACTION) {
return true;
} else if (this == LivingMotions.INACTION && livingMotion == LivingMotions.IDLE) {
return true;
}
return this == livingMotion;
}
}

View File

@@ -0,0 +1,267 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
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 org.jetbrains.annotations.Nullable;
import com.tiedup.remake.rig.TiedUpRigConstants;
/**
* Scanne les fichiers JSON {@code data/<ns>/tiedup/living_motions/*.json} et
* enregistre chacun en tant que {@link DataDrivenLivingMotion} dans le
* registre partage {@link LivingMotion#ENUM_MANAGER}.
*
* <h2>But</h2>
* <p>Permettre a un modder/resourcepack-maker d'ajouter de nouvelles
* {@link LivingMotion} (ex. {@code mymod:orgasm_shake}) sans coder un enum
* Java + sans appel explicite a {@code LivingMotion.ENUM_MANAGER.registerEnumCls}.
* Workflow 100% data-driven.</p>
*
* <h2>Format JSON attendu</h2>
* <pre>{@code
* {
* "description": "Orgasm shake shiver anim — fired on VX state",
* "category": "vx_reactions"
* }
* }</pre>
* <p>Le champ {@code description} est obligatoire (humain + logs). Le champ
* {@code category} est optionnel et sert au regroupement editorial uniquement
* (ex. {@code locomotion}, {@code vx_reactions}, {@code restraint}...).</p>
*
* <h2>Ordinal stability cross-reload</h2>
* <p>Le {@link com.tiedup.remake.rig.util.ExtendableEnumManager#assign} refuse
* d'enregistrer deux fois la meme cle (throw {@link IllegalArgumentException}).
* On garde donc une vue persistante {@link #PERSISTENT_REGISTRY} : au premier
* load d'un id, l'ordinal est attribue et le {@link DataDrivenLivingMotion}
* est cache ; les reloads ulterieurs re-utilisent la meme instance (et donc
* le meme ordinal). Le cache survit aux {@code /reload} (static + JVM lifetime),
* mais PAS aux restart de serveur — voir section "Limitations".</p>
*
* <h2>Limitations connues</h2>
* <ul>
* <li>Les ordinals sont stables pendant une session JVM mais re-attribues
* apres restart — l'ordre de decouverte des fichiers JSON (alpha-sorted
* par ResourceLocation) determine l'ordinal initial. Si un modder
* serialise l'ordinal (ex. network packet ou NBT), la reference cassera
* apres restart si un nouveau motion est ajoute avant dans l'ordre de
* scan. En pratique, tous les consumers internes TiedUp! referencent
* les motions par {@link ResourceLocation}, pas par ordinal — le
* probleme ne se manifeste que si un mod tiers persiste l'ordinal.</li>
* <li>Un JSON mal forme (pas de {@code description} ou type invalide) est
* skip avec un WARN ; le reste du batch continue.</li>
* <li>Un meme id re-charge avec une description differente emet un WARN
* mais garde la PREMIERE description en memoire (immuable). La nouvelle
* description apparait au prochain restart JVM.</li>
* </ul>
*
* <h2>Side & threading</h2>
* <p>{@link SimpleJsonResourceReloadListener#apply} s'execute cote serveur a
* chaque {@code /reload} (+ worldload), et cote client au resource reload
* (F3+T). Les deux sides partagent le meme {@link LivingMotion#ENUM_MANAGER}
* (static JVM-wide) — sur serveur integre, les sides pointent vers le meme
* registre ; pas de double comptabilite. {@code synchronized} sur
* {@code ExtendableEnumManager.assign} absorbe le risque theorique de race
* entre le thread de reload serveur et le thread client.</p>
*/
public class LivingMotionReloadListener extends SimpleJsonResourceReloadListener {
/**
* Cache JVM-wide des motions data-driven : une entree = un ordinal
* definitivement reserve. Le mutex {@link LivingMotion#ENUM_MANAGER} (via
* {@code synchronized} sur {@code assign}) protege les inserts ; cette
* {@link ConcurrentHashMap} supporte les {@code get} concurrents sans
* bloquer.
*/
private static final Map<ResourceLocation, DataDrivenLivingMotion> PERSISTENT_REGISTRY =
new ConcurrentHashMap<>();
/**
* Cache des descriptions pour permettre un WARN quand un reload change la
* description d'un motion existant (l'instance en memoire ne peut pas etre
* mise a jour — ordinal deja consomme et enregistre dans le ENUM_MANAGER).
*/
private static final Map<ResourceLocation, String> LAST_SEEN_DESCRIPTIONS =
new ConcurrentHashMap<>();
/** Dossier scanne : {@code data/<ns>/tiedup/living_motions/*.json}. */
public static final String DIRECTORY = "tiedup/living_motions";
public LivingMotionReloadListener() {
super(new GsonBuilder().create(), DIRECTORY);
}
/**
* Resout une motion data-driven par sa ResourceLocation.
*
* @param id identifiant namespace:path (ex. {@code mymod:orgasm_shake})
* @return la motion enregistree, ou {@code null} si jamais vue
*/
@Nullable
public static DataDrivenLivingMotion get(ResourceLocation id) {
return PERSISTENT_REGISTRY.get(id);
}
/** Nombre de motions data-driven actuellement connues. */
public static int size() {
return PERSISTENT_REGISTRY.size();
}
/**
* Vue immutable du registre, exposee pour debug / tests.
*/
public static Map<ResourceLocation, DataDrivenLivingMotion> view() {
return Collections.unmodifiableMap(PERSISTENT_REGISTRY);
}
/**
* Test hook — vide le registre data-driven. NE vide PAS le
* {@link LivingMotion#ENUM_MANAGER} sous-jacent (qui ne le supporte pas
* nativement), donc a n'utiliser que dans des tests isoles ou le reset
* d'ordinal ne cause pas de collision avec les enum builtin.
*
* <p>En prod, le registre ne se vide jamais — c'est intentionnel
* (preservation des ordinals pendant la session JVM).</p>
*/
static void clearForTests() {
PERSISTENT_REGISTRY.clear();
LAST_SEEN_DESCRIPTIONS.clear();
}
@Override
protected void apply(
Map<ResourceLocation, JsonElement> objectIn,
ResourceManager resourceManager,
ProfilerFiller profileFiller
) {
// Ordre alphabetique stable : si deux JSON sont vus pour la premiere
// fois au meme reload, l'ordre de ResourceLocation.compareTo
// determine l'ordre d'assignation. Reproductible entre deux boot JVM
// avec le meme ensemble de fichiers (evite les ordinals qui dansent).
Map<ResourceLocation, JsonElement> sorted = new TreeMap<>(objectIn);
int added = 0;
int reloaded = 0;
int skipped = 0;
for (Map.Entry<ResourceLocation, JsonElement> entry : sorted.entrySet()) {
ResourceLocation id = entry.getKey();
JsonElement element = entry.getValue();
if (!element.isJsonObject()) {
TiedUpRigConstants.LOGGER.warn(
"[LivingMotionReloadListener] Skipping {} : top-level JSON is not an object",
id
);
skipped++;
continue;
}
JsonObject obj = element.getAsJsonObject();
String description = readStringOrNull(obj, "description");
if (description == null) {
TiedUpRigConstants.LOGGER.warn(
"[LivingMotionReloadListener] Skipping {} : missing or invalid 'description'",
id
);
skipped++;
continue;
}
String category = readStringOrNull(obj, "category");
// Deja connu ? Reutilise l'instance — ordinal stable, pas de
// double-assign (qui throw IAE dans ExtendableEnumManager).
DataDrivenLivingMotion existing = PERSISTENT_REGISTRY.get(id);
if (existing != null) {
String lastDesc = LAST_SEEN_DESCRIPTIONS.get(id);
if (!Objects.equals(lastDesc, description)) {
TiedUpRigConstants.LOGGER.warn(
"[LivingMotionReloadListener] Motion {} reloaded with a different description "
+ "(was '{}', now '{}') — ordinal remains {}, new description takes effect "
+ "at next JVM restart",
id, lastDesc, description, existing.universalOrdinal()
);
LAST_SEEN_DESCRIPTIONS.put(id, description);
}
reloaded++;
continue;
}
// Nouveau motion : construction -> assign() -> pose ordinal.
// Le {@code DataDrivenLivingMotion} est construit avec ordinal=-1,
// puis {@code ExtendableEnumManager.assign} l'indexe par son
// {@code toString()} (la RL full) et retourne l'ordinal concret.
// On pose ensuite l'ordinal sur l'instance via {@code setOrdinal}.
// L'instance stockee dans les maps internes du ENUM_MANAGER EST
// la meme reference que celle dans PERSISTENT_REGISTRY — le set
// est donc visible a travers tous les lookups.
DataDrivenLivingMotion motion =
new DataDrivenLivingMotion(id, description, category);
int assignedOrdinal;
try {
assignedOrdinal = LivingMotion.ENUM_MANAGER.assign(motion);
} catch (IllegalArgumentException dup) {
// Le ENUM_MANAGER contient deja un motion avec cette cle
// (cas limite : conflit avec un enum builtin qui reserverait
// deliberement le meme toString(), p.ex. un modder qui
// appelle {@code mymod:orgasm_shake} un enum Java ET un JSON).
TiedUpRigConstants.LOGGER.warn(
"[LivingMotionReloadListener] Skipping {} : name collision in ENUM_MANAGER ({})",
id, dup.getMessage()
);
skipped++;
continue;
}
motion.setOrdinal(assignedOrdinal);
PERSISTENT_REGISTRY.put(id, motion);
LAST_SEEN_DESCRIPTIONS.put(id, description);
added++;
}
TiedUpRigConstants.LOGGER.info(
"[LivingMotionReloadListener] Reload done : {} new motion(s) registered, "
+ "{} motion(s) reloaded (ordinal preserved), {} skipped",
added, reloaded, skipped
);
}
@Nullable
private static String readStringOrNull(JsonObject obj, String key) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
JsonElement elem = obj.get(key);
if (!elem.isJsonPrimitive() || !elem.getAsJsonPrimitive().isString()) {
return null;
}
return elem.getAsString();
}
/**
* Hook d'apply direct pour tests — {@link SimpleJsonResourceReloadListener#apply}
* est {@code protected}, ce helper l'expose en public pour que les tests
* cross-package ({@code DataDrivenItemParserAnimationsTest}) puissent
* alimenter le registry sans bootstrap MC.
*
* <p>Le {@code ResourceManager} et le {@code ProfilerFiller} ne sont
* pas lus par notre {@code apply}, on peut passer {@code null} en test.</p>
*/
public void applyForTests(Map<ResourceLocation, JsonElement> data) {
this.apply(data, null, null);
}
}

View File

@@ -0,0 +1,23 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
public enum LivingMotions implements LivingMotion {
ALL, // Datapack edit option
INACTION, IDLE, CONFRONT, ANGRY, FLOAT, WALK, RUN, SWIM, FLY, SNEAK, KNEEL, FALL, SIT, MOUNT, DEATH, CHASE, SPELLCAST, JUMP, CELEBRATE, LANDING_RECOVERY, CREATIVE_FLY, CREATIVE_IDLE, SLEEP, // Base
DIGGING, ADMIRE, CLIMB, DRINK, EAT, NONE, AIM, BLOCK, BLOCK_SHIELD, RELOAD, SHOT, SPECTATE; // Mix
final int id;
LivingMotions() {
this.id = LivingMotion.ENUM_MANAGER.assign(this);
}
public int universalOrdinal() {
return this.id;
}
}

View File

@@ -0,0 +1,115 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;
import com.google.common.base.Predicate;
import com.google.common.collect.Maps;
import com.tiedup.remake.rig.armature.JointTransform;
public class Pose {
public static final Pose EMPTY_POSE = new Pose();
public static Pose interpolatePose(Pose pose1, Pose pose2, float pregression) {
Pose pose = new Pose();
Set<String> mergedSet = new HashSet<>(pose1.jointTransformData.keySet());
mergedSet.addAll(pose2.jointTransformData.keySet());
for (String jointName : mergedSet) {
pose.putJointData(jointName, JointTransform.interpolate(pose1.orElseEmpty(jointName), pose2.orElseEmpty(jointName), pregression));
}
return pose;
}
protected final Map<String, JointTransform> jointTransformData;
public Pose() {
this(Maps.newHashMap());
}
public Pose(Map<String, JointTransform> jointTransforms) {
this.jointTransformData = jointTransforms;
}
public void putJointData(String name, JointTransform transform) {
this.jointTransformData.put(name, transform);
}
public Map<String, JointTransform> getJointTransformData() {
return this.jointTransformData;
}
public void disableJoint(Predicate<? super Map.Entry<String, JointTransform>> predicate) {
this.jointTransformData.entrySet().removeIf(predicate);
}
public void disableAllJoints() {
this.jointTransformData.clear();
}
public boolean hasTransform(String jointName) {
return this.jointTransformData.containsKey(jointName);
}
public JointTransform get(String jointName) {
return this.jointTransformData.get(jointName);
}
public JointTransform orElseEmpty(String jointName) {
return this.jointTransformData.getOrDefault(jointName, JointTransform.empty());
}
public JointTransform orElse(String jointName, JointTransform orElse) {
return this.jointTransformData.getOrDefault(jointName, orElse);
}
public void forEachEnabledTransforms(BiConsumer<String, JointTransform> task) {
this.jointTransformData.forEach(task);
}
public void load(Pose pose, LoadOperation operation) {
switch (operation) {
case SET -> {
this.disableAllJoints();
pose.forEachEnabledTransforms(this::putJointData);
}
case OVERWRITE -> {
pose.forEachEnabledTransforms(this::putJointData);
}
case APPEND_ABSENT -> {
pose.forEachEnabledTransforms((name, transform) -> {
if (!this.hasTransform(name)) {
this.putJointData(name, transform);
}
});
}
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("Pose: ");
for (Map.Entry<String, JointTransform> entry : this.jointTransformData.entrySet()) {
sb.append(String.format("%s{%s, %s}, ", entry.getKey(), entry.getValue().translation().toString(), entry.getValue().rotation().toString()) + "\n");
}
return sb.toString();
}
public enum LoadOperation {
SET, OVERWRITE, APPEND_ABSENT
}
}

View File

@@ -0,0 +1,160 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import java.util.Optional;
import com.mojang.datafixers.util.Pair;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.anim.types.EntityState;
import com.tiedup.remake.rig.anim.types.LinkAnimation;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.TiedUpRigRegistry;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public class ServerAnimator extends Animator {
public static Animator getAnimator(LivingEntityPatch<?> entitypatch) {
return new ServerAnimator(entitypatch);
}
private final LinkAnimation linkAnimation;
public final AnimationPlayer animationPlayer;
protected AssetAccessor<? extends DynamicAnimation> nextAnimation;
public boolean hardPaused = false;
public boolean softPaused = false;
public ServerAnimator(LivingEntityPatch<?> entitypatch) {
super(entitypatch);
this.linkAnimation = new LinkAnimation();
this.animationPlayer = new AnimationPlayer();
}
/** Play an animation by animation instance **/
@Override
public void playAnimation(AssetAccessor<? extends StaticAnimation> nextAnimation, float transitionTimeModifier) {
this.softPaused = false;
Pose lastPose = this.animationPlayer.getAnimation().get().getPoseByTime(this.entitypatch, 0.0F, 0.0F);
if (!this.animationPlayer.isEnd()) {
this.animationPlayer.getAnimation().get().end(this.entitypatch, nextAnimation, false);
}
nextAnimation.get().begin(this.entitypatch);
if (!nextAnimation.get().isMetaAnimation()) {
nextAnimation.get().setLinkAnimation(this.animationPlayer.getAnimation(), lastPose, true, transitionTimeModifier, this.entitypatch, this.linkAnimation);
this.linkAnimation.getAnimationClip().setBaked();
this.linkAnimation.putOnPlayer(this.animationPlayer, this.entitypatch);
this.entitypatch.updateEntityState();
this.nextAnimation = nextAnimation;
}
}
@Override
public void playAnimationInstantly(AssetAccessor<? extends StaticAnimation> nextAnimation) {
this.softPaused = false;
if (!this.animationPlayer.isEnd()) {
this.animationPlayer.getAnimation().get().end(this.entitypatch, nextAnimation, false);
}
nextAnimation.get().begin(this.entitypatch);
nextAnimation.get().putOnPlayer(this.animationPlayer, this.entitypatch);
this.entitypatch.updateEntityState();
}
@Override
public void reserveAnimation(AssetAccessor<? extends StaticAnimation> nextAnimation) {
this.softPaused = false;
this.nextAnimation = nextAnimation;
}
@Override
public boolean stopPlaying(AssetAccessor<? extends StaticAnimation> targetAnimation) {
if (this.animationPlayer.getRealAnimation() == targetAnimation) {
this.animationPlayer.terminate(this.entitypatch);
return true;
}
return false;
}
@Override
public void playShootingAnimation() {
}
@Override
public void tick() {
if (this.hardPaused || this.softPaused) {
this.entitypatch.updateEntityState();
return;
}
this.animationPlayer.tick(this.entitypatch);
this.entitypatch.updateEntityState();
if (this.animationPlayer.isEnd()) {
if (this.nextAnimation == null) {
TiedUpRigRegistry.EMPTY_ANIMATION.putOnPlayer(this.animationPlayer, this.entitypatch);
this.softPaused = true;
} else {
if (!this.animationPlayer.getAnimation().get().isLinkAnimation() && !this.nextAnimation.get().isLinkAnimation()) {
this.nextAnimation.get().begin(this.entitypatch);
}
this.nextAnimation.get().putOnPlayer(this.animationPlayer, this.entitypatch);
this.nextAnimation = null;
}
} else {
this.animationPlayer.getAnimation().get().tick(this.entitypatch);
}
}
@Override
public Pose getPose(float partialTicks) {
return this.animationPlayer.getCurrentPose(this.entitypatch, partialTicks);
}
@Override
public AnimationPlayer getPlayerFor(AssetAccessor<? extends DynamicAnimation> playingAnimation) {
return this.animationPlayer;
}
@Override
public Optional<AnimationPlayer> getPlayer(AssetAccessor<? extends DynamicAnimation> playingAnimation) {
if (this.animationPlayer.getRealAnimation() == playingAnimation.get().getRealAnimation()) {
return Optional.of(this.animationPlayer);
} else {
return Optional.empty();
}
}
@Override
@SuppressWarnings("unchecked")
public <T> Pair<AnimationPlayer, T> findFor(Class<T> animationType) {
return animationType.isAssignableFrom(this.animationPlayer.getAnimation().getClass()) ? Pair.of(this.animationPlayer, (T)this.animationPlayer.getAnimation()) : null;
}
@Override
public EntityState getEntityState() {
return this.animationPlayer.getAnimation().get().getState(this.entitypatch, this.animationPlayer.getElapsedTime());
}
@Override
public void setSoftPause(boolean paused) {
this.softPaused = paused;
}
@Override
public void setHardPause(boolean paused) {
this.hardPaused = paused;
}
}

View File

@@ -0,0 +1,127 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import java.util.function.Function;
import javax.annotation.Nullable;
import net.minecraft.core.IdMapper;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.registries.IForgeRegistry;
import net.minecraftforge.registries.IForgeRegistryInternal;
import net.minecraftforge.registries.RegistryManager;
import com.tiedup.remake.rig.anim.AnimationVariables.IndependentAnimationVariableKey;
import com.tiedup.remake.rig.anim.AnimationVariables.SharedAnimationVariableKey;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.util.PacketBufferCodec;
import com.tiedup.remake.rig.util.datastruct.ClearableIdMapper;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.AnimationVariablePacket;
import com.tiedup.remake.rig.anim.AnimationVariablePacket;
import com.tiedup.remake.rig.anim.AnimationVariablePacket;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public interface SynchedAnimationVariableKey<T> {
public static <T> SynchedSharedAnimationVariableKey<T> shared(Function<Animator, T> defaultValueSupplier, boolean mutable, PacketBufferCodec<T> codec) {
return new SynchedSharedAnimationVariableKey<> (defaultValueSupplier, mutable, codec);
}
public static <T> SynchedIndependentAnimationVariableKey<T> independent(Function<Animator, T> defaultValueSupplier, boolean mutable, PacketBufferCodec<T> codec) {
return new SynchedIndependentAnimationVariableKey<> (defaultValueSupplier, mutable, codec);
}
public static final ResourceLocation BY_ID_REGISTRY = TiedUpRigConstants.identifier("variablekeytoid");
public static class SynchedAnimationVariableKeyCallbacks implements IForgeRegistry.BakeCallback<SynchedAnimationVariableKey<?>>, IForgeRegistry.CreateCallback<SynchedAnimationVariableKey<?>>, IForgeRegistry.ClearCallback<SynchedAnimationVariableKey<?>> {
private static final SynchedAnimationVariableKeyCallbacks INSTANCE = new SynchedAnimationVariableKeyCallbacks();
@Override
@SuppressWarnings("unchecked")
public void onBake(IForgeRegistryInternal<SynchedAnimationVariableKey<?>> owner, RegistryManager stage) {
final ClearableIdMapper<SynchedAnimationVariableKey<?>> synchedanimationvariablekeybyid = owner.getSlaveMap(BY_ID_REGISTRY, ClearableIdMapper.class);
owner.forEach(synchedanimationvariablekeybyid::add);
}
@Override
public void onCreate(IForgeRegistryInternal<SynchedAnimationVariableKey<?>> owner, RegistryManager stage) {
owner.setSlaveMap(BY_ID_REGISTRY, new ClearableIdMapper<SynchedAnimationVariableKey<?>> (owner.getKeys().size()));
}
@Override
public void onClear(IForgeRegistryInternal<SynchedAnimationVariableKey<?>> owner, RegistryManager stage) {
owner.getSlaveMap(BY_ID_REGISTRY, ClearableIdMapper.class).clear();
}
}
public static SynchedAnimationVariableKeyCallbacks getRegistryCallback() {
return SynchedAnimationVariableKeyCallbacks.INSTANCE;
}
@SuppressWarnings("unchecked")
public static IdMapper<SynchedAnimationVariableKey<?>> getIdMap() {
return SynchedAnimationVariableKeys.REGISTRY.get().getSlaveMap(BY_ID_REGISTRY, IdMapper.class);
}
@SuppressWarnings("unchecked")
public static <T> SynchedAnimationVariableKey<T> byId(int id) {
return (SynchedAnimationVariableKey<T>)getIdMap().byId(id);
}
public PacketBufferCodec<T> getPacketBufferCodec();
public boolean isSharedKey();
default int getId() {
return getIdMap().getId(this);
}
default void sync(LivingEntityPatch<?> entitypatch, @Nullable AssetAccessor<? extends StaticAnimation> animation, T value, AnimationVariablePacket.Action action) {
// RIG : sync réseau des animation variables strippé.
// Pas d'usage bondage identifié — ré-implémenter Phase 2 avec packet
// dédié si besoin. Voir AnimationVariablePacket stub.
}
public static class SynchedSharedAnimationVariableKey<T> extends SharedAnimationVariableKey<T> implements SynchedAnimationVariableKey<T> {
private final PacketBufferCodec<T> packetBufferCodec;
protected SynchedSharedAnimationVariableKey(Function<Animator, T> defaultValueSupplier, boolean mutable, PacketBufferCodec<T> packetBufferCodec) {
super(defaultValueSupplier, mutable);
this.packetBufferCodec = packetBufferCodec;
}
@Override
public boolean isSynched() {
return true;
}
@Override
public PacketBufferCodec<T> getPacketBufferCodec() {
return this.packetBufferCodec;
}
}
public static class SynchedIndependentAnimationVariableKey<T> extends IndependentAnimationVariableKey<T> implements SynchedAnimationVariableKey<T> {
private final PacketBufferCodec<T> packetBufferCodec;
protected SynchedIndependentAnimationVariableKey(Function<Animator, T> defaultValueSupplier, boolean mutable, PacketBufferCodec<T> packetBufferCodec) {
super(defaultValueSupplier, mutable);
this.packetBufferCodec = packetBufferCodec;
}
@Override
public boolean isSharedKey() {
return false;
}
@Override
public PacketBufferCodec<T> getPacketBufferCodec() {
return this.packetBufferCodec;
}
}
}

View File

@@ -0,0 +1,35 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import java.util.function.Supplier;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.IForgeRegistry;
import net.minecraftforge.registries.RegistryBuilder;
import net.minecraftforge.registries.RegistryObject;
import com.tiedup.remake.rig.anim.SynchedAnimationVariableKey.SynchedIndependentAnimationVariableKey;
import com.tiedup.remake.rig.util.PacketBufferCodec;
import com.tiedup.remake.rig.TiedUpRigConstants;
public class SynchedAnimationVariableKeys {
private static final Supplier<RegistryBuilder<SynchedAnimationVariableKey<?>>> BUILDER = () -> new RegistryBuilder<SynchedAnimationVariableKey<?>>().addCallback(SynchedAnimationVariableKey.getRegistryCallback());
public static final DeferredRegister<SynchedAnimationVariableKey<?>> SYNCHED_ANIMATION_VARIABLE_KEYS = DeferredRegister.create(TiedUpRigConstants.identifier("synched_animation_variable_keys"), TiedUpRigConstants.MODID);
public static final Supplier<IForgeRegistry<SynchedAnimationVariableKey<?>>> REGISTRY = SYNCHED_ANIMATION_VARIABLE_KEYS.makeRegistry(BUILDER);
public static final RegistryObject<SynchedIndependentAnimationVariableKey<Vec3>> DESTINATION = SYNCHED_ANIMATION_VARIABLE_KEYS.register("destination", () ->
SynchedAnimationVariableKey.independent(animator -> animator.getEntityPatch().getOriginal().position(), true, PacketBufferCodec.VEC3));
public static final RegistryObject<SynchedIndependentAnimationVariableKey<Integer>> TARGET_ENTITY = SYNCHED_ANIMATION_VARIABLE_KEYS.register("target_entity", () ->
SynchedAnimationVariableKey.independent(animator -> (Integer)null, true, PacketBufferCodec.INTEGER));
public static final RegistryObject<SynchedIndependentAnimationVariableKey<Integer>> CHARGING_TICKS = SYNCHED_ANIMATION_VARIABLE_KEYS.register("animation_playing_speed", () ->
SynchedAnimationVariableKey.independent(animator -> 0, true, PacketBufferCodec.INTEGER));
}

View File

@@ -0,0 +1,47 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
/**
* Motions custom TiedUp! — extension de {@link LivingMotions} (motions vanilla EF).
*
* Chaque valeur partage le meme {@link LivingMotion#ENUM_MANAGER} que
* {@link LivingMotions} : les universalOrdinal() sont assignes a la suite, sans
* collision, a condition que les deux enums soient class-loaded avant usage.
*
* Les 8 premieres motions correspondent au design original RIG (cf.
* docs/plans/rig/). Les 3 dernieres sont des ajouts UX (P0/P1) :
* - POSE_SLEEP_BOUND : sleep avec restraints (P0)
* - POSE_UNCONSCIOUS : steady-state post-capture (P0)
* - FALL_BOUND : fall sans flailing (P1)
*
* Class-load force dans {@code TiedUpMod.commonSetup} via {@link #values()} —
* sans ca, les ordinals ne sont pas assignes tant que l'enum n'est pas touche
* (JLS : init lazy).
*/
public enum TiedUpLivingMotions implements LivingMotion {
POSE_DOG,
POSE_PET_BED_SIT,
POSE_PET_BED_SLEEP,
POSE_FURNITURE_SEAT,
POSE_KNEEL_BOUND,
STRUGGLE_BOUND,
WALK_BOUND,
SNEAK_BOUND,
POSE_SLEEP_BOUND, // UX P0 — sleep avec restraints
POSE_UNCONSCIOUS, // UX P0 — steady-state post-capture
FALL_BOUND; // UX P1 — no flailing en chute
final int id;
TiedUpLivingMotions() {
this.id = LivingMotion.ENUM_MANAGER.assign(this);
}
@Override
public int universalOrdinal() {
return this.id;
}
}

View File

@@ -0,0 +1,302 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Function;
import org.joml.Quaternionf;
import net.minecraft.util.Mth;
import net.minecraft.world.phys.Vec3;
import com.tiedup.remake.rig.armature.JointTransform;
import com.tiedup.remake.rig.math.MathUtils;
import com.tiedup.remake.rig.math.OpenMatrix4f;
import com.tiedup.remake.rig.math.Vec3f;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public class TransformSheet {
public static final TransformSheet EMPTY_SHEET = new TransformSheet(List.of(new Keyframe(0.0F, JointTransform.empty()), new Keyframe(Float.MAX_VALUE, JointTransform.empty())));
public static final Function<Vec3, TransformSheet> EMPTY_SHEET_PROVIDER = translation -> {
return new TransformSheet(List.of(new Keyframe(0.0F, JointTransform.translation(new Vec3f(translation))), new Keyframe(Float.MAX_VALUE, JointTransform.empty())));
};
private Keyframe[] keyframes;
public TransformSheet() {
this(new Keyframe[0]);
}
public TransformSheet(int size) {
this(new Keyframe[size]);
}
public TransformSheet(List<Keyframe> keyframeList) {
this(keyframeList.toArray(new Keyframe[0]));
}
public TransformSheet(Keyframe[] keyframes) {
this.keyframes = keyframes;
}
public Keyframe[] getKeyframes() {
return this.keyframes;
}
public TransformSheet copyAll() {
return this.copy(0, this.keyframes.length);
}
public TransformSheet copy(int start, int end) {
int len = end - start;
Keyframe[] newKeyframes = new Keyframe[len];
for (int i = 0; i < len; i++) {
Keyframe kf = this.keyframes[i + start];
newKeyframes[i] = new Keyframe(kf);
}
return new TransformSheet(newKeyframes);
}
public TransformSheet readFrom(TransformSheet opponent) {
if (opponent.keyframes.length != this.keyframes.length) {
this.keyframes = new Keyframe[opponent.keyframes.length];
for (int i = 0; i < this.keyframes.length; i++) {
this.keyframes[i] = Keyframe.empty();
}
}
for (int i = 0; i < this.keyframes.length; i++) {
this.keyframes[i].copyFrom(opponent.keyframes[i]);
}
return this;
}
public TransformSheet createInterpolated(float[] timestamp) {
TransformSheet interpolationCreated = new TransformSheet(timestamp.length);
for (int i = 0; i < timestamp.length; i++) {
interpolationCreated.keyframes[i] = new Keyframe(timestamp[i], this.getInterpolatedTransform(timestamp[i]));
}
return interpolationCreated;
}
/**
* Transform each joint
*/
public void forEach(BiConsumer<Integer, Keyframe> task) {
this.forEach(task, 0, this.keyframes.length);
}
public void forEach(BiConsumer<Integer, Keyframe> task, int start, int end) {
end = Math.min(end, this.keyframes.length);
for (int i = start; i < end; i++) {
task.accept(i, this.keyframes[i]);
}
}
public Vec3f getInterpolatedTranslation(float currentTime) {
InterpolationInfo interpolInfo = this.getInterpolationInfo(currentTime);
if (interpolInfo == InterpolationInfo.INVALID) {
return new Vec3f();
}
Vec3f vec3f = MathUtils.lerpVector(this.keyframes[interpolInfo.prev].transform().translation(), this.keyframes[interpolInfo.next].transform().translation(), interpolInfo.delta);
return vec3f;
}
public JointTransform getInterpolatedTransform(float currentTime) {
return this.getInterpolatedTransform(this.getInterpolationInfo(currentTime));
}
public JointTransform getInterpolatedTransform(InterpolationInfo interpolationInfo) {
if (interpolationInfo == InterpolationInfo.INVALID) {
return JointTransform.empty();
}
JointTransform trasnform = JointTransform.interpolate(this.keyframes[interpolationInfo.prev].transform(), this.keyframes[interpolationInfo.next].transform(), interpolationInfo.delta);
return trasnform;
}
public TransformSheet extend(TransformSheet target) {
int newKeyLength = this.keyframes.length + target.keyframes.length;
Keyframe[] newKeyfrmaes = new Keyframe[newKeyLength];
for (int i = 0; i < this.keyframes.length; i++) {
newKeyfrmaes[i] = this.keyframes[i];
}
for (int i = this.keyframes.length; i < newKeyLength; i++) {
newKeyfrmaes[i] = new Keyframe(target.keyframes[i - this.keyframes.length]);
}
this.keyframes = newKeyfrmaes;
return this;
}
public TransformSheet getFirstFrame() {
TransformSheet part = this.copy(0, 2);
Keyframe[] keyframes = part.getKeyframes();
keyframes[1].transform().copyFrom(keyframes[0].transform());
return part;
}
public void correctAnimationByNewPosition(Vec3f startpos, Vec3f startToEnd, Vec3f modifiedStart, Vec3f modifiedStartToEnd) {
Keyframe[] keyframes = this.getKeyframes();
Keyframe startKeyframe = keyframes[0];
Keyframe endKeyframe = keyframes[keyframes.length - 1];
float pitchDeg = (float) Math.toDegrees(Mth.atan2(modifiedStartToEnd.y - startToEnd.y, modifiedStartToEnd.length()));
float yawDeg = (float) MathUtils.getAngleBetween(modifiedStartToEnd.copy().multiply(1.0F, 0.0F, 1.0F), startToEnd.copy().multiply(1.0F, 0.0F, 1.0F));
for (Keyframe kf : keyframes) {
float lerp = (kf.time() - startKeyframe.time()) / (endKeyframe.time() - startKeyframe.time());
Vec3f line = MathUtils.lerpVector(new Vec3f(0F, 0F, 0F), startToEnd, lerp);
Vec3f modifiedLine = MathUtils.lerpVector(new Vec3f(0F, 0F, 0F), modifiedStartToEnd, lerp);
Vec3f keyTransform = kf.transform().translation();
Vec3f startToKeyTransform = keyTransform.copy().sub(startpos).multiply(-1.0F, 1.0F, -1.0F);
Vec3f animOnLine = startToKeyTransform.copy().sub(line);
OpenMatrix4f rotator = OpenMatrix4f.createRotatorDeg(pitchDeg, Vec3f.X_AXIS).mulFront(OpenMatrix4f.createRotatorDeg(yawDeg, Vec3f.Y_AXIS));
Vec3f toNewKeyTransform = modifiedLine.add(OpenMatrix4f.transform3v(rotator, animOnLine, null));
keyTransform.set(modifiedStart.copy().add((toNewKeyTransform)));
}
}
/**
* Transform the animation coord system to world coord system regarding origin point as @param worldDest
*
* @param entitypatch
* @param worldStart
* @param worldDest
* @param xRot
* @param entityYRot
* @param startFrame
* @param endFrame
* @return
*/
public TransformSheet transformToWorldCoordOriginAsDest(LivingEntityPatch<?> entitypatch, Vec3 startInWorld, Vec3 destInWorld, float entityYRot, float destYRot, int startFrmae, int destFrame) {
TransformSheet byStart = this.copy(0, destFrame + 1);
TransformSheet byDest = this.copy(0, destFrame + 1);
TransformSheet result = new TransformSheet(destFrame + 1);
Vec3 toTargetInWorld = destInWorld.subtract(startInWorld);
double worldMagnitude = toTargetInWorld.horizontalDistance();
double animMagnitude = this.keyframes[0].transform().translation().horizontalDistance();
float scale = (float)(worldMagnitude / animMagnitude);
byStart.forEach((idx, keyframe) -> {
keyframe.transform().translation().sub(this.keyframes[0].transform().translation());
keyframe.transform().translation().multiply(1.0F, 1.0F, scale);
keyframe.transform().translation().rotate(-entityYRot, Vec3f.Y_AXIS);
keyframe.transform().translation().multiply(-1.0F, 1.0F, -1.0F);
keyframe.transform().translation().add(startInWorld);
});
byDest.forEach((idx, keyframe) -> {
keyframe.transform().translation().multiply(1.0F, 1.0F, Mth.lerp((idx / (float)destFrame), scale, 1.0F));
keyframe.transform().translation().rotate(-destYRot, Vec3f.Y_AXIS);
keyframe.transform().translation().multiply(-1.0F, 1.0F, -1.0F);
keyframe.transform().translation().add(destInWorld);
});
for (int i = 0; i < destFrame + 1; i++) {
if (i <= startFrmae) {
result.getKeyframes()[i] = new Keyframe(this.keyframes[i].time(), JointTransform.translation(byStart.getKeyframes()[i].transform().translation()));
} else {
float lerp = this.keyframes[i].time() == 0.0F ? 0.0F : this.keyframes[i].time() / this.keyframes[destFrame].time();
Vec3f lerpTranslation = Vec3f.interpolate(byStart.getKeyframes()[i].transform().translation(), byDest.getKeyframes()[i].transform().translation(), lerp, null);
result.getKeyframes()[i] = new Keyframe(this.keyframes[i].time(), JointTransform.translation(lerpTranslation));
}
}
if (this.keyframes.length > destFrame) {
TransformSheet behindDestination = this.copy(destFrame + 1, this.keyframes.length);
behindDestination.forEach((idx, keyframe) -> {
keyframe.transform().translation().sub(this.keyframes[destFrame].transform().translation());
keyframe.transform().translation().rotate(entityYRot, Vec3f.Y_AXIS);
keyframe.transform().translation().multiply(-1.0F, 1.0F, -1.0F);
keyframe.transform().translation().add(result.getKeyframes()[destFrame].transform().translation());
});
result.extend(behindDestination);
}
return result;
}
public InterpolationInfo getInterpolationInfo(float currentTime) {
if (this.keyframes.length == 0) {
return InterpolationInfo.INVALID;
}
if (currentTime < 0.0F) {
currentTime = this.keyframes[this.keyframes.length - 1].time() + currentTime;
}
// Binary search
int begin = 0, end = this.keyframes.length - 1;
while (end - begin > 1) {
int i = begin + (end - begin) / 2;
if (this.keyframes[i].time() <= currentTime && this.keyframes[i+1].time() > currentTime) {
begin = i;
end = i+1;
break;
} else {
if (this.keyframes[i].time() > currentTime) {
end = i;
} else if (this.keyframes[i+1].time() <= currentTime) {
begin = i;
}
}
}
float progression = Mth.clamp((currentTime - this.keyframes[begin].time()) / (this.keyframes[end].time() - this.keyframes[begin].time()), 0.0F, 1.0F);
return new InterpolationInfo(begin, end, Float.isNaN(progression) ? 1.0F : progression);
}
public float maxFrameTime() {
float maxFrameTime = -1.0F;
for (Keyframe kf : this.keyframes) {
if (kf.time() > maxFrameTime) {
maxFrameTime = kf.time();
}
}
return maxFrameTime;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
int idx = 0;
for (Keyframe kf : this.keyframes) {
sb.append(kf);
if (++idx < this.keyframes.length) {
sb.append("\n");
}
}
return sb.toString();
}
public static record InterpolationInfo(int prev, int next, float delta) {
public static final InterpolationInfo INVALID = new InterpolationInfo(-1, -1, -1.0F);
}
}

View File

@@ -0,0 +1,80 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.action;
import com.mojang.serialization.Codec;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
import com.tiedup.remake.rig.util.CodecDispatchRegistry;
/**
* Phase 3 D2 — biggest artist unlock : an {@code AnimationAction} is a
* data-driven unit of behaviour triggered at animation begin / end / a specific
* frame / a period, authored from a datapack JSON block without any Java code.
*
* <p>Serialization goes through {@link AnimationActionRegistry#dispatchCodec()}
* which reads a {@code "type"} field and dispatches to the codec registered
* for that {@link ResourceLocation}. Example JSON :
* <pre>{@code
* { "type": "tiedup:play_sound",
* "sound": "minecraft:entity.player.levelup",
* "volume": 0.8 }
* }</pre>
*
* <p>Each implementation is responsible for its own sidedness — actions that
* mutate world state should early-return on the wrong side, actions that spawn
* client particles should early-return on server. This is by design : the outer
* {@link com.tiedup.remake.rig.anim.property.AnimationEvent.Side} filter still
* applies but individual actions can tighten it further when the outer event
* is configured as {@code BOTH}.
*
* <p>The {@code prevElapsed} / {@code elapsed} arguments are forwarded verbatim
* from the outer {@link com.tiedup.remake.rig.anim.property.AnimationEvent#execute}
* call so that timing-aware actions (future extension) can key off the exact
* trigger frame. Today's core actions ignore them — they execute atomically
* on trigger.
*/
public interface AnimationAction extends CodecDispatchRegistry.Typed {
/**
* Dispatch codec — reads the {@code "type"} field of the JSON object and
* delegates to the codec of the matching registered action. Unknown types
* return a {@link com.mojang.serialization.DataResult} error (logged via
* {@link com.tiedup.remake.rig.anim.property.AnimationProperty#parseFrom}
* and the containing list entry is dropped).
*
* <p>Uses {@code partialDispatch} rather than {@code dispatch} because
* {@code dispatch} requires a total function {@code ResourceLocation ->
* Codec<? extends AnimationAction>}, which cannot express &laquo;unknown
* type&raquo; without throwing.
*/
Codec<AnimationAction> CODEC = AnimationActionRegistry.INSTANCE.dispatchCodec();
/**
* The registered type id of this action (e.g. {@code tiedup:play_sound}).
* Used by the dispatch codec to serialize back to JSON.
*/
@Override
ResourceLocation type();
/**
* Execute this action against the entity playing {@code animation}.
*
* @param patch the living entity patch currently playing the animation
* @param animation the animation accessor (forwarded from the outer event)
* @param prevElapsed previous frame time in the animation (seconds)
* @param elapsed current frame time in the animation (seconds)
*/
void execute(
LivingEntityPatch<?> patch,
AssetAccessor<? extends DynamicAnimation> animation,
float prevElapsed,
float elapsed
);
}

View File

@@ -0,0 +1,48 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.action;
import com.tiedup.remake.rig.anim.action.impl.ApplyEffectAction;
import com.tiedup.remake.rig.anim.action.impl.DamageEntityAction;
import com.tiedup.remake.rig.anim.action.impl.PlaySoundAction;
import com.tiedup.remake.rig.anim.action.impl.SpawnParticleAction;
import com.tiedup.remake.rig.util.CodecDispatchRegistry;
/**
* Registry of {@link AnimationAction} type ids → codecs, used by the dispatch
* codec exposed via {@link AnimationAction#CODEC}.
*
* <p>The four core actions ({@code play_sound}, {@code spawn_particle},
* {@code apply_effect}, {@code damage_entity}) are registered in the static
* initializer of this class so that a single reference to
* {@link AnimationAction#CODEC} in a parse path is enough to bootstrap the
* full dispatch table.
*
* <p>Third-party mods may register additional action types by calling
* {@link #register} on {@link #INSTANCE} from their common setup event
* (post-{@code FMLCommonSetup} to avoid class-loading order surprises with
* the static init of this class).
*
* <p>Plumbing (map, register, dispatchCodec) lives in
* {@link CodecDispatchRegistry} since {@code SMELL-CODEC-01}.
*/
public final class AnimationActionRegistry extends CodecDispatchRegistry<AnimationAction> {
public static final AnimationActionRegistry INSTANCE = new AnimationActionRegistry();
private AnimationActionRegistry() {}
@Override
protected String registryName() {
return "AnimationAction";
}
static {
INSTANCE.register(PlaySoundAction.ID, PlaySoundAction.CODEC);
INSTANCE.register(SpawnParticleAction.ID, SpawnParticleAction.CODEC);
INSTANCE.register(ApplyEffectAction.ID, ApplyEffectAction.CODEC);
INSTANCE.register(DamageEntityAction.ID, DamageEntityAction.CODEC);
}
}

View File

@@ -0,0 +1,209 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.action;
import java.util.ArrayList;
import java.util.List;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import com.tiedup.remake.rig.anim.property.AnimationEvent;
import com.tiedup.remake.rig.anim.property.AnimationEvent.E0;
import com.tiedup.remake.rig.anim.property.AnimationEvent.InPeriodEvent;
import com.tiedup.remake.rig.anim.property.AnimationEvent.InTimeEvent;
import com.tiedup.remake.rig.anim.property.AnimationEvent.SimpleEvent;
/**
* Phase 3 D2 — adapter layer between JSON-authored event blocks and the
* runtime {@link AnimationEvent} hierarchy. Three serialized event shapes are
* supported :
*
* <ul>
* <li>{@link SimpleSerializedEvent} — fires on animation begin or end
* (no timing predicate).</li>
* <li>{@link TimeSerializedEvent} — fires once when the animation crosses
* the {@code frame} timestamp (seconds since anim start).</li>
* <li>{@link PeriodSerializedEvent} — fires every tick while the animation
* is between {@code start} and {@code end}.</li>
* </ul>
*
* <p>Each shape carries a list of {@link AnimationAction}s that all run when
* the event fires. Actions drive their own sidedness — see individual impls —
* so the outer {@link AnimationEvent.Side} defaults to {@link AnimationEvent.Side#BOTH}
* and can be overridden via an optional {@code "side"} field
* ({@code "CLIENT"}, {@code "SERVER"}, {@code "LOCAL_CLIENT"}, {@code "BOTH"}).
*
* <p>The adapter returns thin {@code E0} lambdas : the outer AnimationEvent
* machinery keeps handling side filtering + time-window checking, and the
* lambda body simply iterates the action list. This keeps the runtime path
* identical to the hand-coded Java path (no perf regression, no new class
* to pool).
*/
public final class DataDrivenAnimationEvents {
private DataDrivenAnimationEvents() {}
private static final Codec<AnimationEvent.Side> SIDE_CODEC = Codec.STRING.xmap(
s -> AnimationEvent.Side.valueOf(s.toUpperCase()),
Enum::name
);
/**
* Serialized shape for {@code on_begin} / {@code on_end} events.
*/
public record SimpleSerializedEvent(
List<AnimationAction> actions,
AnimationEvent.Side side
) {
public static final Codec<SimpleSerializedEvent> CODEC = RecordCodecBuilder.create(i -> i.group(
AnimationAction.CODEC.listOf().fieldOf("actions").forGetter(SimpleSerializedEvent::actions),
SIDE_CODEC.optionalFieldOf("side", AnimationEvent.Side.BOTH).forGetter(SimpleSerializedEvent::side)
).apply(i, SimpleSerializedEvent::new));
/**
* Sugar accepting either a full object {@code {"actions":[...], "side":...}}
* or a bare action list {@code [...]} — in which case {@code side}
* defaults to {@link AnimationEvent.Side#BOTH}. This is the shape most
* artists reach for.
*/
public static final Codec<SimpleSerializedEvent> SUGAR_CODEC = Codec.either(
AnimationAction.CODEC.listOf(),
CODEC
).xmap(
either -> either.map(
actions -> new SimpleSerializedEvent(actions, AnimationEvent.Side.BOTH),
full -> full
),
ev -> com.mojang.datafixers.util.Either.right(ev)
);
/**
* Convert this serialized record into the runtime {@link SimpleEvent}
* representation.
*/
public SimpleEvent<E0> toRuntime() {
final List<AnimationAction> captured = List.copyOf(this.actions);
E0 fire = (patch, anim, params) -> {
for (AnimationAction action : captured) {
action.execute(patch, anim, 0.0F, 0.0F);
}
};
return SimpleEvent.create(fire, this.side);
}
}
/**
* Serialized shape for a {@code tick_events} entry that fires once when
* the animation crosses {@code frame} (seconds).
*/
public record TimeSerializedEvent(
float frame,
List<AnimationAction> actions,
AnimationEvent.Side side
) {
public static final Codec<TimeSerializedEvent> CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.FLOAT.fieldOf("frame").forGetter(TimeSerializedEvent::frame),
AnimationAction.CODEC.listOf().fieldOf("actions").forGetter(TimeSerializedEvent::actions),
SIDE_CODEC.optionalFieldOf("side", AnimationEvent.Side.BOTH).forGetter(TimeSerializedEvent::side)
).apply(i, TimeSerializedEvent::new));
public InTimeEvent<E0> toRuntime() {
final List<AnimationAction> captured = List.copyOf(this.actions);
E0 fire = (patch, anim, params) -> {
for (AnimationAction action : captured) {
action.execute(patch, anim, 0.0F, 0.0F);
}
};
return InTimeEvent.create(this.frame, fire, this.side);
}
}
/**
* Serialized shape for a {@code tick_events} entry that fires every tick
* while the animation elapsed-time is between {@code start} and {@code end}.
*/
public record PeriodSerializedEvent(
float start,
float end,
List<AnimationAction> actions,
AnimationEvent.Side side
) {
public static final Codec<PeriodSerializedEvent> CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.FLOAT.fieldOf("start").forGetter(PeriodSerializedEvent::start),
Codec.FLOAT.fieldOf("end").forGetter(PeriodSerializedEvent::end),
AnimationAction.CODEC.listOf().fieldOf("actions").forGetter(PeriodSerializedEvent::actions),
SIDE_CODEC.optionalFieldOf("side", AnimationEvent.Side.BOTH).forGetter(PeriodSerializedEvent::side)
).apply(i, PeriodSerializedEvent::new));
public InPeriodEvent<E0> toRuntime() {
final List<AnimationAction> captured = List.copyOf(this.actions);
E0 fire = (patch, anim, params) -> {
for (AnimationAction action : captured) {
action.execute(patch, anim, 0.0F, 0.0F);
}
};
return InPeriodEvent.create(this.start, this.end, fire, this.side);
}
}
/**
* Discriminator codec for a single {@code tick_events} entry — picks
* between time and period by looking for the {@code "frame"} vs
* {@code "start"}/{@code "end"} keys. Implemented as an {@link Codec#either}
* because the two shapes are structurally disjoint (a time event cannot
* also be a period event).
*/
public static final Codec<AnimationEvent<?, ?>> TICK_EVENT_ENTRY_CODEC = Codec.either(
TimeSerializedEvent.CODEC,
PeriodSerializedEvent.CODEC
).xmap(
either -> either.map(TimeSerializedEvent::toRuntime, PeriodSerializedEvent::toRuntime),
// Encode path — best-effort : runtime events don't carry enough
// structural info to re-serialize losslessly (the action list is lost
// inside the opaque E0 lambda). We fall through to the period shape
// which is the richer of the two — encoding is not a supported
// round-trip path for these properties (see D2 scope notes).
ev -> com.mojang.datafixers.util.Either.right(new PeriodSerializedEvent(0.0F, 0.0F, List.of(), AnimationEvent.Side.BOTH))
);
/**
* Codec for the full {@code tick_events} list property.
*/
public static final Codec<List<AnimationEvent<?, ?>>> TICK_EVENTS_CODEC =
TICK_EVENT_ENTRY_CODEC.listOf().xmap(
ArrayList::new,
ArrayList::new
);
/**
* Codec for an {@code on_begin} / {@code on_end} list property. The entry
* codec accepts either a bare action list or a full object, see
* {@link SimpleSerializedEvent#SUGAR_CODEC}.
*/
public static final Codec<List<SimpleEvent<?>>> BEGIN_END_EVENTS_CODEC =
SimpleSerializedEvent.SUGAR_CODEC.listOf().xmap(
list -> {
List<SimpleEvent<?>> out = new ArrayList<>(list.size());
for (SimpleSerializedEvent ev : list) {
out.add(ev.toRuntime());
}
return out;
},
// Lossy encode — runtime SimpleEvent<?> doesn't retain action list
// after toRuntime wraps them in an opaque E0 lambda. Return empty
// serialized list (see class Javadoc).
runtime -> {
List<SimpleSerializedEvent> out = new ArrayList<>(runtime.size());
for (int i = 0; i < runtime.size(); i++) {
out.add(new SimpleSerializedEvent(List.of(), AnimationEvent.Side.BOTH));
}
return out;
}
);
}

View File

@@ -0,0 +1,91 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.action.impl;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.entity.LivingEntity;
import net.minecraftforge.registries.ForgeRegistries;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.action.AnimationAction;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
/**
* Apply a potion effect to the animating entity. Server-side authoritative —
* {@code LivingEntity.addEffect} is a no-op on the client (the effect will
* be synced down when the server accepts the change).
*
* <p>JSON schema :
* <pre>{@code
* { "type": "tiedup:apply_effect",
* "effect": "minecraft:slowness",
* "duration_ticks": 60,
* "amplifier": 1,
* "ambient": false,
* "show_particles": true,
* "show_icon": true }
* }</pre>
*
* <p>{@code amplifier} defaults to {@code 0} (level 1), {@code ambient} /
* {@code show_particles} / {@code show_icon} mirror {@link MobEffectInstance}'s
* defaults ({@code false} / {@code true} / {@code true}). Unknown effect ids
* are no-op + WARN.
*/
public record ApplyEffectAction(
ResourceLocation effect,
int durationTicks,
int amplifier,
boolean ambient,
boolean showParticles,
boolean showIcon
) implements AnimationAction {
public static final ResourceLocation ID = TiedUpRigConstants.identifier("apply_effect");
public static final Codec<ApplyEffectAction> CODEC = RecordCodecBuilder.create(i -> i.group(
ResourceLocation.CODEC.fieldOf("effect").forGetter(ApplyEffectAction::effect),
Codec.INT.fieldOf("duration_ticks").forGetter(ApplyEffectAction::durationTicks),
Codec.INT.optionalFieldOf("amplifier", 0).forGetter(ApplyEffectAction::amplifier),
Codec.BOOL.optionalFieldOf("ambient", false).forGetter(ApplyEffectAction::ambient),
Codec.BOOL.optionalFieldOf("show_particles", true).forGetter(ApplyEffectAction::showParticles),
Codec.BOOL.optionalFieldOf("show_icon", true).forGetter(ApplyEffectAction::showIcon)
).apply(i, ApplyEffectAction::new));
@Override
public ResourceLocation type() {
return ID;
}
@Override
public void execute(
LivingEntityPatch<?> patch,
AssetAccessor<? extends DynamicAnimation> animation,
float prevElapsed,
float elapsed
) {
MobEffect mobEffect = ForgeRegistries.MOB_EFFECTS.getValue(this.effect);
if (mobEffect == null) {
TiedUpRigConstants.LOGGER.warn("ApplyEffectAction : unknown mob effect {}", this.effect);
return;
}
LivingEntity entity = patch.getOriginal();
entity.addEffect(new MobEffectInstance(
mobEffect,
this.durationTicks,
this.amplifier,
this.ambient,
this.showParticles,
this.showIcon
));
}
}

View File

@@ -0,0 +1,140 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.action.impl;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraft.world.damagesource.DamageSources;
import net.minecraft.world.entity.LivingEntity;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.action.AnimationAction;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
/**
* Damage the animating entity for {@link #amount} half-hearts using the named
* damage source. Server-side authoritative — {@code LivingEntity.hurt} is a
* no-op on the client.
*
* <p>JSON schema :
* <pre>{@code
* { "type": "tiedup:damage_entity",
* "amount": 2.0,
* "source": "generic" }
* }</pre>
*
* <p>The {@code source} field maps to a helper method on {@link DamageSources}
* (see the {@link SourceType} enum). Defaults to {@code generic}. In 1.20.1
* damage types are registry-driven {@link net.minecraft.resources.ResourceKey},
* so the direct JSON-to-ResourceKey path would require a {@code RegistryAccess}
* we don't have in a codec — using the helper names keeps authoring ergonomic
* and side-steps the registry plumbing. Mods that want a custom damage type
* can register an {@link AnimationAction} subclass that consumes their id
* directly.
*/
public record DamageEntityAction(
float amount,
SourceType source
) implements AnimationAction {
public static final ResourceLocation ID = TiedUpRigConstants.identifier("damage_entity");
/**
* Safe codec over {@link SourceType} : uppercases + looks up the enum
* constant, returning a {@link DataResult#error} for unknown names rather
* than throwing. {@code flatXmap} is used (over {@code xmap}) precisely to
* surface the error through the Codec pipeline.
*/
private static final Codec<SourceType> SOURCE_TYPE_CODEC = Codec.STRING.flatXmap(
s -> {
try {
return DataResult.success(SourceType.valueOf(s.toUpperCase()));
} catch (IllegalArgumentException e) {
return DataResult.error(() -> "Unknown damage source type : " + s);
}
},
t -> DataResult.success(t.name().toLowerCase())
);
public static final Codec<DamageEntityAction> CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.FLOAT.fieldOf("amount").forGetter(DamageEntityAction::amount),
SOURCE_TYPE_CODEC.optionalFieldOf("source", SourceType.GENERIC).forGetter(DamageEntityAction::source)
).apply(i, DamageEntityAction::new));
@Override
public ResourceLocation type() {
return ID;
}
@Override
public void execute(
LivingEntityPatch<?> patch,
AssetAccessor<? extends DynamicAnimation> animation,
float prevElapsed,
float elapsed
) {
LivingEntity entity = patch.getOriginal();
if (entity.level().isClientSide()) {
return;
}
DamageSource damageSource = resolveDamageSource(entity.damageSources(), this.source);
if (damageSource == null) {
TiedUpRigConstants.LOGGER.warn("DamageEntityAction : could not resolve damage source {}", this.source);
return;
}
entity.hurt(damageSource, this.amount);
}
private static DamageSource resolveDamageSource(DamageSources sources, SourceType type) {
return switch (type) {
case GENERIC -> sources.generic();
case MAGIC -> sources.magic();
case FALL -> sources.fall();
case IN_FIRE -> sources.inFire();
case ON_FIRE -> sources.onFire();
case LAVA -> sources.lava();
case DROWN -> sources.drown();
case STARVE -> sources.starve();
case CACTUS -> sources.cactus();
case CRAMMING -> sources.cramming();
case IN_WALL -> sources.inWall();
case WITHER -> sources.wither();
case FREEZE -> sources.freeze();
case DRY_OUT -> sources.dryOut();
case SWEET_BERRY_BUSH -> sources.sweetBerryBush();
};
}
/**
* Whitelist of vanilla damage sources addressable from JSON. Artist-facing
* names are lowercase ({@code "generic"}, {@code "magic"}, ...) — the codec
* uppercases before looking up the enum.
*/
public enum SourceType {
GENERIC,
MAGIC,
FALL,
IN_FIRE,
ON_FIRE,
LAVA,
DROWN,
STARVE,
CACTUS,
CRAMMING,
IN_WALL,
WITHER,
FREEZE,
DRY_OUT,
SWEET_BERRY_BUSH
}
}

View File

@@ -0,0 +1,154 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.action.impl;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
import java.util.stream.Collectors;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.LivingEntity;
import net.minecraftforge.registries.ForgeRegistries;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.action.AnimationAction;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
/**
* Play a sound at the entity's position. Server-side authoritative — the
* {@code level.playSound(null, ...)} call broadcasts to all clients within
* the default spatial radius.
*
* <p>JSON schema :
* <pre>{@code
* { "type": "tiedup:play_sound",
* "sound": "minecraft:entity.player.levelup",
* "volume": 0.8,
* "pitch": 1.1,
* "category": "neutral" }
* }</pre>
*
* <p>{@code volume} defaults to {@code 1.0}, {@code pitch} defaults to
* {@code 1.0}, {@code category} defaults to {@code neutral}. Unknown sound
* ids are silently no-op — they log a WARN via the surrounding parse path
* and skip execution (safer than crashing the animation).
*/
public record PlaySoundAction(
ResourceLocation sound,
float volume,
float pitch,
SoundSource category
) implements AnimationAction {
public static final ResourceLocation ID = TiedUpRigConstants.identifier("play_sound");
/**
* Parse a single string into a {@link SoundSource}, accepting the
* canonical lowercase {@link SoundSource#getName()} form (e.g.
* {@code "master"}, {@code "record"}, {@code "block"}, {@code "player"}
* — note these are the singular forms, not the enum constant names like
* {@code RECORDS} / {@code BLOCKS} / {@code PLAYERS}).
*/
private static DataResult<SoundSource> parseSoundSource(String s) {
String normalized = s.toLowerCase(Locale.ROOT);
for (SoundSource src : SoundSource.values()) {
if (src.getName().equals(normalized)) {
return DataResult.success(src);
}
}
String valid = Arrays.stream(SoundSource.values())
.map(SoundSource::getName)
.collect(Collectors.joining(", "));
return DataResult.error(() -> "Unknown SoundSource: '" + s + "'. Valid values: " + valid);
}
/**
* Codec for {@link SoundSource} as a non-optional field.
*
* <p>Uses {@link Codec#comapFlatMap} so that an unknown name produces a
* {@link DataResult#error} with a descriptive message rather than an
* uncaught {@link IllegalArgumentException}. Without this, a typo like
* {@code "ambiant"} would crash the whole animation parse with no clue
* which field was at fault.
*/
private static final Codec<SoundSource> SOURCE_CODEC = Codec.STRING.comapFlatMap(
PlaySoundAction::parseSoundSource,
source -> source.getName()
);
/**
* MapCodec for the optional {@code category} field, with strict error
* propagation.
*
* <p><strong>Why not {@code SOURCE_CODEC.optionalFieldOf(...)}?</strong>
* In DFU 6.0.8 (the version shipped with Forge 1.20.1) the
* {@code OptionalFieldCodec.decode} implementation is
* <em>lenient by default</em> — when the inner codec returns
* {@code DataResult.error} for a present value, the optional codec
* silently swallows it and yields {@code Optional.empty()}. The strict
* {@code lenient=false} flag was only added in a later DFU release.
*
* <p>To surface artist typos like {@code "category": "ambiant"} as a
* real parse error (RISK-001), we instead first decode the raw string
* via {@code Codec.STRING.optionalFieldOf("category")} (which always
* succeeds for strings) and then validate via {@code flatXmap} which
* properly propagates {@link DataResult#error} from the
* {@link #parseSoundSource} validator.
*/
private static final MapCodec<SoundSource> CATEGORY_FIELD = Codec.STRING
.optionalFieldOf("category")
.flatXmap(
rawOpt -> rawOpt.isPresent()
? parseSoundSource(rawOpt.get()).map(s -> s)
: DataResult.success(SoundSource.NEUTRAL),
source -> DataResult.success(Optional.of(source.getName()))
);
public static final Codec<PlaySoundAction> CODEC = RecordCodecBuilder.create(i -> i.group(
ResourceLocation.CODEC.fieldOf("sound").forGetter(PlaySoundAction::sound),
Codec.FLOAT.optionalFieldOf("volume", 1.0F).forGetter(PlaySoundAction::volume),
Codec.FLOAT.optionalFieldOf("pitch", 1.0F).forGetter(PlaySoundAction::pitch),
CATEGORY_FIELD.forGetter(PlaySoundAction::category)
).apply(i, PlaySoundAction::new));
@Override
public ResourceLocation type() {
return ID;
}
@Override
public void execute(
LivingEntityPatch<?> patch,
AssetAccessor<? extends DynamicAnimation> animation,
float prevElapsed,
float elapsed
) {
SoundEvent event = ForgeRegistries.SOUND_EVENTS.getValue(this.sound);
if (event == null) {
TiedUpRigConstants.LOGGER.warn("PlaySoundAction : unknown sound event {}", this.sound);
return;
}
LivingEntity entity = patch.getOriginal();
entity.level().playSound(
null,
entity.getX(), entity.getY(), entity.getZ(),
event,
this.category,
this.volume,
this.pitch
);
}
}

View File

@@ -0,0 +1,226 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.action.impl;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.core.particles.ParticleOptions;
import net.minecraft.core.particles.ParticleType;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.registries.ForgeRegistries;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.action.AnimationAction;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
/**
* Spawn a particle burst at the entity's root position or at a named joint.
* Client-side only — the {@code level.addParticle} API requires a ClientLevel.
* Actions declared on a server-side outer event are no-op.
*
* <p>JSON schema :
* <pre>{@code
* { "type": "tiedup:spawn_particle",
* "particle": "minecraft:smoke",
* "at": "Root",
* "count": 5,
* "speed": 0.05,
* "offset_x": 0.0,
* "offset_y": 1.2,
* "offset_z": 0.0 }
* }</pre>
*
* <p>{@code at} defaults to &laquo;root joint position = entity position&raquo; —
* if the specified joint does not exist in the armature, a WARN is logged and
* the particle spawns at the entity origin. {@code count} defaults to {@code 1},
* {@code speed} defaults to {@code 0.0}, offsets default to zero.
*
* <p><b>Design note :</b> the particle type must implement {@link ParticleOptions}
* directly — vanilla &laquo;simple&raquo; particles like {@code minecraft:smoke}
* satisfy this because {@link ParticleType} extends {@link ParticleOptions}
* when the particle carries no extra data. Complex particles that require data
* parameters (block / item / dust color) cannot currently be authored through
* this action — that's a follow-up (would require a particle data sub-codec).
*/
public record SpawnParticleAction(
ResourceLocation particle,
String joint,
int count,
float speed,
float offsetX,
float offsetY,
float offsetZ
) implements AnimationAction {
public static final ResourceLocation ID = TiedUpRigConstants.identifier("spawn_particle");
/**
* Set (thread-safe) des couples {@code armatureName + ":" + joint} pour
* lesquels un WARN « joint inconnu » a déjà été émis. Évite le spam log :
* les period events fire à 20 Hz × duration, donc une typo modder
* ({@code "at": "wrongJoint"}) sans dedup polluerait {@code latest.log}
* d'un WARN par tick et masquerait les vrais bugs.
*
* <p>Pattern identique à {@code TiedUpAnimationRegistry.WARNED_MISSING_ANIMS}
* — {@link ConcurrentHashMap#newKeySet()} pour la sûreté en cas d'appels
* concurrents (le code execute peut tourner sur le client tick thread comme
* sur un thread de network handler).</p>
*
* <p>Le reset est wired dans
* {@code TiedUpRigRegistryReloadListener.apply()} : à chaque {@code /reload}
* (datapack) ou F3+T (resource pack client) on purge le set, ce qui ré-active
* le WARN pour le cas modder corrige son JSON puis le re-casse.</p>
*/
static final Set<String> WARNED_MISSING_JOINTS = ConcurrentHashMap.newKeySet();
public static final Codec<SpawnParticleAction> CODEC = RecordCodecBuilder.create(i -> i.group(
ResourceLocation.CODEC.fieldOf("particle").forGetter(SpawnParticleAction::particle),
Codec.STRING.optionalFieldOf("at", "").forGetter(SpawnParticleAction::joint),
Codec.INT.optionalFieldOf("count", 1).forGetter(SpawnParticleAction::count),
Codec.FLOAT.optionalFieldOf("speed", 0.0F).forGetter(SpawnParticleAction::speed),
Codec.FLOAT.optionalFieldOf("offset_x", 0.0F).forGetter(SpawnParticleAction::offsetX),
Codec.FLOAT.optionalFieldOf("offset_y", 0.0F).forGetter(SpawnParticleAction::offsetY),
Codec.FLOAT.optionalFieldOf("offset_z", 0.0F).forGetter(SpawnParticleAction::offsetZ)
).apply(i, SpawnParticleAction::new));
@Override
public ResourceLocation type() {
return ID;
}
@Override
public void execute(
LivingEntityPatch<?> patch,
AssetAccessor<? extends DynamicAnimation> animation,
float prevElapsed,
float elapsed
) {
LivingEntity entity = patch.getOriginal();
Level level = entity.level();
// Client-side guard : addParticle is a no-op on server but we short
// circuit early to avoid the registry lookup overhead.
if (!level.isClientSide()) {
return;
}
ParticleType<?> particleType = ForgeRegistries.PARTICLE_TYPES.getValue(this.particle);
if (particleType == null) {
TiedUpRigConstants.LOGGER.warn("SpawnParticleAction : unknown particle type {}", this.particle);
return;
}
if (!(particleType instanceof ParticleOptions options)) {
TiedUpRigConstants.LOGGER.warn(
"SpawnParticleAction : particle {} does not implement ParticleOptions (complex particles not yet supported)",
this.particle
);
return;
}
Vec3 origin = resolveOrigin(patch, entity);
for (int n = 0; n < this.count; n++) {
level.addParticle(
options,
origin.x + this.offsetX,
origin.y + this.offsetY,
origin.z + this.offsetZ,
0.0, this.speed, 0.0
);
}
}
/**
* Resolve the spawn origin. If {@link #joint} is non-empty and matches an
* armature joint, we would ideally transform the joint's local position
* into world space — but the public {@link LivingEntityPatch} API does not
* yet expose a joint-world-position helper (tracked separately). For now
* we only validate the joint exists and fall back to the entity position.
*/
private Vec3 resolveOrigin(LivingEntityPatch<?> patch, LivingEntity entity) {
Vec3 entityPos = new Vec3(entity.getX(), entity.getY(), entity.getZ());
if (this.joint == null || this.joint.isEmpty()) {
return entityPos;
}
Armature armature = patch.getArmature();
if (armature == null) {
return entityPos;
}
Joint target = armature.searchJointByName(this.joint);
if (target == null) {
// Dedup : period events fire à 20 Hz, sans dedup une typo modder
// produirait un WARN par tick × duration. Un seul WARN par couple
// (armature, joint) jusqu'au prochain reload.
if (tryWarnMissingJoint(armature.toString(), this.joint)) {
TiedUpRigConstants.LOGGER.warn(
"SpawnParticleAction : unknown joint '{}' on armature '{}', falling back to entity position "
+ "(further occurrences for this armature+joint suppressed until next /reload)",
this.joint, armature
);
}
return entityPos;
}
// TODO (D2 follow-up) : apply joint's model-space transform to entityPos
// via patch.getModelMatrix(partialTick). Needs a partialTick plumb
// through AnimationAction.execute which today only forwards elapsed.
return entityPos;
}
/**
* Helper de dedup pour le WARN « joint inconnu ». Retourne {@code true} si
* le couple {@code (armatureName, joint)} n'avait pas encore été warné — le
* caller doit alors logger ; sinon retourne {@code false} (déjà warné, on
* skip le log).
*
* <p>Extrait en helper static package-private pour pouvoir tester la
* sémantique de dedup directement (les tests purs ne peuvent pas exercer
* {@link #execute} qui requiert un {@link LivingEntityPatch} mocké et un
* {@link Level} client réels — runtime MC nécessaire). Le test exerce ce
* helper, et l'execute() délègue à ce même helper, donc la couverture de la
* logique critique (dedup) est complète.</p>
*
* @param armatureName le nom de l'armature (généralement
* {@code armature.toString()} qui retourne {@code this.name})
* @param joint le nom du joint introuvable
* @return {@code true} si c'est le premier miss pour ce couple
* (le caller doit logger), {@code false} sinon (déjà warné).
*/
static boolean tryWarnMissingJoint(String armatureName, String joint) {
return WARNED_MISSING_JOINTS.add(armatureName + ":" + joint);
}
/**
* Reset le set dedup des WARN « joint inconnu ». Appelé par
* {@code TiedUpRigRegistryReloadListener.apply()} à chaque reload datapack /
* resource-pack, et par les tests pour garantir l'isolation entre cas.
*
* <p>Sans reset après {@code /reload}, un modder qui corrige son JSON puis
* re-casse l'asset ne reverrait jamais le WARN (l'ID resterait dans le set).
* Avec reset, le warn redéclenche après chaque reload — comportement attendu
* pour le feedback modder.</p>
*
* <p>Thread-safe via {@link ConcurrentHashMap#newKeySet()} — pas de
* synchronisation externe nécessaire.</p>
*/
public static void resetWarnedMissing() {
WARNED_MISSING_JOINTS.clear();
}
}

View File

@@ -0,0 +1,321 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.client;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.internal.Streams;
import com.google.gson.stream.JsonReader;
import com.mojang.datafixers.util.Pair;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.util.GsonHelper;
import com.tiedup.remake.rig.anim.AnimationManager;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.TransformSheet;
import com.tiedup.remake.rig.anim.property.AnimationProperty.StaticAnimationProperty;
import com.tiedup.remake.rig.anim.types.ActionAnimation;
import com.tiedup.remake.rig.anim.types.DirectStaticAnimation;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.JsonAssetLoader;
import com.tiedup.remake.rig.anim.client.property.ClientAnimationProperties;
import com.tiedup.remake.rig.anim.client.property.JointMaskEntry;
import com.tiedup.remake.rig.anim.client.property.JointMaskReloadListener;
import com.tiedup.remake.rig.anim.client.property.LayerInfo;
import com.tiedup.remake.rig.anim.client.property.TrailInfo;
import com.tiedup.remake.rig.exception.AssetLoadingException;
import com.tiedup.remake.rig.util.ParseUtil;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public class AnimationSubFileReader {
public static final SubFileType<ClientProperty> SUBFILE_CLIENT_PROPERTY = new ClientPropertyType();
public static final SubFileType<PovSettings> SUBFILE_POV_ANIMATION = new PovAnimationType();
public static void readAndApply(StaticAnimation animation, Resource iresource, SubFileType<?> subFileType) {
InputStream inputstream = null;
try {
inputstream = iresource.open();
} catch (IOException e) {
e.printStackTrace();
}
assert inputstream != null : "Input stream is null";
try {
subFileType.apply(inputstream, animation);
} catch (JsonParseException e) {
TiedUpRigConstants.LOGGER.warn("Can't read sub file " + subFileType.directory + " for " + animation);
e.printStackTrace();
}
}
public static abstract class SubFileType<T> {
private final String directory;
private final AnimationSubFileDeserializer<T> deserializer;
private SubFileType(String directory, AnimationSubFileDeserializer<T> deserializer) {
this.directory = directory;
this.deserializer = deserializer;
}
// Deserialize from input stream
public void apply(InputStream inputstream, StaticAnimation animation) {
Reader reader = new InputStreamReader(inputstream, StandardCharsets.UTF_8);
JsonReader jsonReader = new JsonReader(reader);
jsonReader.setLenient(true);
T deserialized = this.deserializer.deserialize(animation, Streams.parse(jsonReader));
this.applySubFileInfo(deserialized, animation);
}
// Deserialize from json object
public void apply(JsonElement jsonElement, StaticAnimation animation) {
T deserialized = this.deserializer.deserialize(animation, jsonElement);
this.applySubFileInfo(deserialized, animation);
}
protected abstract void applySubFileInfo(T deserialized, StaticAnimation animation);
public String getDirectory() {
return this.directory;
}
}
private record ClientProperty(LayerInfo layerInfo, LayerInfo multilayerInfo, List<TrailInfo> trailInfo) {
}
private static class ClientPropertyType extends SubFileType<ClientProperty> {
private ClientPropertyType() {
super("data", new AnimationSubFileReader.ClientAnimationPropertyDeserializer());
}
@Override
public void applySubFileInfo(ClientProperty deserialized, StaticAnimation animation) {
if (deserialized.layerInfo() != null) {
if (deserialized.layerInfo().jointMaskEntry.isValid()) {
animation.addProperty(ClientAnimationProperties.JOINT_MASK, deserialized.layerInfo().jointMaskEntry);
}
animation.addProperty(ClientAnimationProperties.LAYER_TYPE, deserialized.layerInfo().layerType);
animation.addProperty(ClientAnimationProperties.PRIORITY, deserialized.layerInfo().priority);
}
if (deserialized.multilayerInfo() != null) {
DirectStaticAnimation multilayerAnimation = new DirectStaticAnimation(animation.getLocation(), animation.getTransitionTime(), animation.isRepeat(), animation.getRegistryName().toString() + "_multilayer", animation.getArmature());
if (deserialized.multilayerInfo().jointMaskEntry.isValid()) {
multilayerAnimation.addProperty(ClientAnimationProperties.JOINT_MASK, deserialized.multilayerInfo().jointMaskEntry);
}
multilayerAnimation.addProperty(ClientAnimationProperties.LAYER_TYPE, deserialized.multilayerInfo().layerType);
multilayerAnimation.addProperty(ClientAnimationProperties.PRIORITY, deserialized.multilayerInfo().priority);
multilayerAnimation.addProperty(StaticAnimationProperty.ELAPSED_TIME_MODIFIER, (self, entitypatch, speed, prevElapsedTime, elapsedTime) -> {
Layer baseLayer = entitypatch.getClientAnimator().baseLayer;
if (baseLayer.animationPlayer.getAnimation().get().getRealAnimation().get() != animation) {
return Pair.of(prevElapsedTime, elapsedTime);
}
if (!self.isStaticAnimation() && baseLayer.animationPlayer.getAnimation().get().isStaticAnimation()) {
return Pair.of(prevElapsedTime + speed, elapsedTime + speed);
}
return Pair.of(baseLayer.animationPlayer.getPrevElapsedTime(), baseLayer.animationPlayer.getElapsedTime());
});
animation.addProperty(ClientAnimationProperties.MULTILAYER_ANIMATION, multilayerAnimation);
}
if (deserialized.trailInfo().size() > 0) {
animation.addProperty(ClientAnimationProperties.TRAIL_EFFECT, deserialized.trailInfo());
}
}
}
private static class ClientAnimationPropertyDeserializer implements AnimationSubFileDeserializer<ClientProperty> {
private static LayerInfo deserializeLayerInfo(JsonObject jsonObject) {
return deserializeLayerInfo(jsonObject, null);
}
private static LayerInfo deserializeLayerInfo(JsonObject jsonObject, @Nullable Layer.LayerType defaultLayerType) {
JointMaskEntry.Builder builder = JointMaskEntry.builder();
Layer.Priority priority = jsonObject.has("priority") ? Layer.Priority.valueOf(GsonHelper.getAsString(jsonObject, "priority")) : null;
Layer.LayerType layerType = jsonObject.has("layer") ? Layer.LayerType.valueOf(GsonHelper.getAsString(jsonObject, "layer")) : Layer.LayerType.BASE_LAYER;
if (jsonObject.has("masks")) {
JsonArray maskArray = jsonObject.get("masks").getAsJsonArray();
if (!maskArray.isEmpty()) {
builder.defaultMask(JointMaskReloadListener.getNoneMask());
maskArray.forEach(element -> {
JsonObject jointMaskEntry = element.getAsJsonObject();
String livingMotionName = GsonHelper.getAsString(jointMaskEntry, "livingmotion");
String type = GsonHelper.getAsString(jointMaskEntry, "type");
if (!type.contains(":")) {
type = (new StringBuilder(TiedUpRigConstants.MODID)).append(":").append(type).toString();
}
if (livingMotionName.equals("ALL")) {
builder.defaultMask(JointMaskReloadListener.getJointMaskEntry(type));
} else {
builder.mask((LivingMotion) LivingMotion.ENUM_MANAGER.getOrThrow(livingMotionName), JointMaskReloadListener.getJointMaskEntry(type));
}
});
}
}
return new LayerInfo(builder.create(), priority, (defaultLayerType == null) ? layerType : defaultLayerType);
}
@Override
public ClientProperty deserialize(StaticAnimation animation, JsonElement json) throws JsonParseException {
JsonObject jsonObject = json.getAsJsonObject();
LayerInfo layerInfo = null;
LayerInfo multilayerInfo = null;
if (jsonObject.has("multilayer")) {
JsonObject multiplayerJson = jsonObject.get("multilayer").getAsJsonObject();
layerInfo = deserializeLayerInfo(multiplayerJson.get("base").getAsJsonObject());
multilayerInfo = deserializeLayerInfo(multiplayerJson.get("composite").getAsJsonObject(), Layer.LayerType.COMPOSITE_LAYER);
} else {
layerInfo = deserializeLayerInfo(jsonObject);
}
List<TrailInfo> trailInfos = Lists.newArrayList();
if (jsonObject.has("trail_effects")) {
JsonArray trailArray = jsonObject.get("trail_effects").getAsJsonArray();
trailArray.forEach(element -> trailInfos.add(TrailInfo.deserialize(element)));
}
return new ClientProperty(layerInfo, multilayerInfo, trailInfos);
}
}
public static record PovSettings(
@Nullable TransformSheet cameraTransform,
Map<String, Boolean> visibilities,
RootTransformation rootTransformation,
@Nullable ViewLimit viewLimit,
boolean visibilityOthers,
boolean hasUniqueAnimation,
boolean syncFrame
) {
public enum RootTransformation {
CAMERA, WORLD
}
public record ViewLimit(float xRotMin, float xRotMax, float yRotMin, float yRotMax) {
}
}
private static class PovAnimationType extends SubFileType<PovSettings> {
private PovAnimationType() {
super("pov", new AnimationSubFileReader.PovAnimationDeserializer());
}
@Override
public void applySubFileInfo(PovSettings deserialized, StaticAnimation animation) {
ResourceLocation povAnimationLocation = deserialized.hasUniqueAnimation() ? AnimationManager.getSubAnimationFileLocation(animation.getLocation(), SUBFILE_POV_ANIMATION) : animation.getLocation();
DirectStaticAnimation povAnimation = new DirectStaticAnimation(povAnimationLocation, animation.getTransitionTime(), animation.isRepeat(), animation.getRegistryName().toString() + "_pov", animation.getArmature()) {
@Override
public float getPlaySpeed(LivingEntityPatch<?> entitypatch, DynamicAnimation pAnimation) {
return animation.getPlaySpeed(entitypatch, pAnimation);
}
};
animation.getProperty(StaticAnimationProperty.PLAY_SPEED_MODIFIER).ifPresent(speedModifier -> {
povAnimation.addProperty(StaticAnimationProperty.PLAY_SPEED_MODIFIER, speedModifier);
});
if (deserialized.syncFrame()) {
animation.getProperty(StaticAnimationProperty.ELAPSED_TIME_MODIFIER).ifPresent(elapsedTimeModifier -> {
povAnimation.addProperty(StaticAnimationProperty.ELAPSED_TIME_MODIFIER, elapsedTimeModifier);
});
}
animation.addProperty(ClientAnimationProperties.POV_ANIMATION, povAnimation);
animation.addProperty(ClientAnimationProperties.POV_SETTINGS, deserialized);
}
}
private static class PovAnimationDeserializer implements AnimationSubFileDeserializer<PovSettings> {
@Override
public PovSettings deserialize(StaticAnimation animation, JsonElement json) throws AssetLoadingException, JsonParseException {
JsonObject jObject = json.getAsJsonObject();
TransformSheet cameraTransform = null;
PovSettings.ViewLimit viewLimit = null;
PovSettings.RootTransformation rootTrasnformation = null;
if (jObject.has("root")) {
rootTrasnformation = PovSettings.RootTransformation.valueOf(ParseUtil.toUpperCase(GsonHelper.getAsString(jObject, "root")));
} else {
if (animation instanceof ActionAnimation) {
rootTrasnformation = PovSettings.RootTransformation.WORLD;
} else {
rootTrasnformation = PovSettings.RootTransformation.CAMERA;
}
}
if (jObject.has("camera")) {
JsonObject cameraTransformJObject = jObject.getAsJsonObject("camera");
cameraTransform = JsonAssetLoader.getTransformSheet(cameraTransformJObject, null, false, JsonAssetLoader.TransformFormat.ATTRIBUTES);
}
ImmutableMap.Builder<String, Boolean> visibilitiesBuilder = ImmutableMap.builder();
boolean others = false;
if (jObject.has("visibilities")) {
JsonObject visibilitiesObject = jObject.getAsJsonObject("visibilities");
visibilitiesObject.entrySet().stream().filter((e) -> !"others".equals(e.getKey())).forEach((entry) -> visibilitiesBuilder.put(entry.getKey(), entry.getValue().getAsBoolean()));
others = visibilitiesObject.get("others").getAsBoolean();
} else {
visibilitiesBuilder.put("leftArm", true);
visibilitiesBuilder.put("leftSleeve", true);
visibilitiesBuilder.put("rightArm", true);
visibilitiesBuilder.put("rightSleeve", true);
}
if (jObject.has("limited_view_degrees")) {
JsonObject limitedViewDegrees = jObject.getAsJsonObject("limited_view_degrees");
JsonArray xRot = limitedViewDegrees.get("xRot").getAsJsonArray();
JsonArray yRot = limitedViewDegrees.get("yRot").getAsJsonArray();
float xRotMin = Math.min(xRot.get(0).getAsFloat(), xRot.get(1).getAsFloat());
float xRotMax = Math.max(xRot.get(0).getAsFloat(), xRot.get(1).getAsFloat());
float yRotMin = Math.min(yRot.get(0).getAsFloat(), yRot.get(1).getAsFloat());
float yRotMax = Math.max(yRot.get(0).getAsFloat(), yRot.get(1).getAsFloat());
viewLimit = new PovSettings.ViewLimit(xRotMin, xRotMax, yRotMin, yRotMax);
}
return new PovSettings(cameraTransform, visibilitiesBuilder.build(), rootTrasnformation, viewLimit, others, jObject.has("animation"), GsonHelper.getAsBoolean(jObject, "sync_frame", false));
}
}
public interface AnimationSubFileDeserializer<T> {
public T deserialize(StaticAnimation animation, JsonElement json) throws JsonParseException;
}
}

View File

@@ -0,0 +1,562 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.client;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.datafixers.util.Pair;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.util.Mth;
import com.tiedup.remake.rig.anim.AnimationManager;
import com.tiedup.remake.rig.anim.AnimationPlayer;
import com.tiedup.remake.rig.anim.Animator;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.LivingMotions;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.ServerAnimator;
import com.tiedup.remake.rig.anim.property.AnimationProperty.ActionAnimationProperty;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.anim.types.EntityState;
import com.tiedup.remake.rig.anim.types.EntityState.StateFactor;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.anim.client.Layer.Priority;
import com.tiedup.remake.rig.anim.client.property.ClientAnimationProperties;
import com.tiedup.remake.rig.anim.client.property.JointMask.BindModifier;
import com.tiedup.remake.rig.anim.client.property.JointMask.JointMaskSet;
import com.tiedup.remake.rig.anim.client.property.JointMaskEntry;
import com.tiedup.remake.rig.util.datastruct.TypeFlexibleHashMap;
import com.tiedup.remake.rig.TiedUpRigRegistry;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public class ClientAnimator extends Animator {
public static Animator getAnimator(LivingEntityPatch<?> entitypatch) {
return entitypatch.isLogicalClient() ? new ClientAnimator(entitypatch) : ServerAnimator.getAnimator(entitypatch);
}
private final Map<LivingMotion, AssetAccessor<? extends StaticAnimation>> compositeLivingAnimations;
private final Map<LivingMotion, AssetAccessor<? extends StaticAnimation>> defaultLivingAnimations;
private final Map<LivingMotion, AssetAccessor<? extends StaticAnimation>> defaultCompositeLivingAnimations;
public final Layer.BaseLayer baseLayer;
private LivingMotion currentMotion;
private LivingMotion currentCompositeMotion;
private boolean hardPaused;
public ClientAnimator(LivingEntityPatch<?> entitypatch) {
this(entitypatch, Layer.BaseLayer::new);
}
public ClientAnimator(LivingEntityPatch<?> entitypatch, Supplier<Layer.BaseLayer> layerSupplier) {
super(entitypatch);
this.currentMotion = LivingMotions.IDLE;
this.currentCompositeMotion = LivingMotions.IDLE;
this.compositeLivingAnimations = Maps.newHashMap();
this.defaultLivingAnimations = Maps.newHashMap();
this.defaultCompositeLivingAnimations = Maps.newHashMap();
this.baseLayer = layerSupplier.get();
}
/** Play an animation by animation instance **/
@Override
public void playAnimation(AssetAccessor<? extends StaticAnimation> nextAnimation, float transitionTimeModifier) {
Layer layer = nextAnimation.get().getLayerType() == Layer.LayerType.BASE_LAYER ? this.baseLayer : this.baseLayer.compositeLayers.get(nextAnimation.get().getPriority());
layer.paused = false;
layer.playAnimation(nextAnimation, this.entitypatch, transitionTimeModifier);
}
// RIG : playAnimationAt(..., AnimatorControlPacket.Layer, AnimatorControlPacket.Priority)
// strippé — re-implem Phase 2 avec packet dédié. Voir AnimationVariablePacket.
@Override
public void playAnimationInstantly(AssetAccessor<? extends StaticAnimation> nextAnimation) {
Layer layer = nextAnimation.get().getLayerType() == Layer.LayerType.BASE_LAYER ? this.baseLayer : this.baseLayer.compositeLayers.get(nextAnimation.get().getPriority());
layer.paused = false;
layer.playAnimationInstantly(nextAnimation, this.entitypatch);
}
@Override
public void reserveAnimation(AssetAccessor<? extends StaticAnimation> nextAnimation) {
Layer layer = nextAnimation.get().getLayerType() == Layer.LayerType.BASE_LAYER ? this.baseLayer : this.baseLayer.compositeLayers.get(nextAnimation.get().getPriority());
if (nextAnimation.get().getPriority().isHigherThan(layer.animationPlayer.getRealAnimation().get().getPriority())) {
if (!layer.animationPlayer.isEnd() && layer.animationPlayer.getAnimation() != null) {
layer.animationPlayer.getAnimation().get().end(this.entitypatch, nextAnimation, false);
}
layer.animationPlayer.terminate(this.entitypatch);
}
layer.nextAnimation = nextAnimation;
layer.paused = false;
}
@Override
public boolean stopPlaying(AssetAccessor<? extends StaticAnimation> targetAnimation) {
Layer layer = targetAnimation.get().getLayerType() == Layer.LayerType.BASE_LAYER ? this.baseLayer : this.baseLayer.compositeLayers.get(targetAnimation.get().getPriority());
if (layer.animationPlayer.getRealAnimation() == targetAnimation) {
layer.animationPlayer.terminate(this.entitypatch);
return true;
}
return false;
}
@Override
public void setSoftPause(boolean paused) {
this.iterAllLayers(layer -> layer.paused = paused);
}
@Override
public void setHardPause(boolean paused) {
this.hardPaused = paused;
}
@Override
public void addLivingAnimation(LivingMotion livingMotion, AssetAccessor<? extends StaticAnimation> animation) {
if (AnimationManager.checkNull(animation)) {
TiedUpRigConstants.LOGGER.warn("Unable to put an empty animation for " + livingMotion);
return;
}
Layer.LayerType layerType = animation.get().getLayerType();
boolean isBaseLayer = (layerType == Layer.LayerType.BASE_LAYER);
Map<LivingMotion, AssetAccessor<? extends StaticAnimation>> storage = layerType == Layer.LayerType.BASE_LAYER ? this.livingAnimations : this.compositeLivingAnimations;
LivingMotion compareMotion = layerType == Layer.LayerType.BASE_LAYER ? this.currentMotion : this.currentCompositeMotion;
Layer layer = layerType == Layer.LayerType.BASE_LAYER ? this.baseLayer : this.baseLayer.compositeLayers.get(animation.get().getPriority());
storage.put(livingMotion, animation);
if (livingMotion == compareMotion) {
EntityState state = this.getEntityState();
if (!state.inaction()) {
layer.playLivingAnimation(animation, this.entitypatch);
}
}
if (isBaseLayer) {
animation.get().getProperty(ClientAnimationProperties.MULTILAYER_ANIMATION).ifPresent(multilayerAnimation -> {
this.compositeLivingAnimations.put(livingMotion, multilayerAnimation);
if (livingMotion == this.currentCompositeMotion) {
EntityState state = this.getEntityState();
if (!state.inaction()) {
layer.playLivingAnimation(multilayerAnimation, this.entitypatch);
}
}
});
}
}
public void setCurrentMotionsAsDefault() {
this.defaultLivingAnimations.putAll(this.livingAnimations);
this.defaultCompositeLivingAnimations.putAll(this.compositeLivingAnimations);
}
@Override
public void resetLivingAnimations() {
super.resetLivingAnimations();
this.compositeLivingAnimations.clear();
this.defaultLivingAnimations.forEach((key, val) -> this.addLivingAnimation(key, val));
this.defaultCompositeLivingAnimations.forEach((key, val) -> this.addLivingAnimation(key, val));
}
public AssetAccessor<? extends StaticAnimation> getLivingMotion(LivingMotion motion) {
return this.livingAnimations.getOrDefault(motion, this.livingAnimations.get(LivingMotions.IDLE));
}
public AssetAccessor<? extends StaticAnimation> getCompositeLivingMotion(LivingMotion motion) {
return this.compositeLivingAnimations.get(motion);
}
@Override
public void postInit() {
super.postInit();
this.setCurrentMotionsAsDefault();
AssetAccessor<? extends StaticAnimation> idleMotion = this.livingAnimations.get(this.currentMotion);
this.baseLayer.playAnimationInstantly(idleMotion, this.entitypatch);
}
@Override
public void tick() {
/**
// Layer debugging
for (Layer layer : this.getAllLayers()) {
System.out.println(layer);
}
System.out.println();
**/
if (this.hardPaused) {
return;
}
this.baseLayer.update(this.entitypatch);
if (this.baseLayer.animationPlayer.isEnd() && this.baseLayer.nextAnimation == null && this.currentMotion != LivingMotions.DEATH) {
this.entitypatch.updateMotion(false);
if (this.compositeLivingAnimations.containsKey(this.entitypatch.currentCompositeMotion)) {
this.playAnimation(this.getCompositeLivingMotion(this.entitypatch.currentCompositeMotion), 0.0F);
}
this.baseLayer.playAnimation(this.getLivingMotion(this.entitypatch.currentLivingMotion), this.entitypatch, 0.0F);
} else {
if (!this.compareCompositeMotion(this.entitypatch.currentCompositeMotion)) {
/* Turns off the multilayer of the base layer */
this.getLivingMotion(this.currentCompositeMotion).get().getProperty(ClientAnimationProperties.MULTILAYER_ANIMATION).ifPresent((multilayerAnimation) -> {
if (!this.compositeLivingAnimations.containsKey(this.entitypatch.currentCompositeMotion)) {
this.getCompositeLayer(multilayerAnimation.get().getPriority()).off(this.entitypatch);
}
});
if (this.compositeLivingAnimations.containsKey(this.currentCompositeMotion)) {
AssetAccessor<? extends StaticAnimation> nextLivingAnimation = this.getCompositeLivingMotion(this.entitypatch.currentCompositeMotion);
if (nextLivingAnimation == null || nextLivingAnimation.get().getPriority() != this.getCompositeLivingMotion(this.currentCompositeMotion).get().getPriority()) {
this.getCompositeLayer(this.getCompositeLivingMotion(this.currentCompositeMotion).get().getPriority()).off(this.entitypatch);
}
}
if (this.compositeLivingAnimations.containsKey(this.entitypatch.currentCompositeMotion)) {
this.playAnimation(this.getCompositeLivingMotion(this.entitypatch.currentCompositeMotion), 0.0F);
}
}
if (!this.compareMotion(this.entitypatch.currentLivingMotion) && this.entitypatch.currentLivingMotion != LivingMotions.DEATH) {
if (this.livingAnimations.containsKey(this.entitypatch.currentLivingMotion)) {
this.baseLayer.playAnimation(this.getLivingMotion(this.entitypatch.currentLivingMotion), this.entitypatch, 0.0F);
}
}
}
this.currentMotion = this.entitypatch.currentLivingMotion;
this.currentCompositeMotion = this.entitypatch.currentCompositeMotion;
}
@Override
public void playDeathAnimation() {
if (!this.getPlayerFor(null).getAnimation().get().getProperty(ActionAnimationProperty.IS_DEATH_ANIMATION).orElse(false)) {
this.playAnimation(this.livingAnimations.getOrDefault(LivingMotions.DEATH, TiedUpRigRegistry.EMPTY_ANIMATION), 0.0F);
this.currentMotion = LivingMotions.DEATH;
}
}
public Layer getCompositeLayer(Layer.Priority priority) {
return this.baseLayer.compositeLayers.get(priority);
}
public void renderDebuggingInfoForAllLayers(PoseStack poseStack, MultiBufferSource buffer, float partialTicks) {
this.iterAllLayers((layer) -> {
if (layer.isOff()) {
return;
}
AnimationPlayer animPlayer = layer.animationPlayer;
float playTime = Mth.lerp(partialTicks, animPlayer.getPrevElapsedTime(), animPlayer.getElapsedTime());
animPlayer.getAnimation().get().renderDebugging(poseStack, buffer, entitypatch, playTime, partialTicks);
});
}
/**
* Iterates all layers
* @param task
*/
public void iterAllLayers(Consumer<Layer> task) {
task.accept(this.baseLayer);
this.baseLayer.compositeLayers.values().forEach(task);
}
/**
* Iterates all activated layers from the highest layer
* when base layer = highest, iterates only base layer
* when base layer = middle, iterates base layer and highest composite layer
* when base layer = lowest, iterates base layer and all composite layers
*
* @param task
* @return true if all layers didn't return false by @param task
*/
public boolean iterVisibleLayersUntilFalse(Function<Layer, Boolean> task) {
Layer.Priority[] highers = this.baseLayer.baseLayerPriority.highers();
for (int i = highers.length - 1; i >= 0; i--) {
Layer layer = this.baseLayer.getLayer(highers[i]);
if (layer.isDisabled() || layer.animationPlayer.isEmpty()) {
if (highers[i] == this.baseLayer.baseLayerPriority) {
return task.apply(this.baseLayer);
}
continue;
}
if (!task.apply(layer)) {
return false;
}
if (highers[i] == this.baseLayer.baseLayerPriority) {
return task.apply(this.baseLayer);
}
}
return true;
}
@Override
public Pose getPose(float partialTicks) {
return this.getPose(partialTicks, true);
}
public Pose getPose(float partialTicks, boolean useCurrentMotion) {
Pose composedPose = new Pose();
Pose baseLayerPose = this.baseLayer.getEnabledPose(this.entitypatch, useCurrentMotion, partialTicks);
Map<Layer.Priority, Pair<AssetAccessor<? extends DynamicAnimation>, Pose>> layerPoses = Maps.newLinkedHashMap();
composedPose.load(baseLayerPose, Pose.LoadOperation.OVERWRITE);
for (Layer.Priority priority : this.baseLayer.baseLayerPriority.highers()) {
Layer compositeLayer = this.baseLayer.compositeLayers.get(priority);
if (!compositeLayer.isDisabled() && !compositeLayer.animationPlayer.isEmpty()) {
Pose layerPose = compositeLayer.getEnabledPose(this.entitypatch, useCurrentMotion, partialTicks);
layerPoses.put(priority, Pair.of(compositeLayer.animationPlayer.getAnimation(), layerPose));
composedPose.load(layerPose, Pose.LoadOperation.OVERWRITE);
}
}
Joint rootJoint = this.entitypatch.getArmature().rootJoint;
this.applyBindModifier(baseLayerPose, composedPose, rootJoint, layerPoses, useCurrentMotion);
return composedPose;
}
public Pose getComposedLayerPoseBelow(Layer.Priority priorityLimit, float partialTicks) {
Pose composedPose = this.baseLayer.getEnabledPose(this.entitypatch, true, partialTicks);
Pose baseLayerPose = this.baseLayer.getEnabledPose(this.entitypatch, true, partialTicks);
Map<Layer.Priority, Pair<AssetAccessor<? extends DynamicAnimation>, Pose>> layerPoses = Maps.newLinkedHashMap();
for (Layer.Priority priority : priorityLimit.lowers()) {
Layer compositeLayer = this.baseLayer.compositeLayers.get(priority);
if (!compositeLayer.isDisabled()) {
Pose layerPose = compositeLayer.getEnabledPose(this.entitypatch, true, partialTicks);
layerPoses.put(priority, Pair.of(compositeLayer.animationPlayer.getAnimation(), layerPose));
composedPose.load(layerPose, Pose.LoadOperation.OVERWRITE);
}
}
if (!layerPoses.isEmpty()) {
this.applyBindModifier(baseLayerPose, composedPose, this.entitypatch.getArmature().rootJoint, layerPoses, true);
}
return composedPose;
}
public void applyBindModifier(Pose basePose, Pose result, Joint joint, Map<Layer.Priority, Pair<AssetAccessor<? extends DynamicAnimation>, Pose>> poses, boolean useCurrentMotion) {
List<Priority> list = Lists.newArrayList(poses.keySet());
Collections.reverse(list);
for (Layer.Priority priority : list) {
AssetAccessor<? extends DynamicAnimation> nowPlaying = poses.get(priority).getFirst();
JointMaskEntry jointMaskEntry = nowPlaying.get().getJointMaskEntry(this.entitypatch, useCurrentMotion).orElse(null);
if (jointMaskEntry != null) {
LivingMotion livingMotion = this.getCompositeLayer(priority).getLivingMotion(this.entitypatch, useCurrentMotion);
if (nowPlaying.get().hasTransformFor(joint.getName()) && !jointMaskEntry.isMasked(livingMotion, joint.getName())) {
JointMaskSet jointmaskset = jointMaskEntry.getMask(livingMotion);
BindModifier bindModifier = jointmaskset.getBindModifier(joint.getName());
if (bindModifier != null) {
bindModifier.modify(this.entitypatch, basePose, result, livingMotion, jointMaskEntry, priority, joint, poses);
break;
}
}
}
}
for (Joint subJoints : joint.getSubJoints()) {
this.applyBindModifier(basePose, result, subJoints, poses, useCurrentMotion);
}
}
public boolean compareMotion(LivingMotion motion) {
return this.currentMotion.isSame(motion);
}
public boolean compareCompositeMotion(LivingMotion motion) {
return this.currentCompositeMotion.isSame(motion);
}
public void forceResetBeforeAction(LivingMotion livingMotion, LivingMotion compositeLivingMotion) {
if (!this.currentMotion.equals(livingMotion)) {
if (this.livingAnimations.containsKey(livingMotion)) {
this.baseLayer.playAnimation(this.getLivingMotion(livingMotion), this.entitypatch, 0.0F);
}
}
this.entitypatch.currentLivingMotion = livingMotion;
this.currentMotion = livingMotion;
if (!this.currentCompositeMotion.equals(compositeLivingMotion)) {
if (this.compositeLivingAnimations.containsKey(this.currentCompositeMotion)) {
this.getCompositeLayer(this.getCompositeLivingMotion(this.currentCompositeMotion).get().getPriority()).off(this.entitypatch);
}
if (this.compositeLivingAnimations.containsKey(compositeLivingMotion)) {
this.playAnimation(this.getCompositeLivingMotion(compositeLivingMotion), 0.0F);
}
}
this.currentCompositeMotion = LivingMotions.NONE;
this.entitypatch.currentCompositeMotion = LivingMotions.NONE;
}
public void resetMotion(boolean resetPrevMotion) {
if (resetPrevMotion) this.currentMotion = LivingMotions.IDLE;
this.entitypatch.currentLivingMotion = LivingMotions.IDLE;
}
public void resetCompositeMotion() {
if (this.currentCompositeMotion != LivingMotions.IDLE && this.compositeLivingAnimations.containsKey(this.currentCompositeMotion)) {
AssetAccessor<? extends StaticAnimation> currentPlaying = this.getCompositeLivingMotion(this.currentCompositeMotion);
AssetAccessor<? extends StaticAnimation> resetPlaying = this.getCompositeLivingMotion(LivingMotions.IDLE);
if (resetPlaying != null && currentPlaying != resetPlaying) {
this.playAnimation(resetPlaying, 0.0F);
} else if (currentPlaying != null) {
this.getCompositeLayer(currentPlaying.get().getPriority()).off(this.entitypatch);
}
}
this.currentCompositeMotion = LivingMotions.NONE;
this.entitypatch.currentCompositeMotion = LivingMotions.NONE;
}
public void offAllLayers() {
for (Layer layer : this.baseLayer.compositeLayers.values()) {
layer.off(this.entitypatch);
}
}
@Override
public void playShootingAnimation() {
if (this.compositeLivingAnimations.containsKey(LivingMotions.SHOT)) {
this.playAnimation(this.compositeLivingAnimations.get(LivingMotions.SHOT), 0.0F);
this.entitypatch.currentCompositeMotion = LivingMotions.NONE;
this.currentCompositeMotion = LivingMotions.NONE;
}
}
@Override
public AnimationPlayer getPlayerFor(AssetAccessor<? extends DynamicAnimation> playingAnimation) {
if (playingAnimation == null) {
return this.baseLayer.animationPlayer;
}
DynamicAnimation animation = playingAnimation.get();
if (animation instanceof StaticAnimation staticAnimation) {
Layer layer = staticAnimation.getLayerType() == Layer.LayerType.BASE_LAYER ? this.baseLayer : this.baseLayer.compositeLayers.get(staticAnimation.getPriority());
if (layer.animationPlayer.getAnimation() == playingAnimation) return layer.animationPlayer;
}
for (Layer layer : this.baseLayer.compositeLayers.values()) {
if (layer.animationPlayer.getRealAnimation().equals(playingAnimation)) {
return layer.animationPlayer;
}
}
return this.baseLayer.animationPlayer;
}
@Override
public Optional<AnimationPlayer> getPlayer(AssetAccessor<? extends DynamicAnimation> playingAnimation) {
DynamicAnimation animation = playingAnimation.get();
if (animation instanceof StaticAnimation staticAnimation) {
Layer layer = staticAnimation.getLayerType() == Layer.LayerType.BASE_LAYER ? this.baseLayer : this.baseLayer.compositeLayers.get(staticAnimation.getPriority());
if (layer.animationPlayer.getRealAnimation().equals(playingAnimation)) {
return Optional.of(layer.animationPlayer);
}
}
if (this.baseLayer.animationPlayer.getRealAnimation().equals(playingAnimation.get().getRealAnimation())) {
return Optional.of(this.baseLayer.animationPlayer);
}
for (Layer layer : this.baseLayer.compositeLayers.values()) {
if (layer.animationPlayer.getRealAnimation().equals(playingAnimation.get().getRealAnimation())) {
return Optional.of(layer.animationPlayer);
}
}
return Optional.empty();
}
public LivingMotion currentMotion() {
return this.currentMotion;
}
public LivingMotion currentCompositeMotion() {
return this.currentCompositeMotion;
}
@SuppressWarnings("unchecked")
@Override
public <T> Pair<AnimationPlayer, T> findFor(Class<T> animationType) {
for (Layer layer : this.baseLayer.compositeLayers.values()) {
if (animationType.isAssignableFrom(layer.animationPlayer.getAnimation().getClass())) {
return Pair.of(layer.animationPlayer, (T)layer.animationPlayer.getAnimation());
}
}
return animationType.isAssignableFrom(this.baseLayer.animationPlayer.getAnimation().getClass()) ? Pair.of(this.baseLayer.animationPlayer, (T)this.baseLayer.animationPlayer.getAnimation()) : null;
}
@Override
public EntityState getEntityState() {
TypeFlexibleHashMap<StateFactor<?>> stateMap = new TypeFlexibleHashMap<> (false);
for (Layer layer : this.baseLayer.compositeLayers.values()) {
if (this.baseLayer.baseLayerPriority.isHigherThan(layer.priority)) {
continue;
}
if (!layer.isOff()) {
stateMap.putAll(layer.animationPlayer.getAnimation().get().getStatesMap(this.entitypatch, layer.animationPlayer.getElapsedTime()));
}
// put base layer states
if (layer.priority == this.baseLayer.baseLayerPriority) {
stateMap.putAll(this.baseLayer.animationPlayer.getAnimation().get().getStatesMap(this.entitypatch, this.baseLayer.animationPlayer.getElapsedTime()));
}
}
return new EntityState(stateMap);
}
}

View File

@@ -0,0 +1,359 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.client;
import java.util.Arrays;
import java.util.Map;
import java.util.function.Supplier;
import com.google.common.collect.Maps;
import com.tiedup.remake.rig.anim.AnimationPlayer;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.types.ConcurrentLinkAnimation;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.anim.types.LayerOffAnimation;
import com.tiedup.remake.rig.anim.types.LinkAnimation;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.TiedUpRigRegistry;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public class Layer {
protected AssetAccessor<? extends StaticAnimation> nextAnimation;
protected final LinkAnimation linkAnimation;
protected final ConcurrentLinkAnimation concurrentLinkAnimation;
protected final LayerOffAnimation layerOffAnimation;
protected final Layer.Priority priority;
protected boolean disabled;
protected boolean paused;
public final AnimationPlayer animationPlayer;
public Layer(Priority priority) {
this(priority, AnimationPlayer::new);
}
public Layer(Priority priority, Supplier<AnimationPlayer> animationPlayerProvider) {
this.animationPlayer = animationPlayerProvider.get();
this.linkAnimation = new LinkAnimation();
this.concurrentLinkAnimation = new ConcurrentLinkAnimation();
this.layerOffAnimation = new LayerOffAnimation(priority);
this.priority = priority;
this.disabled = true;
}
public void playAnimation(AssetAccessor<? extends StaticAnimation> nextAnimation, LivingEntityPatch<?> entitypatch, float transitionTimeModifier) {
// Get pose before StaticAnimation#end is called
Pose lastPose = this.getCurrentPose(entitypatch);
if (!this.animationPlayer.isEnd()) {
this.animationPlayer.getAnimation().get().end(entitypatch, nextAnimation, false);
}
this.resume();
nextAnimation.get().begin(entitypatch);
if (!nextAnimation.get().isMetaAnimation()) {
this.setLinkAnimation(nextAnimation, entitypatch, lastPose, transitionTimeModifier);
this.linkAnimation.putOnPlayer(this.animationPlayer, entitypatch);
entitypatch.updateEntityState();
this.nextAnimation = nextAnimation;
}
}
/**
* Plays an animation without a link animation
*/
public void playAnimationInstantly(AssetAccessor<? extends DynamicAnimation> nextAnimation, LivingEntityPatch<?> entitypatch) {
if (!this.animationPlayer.isEnd()) {
this.animationPlayer.getAnimation().get().end(entitypatch, nextAnimation, false);
}
this.resume();
nextAnimation.get().begin(entitypatch);
nextAnimation.get().putOnPlayer(this.animationPlayer, entitypatch);
entitypatch.updateEntityState();
this.nextAnimation = null;
}
protected void playLivingAnimation(AssetAccessor<? extends StaticAnimation> nextAnimation, LivingEntityPatch<?> entitypatch) {
if (!this.animationPlayer.isEnd()) {
this.animationPlayer.getAnimation().get().end(entitypatch, nextAnimation, false);
}
this.resume();
nextAnimation.get().begin(entitypatch);
if (!nextAnimation.get().isMetaAnimation()) {
this.concurrentLinkAnimation.acceptFrom(this.animationPlayer.getRealAnimation(), nextAnimation, this.animationPlayer.getElapsedTime());
this.concurrentLinkAnimation.putOnPlayer(this.animationPlayer, entitypatch);
entitypatch.updateEntityState();
this.nextAnimation = nextAnimation;
}
}
protected Pose getCurrentPose(LivingEntityPatch<?> entitypatch) {
return entitypatch.getClientAnimator().getPose(0.0F, false);
}
protected void setLinkAnimation(AssetAccessor<? extends StaticAnimation> nextAnimation, LivingEntityPatch<?> entitypatch, Pose lastPose, float transitionTimeModifier) {
AssetAccessor<? extends DynamicAnimation> fromAnimation = this.animationPlayer.isEmpty() ? entitypatch.getClientAnimator().baseLayer.animationPlayer.getAnimation() : this.animationPlayer.getAnimation();
if (fromAnimation.get() instanceof LinkAnimation linkAnimation) {
fromAnimation = linkAnimation.getFromAnimation();
}
nextAnimation.get().setLinkAnimation(fromAnimation, lastPose, !this.animationPlayer.isEmpty(), transitionTimeModifier, entitypatch, this.linkAnimation);
this.linkAnimation.getAnimationClip().setBaked();
}
public void update(LivingEntityPatch<?> entitypatch) {
if (this.paused) {
this.animationPlayer.setElapsedTime(this.animationPlayer.getElapsedTime());
} else {
this.animationPlayer.tick(entitypatch);
}
if (!this.animationPlayer.isEnd()) {
this.animationPlayer.getAnimation().get().tick(entitypatch);
} else if (!this.paused) {
if (this.nextAnimation != null) {
if (!this.animationPlayer.getAnimation().get().isLinkAnimation() && !this.nextAnimation.get().isLinkAnimation()) {
this.nextAnimation.get().begin(entitypatch);
}
this.nextAnimation.get().putOnPlayer(this.animationPlayer, entitypatch);
this.nextAnimation = null;
} else {
if (this.animationPlayer.getAnimation() instanceof LayerOffAnimation) {
this.animationPlayer.getAnimation().get().end(entitypatch, TiedUpRigRegistry.EMPTY_ANIMATION, true);
} else {
this.off(entitypatch);
}
}
}
if (this.isBaseLayer()) {
entitypatch.updateEntityState();
entitypatch.updateMotion(true);
}
}
public void pause() {
this.paused = true;
}
public void resume() {
this.paused = false;
this.disabled = false;
}
protected boolean isDisabled() {
return this.disabled;
}
public boolean isOff() {
return this.isDisabled() || this.animationPlayer.isEmpty();
}
protected boolean isBaseLayer() {
return false;
}
public void copyLayerTo(Layer layer, float playbackTime) {
AssetAccessor<? extends DynamicAnimation> animation;
if (this.animationPlayer.getAnimation() == this.linkAnimation) {
this.linkAnimation.copyTo(layer.linkAnimation);
animation = layer.linkAnimation;
} else {
animation = this.animationPlayer.getAnimation();
}
layer.animationPlayer.setPlayAnimation(animation);
layer.animationPlayer.setElapsedTime(this.animationPlayer.getPrevElapsedTime() + playbackTime, this.animationPlayer.getElapsedTime() + playbackTime);
layer.nextAnimation = this.nextAnimation;
layer.resume();
}
public LivingMotion getLivingMotion(LivingEntityPatch<?> entitypatch, boolean current) {
return current ? entitypatch.currentLivingMotion : entitypatch.getClientAnimator().currentMotion();
}
public Pose getEnabledPose(LivingEntityPatch<?> entitypatch, boolean useCurrentMotion, float partialTick) {
Pose pose = this.animationPlayer.getCurrentPose(entitypatch, partialTick);
this.animationPlayer.getAnimation().get().getJointMaskEntry(entitypatch, useCurrentMotion).ifPresent((jointEntry) -> pose.disableJoint((entry) -> jointEntry.isMasked(this.getLivingMotion(entitypatch, useCurrentMotion), entry.getKey())));
return pose;
}
public void off(LivingEntityPatch<?> entitypatch) {
if (!this.isDisabled() && !(this.animationPlayer.getAnimation() instanceof LayerOffAnimation)) {
if (this.priority == null) {
this.disableLayer();
} else {
float transitionTimeModifier = entitypatch.getClientAnimator().baseLayer.animationPlayer.getAnimation().get().getTransitionTime();
setLayerOffAnimation(this.animationPlayer.getAnimation(), this.getEnabledPose(entitypatch, false, 1.0F), this.layerOffAnimation, transitionTimeModifier);
this.playAnimationInstantly(this.layerOffAnimation, entitypatch);
}
}
}
public void disableLayer() {
this.disabled = true;
this.animationPlayer.setPlayAnimation(TiedUpRigRegistry.EMPTY_ANIMATION);
}
public static void setLayerOffAnimation(AssetAccessor<? extends DynamicAnimation> currentAnimation, Pose currentPose, LayerOffAnimation offAnimation, float transitionTimeModifier) {
offAnimation.setLastAnimation(currentAnimation.get().getRealAnimation());
offAnimation.setLastPose(currentPose);
offAnimation.setTotalTime(transitionTimeModifier);
}
public AssetAccessor<? extends DynamicAnimation> getNextAnimation() {
return this.nextAnimation;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(this.isBaseLayer() ? "Base Layer(" + ((BaseLayer)this).baseLayerPriority + ") : " : " Composite Layer(" + this.priority + ") : ");
sb.append(this.animationPlayer.getAnimation() + " ");
sb.append(", prev elapsed time: " + this.animationPlayer.getPrevElapsedTime() + " ");
sb.append(", elapsed time: " + this.animationPlayer.getElapsedTime() + " ");
sb.append(", total time: " + this.animationPlayer.getAnimation().get().getTotalTime() + " ");
return sb.toString();
}
public static class BaseLayer extends Layer {
protected Map<Layer.Priority, Layer> compositeLayers = Maps.newLinkedHashMap();
protected Layer.Priority baseLayerPriority;
public BaseLayer() {
this(AnimationPlayer::new);
}
public BaseLayer(Supplier<AnimationPlayer> animationPlayerProvider) {
super(null, animationPlayerProvider);
for (Priority priority : Priority.values()) {
this.compositeLayers.computeIfAbsent(priority, Layer::new);
}
this.baseLayerPriority = Priority.LOWEST;
}
@Override
public void playAnimation(AssetAccessor<? extends StaticAnimation> nextAnimation, LivingEntityPatch<?> entitypatch, float transitionTimeModifier) {
this.offCompositeLayersLowerThan(entitypatch, nextAnimation);
super.playAnimation(nextAnimation, entitypatch, transitionTimeModifier);
this.baseLayerPriority = nextAnimation.get().getPriority();
}
@Override
protected void playLivingAnimation(AssetAccessor<? extends StaticAnimation> nextAnimation, LivingEntityPatch<?> entitypatch) {
if (!this.animationPlayer.isEnd()) {
this.animationPlayer.getAnimation().get().end(entitypatch, nextAnimation, false);
}
this.resume();
nextAnimation.get().begin(entitypatch);
if (!nextAnimation.get().isMetaAnimation()) {
this.concurrentLinkAnimation.acceptFrom(this.animationPlayer.getRealAnimation(), nextAnimation, this.animationPlayer.getElapsedTime());
this.concurrentLinkAnimation.putOnPlayer(this.animationPlayer, entitypatch);
entitypatch.updateEntityState();
this.nextAnimation = nextAnimation;
}
}
@Override
public void update(LivingEntityPatch<?> entitypatch) {
super.update(entitypatch);
for (Layer layer : this.compositeLayers.values()) {
layer.update(entitypatch);
}
}
public void offCompositeLayersLowerThan(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> nextAnimation) {
Priority[] layersToOff = nextAnimation.get().isMainFrameAnimation() ? nextAnimation.get().getPriority().lowersAndEqual() : nextAnimation.get().getPriority().lowers();
for (Priority p : layersToOff) {
this.compositeLayers.get(p).off(entitypatch);
}
}
public void disableLayer(Priority priority) {
this.compositeLayers.get(priority).disableLayer();
}
public Layer getLayer(Priority priority) {
return this.compositeLayers.get(priority);
}
public Priority getBaseLayerPriority() {
return this.baseLayerPriority;
}
@Override
public void off(LivingEntityPatch<?> entitypatch) {
}
@Override
protected boolean isDisabled() {
return false;
}
@Override
protected boolean isBaseLayer() {
return true;
}
}
public enum LayerType {
BASE_LAYER, COMPOSITE_LAYER
}
public enum Priority {
/**
* The common usage of each layer
*
* LOWEST: Most of living cycle animations. Also a default value for animations doesn't inherit {@link MainFrameAnimation.class}
* LOW: A few {@link ActionAnimation.class} that allows showing living cycle animations. e.g. step
* MIDDLE: Most of composite living cycle animations. e.g. weapon holding animations
* HIGH: A few composite animations that doesn't repeat. e.g. Uchigatana sheathing, Shield hit
* HIGHEST: Most of {@link MainFrameAnimation.class} and a few living cycle animations. e.g. ladder animation
**/
LOWEST, LOW, MIDDLE, HIGH, HIGHEST;
public Priority[] lowers() {
return Arrays.copyOfRange(Priority.values(), 0, this.ordinal());
}
public Priority[] lowersAndEqual() {
return Arrays.copyOfRange(Priority.values(), 0, this.ordinal() + 1);
}
public Priority[] highers() {
return Arrays.copyOfRange(Priority.values(), this.ordinal(), Priority.values().length);
}
public boolean isHigherThan(Priority priority) {
return this.ordinal() > priority.ordinal();
}
public boolean isHigherOrEqual(Priority priority) {
return this.ordinal() >= priority.ordinal();
}
}
}

View File

@@ -0,0 +1,156 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.client.property;
import java.util.List;
import com.mojang.serialization.Codec;
import com.tiedup.remake.rig.anim.property.AnimationProperty.StaticAnimationProperty;
import com.tiedup.remake.rig.anim.types.DirectStaticAnimation;
import com.tiedup.remake.rig.anim.client.AnimationSubFileReader;
import com.tiedup.remake.rig.anim.client.Layer;
public class ClientAnimationProperties {
/**
* Layer type. (BASE: Living, attack animations, COMPOSITE: Aiming, weapon holding, digging animation)
*
* <p>Artist-freedom Wave A : serializable via a {@link Codec} mapping to
* the {@link Layer.LayerType} enum constant name (case-insensitive
* tolerance — accepts {@code "base_layer"} or {@code "BASE_LAYER"}). This
* allows animation JSON authors to override the property through the
* top-level {@code "properties"} block:
* <pre>{@code
* "properties": {
* "layer_type": "COMPOSITE_LAYER"
* }
* }</pre>
*
* <p>Upstream Epic Fight deserializes this field exclusively through
* {@link AnimationSubFileReader#deserializeLayerInfo} from a {@code
* "layer"} key in a sub-file. We keep that legacy path working and add
* this {@code name+Codec} registration as an additional access route
* (the two surface names — {@code layer_type} here, {@code layer} in the
* sub-file — do not conflict because they live in different JSON blocks).
*/
public static final StaticAnimationProperty<Layer.LayerType> LAYER_TYPE = new StaticAnimationProperty<Layer.LayerType>(
"layer_type",
Codec.STRING.xmap(
s -> Layer.LayerType.valueOf(s.toUpperCase()),
Enum::name
)
);
/**
* Priority of composite layer.
*
* <p>Artist-freedom Wave A : serializable via a {@link Codec} mapping to
* the {@link Layer.Priority} enum constant name. Accepted values are
* {@code "LOWEST"}, {@code "LOW"}, {@code "MIDDLE"}, {@code "HIGH"},
* {@code "HIGHEST"} (case-insensitive).
*/
public static final StaticAnimationProperty<Layer.Priority> PRIORITY = new StaticAnimationProperty<Layer.Priority>(
"priority",
Codec.STRING.xmap(
s -> Layer.Priority.valueOf(s.toUpperCase()),
Enum::name
)
);
/**
* Joint mask for composite layer.
*
* <p>Artist-freedom Wave A : serializable via a {@link Codec} that encodes
* a {@link JointMaskEntry} as a single {@link net.minecraft.resources.ResourceLocation}
* string (namespaced path of a joint-mask JSON file in
* {@code animmodels/joint_mask/...}). At parse time the string is resolved
* through {@link JointMaskReloadListener#getJointMaskEntry(String)} into a
* {@link JointMask.JointMaskSet}, then wrapped into a {@link JointMaskEntry}
* with that set as the default mask and no per-motion overrides.
*
* <p>Round-trip encoding returns {@code "tiedup:none"} when the entry is
* unnamed or reference-equal to the vanilla {@code none} fallback. This is
* a best-effort lossy encode — a {@link JointMaskEntry} with per-motion
* overrides cannot be reduced to a single joint-mask ID without structural
* loss, and round-tripping that through the Codec is explicitly NOT
* supported (the richer upstream sub-file format in
* {@link AnimationSubFileReader} remains the authoring path for those).
*
* <p><b>Intent</b> : the {@code properties} JSON block is the
* &laquo;simple&raquo; override path that 99% of artists use — a single
* default mask is the only practical shape to serialize there. For
* per-motion mask entries the artist still writes a {@code *.data.json}
* sub-file alongside the anim.
*
* <p>Unknown joint-mask IDs resolve to the {@code tiedup:none} fallback
* entry (see {@link JointMaskReloadListener#getNoneMask()}) — no crash
* but a WARN log is emitted at deserialization.
*/
public static final StaticAnimationProperty<JointMaskEntry> JOINT_MASK = new StaticAnimationProperty<JointMaskEntry>(
"joint_mask",
Codec.STRING.xmap(
ClientAnimationProperties::decodeJointMaskEntry,
ClientAnimationProperties::encodeJointMaskEntry
)
);
/**
* Trail particle information
*/
public static final StaticAnimationProperty<List<TrailInfo>> TRAIL_EFFECT = new StaticAnimationProperty<List<TrailInfo>> ();
/**
* An animation clip being played in first person.
*/
public static final StaticAnimationProperty<DirectStaticAnimation> POV_ANIMATION = new StaticAnimationProperty<DirectStaticAnimation> ();
/**
* An animation clip being played in first person.
*/
public static final StaticAnimationProperty<AnimationSubFileReader.PovSettings> POV_SETTINGS = new StaticAnimationProperty<AnimationSubFileReader.PovSettings> ();
/**
* Multilayer for living animations (e.g. Greatsword holding animation should be played simultaneously with jumping animation)
*/
public static final StaticAnimationProperty<DirectStaticAnimation> MULTILAYER_ANIMATION = new StaticAnimationProperty<DirectStaticAnimation> ();
// === Wave A helpers : JOINT_MASK codec (package-private for unit testing) ===
/**
* Decode a joint-mask ID string into a {@link JointMaskEntry} whose default
* mask is the set registered under that ID. Unknown IDs fall back to the
* {@code tiedup:none} empty mask (already handled by
* {@link JointMaskReloadListener#getJointMaskEntry(String)}).
*
* <p>Package-private for direct unit-test coverage — the Codec re-wires
* this through {@link Codec#xmap} but the test can exercise the pure
* lookup logic without bootstrap.
*/
static JointMaskEntry decodeJointMaskEntry(String id) {
JointMask.JointMaskSet set = JointMaskReloadListener.getJointMaskEntry(id);
return JointMaskEntry.builder().defaultMask(set).create();
}
/**
* Encode a {@link JointMaskEntry} by looking up its default-mask ID in the
* {@link JointMaskReloadListener} reverse bimap. Falls back to
* {@code "tiedup:none"} when the mask is unregistered (either never
* reloaded or built programmatically with a custom set).
*
* <p>Per-motion override masks inside the entry are intentionally NOT
* serialized here — see class-level Javadoc on {@link #JOINT_MASK} for
* the rationale.
*/
static String encodeJointMaskEntry(JointMaskEntry entry) {
if (entry == null || entry.getDefaultMask() == null) {
return "tiedup:none";
}
net.minecraft.resources.ResourceLocation key =
JointMaskReloadListener.getKey(entry.getDefaultMask());
return key != null ? key.toString() : "tiedup:none";
}
}

View File

@@ -0,0 +1,104 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.client.property;
import java.util.Map;
import java.util.Set;
import com.google.common.collect.Maps;
import com.mojang.datafixers.util.Pair;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.armature.JointTransform;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.anim.client.Layer;
import com.tiedup.remake.rig.math.OpenMatrix4f;
import com.tiedup.remake.rig.math.Vec3f;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public class JointMask {
@FunctionalInterface
public interface BindModifier {
public void modify(LivingEntityPatch<?> entitypatch, Pose baseLayerPose, Pose resultPose, LivingMotion livingMotion, JointMaskEntry wholeEntry, Layer.Priority priority, Joint joint, Map<Layer.Priority, Pair<AssetAccessor<? extends DynamicAnimation>, Pose>> poses);
}
public static final BindModifier KEEP_CHILD_LOCROT = (entitypatch, baseLayerPose, result, livingMotion, wholeEntry, priority, joint, poses) -> {
Pose currentPose = poses.get(priority).getSecond();
JointTransform lowestTransform = baseLayerPose.orElseEmpty(joint.getName());
JointTransform currentTransform = currentPose.orElseEmpty(joint.getName());
result.orElseEmpty(joint.getName()).translation().y = lowestTransform.translation().y;
OpenMatrix4f lowestMatrix = lowestTransform.toMatrix();
OpenMatrix4f currentMatrix = currentTransform.toMatrix();
OpenMatrix4f currentToLowest = OpenMatrix4f.mul(OpenMatrix4f.invert(currentMatrix, null), lowestMatrix, null);
for (Joint subJoint : joint.getSubJoints()) {
if (wholeEntry.isMasked(livingMotion, subJoint.getName())) {
OpenMatrix4f lowestLocalTransform = OpenMatrix4f.mul(joint.getLocalTransform(), lowestMatrix, null);
OpenMatrix4f currentLocalTransform = OpenMatrix4f.mul(joint.getLocalTransform(), currentMatrix, null);
OpenMatrix4f childTransform = OpenMatrix4f.mul(subJoint.getLocalTransform(), result.orElseEmpty(subJoint.getName()).toMatrix(), null);
OpenMatrix4f lowestFinal = OpenMatrix4f.mul(lowestLocalTransform, childTransform, null);
OpenMatrix4f currentFinal = OpenMatrix4f.mul(currentLocalTransform, childTransform, null);
Vec3f vec = new Vec3f((currentFinal.m30 - lowestFinal.m30) * 0.5F, currentFinal.m31 - lowestFinal.m31, currentFinal.m32 - lowestFinal.m32);
JointTransform jt = result.orElseEmpty(subJoint.getName());
jt.parent(JointTransform.translation(vec), OpenMatrix4f::mul);
jt.jointLocal(JointTransform.fromMatrixWithoutScale(currentToLowest), OpenMatrix4f::mul);
}
}
};
public static JointMask of(String jointName, BindModifier bindModifier) {
return new JointMask(jointName, bindModifier);
}
public static JointMask of(String jointName) {
return new JointMask(jointName, null);
}
private final String jointName;
private final BindModifier bindModifier;
private JointMask(String jointName, BindModifier bindModifier) {
this.jointName = jointName;
this.bindModifier = bindModifier;
}
public static class JointMaskSet {
final Map<String, BindModifier> masks = Maps.newHashMap();
public boolean contains(String name) {
return this.masks.containsKey(name);
}
public BindModifier getBindModifier(String jointName) {
return this.masks.get(jointName);
}
public static JointMaskSet of(JointMask... masks) {
JointMaskSet jointMaskSet = new JointMaskSet();
for (JointMask jointMask : masks) {
jointMaskSet.masks.put(jointMask.jointName, jointMask.bindModifier);
}
return jointMaskSet;
}
public static JointMaskSet of(Set<JointMask> jointMasks) {
JointMaskSet jointMaskSet = new JointMaskSet();
for (JointMask jointMask : jointMasks) {
jointMaskSet.masks.put(jointMask.jointName, jointMask.bindModifier);
}
return jointMaskSet;
}
}
}

View File

@@ -0,0 +1,109 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.client.property;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang3.tuple.Pair;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.client.property.JointMask.JointMaskSet;
public class JointMaskEntry {
public static final JointMaskSet BIPED_UPPER_JOINTS_WITH_ROOT = JointMaskSet.of(
JointMask.of("Root", JointMask.KEEP_CHILD_LOCROT), JointMask.of("Torso"),
JointMask.of("Chest"), JointMask.of("Head"),
JointMask.of("Shoulder_R"), JointMask.of("Arm_R"),
JointMask.of("Hand_R"), JointMask.of("Elbow_R"),
JointMask.of("Tool_R"), JointMask.of("Shoulder_L"),
JointMask.of("Arm_L"), JointMask.of("Hand_L"),
JointMask.of("Elbow_L"), JointMask.of("Tool_L")
);
public static final JointMaskEntry BASIC_ATTACK_MASK = JointMaskEntry.builder().defaultMask(JointMaskEntry.BIPED_UPPER_JOINTS_WITH_ROOT).create();
private final Map<LivingMotion, JointMaskSet> masks = Maps.newHashMap();
private final JointMaskSet defaultMask;
public JointMaskEntry(JointMaskSet defaultMask, List<Pair<LivingMotion, JointMaskSet>> masks) {
this.defaultMask = defaultMask;
for (Pair<LivingMotion, JointMaskSet> mask : masks) {
this.masks.put(mask.getLeft(), mask.getRight());
}
}
public JointMaskSet getMask(LivingMotion livingmotion) {
return this.masks.getOrDefault(livingmotion, this.defaultMask);
}
public boolean isMasked(LivingMotion livingmotion, String jointName) {
return !this.masks.getOrDefault(livingmotion, this.defaultMask).contains(jointName);
}
public Set<Map.Entry<LivingMotion, JointMaskSet>> getEntries() {
return this.masks.entrySet();
}
public JointMaskSet getDefaultMask() {
return this.defaultMask;
}
public boolean isValid() {
return this.defaultMask != null;
}
public static JointMaskEntry.Builder builder() {
return new JointMaskEntry.Builder();
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
for (Map.Entry<LivingMotion, JointMaskSet> entry : this.masks.entrySet()) {
builder.append(entry.getKey() + ": ");
builder.append(JointMaskReloadListener.getKey(entry.getValue()) + ", ");
}
ResourceLocation maskKey = JointMaskReloadListener.getKey(this.defaultMask);
if (maskKey == null) {
builder.append("default: custom");
} else {
builder.append("default: ");
builder.append(JointMaskReloadListener.getKey(this.defaultMask));
}
return builder.toString();
}
public static class Builder {
private final List<Pair<LivingMotion, JointMaskSet>> masks = Lists.newArrayList();
private JointMaskSet defaultMask = null;
public JointMaskEntry.Builder mask(LivingMotion motion, JointMaskSet masks) {
this.masks.add(Pair.of(motion, masks));
return this;
}
public JointMaskEntry.Builder defaultMask(JointMaskSet masks) {
this.defaultMask = masks;
return this;
}
public JointMaskEntry create() {
return new JointMaskEntry(this.defaultMask, this.masks);
}
}
}

View File

@@ -0,0 +1,87 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.client.property;
import java.util.Map;
import java.util.Set;
import com.google.common.collect.BiMap;
import com.google.common.collect.HashBiMap;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
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 com.tiedup.remake.rig.anim.client.property.JointMask.BindModifier;
import com.tiedup.remake.rig.anim.client.property.JointMask.JointMaskSet;
import com.tiedup.remake.rig.TiedUpRigConstants;
public class JointMaskReloadListener extends SimpleJsonResourceReloadListener {
private static final BiMap<ResourceLocation, JointMaskSet> JOINT_MASKS = HashBiMap.create();
private static final Map<String, JointMask.BindModifier> BIND_MODIFIERS = Maps.newHashMap();
private static final ResourceLocation NONE_MASK = TiedUpRigConstants.identifier("none");
static {
BIND_MODIFIERS.put("keep_child_locrot", JointMask.KEEP_CHILD_LOCROT);
}
public static JointMaskSet getJointMaskEntry(String type) {
ResourceLocation rl = ResourceLocation.parse(type);
return JOINT_MASKS.getOrDefault(rl, JOINT_MASKS.get(NONE_MASK));
}
public static JointMaskSet getNoneMask() {
return JOINT_MASKS.get(NONE_MASK);
}
public static ResourceLocation getKey(JointMaskSet type) {
return JOINT_MASKS.inverse().get(type);
}
public static Set<Map.Entry<ResourceLocation, JointMaskSet>> entries() {
return JOINT_MASKS.entrySet();
}
public JointMaskReloadListener() {
super((new GsonBuilder()).create(), "animmodels/joint_mask");
}
@Override
protected void apply(Map<ResourceLocation, JsonElement> objectIn, ResourceManager resourceManager, ProfilerFiller profileFiller) {
JOINT_MASKS.clear();
for (Map.Entry<ResourceLocation, JsonElement> entry : objectIn.entrySet()) {
Set<JointMask> masks = Sets.newHashSet();
JsonObject object = entry.getValue().getAsJsonObject();
JsonArray joints = object.getAsJsonArray("joints");
JsonObject bindModifiers = object.has("bind_modifiers") ? object.getAsJsonObject("bind_modifiers") : null;
for (JsonElement joint : joints) {
String jointName = joint.getAsString();
BindModifier modifier = null;
if (bindModifiers != null) {
String modifierName = bindModifiers.has(jointName) ? bindModifiers.get(jointName).getAsString() : null;
modifier = BIND_MODIFIERS.get(modifierName);
}
masks.add(JointMask.of(jointName, modifier));
}
String path = entry.getKey().toString();
ResourceLocation key = ResourceLocation.fromNamespaceAndPath(entry.getKey().getNamespace(), path.substring(path.lastIndexOf("/") + 1));
JOINT_MASKS.put(key, JointMaskSet.of(masks));
}
}
}

View File

@@ -0,0 +1,21 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.client.property;
import com.tiedup.remake.rig.anim.client.Layer;
public class LayerInfo {
public final JointMaskEntry jointMaskEntry;
public final Layer.Priority priority;
public final Layer.LayerType layerType;
public LayerInfo(JointMaskEntry jointMaskEntry, Layer.Priority priority, Layer.LayerType layerType) {
this.jointMaskEntry = jointMaskEntry;
this.priority = priority;
this.layerType = layerType;
}
}

View File

@@ -0,0 +1,29 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.client.property;
import com.google.gson.JsonElement;
import net.minecraft.core.particles.SimpleParticleType;
import net.minecraft.world.phys.Vec3;
/**
* Stub RIG Phase 0 — combat weapon particle trail. Pas utilisé dans TiedUp
* (bondage, pas d'armes actives), mais on garde l'API typée pour JSON compat.
* {@code deserialize} retourne toujours un trail neutre non-playable, donc
* le block dans StaticAnimation est court-circuité (voir {@link #playable()}).
*/
public record TrailInfo(String joint, SimpleParticleType particle, boolean playable) {
public static TrailInfo deserialize(JsonElement element) {
return new TrailInfo("", null, false);
}
public Vec3 start() { return Vec3.ZERO; }
public Vec3 end() { return Vec3.ZERO; }
public float startTime() { return 0.0F; }
public float endTime() { return 0.0F; }
}

View File

@@ -0,0 +1,41 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.modifier;
import com.mojang.serialization.Codec;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.anim.property.AnimationProperty.PlaybackSpeedModifier;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
import com.tiedup.remake.rig.util.CodecDispatchRegistry;
/**
* Phase 3 D1 — data-driven concrete implementation of
* {@link PlaybackSpeedModifier}. The base functional interface takes five
* parameters ; datapack authors never need all of them, so concrete impls
* ignore the irrelevant ones.
*
* <p>JSON schema :
* <pre>{@code
* "play_speed_modifier": { "type": "tiedup:constant_factor", "factor": 0.5 }
* }</pre>
*
* <p>Consumer : {@link com.tiedup.remake.rig.anim.AnimationPlayer#tick} reads
* the modifier and multiplies the current {@code playbackSpeed} by whatever
* the modifier returns each tick — so a modifier that returns {@code 0.5F}
* halves the animation speed.
*/
public interface PlaybackSpeedModifierImpl extends PlaybackSpeedModifier, CodecDispatchRegistry.Typed {
Codec<PlaybackSpeedModifierImpl> CODEC = PlaybackSpeedModifierRegistry.INSTANCE.dispatchCodec();
@Override
ResourceLocation type();
@Override
float modify(DynamicAnimation self, LivingEntityPatch<?> entitypatch, float speed, float prevElapsedTime, float elapsedTime);
}

View File

@@ -0,0 +1,39 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.modifier;
import com.tiedup.remake.rig.anim.modifier.impl.ConstantFactorSpeedModifier;
import com.tiedup.remake.rig.anim.modifier.impl.LinearRampSpeedModifier;
import com.tiedup.remake.rig.util.CodecDispatchRegistry;
/**
* Registry of {@link PlaybackSpeedModifierImpl} type ids → codecs. Same
* dispatch pattern as {@link PoseModifierRegistry}.
*
* <p>Base impls :
* <ul>
* <li>{@code tiedup:constant_factor} — multiply speed by a fixed factor</li>
* <li>{@code tiedup:linear_ramp} — linear interpolation between a start and
* end factor over {@code elapsedTime} from 0 to {@code duration}</li>
* </ul>
*
* <p>Plumbing lives in {@link CodecDispatchRegistry} since {@code SMELL-CODEC-01}.
*/
public final class PlaybackSpeedModifierRegistry extends CodecDispatchRegistry<PlaybackSpeedModifierImpl> {
public static final PlaybackSpeedModifierRegistry INSTANCE = new PlaybackSpeedModifierRegistry();
private PlaybackSpeedModifierRegistry() {}
@Override
protected String registryName() {
return "PlaybackSpeedModifier";
}
static {
INSTANCE.register(ConstantFactorSpeedModifier.ID, ConstantFactorSpeedModifier.CODEC);
INSTANCE.register(LinearRampSpeedModifier.ID, LinearRampSpeedModifier.CODEC);
}
}

View File

@@ -0,0 +1,39 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.modifier;
import com.mojang.datafixers.util.Pair;
import com.mojang.serialization.Codec;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.anim.property.AnimationProperty.PlaybackTimeModifier;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
import com.tiedup.remake.rig.util.CodecDispatchRegistry;
/**
* Phase 3 D1 — data-driven concrete implementation of
* {@link PlaybackTimeModifier}. The base interface returns a
* {@code Pair<Float, Float>} of {@code (prevElapsed, elapsed)}, typically used
* to loop a sub-section of an animation (set {@code elapsed} back to the
* looppoint when it crosses a threshold).
*
* <p>JSON schema :
* <pre>{@code
* "elapsed_time_modifier": { "type": "tiedup:loop_section",
* "loop_start": 0.3, "loop_end": 0.8 }
* }</pre>
*/
public interface PlaybackTimeModifierImpl extends PlaybackTimeModifier, CodecDispatchRegistry.Typed {
Codec<PlaybackTimeModifierImpl> CODEC = PlaybackTimeModifierRegistry.INSTANCE.dispatchCodec();
@Override
ResourceLocation type();
@Override
Pair<Float, Float> modify(DynamicAnimation self, LivingEntityPatch<?> entitypatch, float speed, float prevElapsedTime, float elapsedTime);
}

View File

@@ -0,0 +1,39 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.modifier;
import com.tiedup.remake.rig.anim.modifier.impl.LoopSectionTimeModifier;
import com.tiedup.remake.rig.util.CodecDispatchRegistry;
/**
* Registry of {@link PlaybackTimeModifierImpl} type ids → codecs.
*
* <p>One base impl today :
* <ul>
* <li>{@code tiedup:loop_section} — rewind elapsed time back to a loop
* start when it crosses a loop end threshold</li>
* </ul>
*
* <p>Not a ton of base impls because the common «replay this window»
* behaviour is what 95% of bondage loops need ; more exotic time warps
* (ping-pong, jitter) can be added later without a schema break.
*
* <p>Plumbing lives in {@link CodecDispatchRegistry} since {@code SMELL-CODEC-01}.
*/
public final class PlaybackTimeModifierRegistry extends CodecDispatchRegistry<PlaybackTimeModifierImpl> {
public static final PlaybackTimeModifierRegistry INSTANCE = new PlaybackTimeModifierRegistry();
private PlaybackTimeModifierRegistry() {}
@Override
protected String registryName() {
return "PlaybackTimeModifier";
}
static {
INSTANCE.register(LoopSectionTimeModifier.ID, LoopSectionTimeModifier.CODEC);
}
}

View File

@@ -0,0 +1,67 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.modifier;
import com.mojang.serialization.Codec;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.property.AnimationProperty.PoseModifier;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
import com.tiedup.remake.rig.util.CodecDispatchRegistry;
/**
* Phase 3 D1 — data-driven concrete implementation of the functional
* {@link PoseModifier} interface.
*
* <p>{@link PoseModifier} is a raw lambda {@code (self, pose, patch, elapsed,
* partialTick) -> void}, which is not serializable on its own. This interface
* extends it with a {@link #type()} identifier and a dispatch codec so that
* datapack authors can write concrete modifiers in JSON :
* <pre>{@code
* "pose_modifier": { "type": "tiedup:joint_rotation_offset",
* "joint": "upper_arm_left",
* "pitch": 15.0, "yaw": 0.0, "roll": 0.0 }
* }</pre>
*
* <p>Unlike {@link com.tiedup.remake.rig.anim.action.AnimationAction} which
* supports lists (a single animation can fire many actions), a single
* {@code pose_modifier} slot accepts exactly one modifier — because
* {@code StaticAnimationProperty.POSE_MODIFIER} is declared as {@code <PoseModifier>}
* (singular) and the consumer
* ({@link com.tiedup.remake.rig.anim.types.StaticAnimation#modifyPose}) calls
* {@code poseModifier.modify(...)} exactly once. Authors who want composition
* should chain via {@link com.tiedup.remake.rig.anim.modifier.impl.ChainedPoseModifier}.
*
* <p>All implementations must be side-safe : {@link PoseModifier#modify} is
* invoked in both client render and server logic ticks. Implementations that
* mutate the pose must do so deterministically from the inputs only, no IO or
* world state.
*/
public interface PoseModifierImpl extends PoseModifier, CodecDispatchRegistry.Typed {
/**
* Dispatch codec — reads the {@code "type"} field and delegates to the
* codec registered for that {@link ResourceLocation}.
*/
Codec<PoseModifierImpl> CODEC = PoseModifierRegistry.INSTANCE.dispatchCodec();
/**
* The registered type id of this modifier (e.g.
* {@code tiedup:joint_rotation_offset}).
*/
@Override
ResourceLocation type();
/**
* Forwarded from {@link PoseModifier#modify}. Subinterface so that the
* generic functional contract stays visible at the implementation site
* without Java default-method ambiguity.
*/
@Override
void modify(DynamicAnimation self, Pose pose, LivingEntityPatch<?> entitypatch, float elapsedTime, float partialTick);
}

View File

@@ -0,0 +1,45 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.modifier;
import com.tiedup.remake.rig.anim.modifier.impl.ChainedPoseModifier;
import com.tiedup.remake.rig.anim.modifier.impl.JointRotationOffsetModifier;
import com.tiedup.remake.rig.anim.modifier.impl.JointTranslationOffsetModifier;
import com.tiedup.remake.rig.util.CodecDispatchRegistry;
/**
* Registry of {@link PoseModifierImpl} type ids → codecs. Mirrors the design
* of {@link com.tiedup.remake.rig.anim.action.AnimationActionRegistry}.
*
* <p>Three base impls are registered in the static initializer :
* <ul>
* <li>{@code tiedup:joint_rotation_offset} — nudge a single joint's rotation</li>
* <li>{@code tiedup:joint_translation_offset} — nudge a single joint's translation</li>
* <li>{@code tiedup:chain} — run a list of modifiers in order</li>
* </ul>
*
* <p>Registering via the static init means a single reference to
* {@link PoseModifierImpl#CODEC} in a parse path is enough to bootstrap the
* dispatch table, same bootstrap contract as the action registry.
*
* <p>Plumbing lives in {@link CodecDispatchRegistry} since {@code SMELL-CODEC-01}.
*/
public final class PoseModifierRegistry extends CodecDispatchRegistry<PoseModifierImpl> {
public static final PoseModifierRegistry INSTANCE = new PoseModifierRegistry();
private PoseModifierRegistry() {}
@Override
protected String registryName() {
return "PoseModifier";
}
static {
INSTANCE.register(JointRotationOffsetModifier.ID, JointRotationOffsetModifier.CODEC);
INSTANCE.register(JointTranslationOffsetModifier.ID, JointTranslationOffsetModifier.CODEC);
INSTANCE.register(ChainedPoseModifier.ID, ChainedPoseModifier.CODEC);
}
}

View File

@@ -0,0 +1,97 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.modifier.impl;
import java.util.List;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.Decoder;
import com.mojang.serialization.DynamicOps;
import com.mojang.serialization.Encoder;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
/**
* Composite modifier — runs a list of modifiers sequentially on the same pose.
* Compensates for the singular {@code POSE_MODIFIER} property slot (only one
* modifier can be attached to an animation, but authors often need several
* per-joint nudges).
*
* <p>JSON schema :
* <pre>{@code
* { "type": "tiedup:chain",
* "modifiers": [
* { "type": "tiedup:joint_rotation_offset", "joint": "upper_arm_left", "pitch": 15.0 },
* { "type": "tiedup:joint_translation_offset","joint": "hand_right", "y": -0.05 }
* ] }
* }</pre>
*
* <p>Nested chains are allowed (no cycle detection — an author-written cycle
* would manifest as a {@link StackOverflowError} at tick time, which is
* identifiable and not worth defensive code). Execution order is list order.
*/
public record ChainedPoseModifier(
List<PoseModifierImpl> modifiers
) implements PoseModifierImpl {
public static final ResourceLocation ID = TiedUpRigConstants.identifier("chain");
/**
* Hand-rolled lazy codec. We cannot use a {@link RecordCodecBuilder} that
* references {@link PoseModifierImpl#CODEC} at static-init time because
* that would create a cycle :
* {@code ChainedPoseModifier.CODEC → PoseModifierImpl.CODEC
* → PoseModifierRegistry.<clinit> → ChainedPoseModifier.CODEC}
* which leaves ChainedPoseModifier.CODEC null during the registry
* registration call. Instead, we defer the recursive lookup by calling
* {@link PoseModifierImpl#CODEC} inside the encode/decode bodies, which
* run only at (de)serialization time — long after all static inits have
* completed.
*/
public static final Codec<ChainedPoseModifier> CODEC = Codec.of(
new Encoder<ChainedPoseModifier>() {
@Override
public <T> DataResult<T> encode(ChainedPoseModifier input, DynamicOps<T> ops, T prefix) {
return PoseModifierImpl.CODEC.listOf()
.encodeStart(ops, input.modifiers())
.flatMap(list -> ops.mergeToMap(prefix, ops.createString("modifiers"), list));
}
},
new Decoder<ChainedPoseModifier>() {
@Override
public <T> DataResult<com.mojang.datafixers.util.Pair<ChainedPoseModifier, T>> decode(DynamicOps<T> ops, T input) {
return ops.getMap(input).flatMap(map -> {
T modifiersField = map.get("modifiers");
if (modifiersField == null) {
return DataResult.error(() -> "Missing field 'modifiers' in tiedup:chain");
}
return PoseModifierImpl.CODEC.listOf()
.parse(ops, modifiersField)
.map(list -> com.mojang.datafixers.util.Pair.of(new ChainedPoseModifier(list), input));
});
}
}
);
@Override
public ResourceLocation type() {
return ID;
}
@Override
public void modify(DynamicAnimation self, Pose pose, LivingEntityPatch<?> entitypatch, float elapsedTime, float partialTick) {
for (PoseModifierImpl m : this.modifiers) {
m.modify(self, pose, entitypatch, elapsedTime, partialTick);
}
}
}

View File

@@ -0,0 +1,47 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.modifier.impl;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.modifier.PlaybackSpeedModifierImpl;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
/**
* Multiply the current playback speed by a fixed factor every tick.
*
* <p>JSON schema :
* <pre>{@code
* "play_speed_modifier": { "type": "tiedup:constant_factor", "factor": 0.5 }
* }</pre>
*
* <p>A {@code factor} of {@code 1.0} is a no-op. Negative factors technically
* work (animation plays backward) but the reverse flag in
* {@link com.tiedup.remake.rig.anim.AnimationPlayer} already handles that
* cleanly — prefer that over a negative speed modifier.
*/
public record ConstantFactorSpeedModifier(float factor) implements PlaybackSpeedModifierImpl {
public static final ResourceLocation ID = TiedUpRigConstants.identifier("constant_factor");
public static final Codec<ConstantFactorSpeedModifier> CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.FLOAT.fieldOf("factor").forGetter(ConstantFactorSpeedModifier::factor)
).apply(i, ConstantFactorSpeedModifier::new));
@Override
public ResourceLocation type() {
return ID;
}
@Override
public float modify(DynamicAnimation self, LivingEntityPatch<?> entitypatch, float speed, float prevElapsedTime, float elapsedTime) {
return speed * this.factor;
}
}

View File

@@ -0,0 +1,79 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.modifier.impl;
import org.joml.Quaternionf;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.Mth;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.armature.JointTransform;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
/**
* Rotate a single named joint by a fixed Euler offset (degrees) at every tick
* of the animation. Bondage pipeline use-case : lock a restrained arm joint to
* a tighter angle than the authored animation provides.
*
* <p>JSON schema :
* <pre>{@code
* { "type": "tiedup:joint_rotation_offset",
* "joint": "upper_arm_left",
* "pitch": 15.0,
* "yaw": 0.0,
* "roll": 0.0 }
* }</pre>
*
* <p>Angles default to zero if omitted — a {@code joint_rotation_offset} with
* all three zero is a no-op (author error, logged nowhere — by design, since
* it's a cheap operation and a valid edge case during iteration).
*
* <p>If the joint is absent from the pose, this modifier is a silent no-op :
* the authored animation simply doesn't touch that joint on this frame. This
* matches the semantics of {@link Pose#orElseEmpty} used elsewhere.
*/
public record JointRotationOffsetModifier(
String joint,
float pitch,
float yaw,
float roll
) implements PoseModifierImpl {
public static final ResourceLocation ID = TiedUpRigConstants.identifier("joint_rotation_offset");
public static final Codec<JointRotationOffsetModifier> CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.STRING.fieldOf("joint").forGetter(JointRotationOffsetModifier::joint),
Codec.FLOAT.optionalFieldOf("pitch", 0.0F).forGetter(JointRotationOffsetModifier::pitch),
Codec.FLOAT.optionalFieldOf("yaw", 0.0F).forGetter(JointRotationOffsetModifier::yaw),
Codec.FLOAT.optionalFieldOf("roll", 0.0F).forGetter(JointRotationOffsetModifier::roll)
).apply(i, JointRotationOffsetModifier::new));
@Override
public ResourceLocation type() {
return ID;
}
@Override
public void modify(DynamicAnimation self, Pose pose, LivingEntityPatch<?> entitypatch, float elapsedTime, float partialTick) {
if (!pose.hasTransform(this.joint)) {
return;
}
JointTransform jt = pose.get(this.joint);
Quaternionf offset = new Quaternionf().rotationXYZ(
this.pitch * Mth.DEG_TO_RAD,
this.yaw * Mth.DEG_TO_RAD,
this.roll * Mth.DEG_TO_RAD
);
jt.rotation().mul(offset);
}
}

View File

@@ -0,0 +1,64 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.modifier.impl;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.armature.JointTransform;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
/**
* Shift a single named joint by a fixed translation offset at every tick of
* the animation. Use-case : pull a constrained wrist joint closer to the
* furniture anchor than the authored animation puts it.
*
* <p>JSON schema :
* <pre>{@code
* { "type": "tiedup:joint_translation_offset",
* "joint": "hand_right",
* "x": 0.0, "y": -0.05, "z": 0.02 }
* }</pre>
*
* <p>All three axes default to zero — a fully-zero offset is a no-op.
* Missing joint is a silent no-op (same contract as the rotation variant).
*/
public record JointTranslationOffsetModifier(
String joint,
float x,
float y,
float z
) implements PoseModifierImpl {
public static final ResourceLocation ID = TiedUpRigConstants.identifier("joint_translation_offset");
public static final Codec<JointTranslationOffsetModifier> CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.STRING.fieldOf("joint").forGetter(JointTranslationOffsetModifier::joint),
Codec.FLOAT.optionalFieldOf("x", 0.0F).forGetter(JointTranslationOffsetModifier::x),
Codec.FLOAT.optionalFieldOf("y", 0.0F).forGetter(JointTranslationOffsetModifier::y),
Codec.FLOAT.optionalFieldOf("z", 0.0F).forGetter(JointTranslationOffsetModifier::z)
).apply(i, JointTranslationOffsetModifier::new));
@Override
public ResourceLocation type() {
return ID;
}
@Override
public void modify(DynamicAnimation self, Pose pose, LivingEntityPatch<?> entitypatch, float elapsedTime, float partialTick) {
if (!pose.hasTransform(this.joint)) {
return;
}
JointTransform jt = pose.get(this.joint);
jt.translation().add(this.x, this.y, this.z);
}
}

View File

@@ -0,0 +1,72 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.modifier.impl;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.Mth;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.modifier.PlaybackSpeedModifierImpl;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
/**
* Ramp the playback speed factor linearly from {@code from} to {@code to} over
* the time window {@code [startTime, endTime]} (in seconds of animation
* elapsed). Outside the window, clamps to the boundary value.
*
* <p>JSON schema :
* <pre>{@code
* "play_speed_modifier": { "type": "tiedup:linear_ramp",
* "from": 0.2, "to": 1.0,
* "start_time": 0.0, "end_time": 0.5 }
* }</pre>
*
* <p>Use-case : hesitant-start animations (slow initial frames, then ramp to
* full speed for the payoff). The modifier's output is multiplied against the
* base speed by {@link com.tiedup.remake.rig.anim.AnimationPlayer#tick}, so
* {@code from=1.0, to=1.0} is a no-op.
*
* <p>{@code end_time <= start_time} degenerates to returning {@code to} for
* all {@code elapsedTime >= start_time} (clamped path above saturation).
*/
public record LinearRampSpeedModifier(
float from,
float to,
float startTime,
float endTime
) implements PlaybackSpeedModifierImpl {
public static final ResourceLocation ID = TiedUpRigConstants.identifier("linear_ramp");
public static final Codec<LinearRampSpeedModifier> CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.FLOAT.fieldOf("from").forGetter(LinearRampSpeedModifier::from),
Codec.FLOAT.fieldOf("to").forGetter(LinearRampSpeedModifier::to),
Codec.FLOAT.optionalFieldOf("start_time", 0.0F).forGetter(LinearRampSpeedModifier::startTime),
Codec.FLOAT.optionalFieldOf("end_time", 1.0F).forGetter(LinearRampSpeedModifier::endTime)
).apply(i, LinearRampSpeedModifier::new));
@Override
public ResourceLocation type() {
return ID;
}
@Override
public float modify(DynamicAnimation self, LivingEntityPatch<?> entitypatch, float speed, float prevElapsedTime, float elapsedTime) {
float factor;
if (elapsedTime <= this.startTime) {
factor = this.from;
} else if (elapsedTime >= this.endTime || this.endTime <= this.startTime) {
factor = this.to;
} else {
float progress = (elapsedTime - this.startTime) / (this.endTime - this.startTime);
factor = Mth.lerp(progress, this.from, this.to);
}
return speed * factor;
}
}

View File

@@ -0,0 +1,68 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.modifier.impl;
import com.mojang.datafixers.util.Pair;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.anim.modifier.PlaybackTimeModifierImpl;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
/**
* Rewind the animation playhead back to {@code loopStart} every time it
* crosses {@code loopEnd}. Matches the «sustain loop» pattern used e.g. for
* breathing idles inside a rest phase — the intro plays once, a middle section
* loops, and the outro is triggered by a separate state transition (not this
* modifier).
*
* <p>JSON schema :
* <pre>{@code
* "elapsed_time_modifier": { "type": "tiedup:loop_section",
* "loop_start": 0.3, "loop_end": 0.8 }
* }</pre>
*
* <p>This modifier is a pure function of {@code elapsedTime} — it does not
* mutate the animation. The {@code prevElapsedTime} is rewound symmetrically
* so event triggers using {@code [prev, elapsed]} windows still fire cleanly
* after the rewind.
*
* <p>Invariant : {@code loopStart < loopEnd}. Violating this yields undefined
* behaviour (probably a NaN or an instant loop) — no defensive check because
* the author would notice immediately on first playback.
*/
public record LoopSectionTimeModifier(
float loopStart,
float loopEnd
) implements PlaybackTimeModifierImpl {
public static final ResourceLocation ID = TiedUpRigConstants.identifier("loop_section");
public static final Codec<LoopSectionTimeModifier> CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.FLOAT.fieldOf("loop_start").forGetter(LoopSectionTimeModifier::loopStart),
Codec.FLOAT.fieldOf("loop_end").forGetter(LoopSectionTimeModifier::loopEnd)
).apply(i, LoopSectionTimeModifier::new));
@Override
public ResourceLocation type() {
return ID;
}
@Override
public Pair<Float, Float> modify(DynamicAnimation self, LivingEntityPatch<?> entitypatch, float speed, float prevElapsedTime, float elapsedTime) {
if (elapsedTime >= this.loopEnd) {
float overshoot = elapsedTime - this.loopEnd;
float sectionLen = this.loopEnd - this.loopStart;
float wrapped = this.loopStart + (overshoot % sectionLen);
float prevWrapped = prevElapsedTime - (elapsedTime - wrapped);
return Pair.of(prevWrapped, wrapped);
}
return Pair.of(prevElapsedTime, elapsedTime);
}
}

View File

@@ -0,0 +1,244 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.property;
import java.util.function.Predicate;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
@SuppressWarnings({"rawtypes", "unchecked"})
public abstract class AnimationEvent<EVENT extends AnimationEvent.Event<?, ?, ?, ?, ?, ?, ?, ?, ?, ?>, T extends AnimationEvent<EVENT, T>> {
protected final AnimationEvent.Side side;
protected final EVENT event;
protected AnimationParameters params;
private AnimationEvent(AnimationEvent.Side executionSide, EVENT event) {
this.side = executionSide;
this.event = event;
}
protected abstract boolean checkCondition(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, float prevElapsed, float elapsed);
public void execute(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, float prevElapsed, float elapsed) {
if (this.side.predicate.test(entitypatch.getOriginal()) && this.checkCondition(entitypatch, animation, prevElapsed, elapsed)) {
this.event.fire(entitypatch, animation, this.params);
}
}
public void executeWithNewParams(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, float prevElapsed, float elapsed, AnimationParameters parameters) {
if (this.side.predicate.test(entitypatch.getOriginal()) && this.checkCondition(entitypatch, animation, prevElapsed, elapsed)) {
this.event.fire(entitypatch, animation, parameters);
}
}
public static class SimpleEvent<EVENT extends AnimationEvent.Event<?, ?, ?, ?, ?, ?, ?, ?, ?, ?>> extends AnimationEvent<EVENT, SimpleEvent<EVENT>> {
private SimpleEvent(AnimationEvent.Side executionSide, EVENT event) {
super(executionSide, event);
}
@Override
protected boolean checkCondition(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, float prevElapsed, float elapsed) {
return true;
}
public static <E extends Event<?, ?, ?, ?, ?, ?, ?, ?, ?, ?>> SimpleEvent<E> create(E event, AnimationEvent.Side isRemote) {
return new SimpleEvent<> (isRemote, event);
}
}
public static class InTimeEvent<EVENT extends AnimationEvent.Event<?, ?, ?, ?, ?, ?, ?, ?, ?, ?>> extends AnimationEvent<EVENT, InTimeEvent<EVENT>> implements Comparable<InTimeEvent<EVENT>> {
final float time;
private InTimeEvent(float time, AnimationEvent.Side executionSide, EVENT event) {
super(executionSide, event);
this.time = time;
}
@Override
public boolean checkCondition(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, float prevElapsed, float elapsed) {
return this.time >= prevElapsed && this.time < elapsed;
}
@Override
public int compareTo(InTimeEvent<EVENT> arg0) {
if(this.time == arg0.time) {
return 0;
} else {
return this.time > arg0.time ? 1 : -1;
}
}
public static <E extends Event<?, ?, ?, ?, ?, ?, ?, ?, ?, ?>> InTimeEvent<E> create(float time, E event, AnimationEvent.Side isRemote) {
return new InTimeEvent<> (time, isRemote, event);
}
}
public static class InPeriodEvent<EVENT extends AnimationEvent.Event<?, ?, ?, ?, ?, ?, ?, ?, ?, ?>> extends AnimationEvent<EVENT, InPeriodEvent<EVENT>> implements Comparable<InPeriodEvent<EVENT>> {
final float start;
final float end;
private InPeriodEvent(float start, float end, AnimationEvent.Side executionSide, EVENT event) {
super(executionSide, event);
this.start = start;
this.end = end;
}
@Override
public boolean checkCondition(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, float prevElapsed, float elapsed) {
return this.start <= elapsed && this.end > elapsed;
}
@Override
public int compareTo(InPeriodEvent<EVENT> arg0) {
if (this.start == arg0.start) {
return 0;
} else {
return this.start > arg0.start ? 1 : -1;
}
}
public static <E extends Event<?, ?, ?, ?, ?, ?, ?, ?, ?, ?>> InPeriodEvent<E> create(float start, float end, E event, AnimationEvent.Side isRemote) {
return new InPeriodEvent<> (start, end, isRemote, event);
}
}
public enum Side {
CLIENT((entity) -> entity.level().isClientSide),
SERVER((entity) -> !entity.level().isClientSide), BOTH((entity) -> true),
LOCAL_CLIENT((entity) -> {
if (entity instanceof Player player) {
return player.isLocalPlayer();
}
return false;
});
Predicate<Entity> predicate;
Side(Predicate<Entity> predicate) {
this.predicate = predicate;
}
}
public AnimationParameters<?, ?, ?, ?, ?, ?, ?, ?, ?, ?> getParameters() {
return this.params;
}
public <A> T params(A p1) {
this.params = AnimationParameters.of(p1);
return (T)this;
}
public <A, B> T params(A p1, B p2) {
this.params = AnimationParameters.of(p1, p2);
return (T)this;
}
public <A, B, C> T params(A p1, B p2, C p3) {
this.params = AnimationParameters.of(p1, p2, p3);
return (T)this;
}
public <A, B, C, D> T params(A p1, B p2, C p3, D p4) {
this.params = AnimationParameters.of(p1, p2, p3, p4);
return (T)this;
}
public <A, B, C, D, E> T params(A p1, B p2, C p3, D p4, E p5) {
this.params = AnimationParameters.of(p1, p2, p3, p4, p5);
return (T)this;
}
public <A, B, C, D, E, F> T params(A p1, B p2, C p3, D p4, E p5, F p6) {
this.params = AnimationParameters.of(p1, p2, p3, p4, p5, p6);
return (T)this;
}
public <A, B, C, D, E, F, G> T params(A p1, B p2, C p3, D p4, E p5, F p6, G p7) {
this.params = AnimationParameters.of(p1, p2, p3, p4, p5, p6, p7);
return (T)this;
}
public <A, B, C, D, E, F, G, H> T params(A p1, B p2, C p3, D p4, E p5, F p6, G p7, H p8) {
this.params = AnimationParameters.of(p1, p2, p3, p4, p5, p6, p7, p8);
return (T)this;
}
public <A, B, C, D, E, F, G, H, I> T params(A p1, B p2, C p3, D p4, E p5, F p6, G p7, H p8, I p9) {
this.params = AnimationParameters.of(p1, p2, p3, p4, p5, p6, p7, p8, p9);
return (T)this;
}
public <A, B, C, D, E, F, G, H, I, J> T params(A p1, B p2, C p3, D p4, E p5, F p6, G p7, H p8, I p9, J p10) {
this.params = AnimationParameters.of(p1, p2, p3, p4, p5, p6, p7, p8, p9, p10);
return (T)this;
}
@FunctionalInterface
public interface Event<A, B, C, D, E, F, G, H, I, J> {
void fire(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, AnimationParameters<A, B, C, D, E, F, G, H, I, J> params);
}
@FunctionalInterface
public interface E0 extends Event<Void, Void, Void, Void, Void, Void, Void, Void, Void, Void> {
void fire(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, AnimationParameters<Void, Void, Void, Void, Void, Void, Void, Void, Void, Void> params);
}
@FunctionalInterface
public interface E1<A> extends Event<A, Void, Void, Void, Void, Void, Void, Void, Void, Void> {
void fire(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, AnimationParameters<A, Void, Void, Void, Void, Void, Void, Void, Void, Void> params);
}
@FunctionalInterface
public interface E2<A, B> extends Event<A, B, Void, Void, Void, Void, Void, Void, Void, Void> {
void fire(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, AnimationParameters<A, B, Void, Void, Void, Void, Void, Void, Void, Void> params);
}
@FunctionalInterface
public interface E3<A, B, C> extends Event<A, B, C, Void, Void, Void, Void, Void, Void, Void> {
void fire(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, AnimationParameters<A, B, C, Void, Void, Void, Void, Void, Void, Void> params);
}
@FunctionalInterface
public interface E4<A, B, C, D> extends Event<A, B, C, D, Void, Void, Void, Void, Void, Void> {
void fire(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, AnimationParameters<A, B, C, D, Void, Void, Void, Void, Void, Void> params);
}
@FunctionalInterface
public interface E5<A, B, C, D, E> extends Event<A, B, C, D, E, Void, Void, Void, Void, Void> {
void fire(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, AnimationParameters<A, B, C, D, E, Void, Void, Void, Void, Void> params);
}
@FunctionalInterface
public interface E6<A, B, C, D, E, F> extends Event<A, B, C, D, E, F, Void, Void, Void, Void> {
void fire(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, AnimationParameters<A, B, C, D, E, F, Void, Void, Void, Void> params);
}
@FunctionalInterface
public interface E7<A, B, C, D, E, F, G> extends Event<A, B, C, D, E, F, G, Void, Void, Void> {
void fire(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, AnimationParameters<A, B, C, D, E, F, G, Void, Void, Void> params);
}
@FunctionalInterface
public interface E8<A, B, C, D, E, F, G, H> extends Event<A, B, C, D, E, F, G, H, Void, Void> {
void fire(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, AnimationParameters<A, B, C, D, E, F, G, H, Void, Void> params);
}
@FunctionalInterface
public interface E9<A, B, C, D, E, F, G, H, I> extends Event<A, B, C, D, E, F, G, H, I, Void> {
void fire(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, AnimationParameters<A, B, C, D, E, F, G, H, I, Void> params);
}
@FunctionalInterface
public interface E10<A, B, C, D, E, F, G, H, I, J> extends Event<A, B, C, D, E, F, G, H, I, J> {
void fire(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends StaticAnimation> animation, AnimationParameters<A, B, C, D, E, F, G, H, I, J> params);
}
}

View File

@@ -0,0 +1,86 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.property;
public record AnimationParameters<A, B, C, D, E, F, G, H, I, J> (
A first,
B second,
C third,
D fourth,
E fifth,
F sixth,
G seventh,
H eighth,
I ninth,
J tenth
) {
public static <A> AnimationParameters<A, Void, Void, Void, Void, Void, Void, Void, Void, Void> of(A first) {
return new AnimationParameters<> (first, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null);
}
public static <A, B> AnimationParameters<A, B, Void, Void, Void, Void, Void, Void, Void, Void> of(A first, B second) {
return new AnimationParameters<> (first, second, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null);
}
public static <A, B, C> AnimationParameters<A, B, C, Void, Void, Void, Void, Void, Void, Void> of(A first, B second, C third) {
return new AnimationParameters<> (first, second, third, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null);
}
public static <A, B, C, D> AnimationParameters<A, B, C, D, Void, Void, Void, Void, Void, Void> of(A first, B second, C third, D fourth) {
return new AnimationParameters<> (first, second, third, fourth, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null);
}
public static <A, B, C, D, E> AnimationParameters<A, B, C, D, E, Void, Void, Void, Void, Void> of(A first, B second, C third, D fourth, E fifth) {
return new AnimationParameters<> (first, second, third, fourth, fifth, (Void)null, (Void)null, (Void)null, (Void)null, (Void)null);
}
public static <A, B, C, D, E, F> AnimationParameters<A, B, C, D, E, F, Void, Void, Void, Void> of(A first, B second, C third, D fourth, E fifth, F sixth) {
return new AnimationParameters<> (first, second, third, fourth, fifth, sixth, (Void)null, (Void)null, (Void)null, (Void)null);
}
public static <A, B, C, D, E, F, G> AnimationParameters<A, B, C, D, E, F, G, Void, Void, Void> of(A first, B second, C third, D fourth, E fifth, F sixth, G seventh) {
return new AnimationParameters<> (first, second, third, fourth, fifth, sixth, seventh, (Void)null, (Void)null, (Void)null);
}
public static <A, B, C, D, E, F, G, H> AnimationParameters<A, B, C, D, E, F, G, H, Void, Void> of(A first, B second, C third, D fourth, E fifth, F sixth, G seventh, H eighth) {
return new AnimationParameters<> (first, second, third, fourth, fifth, sixth, seventh, eighth, (Void)null, (Void)null);
}
public static <A, B, C, D, E, F, G, H, I> AnimationParameters<A, B, C, D, E, F, G, H, I, Void> of(A first, B second, C third, D fourth, E fifth, F sixth, G seventh, H eighth, I ninth) {
return new AnimationParameters<> (first, second, third, fourth, fifth, sixth, seventh, eighth, ninth, (Void)null);
}
public static <A, B, C, D, E, F, G, H, I, J> AnimationParameters<A, B, C, D, E, F, G, H, I, J> of(A first, B second, C third, D fourth, E fifth, F sixth, G seventh, H eighth, I ninth, J tenth) {
return new AnimationParameters<> (first, second, third, fourth, fifth, sixth, seventh, eighth, ninth, tenth);
}
public static <A, B, C, D, E, F, G, H, I, J, N> AnimationParameters<?, ?, ?, ?, ?, ?, ?, ?, ?, ?> addParameter(AnimationParameters<A, B, C, D, E, F, G, H, I, J> parameters, N newParam) {
if (parameters.first() == null) {
return new AnimationParameters<N, Void, Void, Void, Void, Void, Void, Void, Void, Void> (newParam, null, null, null, null, null, null, null, null, null);
} else if (parameters.second() == null) {
return new AnimationParameters<A, N, Void, Void, Void, Void, Void, Void, Void, Void> (parameters.first(), newParam, null, null, null, null, null, null, null, null);
} else if (parameters.third() == null) {
return new AnimationParameters<A, B, N, Void, Void, Void, Void, Void, Void, Void> (parameters.first(), parameters.second(), newParam, null, null, null, null, null, null, null);
} else if (parameters.fourth() == null) {
return new AnimationParameters<A, B, C, N, Void, Void, Void, Void, Void, Void> (parameters.first(), parameters.second(), parameters.third(), newParam, null, null, null, null, null, null);
} else if (parameters.fifth() == null) {
return new AnimationParameters<A, B, C, D, N, Void, Void, Void, Void, Void> (parameters.first(), parameters.second(), parameters.third(), parameters.fourth(), newParam, null, null, null, null, null);
} else if (parameters.sixth() == null) {
return new AnimationParameters<A, B, C, D, E, N, Void, Void, Void, Void> (parameters.first(), parameters.second(), parameters.third(), parameters.fourth(), parameters.fifth(), newParam, null, null, null, null);
} else if (parameters.seventh() == null) {
return new AnimationParameters<A, B, C, D, E, F, N, Void, Void, Void> (parameters.first(), parameters.second(), parameters.third(), parameters.fourth(), parameters.fifth(), parameters.sixth(), newParam, null, null, null);
} else if (parameters.eighth() == null) {
return new AnimationParameters<A, B, C, D, E, F, G, N, Void, Void> (parameters.first(), parameters.second(), parameters.third(), parameters.fourth(), parameters.fifth(), parameters.sixth(), parameters.seventh(), newParam, null, null);
} else if (parameters.ninth() == null) {
return new AnimationParameters<A, B, C, D, E, F, G, H, N, Void> (parameters.first(), parameters.second(), parameters.third(), parameters.fourth(), parameters.fifth(), parameters.sixth(), parameters.seventh(), parameters.eighth(), newParam, null);
} else if (parameters.tenth() == null) {
return new AnimationParameters<A, B, C, D, E, F, G, H, I, N> (parameters.first(), parameters.second(), parameters.third(), parameters.fourth(), parameters.fifth(), parameters.sixth(), parameters.seventh(), parameters.eighth(), parameters.ninth(), newParam);
}
throw new UnsupportedOperationException("Parameters are full!");
}
}

View File

@@ -0,0 +1,623 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.property;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import javax.annotation.Nullable;
import com.google.common.collect.Maps;
import com.google.gson.JsonElement;
import com.mojang.datafixers.util.Pair;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.JsonOps;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.tags.TagKey;
import net.minecraft.world.damagesource.DamageType;
import net.minecraft.world.phys.Vec3;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.TransformSheet;
import com.tiedup.remake.rig.anim.action.DataDrivenAnimationEvents;
import com.tiedup.remake.rig.anim.modifier.PlaybackSpeedModifierImpl;
import com.tiedup.remake.rig.anim.modifier.PlaybackTimeModifierImpl;
import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl;
import com.tiedup.remake.rig.anim.property.AnimationEvent.SimpleEvent;
import com.tiedup.remake.rig.anim.property.MoveCoordFunctions.MoveCoordGetter;
import com.tiedup.remake.rig.anim.property.MoveCoordFunctions.MoveCoordSetter;
import com.tiedup.remake.rig.anim.types.ActionAnimation;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.anim.types.LinkAnimation;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.physics.ik.InverseKinematicsSimulator.BakedInverseKinematicsDefinition;
import com.tiedup.remake.rig.physics.ik.InverseKinematicsSimulator.InverseKinematicsDefinition;
import com.tiedup.remake.rig.util.TimePairList;
import com.tiedup.remake.rig.math.ValueModifier;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
import com.tiedup.remake.rig.patch.item.CapabilityItem;
public abstract class AnimationProperty<T> {
private static final Map<String, AnimationProperty<?>> SERIALIZABLE_ANIMATION_PROPERTY_KEYS = Maps.newHashMap();
/**
* Phase 3 D1 — {@link LivingMotion} codec. {@code LivingMotion} is an
* {@link com.tiedup.remake.rig.util.ExtendableEnum} so its string form is
* the {@link #toString()} lowercased ; we deserialize via
* {@code ENUM_MANAGER.getOrThrow(String)} which throws if unknown.
*/
public static final Codec<LivingMotion> LIVING_MOTION_CODEC = Codec.STRING.flatXmap(
name -> {
try {
return DataResult.success(LivingMotion.ENUM_MANAGER.getOrThrow(name));
} catch (java.util.NoSuchElementException e) {
return DataResult.error(() -> "Unknown living motion: " + name);
}
},
motion -> DataResult.success(motion.toString().toLowerCase())
);
/**
* Phase 3 D1 — {@link TimePairList} codec. Authored as a flat list of
* floats that must have an even length (pairs of {@code begin, end}).
* Odd-length lists surface as a codec error (logged + property skipped).
*
* <p>JSON shape : {@code [0.0, 0.3, 0.6, 0.9]} → two pairs, {@code [0,0.3]}
* and {@code [0.6,0.9]}. We go through a float[] because {@code
* TimePairList.create} is varargs. Decoding via
* {@link TimePairList#create(float...)} surfaces the odd-count invariant
* via {@link IllegalArgumentException} — wrapped into a {@link DataResult}
* error.
*/
public static final Codec<TimePairList> TIME_PAIR_LIST_CODEC = Codec.FLOAT.listOf().flatXmap(
list -> {
if ((list.size() & 1) != 0) {
return DataResult.error(() -> "TimePairList must have an even number of floats, got " + list.size());
}
float[] arr = new float[list.size()];
for (int i = 0; i < arr.length; i++) {
arr[i] = list.get(i);
}
return DataResult.success(TimePairList.create(arr));
},
// Encode back : no public accessor on TimePairList's internal pairs,
// and the property pipeline is read-only from JSON (never re-serialized
// to disk) — we therefore refuse encoding. If round-trip becomes a
// requirement, expose TimePairList#asFloatArray() first.
tpl -> DataResult.error(() -> "TimePairList encoding is not supported (read-only datapack property)")
);
@SuppressWarnings("unchecked")
public static <T> AnimationProperty<T> getSerializableProperty(String name) {
if (!SERIALIZABLE_ANIMATION_PROPERTY_KEYS.containsKey(name)) {
throw new IllegalStateException("No property key named " + name);
}
return (AnimationProperty<T>) SERIALIZABLE_ANIMATION_PROPERTY_KEYS.get(name);
}
private final Codec<T> codecs;
private final String name;
public AnimationProperty(String name, @Nullable Codec<T> codecs) {
this.codecs = codecs;
this.name = name;
if (name != null) {
if (SERIALIZABLE_ANIMATION_PROPERTY_KEYS.containsKey(name)) {
throw new IllegalStateException("Animation property key " + name + " is already registered.");
}
SERIALIZABLE_ANIMATION_PROPERTY_KEYS.put(name, this);
}
}
public AnimationProperty(String name) {
this(name, null);
}
public T parseFrom(JsonElement e) {
return this.codecs.parse(JsonOps.INSTANCE, e).resultOrPartial((errm) -> TiedUpRigConstants.LOGGER.warn("Failed to parse property " + this.name + " because of " + errm)).orElseThrow();
}
public Codec<T> getCodecs() {
return this.codecs;
}
public static class StaticAnimationProperty<T> extends AnimationProperty<T> {
public StaticAnimationProperty(String rl, @Nullable Codec<T> codecs) {
super(rl, codecs);
}
public StaticAnimationProperty() {
this(null, null);
}
/**
* Events that are fired in every tick.
*
* <p>Phase 3 D2 — serializable from a datapack JSON
* {@code "tick_events"} block. Each entry is either a &laquo;time&raquo;
* event ({@code {"frame":0.15, "actions":[...]}}) or a
* &laquo;period&raquo; event
* ({@code {"start":0.0, "end":1.0, "actions":[...]}}). See
* {@link DataDrivenAnimationEvents#TICK_EVENTS_CODEC}.
*/
public static final StaticAnimationProperty<List<AnimationEvent<?, ?>>> TICK_EVENTS = new StaticAnimationProperty<List<AnimationEvent<?, ?>>> (
"tick_events",
DataDrivenAnimationEvents.TICK_EVENTS_CODEC
);
/**
* Events that are fired when the animation starts.
*
* <p>Phase 3 D2 — serializable from a datapack JSON {@code "on_begin"}
* block. Each entry is an &laquo;action list&raquo;
* ({@code [{"type":"tiedup:play_sound", ...}, ...]}) or a full object
* ({@code {"actions":[...], "side":"SERVER"}}). See
* {@link DataDrivenAnimationEvents#BEGIN_END_EVENTS_CODEC}.
*/
public static final StaticAnimationProperty<List<SimpleEvent<?>>> ON_BEGIN_EVENTS = new StaticAnimationProperty<List<SimpleEvent<?>>> (
"on_begin",
DataDrivenAnimationEvents.BEGIN_END_EVENTS_CODEC
);
/**
* Events that are fired when the animation ends.
*
* <p>Phase 3 D2 — serializable from a datapack JSON {@code "on_end"}
* block. Same shape as {@link #ON_BEGIN_EVENTS}.
*/
public static final StaticAnimationProperty<List<SimpleEvent<?>>> ON_END_EVENTS = new StaticAnimationProperty<List<SimpleEvent<?>>> (
"on_end",
DataDrivenAnimationEvents.BEGIN_END_EVENTS_CODEC
);
/**
* An event triggered when entity changes an item in hand.
*
* <p>Phase 3 D1 — Category C, SKIPPED. The item-change event is a
* combat/equipment hook from Epic Fight upstream ; no bondage pipeline
* consumer reads this property today. Re-enable (register a codec) when
* weapon/equipment reactive animations are reintroduced.
*/
public static final StaticAnimationProperty<SimpleEvent<AnimationEvent.E2<CapabilityItem, CapabilityItem>>> ON_ITEM_CHANGE_EVENT = new StaticAnimationProperty<SimpleEvent<AnimationEvent.E2<CapabilityItem, CapabilityItem>>> ();
/**
* You can modify the playback speed of the animation.
*
* <p>Phase 3 D1 — serializable via the
* {@link PlaybackSpeedModifierImpl#CODEC} dispatch codec (Category B).
* JSON example :
* <pre>{@code
* "play_speed_modifier": { "type": "tiedup:constant_factor", "factor": 0.5 }
* }</pre>
* The upcast {@code PlaybackSpeedModifierImpl → PlaybackSpeedModifier}
* is implicit (sub-interface) so the property stays typed as the wider
* functional type for backward-compat with existing consumers in
* {@link com.tiedup.remake.rig.anim.AnimationPlayer#tick} and
* {@link com.tiedup.remake.rig.anim.client.AnimationSubFileReader}.
*/
public static final StaticAnimationProperty<PlaybackSpeedModifier> PLAY_SPEED_MODIFIER = new StaticAnimationProperty<PlaybackSpeedModifier> (
"play_speed_modifier",
PlaybackSpeedModifierImpl.CODEC.xmap(
impl -> (PlaybackSpeedModifier) impl,
base -> {
if (base instanceof PlaybackSpeedModifierImpl impl) return impl;
throw new IllegalStateException("PlaybackSpeedModifier value is not a serializable impl: " + base);
}
)
);
/**
* You can modify the elapsed playback time of the animation.
*
* <p>Phase 3 D1 — serializable via the
* {@link PlaybackTimeModifierImpl#CODEC} dispatch codec (Category B).
* JSON example :
* <pre>{@code
* "elapsed_time_modifier": { "type": "tiedup:loop_section",
* "loop_start": 0.3, "loop_end": 0.8 }
* }</pre>
*/
public static final StaticAnimationProperty<PlaybackTimeModifier> ELAPSED_TIME_MODIFIER = new StaticAnimationProperty<PlaybackTimeModifier> (
"elapsed_time_modifier",
PlaybackTimeModifierImpl.CODEC.xmap(
impl -> (PlaybackTimeModifier) impl,
base -> {
if (base instanceof PlaybackTimeModifierImpl impl) return impl;
throw new IllegalStateException("PlaybackTimeModifier value is not a serializable impl: " + base);
}
)
);
/**
* This property will be called both in client and server when modifying the pose.
*
* <p>Phase 3 D1 — serializable via the
* {@link PoseModifierImpl#CODEC} dispatch codec (Category B). Key
* artist unlock for bondage : per-joint nudges can be authored
* from JSON :
* <pre>{@code
* "pose_modifier": { "type": "tiedup:chain",
* "modifiers": [
* { "type": "tiedup:joint_rotation_offset", "joint": "upper_arm_left", "pitch": 15.0 },
* { "type": "tiedup:joint_translation_offset", "joint": "hand_right", "y": -0.05 }
* ] }
* }</pre>
*/
public static final StaticAnimationProperty<PoseModifier> POSE_MODIFIER = new StaticAnimationProperty<PoseModifier> (
"pose_modifier",
PoseModifierImpl.CODEC.xmap(
impl -> (PoseModifier) impl,
base -> {
if (base instanceof PoseModifierImpl impl) return impl;
throw new IllegalStateException("PoseModifier value is not a serializable impl: " + base);
}
)
);
/**
* Fix the head rotation to the player's body rotation.
*
* <p>Phase 3 D1 — Category A, trivial boolean codec.
*/
public static final StaticAnimationProperty<Boolean> FIXED_HEAD_ROTATION = new StaticAnimationProperty<Boolean> (
"fixed_head_rotation",
Codec.BOOL
);
/**
* Defines static animations as link animation when the animation is followed by a specific animation.
*
* <p>Phase 3 D1 — Category D, SKIPPED. The map value is an
* {@link AnimationAccessor} which is an internal registry handle (not a
* data-describable object) ; resolving accessors from ids requires the
* {@link com.tiedup.remake.rig.anim.AnimationManager} to already be
* populated, which is only true after full resource-pack load. The
* existing sub-file reader path handles transitions correctly — moving
* the parse here would create a chicken-and-egg bootstrapping issue.
*/
public static final StaticAnimationProperty<Map<ResourceLocation, AnimationAccessor<? extends StaticAnimation>>> TRANSITION_ANIMATIONS_FROM = new StaticAnimationProperty<Map<ResourceLocation, AnimationAccessor<? extends StaticAnimation>>> ();
/**
* Defines static animations as link animation when the animation is following a specific animation.
*
* <p>Phase 3 D1 — Category D, SKIPPED (same reason as
* {@link #TRANSITION_ANIMATIONS_FROM}).
*/
public static final StaticAnimationProperty<Map<ResourceLocation, AnimationAccessor<? extends StaticAnimation>>> TRANSITION_ANIMATIONS_TO = new StaticAnimationProperty<Map<ResourceLocation, AnimationAccessor<? extends StaticAnimation>>> ();
/**
* Disable physics while playing animation
*/
public static final StaticAnimationProperty<Boolean> NO_PHYSICS = new StaticAnimationProperty<Boolean> ("no_physics", Codec.BOOL);
/**
* Inverse kinematics information.
*
* <p>Phase 3 D1 — Category D, SKIPPED. {@link InverseKinematicsDefinition}
* holds complex baked state (joint chains, pole targets, constraint
* weights) that is authored via a dedicated IK definition file, not
* via the animation properties block. See the sub-file reader in
* {@link com.tiedup.remake.rig.anim.client.AnimationSubFileReader}.
*/
public static final StaticAnimationProperty<List<InverseKinematicsDefinition>> IK_DEFINITION = new StaticAnimationProperty<List<InverseKinematicsDefinition>> ();
/**
* This property automatically baked when animation is loaded.
*
* <p>Phase 3 D1 — Category D, SKIPPED. Never authored in JSON : this
* slot is populated at load time by
* {@link com.tiedup.remake.rig.anim.types.StaticAnimation#loadAnimation}
* from {@link #IK_DEFINITION} which is then cleared.
*/
public static final StaticAnimationProperty<List<BakedInverseKinematicsDefinition>> BAKED_IK_DEFINITION = new StaticAnimationProperty<List<BakedInverseKinematicsDefinition>> ();
/**
* This property reset the entity's living motion.
*
* <p>Phase 3 D1 — Category A, codec uses the shared
* {@link AnimationProperty#LIVING_MOTION_CODEC} which resolves against
* {@link LivingMotion#ENUM_MANAGER}. JSON example :
* <pre>{@code "reset_living_motion": "idle"}</pre>
*/
public static final StaticAnimationProperty<LivingMotion> RESET_LIVING_MOTION = new StaticAnimationProperty<LivingMotion> (
"reset_living_motion",
LIVING_MOTION_CODEC
);
}
public static class ActionAnimationProperty<T> extends StaticAnimationProperty<T> {
public ActionAnimationProperty(String rl, @Nullable Codec<T> codecs) {
super(rl, codecs);
}
public ActionAnimationProperty() {
this(null, null);
}
/**
* This property will set the entity's delta movement to (0, 0, 0) at the beginning of an animation if true.
*/
public static final ActionAnimationProperty<Boolean> STOP_MOVEMENT = new ActionAnimationProperty<Boolean> ("stop_movements", Codec.BOOL);
/**
* This property will set the entity's delta movement to (0, 0, 0) at the beginning of an animation if true.
*/
public static final ActionAnimationProperty<Boolean> REMOVE_DELTA_MOVEMENT = new ActionAnimationProperty<Boolean> ("revmoe_delta_move", Codec.BOOL);
/**
* This property will move entity's coord also as y axis if true.
* Don't recommend using this property because it's old system. Use the coord joint instead.
*/
public static final ActionAnimationProperty<Boolean> MOVE_VERTICAL = new ActionAnimationProperty<Boolean> ("move_vertically", Codec.BOOL);
/**
* This property determines the time of entity not affected by gravity.
*
* <p>Phase 3 D1 — Category A, codec uses the shared
* {@link AnimationProperty#TIME_PAIR_LIST_CODEC}. JSON example :
* <pre>{@code "no_gravity_time": [0.1, 0.4, 0.6, 0.9]}</pre>
* (two no-gravity windows : [0.1..0.4] and [0.6..0.9]).
*/
public static final ActionAnimationProperty<TimePairList> NO_GRAVITY_TIME = new ActionAnimationProperty<TimePairList> (
"no_gravity_time",
TIME_PAIR_LIST_CODEC
);
/**
* Coord of action animation.
*
* <p>Phase 3 D1 — Category C, SKIPPED. {@link TransformSheet} is
* populated from the animation's baked keyframes by
* {@link com.tiedup.remake.rig.asset.JsonAssetLoader} at load time
* (not authored in the properties block). Exposing a codec here would
* double-write an already-computed value.
*/
public static final ActionAnimationProperty<TransformSheet> COORD = new ActionAnimationProperty<TransformSheet> ();
/**
* This property determines whether to move the entity in link animation or not.
*/
public static final ActionAnimationProperty<Boolean> MOVE_ON_LINK = new ActionAnimationProperty<Boolean> ("move_during_link", Codec.BOOL);
/**
* You can specify the coord movement time in action animation. Must be registered in order of time.
*
* <p>Phase 3 D1 — Category A, codec uses
* {@link AnimationProperty#TIME_PAIR_LIST_CODEC}.
*/
public static final ActionAnimationProperty<TimePairList> MOVE_TIME = new ActionAnimationProperty<TimePairList> (
"move_time",
TIME_PAIR_LIST_CODEC
);
/**
* Set the dynamic coordinates of {@link ActionAnimation}. Called before creation of {@link LinkAnimation}.
*
* <p>Phase 3 D1 — Category C, SKIPPED. {@link MoveCoordSetter} is a
* combat/action-movement hook from Epic Fight upstream ; the bondage
* pipeline never authors these at the datapack level (coord work is
* done via coord joints and the sub-file reader).
*/
public static final ActionAnimationProperty<MoveCoordSetter> COORD_SET_BEGIN = new ActionAnimationProperty<MoveCoordSetter> ();
/**
* Set the dynamic coordinates of {@link ActionAnimation}.
*
* <p>Phase 3 D1 — Category C, SKIPPED (same reason as
* {@link #COORD_SET_BEGIN}).
*/
public static final ActionAnimationProperty<MoveCoordSetter> COORD_SET_TICK = new ActionAnimationProperty<MoveCoordSetter> ();
/**
* Set the coordinates of action animation.
*
* <p>Phase 3 D1 — Category C, SKIPPED (same reason as
* {@link #COORD_SET_BEGIN}).
*/
public static final ActionAnimationProperty<MoveCoordGetter> COORD_GET = new ActionAnimationProperty<MoveCoordGetter> ();
/**
* This property determines if the speed effect will increase the move distance.
*/
public static final ActionAnimationProperty<Boolean> AFFECT_SPEED = new ActionAnimationProperty<Boolean> ("move_speed_based_distance", Codec.BOOL);
/**
* This property determines if the movement can be canceled by {@link LivingEntityPatch#shouldBlockMoving()}.
*/
public static final ActionAnimationProperty<Boolean> CANCELABLE_MOVE = new ActionAnimationProperty<Boolean> ("cancellable_movement", Codec.BOOL);
/**
* Death animations won't be played if this value is true
*/
public static final ActionAnimationProperty<Boolean> IS_DEATH_ANIMATION = new ActionAnimationProperty<Boolean> ("is_death", Codec.BOOL);
/**
* This property determines the update time of {@link ActionAnimationProperty#COORD_SET_TICK}. If the current time out of the bound it uses {@link MoveCoordFunctions#RAW_COORD and MoveCoordFunctions#DIFF_FROM_PREV_COORD}}
*
* <p>Phase 3 D1 — Category A, codec uses
* {@link AnimationProperty#TIME_PAIR_LIST_CODEC}. Still serialized
* despite being tied to {@link #COORD_SET_TICK} (Category C) because
* the underlying datatype is data and authoring the time windows
* without the setter is still legal (it degrades to a no-op, cheap).
*/
public static final ActionAnimationProperty<TimePairList> COORD_UPDATE_TIME = new ActionAnimationProperty<TimePairList> (
"coord_update_time",
TIME_PAIR_LIST_CODEC
);
/**
* This property determines if it reset the player basic attack combo counter or not.
* RIG : BasicAttack strippé, flag conservé pour compat JSON.
*/
public static final ActionAnimationProperty<Boolean> RESET_PLAYER_COMBO_COUNTER = new ActionAnimationProperty<Boolean> ("reset_combo_attack_counter", Codec.BOOL);
/**
* Provide destination of action animation {@link MoveCoordFunctions}.
*
* <p>Phase 3 D1 — Category C, SKIPPED. Combat-movement hook ; the
* bondage pipeline would need a whole new destination-provider
* registry to expose this via JSON, and there is no artist use-case
* today.
*/
public static final ActionAnimationProperty<DestLocationProvider> DEST_LOCATION_PROVIDER = new ActionAnimationProperty<DestLocationProvider> ();
/**
* Provide y rotation of entity {@link MoveCoordFunctions}.
*
* <p>Phase 3 D1 — Category C, SKIPPED (combat-rotation hook).
*/
public static final ActionAnimationProperty<YRotProvider> ENTITY_YROT_PROVIDER = new ActionAnimationProperty<YRotProvider> ();
/**
* Provide y rotation of tracing coord {@link MoveCoordFunctions}.
*
* <p>Phase 3 D1 — Category C, SKIPPED (combat-rotation hook).
*/
public static final ActionAnimationProperty<YRotProvider> DEST_COORD_YROT_PROVIDER = new ActionAnimationProperty<YRotProvider> ();
/**
* Decides the index of start key frame for coord transform, See also with {@link MoveCoordFunctions#TRACE_ORIGIN_AS_DESTINATION}.
*
* <p>Phase 3 D1 — Category A, trivial int codec.
*/
public static final ActionAnimationProperty<Integer> COORD_START_KEYFRAME_INDEX = new ActionAnimationProperty<Integer> (
"coord_start_keyframe_index",
Codec.INT
);
/**
* Decides the index of destination key frame for coord transform, See also with {@link MoveCoordFunctions#TRACE_ORIGIN_AS_DESTINATION}.
*
* <p>Phase 3 D1 — Category A, trivial int codec.
*/
public static final ActionAnimationProperty<Integer> COORD_DEST_KEYFRAME_INDEX = new ActionAnimationProperty<Integer> (
"coord_dest_keyframe_index",
Codec.INT
);
/**
* Determines if an entity should look where a camera is looking at the beginning of an animation (player only)
*/
public static final ActionAnimationProperty<Boolean> SYNC_CAMERA = new ActionAnimationProperty<Boolean> ("sync_camera", Codec.BOOL);
}
public static class AttackAnimationProperty<T> extends ActionAnimationProperty<T> {
public AttackAnimationProperty(String rl, @Nullable Codec<T> codecs) {
super(rl, codecs);
}
public AttackAnimationProperty() {
this(null, null);
}
/**
* This property determines if the animation has a fixed amount of move distance not depending on the distance between attacker and target entity
*/
public static final AttackAnimationProperty<Boolean> FIXED_MOVE_DISTANCE = new AttackAnimationProperty<Boolean> ("fixed_movement_distance", Codec.BOOL);
/**
* This property determines how much the playback speed will be affected by entity's attack speed.
*/
public static final AttackAnimationProperty<Float> ATTACK_SPEED_FACTOR = new AttackAnimationProperty<Float> ("attack_speed_factor", Codec.FLOAT);
/**
* This property determines the basis of the speed factor. Default basis is the total animation time.
*/
public static final AttackAnimationProperty<Float> BASIS_ATTACK_SPEED = new AttackAnimationProperty<Float> ("basis_attack_speed", Codec.FLOAT);
/**
* This property adds interpolated colliders when detecting colliding entities by using @MultiCollider.
*/
public static final AttackAnimationProperty<Integer> EXTRA_COLLIDERS = new AttackAnimationProperty<Integer> ("extra_colliders", Codec.INT);
/**
* This property determines a minimal distance between attacker and target.
*/
public static final AttackAnimationProperty<Float> REACH = new AttackAnimationProperty<Float> ("reach", Codec.FLOAT);
}
/**
* Combat-phase properties for attack animations.
*
* <p>Phase 3 D1 — entire class body is Category C (combat-only, EF
* legacy). The constructor no longer registers properties in the shared
* dispatch map (see the commented {@code super(...)} call below) — this
* was deliberately neutered in a prior audit because the bondage pipeline
* never reads {@link AttackPhaseProperty} values. The four
* {@link ValueModifier} constants kept their names purely for static
* reference by surviving combat-adjacent code paths ; the sound/tag/
* location properties are forever null-codec. Do not add codecs here
* until a combat feature is reintroduced (see V3-REW-11+ in the
* roadmap) — instead, resurrect the commented {@code super} call first
* so the dispatch map picks them up consistently.
*/
public static class AttackPhaseProperty<T> {
public AttackPhaseProperty(String rl, @Nullable Codec<? extends T> codecs) {
//super(rl, codecs);
}
public AttackPhaseProperty() {
//this(null, null);
}
public static final AttackPhaseProperty<ValueModifier> MAX_STRIKES_MODIFIER = new AttackPhaseProperty<ValueModifier> ("max_strikes", ValueModifier.CODEC);
public static final AttackPhaseProperty<ValueModifier> DAMAGE_MODIFIER = new AttackPhaseProperty<ValueModifier> ("damage", ValueModifier.CODEC);
public static final AttackPhaseProperty<ValueModifier> ARMOR_NEGATION_MODIFIER = new AttackPhaseProperty<ValueModifier> ("armor_negation", ValueModifier.CODEC);
public static final AttackPhaseProperty<ValueModifier> IMPACT_MODIFIER = new AttackPhaseProperty<ValueModifier> ("impact", ValueModifier.CODEC);
// RIG : EXTRA_DAMAGE, STUN_TYPE, PARTICLE strippés (combat).
public static final AttackPhaseProperty<SoundEvent> SWING_SOUND = new AttackPhaseProperty<SoundEvent> ();
public static final AttackPhaseProperty<SoundEvent> HIT_SOUND = new AttackPhaseProperty<SoundEvent> ();
public static final AttackPhaseProperty<Set<TagKey<DamageType>>> SOURCE_TAG = new AttackPhaseProperty<Set<TagKey<DamageType>>> ();
public static final AttackPhaseProperty<Function<LivingEntityPatch<?>, Vec3>> SOURCE_LOCATION_PROVIDER = new AttackPhaseProperty<Function<LivingEntityPatch<?>, Vec3>> ();
}
@FunctionalInterface
public interface Registerer<T> {
void register(Map<AnimationProperty<T>, Object> properties, AnimationProperty<T> key, T object);
}
/******************************
* Static Animation Properties
******************************/
/**
* elapsedTime contains partial tick
*/
@FunctionalInterface
public interface PoseModifier {
void modify(DynamicAnimation self, Pose pose, LivingEntityPatch<?> entitypatch, float elapsedTime, float partialTick);
}
@FunctionalInterface
public interface PlaybackSpeedModifier {
float modify(DynamicAnimation self, LivingEntityPatch<?> entitypatch, float speed, float prevElapsedTime, float elapsedTime);
}
@FunctionalInterface
public interface PlaybackTimeModifier {
Pair<Float, Float> modify(DynamicAnimation self, LivingEntityPatch<?> entitypatch, float speed, float prevElapsedTime, float elapsedTime);
}
@FunctionalInterface
public interface DestLocationProvider {
Vec3 get(DynamicAnimation self, LivingEntityPatch<?> entitypatch);
}
@FunctionalInterface
public interface YRotProvider {
float get(DynamicAnimation self, LivingEntityPatch<?> entitypatch);
}
}

View File

@@ -0,0 +1,480 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.property;
import java.util.Optional;
import net.minecraft.core.BlockPos;
import net.minecraft.tags.BlockTags;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.ai.attributes.AttributeInstance;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.enchantment.EnchantmentHelper;
import net.minecraft.world.item.enchantment.Enchantments;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.Vec3;
import com.tiedup.remake.rig.anim.AnimationPlayer;
import com.tiedup.remake.rig.armature.JointTransform;
import com.tiedup.remake.rig.anim.Keyframe;
import com.tiedup.remake.rig.anim.SynchedAnimationVariableKeys;
import com.tiedup.remake.rig.anim.TransformSheet;
import com.tiedup.remake.rig.anim.property.AnimationProperty.ActionAnimationProperty;
import com.tiedup.remake.rig.anim.property.AnimationProperty.AttackAnimationProperty;
import com.tiedup.remake.rig.anim.property.AnimationProperty.DestLocationProvider;
import com.tiedup.remake.rig.anim.property.AnimationProperty.YRotProvider;
import com.tiedup.remake.rig.anim.types.ActionAnimation;
import com.tiedup.remake.rig.anim.types.AttackAnimation;
import com.tiedup.remake.rig.anim.types.AttackAnimation.Phase;
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
import com.tiedup.remake.rig.anim.types.EntityState;
// RIG : GrapplingAttackAnimation strippé (combat grappling hook), ref javadoc conservée
import com.tiedup.remake.rig.math.MathUtils;
import com.tiedup.remake.rig.math.OpenMatrix4f;
import com.tiedup.remake.rig.math.Vec3f;
import com.tiedup.remake.rig.math.Vec4f;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
import com.tiedup.remake.rig.patch.MobPatch;
/**
* Registry complet des constantes {@code MoveCoord*} consommées par les datapacks EF tiers
* via réflection ({@code StaticFieldArgument}). Ne pas purger individuellement sans couper
* le support datapack — l'absence d'un nom au runtime crash le chargement JSON.
*/
public class MoveCoordFunctions {
/**
* Defines a function that how to interpret given coordinate and return the movement vector from entity's current position
*/
@FunctionalInterface
public interface MoveCoordGetter {
Vec3f get(DynamicAnimation animation, LivingEntityPatch<?> entitypatch, TransformSheet transformSheet, float prevElapsedTime, float elapsedTime);
}
/**
* Defines a function that how to build the coordinate of {@link ActionAnimation}
*/
@FunctionalInterface
public interface MoveCoordSetter {
void set(DynamicAnimation animation, LivingEntityPatch<?> entitypatch, TransformSheet transformSheet);
}
/**
* MODEL_COORD
* - Calculates the coordinate gap between previous and current elapsed time
* - the coordinate doesn't reflect the entity's rotation
*/
public static final MoveCoordGetter MODEL_COORD = (animation, entitypatch, coord, prevElapsedTime, elapsedTime) -> {
LivingEntity livingentity = entitypatch.getOriginal();
JointTransform oJt = coord.getInterpolatedTransform(prevElapsedTime);
JointTransform jt = coord.getInterpolatedTransform(elapsedTime);
Vec4f prevpos = new Vec4f(oJt.translation());
Vec4f currentpos = new Vec4f(jt.translation());
OpenMatrix4f rotationTransform = entitypatch.getModelMatrix(1.0F).removeTranslation().removeScale();
OpenMatrix4f localTransform = entitypatch.getArmature().searchJointByName("Root").getLocalTransform().removeTranslation();
rotationTransform.mulBack(localTransform);
currentpos.transform(rotationTransform);
prevpos.transform(rotationTransform);
boolean hasNoGravity = entitypatch.getOriginal().isNoGravity();
boolean moveVertical = animation.getProperty(ActionAnimationProperty.MOVE_VERTICAL).orElse(false) || animation.getProperty(ActionAnimationProperty.COORD).isPresent();
float dx = prevpos.x - currentpos.x;
float dy = (moveVertical || hasNoGravity) ? currentpos.y - prevpos.y : 0.0F;
float dz = prevpos.z - currentpos.z;
dx = Math.abs(dx) > 0.0001F ? dx : 0.0F;
dz = Math.abs(dz) > 0.0001F ? dz : 0.0F;
BlockPos blockpos = new BlockPos.MutableBlockPos(livingentity.getX(), livingentity.getBoundingBox().minY - 1.0D, livingentity.getZ());
BlockState blockState = livingentity.level().getBlockState(blockpos);
AttributeInstance movementSpeed = livingentity.getAttribute(Attributes.MOVEMENT_SPEED);
boolean soulboost = blockState.is(BlockTags.SOUL_SPEED_BLOCKS) && EnchantmentHelper.getEnchantmentLevel(Enchantments.SOUL_SPEED, livingentity) > 0;
float speedFactor = (float)(soulboost ? 1.0D : livingentity.level().getBlockState(blockpos).getBlock().getSpeedFactor());
float moveMultiplier = (float)(animation.getProperty(ActionAnimationProperty.AFFECT_SPEED).orElse(false) ? (movementSpeed.getValue() / movementSpeed.getBaseValue()) : 1.0F);
return new Vec3f(dx * moveMultiplier * speedFactor, dy, dz * moveMultiplier * speedFactor);
};
/**
* WORLD_COORD
* - Calculates the coordinate of current elapsed time
* - the coordinate is the world position
*/
public static final MoveCoordGetter WORLD_COORD = (animation, entitypatch, coord, prevElapsedTime, elapsedTime) -> {
JointTransform jt = coord.getInterpolatedTransform(elapsedTime);
Vec3 entityPos = entitypatch.getOriginal().position();
return jt.translation().copy().sub(Vec3f.fromDoubleVector(entityPos));
};
/**
* ATTACHED
* Calculates the relative position of a grappling target entity.
* - especially used by {@link GrapplingAttackAnimation}
* - read by {@link MoveCoordFunctions#RAW_COORD}
*/
public static final MoveCoordGetter ATTACHED = (animation, entitypatch, coord, prevElapsedTime, elapsedTime) -> {
LivingEntity target = entitypatch.getGrapplingTarget();
if (target == null) {
return MODEL_COORD.get(animation, entitypatch, coord, prevElapsedTime, elapsedTime);
}
TransformSheet rootCoord = animation.getCoord();
LivingEntity livingentity = entitypatch.getOriginal();
Vec3f model = rootCoord.getInterpolatedTransform(elapsedTime).translation();
Vec3f world = OpenMatrix4f.transform3v(OpenMatrix4f.createRotatorDeg(-target.getYRot(), Vec3f.Y_AXIS), model, null);
Vec3f dst = Vec3f.fromDoubleVector(target.position()).add(world);
entitypatch.setYRot(Mth.wrapDegrees(target.getYRot() + 180.0F));
return dst.sub(Vec3f.fromDoubleVector(livingentity.position()));
};
/******************************************************
* Action animation properties
******************************************************/
/**
* No destination
*/
public static final DestLocationProvider NO_DEST = (DynamicAnimation self, LivingEntityPatch<?> entitypatch) -> {
return null;
};
/**
* Location of the current attack target
*/
public static final DestLocationProvider ATTACK_TARGET_LOCATION = (DynamicAnimation self, LivingEntityPatch<?> entitypatch) -> {
return entitypatch.getTarget() == null ? null : entitypatch.getTarget().position();
};
/**
* Location set by Animation Variable
*/
public static final DestLocationProvider SYNCHED_DEST_VARIABLE = (DynamicAnimation self, LivingEntityPatch<?> entitypatch) -> {
return entitypatch.getAnimator().getVariables().getOrDefault(SynchedAnimationVariableKeys.DESTINATION.get(), self.getRealAnimation());
};
/**
* Location of current attack target that is provided by animation variable
*/
public static final DestLocationProvider SYNCHED_TARGET_ENTITY_LOCATION_VARIABLE = (DynamicAnimation self, LivingEntityPatch<?> entitypatch) -> {
Optional<Integer> targetEntityId = entitypatch.getAnimator().getVariables().get(SynchedAnimationVariableKeys.TARGET_ENTITY.get(), self.getRealAnimation());
if (targetEntityId.isPresent()) {
Entity entity = entitypatch.getOriginal().level().getEntity(targetEntityId.get());
if (entity != null) {
return entity.position();
}
}
return entitypatch.getOriginal().position();
};
/**
* Looking direction from an action beginning location to a destination location
*/
public static final YRotProvider LOOK_DEST = (DynamicAnimation self, LivingEntityPatch<?> entitypatch) -> {
Vec3 destLocation = self.getRealAnimation().get().getProperty(ActionAnimationProperty.DEST_LOCATION_PROVIDER).orElse(NO_DEST).get(self, entitypatch);
if (destLocation != null) {
Vec3 startInWorld = entitypatch.getAnimator().getVariables().getOrDefault(ActionAnimation.BEGINNING_LOCATION, self.getRealAnimation());
if (startInWorld == null) {
startInWorld = entitypatch.getOriginal().position();
}
Vec3 toDestWorld = destLocation.subtract(startInWorld);
float yRot = (float)Mth.wrapDegrees(MathUtils.getYRotOfVector(toDestWorld));
float entityYRot = MathUtils.rotlerp(entitypatch.getYRot(), yRot, entitypatch.getYRotLimit());
return entityYRot;
} else {
return entitypatch.getYRot();
}
};
/**
* Rotate an entity toward target for attack animations
*/
public static final YRotProvider MOB_ATTACK_TARGET_LOOK = (DynamicAnimation self, LivingEntityPatch<?> entitypatch) -> {
if (!entitypatch.isLogicalClient() && entitypatch instanceof MobPatch<?> mobpatch) {
AnimationPlayer player = entitypatch.getAnimator().getPlayerFor(self.getAccessor());
float elapsedTime = player.getElapsedTime();
EntityState state = self.getState(entitypatch, elapsedTime);
if (state.getLevel() == 1 && !state.turningLocked()) {
mobpatch.getOriginal().getNavigation().stop();
entitypatch.getOriginal().attackAnim = 2;
LivingEntity target = entitypatch.getTarget();
if (target != null) {
float currentYRot = Mth.wrapDegrees(entitypatch.getOriginal().getYRot());
float clampedYRot = entitypatch.getYRotDeltaTo(target);
return currentYRot + clampedYRot;
}
}
}
return entitypatch.getYRot();
};
/******************************************************
* MoveCoordSetters
* Consider that getAnimationPlayer(self) returns null at the beginning.
******************************************************/
/**
* Sets a raw animation coordinate as action animation's coord
* - read by {@link MoveCoordFunctions#MODEL_COORD}
*/
public static final MoveCoordSetter RAW_COORD = (self, entitypatch, transformSheet) -> {
transformSheet.readFrom(self.getCoord().copyAll());
};
/**
* Sets a raw animation coordinate multiplied by entity's pitch as action animation's coord
* - read by {@link MoveCoordFunctions#MODEL_COORD}
*/
public static final MoveCoordSetter RAW_COORD_WITH_X_ROT = (self, entitypatch, transformSheet) -> {
TransformSheet sheet = self.getCoord().copyAll();
float xRot = entitypatch.getOriginal().getXRot();
for (Keyframe kf : sheet.getKeyframes()) {
kf.transform().translation().rotate(-xRot, Vec3f.X_AXIS);
}
transformSheet.readFrom(sheet);
};
/**
* Trace the origin point(0, 0, 0) in blender coord system as the destination
* - specify the {@link ActionAnimationProperty#DEST_LOCATION_PROVIDER} or it will act as {@link MoveCoordFunctions#RAW_COORD}.
* - the first keyframe's location is where the entity is in world
* - you can specify target frame distance by {@link ActionAnimationProperty#COORD_START_KEYFRAME_INDEX}, {@link ActionAnimationProperty#COORD_DEST_KEYFRAME_INDEX}
* - the coord after destination frame will not be scaled or rotated by distance gap between start location and end location in world coord
* - entity's x rotation is not affected by this coord function
* - entity's y rotation is the direction toward a destination, or you can give specific rotation value by {@link ActionAnimation#ENTITY_Y_ROT AnimationProperty}
* - no movements in link animation
* - read by {@link MoveCoordFunctions#WORLD_COORD}
*/
public static final MoveCoordSetter TRACE_ORIGIN_AS_DESTINATION = (self, entitypatch, transformSheet) -> {
if (self.isLinkAnimation()) {
transformSheet.readFrom(TransformSheet.EMPTY_SHEET_PROVIDER.apply(entitypatch.getOriginal().position()));
return;
}
Keyframe[] coordKeyframes = self.getCoord().getKeyframes();
int startFrame = self.getRealAnimation().get().getProperty(ActionAnimationProperty.COORD_START_KEYFRAME_INDEX).orElse(0);
int destFrame = self.getRealAnimation().get().getProperty(ActionAnimationProperty.COORD_DEST_KEYFRAME_INDEX).orElse(coordKeyframes.length - 1);
Vec3 destInWorld = self.getRealAnimation().get().getProperty(ActionAnimationProperty.DEST_LOCATION_PROVIDER).orElse(NO_DEST).get(self, entitypatch);
if (destInWorld == null) {
Vec3f beginningPosition = coordKeyframes[0].transform().translation().copy().multiply(1.0F, 1.0F, -1.0F);
beginningPosition.rotate(-entitypatch.getYRot(), Vec3f.Y_AXIS);
destInWorld = entitypatch.getOriginal().position().add(-beginningPosition.x, -beginningPosition.y, -beginningPosition.z);
}
Vec3 startInWorld = entitypatch.getAnimator().getVariables().getOrDefault(ActionAnimation.BEGINNING_LOCATION, self.getRealAnimation());
if (startInWorld == null) {
startInWorld = entitypatch.getOriginal().position();
}
Vec3 toTargetInWorld = destInWorld.subtract(startInWorld);
float yRot = (float)Mth.wrapDegrees(MathUtils.getYRotOfVector(toTargetInWorld));
Optional<YRotProvider> destYRotProvider = self.getRealAnimation().get().getProperty(ActionAnimationProperty.DEST_COORD_YROT_PROVIDER);
float destYRot = destYRotProvider.isEmpty() ? yRot : destYRotProvider.get().get(self, entitypatch);
TransformSheet result = self.getCoord().transformToWorldCoordOriginAsDest(entitypatch, startInWorld, destInWorld, yRot, destYRot, startFrame, destFrame);
transformSheet.readFrom(result);
};
/**
* Trace the target entity's position (use it with MODEL_COORD)
* - the location of the last keyfram is basis to limit maximum distance
* - rotation is where the entity is looking
*/
public static final MoveCoordSetter TRACE_TARGET_DISTANCE = (self, entitypatch, transformSheet) -> {
Vec3 destLocation = self.getRealAnimation().get().getProperty(ActionAnimationProperty.DEST_LOCATION_PROVIDER).orElse(NO_DEST).get(self, entitypatch);
if (destLocation != null) {
TransformSheet transform = self.getCoord().copyAll();
Keyframe[] coord = transform.getKeyframes();
Keyframe[] realAnimationCoord = self.getRealAnimation().get().getCoord().getKeyframes();
Vec3 startInWorld = entitypatch.getAnimator().getVariables().getOrDefault(ActionAnimation.BEGINNING_LOCATION, self.getRealAnimation());
if (startInWorld == null) {
startInWorld = entitypatch.getOriginal().position();
}
int startFrame = self.getRealAnimation().get().getProperty(ActionAnimationProperty.COORD_START_KEYFRAME_INDEX).orElse(0);
int realAnimationEndFrame = self.getRealAnimation().get().getProperty(ActionAnimationProperty.COORD_DEST_KEYFRAME_INDEX).orElse(self.getRealAnimation().get().getCoord().getKeyframes().length - 1);
Vec3 toDestWorld = destLocation.subtract(startInWorld);
Vec3f toDestAnim = realAnimationCoord[realAnimationEndFrame].transform().translation();
LivingEntity attackTarget = entitypatch.getTarget();
// Calculate Entity-Entity collide radius
float entityRadius = 0.0F;
if (attackTarget != null) {
float reach = 0.0F;
if (self.getRealAnimation().get() instanceof AttackAnimation attackAnimation) {
Optional<Float> reachOpt = attackAnimation.getProperty(AttackAnimationProperty.REACH);
if (reachOpt.isPresent()) {
reach = reachOpt.get();
} else {
AnimationPlayer player = entitypatch.getAnimator().getPlayerFor(self.getAccessor());
if (player != null) {
Phase phase = attackAnimation.getPhaseByTime(player.getElapsedTime());
reach = entitypatch.getReach(phase.hand);
}
}
}
entityRadius = (attackTarget.getBbWidth() + entitypatch.getOriginal().getBbWidth()) * 0.7F + reach;
}
float worldLength = Math.max((float)toDestWorld.length() - entityRadius, 0.0F);
float animLength = toDestAnim.length();
float dot = entitypatch.getAnimator().getVariables().getOrDefault(ActionAnimation.INITIAL_LOOK_VEC_DOT, self.getRealAnimation());
float lookLength = Mth.lerp(dot, animLength, worldLength);
float scale = Math.min(lookLength / animLength, 1.0F);
if (self.isLinkAnimation()) {
scale *= coord[coord.length - 1].transform().translation().length() / animLength;
}
int endFrame = self.getRealAnimation().get().getProperty(ActionAnimationProperty.COORD_DEST_KEYFRAME_INDEX).orElse(coord.length - 1);
for (int i = startFrame; i <= endFrame; i++) {
Vec3f translation = coord[i].transform().translation();
translation.x *= scale;
if (translation.z < 0.0F) {
translation.z *= scale;
}
}
transformSheet.readFrom(transform);
} else {
transformSheet.readFrom(self.getCoord().copyAll());
}
};
/**
* Trace the target entity's position (use it MODEL_COORD)
* - the location of the last keyframe is a basis to limit maximum distance
* - rotation is the direction toward a target entity
*/
public static final MoveCoordSetter TRACE_TARGET_LOCATION_ROTATION = (self, entitypatch, transformSheet) -> {
Vec3 destLocation = self.getRealAnimation().get().getProperty(ActionAnimationProperty.DEST_LOCATION_PROVIDER).orElse(NO_DEST).get(self, entitypatch);
if (destLocation != null) {
TransformSheet transform = self.getCoord().copyAll();
Keyframe[] coord = transform.getKeyframes();
Keyframe[] realAnimationCoord = self.getRealAnimation().get().getCoord().getKeyframes();
Vec3 startInWorld = entitypatch.getAnimator().getVariables().getOrDefault(ActionAnimation.BEGINNING_LOCATION, self.getRealAnimation());
if (startInWorld == null) {
startInWorld = entitypatch.getOriginal().position();
}
int startFrame = self.getRealAnimation().get().getProperty(ActionAnimationProperty.COORD_START_KEYFRAME_INDEX).orElse(0);
int endFrame = self.isLinkAnimation() ? coord.length - 1 : self.getRealAnimation().get().getProperty(ActionAnimationProperty.COORD_DEST_KEYFRAME_INDEX).orElse(coord.length - 1);
Vec3 toDestWorld = destLocation.subtract(startInWorld);
Vec3f toDestAnim = realAnimationCoord[endFrame].transform().translation();
LivingEntity attackTarget = entitypatch.getTarget();
// Calculate Entity-Entity collide radius
float entityRadius = 0.0F;
if (attackTarget != null) {
float reach = 0.0F;
if (self.getRealAnimation().get() instanceof AttackAnimation attackAnimation) {
Optional<Float> reachOpt = attackAnimation.getProperty(AttackAnimationProperty.REACH);
if (reachOpt.isPresent()) {
reach = reachOpt.get();
} else {
AnimationPlayer player = entitypatch.getAnimator().getPlayerFor(self.getAccessor());
if (player != null) {
Phase phase = attackAnimation.getPhaseByTime(player.getElapsedTime());
reach = entitypatch.getReach(phase.hand);
}
}
}
entityRadius = (attackTarget.getBbWidth() + entitypatch.getOriginal().getBbWidth()) * 0.7F + reach;
}
float worldLength = Math.max((float)toDestWorld.length() - entityRadius, 0.0F);
float animLength = toDestAnim.length();
float scale = Math.min(worldLength / animLength, 1.0F);
if (self.isLinkAnimation()) {
scale *= coord[endFrame].transform().translation().length() / animLength;
}
for (int i = startFrame; i <= endFrame; i++) {
Vec3f translation = coord[i].transform().translation();
translation.x *= scale;
if (translation.z < 0.0F) {
translation.z *= scale;
}
}
transformSheet.readFrom(transform);
} else {
transformSheet.readFrom(self.getCoord().copyAll());
}
};
public static final MoveCoordSetter VEX_TRACE = (self, entitypatch, transformSheet) -> {
if (!self.isLinkAnimation()) {
TransformSheet transform = self.getCoord().copyAll();
if (entitypatch.getTarget() != null) {
Keyframe[] keyframes = transform.getKeyframes();
Vec3 pos = entitypatch.getOriginal().position();
Vec3 targetpos = entitypatch.getTarget().getEyePosition();
double flyDistance = Math.max(5.0D, targetpos.subtract(pos).length() * 2);
transform.forEach((index, keyframe) -> {
keyframe.transform().translation().scale((float)(flyDistance / Math.abs(keyframes[keyframes.length - 1].transform().translation().z)));
});
Vec3 toTarget = targetpos.subtract(pos);
float xRot = (float)-MathUtils.getXRotOfVector(toTarget);
float yRot = (float)MathUtils.getYRotOfVector(toTarget);
entitypatch.setYRot(yRot);
transform.forEach((index, keyframe) -> {
keyframe.transform().translation().rotateDegree(Vec3f.X_AXIS, xRot);
keyframe.transform().translation().rotateDegree(Vec3f.Y_AXIS, 180.0F - yRot);
keyframe.transform().translation().add(entitypatch.getOriginal().position());
});
transformSheet.readFrom(transform);
} else {
transform.forEach((index, keyframe) -> {
keyframe.transform().translation().rotateDegree(Vec3f.Y_AXIS, 180.0F - entitypatch.getYRot());
keyframe.transform().translation().add(entitypatch.getOriginal().position());
});
}
}
};
}

View File

@@ -0,0 +1,74 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.types;
import net.minecraft.world.phys.Vec3;
import com.tiedup.remake.rig.anim.AnimationVariables;
import com.tiedup.remake.rig.anim.AnimationVariables.IndependentAnimationVariableKey;
import com.tiedup.remake.rig.anim.property.AnimationProperty.ActionAnimationProperty;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.asset.AssetAccessor;
/**
* RIG stub. Upstream EF : ActionAnimation ajoute un système de COORD
* TransformSheet pour les mouvements d'attaque en espace monde + des
* packets sync serveur/client via {@code CPSyncPlayerAnimationPosition}.
* Strippé pour TiedUp — garde juste l'API {@code addProperty} utilisée par
* {@link com.tiedup.remake.rig.asset.JsonAssetLoader} (ligne 621).
*/
public class ActionAnimation extends MainFrameAnimation {
// Variables indépendantes propagées à MoveCoordFunctions (position de départ
// en coord monde + produit scalaire lookVec initial pour lerp anim→world).
public static final IndependentAnimationVariableKey<Vec3> BEGINNING_LOCATION =
AnimationVariables.independent((animator) -> animator.getEntityPatch().getOriginal().position(), true);
public static final IndependentAnimationVariableKey<Float> INITIAL_LOOK_VEC_DOT =
AnimationVariables.independent((animator) -> 1.0F, true);
public ActionAnimation(float transitionTime, boolean isRepeat, String registryName, AssetAccessor<? extends Armature> armature) {
super(transitionTime, isRepeat, registryName, armature);
}
public ActionAnimation(boolean isRepeat, String registryName, AssetAccessor<? extends Armature> armature) {
super(isRepeat, registryName, armature);
}
/**
* Ajoute une propriété action-specific (COORD TransformSheet typiquement).
* Version stub : no-op, juste pour que les refs JsonAssetLoader compilent.
*/
public <V> ActionAnimation addProperty(ActionAnimationProperty<V> property, V value) {
return this;
}
/**
* Stub Phase 0 — LinkAnimation.modifyPose appelle ça pour zero-out X/Z de la
* Root joint en espace monde (empêche le sliding visuel pendant la transition
* vers une ActionAnimation sans keyframe "Coord"). Voir EF
* {@code yesman.epicfight.api.animation.types.ActionAnimation:209-222}.
*
* <p>Safe Phase 1 (idle/walk sont des StaticAnimation, pas ActionAnimation →
* jamais appelée). À re-implémenter Phase 2 dès qu'on introduit de vraies
* ActionAnimations bondage — sinon : sliding visible pendant transitionTime
* frames à chaque entrée dans l'action anim.</p>
*/
public void correctRootJoint(
com.tiedup.remake.rig.anim.types.DynamicAnimation animation,
com.tiedup.remake.rig.anim.Pose pose,
com.tiedup.remake.rig.patch.LivingEntityPatch<?> entitypatch,
float time,
float partialTicks
) {
if (com.tiedup.remake.rig.TiedUpRigConstants.IS_DEV_ENV) {
com.tiedup.remake.rig.TiedUpRigConstants.LOGGER.warn(
"correctRootJoint no-op appelé (Phase 0 stub) — si ActionAnimation jouée, "
+ "sliding visuel attendu. Voir docs/plans/rig/PHASE0_DEGRADATIONS.md D-07."
);
}
}
}

View File

@@ -0,0 +1,79 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.types;
import net.minecraft.world.InteractionHand;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.asset.AssetAccessor;
/**
* RIG stub. Upstream EF : AttackAnimation = système d'attaque combat (570L,
* Phase timings, hitboxes, damage source, attack events, colliders). 100%
* combat, strippé en stub minimal pour TiedUp.
*
* <p>On conserve :</p>
* <ul>
* <li>Le type {@code AttackAnimation} pour les {@code instanceof} dans
* {@link com.tiedup.remake.rig.asset.JsonAssetLoader}:584</li>
* <li>Le field {@code phases} (liste vide) pour la boucle d'itération
* en JsonAssetLoader:591</li>
* <li>La classe interne {@link Phase} avec un {@code getColliders()}
* no-op pour JsonAssetLoader:592</li>
* </ul>
*/
public class AttackAnimation extends ActionAnimation {
/**
* Liste des phases d'attaque. Toujours vide en TiedUp — on ne joue
* jamais d'animation attaque. Mais le field doit exister pour que
* JsonAssetLoader.java ligne 591 puisse itérer dessus sans NPE.
*
* <p>Type {@code Phase[]} (et non {@code List<Phase>}) pour s'aligner
* sur la signature upstream EF — facilite le re-port de fixes EF.</p>
*/
public final Phase[] phases = new Phase[0];
public AttackAnimation(float transitionTime, boolean isRepeat, String registryName, AssetAccessor<? extends Armature> armature) {
super(transitionTime, isRepeat, registryName, armature);
}
public AttackAnimation(boolean isRepeat, String registryName, AssetAccessor<? extends Armature> armature) {
super(isRepeat, registryName, armature);
}
/**
* Stub — MoveCoordFunctions appelle ça pour calculer la reach mid-anim.
* On retourne toujours une Phase neutre (mainHand, pas de colliders),
* donc le reach retombe sur {@code entitypatch.getReach(MAIN_HAND)}.
*/
public Phase getPhaseByTime(float elapsedTime) {
return new Phase();
}
/**
* Phase d'attaque. Stub pour satisfaire {@code phase.getColliders()}
* en JsonAssetLoader + {@code phase.hand} en MoveCoordFunctions.
*/
public static class Phase {
public final InteractionHand hand = InteractionHand.MAIN_HAND;
public JointColliderPair[] getColliders() {
return new JointColliderPair[0];
}
}
/**
* Stub — (Joint, Collider) pair pour les hitboxes combat. Non utilisé
* en TiedUp, juste un placeholder typé pour que JsonAssetLoader:592 compile.
*/
public static class JointColliderPair {
public com.tiedup.remake.rig.armature.Joint getFirst() {
return null;
}
}
}

View File

@@ -0,0 +1,179 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.types;
import java.util.Optional;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import com.tiedup.remake.rig.anim.AnimationClip;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.types.EntityState.StateFactor;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.anim.client.Layer;
import com.tiedup.remake.rig.anim.client.property.ClientAnimationProperties;
import com.tiedup.remake.rig.anim.client.property.JointMaskEntry;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
@OnlyIn(Dist.CLIENT)
public class ConcurrentLinkAnimation extends DynamicAnimation implements AnimationAccessor<ConcurrentLinkAnimation> {
protected AssetAccessor<? extends StaticAnimation> nextAnimation;
protected AssetAccessor<? extends DynamicAnimation> currentAnimation;
protected float startsAt;
public ConcurrentLinkAnimation() {
this.animationClip = new AnimationClip();
}
public void acceptFrom(AssetAccessor<? extends DynamicAnimation> currentAnimation, AssetAccessor<? extends StaticAnimation> nextAnimation, float time) {
this.currentAnimation = currentAnimation;
this.nextAnimation = nextAnimation;
this.startsAt = time;
this.setTotalTime(nextAnimation.get().getTransitionTime());
}
@Override
public void tick(LivingEntityPatch<?> entitypatch) {
this.nextAnimation.get().linkTick(entitypatch, this);
}
@Override
public void end(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends DynamicAnimation> nextAnimation, boolean isEnd) {
if (!isEnd) {
this.nextAnimation.get().end(entitypatch, nextAnimation, isEnd);
} else {
if (this.startsAt > 0.0F) {
entitypatch.getAnimator().getPlayer(this).ifPresent(player -> {
player.setElapsedTime(this.startsAt);
player.markDoNotResetTime();
});
this.startsAt = 0.0F;
}
}
}
@Override
public EntityState getState(LivingEntityPatch<?> entitypatch, float time) {
return this.nextAnimation.get().getState(entitypatch, 0.0F);
}
@Override
public <T> T getState(StateFactor<T> stateFactor, LivingEntityPatch<?> entitypatch, float time) {
return this.nextAnimation.get().getState(stateFactor, entitypatch, 0.0F);
}
@Override
public Pose getPoseByTime(LivingEntityPatch<?> entitypatch, float time, float partialTicks) {
float elapsed = time + this.startsAt;
float currentElapsed = elapsed % this.currentAnimation.get().getTotalTime();
float nextElapsed = elapsed % this.nextAnimation.get().getTotalTime();
Pose currentAnimPose = this.currentAnimation.get().getPoseByTime(entitypatch, currentElapsed, 1.0F);
Pose nextAnimPose = this.nextAnimation.get().getPoseByTime(entitypatch, nextElapsed, 1.0F);
float interpolate = time / this.getTotalTime();
Pose interpolatedPose = Pose.interpolatePose(currentAnimPose, nextAnimPose, interpolate);
JointMaskEntry maskEntry = this.nextAnimation.get().getJointMaskEntry(entitypatch, true).orElse(null);
if (maskEntry != null && entitypatch.isLogicalClient()) {
interpolatedPose.disableJoint((entry) ->
maskEntry.isMasked(
this.nextAnimation.get().getProperty(ClientAnimationProperties.LAYER_TYPE).orElse(Layer.LayerType.BASE_LAYER) == Layer.LayerType.BASE_LAYER ? entitypatch.getClientAnimator().currentMotion() : entitypatch.getClientAnimator().currentCompositeMotion()
, entry.getKey()
));
}
return interpolatedPose;
}
@Override
public void modifyPose(DynamicAnimation animation, Pose pose, LivingEntityPatch<?> entitypatch, float time, float partialTicks) {
this.nextAnimation.get().modifyPose(this, pose, entitypatch, time, partialTicks);
}
@Override
public float getPlaySpeed(LivingEntityPatch<?> entitypatch, DynamicAnimation animation) {
return this.nextAnimation.get().getPlaySpeed(entitypatch, animation);
}
public void setNextAnimation(AnimationAccessor<? extends StaticAnimation> animation) {
this.nextAnimation = animation;
}
@OnlyIn(Dist.CLIENT)
@Override
public Optional<JointMaskEntry> getJointMaskEntry(LivingEntityPatch<?> entitypatch, boolean useCurrentMotion) {
return this.nextAnimation.get().getJointMaskEntry(entitypatch, useCurrentMotion);
}
@Override
public boolean isMainFrameAnimation() {
return this.nextAnimation.get().isMainFrameAnimation();
}
@Override
public boolean isReboundAnimation() {
return this.nextAnimation.get().isReboundAnimation();
}
@Override
public AssetAccessor<? extends StaticAnimation> getRealAnimation() {
return this.nextAnimation;
}
@Override
public String toString() {
return "ConcurrentLinkAnimation: Mix " + this.currentAnimation + " and " + this.nextAnimation;
}
@Override
public AnimationClip getAnimationClip() {
return this.animationClip;
}
@Override
public boolean hasTransformFor(String joint) {
return this.nextAnimation.get().hasTransformFor(joint);
}
@Override
public boolean isLinkAnimation() {
return true;
}
@Override
public ConcurrentLinkAnimation get() {
return this;
}
@Override
public ResourceLocation registryName() {
return null;
}
@Override
public boolean isPresent() {
return true;
}
@Override
public int id() {
return -1;
}
@Override
public AnimationAccessor<? extends DynamicAnimation> getAccessor() {
return this;
}
@Override
public boolean inRegistry() {
return false;
}
}

View File

@@ -0,0 +1,73 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.types;
import org.jetbrains.annotations.ApiStatus;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.armature.Armature;
public class DirectStaticAnimation extends StaticAnimation implements AnimationAccessor<DirectStaticAnimation> {
private ResourceLocation registryName;
public DirectStaticAnimation() {
this.accessor = this;
}
public DirectStaticAnimation(float transitionTime, boolean isRepeat, ResourceLocation registryName, AssetAccessor<? extends Armature> armature) {
super(transitionTime, isRepeat, registryName.toString(), armature);
this.registryName = registryName;
this.accessor = this;
}
/* Multilayer, Pov animation Constructor */
@ApiStatus.Internal
public DirectStaticAnimation(ResourceLocation baseAnimPath, float transitionTime, boolean repeatPlay, String registryName, AssetAccessor<? extends Armature> armature) {
super(baseAnimPath, transitionTime, repeatPlay, registryName, armature);
this.registryName = ResourceLocation.parse(registryName);
}
@Override
public DirectStaticAnimation get() {
return this;
}
@SuppressWarnings("unchecked")
@Override
public <A extends DynamicAnimation> AnimationAccessor<A> getAccessor() {
return (AnimationAccessor<A>)this;
}
@Override
public ResourceLocation registryName() {
return this.registryName;
}
@Override
public boolean isPresent() {
return true;
}
@Override
public int id() {
return -1;
}
@Override
public int getId() {
return -1;
}
@Override
public boolean inRegistry() {
return false;
}
}

View File

@@ -0,0 +1,199 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.types;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nullable;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import com.tiedup.remake.rig.anim.AnimationClip;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.AnimationPlayer;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.TransformSheet;
import com.tiedup.remake.rig.anim.property.AnimationProperty;
import com.tiedup.remake.rig.anim.types.EntityState.StateFactor;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.anim.client.property.JointMaskEntry;
import com.tiedup.remake.rig.util.datastruct.TypeFlexibleHashMap;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public abstract class DynamicAnimation {
protected final boolean isRepeat;
protected final float transitionTime;
protected AnimationClip animationClip;
public DynamicAnimation() {
this(TiedUpRigConstants.GENERAL_ANIMATION_TRANSITION_TIME, false);
}
public DynamicAnimation(float transitionTime, boolean isRepeat) {
this.isRepeat = isRepeat;
this.transitionTime = transitionTime;
}
public final Pose getRawPose(float time) {
return this.getAnimationClip().getPoseInTime(time);
}
public Pose getPoseByTime(LivingEntityPatch<?> entitypatch, float time, float partialTicks) {
Pose pose = this.getRawPose(time);
this.modifyPose(this, pose, entitypatch, time, partialTicks);
return pose;
}
/** Modify the pose both this and link animation. **/
public void modifyPose(DynamicAnimation animation, Pose pose, LivingEntityPatch<?> entitypatch, float time, float partialTicks) {
}
public void putOnPlayer(AnimationPlayer animationPlayer, LivingEntityPatch<?> entitypatch) {
animationPlayer.setPlayAnimation(this.getAccessor());
animationPlayer.tick(entitypatch);
animationPlayer.begin(this.getAccessor(), entitypatch);
}
/**
* Called when the animation put on the {@link AnimationPlayer}
* @param entitypatch
*/
public void begin(LivingEntityPatch<?> entitypatch) {}
/**
* Called each tick when the animation is played
* @param entitypatch
*/
public void tick(LivingEntityPatch<?> entitypatch) {}
/**
* Called when both the animation finished or stopped by other animation.
* @param entitypatch
* @param nextAnimation the next animation to play after the animation ends
* @param isEnd whether the animation completed or not
*
* if @param isEnd true, nextAnimation is null
* if @param isEnd false, nextAnimation is not null
*/
public void end(LivingEntityPatch<?> entitypatch, @Nullable AssetAccessor<? extends DynamicAnimation> nextAnimation, boolean isEnd) {}
public void linkTick(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends DynamicAnimation> linkAnimation) {};
public boolean hasTransformFor(String joint) {
return this.getTransfroms().containsKey(joint);
}
@OnlyIn(Dist.CLIENT)
public Optional<JointMaskEntry> getJointMaskEntry(LivingEntityPatch<?> entitypatch, boolean useCurrentMotion) {
return Optional.empty();
}
public EntityState getState(LivingEntityPatch<?> entitypatch, float time) {
return EntityState.DEFAULT_STATE;
}
public TypeFlexibleHashMap<StateFactor<?>> getStatesMap(LivingEntityPatch<?> entitypatch, float time) {
return new TypeFlexibleHashMap<> (false);
}
public <T> T getState(StateFactor<T> stateFactor, LivingEntityPatch<?> entitypatch, float time) {
return stateFactor.defaultValue();
}
public AnimationClip getAnimationClip() {
return this.animationClip;
}
public Map<String, TransformSheet> getTransfroms() {
return this.getAnimationClip().getJointTransforms();
}
public float getPlaySpeed(LivingEntityPatch<?> entitypatch, DynamicAnimation animation) {
return 1.0F;
}
public TransformSheet getCoord() {
return this.getTransfroms().containsKey("Root") ? this.getTransfroms().get("Root") : TransformSheet.EMPTY_SHEET;
}
public void setTotalTime(float totalTime) {
this.getAnimationClip().setClipTime(totalTime);
}
public float getTotalTime() {
return this.getAnimationClip().getClipTime();
}
public float getTransitionTime() {
return this.transitionTime;
}
public boolean isRepeat() {
return this.isRepeat;
}
public boolean canBePlayedReverse() {
return false;
}
public ResourceLocation getRegistryName() {
return TiedUpRigConstants.identifier("");
}
public int getId() {
return -1;
}
public <V> Optional<V> getProperty(AnimationProperty<V> propertyType) {
return Optional.empty();
}
public boolean isBasicAttackAnimation() {
return false;
}
public boolean isMainFrameAnimation() {
return false;
}
public boolean isReboundAnimation() {
return false;
}
public boolean isMetaAnimation() {
return false;
}
public boolean isClientAnimation() {
return false;
}
public boolean isStaticAnimation() {
return false;
}
public abstract <A extends DynamicAnimation> AnimationAccessor<? extends DynamicAnimation> getAccessor();
public abstract AssetAccessor<? extends StaticAnimation> getRealAnimation();
public boolean isLinkAnimation() {
return false;
}
public boolean doesHeadRotFollowEntityHead() {
return false;
}
@OnlyIn(Dist.CLIENT)
public void renderDebugging(PoseStack poseStack, MultiBufferSource buffer, LivingEntityPatch<?> entitypatch, float playTime, float partialTicks) {
}
}

View File

@@ -0,0 +1,146 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.types;
import java.util.function.Consumer;
import java.util.function.Function;
import net.minecraft.world.damagesource.DamageSource;
import net.minecraftforge.event.entity.ProjectileImpactEvent;
import com.tiedup.remake.rig.util.AttackResult;
import com.tiedup.remake.rig.util.datastruct.TypeFlexibleHashMap;
public class EntityState {
public static class StateFactor<T> implements TypeFlexibleHashMap.TypeKey<T> {
private final String name;
private final T defaultValue;
public StateFactor(String name, T defaultValue) {
this.name = name;
this.defaultValue = defaultValue;
}
public String toString() {
return this.name;
}
public T defaultValue() {
return this.defaultValue;
}
}
public static final EntityState DEFAULT_STATE = new EntityState(new TypeFlexibleHashMap<>(true));
public static final StateFactor<Boolean> TURNING_LOCKED = new StateFactor<>("turningLocked", false);
public static final StateFactor<Boolean> MOVEMENT_LOCKED = new StateFactor<>("movementLocked", false);
public static final StateFactor<Boolean> ATTACKING = new StateFactor<>("attacking", false);
public static final StateFactor<Boolean> CAN_BASIC_ATTACK = new StateFactor<>("canBasicAttack", true);
public static final StateFactor<Boolean> CAN_SKILL_EXECUTION = new StateFactor<>("canExecuteSkill", true);
public static final StateFactor<Boolean> CAN_USE_ITEM = new StateFactor<>("canUseItem", true);
public static final StateFactor<Boolean> CAN_SWITCH_HAND_ITEM = new StateFactor<>("canSwitchHandItem", true);
public static final StateFactor<Boolean> INACTION = new StateFactor<>("takingAction", false);
public static final StateFactor<Boolean> KNOCKDOWN = new StateFactor<>("knockdown", false);
public static final StateFactor<Boolean> LOCKON_ROTATE = new StateFactor<>("lockonRotate", false);
public static final StateFactor<Boolean> UPDATE_LIVING_MOTION = new StateFactor<>("updateLivingMotion", true);
public static final StateFactor<Integer> HURT_LEVEL = new StateFactor<>("hurtLevel", 0);
public static final StateFactor<Integer> PHASE_LEVEL = new StateFactor<>("phaseLevel", 0);
public static final StateFactor<Function<DamageSource, AttackResult.ResultType>> ATTACK_RESULT = new StateFactor<>("attackResultModifier", (damagesource) -> AttackResult.ResultType.SUCCESS);
public static final StateFactor<Consumer<ProjectileImpactEvent>> PROJECTILE_IMPACT_RESULT = new StateFactor<>("projectileImpactResult", (event) -> {});
private final TypeFlexibleHashMap<StateFactor<?>> stateMap;
public EntityState(TypeFlexibleHashMap<StateFactor<?>> states) {
this.stateMap = states;
}
public <T> void setState(StateFactor<T> stateFactor, T val) {
this.stateMap.put(stateFactor, (Object)val);
}
public <T> T getState(StateFactor<T> stateFactor) {
return this.stateMap.getOrDefault(stateFactor);
}
public TypeFlexibleHashMap<StateFactor<?>> getStateMap() {
return this.stateMap;
}
public boolean turningLocked() {
return this.getState(EntityState.TURNING_LOCKED);
}
public boolean movementLocked() {
return this.getState(EntityState.MOVEMENT_LOCKED);
}
public boolean attacking() {
return this.getState(EntityState.ATTACKING);
}
public AttackResult.ResultType attackResult(DamageSource damagesource) {
return this.getState(EntityState.ATTACK_RESULT).apply(damagesource);
}
public void setProjectileImpactResult(ProjectileImpactEvent event) {
this.getState(EntityState.PROJECTILE_IMPACT_RESULT).accept(event);
}
public boolean canBasicAttack() {
return this.getState(EntityState.CAN_BASIC_ATTACK);
}
public boolean canUseSkill() {
return this.getState(EntityState.CAN_SKILL_EXECUTION);
}
public boolean canUseItem() {
return this.canUseSkill() && this.getState(EntityState.CAN_USE_ITEM);
}
public boolean canSwitchHoldingItem() {
return !this.inaction() && this.getState(EntityState.CAN_SWITCH_HAND_ITEM);
}
public boolean inaction() {
return this.getState(EntityState.INACTION);
}
public boolean updateLivingMotion() {
return this.getState(EntityState.UPDATE_LIVING_MOTION);
}
public boolean hurt() {
return this.getState(EntityState.HURT_LEVEL) > 0;
}
public int hurtLevel() {
return this.getState(EntityState.HURT_LEVEL);
}
public boolean knockDown() {
return this.getState(EntityState.KNOCKDOWN);
}
public boolean lockonRotate() {
return this.getState(EntityState.LOCKON_ROTATE);
}
/**
* 1: anticipation
* 2: attacking
* 3: recovery
* @return level
*/
public int getLevel() {
return this.getState(EntityState.PHASE_LEVEL);
}
@Override
public String toString() {
return this.stateMap.toString();
}
}

View File

@@ -0,0 +1,124 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.types;
import java.util.Optional;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import com.tiedup.remake.rig.anim.AnimationClip;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.property.AnimationProperty;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.anim.client.Layer.Priority;
import com.tiedup.remake.rig.anim.client.property.JointMaskEntry;
import com.tiedup.remake.rig.TiedUpRigRegistry;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
@OnlyIn(Dist.CLIENT)
public class LayerOffAnimation extends DynamicAnimation implements AnimationAccessor<LayerOffAnimation> {
private AssetAccessor<? extends DynamicAnimation> lastAnimation;
private Pose lastPose;
private final Priority layerPriority;
public LayerOffAnimation(Priority layerPriority) {
this.layerPriority = layerPriority;
this.animationClip = new AnimationClip();
}
public void setLastPose(Pose pose) {
this.lastPose = pose;
}
@Override
public void end(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends DynamicAnimation> nextAnimation, boolean isEnd) {
if (entitypatch.isLogicalClient() && isEnd) {
entitypatch.getClientAnimator().baseLayer.disableLayer(this.layerPriority);
}
}
@Override
public Pose getPoseByTime(LivingEntityPatch<?> entitypatch, float time, float partialTicks) {
Pose lowerLayerPose = entitypatch.getClientAnimator().getComposedLayerPoseBelow(this.layerPriority, Minecraft.getInstance().getFrameTime());
Pose interpolatedPose = Pose.interpolatePose(this.lastPose, lowerLayerPose, time / this.getTotalTime());
interpolatedPose.disableJoint((joint) -> !this.lastPose.hasTransform(joint.getKey()));
return interpolatedPose;
}
@Override
public Optional<JointMaskEntry> getJointMaskEntry(LivingEntityPatch<?> entitypatch, boolean useCurrentMotion) {
return this.lastAnimation.get().getJointMaskEntry(entitypatch, useCurrentMotion);
}
@Override
public <V> Optional<V> getProperty(AnimationProperty<V> propertyType) {
return this.lastAnimation.get().getProperty(propertyType);
}
public void setLastAnimation(AssetAccessor<? extends DynamicAnimation> animation) {
this.lastAnimation = animation;
}
@Override
public boolean doesHeadRotFollowEntityHead() {
return this.lastAnimation.get().doesHeadRotFollowEntityHead();
}
@Override
public AssetAccessor<? extends StaticAnimation> getRealAnimation() {
return TiedUpRigRegistry.EMPTY_ANIMATION;
}
@Override
public AnimationClip getAnimationClip() {
return this.animationClip;
}
@Override
public boolean hasTransformFor(String joint) {
return this.lastPose.hasTransform(joint);
}
@Override
public boolean isLinkAnimation() {
return true;
}
@Override
public LayerOffAnimation get() {
return this;
}
@Override
public ResourceLocation registryName() {
return null;
}
@Override
public boolean isPresent() {
return true;
}
@Override
public int id() {
return -1;
}
@Override
public AnimationAccessor<? extends LayerOffAnimation> getAccessor() {
return this;
}
@Override
public boolean inRegistry() {
return false;
}
}

View File

@@ -0,0 +1,244 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.types;
import java.util.Map;
import java.util.Optional;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import com.tiedup.remake.rig.anim.AnimationClip;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.armature.JointTransform;
import com.tiedup.remake.rig.anim.Keyframe;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.TransformSheet;
import com.tiedup.remake.rig.anim.types.EntityState.StateFactor;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.anim.client.property.JointMaskEntry;
import com.tiedup.remake.rig.util.datastruct.TypeFlexibleHashMap;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public class LinkAnimation extends DynamicAnimation implements AnimationAccessor<LinkAnimation> {
protected TransformSheet coord;
protected AssetAccessor<? extends DynamicAnimation> fromAnimation;
protected AssetAccessor<? extends StaticAnimation> toAnimation;
protected float nextStartTime;
public LinkAnimation() {
this.animationClip = new AnimationClip();
}
@Override
public void tick(LivingEntityPatch<?> entitypatch) {
this.toAnimation.get().linkTick(entitypatch, this);
}
@Override
public void end(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends DynamicAnimation> nextAnimation, boolean isEnd) {
if (!isEnd) {
this.toAnimation.get().end(entitypatch, nextAnimation, isEnd);
} else {
if (this.nextStartTime > 0.0F) {
entitypatch.getAnimator().getPlayer(this).ifPresent(player -> {
player.setElapsedTime(this.nextStartTime);
player.markDoNotResetTime();
});
}
}
}
@Override
public TypeFlexibleHashMap<StateFactor<?>> getStatesMap(LivingEntityPatch<?> entitypatch, float time) {
float timeInRealAnimation = Math.max(time - (this.getTotalTime() - this.nextStartTime), 0.0F);
TypeFlexibleHashMap<StateFactor<?>> map = this.toAnimation.get().getStatesMap(entitypatch, timeInRealAnimation);
for (Map.Entry<StateFactor<?>, Object> entry : map.entrySet()) {
Object val = this.toAnimation.get().getModifiedLinkState(entry.getKey(), entry.getValue(), entitypatch, time);
map.put(entry.getKey(), val);
}
return map;
}
@Override
public EntityState getState(LivingEntityPatch<?> entitypatch, float time) {
float timeInRealAnimation = Math.max(time - (this.getTotalTime() - this.nextStartTime), 0.0F);
EntityState state = this.toAnimation.get().getState(entitypatch, timeInRealAnimation);
TypeFlexibleHashMap<StateFactor<?>> map = state.getStateMap();
for (Map.Entry<StateFactor<?>, Object> entry : map.entrySet()) {
Object val = this.toAnimation.get().getModifiedLinkState(entry.getKey(), entry.getValue(), entitypatch, time);
map.put(entry.getKey(), val);
}
return state;
}
@SuppressWarnings("unchecked")
@Override
public <T> T getState(StateFactor<T> stateFactor, LivingEntityPatch<?> entitypatch, float time) {
float timeInRealAnimation = Math.max(time - (this.getTotalTime() - this.nextStartTime), 0.0F);
T state = this.toAnimation.get().getState(stateFactor, entitypatch, timeInRealAnimation);
return (T)this.toAnimation.get().getModifiedLinkState(stateFactor, state, entitypatch, time);
}
@Override
public Pose getPoseByTime(LivingEntityPatch<?> entitypatch, float time, float partialTicks) {
Pose nextStartingPose = this.toAnimation.get().getPoseByTime(entitypatch, this.nextStartTime, partialTicks);
/**
* Update dest pose
*/
for (Map.Entry<String, JointTransform> entry : nextStartingPose.getJointTransformData().entrySet()) {
if (this.animationClip.hasJointTransform(entry.getKey())) {
Keyframe[] keyframe = this.animationClip.getJointTransform(entry.getKey()).getKeyframes();
JointTransform jt = keyframe[keyframe.length - 1].transform();
JointTransform newJt = nextStartingPose.getJointTransformData().get(entry.getKey());
newJt.translation().set(jt.translation());
jt.copyFrom(newJt);
}
}
return super.getPoseByTime(entitypatch, time, partialTicks);
}
@Override
public void modifyPose(DynamicAnimation animation, Pose pose, LivingEntityPatch<?> entitypatch, float time, float partialTicks) {
// Bad implementation: Add root joint as coord in loading animation
if (this.toAnimation.get() instanceof ActionAnimation actionAnimation) {
if (!this.getTransfroms().containsKey("Coord")) {
actionAnimation.correctRootJoint(this, pose, entitypatch, time, partialTicks);
}
}
}
@Override
public float getPlaySpeed(LivingEntityPatch<?> entitypatch, DynamicAnimation animation) {
return this.toAnimation.get().getPlaySpeed(entitypatch, animation);
}
public void setConnectedAnimations(AssetAccessor<? extends DynamicAnimation> from, AssetAccessor<? extends StaticAnimation> to) {
this.fromAnimation = from.get().getRealAnimation();
this.toAnimation = to;
}
public AssetAccessor<? extends StaticAnimation> getNextAnimation() {
return this.toAnimation;
}
@Override
public TransformSheet getCoord() {
if (this.coord != null) {
return this.coord;
} else if (this.getTransfroms().containsKey("Root")) {
return this.getTransfroms().get("Root");
}
return TransformSheet.EMPTY_SHEET;
}
@OnlyIn(Dist.CLIENT)
public Optional<JointMaskEntry> getJointMaskEntry(LivingEntityPatch<?> entitypatch, boolean useCurrentMotion) {
return useCurrentMotion ? this.toAnimation.get().getJointMaskEntry(entitypatch, true) : this.fromAnimation.get().getJointMaskEntry(entitypatch, false);
}
@Override
public boolean isMainFrameAnimation() {
return this.toAnimation.get().isMainFrameAnimation();
}
@Override
public boolean isReboundAnimation() {
return this.toAnimation.get().isReboundAnimation();
}
@Override
public boolean doesHeadRotFollowEntityHead() {
return this.fromAnimation.get().doesHeadRotFollowEntityHead() && this.toAnimation.get().doesHeadRotFollowEntityHead();
}
@Override
public AssetAccessor<? extends StaticAnimation> getRealAnimation() {
return this.toAnimation;
}
public AssetAccessor<? extends DynamicAnimation> getFromAnimation() {
return this.fromAnimation;
}
@Override
public AnimationAccessor<? extends DynamicAnimation> getAccessor() {
return this;
}
public void copyTo(LinkAnimation dest) {
dest.setConnectedAnimations(this.fromAnimation, this.toAnimation);
dest.setTotalTime(this.getTotalTime());
dest.getAnimationClip().reset();
this.getTransfroms().forEach((jointName, transformSheet) -> dest.getAnimationClip().addJointTransform(jointName, transformSheet.copyAll()));
}
public void loadCoord(TransformSheet coord) {
this.coord = coord;
}
public float getNextStartTime() {
return this.nextStartTime;
}
public void setNextStartTime(float nextStartTime) {
this.nextStartTime = nextStartTime;
}
public void resetNextStartTime() {
this.nextStartTime = 0.0F;
}
@Override
public boolean isLinkAnimation() {
return true;
}
@Override
public String toString() {
return "From " + this.fromAnimation + " to " + this.toAnimation;
}
@Override
public AnimationClip getAnimationClip() {
return this.animationClip;
}
@Override
public LinkAnimation get() {
return this;
}
@Override
public ResourceLocation registryName() {
return null;
}
@Override
public boolean isPresent() {
return true;
}
@Override
public int id() {
return -1;
}
@Override
public boolean inRegistry() {
return false;
}
}

View File

@@ -0,0 +1,28 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.types;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.asset.AssetAccessor;
/**
* RIG stub. Upstream EF : MainFrameAnimation ajoute du scheduling combat
* via {@code ActionEvent}/{@code PlayerEventListener} (combat tick hooks).
* Strippé pour TiedUp — garde juste le type pour satisfaire les
* instanceof checks de {@link com.tiedup.remake.rig.asset.JsonAssetLoader}
* et la hiérarchie {@link ActionAnimation} / {@link AttackAnimation}.
*/
public class MainFrameAnimation extends StaticAnimation {
public MainFrameAnimation(float transitionTime, boolean isRepeat, String registryName, AssetAccessor<? extends Armature> armature) {
super(transitionTime, isRepeat, registryName, armature);
}
public MainFrameAnimation(boolean isRepeat, String registryName, AssetAccessor<? extends Armature> armature) {
super(isRepeat, registryName, armature);
}
}

View File

@@ -0,0 +1,288 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.types;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import com.tiedup.remake.rig.anim.types.EntityState.StateFactor;
import com.tiedup.remake.rig.util.datastruct.TypeFlexibleHashMap;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public class StateSpectrum {
private final Set<StatesInTime> timePairs = Sets.newHashSet();
void readFrom(StateSpectrum.Blueprint blueprint) {
this.timePairs.clear();
this.timePairs.addAll(blueprint.timePairs);
}
@SuppressWarnings("unchecked")
public <T> T getSingleState(StateFactor<T> stateFactor, LivingEntityPatch<?> entitypatch, float time) {
for (StatesInTime state : this.timePairs) {
if (state.isIn(entitypatch, time)) {
for (Map.Entry<StateFactor<?>, ?> timeEntry : state.getStates(entitypatch)) {
if (timeEntry.getKey() == stateFactor) {
return (T) timeEntry.getValue();
}
}
}
}
return stateFactor.defaultValue();
}
public TypeFlexibleHashMap<StateFactor<?>> getStateMap(LivingEntityPatch<?> entitypatch, float time) {
TypeFlexibleHashMap<StateFactor<?>> stateMap = new TypeFlexibleHashMap<>(true);
for (StatesInTime state : this.timePairs) {
if (state.isIn(entitypatch, time)) {
for (Map.Entry<StateFactor<?>, ?> timeEntry : state.getStates(entitypatch)) {
stateMap.put(timeEntry.getKey(), timeEntry.getValue());
}
}
}
return stateMap;
}
abstract static class StatesInTime {
public abstract Set<Map.Entry<StateFactor<?>, Object>> getStates(LivingEntityPatch<?> entitypatch);
public abstract void removeState(StateFactor<?> state);
public abstract boolean hasState(StateFactor<?> state);
public abstract boolean isIn(LivingEntityPatch<?> entitypatch, float time);
}
static class SimpleStatesInTime extends StatesInTime {
float start;
float end;
Map<StateFactor<?>, Object> states = Maps.newHashMap();
public SimpleStatesInTime(float start, float end) {
this.start = start;
this.end = end;
}
@Override
public boolean isIn(LivingEntityPatch<?> entitypatch, float time) {
return this.start <= time && this.end > time;
}
public <T> StatesInTime addState(StateFactor<T> factor, T val) {
this.states.put(factor, val);
return this;
}
@Override
public Set<Map.Entry<StateFactor<?>, Object>> getStates(LivingEntityPatch<?> entitypatch) {
return this.states.entrySet();
}
@Override
public boolean hasState(StateFactor<?> state) {
return this.states.containsKey(state);
}
@Override
public void removeState(StateFactor<?> state) {
this.states.remove(state);
}
@Override
public String toString() {
return String.format("Time: %.2f ~ %.2f, States: %s", this.start, this.end, this.states);
}
}
static class ConditionalStatesInTime extends StatesInTime {
float start;
float end;
Int2ObjectMap<Map<StateFactor<?>, Object>> conditionalStates = new Int2ObjectOpenHashMap<>();
Function<LivingEntityPatch<?>, Integer> condition;
public ConditionalStatesInTime(Function<LivingEntityPatch<?>, Integer> condition, float start, float end) {
this.start = start;
this.end = end;
this.condition = condition;
}
public <T> StatesInTime addConditionalState(int metadata, StateFactor<T> factor, T val) {
Map<StateFactor<?>, Object> states = this.conditionalStates.computeIfAbsent(metadata, (key) -> Maps.newHashMap());
states.put(factor, val);
return this;
}
@SuppressWarnings("deprecation")
@Override
public Set<Map.Entry<StateFactor<?>, Object>> getStates(LivingEntityPatch<?> entitypatch) {
return this.conditionalStates.get(this.condition.apply(entitypatch)).entrySet();
}
@Override
public boolean isIn(LivingEntityPatch<?> entitypatch, float time) {
return this.start <= time && this.end > time;
}
@Override
public boolean hasState(StateFactor<?> state) {
boolean hasState = false;
for (Map<StateFactor<?>, Object> states : this.conditionalStates.values()) {
hasState |= states.containsKey(state);
}
return hasState;
}
@Override
public void removeState(StateFactor<?> state) {
for (Map<StateFactor<?>, Object> states : this.conditionalStates.values()) {
states.remove(state);
}
}
@SuppressWarnings("deprecation")
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(String.format("Time: %.2f ~ %.2f, ", this.start, this.end));
int entryCnt = 0;
for (Map.Entry<Integer, Map<StateFactor<?>, Object>> entry : this.conditionalStates.entrySet()) {
sb.append(String.format("States %d: %s", entry.getKey(), entry.getValue()));
entryCnt++;
if (entryCnt < this.conditionalStates.size()) {
sb.append(", ");
}
}
return sb.toString();
}
}
static class VariableStatesInTime extends StatesInTime {
Function<LivingEntityPatch<?>, Float> variableStart;
Function<LivingEntityPatch<?>, Float> variableEnd;
Map<StateFactor<?>, Object> states = Maps.newHashMap();
public VariableStatesInTime(Function<LivingEntityPatch<?>, Float> variableStart, Function<LivingEntityPatch<?>, Float> variableEnd) {
this.variableStart = variableStart;
this.variableEnd = variableEnd;
}
@Override
public boolean isIn(LivingEntityPatch<?> entitypatch, float time) {
return this.variableStart.apply(entitypatch) <= time && this.variableEnd.apply(entitypatch) > time;
}
public <T> StatesInTime addState(StateFactor<T> factor, T val) {
this.states.put(factor, val);
return this;
}
@Override
public Set<Map.Entry<StateFactor<?>, Object>> getStates(LivingEntityPatch<?> entitypatch) {
return this.states.entrySet();
}
@Override
public boolean hasState(StateFactor<?> state) {
return this.states.containsKey(state);
}
@Override
public void removeState(StateFactor<?> state) {
this.states.remove(state);
}
@Override
public String toString() {
return String.format("States: %s", this.states);
}
}
public static class Blueprint {
StatesInTime currentState;
Set<StatesInTime> timePairs = Sets.newHashSet();
public Blueprint newTimePair(float start, float end) {
this.currentState = new SimpleStatesInTime(start, end);
this.timePairs.add(this.currentState);
return this;
}
public Blueprint newConditionalTimePair(Function<LivingEntityPatch<?>, Integer> condition, float start, float end) {
this.currentState = new ConditionalStatesInTime(condition, start, end);
this.timePairs.add(this.currentState);
return this;
}
public Blueprint newVariableTimePair(Function<LivingEntityPatch<?>, Float> variableStart, Function<LivingEntityPatch<?>, Float> variableEnd) {
this.currentState = new VariableStatesInTime(variableStart, variableEnd);
this.timePairs.add(this.currentState);
return this;
}
public <T> Blueprint addState(StateFactor<T> factor, T val) {
if (this.currentState instanceof SimpleStatesInTime simpleState) {
simpleState.addState(factor, val);
}
if (this.currentState instanceof VariableStatesInTime variableState) {
variableState.addState(factor, val);
}
return this;
}
public <T> Blueprint addConditionalState(int metadata, StateFactor<T> factor, T val) {
if (this.currentState instanceof ConditionalStatesInTime conditionalState) {
conditionalState.addConditionalState(metadata, factor, val);
}
return this;
}
public <T> Blueprint removeState(StateFactor<T> factor) {
for (StatesInTime timePair : this.timePairs) {
timePair.removeState(factor);
}
return this;
}
public <T> Blueprint addStateRemoveOld(StateFactor<T> factor, T val) {
this.removeState(factor);
return this.addState(factor, val);
}
public <T> Blueprint addStateIfNotExist(StateFactor<T> factor, T val) {
for (StatesInTime timePair : this.timePairs) {
if (timePair.hasState(factor)) {
return this;
}
}
return this.addState(factor, val);
}
public Blueprint clear() {
this.currentState = null;
this.timePairs.clear();
return this;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (StatesInTime state : this.timePairs) {
sb.append(state + "\n");
}
return sb.toString();
}
}
}

View File

@@ -0,0 +1,483 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.anim.types;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import com.google.common.collect.Maps;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import io.netty.util.internal.StringUtil;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import com.tiedup.remake.rig.anim.AnimationClip;
import com.tiedup.remake.rig.anim.AnimationManager;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.AnimationVariables;
import com.tiedup.remake.rig.anim.AnimationVariables.IndependentAnimationVariableKey;
import com.tiedup.remake.rig.armature.JointTransform;
import com.tiedup.remake.rig.anim.Keyframe;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.TransformSheet;
import com.tiedup.remake.rig.anim.property.AnimationEvent;
import com.tiedup.remake.rig.anim.property.AnimationEvent.SimpleEvent;
import com.tiedup.remake.rig.anim.property.AnimationParameters;
import com.tiedup.remake.rig.anim.property.AnimationProperty;
import com.tiedup.remake.rig.anim.property.AnimationProperty.ActionAnimationProperty;
import com.tiedup.remake.rig.anim.property.AnimationProperty.PlaybackSpeedModifier;
import com.tiedup.remake.rig.anim.property.AnimationProperty.StaticAnimationProperty;
import com.tiedup.remake.rig.anim.types.EntityState.StateFactor;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.asset.JsonAssetLoader;
import com.tiedup.remake.rig.anim.client.Layer;
import com.tiedup.remake.rig.anim.client.Layer.LayerType;
import com.tiedup.remake.rig.anim.client.property.ClientAnimationProperties;
import com.tiedup.remake.rig.anim.client.property.JointMaskEntry;
import com.tiedup.remake.rig.anim.client.property.TrailInfo;
import com.tiedup.remake.rig.exception.AssetLoadingException;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.physics.ik.InverseKinematicsProvider;
import com.tiedup.remake.rig.physics.ik.InverseKinematicsSimulatable;
import com.tiedup.remake.rig.physics.ik.InverseKinematicsSimulator;
import com.tiedup.remake.rig.physics.ik.InverseKinematicsSimulator.BakedInverseKinematicsDefinition;
import com.tiedup.remake.rig.physics.ik.InverseKinematicsSimulator.InverseKinematicsObject;
import com.tiedup.remake.rig.util.datastruct.TypeFlexibleHashMap;
import com.tiedup.remake.rig.math.OpenMatrix4f;
import com.tiedup.remake.rig.math.Vec3f;
import com.tiedup.remake.rig.render.TiedUpRenderTypes;
import com.tiedup.remake.rig.TiedUpRigRegistry;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
import com.tiedup.remake.rig.patch.PlayerPatch;
public class StaticAnimation extends DynamicAnimation implements InverseKinematicsProvider {
public static final IndependentAnimationVariableKey<Boolean> HAD_NO_PHYSICS = AnimationVariables.independent((animator) -> false, true);
public static String getFileHash(ResourceLocation rl) {
String fileHash;
try {
JsonAssetLoader jsonfile = new JsonAssetLoader(AnimationManager.getAnimationResourceManager(), rl);
fileHash = jsonfile.getFileHash();
} catch (AssetLoadingException e) {
fileHash = StringUtil.EMPTY_STRING;
}
return fileHash;
}
protected final Map<AnimationProperty<?>, Object> properties = Maps.newHashMap();
/**
* States will bind into animation on {@link AnimationManager#apply}
*/
protected final StateSpectrum.Blueprint stateSpectrumBlueprint = new StateSpectrum.Blueprint();
protected final StateSpectrum stateSpectrum = new StateSpectrum();
protected final AssetAccessor<? extends Armature> armature;
protected ResourceLocation resourceLocation;
protected AnimationAccessor<? extends StaticAnimation> accessor;
private final String filehash;
public StaticAnimation() {
super(0.0F, true);
this.resourceLocation = TiedUpRigConstants.identifier("emtpy");
this.armature = null;
this.filehash = StringUtil.EMPTY_STRING;
}
public StaticAnimation(boolean isRepeat, AnimationAccessor<? extends StaticAnimation> accessor, AssetAccessor<? extends Armature> armature) {
this(TiedUpRigConstants.GENERAL_ANIMATION_TRANSITION_TIME, isRepeat, accessor, armature);
}
public StaticAnimation(float transitionTime, boolean isRepeat, AnimationAccessor<? extends StaticAnimation> accessor, AssetAccessor<? extends Armature> armature) {
super(transitionTime, isRepeat);
this.resourceLocation = ResourceLocation.fromNamespaceAndPath(accessor.registryName().getNamespace(), "animmodels/animations/" + accessor.registryName().getPath() + ".json");
this.armature = armature;
this.accessor = accessor;
this.filehash = getFileHash(this.resourceLocation);
}
/* Resourcepack animations — transitionTime par défaut */
public StaticAnimation(boolean isRepeat, String path, AssetAccessor<? extends Armature> armature) {
this(TiedUpRigConstants.GENERAL_ANIMATION_TRANSITION_TIME, isRepeat, path, armature);
}
/* Resourcepack animations */
public StaticAnimation(float transitionTime, boolean isRepeat, String path, AssetAccessor<? extends Armature> armature) {
super(transitionTime, isRepeat);
ResourceLocation registryName = ResourceLocation.parse(path);
this.resourceLocation = ResourceLocation.fromNamespaceAndPath(registryName.getNamespace(), "animmodels/animations/" + registryName.getPath() + ".json");
this.armature = armature;
this.filehash = StringUtil.EMPTY_STRING;
}
/* Multilayer Constructor */
public StaticAnimation(ResourceLocation fileLocation, float transitionTime, boolean isRepeat, String registryName, AssetAccessor<? extends Armature> armature) {
super(transitionTime, isRepeat);
this.resourceLocation = fileLocation;
this.armature = armature;
this.filehash = StringUtil.EMPTY_STRING;
}
public void loadAnimation() {
if (!this.isMetaAnimation()) {
if (this.properties.containsKey(StaticAnimationProperty.IK_DEFINITION)) {
this.animationClip = AnimationManager.getInstance().loadAnimationClip(this, JsonAssetLoader::loadAllJointsClipForAnimation);
this.getProperty(StaticAnimationProperty.IK_DEFINITION).ifPresent(ikDefinitions -> {
boolean correctY = this.getProperty(ActionAnimationProperty.MOVE_VERTICAL).orElse(false);
boolean correctZ = this.isMainFrameAnimation();
List<BakedInverseKinematicsDefinition> bakedIKDefinitionList = ikDefinitions.stream().map(ikDefinition -> ikDefinition.bake(this.armature, this.animationClip.getJointTransforms(), correctY, correctZ)).toList();
this.addProperty(StaticAnimationProperty.BAKED_IK_DEFINITION, bakedIKDefinitionList);
// Remove the unbaked data
this.properties.remove(StaticAnimationProperty.IK_DEFINITION);
});
} else {
this.animationClip = AnimationManager.getInstance().loadAnimationClip(this, JsonAssetLoader::loadClipForAnimation);
}
this.animationClip.bakeKeyframes();
}
}
public void postInit() {
this.stateSpectrum.readFrom(this.stateSpectrumBlueprint);
}
@Override
public AnimationClip getAnimationClip() {
if (this.animationClip == null) {
this.loadAnimation();
}
return this.animationClip;
}
public void setLinkAnimation(final AssetAccessor<? extends DynamicAnimation> fromAnimation, Pose startPose, boolean isOnSameLayer, float transitionTimeModifier, LivingEntityPatch<?> entitypatch, LinkAnimation dest) {
if (!entitypatch.isLogicalClient()) {
startPose = TiedUpRigRegistry.EMPTY_ANIMATION.getPoseByTime(entitypatch, 0.0F, 1.0F);
}
dest.resetNextStartTime();
float playTime = this.getPlaySpeed(entitypatch, dest);
PlaybackSpeedModifier playSpeedModifier = this.getRealAnimation().get().getProperty(StaticAnimationProperty.PLAY_SPEED_MODIFIER).orElse(null);
if (playSpeedModifier != null) {
playTime = playSpeedModifier.modify(dest, entitypatch, playTime, 0.0F, playTime);
}
playTime = Math.abs(playTime);
playTime *= TiedUpRigConstants.A_TICK;
float linkTime = transitionTimeModifier > 0.0F ? transitionTimeModifier + this.transitionTime : this.transitionTime;
float totalTime = playTime * (int)Math.ceil(linkTime / playTime);
float nextStartTime = Math.max(0.0F, -transitionTimeModifier);
nextStartTime += totalTime - linkTime;
dest.setNextStartTime(nextStartTime);
dest.getAnimationClip().reset();
dest.setTotalTime(totalTime);
dest.setConnectedAnimations(fromAnimation, this.getAccessor());
Map<String, JointTransform> data1 = startPose.getJointTransformData();
Map<String, JointTransform> data2 = this.getPoseByTime(entitypatch, nextStartTime, 0.0F).getJointTransformData();
Set<String> joint1 = new HashSet<> (isOnSameLayer ? data1.keySet() : Set.of());
Set<String> joint2 = new HashSet<> (data2.keySet());
if (entitypatch.isLogicalClient()) {
JointMaskEntry entry = fromAnimation.get().getJointMaskEntry(entitypatch, false).orElse(null);
JointMaskEntry entry2 = this.getJointMaskEntry(entitypatch, true).orElse(null);
if (entry != null) {
joint1.removeIf((jointName) -> entry.isMasked(fromAnimation.get().getProperty(ClientAnimationProperties.LAYER_TYPE).orElse(Layer.LayerType.BASE_LAYER) == Layer.LayerType.BASE_LAYER ?
entitypatch.getClientAnimator().currentMotion() : entitypatch.getClientAnimator().currentCompositeMotion(), jointName));
}
if (entry2 != null) {
joint2.removeIf((jointName) -> entry2.isMasked(this.getProperty(ClientAnimationProperties.LAYER_TYPE).orElse(Layer.LayerType.BASE_LAYER) == Layer.LayerType.BASE_LAYER ?
entitypatch.getCurrentLivingMotion() : entitypatch.currentCompositeMotion, jointName));
}
}
joint1.addAll(joint2);
if (linkTime != totalTime) {
Map<String, JointTransform> firstPose = this.getPoseByTime(entitypatch, 0.0F, 0.0F).getJointTransformData();
for (String jointName : joint1) {
Keyframe[] keyframes = new Keyframe[3];
keyframes[0] = new Keyframe(0.0F, data1.get(jointName));
keyframes[1] = new Keyframe(linkTime, firstPose.get(jointName));
keyframes[2] = new Keyframe(totalTime, data2.get(jointName));
TransformSheet sheet = new TransformSheet(keyframes);
dest.getAnimationClip().addJointTransform(jointName, sheet);
}
} else {
for (String jointName : joint1) {
Keyframe[] keyframes = new Keyframe[2];
keyframes[0] = new Keyframe(0.0F, data1.get(jointName));
keyframes[1] = new Keyframe(totalTime, data2.get(jointName));
TransformSheet sheet = new TransformSheet(keyframes);
dest.getAnimationClip().addJointTransform(jointName, sheet);
}
}
}
@Override
public void begin(LivingEntityPatch<?> entitypatch) {
// Load if null
this.getAnimationClip();
// Please fix this implementation when minecraft supports any mixinable method that returns noPhysics variable
this.getProperty(StaticAnimationProperty.NO_PHYSICS).ifPresent(val -> {
if (val) {
entitypatch.getAnimator().getVariables().put(HAD_NO_PHYSICS, this.getAccessor(), entitypatch.getOriginal().noPhysics);
entitypatch.getOriginal().noPhysics = true;
}
});
if (entitypatch.isLogicalClient()) {
this.getProperty(ClientAnimationProperties.TRAIL_EFFECT).ifPresent(trailInfos -> {
int idx = 0;
for (TrailInfo trailInfo : trailInfos) {
double eid = Double.longBitsToDouble((long)entitypatch.getOriginal().getId());
double animid = Double.longBitsToDouble((long)this.getId());
double jointId = Double.longBitsToDouble((long)this.armature.get().searchJointByName(trailInfo.joint()).getId());
double index = Double.longBitsToDouble((long)idx++);
// RIG : RenderItemBase (combat weapon item render) strippé —
// TiedUp n'a pas d'items "actifs" porteurs de trails comme
// les weapons EF. Le trailInfo reste tel quel de la définition.
if (!trailInfo.playable()) {
continue;
}
entitypatch.getOriginal().level().addParticle(trailInfo.particle(), eid, 0, animid, jointId, index, 0);
}
});
}
this.getProperty(StaticAnimationProperty.ON_BEGIN_EVENTS).ifPresent(events -> {
for (SimpleEvent<?> event : events) {
event.execute(entitypatch, this.getAccessor(), 0.0F, 0.0F);
}
});
// RIG : PlayerEventListener (combat animation events) strippé.
// Les ON_BEGIN_EVENTS SimpleEvent continuent de fonctionner ci-dessus.
}
@Override
public void end(LivingEntityPatch<?> entitypatch, AssetAccessor<? extends DynamicAnimation> nextAnimation, boolean isEnd) {
// RIG : ANIMATION_END_EVENT fire strippé (PlayerEventListener combat).
this.getProperty(StaticAnimationProperty.ON_END_EVENTS).ifPresent((events) -> {
for (SimpleEvent<?> event : events) {
event.executeWithNewParams(entitypatch, this.getAccessor(), this.getTotalTime(), this.getTotalTime(), event.getParameters() == null ? AnimationParameters.of(isEnd) : AnimationParameters.addParameter(event.getParameters(), isEnd));
}
});
this.getProperty(StaticAnimationProperty.NO_PHYSICS).ifPresent((val) -> {
if (val) {
entitypatch.getOriginal().noPhysics = entitypatch.getAnimator().getVariables().getOrDefault(HAD_NO_PHYSICS, this.getAccessor());
}
});
entitypatch.getAnimator().getVariables().removeAll(this.getAccessor());
}
@Override
public void tick(LivingEntityPatch<?> entitypatch) {
this.getProperty(StaticAnimationProperty.NO_PHYSICS).ifPresent((val) -> {
if (val) {
entitypatch.getOriginal().noPhysics = true;
}
});
this.getProperty(StaticAnimationProperty.TICK_EVENTS).ifPresent((events) -> {
entitypatch.getAnimator().getPlayer(this.getAccessor()).ifPresent(player -> {
for (AnimationEvent<?, ?> event : events) {
float prevElapsed = player.getPrevElapsedTime();
float elapsed = player.getElapsedTime();
event.execute(entitypatch, this.getAccessor(), prevElapsed, elapsed);
}
});
});
}
@Override
public EntityState getState(LivingEntityPatch<?> entitypatch, float time) {
return new EntityState(this.getStatesMap(entitypatch, time));
}
@Override
public TypeFlexibleHashMap<StateFactor<?>> getStatesMap(LivingEntityPatch<?> entitypatch, float time) {
return this.stateSpectrum.getStateMap(entitypatch, time);
}
@Override
public <T> T getState(StateFactor<T> stateFactor, LivingEntityPatch<?> entitypatch, float time) {
return this.stateSpectrum.getSingleState(stateFactor, entitypatch, time);
}
@Override
public Optional<JointMaskEntry> getJointMaskEntry(LivingEntityPatch<?> entitypatch, boolean useCurrentMotion) {
return this.getProperty(ClientAnimationProperties.JOINT_MASK);
}
@Override
public void modifyPose(DynamicAnimation animation, Pose pose, LivingEntityPatch<?> entitypatch, float time, float partialTicks) {
entitypatch.poseTick(animation, pose, time, partialTicks);
this.getProperty(StaticAnimationProperty.POSE_MODIFIER).ifPresent((poseModifier) -> {
poseModifier.modify(animation, pose, entitypatch, time, partialTicks);
});
}
@Override
public boolean isStaticAnimation() {
return true;
}
@Override
public boolean doesHeadRotFollowEntityHead() {
return !this.getProperty(StaticAnimationProperty.FIXED_HEAD_ROTATION).orElse(false);
}
@Override
public int getId() {
return this.accessor.id();
}
@Override
public boolean equals(Object obj) {
if (obj instanceof StaticAnimation staticAnimation) {
if (this.accessor != null && staticAnimation.accessor != null) {
return this.getId() == staticAnimation.getId();
}
}
return super.equals(obj);
}
public ResourceLocation getLocation() {
return this.resourceLocation;
}
@Override
public ResourceLocation getRegistryName() {
return this.accessor.registryName();
}
public AssetAccessor<? extends Armature> getArmature() {
return this.armature;
}
@Override
public float getPlaySpeed(LivingEntityPatch<?> entitypatch, DynamicAnimation animation) {
return 1.0F;
}
@Override
public TransformSheet getCoord() {
return this.getProperty(ActionAnimationProperty.COORD).orElse(super.getCoord());
}
@Override
public String toString() {
String classPath = this.getClass().toString();
return classPath.substring(classPath.lastIndexOf(".") + 1) + " " + this.getLocation();
}
/**
* Internal use only
*/
@Deprecated
public StaticAnimation addPropertyUnsafe(AnimationProperty<?> propertyType, Object value) {
this.properties.put(propertyType, value);
this.getSubAnimations().forEach((subAnimation) -> subAnimation.get().addPropertyUnsafe(propertyType, value));
return this;
}
@SuppressWarnings("unchecked")
public <A extends StaticAnimation, V> A addProperty(StaticAnimationProperty<V> propertyType, V value) {
this.properties.put(propertyType, value);
this.getSubAnimations().forEach((subAnimation) -> subAnimation.get().addProperty(propertyType, value));
return (A)this;
}
@SuppressWarnings("unchecked")
@Override
public <V> Optional<V> getProperty(AnimationProperty<V> propertyType) {
return (Optional<V>) Optional.ofNullable(this.properties.get(propertyType));
}
@OnlyIn(Dist.CLIENT)
public Layer.Priority getPriority() {
return this.getProperty(ClientAnimationProperties.PRIORITY).orElse(Layer.Priority.LOWEST);
}
@OnlyIn(Dist.CLIENT)
public Layer.LayerType getLayerType() {
return this.getProperty(ClientAnimationProperties.LAYER_TYPE).orElse(LayerType.BASE_LAYER);
}
public Object getModifiedLinkState(StateFactor<?> factor, Object val, LivingEntityPatch<?> entitypatch, float elapsedTime) {
return val;
}
public List<AssetAccessor<? extends StaticAnimation>> getSubAnimations() {
return List.of();
}
@Override
public AnimationAccessor<? extends StaticAnimation> getRealAnimation() {
return this.getAccessor();
}
@SuppressWarnings("unchecked")
@Override
public <A extends DynamicAnimation> AnimationAccessor<A> getAccessor() {
return (AnimationAccessor<A>)this.accessor;
}
public void setAccessor(AnimationAccessor<? extends StaticAnimation> accessor) {
this.accessor = accessor;
}
@OnlyIn(Dist.CLIENT)
public void renderDebugging(PoseStack poseStack, MultiBufferSource buffer, LivingEntityPatch<?> entitypatch, float playTime, float partialTicks) {
// RIG : debug render des targets IK (RenderingTool.drawQuad) strippé.
// Pas d'IK en TiedUp. Reactivable Phase 2+ avec un helper drawQuad
// simple si on veut debug les joints.
}
@Override
public InverseKinematicsObject createSimulationData(InverseKinematicsProvider provider, InverseKinematicsSimulatable simOwner, InverseKinematicsSimulator.InverseKinematicsBuilder simBuilder) {
return new InverseKinematicsObject(simBuilder);
}
}

View File

@@ -0,0 +1,269 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.armature;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.Collection;
import java.util.Map;
import java.util.NoSuchElementException;
import com.google.common.collect.Maps;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.armature.JointTransform;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.asset.JsonAssetLoader;
import com.tiedup.remake.rig.math.OpenMatrix4f;
import com.tiedup.remake.rig.TiedUpRigConstants;
public class Armature {
private final String name;
private final Int2ObjectMap<Joint> jointById;
private final Map<String, Joint> jointByName;
private final Map<String, Joint.HierarchicalJointAccessor> pathIndexMap;
private final int jointCount;
private final OpenMatrix4f[] poseMatrices;
public final Joint rootJoint;
public Armature(String name, int jointNumber, Joint rootJoint, Map<String, Joint> jointMap) {
this.name = name;
this.jointCount = jointNumber;
this.rootJoint = rootJoint;
this.jointByName = jointMap;
this.jointById = new Int2ObjectOpenHashMap<>();
this.pathIndexMap = Maps.newHashMap();
this.jointByName.values().forEach((joint) -> {
this.jointById.put(joint.getId(), joint);
});
this.poseMatrices = OpenMatrix4f.allocateMatrixArray(this.jointCount);
}
protected Joint getOrLogException(Map<String, Joint> jointMap, String name) {
if (!jointMap.containsKey(name)) {
if (TiedUpRigConstants.IS_DEV_ENV) {
TiedUpRigConstants.LOGGER.debug("Cannot find the joint named " + name + " in " + this.getClass().getCanonicalName());
}
return Joint.EMPTY;
}
return jointMap.get(name);
}
public void setPose(Pose pose) {
this.getPoseTransform(this.rootJoint, new OpenMatrix4f(), pose, this.poseMatrices, false);
}
public void bakeOriginMatrices() {
this.rootJoint.initOriginTransform(new OpenMatrix4f());
}
public OpenMatrix4f[] getPoseMatrices() {
return this.poseMatrices;
}
/**
* @param applyOriginTransform if you need a final pose of the animations, give it false.
*/
public OpenMatrix4f[] getPoseAsTransformMatrix(Pose pose, boolean applyOriginTransform) {
OpenMatrix4f[] jointMatrices = new OpenMatrix4f[this.jointCount];
this.getPoseTransform(this.rootJoint, new OpenMatrix4f(), pose, jointMatrices, applyOriginTransform);
return jointMatrices;
}
private void getPoseTransform(Joint joint, OpenMatrix4f parentTransform, Pose pose, OpenMatrix4f[] jointMatrices, boolean applyOriginTransform) {
OpenMatrix4f result = pose.orElseEmpty(joint.getName()).getAnimationBoundMatrix(joint, parentTransform);
jointMatrices[joint.getId()] = result;
for (Joint joints : joint.getSubJoints()) {
this.getPoseTransform(joints, result, pose, jointMatrices, applyOriginTransform);
}
if (applyOriginTransform) {
result.mulBack(joint.getToOrigin());
}
}
/**
* Inapposite past perfect
*/
@Deprecated(forRemoval = true, since = "1.21.1")
public OpenMatrix4f getBindedTransformFor(Pose pose, Joint joint) {
return this.getBoundTransformByJointIndex(pose, this.searchPathIndex(joint.getName()).createAccessTicket(this.rootJoint));
}
public OpenMatrix4f getBoundTransformFor(Pose pose, Joint joint) {
return this.getBoundTransformByJointIndex(pose, this.searchPathIndex(joint.getName()).createAccessTicket(this.rootJoint));
}
public OpenMatrix4f getBoundTransformByJointIndex(Pose pose, Joint.AccessTicket pathIndices) {
return this.getBoundJointTransformRecursively(pose, this.rootJoint, new OpenMatrix4f(), pathIndices);
}
private OpenMatrix4f getBoundJointTransformRecursively(Pose pose, Joint joint, OpenMatrix4f parentTransform, Joint.AccessTicket pathIndices) {
JointTransform jt = pose.orElseEmpty(joint.getName());
OpenMatrix4f result = jt.getAnimationBoundMatrix(joint, parentTransform);
return pathIndices.hasNext() ? this.getBoundJointTransformRecursively(pose, pathIndices.next(), result, pathIndices) : result;
}
public boolean hasJoint(String name) {
return this.jointByName.containsKey(name);
}
public Joint searchJointById(int id) {
return this.jointById.get(id);
}
public Joint searchJointByName(String name) {
return this.jointByName.get(name);
}
/**
* Search and record joint path from root to terminal
*
* @param terminalJointName
* @return
*/
public Joint.HierarchicalJointAccessor searchPathIndex(String terminalJointName) {
return this.searchPathIndex(this.rootJoint, terminalJointName);
}
/**
* Search and record joint path to terminal
*
* @param start
* @param terminalJointName
* @return
*/
public Joint.HierarchicalJointAccessor searchPathIndex(Joint start, String terminalJointName) {
String signature = start.getName() + "-" + terminalJointName;
if (this.pathIndexMap.containsKey(signature)) {
return this.pathIndexMap.get(signature);
} else {
Joint.HierarchicalJointAccessor.Builder pathBuilder = start.searchPath(Joint.HierarchicalJointAccessor.builder(), terminalJointName);
Joint.HierarchicalJointAccessor accessor;
if (pathBuilder == null) {
throw new IllegalArgumentException("Failed to get joint path index for " + terminalJointName);
} else {
accessor = pathBuilder.build();
this.pathIndexMap.put(signature, accessor);
}
return accessor;
}
}
public void gatherAllJointsInPathToTerminal(String terminalJointName, Collection<String> jointsInPath) {
if (!this.jointByName.containsKey(terminalJointName)) {
throw new NoSuchElementException("No " + terminalJointName + " joint in this armature!");
}
Joint.HierarchicalJointAccessor pathIndices = this.searchPathIndex(terminalJointName);
Joint.AccessTicket accessTicket = pathIndices.createAccessTicket(this.rootJoint);
Joint joint = this.rootJoint;
jointsInPath.add(joint.getName());
while (accessTicket.hasNext()) {
jointsInPath.add(accessTicket.next().getName());
}
}
public int getJointNumber() {
return this.jointCount;
}
@Override
public String toString() {
return this.name;
}
public Armature deepCopy() {
Map<String, Joint> oldToNewJoint = Maps.newHashMap();
oldToNewJoint.put("empty", Joint.EMPTY);
Joint newRoot = this.copyHierarchy(this.rootJoint, oldToNewJoint);
newRoot.initOriginTransform(new OpenMatrix4f());
Armature newArmature = null;
// Uses reflection to keep the type of copied armature
try {
Constructor<? extends Armature> constructor = this.getClass().getConstructor(String.class, int.class, Joint.class, Map.class);
newArmature = constructor.newInstance(this.name, this.jointCount, newRoot, oldToNewJoint);
} catch (NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
throw new IllegalStateException("Armature copy failed! " + e);
}
return newArmature;
}
private Joint copyHierarchy(Joint joint, Map<String, Joint> oldToNewJoint) {
if (joint == Joint.EMPTY) {
return Joint.EMPTY;
}
Joint newJoint = new Joint(joint.getName(), joint.getId(), joint.getLocalTransform());
oldToNewJoint.put(joint.getName(), newJoint);
for (Joint subJoint : joint.getSubJoints()) {
newJoint.addSubJoints(this.copyHierarchy(subJoint, oldToNewJoint));
}
return newJoint;
}
public JsonObject toJsonObject() {
JsonObject root = new JsonObject();
JsonObject armature = new JsonObject();
JsonArray jointNamesArray = new JsonArray();
JsonArray jointHierarchy = new JsonArray();
this.jointById.int2ObjectEntrySet().stream().sorted((entry1, entry2) -> Integer.compare(entry1.getIntKey(), entry2.getIntKey())).forEach((entry) -> jointNamesArray.add(entry.getValue().getName()));
armature.add("joints", jointNamesArray);
armature.add("hierarchy", jointHierarchy);
exportJoint(jointHierarchy, this.rootJoint, true);
root.add("armature", armature);
return root;
}
private static void exportJoint(JsonArray parent, Joint joint, boolean root) {
JsonObject jointJson = new JsonObject();
jointJson.addProperty("name", joint.getName());
JsonArray transformMatrix = new JsonArray();
OpenMatrix4f localMatrixInBlender = new OpenMatrix4f(joint.getLocalTransform());
if (root) {
localMatrixInBlender.mulFront(OpenMatrix4f.invert(JsonAssetLoader.BLENDER_TO_MINECRAFT_COORD, null));
}
localMatrixInBlender.transpose();
localMatrixInBlender.toList().forEach(transformMatrix::add);
jointJson.add("transform", transformMatrix);
parent.add(jointJson);
if (!joint.getSubJoints().isEmpty()) {
JsonArray children = new JsonArray();
jointJson.add("children", children);
joint.getSubJoints().forEach((joint$2) -> exportJoint(children, joint$2, false));
}
}
}

View File

@@ -0,0 +1,119 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.armature;
import java.util.Map;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.armature.types.HumanLikeArmature;
public class HumanoidArmature extends Armature implements HumanLikeArmature {
public final Joint thighR;
public final Joint legR;
public final Joint kneeR;
public final Joint thighL;
public final Joint legL;
public final Joint kneeL;
public final Joint torso;
public final Joint chest;
public final Joint head;
public final Joint shoulderR;
public final Joint armR;
public final Joint handR;
public final Joint toolR;
public final Joint elbowR;
public final Joint shoulderL;
public final Joint armL;
public final Joint handL;
public final Joint toolL;
public final Joint elbowL;
public HumanoidArmature(String name, int jointNumber, Joint rootJoint, Map<String, Joint> jointMap) {
super(name, jointNumber, rootJoint, jointMap);
this.thighR = this.getOrLogException(jointMap, "Thigh_R");
this.legR = this.getOrLogException(jointMap, "Leg_R");
this.kneeR = this.getOrLogException(jointMap, "Knee_R");
this.thighL = this.getOrLogException(jointMap, "Thigh_L");
this.legL = this.getOrLogException(jointMap, "Leg_L");
this.kneeL = this.getOrLogException(jointMap, "Knee_L");
this.torso = this.getOrLogException(jointMap, "Torso");
this.chest = this.getOrLogException(jointMap, "Chest");
this.head = this.getOrLogException(jointMap, "Head");
this.shoulderR = this.getOrLogException(jointMap, "Shoulder_R");
this.armR = this.getOrLogException(jointMap, "Arm_R");
this.handR = this.getOrLogException(jointMap, "Hand_R");
this.toolR = this.getOrLogException(jointMap, "Tool_R");
this.elbowR = this.getOrLogException(jointMap, "Elbow_R");
this.shoulderL = this.getOrLogException(jointMap, "Shoulder_L");
this.armL = this.getOrLogException(jointMap, "Arm_L");
this.handL = this.getOrLogException(jointMap, "Hand_L");
this.toolL = this.getOrLogException(jointMap, "Tool_L");
this.elbowL = this.getOrLogException(jointMap, "Elbow_L");
}
@Override
public Joint leftToolJoint() {
return this.toolL;
}
@Override
public Joint rightToolJoint() {
return this.toolR;
}
@Override
public Joint backToolJoint() {
return this.chest;
}
@Override
public Joint leftHandJoint() {
return this.handL;
}
@Override
public Joint rightHandJoint() {
return this.handR;
}
@Override
public Joint leftArmJoint() {
return this.armL;
}
@Override
public Joint rightArmJoint() {
return this.armR;
}
@Override
public Joint leftLegJoint() {
return this.legL;
}
@Override
public Joint rightLegJoint() {
return this.legR;
}
@Override
public Joint leftThighJoint() {
return this.thighL;
}
@Override
public Joint rightThighJoint() {
return this.thighR;
}
@Override
public Joint headJoint() {
return this.head;
}
}

View File

@@ -0,0 +1,278 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.armature;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Queue;
import java.util.function.Consumer;
import javax.annotation.Nullable;
import org.jetbrains.annotations.ApiStatus;
import com.google.common.collect.Lists;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.math.OpenMatrix4f;
public class Joint {
public static final Joint EMPTY = new Joint("empty", -1, new OpenMatrix4f());
private final List<Joint> subJoints = Lists.newArrayList();
private final int jointId;
private final String jointName;
private final OpenMatrix4f localTransform;
private final OpenMatrix4f toOrigin = new OpenMatrix4f();
public Joint(String name, int jointId, OpenMatrix4f localTransform) {
this.jointId = jointId;
this.jointName = name;
this.localTransform = localTransform.unmodifiable();
}
public void addSubJoints(Joint... joints) {
for (Joint joint : joints) {
if (!this.subJoints.contains(joint)) {
this.subJoints.add(joint);
}
}
}
public void removeSubJoints(Joint... joints) {
for (Joint joint : joints) {
this.subJoints.remove(joint);
}
}
public List<Joint> getAllJoints() {
List<Joint> list = Lists.newArrayList();
this.getSubJoints(list);
return list;
}
public void iterSubJoints(Consumer<Joint> iterTask) {
iterTask.accept(this);
for (Joint joint : this.subJoints) {
joint.iterSubJoints(iterTask);
}
}
private void getSubJoints(List<Joint> list) {
list.add(this);
for (Joint joint : this.subJoints) {
joint.getSubJoints(list);
}
}
public void initOriginTransform(OpenMatrix4f parentTransform) {
OpenMatrix4f modelTransform = OpenMatrix4f.mul(parentTransform, this.localTransform, null);
OpenMatrix4f.invert(modelTransform, this.toOrigin);
for (Joint joint : this.subJoints) {
joint.initOriginTransform(modelTransform);
}
}
public OpenMatrix4f getLocalTransform() {
return this.localTransform;
}
public OpenMatrix4f getToOrigin() {
return this.toOrigin;
}
public List<Joint> getSubJoints() {
return this.subJoints;
}
// Null if index out of range
@Nullable
public Joint getSubJoint(int index) {
if (index < 0 || this.subJoints.size() <= index) {
return null;
}
return this.subJoints.get(index);
}
public String getName() {
return this.jointName;
}
public int getId() {
return this.jointId;
}
@Override
public boolean equals(Object o) {
if (o instanceof Joint joint) {
return this.jointName.equals(joint.jointName) && this.jointId == joint.jointId;
} else {
return super.equals(o);
}
}
@Override
public int hashCode() {
return this.jointName.hashCode() ^ this.jointId;
}
/**
* Use the method that memorize path search results. {@link Armature#searchPathIndex(Joint, String)}
*
* @param builder
* @param jointName
* @return
*/
@ApiStatus.Internal
public HierarchicalJointAccessor.Builder searchPath(HierarchicalJointAccessor.Builder builder, String jointName) {
if (jointName.equals(this.getName())) {
return builder;
} else {
int i = 0;
for (Joint subJoint : this.subJoints) {
HierarchicalJointAccessor.Builder nextBuilder = subJoint.searchPath(builder.append(i), jointName);
i++;
if (nextBuilder != null) {
return nextBuilder;
}
}
return null;
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("\nid: " + this.jointId);
sb.append("\nname: " + this.jointName);
sb.append("\nlocal transform: " + this.localTransform);
sb.append("\nto origin: " + this.toOrigin);
sb.append("\nchildren: [");
int idx = 0;
for (Joint joint : this.subJoints) {
idx++;
sb.append(joint.jointName);
if (idx != this.subJoints.size()) {
sb.append(", ");
}
}
sb.append("]\n");
return sb.toString();
}
public String printIncludingChildren() {
StringBuilder sb = new StringBuilder();
sb.append(this.toString());
for (Joint joint : this.subJoints) {
sb.append(joint.printIncludingChildren());
}
return sb.toString();
}
public static class HierarchicalJointAccessor {
private Queue<Integer> indicesToTerminal;
private final String signature;
private HierarchicalJointAccessor(Builder builder) {
this.indicesToTerminal = builder.indicesToTerminal;
this.signature = builder.signature;
}
public AccessTicket createAccessTicket(Joint rootJoint) {
return new AccessTicket(this.indicesToTerminal, rootJoint);
}
@Override
public boolean equals(Object o) {
if (o instanceof HierarchicalJointAccessor accessor) {
this.signature.equals(accessor.signature);
}
return super.equals(o);
}
@Override
public int hashCode() {
return this.signature.hashCode();
}
public static Builder builder() {
return new Builder(new LinkedList<> (), "");
}
public static class Builder {
private Queue<Integer> indicesToTerminal;
private String signature;
private Builder(Queue<Integer> indicesToTerminal, String signature) {
this.indicesToTerminal = indicesToTerminal;
this.signature = signature;
}
public Builder append(int index) {
String signatureNext;
if (this.indicesToTerminal.isEmpty()) {
signatureNext = this.signature + String.valueOf(index);
} else {
signatureNext = this.signature + "-" + String.valueOf(index);
}
Queue<Integer> nextQueue = new LinkedList<> (this.indicesToTerminal);
nextQueue.add(index);
return new Builder(nextQueue, signatureNext);
}
public HierarchicalJointAccessor build() {
return new HierarchicalJointAccessor(this);
}
}
}
public static class AccessTicket implements Iterator<Joint> {
Queue<Integer> accecssStack;
Joint joint;
private AccessTicket(Queue<Integer> indicesToTerminal, Joint rootJoint) {
this.accecssStack = new LinkedList<> (indicesToTerminal);
this.joint = rootJoint;
}
public boolean hasNext() {
return !this.accecssStack.isEmpty();
}
public Joint next() {
if (this.hasNext()) {
int nextIndex = this.accecssStack.poll();
this.joint = this.joint.subJoints.get(nextIndex);
} else {
throw new NoSuchElementException();
}
return this.joint;
}
}
}

View File

@@ -0,0 +1,215 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.armature;
import java.util.Map;
import org.joml.Quaternionf;
import com.google.common.collect.Maps;
import net.minecraft.util.Mth;
import com.tiedup.remake.rig.math.AnimationTransformEntry;
import com.tiedup.remake.rig.math.MathUtils;
import com.tiedup.remake.rig.math.MatrixOperation;
import com.tiedup.remake.rig.math.OpenMatrix4f;
import com.tiedup.remake.rig.math.Vec3f;
public class JointTransform {
public static final String ANIMATION_TRANSFORM = "animation_transform";
public static final String JOINT_LOCAL_TRANSFORM = "joint_local_transform";
public static final String PARENT = "parent";
public static final String RESULT1 = "front_result";
public static final String RESULT2 = "overwrite_rotation";
public static class TransformEntry {
public final MatrixOperation multiplyFunction;
public final JointTransform transform;
public TransformEntry(MatrixOperation multiplyFunction, JointTransform transform) {
this.multiplyFunction = multiplyFunction;
this.transform = transform;
}
}
private final Map<String, TransformEntry> entries = Maps.newHashMap();
private final Vec3f translation;
private final Vec3f scale;
private final Quaternionf rotation;
public JointTransform(Vec3f translation, Quaternionf rotation, Vec3f scale) {
this.translation = translation;
this.rotation = rotation;
this.scale = scale;
}
public Vec3f translation() {
return this.translation;
}
public Quaternionf rotation() {
return this.rotation;
}
public Vec3f scale() {
return this.scale;
}
public void clearTransform() {
this.translation.set(0.0F, 0.0F, 0.0F);
this.rotation.set(0.0F, 0.0F, 0.0F, 1.0F);
this.scale.set(1.0F, 1.0F, 1.0F);
}
public JointTransform copy() {
return JointTransform.empty().copyFrom(this);
}
public JointTransform copyFrom(JointTransform jt) {
Vec3f newV = jt.translation();
Quaternionf newQ = jt.rotation();
Vec3f newS = jt.scale;
this.translation.set(newV);
this.rotation.set(newQ);
this.scale.set(newS);
this.entries.putAll(jt.entries);
return this;
}
public void jointLocal(JointTransform transform, MatrixOperation multiplyFunction) {
this.entries.put(JOINT_LOCAL_TRANSFORM, new TransformEntry(multiplyFunction, this.mergeIfExist(JOINT_LOCAL_TRANSFORM, transform)));
}
public void parent(JointTransform transform, MatrixOperation multiplyFunction) {
this.entries.put(PARENT, new TransformEntry(multiplyFunction, this.mergeIfExist(PARENT, transform)));
}
public void animationTransform(JointTransform transform, MatrixOperation multiplyFunction) {
this.entries.put(ANIMATION_TRANSFORM, new TransformEntry(multiplyFunction, this.mergeIfExist(ANIMATION_TRANSFORM, transform)));
}
public void frontResult(JointTransform transform, MatrixOperation multiplyFunction) {
this.entries.put(RESULT1, new TransformEntry(multiplyFunction, this.mergeIfExist(RESULT1, transform)));
}
public void overwriteRotation(JointTransform transform) {
this.entries.put(RESULT2, new TransformEntry(OpenMatrix4f::mul, this.mergeIfExist(RESULT2, transform)));
}
public JointTransform mergeIfExist(String entryName, JointTransform transform) {
if (this.entries.containsKey(entryName)) {
TransformEntry transformEntry = this.entries.get(entryName);
return JointTransform.mul(transform, transformEntry.transform, transformEntry.multiplyFunction);
}
return transform;
}
public OpenMatrix4f getAnimationBoundMatrix(Joint joint, OpenMatrix4f parentTransform) {
AnimationTransformEntry animationTransformEntry = new AnimationTransformEntry();
for (Map.Entry<String, TransformEntry> entry : this.entries.entrySet()) {
animationTransformEntry.put(entry.getKey(), entry.getValue().transform.toMatrix(), entry.getValue().multiplyFunction);
}
animationTransformEntry.put(ANIMATION_TRANSFORM, this.toMatrix(), OpenMatrix4f::mul);
animationTransformEntry.put(JOINT_LOCAL_TRANSFORM, joint.getLocalTransform());
animationTransformEntry.put(PARENT, parentTransform);
return animationTransformEntry.getResult();
}
public OpenMatrix4f toMatrix() {
return new OpenMatrix4f().translate(this.translation).mulBack(OpenMatrix4f.fromQuaternion(this.rotation)).scale(this.scale);
}
@Override
public String toString() {
return String.format("translation:%s, rotation:%s, scale:%s %d entries ", this.translation, this.rotation, this.scale, this.entries.size());
}
public static JointTransform interpolateTransform(JointTransform prev, JointTransform next, float progression, JointTransform dest) {
if (dest == null) {
dest = JointTransform.empty();
}
MathUtils.lerpVector(prev.translation, next.translation, progression, dest.translation);
MathUtils.lerpQuaternion(prev.rotation, next.rotation, progression, dest.rotation);
MathUtils.lerpVector(prev.scale, next.scale, progression, dest.scale);
return dest;
}
public static JointTransform interpolate(JointTransform prev, JointTransform next, float progression) {
return interpolate(prev, next, progression, null);
}
public static JointTransform interpolate(JointTransform prev, JointTransform next, float progression, JointTransform dest) {
if (dest == null) {
dest = JointTransform.empty();
}
if (prev == null || next == null) {
dest.clearTransform();
return dest;
}
progression = Mth.clamp(progression, 0.0F, 1.0F);
interpolateTransform(prev, next, progression, dest);
dest.entries.clear();
for (Map.Entry<String, TransformEntry> entry : prev.entries.entrySet()) {
JointTransform transform = next.entries.containsKey(entry.getKey()) ? next.entries.get(entry.getKey()).transform : JointTransform.empty();
dest.entries.put(entry.getKey(), new TransformEntry(entry.getValue().multiplyFunction, interpolateTransform(entry.getValue().transform, transform, progression, null)));
}
for (Map.Entry<String, TransformEntry> entry : next.entries.entrySet()) {
if (!dest.entries.containsKey(entry.getKey())) {
dest.entries.put(entry.getKey(), new TransformEntry(entry.getValue().multiplyFunction, interpolateTransform(JointTransform.empty(), entry.getValue().transform, progression, null)));
}
}
return dest;
}
public static JointTransform fromMatrixWithoutScale(OpenMatrix4f matrix) {
return new JointTransform(matrix.toTranslationVector(), matrix.toQuaternion(), new Vec3f(1.0F, 1.0F, 1.0F));
}
public static JointTransform translation(Vec3f vec) {
return JointTransform.translationRotation(vec, new Quaternionf(0.0F, 0.0F, 0.0F, 1.0F));
}
public static JointTransform rotation(Quaternionf quat) {
return JointTransform.translationRotation(new Vec3f(0.0F, 0.0F, 0.0F), quat);
}
public static JointTransform scale(Vec3f vec) {
return new JointTransform(new Vec3f(0.0F, 0.0F, 0.0F), new Quaternionf(0.0F, 0.0F, 0.0F, 1.0F), vec);
}
public static JointTransform fromMatrix(OpenMatrix4f matrix) {
return new JointTransform(matrix.toTranslationVector(), matrix.toQuaternion(), matrix.toScaleVector());
}
public static JointTransform translationRotation(Vec3f vec, Quaternionf quat) {
return new JointTransform(vec, quat, new Vec3f(1.0F, 1.0F, 1.0F));
}
public static JointTransform mul(JointTransform left, JointTransform right, MatrixOperation operation) {
return JointTransform.fromMatrix(operation.mul(left.toMatrix(), right.toMatrix(), null));
}
public static JointTransform fromPrimitives(float locX, float locY, float locZ, float quatX, float quatY, float quatZ, float quatW, float scaX, float scaY, float scaZ) {
return new JointTransform(new Vec3f(locX, locY, locZ), new Quaternionf(quatX, quatY, quatZ, quatW), new Vec3f(scaX, scaY, scaZ));
}
public static JointTransform empty() {
return new JointTransform(new Vec3f(0.0F, 0.0F, 0.0F), new Quaternionf(0.0F, 0.0F, 0.0F, 1.0F), new Vec3f(1.0F, 1.0F, 1.0F));
}
}

View File

@@ -0,0 +1,340 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.armature.datapack;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import javax.annotation.Nullable;
import org.joml.Quaternionf;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.math.OpenMatrix4f;
import com.tiedup.remake.rig.math.Vec3f;
/**
* Descriptor immuable d'une armature custom chargee depuis un datapack
* ({@code data/<ns>/tiedup/armatures/<name>.json}).
*
* <h2>But</h2>
* <p>Permet a un modder de definir une armature custom (quadruped, centaure,
* neko, ...) en JSON sans ecrire de code Java. Cette classe est la
* representation parsee du JSON ; elle sait se valider et se convertir en
* {@link Armature} runtime via {@link #toRuntimeArmature()}.</p>
*
* <h2>Format JSON attendu</h2>
* <pre>{@code
* {
* "description": "Four-legged pet creature armature",
* "root_joint": "Root",
* "joints": {
* "Root": {
* "id": 0,
* "parent": null,
* "translation": [0.0, 0.0, 0.0],
* "rotation": [0.0, 0.0, 0.0, 1.0],
* "children": ["Torso"]
* },
* "Torso": {
* "id": 1,
* "parent": "Root",
* "translation": [0.0, 12.0, 0.0],
* "rotation": [0.0, 0.0, 0.0, 1.0],
* "children": []
* }
* }
* }
* }</pre>
*
* <h2>Invariants (enforces par {@link #validate()})</h2>
* <ul>
* <li>{@code root_joint} doit exister dans {@code joints}.</li>
* <li>Tous les {@code parent} (sauf null) et tous les {@code children}
* doivent referencer un joint declare dans la map.</li>
* <li>Les {@code id} des joints doivent etre uniques et contigus a partir
* de 0 (donc 0..N-1 exactement, ou N = taille de la map).</li>
* <li>La hierarchie parent-children doit former un DAG acyclique a racine
* unique (le root_joint).</li>
* <li>Le nombre total de joints est plafonne a
* {@link TiedUpRigConstants#MAX_JOINTS}.</li>
* <li>Relation parent-child bidirectionnelle coherente : si A liste B dans
* {@code children}, B doit avoir {@code parent = A}.</li>
* </ul>
*
* <h2>Quaternion convention</h2>
* <p>Format <b>xyzw</b> (JOML standard, aligne sur {@link Quaternionf}).
* Une rotation identity = {@code [0, 0, 0, 1]}.</p>
*/
public record ArmatureDefinition(
ResourceLocation id,
String description,
String rootJointName,
Map<String, JointDefinition> joints
) {
public ArmatureDefinition {
// Defensive immutable wrap for the record's invariants. The loader
// already passes LinkedHashMap but callers who construct directly in
// tests shouldn't be able to mutate the internal state post-hoc.
joints = Collections.unmodifiableMap(new LinkedHashMap<>(joints));
}
/**
* Descriptor immuable d'un seul joint.
*
* @param name nom du joint (cle dans la map parente)
* @param jointId id int unique [0..N-1]
* @param parentName nom du joint parent, ou null si c'est le root
* @param translation translation locale (relative au parent)
* @param rotation rotation locale quaternion xyzw
* @param childrenNames liste des joints enfants (par nom, ordre preserve)
*/
public record JointDefinition(
String name,
int jointId,
@Nullable String parentName,
Vec3f translation,
Quaternionf rotation,
List<String> childrenNames
) {
public JointDefinition {
Objects.requireNonNull(name, "joint name");
Objects.requireNonNull(translation, "translation");
Objects.requireNonNull(rotation, "rotation");
Objects.requireNonNull(childrenNames, "childrenNames");
childrenNames = List.copyOf(childrenNames);
}
}
/**
* Verifie la coherence structurelle du descriptor.
*
* @return {@link Optional#empty()} si la structure est valide, sinon un
* {@link Optional} contenant un message d'erreur humain-lisible
* (premiere erreur detectee — la validation s'arrete au premier
* probleme pour eviter un deluge de logs).
*/
public Optional<String> validate() {
if (joints.isEmpty()) {
return Optional.of("joints map is empty");
}
if (joints.size() > TiedUpRigConstants.MAX_JOINTS) {
return Optional.of(
"too many joints: " + joints.size() + " > MAX_JOINTS="
+ TiedUpRigConstants.MAX_JOINTS
);
}
if (rootJointName == null || rootJointName.isEmpty()) {
return Optional.of("root_joint is null or empty");
}
if (!joints.containsKey(rootJointName)) {
return Optional.of(
"root_joint '" + rootJointName + "' is not declared in the joints map"
);
}
// Check each joint's internal consistency (name match, id range,
// parent exists, children exist, uniqueness).
Set<Integer> seenIds = new HashSet<>();
int jointCount = joints.size();
for (Map.Entry<String, JointDefinition> e : joints.entrySet()) {
String declaredName = e.getKey();
JointDefinition j = e.getValue();
if (!declaredName.equals(j.name())) {
return Optional.of(
"joint map key '" + declaredName + "' does not match joint.name '"
+ j.name() + "'"
);
}
int jid = j.jointId();
if (jid < 0 || jid >= jointCount) {
return Optional.of(
"joint '" + declaredName + "' has id " + jid
+ " outside the contiguous range [0.."
+ (jointCount - 1) + "]"
);
}
if (!seenIds.add(jid)) {
return Optional.of(
"duplicate joint id " + jid + " on joint '" + declaredName + "'"
);
}
if (j.parentName() != null && !joints.containsKey(j.parentName())) {
return Optional.of(
"joint '" + declaredName + "' references unknown parent '"
+ j.parentName() + "'"
);
}
for (String child : j.childrenNames()) {
if (!joints.containsKey(child)) {
return Optional.of(
"joint '" + declaredName + "' references unknown child '"
+ child + "'"
);
}
}
}
// The above "id in [0..N-1] + unique" implies ids are exactly the set
// {0,1,...,N-1} (pigeonhole) — contiguity is therefore guaranteed by
// the uniqueness + range checks, no further pass needed.
// Root must have parent = null; all non-root joints must have a
// non-null parent. Verifies that there's exactly one root in the set.
JointDefinition rootDef = joints.get(rootJointName);
if (rootDef.parentName() != null) {
return Optional.of(
"root_joint '" + rootJointName + "' must have parent = null, got '"
+ rootDef.parentName() + "'"
);
}
for (Map.Entry<String, JointDefinition> e : joints.entrySet()) {
if (e.getKey().equals(rootJointName)) continue;
if (e.getValue().parentName() == null) {
return Optional.of(
"joint '" + e.getKey() + "' has no parent but is not the root_joint "
+ "'" + rootJointName + "'"
);
}
}
// Bidirectional coherence : if A lists B in children, B must have
// parent=A. Detects orphans + bogus child refs up-front.
for (Map.Entry<String, JointDefinition> e : joints.entrySet()) {
String parentName = e.getKey();
for (String childName : e.getValue().childrenNames()) {
JointDefinition child = joints.get(childName);
if (!parentName.equals(child.parentName())) {
return Optional.of(
"joint '" + parentName + "' lists '" + childName
+ "' as child, but '" + childName + "'.parent = '"
+ child.parentName() + "' (mismatch)"
);
}
}
}
// DAG / connectivity check: BFS from the root, every joint must be
// reachable exactly once. Detects cycles (would require infinite
// reachability — bounded by seen-set, any loop shows up as "child
// visited twice"), detached subtrees, and disconnected components.
Set<String> visited = new LinkedHashSet<>();
Deque<String> queue = new ArrayDeque<>();
queue.add(rootJointName);
visited.add(rootJointName);
while (!queue.isEmpty()) {
String current = queue.poll();
JointDefinition jd = joints.get(current);
for (String child : jd.childrenNames()) {
if (!visited.add(child)) {
return Optional.of(
"cycle or duplicate parent detected: joint '" + child
+ "' is reachable from more than one path "
+ "(parent chain is not a tree)"
);
}
queue.add(child);
}
}
if (visited.size() != joints.size()) {
List<String> unreachable = new ArrayList<>(joints.keySet());
unreachable.removeAll(visited);
return Optional.of(
"joints not reachable from root '" + rootJointName + "': "
+ unreachable
);
}
return Optional.empty();
}
/**
* Convertit ce descriptor en {@link Armature} runtime.
*
* <p>Pre-condition : {@link #validate()} doit avoir retourne
* {@link Optional#empty()}. Si ce n'est pas le cas, cette methode peut
* throw {@link IllegalStateException} lors de la construction (ex :
* child introuvable). Le loader public
* ({@link ArmatureReloadListener}) appelle toujours validate() avant.</p>
*
* <p>L'armature retournee a ses {@code toOrigin} matrices calcules via
* {@link Armature#bakeOriginMatrices()} — prete a etre utilisee par
* le renderer sans etape supplementaire.</p>
*
* @return un {@link Armature} avec la hierarchie + les localTransform
* baked depuis (translation, rotation).
* @throws IllegalStateException si le descriptor est structurellement
* invalide (validate() devrait avoir detecte
* ca en amont).
*/
public Armature toRuntimeArmature() {
Map<String, Joint> joints = new LinkedHashMap<>(this.joints.size());
// Pass 1 : create every Joint with its localTransform (translation +
// rotation composed into an OpenMatrix4f). No parent-child wiring yet.
for (JointDefinition def : this.joints.values()) {
OpenMatrix4f local = OpenMatrix4f.fromQuaternion(def.rotation())
.translate(def.translation());
joints.put(def.name(), new Joint(def.name(), def.jointId(), local));
}
// Pass 2 : wire up the hierarchy. Children are added in the order
// they appear in the JSON's children array — this determines the
// subJoints[i] index used by Joint.HierarchicalJointAccessor, so we
// must preserve it verbatim.
for (JointDefinition def : this.joints.values()) {
Joint parentJoint = joints.get(def.name());
for (String childName : def.childrenNames()) {
Joint childJoint = joints.get(childName);
if (childJoint == null) {
throw new IllegalStateException(
"child joint '" + childName + "' not found while building runtime "
+ "armature '" + id + "' — did you call validate() first?"
);
}
parentJoint.addSubJoints(childJoint);
}
}
Joint rootJoint = joints.get(rootJointName);
if (rootJoint == null) {
// Should never happen if validate() passed.
throw new IllegalStateException(
"root joint '" + rootJointName + "' missing after pass 1 — "
+ "validate() should have caught this"
);
}
Armature armature = new Armature(id.toString(), joints.size(), rootJoint, joints);
armature.bakeOriginMatrices();
return armature;
}
}

View File

@@ -0,0 +1,358 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.armature.datapack;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import org.joml.Quaternionf;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
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 com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.armature.datapack.ArmatureDefinition.JointDefinition;
import com.tiedup.remake.rig.math.Vec3f;
/**
* Scanne les fichiers JSON {@code data/<ns>/tiedup/armatures/<name>.json} et
* enregistre chaque definition d'armature custom comme un {@link Armature}
* runtime queryable via {@link #get(ResourceLocation)}.
*
* <h2>But</h2>
* <p>Permet a un modder / resourcepack-maker de definir des armatures custom
* (quadruped, centaure, neko, ...) en JSON — zero code Java. Le format est
* decrit dans {@link ArmatureDefinition}.</p>
*
* <h2>Lookup flow</h2>
* <p>{@link com.tiedup.remake.rig.TiedUpArmatures#get(ResourceLocation)} delegue
* a cette classe pour tous les IDs qui ne sont pas {@code tiedup:biped} (ni
* son alias long-form). Le biped builtin reste hardcode en Java pour
* performance + compatibilite backward.</p>
*
* <h2>Full reload on apply</h2>
* <p>A chaque {@code /reload} (server) ou {@code F3+T} (client) le registre
* est <b>entierement vide puis repopule</b>. Contrairement a
* {@link com.tiedup.remake.rig.anim.LivingMotionReloadListener} qui maintient
* un cache JVM-wide pour preserver les ordinals (les enums sont sensibles),
* les armatures n'ont pas de contrat d'ordinal stable — une armature peut
* etre supprimee / renommee / restructuree par un datapack reload sans casser
* un invariant global. Les consumers (renderer, animations) se contentent
* de resoudre l'ID a chaque frame, un nouvel {@link Armature} au meme ID est
* transparent pour eux.</p>
*
* <p><b>Attention</b> : les {@link Armature} produites par cette classe ne
* sont PAS deep-copied. Si une animation binde une reference a un {@code Joint}
* via {@code Armature.searchJointByName} et qu'un reload reconstruit une
* nouvelle Armature au meme ID, la reference originale devient orpheline
* (la vieille instance existe toujours tant que l'animation la retient). Les
* consumers doivent re-resoudre apres reload — c'est deja le pattern de
* {@link com.tiedup.remake.rig.util.InstantiateInvoker} (resolution via
* {@link com.tiedup.remake.rig.TiedUpArmatures#get(ResourceLocation)}).</p>
*
* <h2>Side & threading</h2>
* <p>Registered server-side via {@code AddReloadListenerEvent} et client-side
* via {@code RegisterClientReloadListenersEvent}. Sur server integre, les deux
* hooks pointent vers le meme {@link ConcurrentHashMap} — pas de double
* registre, pas de race (apply() est appele sequentiellement sur le server
* thread ou le client render thread, jamais simultanement).</p>
*
* <h2>Limitations connues</h2>
* <ul>
* <li>Les animations Blender-authored qui binds a une armature custom doivent
* etre recompilees si la structure de l'armature change (nouveaux
* joints, reordering des IDs). Pas de retargeting cross-armature (Phase 5+).</li>
* <li>Si un JSON est malforme (structure invalide), il est skip avec un
* WARN ; le reste du batch continue.</li>
* <li>Les armatures identity (toutes localTransform = identity) rendent un
* mesh "effondre a l'origine" — c'est aux auteurs du JSON de fournir
* des translations + rotations sensees depuis Blender.</li>
* </ul>
*/
public class ArmatureReloadListener extends SimpleJsonResourceReloadListener {
/** Dossier scanne : {@code data/<ns>/tiedup/armatures/*.json}. */
public static final String DIRECTORY = "tiedup/armatures";
/**
* Registre runtime des armatures datapack. Re-populated at every reload.
* {@link ConcurrentHashMap} protege les reads concurrents depuis les
* consumers (rendering) pendant qu'un reload en cours repopule.
*/
private static final Map<ResourceLocation, Armature> DATAPACK_ARMATURES =
new ConcurrentHashMap<>();
public ArmatureReloadListener() {
super(new GsonBuilder().create(), DIRECTORY);
}
/**
* Resout une armature datapack par son ResourceLocation.
*
* @param id identifiant namespace:path (ex. {@code mymod:quadruped})
* @return l'{@link Armature} enregistree, ou {@code null} si aucun JSON
* n'a charge ce ID.
*/
@Nullable
public static Armature get(ResourceLocation id) {
return DATAPACK_ARMATURES.get(id);
}
/** Nombre d'armatures datapack enregistrees actuellement. */
public static int size() {
return DATAPACK_ARMATURES.size();
}
/** Vue immutable du registre, expose pour debug / tests. */
public static Map<ResourceLocation, Armature> view() {
return Collections.unmodifiableMap(DATAPACK_ARMATURES);
}
/**
* Test hook — vide le registre. En prod le clear() est implicite dans
* {@link #apply} au debut de chaque reload, pas besoin d'y toucher a la
* main.
*/
public static void clearForTests() {
DATAPACK_ARMATURES.clear();
}
/**
* Hook d'apply direct pour tests — {@link SimpleJsonResourceReloadListener#apply}
* est {@code protected}, ce helper l'expose en public pour que les tests
* cross-package puissent alimenter le registry sans bootstrap MC.
*
* <p>Le {@code ResourceManager} et le {@code ProfilerFiller} ne sont pas
* lus par notre {@code apply}, on peut passer {@code null} en test.</p>
*/
public void applyForTests(Map<ResourceLocation, JsonElement> data) {
this.apply(data, null, null);
}
@Override
protected void apply(
Map<ResourceLocation, JsonElement> objectIn,
ResourceManager resourceManager,
ProfilerFiller profileFiller
) {
// Full reset : datapack armatures are ephemeral (no ordinal contract).
// A JSON deleted between two reloads must disappear from the registry.
DATAPACK_ARMATURES.clear();
// Stable ordering — if two JSON files are processed in the same reload
// and produce the same WARN-log output, TreeMap ensures reproducibility
// between JVM boots for debugging.
Map<ResourceLocation, JsonElement> sorted = new TreeMap<>(objectIn);
int loaded = 0;
int skipped = 0;
for (Map.Entry<ResourceLocation, JsonElement> entry : sorted.entrySet()) {
ResourceLocation id = entry.getKey();
JsonElement element = entry.getValue();
try {
ArmatureDefinition def = parseDefinition(id, element);
Optional<String> err = def.validate();
if (err.isPresent()) {
TiedUpRigConstants.LOGGER.warn(
"[ArmatureReloadListener] Invalid armature {}: {}",
id, err.get()
);
skipped++;
continue;
}
Armature armature = def.toRuntimeArmature();
DATAPACK_ARMATURES.put(id, armature);
// INFO (was DEBUG) — datapack-loaded custom armatures are visible
// at the default log level, providing a gameday smoke test for
// D6 wiring. Volume is bounded by the number of files in
// data/<ns>/tiedup/armatures/, currently 0 in vanilla setups.
TiedUpRigConstants.LOGGER.info(
"[ArmatureReloadListener] Registered armature: {} ({} joints)",
id, armature.getJointNumber()
);
loaded++;
} catch (Exception e) {
TiedUpRigConstants.LOGGER.warn(
"[ArmatureReloadListener] Failed to parse armature {}: {}",
id, e.getMessage()
);
skipped++;
}
}
// Summary line — emitted unconditionally to confirm the listener ran.
// The builtin BIPED is registered separately in TiedUpArmatures (Java),
// not here ; this counter only reflects datapack JSONs from
// data/<ns>/tiedup/armatures/.
TiedUpRigConstants.LOGGER.info(
"[ArmatureReloadListener] Datapack armature reload : {} custom armature(s) "
+ "registered ({} skipped, builtin BIPED active)",
loaded, skipped
);
}
/**
* Parse un JsonElement en {@link ArmatureDefinition}. Ne valide pas la
* coherence structurelle (c'est le role de
* {@link ArmatureDefinition#validate()}) — se contente de mapper le JSON
* brut sur la structure Java.
*
* @throws JsonStructureException si le JSON est mal forme (champ manquant,
* type incorrect, taille de tableau fausse)
*/
private static ArmatureDefinition parseDefinition(ResourceLocation id, JsonElement element) {
if (!element.isJsonObject()) {
throw new JsonStructureException(
"top-level JSON is not an object (got " + element.getClass().getSimpleName() + ")"
);
}
JsonObject obj = element.getAsJsonObject();
String description = readOptionalString(obj, "description", "");
String rootJointName = readRequiredString(obj, "root_joint");
if (!obj.has("joints") || !obj.get("joints").isJsonObject()) {
throw new JsonStructureException("missing or non-object 'joints'");
}
JsonObject jointsObj = obj.getAsJsonObject("joints");
Map<String, JointDefinition> joints = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> e : jointsObj.entrySet()) {
String name = e.getKey();
if (!e.getValue().isJsonObject()) {
throw new JsonStructureException(
"joint '" + name + "' is not a JSON object"
);
}
joints.put(name, parseJoint(name, e.getValue().getAsJsonObject()));
}
return new ArmatureDefinition(id, description, rootJointName, joints);
}
private static JointDefinition parseJoint(String name, JsonObject obj) {
if (!obj.has("id")) {
throw new JsonStructureException("joint '" + name + "' missing 'id'");
}
int jointId = obj.get("id").getAsInt();
String parent = null;
if (obj.has("parent") && !obj.get("parent").isJsonNull()) {
parent = obj.get("parent").getAsString();
}
Vec3f translation = readVec3(obj, "translation", "joint '" + name + "'");
Quaternionf rotation = readQuaternion(obj, "rotation", "joint '" + name + "'");
List<String> children = new ArrayList<>();
if (obj.has("children")) {
JsonElement childrenEl = obj.get("children");
if (!childrenEl.isJsonArray()) {
throw new JsonStructureException(
"joint '" + name + "'.children is not a JSON array"
);
}
for (JsonElement c : childrenEl.getAsJsonArray()) {
children.add(c.getAsString());
}
}
return new JointDefinition(name, jointId, parent, translation, rotation, children);
}
private static Vec3f readVec3(JsonObject obj, String key, String context) {
if (!obj.has(key)) {
throw new JsonStructureException(context + " missing '" + key + "'");
}
JsonElement el = obj.get(key);
if (!el.isJsonArray()) {
throw new JsonStructureException(
context + "." + key + " is not a JSON array"
);
}
JsonArray arr = el.getAsJsonArray();
if (arr.size() != 3) {
throw new JsonStructureException(
context + "." + key + " must have 3 elements, got " + arr.size()
);
}
return new Vec3f(arr.get(0).getAsFloat(), arr.get(1).getAsFloat(), arr.get(2).getAsFloat());
}
private static Quaternionf readQuaternion(JsonObject obj, String key, String context) {
if (!obj.has(key)) {
throw new JsonStructureException(context + " missing '" + key + "'");
}
JsonElement el = obj.get(key);
if (!el.isJsonArray()) {
throw new JsonStructureException(
context + "." + key + " is not a JSON array"
);
}
JsonArray arr = el.getAsJsonArray();
if (arr.size() != 4) {
throw new JsonStructureException(
context + "." + key + " must have 4 elements (xyzw), got " + arr.size()
);
}
// xyzw convention — JOML default. Identity = [0, 0, 0, 1].
return new Quaternionf(
arr.get(0).getAsFloat(),
arr.get(1).getAsFloat(),
arr.get(2).getAsFloat(),
arr.get(3).getAsFloat()
);
}
private static String readRequiredString(JsonObject obj, String key) {
if (!obj.has(key) || obj.get(key).isJsonNull()) {
throw new JsonStructureException("missing required string field '" + key + "'");
}
JsonElement el = obj.get(key);
if (!el.isJsonPrimitive() || !el.getAsJsonPrimitive().isString()) {
throw new JsonStructureException("field '" + key + "' is not a string");
}
return el.getAsString();
}
private static String readOptionalString(JsonObject obj, String key, String fallback) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return fallback;
JsonElement el = obj.get(key);
if (!el.isJsonPrimitive() || !el.getAsJsonPrimitive().isString()) return fallback;
return el.getAsString();
}
/**
* Exception thrown par le parseur JSON — indique un probleme structurel
* dans le fichier (champ manquant, type incorrect). Pas exposee publiquement,
* capturee dans {@link #apply} pour log et continuer.
*/
private static final class JsonStructureException extends RuntimeException {
JsonStructureException(String message) {
super(Objects.requireNonNull(message));
}
}
}

View File

@@ -0,0 +1,25 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.armature.types;
import com.tiedup.remake.rig.armature.Joint;
/**
* This class is not being used by Epic Fight, but is left to meet various purposes of developers
* Also presents developers which joints are necessary when an armature would be Human-like
*/
public interface HumanLikeArmature extends ToolHolderArmature {
public Joint leftHandJoint();
public Joint rightHandJoint();
public Joint leftArmJoint();
public Joint rightArmJoint();
public Joint leftLegJoint();
public Joint rightLegJoint();
public Joint leftThighJoint();
public Joint rightThighJoint();
public Joint headJoint();
}

View File

@@ -0,0 +1,21 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.armature.types;
import com.tiedup.remake.rig.armature.Joint;
/**
* Interface pour armatures portant un outil (main gauche/droite + dos).
* TiedUp gardera cette convention pour maintenir la compat avec les JSON EF
* (rig équivalent : Tool_R, Tool_L, Tool_Back). Dans les faits, dans un
* contexte bondage, "outil" = menottes, laisse, cage, etc.
*/
public interface ToolHolderArmature {
Joint leftToolJoint();
Joint rightToolJoint();
Joint backToolJoint();
}

View File

@@ -0,0 +1,69 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.asset;
import java.util.NoSuchElementException;
import java.util.function.Consumer;
import java.util.function.Supplier;
import net.minecraft.resources.ResourceLocation;
/**
* An accessor class
* @param <O> {@link Object} can be any object
*/
public interface AssetAccessor<O> extends Supplier<O> {
O get();
ResourceLocation registryName();
default boolean isPresent() {
return this.get() != null;
}
default boolean isEmpty() {
return !this.isPresent();
}
boolean inRegistry();
default boolean checkType(Class<?> cls) {
return cls.isAssignableFrom(this.get().getClass());
}
default O orElse(O whenNull) {
return this.isPresent() ? this.get() : whenNull;
}
default void ifPresent(Consumer<O> action) {
if (this.isPresent()) {
action.accept(this.get());
}
}
default void ifPresentOrElse(Consumer<O> action, Runnable whenNull) {
if (this.isPresent()) {
action.accept(this.get());
} else {
whenNull.run();
}
}
default void doOrThrow(Consumer<O> action) {
if (this.isPresent()) {
action.accept(this.get());
} else {
throw new NoSuchElementException("No asset " + this.registryName());
}
}
default void checkNotNull() {
if (!this.isPresent()) {
throw new NoSuchElementException("No asset " + this.registryName());
}
}
}

View File

@@ -0,0 +1,718 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.asset;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import javax.annotation.Nullable;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.internal.Streams;
import com.google.gson.stream.JsonReader;
import io.netty.util.internal.StringUtil;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.util.GsonHelper;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.fml.ModList;
import net.minecraftforge.fml.loading.FMLEnvironment;
import com.tiedup.remake.rig.anim.AnimationClip;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.armature.JointTransform;
import com.tiedup.remake.rig.anim.Keyframe;
import com.tiedup.remake.rig.anim.TransformSheet;
import com.tiedup.remake.rig.anim.property.AnimationProperty.ActionAnimationProperty;
import com.tiedup.remake.rig.anim.types.ActionAnimation;
import com.tiedup.remake.rig.anim.types.AttackAnimation;
import com.tiedup.remake.rig.anim.types.AttackAnimation.Phase;
import com.tiedup.remake.rig.anim.types.MainFrameAnimation;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.mesh.ClassicMesh;
import com.tiedup.remake.rig.mesh.CompositeMesh;
import com.tiedup.remake.rig.mesh.Mesh;
import com.tiedup.remake.rig.mesh.MeshPartDefinition;
import com.tiedup.remake.rig.mesh.Meshes;
import com.tiedup.remake.rig.mesh.Meshes.MeshContructor;
import com.tiedup.remake.rig.mesh.SkinnedMesh;
import com.tiedup.remake.rig.mesh.SoftBodyTranslatable;
import com.tiedup.remake.rig.mesh.StaticMesh;
import com.tiedup.remake.rig.mesh.VertexBuilder;
import com.tiedup.remake.rig.mesh.transformer.VanillaModelTransformer.VanillaMeshPartDefinition;
import com.tiedup.remake.rig.cloth.ClothSimulator.ClothObject.ClothPart.ConstraintType;
import com.tiedup.remake.rig.exception.AssetLoadingException;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.util.ParseUtil;
import com.tiedup.remake.rig.math.MathUtils;
import com.tiedup.remake.rig.math.OpenMatrix4f;
import com.tiedup.remake.rig.math.Vec3f;
import com.tiedup.remake.rig.math.Vec4f;
import com.tiedup.remake.rig.TiedUpRigConstants;
public class JsonAssetLoader {
public static final OpenMatrix4f BLENDER_TO_MINECRAFT_COORD = OpenMatrix4f.createRotatorDeg(-90.0F, Vec3f.X_AXIS);
public static final OpenMatrix4f MINECRAFT_TO_BLENDER_COORD = OpenMatrix4f.invert(BLENDER_TO_MINECRAFT_COORD, null);
public static final String UNGROUPED_NAME = "noGroups";
public static final String COORD_BONE = "Coord";
public static final String ROOT_BONE = "Root";
private JsonObject rootJson;
// Used for deciding armature name, other resources are nullable
@Nullable
private ResourceLocation resourceLocation;
private String filehash;
public JsonAssetLoader(ResourceManager resourceManager, ResourceLocation resourceLocation) throws AssetLoadingException {
JsonReader jsonReader = null;
this.resourceLocation = resourceLocation;
try {
try {
if (resourceManager == null) {
throw new NoSuchElementException();
}
Resource resource = resourceManager.getResource(resourceLocation).orElseThrow();
InputStream inputStream = resource.open();
InputStreamReader isr = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
jsonReader = new JsonReader(isr);
jsonReader.setLenient(true);
this.rootJson = Streams.parse(jsonReader).getAsJsonObject();
} catch (NoSuchElementException e) {
// In this case, reads the animation data from mod.jar (Especially in a server)
Class<?> modClass = ModList.get().getModObjectById(resourceLocation.getNamespace()).orElseThrow(() -> new AssetLoadingException("No modid " + resourceLocation)).getClass();
InputStream inputStream = modClass.getResourceAsStream("/assets/" + resourceLocation.getNamespace() + "/" + resourceLocation.getPath());
if (inputStream == null) {
modClass = ModList.get().getModObjectById(TiedUpRigConstants.MODID).get().getClass();
inputStream = modClass.getResourceAsStream("/assets/" + resourceLocation.getNamespace() + "/" + resourceLocation.getPath());
}
//Still null, throws exception.
if (inputStream == null) {
throw new AssetLoadingException("Can't find resource file: " + resourceLocation);
}
BufferedInputStream bufferedInputStream = new BufferedInputStream(inputStream);
InputStreamReader reader = new InputStreamReader(bufferedInputStream, StandardCharsets.UTF_8);
jsonReader = new JsonReader(reader);
jsonReader.setLenient(true);
this.rootJson = Streams.parse(jsonReader).getAsJsonObject();
}
} catch (IOException e) {
throw new AssetLoadingException("Can't read " + resourceLocation.toString() + " because of " + e);
} finally {
if (jsonReader != null) {
try {
jsonReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
this.filehash = ParseUtil.getBytesSHA256Hash(this.rootJson.toString().getBytes());
}
@OnlyIn(Dist.CLIENT)
public JsonAssetLoader(InputStream inputstream, ResourceLocation resourceLocation) throws AssetLoadingException {
JsonReader jsonReader = null;
this.resourceLocation = resourceLocation;
jsonReader = new JsonReader(new InputStreamReader(inputstream, StandardCharsets.UTF_8));
jsonReader.setLenient(true);
this.rootJson = Streams.parse(jsonReader).getAsJsonObject();
try {
jsonReader.close();
} catch (IOException e) {
throw new AssetLoadingException("Can't read " + resourceLocation.toString() + ": " + e);
}
this.filehash = StringUtil.EMPTY_STRING;
}
@OnlyIn(Dist.CLIENT)
public JsonAssetLoader(JsonObject rootJson, ResourceLocation rl) {
this.rootJson = rootJson;
this.resourceLocation = rl;
this.filehash = StringUtil.EMPTY_STRING;
}
@OnlyIn(Dist.CLIENT)
public static Mesh.RenderProperties getRenderProperties(JsonObject json) {
if (!json.has("render_properties")) {
return null;
}
JsonObject properties = json.getAsJsonObject("render_properties");
Mesh.RenderProperties.Builder renderProperties = Mesh.RenderProperties.Builder.create();
if (properties.has("transparent")) {
renderProperties.transparency(properties.get("transparent").getAsBoolean());
}
if (properties.has("texture_path")) {
renderProperties.customTexturePath(properties.get("texture_path").getAsString());
}
if (properties.has("color")) {
JsonArray jsonarray = properties.getAsJsonArray("color");
renderProperties.customColor(jsonarray.get(0).getAsFloat(), jsonarray.get(1).getAsFloat(), jsonarray.get(2).getAsFloat());
}
return renderProperties.build();
}
@OnlyIn(Dist.CLIENT)
public ResourceLocation getParent() {
return this.rootJson.has("parent") ? ResourceLocation.parse(this.rootJson.get("parent").getAsString()) : null;
}
private static final float DEFAULT_PARTICLE_MASS = 0.16F;
private static final float DEFAULT_SELF_COLLISON = 0.05F;
@Nullable
@OnlyIn(Dist.CLIENT)
public Map<String, SoftBodyTranslatable.ClothSimulationInfo> loadClothInformation(Float[] positionArray) {
JsonObject obj = this.rootJson.getAsJsonObject("vertices");
JsonObject clothInfoObj = obj.getAsJsonObject("cloth_info");
if (clothInfoObj == null) {
return null;
}
Map<String, SoftBodyTranslatable.ClothSimulationInfo> clothInfo = Maps.newHashMap();
for (Map.Entry<String, JsonElement> e : clothInfoObj.entrySet()) {
JsonObject clothObject = e.getValue().getAsJsonObject();
int[] particlesArray = ParseUtil.toIntArrayPrimitive(clothObject.get("particles").getAsJsonObject().get("array").getAsJsonArray());
float[] weightsArray = ParseUtil.toFloatArrayPrimitive(clothObject.get("weights").getAsJsonObject().get("array").getAsJsonArray());
float particleMass = clothObject.has("particle_mass") ? clothObject.get("particle_mass").getAsFloat() : DEFAULT_PARTICLE_MASS;
float selfCollision = clothObject.has("self_collision") ? clothObject.get("self_collision").getAsFloat() : DEFAULT_SELF_COLLISON;
JsonArray constraintsArray = clothObject.get("constraints").getAsJsonArray();
List<int[]> constraintsList = new ArrayList<> (constraintsArray.size());
float[] compliances = new float[constraintsArray.size()];
ConstraintType[] constraintType = new ConstraintType[constraintsArray.size()];
float[] rootDistances = new float[particlesArray.length / 2];
int i = 0;
for (JsonElement element : constraintsArray) {
JsonObject asJsonObject = element.getAsJsonObject();
if (asJsonObject.has("unused") && GsonHelper.getAsBoolean(asJsonObject, "unused")) {
continue;
}
constraintType[i] = ConstraintType.valueOf(GsonHelper.getAsString(asJsonObject, "type").toUpperCase(Locale.ROOT));
compliances[i] = GsonHelper.getAsFloat(asJsonObject, "compliance");
constraintsList.add(ParseUtil.toIntArrayPrimitive(asJsonObject.get("array").getAsJsonArray()));
element.getAsJsonObject().get("compliance");
i++;
}
List<Vec3> rootParticles = Lists.newArrayList();
for (int j = 0; j < particlesArray.length / 2; j++) {
int weightIndex = particlesArray[j * 2 + 1];
float weight = weightsArray[weightIndex];
if (weight == 0.0F) {
int posId = particlesArray[j * 2];
rootParticles.add(new Vec3(positionArray[posId * 3 + 0], positionArray[posId * 3 + 1], positionArray[posId * 3 + 2]));
}
}
for (int j = 0; j < particlesArray.length / 2; j++) {
int posId = particlesArray[j * 2];
Vec3 position = new Vec3(positionArray[posId * 3 + 0], positionArray[posId * 3 + 1], positionArray[posId * 3 + 2]);
Vec3 nearest = MathUtils.getNearestVector(position, rootParticles);
rootDistances[j] = (float)position.distanceTo(nearest);
}
int[] normalOffsetMappingArray = null;
if (clothObject.has("normal_offsets")) {
normalOffsetMappingArray = ParseUtil.toIntArrayPrimitive(clothObject.get("normal_offsets").getAsJsonObject().get("array").getAsJsonArray());
}
SoftBodyTranslatable.ClothSimulationInfo clothSimulInfo = new SoftBodyTranslatable.ClothSimulationInfo(particleMass, selfCollision, constraintsList, constraintType, compliances, particlesArray, weightsArray, rootDistances, normalOffsetMappingArray);
clothInfo.put(e.getKey(), clothSimulInfo);
}
return clothInfo;
}
@OnlyIn(Dist.CLIENT)
public <T extends ClassicMesh> T loadClassicMesh(MeshContructor<ClassicMesh.ClassicMeshPart, VertexBuilder, T> constructor) {
ResourceLocation parent = this.getParent();
if (parent != null) {
T mesh = Meshes.getOrCreate(parent, (jsonLoader) -> jsonLoader.loadClassicMesh(constructor)).get();
return constructor.invoke(null, null, mesh, getRenderProperties(this.rootJson));
} else {
JsonObject obj = this.rootJson.getAsJsonObject("vertices");
JsonObject positions = obj.getAsJsonObject("positions");
JsonObject normals = obj.getAsJsonObject("normals");
JsonObject uvs = obj.getAsJsonObject("uvs");
JsonObject parts = obj.getAsJsonObject("parts");
JsonObject indices = obj.getAsJsonObject("indices");
Float[] positionArray = ParseUtil.toFloatArray(positions.get("array").getAsJsonArray());
for (int i = 0; i < positionArray.length / 3; i++) {
int k = i * 3;
Vec4f posVector = new Vec4f(positionArray[k], positionArray[k+1], positionArray[k+2], 1.0F);
OpenMatrix4f.transform(BLENDER_TO_MINECRAFT_COORD, posVector, posVector);
positionArray[k] = posVector.x;
positionArray[k+1] = posVector.y;
positionArray[k+2] = posVector.z;
}
Float[] normalArray = ParseUtil.toFloatArray(normals.get("array").getAsJsonArray());
for (int i = 0; i < normalArray.length / 3; i++) {
int k = i * 3;
Vec4f normVector = new Vec4f(normalArray[k], normalArray[k+1], normalArray[k+2], 1.0F);
OpenMatrix4f.transform(BLENDER_TO_MINECRAFT_COORD, normVector, normVector);
normalArray[k] = normVector.x;
normalArray[k+1] = normVector.y;
normalArray[k+2] = normVector.z;
}
Float[] uvArray = ParseUtil.toFloatArray(uvs.get("array").getAsJsonArray());
Map<String, Number[]> arrayMap = Maps.newHashMap();
Map<MeshPartDefinition, List<VertexBuilder>> meshMap = Maps.newHashMap();
arrayMap.put("positions", positionArray);
arrayMap.put("normals", normalArray);
arrayMap.put("uvs", uvArray);
if (parts != null) {
for (Map.Entry<String, JsonElement> e : parts.entrySet()) {
meshMap.put(VanillaMeshPartDefinition.of(e.getKey(), getRenderProperties(e.getValue().getAsJsonObject())), VertexBuilder.create(ParseUtil.toIntArrayPrimitive(e.getValue().getAsJsonObject().get("array").getAsJsonArray())));
}
}
if (indices != null) {
meshMap.put(VanillaMeshPartDefinition.of(UNGROUPED_NAME), VertexBuilder.create(ParseUtil.toIntArrayPrimitive(indices.get("array").getAsJsonArray())));
}
T mesh = constructor.invoke(arrayMap, meshMap, null, getRenderProperties(this.rootJson));
mesh.putSoftBodySimulationInfo(this.loadClothInformation(positionArray));
return mesh;
}
}
@OnlyIn(Dist.CLIENT)
public <T extends SkinnedMesh> T loadSkinnedMesh(MeshContructor<SkinnedMesh.SkinnedMeshPart, VertexBuilder, T> constructor) {
ResourceLocation parent = this.getParent();
if (parent != null) {
T mesh = Meshes.getOrCreate(parent, (jsonLoader) -> jsonLoader.loadSkinnedMesh(constructor)).get();
return constructor.invoke(null, null, mesh, getRenderProperties(this.rootJson));
} else {
JsonObject obj = this.rootJson.getAsJsonObject("vertices");
JsonObject positions = obj.getAsJsonObject("positions");
JsonObject normals = obj.getAsJsonObject("normals");
JsonObject uvs = obj.getAsJsonObject("uvs");
JsonObject vdincies = obj.getAsJsonObject("vindices");
JsonObject weights = obj.getAsJsonObject("weights");
JsonObject vcounts = obj.getAsJsonObject("vcounts");
JsonObject parts = obj.getAsJsonObject("parts");
JsonObject indices = obj.getAsJsonObject("indices");
Float[] positionArray = ParseUtil.toFloatArray(positions.get("array").getAsJsonArray());
for (int i = 0; i < positionArray.length / 3; i++) {
int k = i * 3;
Vec4f posVector = new Vec4f(positionArray[k], positionArray[k+1], positionArray[k+2], 1.0F);
OpenMatrix4f.transform(BLENDER_TO_MINECRAFT_COORD, posVector, posVector);
positionArray[k] = posVector.x;
positionArray[k+1] = posVector.y;
positionArray[k+2] = posVector.z;
}
Float[] normalArray = ParseUtil.toFloatArray(normals.get("array").getAsJsonArray());
for (int i = 0; i < normalArray.length / 3; i++) {
int k = i * 3;
Vec4f normVector = new Vec4f(normalArray[k], normalArray[k+1], normalArray[k+2], 1.0F);
OpenMatrix4f.transform(BLENDER_TO_MINECRAFT_COORD, normVector, normVector);
normalArray[k] = normVector.x;
normalArray[k+1] = normVector.y;
normalArray[k+2] = normVector.z;
}
Float[] uvArray = ParseUtil.toFloatArray(uvs.get("array").getAsJsonArray());
Float[] weightArray = ParseUtil.toFloatArray(weights.get("array").getAsJsonArray());
Integer[] affectingJointCounts = ParseUtil.toIntArray(vcounts.get("array").getAsJsonArray());
Integer[] affectingJointIndices = ParseUtil.toIntArray(vdincies.get("array").getAsJsonArray());
Map<String, Number[]> arrayMap = Maps.newHashMap();
Map<MeshPartDefinition, List<VertexBuilder>> meshMap = Maps.newHashMap();
arrayMap.put("positions", positionArray);
arrayMap.put("normals", normalArray);
arrayMap.put("uvs", uvArray);
arrayMap.put("weights", weightArray);
arrayMap.put("vcounts", affectingJointCounts);
arrayMap.put("vindices", affectingJointIndices);
if (parts != null) {
for (Map.Entry<String, JsonElement> e : parts.entrySet()) {
meshMap.put(VanillaMeshPartDefinition.of(e.getKey(), getRenderProperties(e.getValue().getAsJsonObject())), VertexBuilder.create(ParseUtil.toIntArrayPrimitive(e.getValue().getAsJsonObject().get("array").getAsJsonArray())));
}
}
if (indices != null) {
meshMap.put(VanillaMeshPartDefinition.of(UNGROUPED_NAME), VertexBuilder.create(ParseUtil.toIntArrayPrimitive(indices.get("array").getAsJsonArray())));
}
T mesh = constructor.invoke(arrayMap, meshMap, null, getRenderProperties(this.rootJson));
mesh.putSoftBodySimulationInfo(this.loadClothInformation(positionArray));
return mesh;
}
}
@OnlyIn(Dist.CLIENT)
public CompositeMesh loadCompositeMesh() throws AssetLoadingException {
if (!this.rootJson.has("meshes")) {
throw new AssetLoadingException("Composite mesh loading exception: lower meshes undefined");
}
JsonAssetLoader clothLoader = new JsonAssetLoader(this.rootJson.get("meshes").getAsJsonObject().get("cloth").getAsJsonObject(), null);
JsonAssetLoader staticLoader = new JsonAssetLoader(this.rootJson.get("meshes").getAsJsonObject().get("static").getAsJsonObject(), null);
SoftBodyTranslatable softBodyMesh = (SoftBodyTranslatable)clothLoader.loadMesh(false);
StaticMesh<?> staticMesh = (StaticMesh<?>)staticLoader.loadMesh(false);
if (!softBodyMesh.canStartSoftBodySimulation()) {
throw new AssetLoadingException("Composite mesh loading exception: soft mesh doesn't have cloth info");
}
return new CompositeMesh(staticMesh, softBodyMesh);
}
@OnlyIn(Dist.CLIENT)
public Mesh loadMesh() throws AssetLoadingException {
return this.loadMesh(true);
}
@OnlyIn(Dist.CLIENT)
private Mesh loadMesh(boolean allowCompositeMesh) throws AssetLoadingException {
if (!this.rootJson.has("mesh_loader")) {
throw new AssetLoadingException("Mesh loading exception: No mesh loader provided!");
}
String loader = this.rootJson.get("mesh_loader").getAsString();
switch (loader) {
case "classic_mesh" -> {
return this.loadClassicMesh(ClassicMesh::new);
}
case "skinned_mesh" -> {
return this.loadSkinnedMesh(SkinnedMesh::new);
}
case "composite_mesh" -> {
if (!allowCompositeMesh) {
throw new AssetLoadingException("Can't have a composite mesh inside another composite mesh");
}
return this.loadCompositeMesh();
}
default -> {
throw new AssetLoadingException("Mesh loading exception: Unsupported mesh loader: " + loader);
}
}
}
public AnimationClip loadClipForAnimation(StaticAnimation animation) {
if (this.rootJson == null) {
throw new AssetLoadingException("Can't find animation in path: " + animation);
}
if (animation.getArmature() == null) {
TiedUpRigConstants.LOGGER.error("Animation " + animation + " doesn't have an armature.");
}
TransformFormat format = getAsTransformFormatOrDefault(this.rootJson, "format");
JsonArray array = this.rootJson.get("animation").getAsJsonArray();
boolean action = animation instanceof MainFrameAnimation;
boolean attack = animation instanceof AttackAnimation;
boolean noTransformData = !action && !attack && FMLEnvironment.dist == Dist.DEDICATED_SERVER;
boolean root = true;
Armature armature = animation.getArmature().get();
Set<String> allowedJoints = Sets.newLinkedHashSet();
if (attack) {
for (Phase phase : ((AttackAnimation)animation).phases) {
for (AttackAnimation.JointColliderPair colliderInfo : phase.getColliders()) {
armature.gatherAllJointsInPathToTerminal(colliderInfo.getFirst().getName(), allowedJoints);
}
}
} else if (action) {
allowedJoints.add(ROOT_BONE);
}
AnimationClip clip = new AnimationClip();
for (JsonElement element : array) {
JsonObject jObject = element.getAsJsonObject();
String name = jObject.get("name").getAsString();
if (attack && FMLEnvironment.dist == Dist.DEDICATED_SERVER && !allowedJoints.contains(name)) {
if (name.equals(COORD_BONE)) {
root = false;
}
continue;
}
Joint joint = armature.searchJointByName(name);
if (joint == null) {
if (name.equals(COORD_BONE)) {
TransformSheet sheet = getTransformSheet(jObject, new OpenMatrix4f(), true, format);
if (action) {
((ActionAnimation)animation).addProperty(ActionAnimationProperty.COORD, sheet);
}
root = false;
continue;
} else {
TiedUpRigConstants.LOGGER.debug("[EpicFightMod] No joint named " + name + " in " + animation);
continue;
}
}
TransformSheet sheet = getTransformSheet(jObject, OpenMatrix4f.invert(joint.getLocalTransform(), null), root, format);
if (!noTransformData) {
clip.addJointTransform(name, sheet);
}
float maxFrameTime = sheet.maxFrameTime();
if (clip.getClipTime() < maxFrameTime) {
clip.setClipTime(maxFrameTime);
}
root = false;
}
return clip;
}
public AnimationClip loadAllJointsClipForAnimation(StaticAnimation animation) {
TransformFormat format = getAsTransformFormatOrDefault(this.rootJson, "format");
JsonArray array = this.rootJson.get("animation").getAsJsonArray();
boolean root = true;
if (animation.getArmature() == null) {
TiedUpRigConstants.LOGGER.error("Animation " + animation + " doesn't have an armature.");
}
Armature armature = animation.getArmature().get();
AnimationClip clip = new AnimationClip();
for (JsonElement element : array) {
JsonObject jObject = element.getAsJsonObject();
String name = jObject.get("name").getAsString();
Joint joint = armature.searchJointByName(name);
if (joint == null) {
if (TiedUpRigConstants.IS_DEV_ENV) {
TiedUpRigConstants.LOGGER.debug(animation.getRegistryName() + ": No joint named " + name + " in armature");
}
continue;
}
TransformSheet sheet = getTransformSheet(jObject, OpenMatrix4f.invert(joint.getLocalTransform(), null), root, format);
clip.addJointTransform(name, sheet);
float maxFrameTime = sheet.maxFrameTime();
if (clip.getClipTime() < maxFrameTime) {
clip.setClipTime(maxFrameTime);
}
root = false;
}
return clip;
}
public JsonObject getRootJson() {
return this.rootJson;
}
public String getFileHash() {
return this.filehash;
}
public static TransformFormat getAsTransformFormatOrDefault(JsonObject jsonObject, String propertyName) {
return jsonObject.has(propertyName) ? ParseUtil.enumValueOfOrNull(TransformFormat.class, GsonHelper.getAsString(jsonObject, propertyName)) : TransformFormat.MATRIX;
}
public AnimationClip loadAnimationClip(Armature armature) {
TransformFormat format = getAsTransformFormatOrDefault(this.rootJson, "format");
JsonArray array = this.rootJson.get("animation").getAsJsonArray();
AnimationClip clip = new AnimationClip();
boolean root = true;
for (JsonElement element : array) {
JsonObject jObject = element.getAsJsonObject();
String name = jObject.get("name").getAsString();
Joint joint = armature.searchJointByName(name);
if (joint == null) {
continue;
}
TransformSheet sheet = getTransformSheet(element.getAsJsonObject(), OpenMatrix4f.invert(joint.getLocalTransform(), null), root, format);
clip.addJointTransform(name, sheet);
float maxFrameTime = sheet.maxFrameTime();
if (clip.getClipTime() < maxFrameTime) {
clip.setClipTime(maxFrameTime);
}
root = false;
}
return clip;
}
/**
* @param jObject
* @param invLocalTransform nullable if transformFormat == {@link TransformFormat#ATTRIBUTES}
* @param rootCorrection no matter what the value is if transformFormat == {@link TransformFormat#ATTRIBUTES}
* @param transformFormat
* @return
*/
public static TransformSheet getTransformSheet(JsonObject jObject, @Nullable OpenMatrix4f invLocalTransform, boolean rootCorrection, TransformFormat transformFormat) throws AssetLoadingException, JsonParseException {
JsonArray timeArray = jObject.getAsJsonArray("time");
JsonArray transformArray = jObject.getAsJsonArray("transform");
if (timeArray.size() != transformArray.size()) {
throw new AssetLoadingException(
"Can't read transform sheet: the size of timestamp and transform array is different."
+ "timestamp array size: " + timeArray.size() + ", transform array size: " + transformArray.size()
);
}
int timesCount = timeArray.size();
List<Keyframe> keyframeList = Lists.newArrayList();
for (int i = 0; i < timesCount; i++) {
float timeStamp = timeArray.get(i).getAsFloat();
if (timeStamp < 0.0F) {
continue;
}
// WORKAROUND: The case when transform format is wrongly specified!
if (transformFormat == TransformFormat.ATTRIBUTES && transformArray.get(i).isJsonArray()) {
transformFormat = TransformFormat.MATRIX;
} else if (transformFormat == TransformFormat.MATRIX && transformArray.get(i).isJsonObject()) {
transformFormat = TransformFormat.ATTRIBUTES;
}
switch (transformFormat) {
case MATRIX -> {
JsonArray matrixArray = transformArray.get(i).getAsJsonArray();
float[] matrixElements = new float[16];
for (int j = 0; j < 16; j++) {
matrixElements[j] = matrixArray.get(j).getAsFloat();
}
OpenMatrix4f matrix = OpenMatrix4f.load(null, matrixElements);
matrix.transpose();
if (rootCorrection) {
matrix.mulFront(BLENDER_TO_MINECRAFT_COORD);
}
matrix.mulFront(invLocalTransform);
JointTransform transform = JointTransform.fromMatrix(matrix);
transform.rotation().normalize();
keyframeList.add(new Keyframe(timeStamp, transform));
}
case ATTRIBUTES -> {
JsonObject transformObject = transformArray.get(i).getAsJsonObject();
JsonArray locArray = transformObject.get("loc").getAsJsonArray();
JsonArray rotArray = transformObject.get("rot").getAsJsonArray();
JsonArray scaArray = transformObject.get("sca").getAsJsonArray();
JointTransform transform
= JointTransform.fromPrimitives(
locArray.get(0).getAsFloat()
, locArray.get(1).getAsFloat()
, locArray.get(2).getAsFloat()
, -rotArray.get(1).getAsFloat()
, -rotArray.get(2).getAsFloat()
, -rotArray.get(3).getAsFloat()
, rotArray.get(0).getAsFloat()
, scaArray.get(0).getAsFloat()
, scaArray.get(1).getAsFloat()
, scaArray.get(2).getAsFloat()
);
keyframeList.add(new Keyframe(timeStamp, transform));
}
}
}
TransformSheet sheet = new TransformSheet(keyframeList);
return sheet;
}
/**
* Determines how the transform is expressed in json
*
* {@link TransformFormat#MATRIX} be like,
* [0, 1, 2, ..., 15]
*
* {@link TransformFormat#ATTRIBUTES} be like,
* {
* "loc": [0, 0, 0],
* "rot": [0, 0, 0, 1],
* "sca": [1, 1, 1],
* }
*/
public enum TransformFormat {
MATRIX, ATTRIBUTES
}
}

View File

@@ -0,0 +1,119 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.bridge;
import java.util.Map;
import javax.annotation.Nullable;
import com.google.common.collect.ImmutableMap;
/**
* Table d'alias runtime pour mapper les noms de joints des GLB legacy TiedUp
* (riggés via PlayerAnimator / bendy-lib avec noms type {@code leftUpperArm})
* vers le skeleton biped Epic Fight utilisé par RIG ({@code Arm_L}, etc.).
*
* <p>Voir {@code docs/plans/rig/ARCHITECTURE.md §6.3} pour la source de vérité
* du mapping. Tout joint inconnu après lookup doit être loggé WARN par le
* caller et fallback sur {@code Root}.</p>
*
* <p><b>Cas spécial "body/torso"</b> — le GLB legacy a souvent un unique joint
* couvrant l'ensemble du torse. On le mappe sur {@code Chest} par défaut
* (meilleur fit pour les items bondage majoritairement attachés au haut du
* corps : harnais, menottes de poitrine, collier). Si un item a besoin
* d'attachement à {@code Torso} (ceinture), le modeler devra renommer son
* joint en {@code waist} explicitement.</p>
*/
public final class GlbJointAliasTable {
/**
* Mapping direct PlayerAnimator → biped EF. Les clés sont la forme
* lowercase EXACTE des noms exportés par les GLB legacy.
*/
private static final Map<String, String> ALIAS = ImmutableMap.<String, String>builder()
// Torso region
.put("body", "Chest")
.put("torso", "Chest")
.put("chest", "Chest")
.put("waist", "Torso")
.put("hip", "Torso")
// Head
.put("head", "Head")
// Arms left
.put("leftshoulder", "Shoulder_L")
.put("leftupperarm", "Arm_L")
.put("leftarm", "Arm_L")
.put("leftlowerarm", "Elbow_L")
.put("leftforearm", "Elbow_L")
.put("leftelbow", "Elbow_L")
.put("lefthand", "Hand_L")
// Arms right
.put("rightshoulder", "Shoulder_R")
.put("rightupperarm", "Arm_R")
.put("rightarm", "Arm_R")
.put("rightlowerarm", "Elbow_R")
.put("rightforearm", "Elbow_R")
.put("rightelbow", "Elbow_R")
.put("righthand", "Hand_R")
// Legs left
.put("leftupperleg", "Thigh_L")
.put("leftleg", "Thigh_L")
.put("leftlowerleg", "Knee_L")
.put("leftknee", "Knee_L")
.put("leftfoot", "Leg_L")
// Legs right
.put("rightupperleg", "Thigh_R")
.put("rightleg", "Thigh_R")
.put("rightlowerleg", "Knee_R")
.put("rightknee", "Knee_R")
.put("rightfoot", "Leg_R")
// Root fallback (déjà nommé Root dans GLB modernes)
.put("root", "Root")
.put("armature", "Root")
.build();
private GlbJointAliasTable() {}
/**
* Traduit un nom de joint GLB legacy vers le nom biped EF équivalent.
* Case-insensitive. Les noms déjà au format biped EF (ex: {@code Arm_L}) sont
* retournés tels quels après vérification.
*
* @param gltfJointName nom tel qu'exporté dans le GLB (jointNames[])
* @return nom biped EF (ex: {@code Arm_L}), ou null si inconnu
*/
@Nullable
public static String mapGltfJointName(String gltfJointName) {
if (gltfJointName == null || gltfJointName.isEmpty()) {
return null;
}
// Direct hit sur le biped EF (GLB moderne déjà bien rigged).
if (isBipedJointName(gltfJointName)) {
return gltfJointName;
}
return ALIAS.get(gltfJointName.toLowerCase());
}
/**
* Vérifie si un nom est déjà au format biped EF. Utilisé pour court-circuiter
* l'alias lookup sur les GLB modernes.
*/
public static boolean isBipedJointName(String name) {
// Heuristique : les noms biped EF sont en PascalCase avec suffixe _R/_L,
// ou parmi {Root, Torso, Chest, Head}.
return switch (name) {
case "Root", "Torso", "Chest", "Head" -> true;
default -> name.endsWith("_R") || name.endsWith("_L");
};
}
}

View File

@@ -0,0 +1,238 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.bridge;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import it.unimi.dsi.fastutil.ints.IntList;
import com.tiedup.remake.client.gltf.GltfData;
import com.tiedup.remake.client.gltf.GltfData.Primitive;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.math.Vec2f;
import com.tiedup.remake.rig.math.Vec3f;
import com.tiedup.remake.rig.mesh.MeshPartDefinition;
import com.tiedup.remake.rig.mesh.SingleGroupVertexBuilder;
import com.tiedup.remake.rig.mesh.SkinnedMesh;
import com.tiedup.remake.rig.mesh.transformer.VanillaModelTransformer.VanillaMeshPartDefinition;
/**
* Pont Phase 1 : convertit un {@link GltfData} (format GLB legacy TiedUp
* riggé 11-joints PlayerAnimator) en {@link SkinnedMesh} Epic Fight
* (biped ~20 joints).
*
* <p>Algorithme (voir {@code docs/plans/rig/MIGRATION.md §1.2.1}) :</p>
* <ol>
* <li>Pré-calculer le mapping {@code gltfJointIdx → bipedJointId} via
* {@link GlbJointAliasTable} + {@link Armature#searchJointByName}.</li>
* <li>Pour chaque vertex :
* <ul>
* <li>Position / normal / UV depuis {@link GltfData}</li>
* <li>Retenir les 3 joints de plus fort poids parmi les 4 glTF</li>
* <li>Renormaliser les poids retenus pour sommer à 1.0</li>
* <li>Construire le {@link SingleGroupVertexBuilder}</li>
* </ul>
* </li>
* <li>Grouper les indices par {@link Primitive} en autant de
* {@link MeshPartDefinition}.</li>
* <li>{@link SingleGroupVertexBuilder#loadVertexInformation(List, Map)}
* construit le {@link SkinnedMesh}.</li>
* </ol>
*
* <p>Les animations éventuellement embarquées dans le GLB sont <b>ignorées</b> —
* les animations passent par JSON EF natif via {@code JsonAssetLoader}.</p>
*/
public final class GltfToSkinnedMesh {
private static final float WEIGHT_EPSILON = 1.0e-4F;
private GltfToSkinnedMesh() {}
/**
* Convertit un GLB parsé en {@link SkinnedMesh} utilisable par le pipeline
* de rendu RIG.
*
* @param data données GLB parsées par {@code GlbParser.parse(...)}
* @param armature armature biped EF cible (doit être déjà chargée)
* @return SkinnedMesh prêt à être rendu
* @throws IllegalStateException si {@code armature} est null
*/
public static SkinnedMesh convert(GltfData data, AssetAccessor<? extends Armature> armature) {
if (armature == null || armature.get() == null) {
throw new IllegalStateException(
"Armature not loaded — GltfToSkinnedMesh.convert() called before resource reload completed"
);
}
Armature arm = armature.get();
int[] jointIdMap = buildJointIdMap(data.jointNames(), arm);
int vertexCount = data.vertexCount();
float[] positions = data.positions();
float[] normals = data.normals();
float[] texCoords = data.texCoords();
int[] joints = data.joints();
float[] weights = data.weights();
List<SingleGroupVertexBuilder> vertices = new ArrayList<>(vertexCount);
for (int i = 0; i < vertexCount; i++) {
vertices.add(buildVertex(i, positions, normals, texCoords, joints, weights, jointIdMap));
}
Map<MeshPartDefinition, IntList> partIndices = buildPartIndices(data.primitives());
return SingleGroupVertexBuilder.loadVertexInformation(vertices, partIndices);
}
/**
* Construit le mapping {@code gltfJointIdx → bipedJointId} une seule fois
* avant la boucle vertex. Les noms inconnus retombent sur la racine
* {@code Root} (id 0) avec un log WARN.
*/
private static int[] buildJointIdMap(String[] gltfJointNames, Armature arm) {
int[] map = new int[gltfJointNames.length];
int unknownCount = 0;
int aliasedCount = 0;
int rootId = arm.rootJoint != null ? arm.rootJoint.getId() : 0;
for (int i = 0; i < gltfJointNames.length; i++) {
String gltfName = gltfJointNames[i];
String bipedName = GlbJointAliasTable.mapGltfJointName(gltfName);
if (bipedName == null) {
TiedUpRigConstants.LOGGER.warn(
"GltfToSkinnedMesh: unknown joint '{}' — fallback to Root",
gltfName
);
map[i] = rootId;
unknownCount++;
continue;
}
Joint joint = arm.searchJointByName(bipedName);
if (joint == null) {
TiedUpRigConstants.LOGGER.warn(
"GltfToSkinnedMesh: biped joint '{}' (aliased from '{}') not found in armature — fallback to Root",
bipedName, gltfName
);
map[i] = rootId;
unknownCount++;
continue;
}
map[i] = joint.getId();
if (!gltfName.equals(bipedName)) {
aliasedCount++;
}
}
TiedUpRigConstants.LOGGER.info(
"GltfToSkinnedMesh: {} joints mapped ({} via alias, {} unknown→Root)",
gltfJointNames.length, aliasedCount, unknownCount
);
return map;
}
/**
* Construit un vertex individuel : position/normal/UV depuis les arrays
* flattened, puis sélection des 3 plus forts poids (drop du 4e) + renormalisation.
*/
private static SingleGroupVertexBuilder buildVertex(
int i,
float[] positions, float[] normals, float[] texCoords,
int[] joints, float[] weights,
int[] jointIdMap) {
SingleGroupVertexBuilder vb = new SingleGroupVertexBuilder();
vb.setPosition(new Vec3f(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]));
vb.setNormal(new Vec3f(normals[i * 3], normals[i * 3 + 1], normals[i * 3 + 2]));
vb.setTextureCoordinate(new Vec2f(texCoords[i * 2], texCoords[i * 2 + 1]));
// Récupère les 4 joints/poids glTF, sélectionne les 3 plus forts.
int[] rawJoints = new int[4];
float[] rawWeights = new float[4];
for (int k = 0; k < 4; k++) {
rawJoints[k] = joints[i * 4 + k];
rawWeights[k] = weights[i * 4 + k];
}
// Trouve l'index du plus faible poids (à drop).
int minIdx = 0;
for (int k = 1; k < 4; k++) {
if (rawWeights[k] < rawWeights[minIdx]) {
minIdx = k;
}
}
// Build les 3 retenus + compte effective.
float w0 = 0, w1 = 0, w2 = 0;
int id0 = 0, id1 = 0, id2 = 0;
int effectiveCount = 0;
int slot = 0;
for (int k = 0; k < 4; k++) {
if (k == minIdx) continue;
float w = rawWeights[k];
int id = jointIdMap[rawJoints[k]];
switch (slot) {
case 0 -> { w0 = w; id0 = id; }
case 1 -> { w1 = w; id1 = id; }
case 2 -> { w2 = w; id2 = id; }
}
if (w > WEIGHT_EPSILON) effectiveCount++;
slot++;
}
// Renormalise les 3 poids pour qu'ils somment à 1.0.
float sum = w0 + w1 + w2;
if (sum > WEIGHT_EPSILON) {
float inv = 1.0F / sum;
w0 *= inv; w1 *= inv; w2 *= inv;
} else {
// Vertex sans skinning (tout-zéro ou bugué) — attache au Root avec poids 1.
w0 = 1.0F; w1 = 0; w2 = 0;
id0 = 0; id1 = 0; id2 = 0;
effectiveCount = 1;
}
vb.setEffectiveJointIDs(new Vec3f(id0, id1, id2));
vb.setEffectiveJointWeights(new Vec3f(w0, w1, w2));
vb.setEffectiveJointNumber(Math.max(1, effectiveCount));
return vb;
}
/**
* Groupe les indices par primitive (= material dans Blender) → une
* {@link VanillaMeshPartDefinition} par primitive. Le partName est pris sur
* le {@code materialName} si défini, sinon un nom synthétique
* {@code "part_N"}.
*/
private static Map<MeshPartDefinition, IntList> buildPartIndices(List<Primitive> primitives) {
Map<MeshPartDefinition, IntList> partIndices = new HashMap<>();
int fallbackCounter = 0;
for (Primitive prim : primitives) {
String partName = prim.materialName();
if (partName == null || partName.isEmpty()) {
partName = "part_" + fallbackCounter++;
}
MeshPartDefinition partDef = VanillaMeshPartDefinition.of(partName);
IntList indexList = new IntArrayList(prim.indices().length);
for (int idx : prim.indices()) {
indexList.add(idx);
}
partIndices.put(partDef, indexList);
}
return partIndices;
}
}

View File

@@ -0,0 +1,135 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.cloth;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.BooleanSupplier;
import org.apache.commons.lang3.tuple.Pair;
import com.google.common.collect.Maps;
import com.tiedup.remake.rig.physics.PhysicsSimulator;
import com.tiedup.remake.rig.physics.SimulationObject;
import com.tiedup.remake.rig.physics.SimulationObject.SimulationObjectBuilder;
import com.tiedup.remake.rig.physics.SimulationProvider;
public abstract class AbstractSimulator<KEY, B extends SimulationObjectBuilder, PV extends SimulationProvider<O, SO, B, PV>, O, SO extends SimulationObject<B, PV, O>> implements PhysicsSimulator<KEY, B, PV, O, SO> {
protected Map<KEY, ObjectWrapper> simulationObjects = Maps.newHashMap();
@Override
public void tick(O simObject) {
this.simulationObjects.values().removeIf((keyWrapper) -> {
if (keyWrapper.isRunning()) {
if (!keyWrapper.runWhen.getAsBoolean()) {
keyWrapper.stopRunning();
if (!keyWrapper.permanent) {
return true;
}
}
} else {
if (keyWrapper.runWhen.getAsBoolean()) {
keyWrapper.startRunning(simObject);
}
}
return false;
});
}
/**
* Add a simulation object and run. Remove when @Param until returns false
*/
@Override
public void runUntil(KEY key, PV provider, B builder, BooleanSupplier until) {
this.simulationObjects.put(key, new ObjectWrapper(provider, until, false, builder));
}
/**
* Add an undeleted simulation object. Run simulation when @Param when returns true
*/
@Override
public void runWhen(KEY key, PV provider, B builder, BooleanSupplier when) {
this.simulationObjects.put(key, new ObjectWrapper(provider, when, true, builder));
}
/**
* Stop simulation
*/
@Override
public void stop(KEY key) {
this.simulationObjects.remove(key);
}
/**
* Restart with the same condition but with another provider
*/
@Override
public void restart(KEY key) {
ObjectWrapper kwrap = this.simulationObjects.get(key);
if (kwrap != null) {
this.stop(key);
this.simulationObjects.put(key, new ObjectWrapper(kwrap.provider, kwrap.runWhen, kwrap.permanent, kwrap.builder));
}
}
@Override
public boolean isRunning(KEY key) {
return this.simulationObjects.containsKey(key) ? this.simulationObjects.get(key).isRunning() : false;
}
@Override
public Optional<SO> getRunningObject(KEY key) {
if (!this.simulationObjects.containsKey(key)) {
return Optional.empty();
}
return Optional.ofNullable(this.simulationObjects.get(key).simulationObject);
}
public List<Pair<KEY, SO>> getAllRunningObjects() {
return this.simulationObjects.entrySet().stream().filter((entry) -> entry.getValue().isRunning()).map((entry) -> Pair.of(entry.getKey(), entry.getValue().simulationObject)).toList();
}
protected class ObjectWrapper {
final PV provider;
final B builder;
final BooleanSupplier runWhen;
final boolean permanent;
SO simulationObject;
boolean isRunning;
ObjectWrapper(PV key, BooleanSupplier runWhen, boolean permanent, B builder) {
this.provider = key;
this.runWhen = runWhen;
this.permanent = permanent;
this.builder = builder;
}
public void startRunning(O simObject) {
this.simulationObject = this.provider.createSimulationData(this.provider, simObject, this.builder);
if (this.simulationObject != null) {
this.isRunning = true;
}
}
public void stopRunning() {
this.isRunning = false;
this.simulationObject = null;
}
public boolean isRunning() {
return this.isRunning;
}
}
}

View File

@@ -0,0 +1,68 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.cloth;
import java.util.List;
import java.util.function.Function;
import com.google.common.collect.ImmutableList;
import com.mojang.datafixers.util.Pair;
import com.tiedup.remake.rig.math.OpenMatrix4f;
/**
* 0: Root,
* 1: Thigh_R,
* 2: "Leg_R",
* 3: "Knee_R",
* 4: "Thigh_L",
* 5: "Leg_L",
* 6: "Knee_L",
* 7: "Torso",
* 8: "Chest",
* 9: "Head",
* 10: "Shoulder_R",
* 11: "Arm_R",
* 12: "Hand_R",
* 13: "Tool_R",
* 14: "Elbow_R",
* 15: "Shoulder_L",
* 16: "Arm_L",
* 17: "Hand_L",
* 18: "Tool_L",
* 19: "Elbow_L"
**/
public class ClothColliderPresets {
public static final List<Pair<Function<ClothSimulatable, OpenMatrix4f>, ClothSimulator.ClothOBBCollider>> BIPED_SLIM = ImmutableList.<Pair<Function<ClothSimulatable, OpenMatrix4f>, ClothSimulator.ClothOBBCollider>>builder()
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[1], new ClothSimulator.ClothOBBCollider(0.125D, 0.24D, 0.125D, 0.0D, 0.22D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[2], new ClothSimulator.ClothOBBCollider(0.125D, 0.1875D, 0.125D, 0.0D, 0.1875D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[4], new ClothSimulator.ClothOBBCollider(0.125D, 0.24D, 0.125D, 0.0D, 0.22D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[5], new ClothSimulator.ClothOBBCollider(0.125D, 0.1875D, 0.125D, 0.0D, 0.1875D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[7], new ClothSimulator.ClothOBBCollider(0.25D, 0.25D, 0.13D, 0.0D, 0.125D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[8], new ClothSimulator.ClothOBBCollider(0.25D, 0.25D, 0.13D, 0.0D, 0.3D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[9], new ClothSimulator.ClothOBBCollider(0.25D, 0.25D, 0.25D, 0.0D, 0.2D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[11], new ClothSimulator.ClothOBBCollider(0.12D, 0.24D, 0.125D, -0.05D, 0.14D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[12], new ClothSimulator.ClothOBBCollider(0.12D, 0.1875D, 0.125D, -0.05D, 0.14D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[16], new ClothSimulator.ClothOBBCollider(0.12D, 0.24D, 0.125D, 0.05D, 0.14D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[17], new ClothSimulator.ClothOBBCollider(0.12D, 0.1875D, 0.125D, 0.05D, 0.14D, 0.0D)))
.build();
public static final List<Pair<Function<ClothSimulatable, OpenMatrix4f>, ClothSimulator.ClothOBBCollider>> BIPED = ImmutableList.<Pair<Function<ClothSimulatable, OpenMatrix4f>, ClothSimulator.ClothOBBCollider>>builder()
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[1], new ClothSimulator.ClothOBBCollider(0.125D, 0.24D, 0.125D, 0.0D, 0.22D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[2], new ClothSimulator.ClothOBBCollider(0.125D, 0.1875D, 0.125D, 0.0D, 0.1875D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[4], new ClothSimulator.ClothOBBCollider(0.125D, 0.24D, 0.125D, 0.0D, 0.22D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[5], new ClothSimulator.ClothOBBCollider(0.125D, 0.1875D, 0.125D, 0.0D, 0.1875D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[7], new ClothSimulator.ClothOBBCollider(0.25D, 0.25D, 0.13D, 0.0D, 0.125D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[8], new ClothSimulator.ClothOBBCollider(0.25D, 0.25D, 0.13D, 0.0D, 0.3D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[9], new ClothSimulator.ClothOBBCollider(0.25D, 0.25D, 0.25D, 0.0D, 0.2D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[11], new ClothSimulator.ClothOBBCollider(0.13D, 0.24D, 0.13D, -0.0D, 0.14D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[12], new ClothSimulator.ClothOBBCollider(0.13D, 0.1875D, 0.13D, -0.0D, 0.14D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[16], new ClothSimulator.ClothOBBCollider(0.13D, 0.24D, 0.13D, 0.0D, 0.14D, 0.0D)))
.add(Pair.of((simObject) -> simObject.getArmature().getPoseMatrices()[17], new ClothSimulator.ClothOBBCollider(0.13D, 0.1875D, 0.13D, 0.0D, 0.14D, 0.0D)))
.build();
}

View File

@@ -0,0 +1,37 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.cloth;
import javax.annotation.Nullable;
import net.minecraft.world.phys.Vec3;
import com.tiedup.remake.rig.anim.Animator;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.physics.SimulatableObject;
public interface ClothSimulatable extends SimulatableObject {
@Nullable
Armature getArmature();
@Nullable
Animator getSimulatableAnimator();
boolean invalid();
public Vec3 getObjectVelocity();
public float getYRot();
public float getYRotO();
// Cloth object requires providing location info for 2 steps before for accurate continuous collide detection.
public Vec3 getAccurateCloakLocation(float partialFrame);
public Vec3 getAccuratePartialLocation(float partialFrame);
public float getAccurateYRot(float partialFrame);
public float getYRotDelta(float partialFrame);
public float getScale();
public float getGravity();
ClothSimulator getClothSimulator();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.collider;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import com.tiedup.remake.rig.math.OpenMatrix4f;
import com.tiedup.remake.rig.math.Vec3f;
/**
* OBB géométrique forké depuis EF, strippé de la logique combat
* (isCollide(Entity), drawInternal, updateAndSelectCollideEntity).
* Ne garde que les données + transform() utilisés par ClothOBBCollider.
*/
public class OBBCollider {
protected final Vec3 modelCenter;
protected final AABB outerAABB;
protected Vec3 worldCenter;
protected final Vec3[] modelVertices;
protected final Vec3[] modelNormals;
protected Vec3[] rotatedVertices;
protected Vec3[] rotatedNormals;
protected Vec3f scale = new Vec3f(1.0F, 1.0F, 1.0F);
public OBBCollider(double vertexX, double vertexY, double vertexZ, double centerX, double centerY, double centerZ) {
this(getInitialAABB(vertexX, vertexY, vertexZ, centerX, centerY, centerZ), vertexX, vertexY, vertexZ, centerX, centerY, centerZ);
}
protected OBBCollider(AABB outerAABB, double vertexX, double vertexY, double vertexZ, double centerX, double centerY, double centerZ) {
this.modelCenter = new Vec3(centerX, centerY, centerZ);
this.outerAABB = outerAABB;
this.worldCenter = new Vec3(0.0D, 0.0D, 0.0D);
this.modelVertices = new Vec3[4];
this.modelNormals = new Vec3[3];
this.rotatedVertices = new Vec3[4];
this.rotatedNormals = new Vec3[3];
this.modelVertices[0] = new Vec3(vertexX, vertexY, -vertexZ);
this.modelVertices[1] = new Vec3(vertexX, vertexY, vertexZ);
this.modelVertices[2] = new Vec3(-vertexX, vertexY, vertexZ);
this.modelVertices[3] = new Vec3(-vertexX, vertexY, -vertexZ);
this.modelNormals[0] = new Vec3(1, 0, 0);
this.modelNormals[1] = new Vec3(0, 1, 0);
this.modelNormals[2] = new Vec3(0, 0, 1);
this.rotatedVertices[0] = new Vec3(0.0D, 0.0D, 0.0D);
this.rotatedVertices[1] = new Vec3(0.0D, 0.0D, 0.0D);
this.rotatedVertices[2] = new Vec3(0.0D, 0.0D, 0.0D);
this.rotatedVertices[3] = new Vec3(0.0D, 0.0D, 0.0D);
this.rotatedNormals[0] = new Vec3(0.0D, 0.0D, 0.0D);
this.rotatedNormals[1] = new Vec3(0.0D, 0.0D, 0.0D);
this.rotatedNormals[2] = new Vec3(0.0D, 0.0D, 0.0D);
}
static AABB getInitialAABB(double posX, double posY, double posZ, double center_x, double center_y, double center_z) {
double xLength = Math.abs(posX) + Math.abs(center_x);
double yLength = Math.abs(posY) + Math.abs(center_y);
double zLength = Math.abs(posZ) + Math.abs(center_z);
double maxLength = Math.max(xLength, Math.max(yLength, zLength));
return new AABB(maxLength, maxLength, maxLength, -maxLength, -maxLength, -maxLength);
}
public void transform(OpenMatrix4f modelMatrix) {
OpenMatrix4f noTranslation = modelMatrix.removeTranslation();
for (int i = 0; i < this.modelVertices.length; i++) {
this.rotatedVertices[i] = OpenMatrix4f.transform(noTranslation, this.modelVertices[i]);
}
for (int i = 0; i < this.modelNormals.length; i++) {
this.rotatedNormals[i] = OpenMatrix4f.transform(noTranslation, this.modelNormals[i]);
}
this.scale = noTranslation.toScaleVector();
this.worldCenter = OpenMatrix4f.transform(modelMatrix, this.modelCenter);
}
}

View File

@@ -0,0 +1,357 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.debug;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.client.ClientAnimator;
import com.tiedup.remake.rig.anim.client.Layer;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.patch.PlayerPatch;
import com.tiedup.remake.rig.patch.TiedUpCapabilities;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
/**
* P3-19 — debug overlay textuel inspiré de F3+B (vanilla debug hitboxes).
*
* <p>Affiche en temps réel l'état du pipeline RIG du {@link LocalPlayer} :</p>
* <ul>
* <li>{@code currentLivingMotion} + {@code currentCompositeMotion}</li>
* <li>Nombre d'items bondage équipés (data-driven)</li>
* <li>Nombre de bindings {@code livingAnimations} dans l'animator</li>
* <li>Liste des layers actifs (base + composite) avec priority + anim courante</li>
* </ul>
*
* <p><b>Objectif dev</b> : distinguer pipeline broken vs pipeline OK mais anim
* identity invisible (cas placeholder JSON pré-Phase 4 où aucune anim Blender
* co-authored n'est encore bound). Sans ce feedback visuel, impossible de
* vérifier gameday que {@code addLivingAnimation(WALK_BOUND, ...)} a bien
* pushé le binding.</p>
*
* <h2>Usage</h2>
* <p>Toggle via la keybind {@code key.tiedup.rig_debug} (default F6, configurable
* dans le menu Controls, catégorie TiedUp!). L'overlay ne render que lorsque
* {@link #DEBUG_OVERLAY_ENABLED} est {@code true}.</p>
*
* <h2>Testabilité</h2>
* <p>{@link #buildOverlayLines(Player)} est package-private et pur — testable
* unit avec un player null ou un player sans patch. Le rendu effectif
* ({@link #onRenderOverlay}) nécessite MC runtime (GuiGraphics, Font,
* Minecraft.getInstance) et n'est validable qu'en gameday.</p>
*
* <h2>Perf</h2>
* <p>Coût négligeable quand {@link #DEBUG_OVERLAY_ENABLED} = false (early
* return). Quand actif, le build lit la capability + itère les layers composite
* (5 priorities max) → O(1) par frame.</p>
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public final class RigDebugOverlay {
/** Toggle global — flipped par la keybind dans {@code ModKeybindings#onClientTick}. */
private static boolean DEBUG_OVERLAY_ENABLED = false;
/** Padding gauche/haut de l'overlay en pixels guiScaled. */
private static final int MARGIN = 4;
/** Hauteur d'une ligne en pixels (vanilla font = 9, +3 pour l'espacement). */
private static final int LINE_HEIGHT = 12;
/** Couleur par défaut (blanc), drop shadow activé dans drawString. */
private static final int COLOR_DEFAULT = 0xFFFFFFFF;
private RigDebugOverlay() {
// utility class — enregistrée via @EventBusSubscriber
}
// ==================== TOGGLE STATE ====================
/**
* Flip l'état de l'overlay. Appelé depuis le handler de keybind dans
* {@code ModKeybindings}.
*
* @return le nouvel état (après toggle)
*/
public static boolean toggle() {
DEBUG_OVERLAY_ENABLED = !DEBUG_OVERLAY_ENABLED;
return DEBUG_OVERLAY_ENABLED;
}
/** Lecture de l'état courant (utilisé en tests). */
public static boolean isEnabled() {
return DEBUG_OVERLAY_ENABLED;
}
/** Reset state — uniquement pour les tests. */
static void resetEnabledForTesting() {
DEBUG_OVERLAY_ENABLED = false;
}
// ==================== RENDER EVENT ====================
/**
* Hook render — s'attache au post-hotbar overlay (non-critical placement
* qui ne perturbe pas les HUD vanilla de gameplay).
*/
@SubscribeEvent
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
if (!DEBUG_OVERLAY_ENABLED) return;
// On attache sur le HOTBAR overlay pour ne render qu'une fois par frame
// (choisi arbitrairement — n'importe quel overlay post-render ferait
// l'affaire, HOTBAR est présent dans 100% des contextes gameplay).
if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) {
return;
}
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (player == null) return;
GuiGraphics graphics = event.getGuiGraphics();
Font font = mc.font;
if (font == null) return;
List<String> lines = buildOverlayLines(player);
renderLines(graphics, font, lines);
}
/**
* Render effectif des lignes en haut-gauche. Extrait pour pouvoir être
* appelé depuis un contexte non-event (future devtool).
*/
private static void renderLines(GuiGraphics graphics, Font font, List<String> lines) {
int y = MARGIN;
for (String line : lines) {
graphics.drawString(font, line, MARGIN, y, COLOR_DEFAULT, true);
y += LINE_HEIGHT;
}
}
// ==================== PURE LOGIC (testable) ====================
/**
* Construit la liste de lignes à afficher. Pur (à l'exception de la lecture
* de la capability) — testable unit avec un player null.
*
* <p>Contrat null-safety :</p>
* <ul>
* <li>{@code player == null} → liste avec header + "player: null"</li>
* <li>patch absent → header + "patch: null"</li>
* <li>animator absent (server side / pas ClientAnimator) → header
* + "animator: null"</li>
* <li>nominal : header + motion + items + bindings + layers</li>
* </ul>
*
* @return liste non-null, jamais vide (header toujours présent)
*/
static List<String> buildOverlayLines(Player player) {
List<String> lines = new ArrayList<>();
lines.add("§l[TiedUp RIG Debug]§r");
if (player == null) {
lines.add("§cplayer: null§r");
return lines;
}
PlayerPatch<?> patch = TiedUpCapabilities.getPlayerPatch(player);
if (patch == null) {
lines.add("§cpatch: null§r");
return lines;
}
ClientAnimator animator = patch.getClientAnimator();
if (animator == null) {
lines.add("§canimator: null§r");
return lines;
}
appendMotionLines(lines, patch);
appendItemCountLine(lines, player);
appendBindingsCountLine(lines, animator);
appendLayerLines(lines, animator);
return lines;
}
/**
* Append les lignes {@code motion:} et {@code composite:} pour le patch
* fourni. Extrait pour lisibilité du {@link #buildOverlayLines} pipeline.
*/
private static void appendMotionLines(List<String> lines, PlayerPatch<?> patch) {
LivingMotion motion = patch.currentLivingMotion;
LivingMotion composite = patch.currentCompositeMotion;
lines.add(String.format("motion: §a%s§r", motionName(motion)));
lines.add(String.format("composite: §a%s§r", motionName(composite)));
}
/**
* Compte les items data-driven équipés. Un armbinder occupant 3 régions
* compte pour 1 — la dédup identity est déjà garantie upstream par
* {@link com.tiedup.remake.v2.bondage.capability.V2BondageEquipment#getAllEquipped()}
* via {@code IdentityHashMap}. Pas de {@code .distinct()} ici : serait
* du dead defensive code.
*/
private static void appendItemCountLine(List<String> lines, Player player) {
Map<BodyRegionV2, ItemStack> equipped = V2EquipmentHelper.getAllEquipped(player);
long count = equipped.values().stream()
.filter(s -> s != null && !s.isEmpty())
.filter(s -> DataDrivenItemRegistry.get(s) != null)
.count();
lines.add(String.format("items: §e%d§r", count));
}
/**
* Compte les bindings {@code livingAnimations} actifs dans l'animator.
* Utilise l'API publique {@link com.tiedup.remake.rig.anim.Animator#getLivingAnimations()}
* qui retourne une {@code ImmutableMap.copyOf} — safe à lire sans
* modification.
*/
private static void appendBindingsCountLine(List<String> lines, ClientAnimator animator) {
int bindings = animator.getLivingAnimations().size();
lines.add(String.format("bindings: §e%d§r", bindings));
}
/**
* Append une ligne header {@code layers:} puis une ligne par layer actif
* (base + composite non-off).
*
* <p>Format ligne layer : {@code " [BASE|priority] anim_name"} — avec
* indentation 2 espaces pour visual grouping sous le header.</p>
*/
private static void appendLayerLines(List<String> lines, ClientAnimator animator) {
lines.add("§7layers:§r");
// Collect layer descriptions via iterAllLayers. On ne peut pas early-
// return depuis un Consumer, on collecte tout puis filtre.
List<String> layerLines = new ArrayList<>();
Consumer<Layer> layerCollector = layer -> {
if (layer.isOff() && !isBaseLayer(layer)) {
// Skip composite layers off — le base est toujours rendu,
// même s'il tourne en IDLE.
return;
}
layerLines.add(describeLayer(layer));
};
animator.iterAllLayers(layerCollector);
if (layerLines.isEmpty()) {
lines.add(" §8(none)§r");
} else {
lines.addAll(layerLines);
}
}
/**
* Format une ligne de description pour un {@link Layer}. Public-ish
* (package-private) pour testabilité.
*
* <p>Format :</p>
* <ul>
* <li>Base layer : {@code " [BASE/PRIORITY] anim_registry_name"}</li>
* <li>Composite layer : {@code " [COMP/PRIORITY] anim_registry_name"}</li>
* <li>Anim null/empty : {@code "(empty)"}</li>
* </ul>
*/
static String describeLayer(Layer layer) {
String kind = isBaseLayer(layer) ? "BASE" : "COMP";
String priority = layerPriorityName(layer);
String animName = currentAnimationName(layer);
return String.format(" §7[%s/%s]§r §b%s§r", kind, priority, animName);
}
/**
* Retourne le nom d'une {@link LivingMotion}. Pour un enum classique
* (LivingMotions, TiedUpLivingMotions), {@code toString()} renvoie
* {@code name()}. Pour null, retourne {@code "null"}.
*/
static String motionName(LivingMotion motion) {
return motion != null ? motion.toString() : "null";
}
/**
* Retourne le nom du registre de l'animation courante jouée par le layer,
* ou {@code "(empty)"} si le player n'a pas d'anim (layer off / fraichement
* initialisé).
*/
private static String currentAnimationName(Layer layer) {
if (layer.animationPlayer == null || layer.animationPlayer.isEmpty()) {
return "(empty)";
}
AssetAccessor<? extends com.tiedup.remake.rig.anim.types.DynamicAnimation> anim =
layer.animationPlayer.getAnimation();
if (anim == null) return "(null)";
try {
var rl = anim.registryName();
return rl != null ? rl.toString() : "(unnamed)";
} catch (Exception e) {
// registryName() peut throw pour EMPTY_ANIMATION / LinkAnimation
// / LayerOffAnimation ; on fallback sur le class name.
return anim.getClass().getSimpleName();
}
}
/**
* Retourne le nom de la priority d'un layer. Pour la base layer, on lit
* {@link Layer.BaseLayer#getBaseLayerPriority()}. Pour les composite,
* on lit le champ priority (protected — accessed via public describeLayer
* helper). Priority peut être null sur la base layer si elle n'a pas encore
* été initialisée (ne devrait jamais arriver après postInit, mais défensif).
*/
private static String layerPriorityName(Layer layer) {
if (layer instanceof Layer.BaseLayer baseLayer) {
Layer.Priority p = baseLayer.getBaseLayerPriority();
return p != null ? p.name() : "?";
}
// Pour un Layer composite, priority est protected — on passe par
// toString() en fallback et on parse. Plus simple : on regarde dans
// quelle entry du map il se trouve. Mais on n'a pas accès au map.
// Compromis pragmatique : le toString() de Layer inclut déjà la priority.
// Exemple : " Composite Layer(HIGH) : ..." → on extrait HIGH.
String toString = layer.toString();
int open = toString.indexOf('(');
int close = toString.indexOf(')');
if (open >= 0 && close > open) {
return toString.substring(open + 1, close);
}
return "?";
}
/**
* Teste si un {@link Layer} est la base layer. On ne peut pas utiliser
* {@code instanceof BaseLayer} car certains tests fournissent des mocks ;
* pragmatique : instanceof + null-check.
*/
private static boolean isBaseLayer(Layer layer) {
return layer instanceof Layer.BaseLayer;
}
}

View File

@@ -0,0 +1,29 @@
/*
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
* by the Epic Fight Team, licensed under GPLv3.
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.event;
import net.minecraftforge.eventbus.api.Event;
import com.tiedup.remake.rig.anim.Animator;
import com.tiedup.remake.rig.patch.LivingEntityPatch;
public class InitAnimatorEvent extends Event {
private final LivingEntityPatch<?> entitypatch;
private final Animator animator;
public InitAnimatorEvent(LivingEntityPatch<?> entitypatch, Animator animator) {
this.entitypatch = entitypatch;
this.animator = animator;
}
public LivingEntityPatch<?> getEntityPatch() {
return this.entitypatch;
}
public Animator getAnimator() {
return this.animator;
}
}

Some files were not shown because too many files have changed in this diff Show More