Compare commits

114 Commits

Author SHA1 Message Date
5d0c7c6c69 Merge pull request 'Polish V2 subsystem: lockpick kinds, package boundaries, client extractions' (#22) from refactor/v2-polish into develop
Reviewed-on: #22
2026-04-19 00:06:54 +00:00
NotEvil
cc6a62a6e5 Polish V2 subsystem: lockpick kinds, package boundaries, client extractions
Architectural debt cleanup on top of the earlier V2 hardening pass.

Minigame:
  - LockpickMiniGameState splits the overloaded targetSlot int into a
    LockpickTargetKind enum + targetData int. Body-vs-furniture
    dispatch is now a simple enum check; the NBT-tag nonce it
    previously depended on is gone, along with the AIOOBE risk at
    BodyRegionV2.values()[targetSlot].
  - PacketLockpickAttempt.handleFurnitureLockpickSuccess takes the
    entity and seat id as explicit parameters. Caller pre-validates
    both before any side effect, so a corrupted ctx tag can no longer
    produce a "Lock picked!" UI with a used lockpick and nothing
    unlocked.

Package boundaries:
  - client.gltf no longer imports v2.bondage. Render-layer attachment,
    DataDrivenItemReloadListener, and GlbValidationReloadListener all
    live in v2.client.V2ClientSetup.
  - GlbValidationReloadListener moved to v2.bondage.client.diagnostic.
  - Reload-listener ordering is preserved via EventPriority (HIGH for
    the generic GLB cache clear in GltfClientSetup, LOW for bondage
    consumers in V2ClientSetup).
  - Removed the unused validateAgainstDefinition stub on GlbValidator.

Extractions from EntityFurniture:
  - FurnitureSeatSyncCodec (pipe/semicolon serialization for the
    SEAT_ASSIGNMENTS_SYNC entity data field), with 8 unit tests.
  - FurnitureClientAnimator (client-only seat-pose kickoff, moved out
    of the dual-side entity class).
  - EntityFurniture drops ~100 lines with no behavior change.

Interface docs:
  - ISeatProvider Javadoc narrowed to reflect that EntityFurniture is
    the only implementation; callers that need animation state or
    definition reference still downcast.
  - FurnitureAuthPredicate.findOccupant uses the interface only.
  - AnimationIdBuilder flagged as legacy JSON-era utility (NPC
    fallback + MCA mixin).

Artist guide: corrected the "Monster Seat System (Planned)" section
to match the ISeatProvider single-impl reality.
2026-04-19 02:06:02 +02:00
d391b892aa Merge pull request 'Refactor V2 animation, furniture, and GLTF rendering' (#21) from refactor/v2-animation-hardening into develop
Reviewed-on: #21
2026-04-18 22:26:58 +00:00
NotEvil
355e2936c9 Refactor V2 animation, furniture, and GLTF rendering
Broad consolidation of the V2 bondage-item, furniture-entity, and
client-side GLTF pipeline.

Parsing and rendering
  - Shared GLB parsing helpers consolidated into GlbParserUtils
    (accessor reads, weight normalization, joint-index clamping,
    coordinate-space conversion, animation parse, primitive loop).
  - Grow-on-demand Matrix4f[] scratch pool in GltfSkinningEngine and
    GltfLiveBoneReader — removes per-frame joint-matrix allocation
    from the render hot path.
  - emitVertex helper dedups three parallel loops in GltfMeshRenderer.
  - TintColorResolver.resolve has a zero-alloc path when the item
    declares no tint channels.
  - itemAnimCache bounded to 256 entries (access-order LRU) with
    atomic get-or-compute under the map's monitor.

Animation correctness
  - First-in-joint-order wins when body and torso both map to the
    same PlayerAnimator slot; duplicate writes log a single WARN.
  - Multi-item composites honor the FullX / FullHeadX opt-in that
    the single-item path already recognized.
  - Seat transforms converted to Minecraft model-def space so
    asymmetric furniture renders passengers at the correct offset.
  - GlbValidator: IBM count / type / presence, JOINTS_0 presence,
    animation channel target validation, multi-skin support.

Furniture correctness and anti-exploit
  - Seat assignment synced via SynchedEntityData (server is
    authoritative; eliminates client-server divergence on multi-seat).
  - Force-mount authorization requires same dimension and a free
    seat; cross-dimension distance checks rejected.
  - Reconnection on login checks for seat takeover before re-mount
    and force-loads the target chunk for cross-dimension cases.
  - tiedup_furniture_lockpick_ctx carries a session UUID nonce so
    stale context can't misroute a body-item lockpick.
  - tiedup_locked_furniture survives death without keepInventory
    (Forge 1.20.1 does not auto-copy persistent data on respawn).

Lifecycle and memory
  - EntityCleanupHandler fans EntityLeaveLevelEvent out to every
    per-entity state map on the client.
  - DogPoseRenderHandler re-keyed by UUID (stable across dimension
    change; entity int ids are recycled).
  - PetBedRenderHandler, PlayerArmHideEventHandler, and
    HeldItemHideHandler use receiveCanceled + sentinel sets so
    Pre-time mutations are restored even when a downstream handler
    cancels the render.

Tests
  - JUnit harness with 76+ tests across GlbParserUtils, GltfPoseConverter,
    FurnitureSeatGeometry, and FurnitureAuthPredicate.
2026-04-18 17:34:03 +02:00
17815873ac Merge pull request 'chore/final-polish' (#19) from chore/final-polish into develop
Reviewed-on: #19
2026-04-17 02:08:26 +00:00
NotEvil
b5ae04a1f1 guard ResourceLocation.parse() against corrupted NBT in furniture reconnect 2026-04-17 04:08:02 +02:00
NotEvil
fe36a1a47e vary room theme weights — sculk rare, inferno/ice uncommon 2026-04-17 04:08:02 +02:00
cc8adfe015 Merge pull request 'feature/gltf-pipeline-v2' (#18) from feature/gltf-pipeline-v2 into develop
Reviewed-on: #18
2026-04-17 02:07:43 +00:00
NotEvil
f37600783a docs(artist-guide): update for Full/FullHead conventions, custom bones, frame 0 behavior, validation tools 2026-04-17 04:06:33 +02:00
NotEvil
168c0675bb fix(animation): fix variant caching bug in multi-item path — same fix as single-item 2026-04-17 04:06:33 +02:00
NotEvil
a3287b7db8 fix(animation): variant randomness no longer permanently cached + fix FullHead false-positive
Two fixes from the animation audit:

1. Variant cache key now includes the resolved animation name (e.g., Struggle.2).
   Previously, the cache key only used context+parts, so the first random variant
   pick was reused forever. Now each variant gets its own cache entry, and a fresh
   random pick happens each time the context changes.

2. FullHead check changed from contains("Head") to startsWith("gltf_FullHead")
   to prevent false positives on names like FullOverhead or FullAhead.
2026-04-17 04:06:33 +02:00
NotEvil
e56e6dd551 fix(animation): extend resolver fallback chain to include FullHead variants
The resolver now tries FullHead* before Full* at each fallback step.
Example: FullHeadStruggle → FullStruggle → Struggle → FullHeadIdle → FullIdle → Idle

Previously FullHead* names were dead — the resolver never constructed them,
so animations named FullHeadStruggle were unreachable.
2026-04-17 04:06:33 +02:00
NotEvil
806a1e732d feat(animation): add FullHead convention — opt-in head animation in Full animations
FullStruggle, FullWalk etc. animate body+legs but preserve head tracking.
FullHeadStruggle, FullHeadWalk etc. also animate the head.
The 'Head' keyword in the animation name is the opt-in signal.
2026-04-17 04:06:33 +02:00
NotEvil
3d57d83a5b fix(animation): preserve head tracking in Full animations — head never enabled as free bone 2026-04-17 04:06:33 +02:00
NotEvil
229fc66340 fix(animation): free bones only enabled for Full-prefixed animations
Previously, any GLB with keyframes on free bones would animate them,
even for standard animations like Idle. This caused accidental bone
hijacking — e.g., handcuffs freezing the player's head because the
artist keyframed all bones in Blender.

Now the Full prefix (FullIdle, FullStruggle, FullWalk) is enforced:
only Full-prefixed animations can animate free bones. Standard
animations (Idle, Struggle, Walk) only animate owned bones.

This aligns the code with the documented convention in ARTIST_GUIDE.md.
2026-04-17 04:06:33 +02:00
NotEvil
b0766fecc6 feat(validation): show toast notification when GLB errors are detected on reload 2026-04-17 04:06:33 +02:00
NotEvil
9dfd2d1724 fix(validation): reduce log spam + add OOM guard + check correct mesh for WEIGHTS_0
- DataDrivenItemParser: downgrade animation_bones absence log from INFO to
  DEBUG (was spamming for every item without the optional field)
- GlbValidator: read 12-byte GLB header first and reject files declaring
  totalLength > 50 MB before allocating (prevents OOM on malformed GLBs)
- GlbValidator: WEIGHTS_0 check now uses the same mesh selection logic as
  GlbParser (prefer "Item", fallback to last non-Player) instead of
  blindly checking the first mesh
2026-04-17 04:06:33 +02:00
NotEvil
3f6e04edb0 feat(validation): add /tiedup validate client command for GLB diagnostics 2026-04-17 04:06:33 +02:00
NotEvil
ca4cbcad12 feat(validation): add GlbValidationReloadListener — validates GLBs on resource reload 2026-04-17 04:06:33 +02:00
NotEvil
17269f51f8 perf(gltf): add skinning cache — skip re-skinning when pose is unchanged 2026-04-17 04:06:33 +02:00
NotEvil
c0c53f9504 feat(validation): add GlbValidator — structural validation from JSON chunk 2026-04-17 04:06:33 +02:00
NotEvil
eb759fefff feat(validation): add diagnostic data model — GlbDiagnostic, GlbValidationResult, GlbDiagnosticRegistry 2026-04-17 04:06:33 +02:00
NotEvil
8af58b5dd5 feat(gltf): accept custom bones — remove 11-bone filter, simplify joint remap 2026-04-17 04:06:33 +02:00
NotEvil
d7f8bf6c72 fix(gltf): strip armature prefix from bone names in FurnitureGlbParser
Strip pipe-delimited armature prefixes (e.g., "MyRig|body" -> "body")
from bone names in parseSeatSkeleton and remapSeatAnimations, matching
the existing stripping already present in GlbParser. Without this,
isKnownBone checks fail when Blender exports prefixed bone names.
2026-04-17 04:06:33 +02:00
NotEvil
6dad447c05 fix(gltf): select mesh by 'Item' name convention with fallback 2026-04-17 04:06:33 +02:00
NotEvil
7ef85b4e92 feat(datadriven): make animation_bones optional — absent means permissive 2026-04-17 04:06:33 +02:00
NotEvil
ad74d320be feat(gltf): add suggestBoneName + knownBoneNames helpers to GltfBoneMapper 2026-04-17 04:06:33 +02:00
5788f39d9f Merge pull request 'refactor/god-class-decomposition' (#17) from refactor/god-class-decomposition into develop
Reviewed-on: #17
2026-04-16 12:38:55 +00:00
NotEvil
27c86bc831 fix ghost LaborRecord entries for non-working prisoners on escape 2026-04-16 14:36:45 +02:00
NotEvil
f4aa5ffdc5 split PrisonerService + decompose EntityKidnapper
PrisonerService 1057L -> 474L lifecycle + 616L EscapeMonitorService
EntityKidnapper 2035L -> 1727L via LootManager, Dialogue, CaptivePriority extraction
2026-04-16 14:08:52 +02:00
ea14fc2cec Merge pull request 'centralize all ModConfig.SERVER reads through SettingsAccessor' (#16) from chore/audit-c02-config-unification into develop
Reviewed-on: #16
2026-04-16 11:49:22 +00:00
NotEvil
4e136cff96 centralize all ModConfig.SERVER reads through SettingsAccessor
No more direct ModConfig.SERVER access outside SettingsAccessor.
32 new accessor methods, 21 consumer files rerouted.
2026-04-16 13:16:05 +02:00
683eeec11f Merge pull request 'fix/swarm-review-p0-p1-p2' (#15) from fix/swarm-review-p0-p1-p2 into develop
Reviewed-on: #15
2026-04-16 10:37:05 +00:00
NotEvil
fd60086322 feat(i18n): complete migration — items, entities, AI goals, GUI screens
119 new translation keys across 3 domains:
- Items/Blocks/Misc (46 keys): tooltips, action messages, trap states
- Entities/AI Goals (55 keys): NPC speech, maid/master/guard messages
- Client GUI (18 keys): widget labels, screen buttons, merchant display

Remaining 119 Component.literal() are all intentional:
- Debug/Admin/Command wands (47) — dev tools, not player-facing
- Entity display names (~25) — dynamic getNpcName() calls
- Empty string roots (~15) — .append() chain bases
- User-typed text (~10) — /me, /pm, /norp chat content
- Runtime data (~12) — StringBuilder, gag muffling, MCA compat
2026-04-16 12:33:13 +02:00
NotEvil
9b2c5dec8e chore(P2): V1 zombie comments cleanup + i18n events/network/items
Zombie comments (14 files):
- Replace references to deleted V1 classes (ItemBind, ItemCollar, IBondageItem,
  BindVariant, etc.) with V2 equivalents or past-tense historical notes
- "V1 path" → "legacy slot-index path" for still-active legacy codepaths
- Clean misleading javadoc that implied V1 code was still present

i18n (13 files, 31 new keys):
- Events: 12 Component.literal() → translatable (labor tools, gag eat,
  death escape, debt, punishment, grace, camp protection, chest, maid)
- Network: 12 Component.literal() → translatable (trader messages,
  lockpick jam, slave freedom warnings)
- Items: 2 Component.literal() → translatable (ItemOwnerTarget tooltips)
2026-04-16 11:20:17 +02:00
NotEvil
371a138b71 fix(P1+P2): swarm review — UX fixes + V1 dead code cleanup
P1 — UX visible:
- Add 4 missing translations (leather_mittens, ball_gag_3d, taser, debug_wand)
- Fix earplugs resistance bucket: "blindfold" → "earplug"
- Split mixin config: core (require=1) + compat MCA (require=0)
- Bump PROTOCOL_VERSION from "1" to "2"
- Add debug_wand item model (no texture — debug only)
- Fix 3 display_name inconsistencies (chain, vine_seed, shock_collar_auto)

P2 — Dead code cleanup:
- Delete IHas3DModelConfig + Model3DConfig (zero imports)
- Remove MovementStyleResolver.resolveV1Fallback() + V1Fallback record + V1 constants
- Remove AnimationTickHandler V1 fallback block + buildAnimationId()
- Document PlayerEquipment.equipInRegion() bypass as intentional (force-equip paths)
2026-04-16 10:58:44 +02:00
NotEvil
d75b74f9f9 fix(P0): 3 exploit fixes from swarm review
- RISK-006: Mittens bypass lock/unlock — add HANDS check in PacketV2SelfLock/Unlock
- RISK-002: Struggle re-roll exploit — reject-if-active in startContinuous*Session()
- RISK-003: Non-V2 locked items bypass conflict resolution — check ILockable before swap
2026-04-16 10:49:04 +02:00
bce0598059 Merge pull request 'chore/audit-uc02-roomtheme-datadriven' (#14) from chore/audit-uc02-roomtheme-datadriven into develop
Reviewed-on: #14
2026-04-16 00:38:58 +00:00
NotEvil
6d56024c7e fix(UC-02): LOW review items — anchored path strip + corner offset doc
- Use substring instead of replace for path cleanup (anchored, no double-strip risk)
- Document that corner_decorations ignores x/z offsets (by design)
2026-04-16 02:32:50 +02:00
NotEvil
8823c671d7 fix(UC-02): clean theme IDs — strip directory prefix and .json suffix
RISK-003: Definition IDs were "tiedup:tiedup_room_themes/oubliette.json"
instead of clean "tiedup:oubliette". Now derived from filename only.
2026-04-16 01:54:03 +02:00
NotEvil
6d9d6b4b81 fix(UC-02): arch review — furniture x/z offsets + ceiling chain fidelity
- Add x_offset/z_offset to PositionedBlock for multi-block furniture clusters
- Furniture items now spread across 2-3 positions (matching original Java code)
- Offsets multiplied by inward direction for correct corner orientation
- Fix has_ceiling_chain: only oubliette has ceiling chains (was true for all 6)
- Fix firstCornerSpecial: oubliette cauldron uses x/z offset +1 (inward diagonal)
- Update parser to read x_offset/z_offset (defaults to 0)
2026-04-16 01:52:31 +02:00
NotEvil
706172fb9a fix(UC-02): QA review — crypt bottom_row + ice decorations fidelity
- BUG-001: CRYPT bottom_row had unreachable mossy_stone_bricks (same f variable
  makes mossy_cobblestone guard trigger first). Fixed weights: 0.20/0.10/0.70
- BUG-003: ICE ceiling ice stalactites (y_offset=10) were missing from JSON
- BUG-002: ICE snow corner layers use middle value (3) as compromise since
  DecorationConfig.PositionedBlock doesn't support random_property
2026-04-16 01:45:04 +02:00
NotEvil
3aaf92b788 feat(UC-02): data-driven room themes — 6 JSON + consumer migration + cleanup
Phase 3: Extract 6 themes into JSON (oubliette, inferno, crypt, ice, sculk, sandstone)
Phase 4: Migrate HangingCagePiece to use RoomThemeRegistry.pickRandomOrFallback()
  - Move 7 shared static methods from RoomTheme into HangingCagePiece
  - Replace per-enum placeDecorations() with generic DecorationConfig-based placement
Phase 5: Delete RoomTheme.java (-1368L)
2026-04-16 01:39:40 +02:00
NotEvil
69f52eacf3 feat(UC-02): data-driven room theme infrastructure (Phase 1+2)
- BlockPalette: weighted random block selection with condition variants
- RoomThemeDefinition: immutable record with wallBlock/floorBlock/etc convenience API
- DecorationConfig: positioned block records for theme-specific decorations
- RoomThemeRegistry: volatile atomic snapshot + pickRandom(weight-based)
- RoomThemeParser: JSON parsing with BlockStateParser + random_property expansion
- RoomThemeReloadListener: scans data/<ns>/tiedup_room_themes/*.json
- Register listener in TiedUpMod.onAddReloadListeners()
2026-04-16 01:30:51 +02:00
a4fc05b503 Merge pull request 'chore/audit-s02-s05-state-cleanup' (#13) from chore/audit-s02-s05-state-cleanup into develop
Reviewed-on: #13
2026-04-15 14:48:42 +00:00
NotEvil
7444853840 fix(S-02): remove unused host field from PlayerMovement (review) 2026-04-15 16:48:22 +02:00
NotEvil
22d79a452b refactor(S-02/S-05): extract PlayerMovement component + fix thread safety
- Extract 11 movement fields from PlayerBindState into PlayerMovement component
- Replace volatile isStruggling/struggleStartTick pair with atomic StruggleSnapshot record
- Remove 5+2 misleading synchronized keywords (different monitors, all server-thread-only)
- Update all 36 MovementStyleManager field accesses to use getMovement() getters/setters
2026-04-15 16:48:22 +02:00
70f85b58a6 Merge pull request 'chore/audit-c01-i18n' (#12) from chore/audit-c01-i18n into develop
Reviewed-on: #12
2026-04-15 14:48:07 +00:00
NotEvil
c34bac11b0 fix(C-01): review fixes — missing keys, duplicate, GPS shock i18n
- Add missing keephead_enabled/disabled translation keys (BUG-001)
- Remove duplicate gui.tiedup.close key at line 403 (RISK-004)
- Fix GPS shock: use null addon + send GPS_ZONE_VIOLATION separately (RISK-001)
2026-04-15 14:08:41 +02:00
NotEvil
fa5cfb913c feat(C-01): i18n main commands — 148 translatable keys
Phase 3: Migrate Component.literal() in all remaining command files.
- NPCCommand (34), CellCommand (33), SocialCommand (16), CollarCommand (25),
  KeyCommand (18), BountyCommand (6), KidnapSetCommand (2), CaptivityDebugCommand (7),
  InventorySubCommand (3), TestAnimSubCommand (2), MasterTestSubCommand (7), DebtSubCommand (8)
- Strip all section sign color codes, use .withStyle(ChatFormatting)
- 148 new keys in en_us.json (command.tiedup.*)
- Debug/dynamic strings intentionally kept as literal
2026-04-15 13:54:26 +02:00
NotEvil
70965c2dda feat(C-01): i18n subcommands — 33 translatable keys
Phase 2: Migrate all Component.literal() in 5 subcommand files.
- BindCommands, GagCommands, BlindfoldCommands, CollarCommands, AccessoryCommands
- Strip \u00a7a section signs, use .withStyle(ChatFormatting.GREEN)
- Add 33 keys to en_us.json (command.tiedup.*)
- Shared error key: command.tiedup.error.no_state
2026-04-15 13:28:02 +02:00
NotEvil
0662739fe0 feat(C-01): i18n SystemMessageManager — 83 translatable keys
Phase 1: Core system message migration to Component.translatable().
- Replace getMessageTemplate() hardcoded strings with getTranslationKey() key derivation
- All send methods now use Component.translatable() with positional args
- Add 83 keys to en_us.json (msg.tiedup.system.*)
- Add sendTranslatable() convenience for external callers with string args
- Migrate 3 external getTemplate() callers (PlayerShockCollar, CellRegistryV2)
- Add resistance_suffix key for sendWithResistance()
2026-04-15 13:24:05 +02:00
ac72f6aae7 Merge pull request 'chore/quickwin-fix' (#11) from chore/quickwin-fix into develop
Reviewed-on: #11
2026-04-15 09:22:08 +00:00
NotEvil
d3bdb026f3 fix: restore missing /tiedup tie command registration (review) 2026-04-15 11:14:28 +02:00
NotEvil
c1e1f56058 refactor: split BondageSubCommand 1207L → 5 focused files (UC-01)
- BindCommands.java: tie, untie (156L)
- GagCommands.java: gag, ungag (140L)
- BlindfoldCommands.java: blindfold, unblind (142L)
- CollarCommands.java: collar, takecollar, enslave, free (288L)
- AccessoryCommands.java: putearplugs, takeearplugs, putclothes,
  takeclothes, fullyrestrain, adjust (469L)
- BondageSubCommand.java: thin delegator (19L)

Zero logic changes — purely mechanical code move.
2026-04-15 11:09:48 +02:00
NotEvil
f945e9449b feat(D-01/E): quickwins — debug toggle, HumanChairHelper move, collar equip
Q1: Remove F9 debug toggle from GltfAnimationApplier + delete dead
    GltfRenderLayer + remove keybind registration

Q2: Move HumanChairHelper from state/ to util/ — pure utility with
    no state dependency. 7 import updates.

Q3: Wire NECK collar equip flow in DataDrivenBondageItem:
    - Target must be tied up (V1 rule preserved)
    - Distance + line-of-sight validation
    - Owner added to NBT before equip via CollarHelper.addOwner()
    - V2EquipmentHelper handles conflict resolution
    - ModSounds.COLLAR_PUT played on success
    - OwnershipComponent.onEquipped registers in CollarRegistry
2026-04-15 10:57:01 +02:00
cc0ce89de5 Merge pull request 'feature/d01-branch-e-resistance' (#10) from feature/d01-branch-e-resistance into develop
Reviewed-on: #10
2026-04-15 01:45:58 +00:00
NotEvil
db407ee68f fix(D-01/E): padlock resistance double-count + knife progress persist (review)
BUG-001: PacketV2StruggleStart only adds padlock resistance bonus on
first session (when resistance >= base). Interrupted sessions that
persisted combined resistance no longer re-add the bonus, preventing
infinite resistance inflation.

BUG-002: PacketV2LockToggle UNLOCK clears knifeCutProgress and
accessoryStruggleResistance NBT keys, preventing partial knife-cut
progress from persisting across lock/unlock cycles.
2026-04-15 03:44:12 +02:00
NotEvil
d6bb030ad7 feat(D-01/E): resistance & lock system rework (E1-E7)
E1: Initialize currentResistance in NBT at equip time from
    ResistanceComponent — eliminates MAX-scan fallback bug

E2: BuiltInLockComponent for organic items (already committed)

E3: canStruggle refactor — new model:
    - ARMS: always struggle-able (no lock gating)
    - Non-ARMS: only if locked OR built-in lock
    - Removed dead isItemLocked() from StruggleState + overrides

E4: canUnequip already handled by BuiltInLockComponent.blocksUnequip()
    via ComponentHolder delegation

E5: Help/assist mechanic deferred (needs UI design)

E6: Removed lock resistance from ILockable (5 methods + NBT key deleted)
    - GenericKnife: new knifeCutProgress NBT for cutting locks
    - StruggleAccessory: accessoryStruggleResistance NBT replaces lock resistance
    - PacketV2StruggleStart: uses config-based padlock resistance
    - All lock/unlock packets cleaned of initializeLockResistance/clearLockResistance

E7: Fixed 3 pre-existing bugs:
    - B2: DataDrivenItemRegistry.clear() synchronized on RELOAD_LOCK
    - B3: V2TyingPlayerTask validates heldStack before equip (prevents duplication)
    - B5: EntityKidnapperMerchant.remove() cleans playerToMerchant map (memory leak)
2026-04-15 03:23:49 +02:00
NotEvil
199bf00aef feat(D-01/E): BuiltInLockComponent for organic items (E2)
- New BuiltInLockComponent: blocksUnequip() returns true (permanent lock)
- ComponentType: add BUILT_IN_LOCK enum value
- 8 organic item JSONs updated (slime, vine, web, tape variants)
- DataDrivenBondageItem: add hasBuiltInLock() static helper
2026-04-15 03:11:15 +02:00
4a3ff438c2 Merge pull request 'feature/d01-branch-d-cleanup' (#9) from feature/d01-branch-d-cleanup into develop
Reviewed-on: #9
2026-04-15 00:54:11 +00:00
NotEvil
4d128124e6 chore: gitignore docs/ to stop them from being tracked 2026-04-15 02:52:37 +02:00
NotEvil
3df979ceee fix(D-01/D): checkup cleanup — 5 issues resolved
1. LockableComponent: remove duplicate "Lockable" tooltip line
   (ILockable.appendLockTooltip already handles lock status display)
2. ILockable/IHasResistance Javadoc: update @link refs from deleted
   V1 classes to V2 AbstractV2BondageItem/DataDrivenBondageItem
3. SettingsAccessor Javadoc: remove stale BindVariant @link references
4. DataDrivenBondageItem: update NECK block comment (remove branch ref)
5. Delete empty bondage3d/gags/ directory
2026-04-15 02:52:27 +02:00
NotEvil
a572513640 chore(D-01/D): remove dead V1 handleSelfAccessory method 2026-04-15 02:12:05 +02:00
NotEvil
dfa7024e21 fix(D-01/D): blindfold ID mismatch + dispenser V2 registration (review)
KidnapperTheme: fix "mask_blindfold" → "blindfold_mask" across 8 themes.
The incorrect ID produced ghost items with no definition.

DispenserBehaviors: register GenericBondageDispenseBehavior.forAnyDataDriven()
for the V2 DATA_DRIVEN_ITEM singleton. Dispatches by region from the stack's
definition. V1 per-variant registrations were deleted but V2 replacement
was missing.
2026-04-15 02:10:25 +02:00
NotEvil
9302b6ccaf chore: remove docs from branch D tracking 2026-04-15 01:59:16 +02:00
NotEvil
099cd0d984 feat(D-01/D): V1 cleanup — delete 28 files, ~5400 lines removed
D1: ThreadLocal alert suppression moved from ItemCollar to CollarHelper.
    onCollarRemoved() logic (kidnapper alert) moved to CollarHelper.

D2+D3: Deleted 17 V1 item classes + 4 V1-only interfaces:
  ItemBind, ItemGag, ItemBlindfold, ItemCollar, ItemEarplugs, ItemMittens,
  ItemColor, ItemClassicCollar, ItemShockCollar, ItemShockCollarAuto,
  ItemGpsCollar, ItemChokeCollar, ItemHood, ItemMedicalGag,
  IBondageItem, IHasGaggingEffect, IHasBlindingEffect, IAdjustable

D4: KidnapperTheme/KidnapperItemSelector/DispenserBehaviors migrated
    from variant enums to string-based DataDrivenItemRegistry IDs.

D5: Deleted 11 variant enums + Generic* factories + ItemBallGag3D:
  BindVariant, GagVariant, BlindfoldVariant, EarplugsVariant, MittensVariant,
  GenericBind, GenericGag, GenericBlindfold, GenericEarplugs, GenericMittens

D6: ModItems cleaned — all V1 bondage registrations removed.
D7: ModCreativeTabs rewritten — iterates DataDrivenItemRegistry.
D8+D9: All V2 helpers cleaned (V1 fallbacks removed), orphan imports removed.

Zero V1 bondage code references remain (only Javadoc comments).
All bondage items are now data-driven via 47 JSON definitions.
2026-04-15 01:55:16 +02:00
fccb99ef9a Merge pull request 'feature/d01-branch-c-migration' (#8) from feature/d01-branch-c-migration into develop
Reviewed-on: #8
2026-04-14 22:57:05 +00:00
NotEvil
b04497b5a1 chore: remove docs from branch C (should not be tracked here) 2026-04-15 00:55:02 +02:00
NotEvil
3515c89f82 fix(D-01/C): missing sync + worldgen empty registry race (review)
StruggleSessionManager: add V2EquipmentHelper.sync(player) after bind
resistance update to prevent data loss on server restart during struggle

HangingCagePiece: add fallback ResourceLocation arrays for worldgen when
DataDrivenItemRegistry is empty (race with reload listener on initial
world creation). Registry-first with hardcoded fallbacks.
2026-04-15 00:26:07 +02:00
NotEvil
3d61c9e9e6 feat(D-01/C): consumer migration — 85 files migrated to V2 helpers
Phase 1 (state): PlayerBindState, PlayerCaptorManager, PlayerEquipment,
  PlayerDataRetrieval, PlayerLifecycle, PlayerShockCollar, StruggleAccessory
Phase 2 (client): AnimationTickHandler, NpcAnimationTickHandler, 5 render
  handlers, DamselModel, 3 client mixins, SelfBondageInputHandler,
  SlaveManagementScreen, ActionPanel, SlaveEntryWidget, ModKeybindings
Phase 3 (entities): 28 entity/AI files migrated to CollarHelper,
  BindModeHelper, PoseTypeHelper, createStack()
Phase 4 (network): PacketSlaveAction, PacketMasterEquip,
  PacketAssignCellToCollar, PacketNpcCommand, PacketFurnitureForcemount
Phase 5 (events): RestraintTaskTickHandler, PetPlayRestrictionHandler,
  PlayerEnslavementHandler, ChatEventHandler, LaborAttackPunishmentHandler
Phase 6 (commands): BondageSubCommand, CollarCommand, NPCCommand,
  KidnapSetCommand
Phase 7 (compat): MCAKidnappedAdapter, MCA mixins
Phase 8 (misc): GagTalkManager, PetRequestManager, HangingCagePiece,
  BondageItemBlockEntity, TrappedChestBlockEntity, DispenserBehaviors,
  BondageItemLoaderUtility, RestraintApplicator, StruggleSessionManager,
  MovementStyleResolver, CampLifecycleManager

Some files retain dual V1/V2 checks (instanceof V1 || V2Helper) for
coexistence — V1-only branches removed in Branch D.
2026-04-15 00:16:50 +02:00
52d1044e9a Merge pull request 'feature/d01-branch-b-definitions' (#7) from feature/d01-branch-b-definitions into develop
Reviewed-on: #7
2026-04-14 20:23:53 +00:00
NotEvil
530b86a9a7 fix(D-01/B): hood missing MOUTH block + organic items lockable:false
- Hood: add MOUTH to blocked_regions — prevents double gag stacking
- 8 organic items (slime, vine, web, tape): set lockable:false at top
  level for consistency with can_attach_padlock:false
2026-04-14 17:59:27 +02:00
NotEvil
258223bf68 feat(D-01/B): 47 data-driven item definitions + cleanup test files
16 binds: ropes, armbinder, dogbinder, chain, ribbon, slime, vine_seed,
  web_bind, shibari, leather_straps, medical_straps, beam_cuffs,
  duct_tape, straitjacket, wrap, latex_sack

19 gags: cloth_gag, ropes_gag, cleave_gag, ribbon_gag, ball_gag,
  ball_gag_strap, tape_gag, wrap_gag, slime_gag, vine_gag, web_gag,
  panel_gag, beam_panel_gag, chain_panel_gag, latex_gag, tube_gag,
  bite_gag, sponge_gag, baguette_gag

2 blindfolds, 1 earplugs, 1 mittens
5 collars (classic, shock, shock_auto, gps, choke) with ownership component
3 combos (hood, medical_gag, ball_gag_3d)

All items config-driven via ResistanceComponent (id) and GaggingComponent
(material). Organic items have can_attach_padlock: false.

Removed 4 test files (test_component_gag, test_handcuffs, test_leg_cuffs).
2026-04-14 17:52:19 +02:00
NotEvil
679d7033f9 feat(D-01/B): add can_attach_padlock field to data-driven items (B0)
- DataDrivenItemDefinition: add canAttachPadlock boolean (default true)
- DataDrivenItemParser: parse "can_attach_padlock" from JSON
- DataDrivenBondageItem: add static canAttachPadlockTo(stack) method
- AnvilEventHandler: check V2 definition before allowing padlock attach
2026-04-14 17:47:32 +02:00
8bfd97ba57 Merge pull request 'feature/d01-branch-a-bridge' (#6) from feature/d01-branch-a-bridge into develop
Reviewed-on: #6
2026-04-14 15:19:20 +00:00
NotEvil
df56ebb6bc fix(D-01/A): adversarial review fixes — 4 logic bugs
1. NECK region explicitly blocked in interactLivingEntity() — prevents
   V2 collars equipping without ownership setup (latent, no JSONs yet)

2. Swap rollback safety: if re-equip fails after swap failure, drop the
   old bind as item entity instead of losing it silently

3. GaggingComponent: cache GagMaterial at construction time — eliminates
   valueOf() log spam on every chat message with misconfigured material

4. Dual-bind prevention: check both V1 isTiedUp() AND V2 region occupied
   in TyingInteractionHelper and PacketSelfBondage to prevent equipping
   V2 bind on top of V1 bind
2026-04-14 16:48:50 +02:00
NotEvil
b97bdf367e fix(D-01/A): V2 bind/collar resistance completely broken (CRITICAL)
PlayerEquipment.getCurrentBindResistance/setCurrentBindResistance and
getCurrentCollarResistance/setCurrentCollarResistance all checked
instanceof ItemBind/ItemCollar — V2 DataDrivenBondageItem silently
returned 0, making V2 items escapable in 1 struggle roll.

Fix: use instanceof IHasResistance which both V1 and V2 implement.

Also fix StruggleCollar.tighten() to read ResistanceComponent directly
for V2 collars instead of IHasResistance.getBaseResistance(entity)
which triggers the singleton MAX-scan across all equipped items.

Note: isItemLocked() dead code in StruggleState is a PRE-EXISTING bug
(x10 locked penalty never applied) — tracked for separate fix.
2026-04-14 16:44:59 +02:00
NotEvil
eb7f06bfc8 fix(D-01/A): 3 review bugs + null guards (BUG-001, BUG-002, BUG-003, RISK-003)
BUG-001: TyingInteractionHelper swap now checks V2EquipResult — on failure,
rolls back by re-equipping the old bind instead of leaving target untied

BUG-002: OwnershipComponent.onUnequipped no longer double-calls
unregisterWearer — onCollarRemoved already handles it. Suppressed-alert
path calls unregister directly since onCollarRemoved is skipped.

BUG-003: PacketSelfBondage handleV2SelfBind now clears completed tying
task from PlayerBindState to prevent blocking future tying interactions

RISK-003: StruggleCollar get/setResistanceState null-guard on player
2026-04-14 16:38:09 +02:00
NotEvil
5c4e4c2352 fix(D-01/A): double item consumption + unchecked cast in TyingInteractionHelper
QA-001: add instanceof V2TyingPlayerTask guard before cast to prevent
ClassCastException when a V1 TyingPlayerTask was still active

QA-002: remove stack.shrink(1) after tying completion — V2TyingPlayerTask
.onComplete() already consumes the held item via heldStack.shrink(1)
2026-04-14 16:35:05 +02:00
NotEvil
eee4825aba feat(D-01/A): NPC speed reduction for V2 items (A12)
- onEquipped: apply RestraintEffectUtils speed reduction for non-Player
  entities with ARMS region and legs bound. Full immobilization for
  WRAP/LATEX_SACK pose types.
- onUnequipped: remove speed reduction for non-Player entities
- Players use MovementStyleManager (V2 tick-based), not this legacy path
2026-04-14 16:07:41 +02:00
NotEvil
b359c6be35 feat(D-01/A): self-bondage region routing (A11)
- handleV2SelfBondage: split into region-based routing
  - NECK → blocked (cannot self-collar)
  - ARMS → handleV2SelfBind (tying task with progress bar)
  - Other → handleV2SelfAccessory (instant equip)
- handleV2SelfAccessory: arms-bound check via BindModeHelper,
  locked check for swap, V2EquipmentHelper for conflict resolution
2026-04-14 16:06:01 +02:00
NotEvil
19cc69985d feat(D-01/A): V2-aware struggle system (A9)
StruggleBinds:
- canStruggle(): instanceof ItemBind → BindModeHelper.isBindItem()
- isItemLocked(): instanceof ItemBind → instanceof ILockable (fixes R4)
- onAttempt(): instanceof ItemShockCollar → CollarHelper.canShock() (fixes R5)
- tighten(): reads ResistanceComponent directly for V2, avoids MAX scan bug

StruggleCollar:
- getResistanceState/setResistanceState: instanceof ItemCollar → IHasResistance
- canStruggle(): instanceof ItemCollar → CollarHelper.isCollar() + ILockable
- onAttempt(): shock check via CollarHelper.canShock()
- successAction(): unlock via ILockable
- tighten(): resistance via IHasResistance

All V1 items continue working through the same interfaces they already implement.
2026-04-14 15:47:20 +02:00
NotEvil
737a4fd59b feat(D-01/A): interaction routing + TyingInteractionHelper (A8)
- DataDrivenBondageItem.use(): shift+click cycles bind mode for ARMS items
- DataDrivenBondageItem.interactLivingEntity(): region-based routing
  - ARMS → TyingInteractionHelper (tying task with progress bar)
  - NECK → deferred to Branch C (no V2 collar JSONs yet)
  - Other regions → instant equip via parent AbstractV2BondageItem
- TyingInteractionHelper: extracted tying flow using V2TyingPlayerTask
  - Distance/LoS validation, swap if already tied, task lifecycle
2026-04-14 15:35:31 +02:00
NotEvil
b79225d684 feat(D-01/A): OwnershipComponent lifecycle hooks (A7)
- onEquipped: register collar owners in CollarRegistry (server-side only)
- onUnequipped: alert kidnappers + unregister from CollarRegistry
- Guards: client-side check, ServerLevel cast, empty owners skip, try-catch
- appendTooltip: nickname, owner count, shock/GPS/choke capabilities
- Delegates alert suppression to ItemCollar.isRemovalAlertSuppressed()
2026-04-14 15:29:31 +02:00
NotEvil
751bad418d feat(D-01/A): poseType, helpers, OWNERSHIP ComponentType (A4, A5, A6)
- DataDrivenItemDefinition: add poseType field, parsed from JSON "pose_type"
- PoseTypeHelper: resolves PoseType from V2 definition or V1 ItemBind fallback
- BindModeHelper: static bind mode NBT utilities (isBindItem, hasArmsBound,
  hasLegsBound, cycleBindModeId) — works for V1 and V2 items
- CollarHelper: complete static utility class for collar operations with
  dual-path V2/V1 dispatch (ownership, features, shock, GPS, choke, alert)
- ComponentType: add OWNERSHIP enum value
- OwnershipComponent: stub class (lifecycle hooks added in next commit)
2026-04-14 15:23:08 +02:00
NotEvil
b81d3eed95 feat(D-01/A): config-driven components + tooltip hook (A1, A2, A3)
- ResistanceComponent: resistanceId delegates to SettingsAccessor at runtime,
  fallback to hardcoded base for backward compat
- GaggingComponent: material field delegates to GagMaterial enum from ModConfig,
  explicit comprehension/range overrides take priority
- IItemComponent: add default appendTooltip() method
- ComponentHolder: iterate components for tooltip contribution
- 6 components implement appendTooltip (lockable, resistance, gagging, shock,
  gps, choking)
- DataDrivenBondageItem: call holder.appendTooltip() in appendHoverText()
2026-04-14 15:05:48 +02:00
fa4c332a10 Merge pull request 'feature/d01-component-system' (#5) from feature/d01-component-system into develop
Reviewed-on: #5
2026-04-14 00:54:16 +00:00
NotEvil
7bd840705a docs: add components section to ARTIST_GUIDE.md
Documents the 8 available gameplay components (lockable, resistance,
gagging, blinding, shock, gps, choking, adjustable) with config fields,
examples (GPS shock collar, adjustable blindfold), and usage tips.
2026-04-14 02:51:37 +02:00
NotEvil
90bc890b95 fix(D-01): synchronize reload paths and capture snapshot locally (RISK-001, RISK-002) 2026-04-14 02:46:09 +02:00
NotEvil
185ac63a44 feat(D-01): implement 5 remaining components (blinding, shock, gps, choking, adjustable) 2026-04-14 02:37:14 +02:00
NotEvil
dcc8493e5e fix(D-01): pre-built map for O(1) ComponentType.fromKey() lookup (RISK-005)
Replace linear values() scan with a static unmodifiable HashMap lookup.
While only 3 entries currently exist, this establishes the correct
pattern for when more component types are added.
2026-04-14 02:29:46 +02:00
NotEvil
bfcc20d242 fix(D-01): compact constructor defaults null componentConfigs to empty (RISK-004)
Add compact constructor to DataDrivenItemDefinition that defaults null
componentConfigs to Map.of(). This makes the field guaranteed non-null,
allowing removal of null checks in hasComponent() and
DataDrivenItemRegistry.buildComponentHolders().
2026-04-14 02:29:25 +02:00
NotEvil
3a81bb6e12 fix(D-01): clamp component config values to valid ranges (RISK-003)
- LockableComponent: lock_resistance clamped to >= 0
- ResistanceComponent: base resistance clamped to >= 0
- GaggingComponent: comprehension clamped to [0.0, 1.0], range to >= 0.0

Prevents nonsensical negative values from malformed JSON configs.
2026-04-14 02:28:42 +02:00
NotEvil
bb589d44f8 fix(D-01): warn on non-object component config, deep-copy configs (RISK-001, RISK-002)
- Deep-copy JsonObject configs via deepCopy() before storing in the
  definition to prevent external mutation of the parsed JSON tree
- Log a warning when a component config value is not a JsonObject,
  making misconfigured JSON easier to diagnose
2026-04-14 02:27:59 +02:00
NotEvil
456335e0dd fix(D-01): wire LockableComponent.lockResistance via getItemLockResistance() (BUG-003)
- Remove redundant blocksUnequip() from LockableComponent since
  AbstractV2BondageItem.canUnequip() already checks ILockable.isLocked()
- Add DataDrivenBondageItem.getItemLockResistance(ItemStack) that reads
  the per-item lock resistance from the LockableComponent, falling back
  to the global config value when absent
2026-04-14 02:27:37 +02:00
NotEvil
bb209bcd8e fix(D-01): remove dead onWornTick() until V2 tick mechanism exists (BUG-002)
Remove onWornTick() from IItemComponent (default method) and
ComponentHolder (aggregate method). No V2 tick caller invokes these,
so they create a broken contract. Can be re-added when a tick
mechanism is implemented.
2026-04-14 02:26:31 +02:00
NotEvil
1327e3bfc3 fix(D-01): atomic snapshot for registry to prevent torn reads (BUG-001)
Replace two separate volatile fields (DEFINITIONS, COMPONENT_HOLDERS)
with a single RegistrySnapshot record swapped atomically. This prevents
race conditions where a reader thread could see new definitions paired
with stale/empty component holders between the two volatile writes.
2026-04-14 02:25:57 +02:00
NotEvil
dbacef66d5 feat(D-01): add test_component_gag.json demonstrating component system
JSON item using all 3 implemented components: lockable (lock_resistance: 200),
resistance (base: 80), and gagging (comprehension: 0.15, range: 8.0).
2026-04-14 02:03:50 +02:00
NotEvil
231522c68e feat(D-01): implement GaggingComponent with comprehension and range 2026-04-14 02:01:50 +02:00
NotEvil
84f4c3a53f feat(D-01): implement ResistanceComponent, improve stack-aware resistance lookup 2026-04-14 02:01:46 +02:00
NotEvil
caeb4469b1 feat(D-01): implement LockableComponent with configurable lock resistance 2026-04-14 02:01:41 +02:00
NotEvil
3a1f401ccf feat(D-01): delegate DataDrivenBondageItem lifecycle to components
Override onEquipped(), onUnequipped(), and canUnequip() in
DataDrivenBondageItem to delegate to the item's ComponentHolder.
The canUnequip() override preserves the existing lock check from
AbstractV2BondageItem via super.canUnequip().

Add a static getComponent() helper for external code to retrieve
a typed component from any data-driven item stack.
2026-04-14 01:47:19 +02:00
NotEvil
a781dad597 feat(D-01): instantiate ComponentHolder per item definition on reload
Add a parallel COMPONENT_HOLDERS volatile cache to DataDrivenItemRegistry,
rebuilt from raw componentConfigs every time definitions are loaded via
reload() or mergeAll(). Cleared alongside DEFINITIONS in clear().

Two accessor methods allow looking up a ComponentHolder by ItemStack
(reads tiedup_item_id NBT) or by ResourceLocation directly.
2026-04-14 01:46:19 +02:00
NotEvil
750be66d80 feat(D-01): parse component configs from item JSON definitions
Add componentConfigs field (Map<ComponentType, JsonObject>) to
DataDrivenItemDefinition record. The parser now reads an optional
"components" JSON block, resolves each key via ComponentType.fromKey(),
and stores the raw JsonObject configs for later instantiation.
2026-04-14 01:44:19 +02:00
NotEvil
1b70041c36 feat(D-01): add ComponentHolder container for item components 2026-04-14 01:38:09 +02:00
NotEvil
b8a0d839f5 feat(D-01): add ComponentType enum with stub component classes 2026-04-14 01:33:37 +02:00
NotEvil
edfc3c6506 feat(D-01): add IItemComponent interface for data-driven item behaviors 2026-04-14 01:29:24 +02:00
3fe3e16e0a Merge pull request 'feature/item-tooltip-creator' (#4) from feature/item-tooltip-creator into develop
Reviewed-on: #4
2026-04-13 02:01:21 +00:00
NotEvil
e17998933c Add creator field and enriched tooltip for data-driven items
- Add optional "creator" JSON field to display author name in tooltip
- Show body regions, movement style, lock status, and escape difficulty
- Show pose priority and item ID in advanced mode (F3+H)
- Update ARTIST_GUIDE.md field reference and test JSON
2026-04-13 04:00:27 +02:00
3a1082dc38 Merge pull request 'Actualiser README.md' (#3) from notevil-patch-1 into develop
Reviewed-on: #3
2026-04-13 00:59:12 +00:00
46e7cf8fe7 Actualiser README.md 2026-04-13 00:59:01 +00:00
59f4064259 Merge pull request 'init / code cleanup' (#2) from init/code-cleanup into develop
Reviewed-on: #2
2026-04-11 23:27:11 +00:00
NotEvil
a71093ba9c Remove internal phase comments and format code
Strip all Phase references, TODO/FUTURE roadmap notes, and internal
planning comments from the codebase. Run Prettier for consistent
formatting across all Java files.
2026-04-12 01:25:55 +02:00
711 changed files with 25948 additions and 18131 deletions

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ build_output.log
.DS_Store .DS_Store
Thumbs.db Thumbs.db
desktop.ini desktop.ini
docs/

View File

@@ -138,6 +138,16 @@ idea: ## Generate IntelliJ IDEA run configurations
eclipse: ## Generate Eclipse project files eclipse: ## Generate Eclipse project files
@$(GRADLE) eclipse @$(GRADLE) eclipse
##@ Formatting
.PHONY: format
format: ## Format code using Prettier
@npx --yes prettier --plugin prettier-plugin-java --tab-width 4 --write "src/**/*.java"
.PHONY: check-format
check-format: ## Check code formatting
@npx --yes prettier --plugin prettier-plugin-java --tab-width 4 --check "src/**/*.java"
##@ Information ##@ Information
.PHONY: info .PHONY: info

View File

@@ -105,6 +105,7 @@ Some dependencies are included as local JARs in `libs/` because they are not ava
GPL-3.0 with Commons Clause - see [LICENSE](LICENSE) for details. GPL-3.0 with Commons Clause - see [LICENSE](LICENSE) for details.
**TL;DR:** Free to use, modify, and distribute. Cannot be sold or put behind a paywall. **TL;DR:** Free to use, modify, and distribute. Cannot be sold or put behind a paywall.
The 3D models are the **property of their creators**; if their names are listed, please ask them for permission otherwise me.
## Status ## Status

View File

@@ -104,6 +104,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'
} }
server { server {
@@ -116,6 +117,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'
} }
// Additional client instances for multiplayer testing // Additional client instances for multiplayer testing
@@ -155,6 +157,7 @@ sourceSets.main.resources { srcDir 'src/generated/resources' }
mixin { mixin {
add sourceSets.main, 'tiedup.refmap.json' add sourceSets.main, 'tiedup.refmap.json'
config 'tiedup.mixins.json' config 'tiedup.mixins.json'
config 'tiedup-compat.mixins.json'
} }
repositories { repositories {
@@ -228,11 +231,30 @@ dependencies {
// The group id is ignored when searching -- in this case, it is "blank" // The group id is ignored when searching -- in this case, it is "blank"
// implementation fg.deobf("blank:coolmod-${mc_version}:${coolmod_version}") // implementation fg.deobf("blank:coolmod-${mc_version}:${coolmod_version}")
// Unit tests (pure-logic, no Minecraft runtime).
// Do NOT add Forge/Minecraft dependencies here — the test classpath is intentionally
// kept minimal so tests run fast and are isolated from the mod environment.
// Tests that need MC runtime should use the Forge GameTest framework instead.
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
testImplementation 'org.mockito:mockito-core:5.11.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2'
// For more info: // For more info:
// http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html // http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html
// http://www.gradle.org/docs/current/userguide/dependency_management.html // http://www.gradle.org/docs/current/userguide/dependency_management.html
} }
// JUnit 5 test task configuration.
// ForgeGradle's default `test` task does not enable JUnit Platform by default — we
// must opt-in explicitly for the Jupiter engine to discover @Test methods.
tasks.named('test', Test).configure {
useJUnitPlatform()
testLogging {
events 'passed', 'skipped', 'failed'
showStandardStreams = false
}
}
// This block of code expands all declared replace properties in the specified resource targets. // This block of code expands all declared replace properties in the specified resource targets.
// A missing property will result in an error. Properties are expanded using ${} Groovy notation. // A missing property will result in an error. Properties are expanded using ${} Groovy notation.
// When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments. // When "copyIdeResources" is enabled, this will also run before the game launches in IDE environments.
@@ -263,7 +285,7 @@ tasks.named('jar', Jar).configure {
'Implementation-Version' : project.jar.archiveVersion, 'Implementation-Version' : project.jar.archiveVersion,
'Implementation-Vendor' : mod_authors, 'Implementation-Vendor' : mod_authors,
'Implementation-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), 'Implementation-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"),
'MixinConfigs' : 'tiedup.mixins.json' 'MixinConfigs' : 'tiedup.mixins.json,tiedup-compat.mixins.json'
]) ])
} }

View File

@@ -16,7 +16,7 @@
7. [Animations](#animations) — item poses, fallback chain, variants, context animations 7. [Animations](#animations) — item poses, fallback chain, variants, context animations
8. [Animation Templates](#animation-templates) 8. [Animation Templates](#animation-templates)
9. [Exporting from Blender](#exporting-from-blender) 9. [Exporting from Blender](#exporting-from-blender)
10. [The JSON Definition](#the-json-definition) 10. [The JSON Definition](#the-json-definition) — field reference, components, pose priority, movement styles
11. [Packaging as a Resource Pack](#packaging-as-a-resource-pack) 11. [Packaging as a Resource Pack](#packaging-as-a-resource-pack)
12. [Common Mistakes](#common-mistakes) 12. [Common Mistakes](#common-mistakes)
13. [Examples](#examples) 13. [Examples](#examples)
@@ -96,18 +96,19 @@ PlayerArmature ← armature root object (never keyframe this)
**Never animate:** `PlayerArmature` — it's the armature root object, not a bone. **Never animate:** `PlayerArmature` — it's the armature root object, not a bone.
**Everything else follows this rule:** **Everything else follows this rule:**
- Your item **always** controls bones in its declared regions. - **Standard animations** (`Idle`, `Struggle`, `Walk`): your item controls ONLY bones in its declared regions. Keyframes on other bones are ignored.
- Your item **can also** animate free bones (not owned by any other equipped item). - **`Full` animations** (`FullIdle`, `FullStruggle`, `FullWalk`): your item also controls free bones (body, legs — not owned by another item). See [Full-Body Animations](#full-body-animations-naming-convention).
- **Head is protected by default**: vanilla head tracking is preserved unless your item owns a head region (HEAD, EYES, EARS, MOUTH). In `Full` animations, head stays protected. Use `FullHead` prefix (e.g., `FullHeadStruggle`) to explicitly opt into head animation as a free bone.
- Your item **cannot** override bones owned by another equipped item. - Your item **cannot** override bones owned by another equipped item.
| Bone | Who Controls It | | Bone | Who Controls It |
|------|----------------| |------|----------------|
| `body` / `torso` | Context layer by default. Your item if it owns TORSO or WAIST, or if `body` is free. | | `body` / `torso` | Context layer by default. Your item if it owns TORSO or WAIST. Also available as a free bone in `Full` animations. |
| `head` | Vanilla head tracking by default. Your item if it owns HEAD, EYES, EARS, or MOUTH. | | `head` | **Vanilla head tracking by default.** Your item if it owns HEAD, EYES, EARS, or MOUTH. Available as a free bone ONLY in `FullHead` animations (e.g., `FullHeadStruggle`). |
| Arms (`*UpperArm`, `*LowerArm`) | Vanilla by default. Your item if it owns ARMS or HANDS. | | Arms (`*UpperArm`, `*LowerArm`) | Vanilla by default. Your item if it owns ARMS or HANDS. Also available as free bones in `Full` animations. |
| Legs (`*UpperLeg`, `*LowerLeg`) | Context layer by default. Your item if it owns LEGS or FEET. | | Legs (`*UpperLeg`, `*LowerLeg`) | Context layer by default. Your item if it owns LEGS or FEET. Also available as free bones in `Full` animations. |
**Note:** `torso` and `body` both map to the same internal part. Prefer animating `body` — using `torso` produces the same result but is less intuitive. **Note:** `torso` and `body` both map to the same internal part. Prefer animating `body` — using `torso` produces the same result but is less intuitive. If your GLB contains **both** `body` and `torso` bones, the runtime will use only the first one encountered in the joint array and emit a WARN in the log: `"Bone 'torso' maps to PlayerAnimator part 'body' already written by an earlier bone — ignoring."` To avoid this, rig with one or the other, never both.
--- ---
@@ -160,7 +161,7 @@ Three regions are **global** — they encompass sub-regions:
Use the **TiedUp! Blender Template** (provided with the mod). It contains: Use the **TiedUp! Blender Template** (provided with the mod). It contains:
- The correct `PlayerArmature` skeleton with all 11 bones - The correct `PlayerArmature` skeleton with all 11 bones
- A reference player mesh (Steve + Alex) for scale — toggle visibility as needed - A reference player mesh (Slime model) for scale — toggle visibility as needed
- Pre-named Action slots - Pre-named Action slots
### Guidelines ### Guidelines
@@ -183,7 +184,8 @@ Weight paint your mesh to the skeleton bones it should follow.
### Rules ### Rules
- **Only paint to the 11 standard bones.** Any other bones in your Blender file will be ignored by the mod. - **Paint to the 11 standard bones** for parts that should follow the player's body.
- **You can also use custom bones** for decorative elements like chains, ribbons, pendants, or twist bones. Custom bones follow their parent in the bone hierarchy (rest pose) — they won't animate independently, but they move with the body part they're attached to. This is useful for better weight painting and mesh deformation.
- **Paint to the bones of your regions.** Handcuffs (ARMS region) should be weighted to `rightUpperArm`, `rightLowerArm`, `leftUpperArm`, `leftLowerArm`. - **Paint to the bones of your regions.** Handcuffs (ARMS region) should be weighted to `rightUpperArm`, `rightLowerArm`, `leftUpperArm`, `leftLowerArm`.
- **You can paint to bones outside your regions** for smooth deformation. For example, handcuffs might have small weights on `body` near the shoulder area for smoother bending. This is fine — the weight painting is about mesh deformation, not animation control. - **You can paint to bones outside your regions** for smooth deformation. For example, handcuffs might have small weights on `body` near the shoulder area for smoother bending. This is fine — the weight painting is about mesh deformation, not animation control.
- **Normalize your weights.** Each vertex's total weights across all bones must sum to 1.0. Blender does this by default. - **Normalize your weights.** Each vertex's total weights across all bones must sum to 1.0. Blender does this by default.
@@ -193,6 +195,7 @@ Weight paint your mesh to the skeleton bones it should follow.
- For rigid items (metal cuffs), use hard weights — each vertex fully assigned to one bone. - For rigid items (metal cuffs), use hard weights — each vertex fully assigned to one bone.
- For flexible items (rope, leather), blend weights between adjacent bones for smooth bending. - For flexible items (rope, leather), blend weights between adjacent bones for smooth bending.
- The chain between handcuffs? Weight it 50/50 to both arms, or use a separate mesh element weighted to `body`. - The chain between handcuffs? Weight it 50/50 to both arms, or use a separate mesh element weighted to `body`.
- Custom bones are great for chains, dangling locks, or decorative straps — add a bone parented to a standard bone, weight your mesh to it, and it'll follow the parent's movement automatically.
--- ---
@@ -258,9 +261,6 @@ Declare default colors per channel in your item JSON:
"tintable_1": "#FF0000", "tintable_1": "#FF0000",
"tintable_2": "#C0C0C0" "tintable_2": "#C0C0C0"
}, },
"animation_bones": {
"idle": []
},
"pose_priority": 10, "pose_priority": 10,
"escape_difficulty": 3 "escape_difficulty": 3
} }
@@ -309,19 +309,42 @@ The `PlayerArmature|` prefix is Blender's convention for armature-scoped actions
| Gag (MOUTH) | *(none)* | No pose change — mesh only | | Gag (MOUTH) | *(none)* | No pose change — mesh only |
| Straitjacket (ARMS+TORSO) | Arms + body (+ legs if free) | Arms crossed, slight forward lean, optional waddle | | Straitjacket (ARMS+TORSO) | Arms + body (+ legs if free) | Arms crossed, slight forward lean, optional waddle |
**Why only your bones?** The mod's 2-layer system activates your keyframes for bones in your declared regions. But there's a nuance: **free bones** (bones not owned by any equipped item) can also be animated by your item. **Why only your bones?** In standard animations (`Idle`, `Struggle`), the mod only uses keyframes for bones in your declared regions. Keyframes on other bones are ignored — safe to leave them in your GLB.
For example: if a player wears only a straitjacket (ARMS+TORSO), the legs are "free" — no item claims them. If your straitjacket's GLB has leg keyframes (e.g., a waddle walk), the mod will use them. But if the player also wears ankle cuffs (LEGS), those leg keyframes are ignored — the ankle cuffs take over. To animate free bones (body, legs not owned by another item), use the `Full` prefix — see [Full-Body Animations](#full-body-animations-naming-convention). For example, a straitjacket's `FullWalk` can animate legs for a waddle, but only if no ankle cuffs are equipped.
**The rule:** Your item always controls its own bones. It can also animate free bones if your GLB has keyframes for them. It can never override another item's bones. **The rule:** Standard animations = owned bones only. `Full` animations = owned + free bones. `FullHead` animations = owned + free + head. Your item can never override another item's bones.
### Idle is a Single-Frame Pose ### Animation Frames
`Idle` should be a **static pose** — one keyframe at frame 0. The mod loops it as a held position. **Frame 0 is the base pose** — the minimum every animation must have. The mod always has a valid pose to display from frame 0.
``` **Multi-frame animations play fully** — struggle thrashing, walk cycles, breathing idles all animate at runtime. The converter iterates every keyframe and emits them at the correct MC tick.
Frame 0: Pose all owned bones → done.
``` #### Authoring at 20 FPS (strongly recommended)
Minecraft ticks at **20 Hz**. Authoring at 20 FPS gives you a 1:1 mapping — every source frame becomes one MC tick.
If you author at a higher rate (24 / 30 / 60 FPS — Blender defaults), the converter quantizes timestamps to MC ticks via rounding. Multiple source frames that round to the same tick are **deduplicated** — only the first is kept, the rest are skipped. Practical impact:
| Source FPS | Frames kept per second | Lost per second |
|---|---|---|
| 20 | 20 | 0 |
| 24 | ~20 | ~4 |
| 30 | 20 | ~10 |
| 60 | 20 | ~40 |
For smooth motion at any rate, set Blender's scene FPS to 20 and author accordingly. If you must author at 24+, put critical keyframes on integer multiples of `1/20s = 50ms` to ensure they land on unique ticks.
#### Timeline start
The converter **normalizes the timeline** so the first keyframe plays at tick 0, even if your Blender action's first keyframe is at a non-zero time (NLA strips, trimmed clips). You don't need to pre-shift your timelines.
#### What the converter reads
- **Rotations** per joint (full multi-frame). **This is the primary driver.**
- **Translations** are parsed but not yet applied to the player animation — use rotations for all motion. (Bone translations are used for the furniture seat skeleton anchor, not the player pose.)
- **Ease**: linear interpolation between keyframes. Blender's default F-Curve interpolation (Bezier) is sampled by the exporter at the authored framerate — if you need smooth motion, add keyframes at the sample rate, don't rely on curve-side smoothing.
### Optional Animations ### Optional Animations
@@ -329,13 +352,13 @@ Beyond `Idle`, you can provide animations for specific contexts. All are optiona
| Animation Name | Context | Notes | | Animation Name | Context | Notes |
|----------------|---------|-------| |----------------|---------|-------|
| `Idle` | Standing still | **Required.** Single-frame pose. | | `Idle` | Standing still | **Required.** Single-frame or looped. Frame 0 = base pose. |
| `Struggle` | Player is struggling | Multi-frame loop. 20-40 frames recommended. | | `Struggle` | Player is struggling | Multi-frame loop recommended. 20-40 frames. |
| `Walk` | Player is walking | Multi-frame loop synced to walk speed. | | `Walk` | Player is walking | Multi-frame loop synced to walk speed. |
| `Sneak` | Player is sneaking | Single-frame or short loop. | | `Sneak` | Player is sneaking | Single-frame or short loop. |
| `SitIdle` | Sitting (chair, minecart) | Single-frame pose. | | `SitIdle` | Sitting (chair, minecart) | Single-frame or looped. |
| `SitStruggle` | Sitting + struggling | Multi-frame loop. | | `SitStruggle` | Sitting + struggling | Multi-frame loop. |
| `KneelIdle` | Kneeling | Single-frame pose. | | `KneelIdle` | Kneeling | Single-frame or looped. |
| `KneelStruggle` | Kneeling + struggling | Multi-frame loop. | | `KneelStruggle` | Kneeling + struggling | Multi-frame loop. |
| `Crawl` | Crawling (dog pose) | Multi-frame loop. | | `Crawl` | Crawling (dog pose) | Multi-frame loop. |
@@ -350,21 +373,27 @@ If an animation doesn't exist in your GLB, the mod looks for alternatives. At ea
``` ```
SIT + STRUGGLE: SIT + STRUGGLE:
FullSitStruggle → SitStruggle → FullStruggle → Struggle FullHeadSitStruggle → FullSitStruggle → SitStruggle
→ FullSit → Sit → FullStruggle → Struggle → FullIdle → Idle → FullHeadStruggle → FullStruggle → Struggle
→ FullHeadSit → FullSit → Sit
→ FullHeadIdle → FullIdle → Idle
KNEEL + STRUGGLE: KNEEL + STRUGGLE:
FullKneelStruggle → KneelStruggle → FullStruggle → Struggle FullHeadKneelStruggle → FullKneelStruggle → KneelStruggle
→ FullKneel → Kneel → FullStruggle → Struggle → FullIdle → Idle → FullHeadStruggle → FullStruggle → Struggle
→ FullHeadKneel → FullKneel → Kneel
→ FullHeadIdle → FullIdle → Idle
SIT + IDLE: FullSitIdle → SitIdle → FullSit → Sit → FullIdle → Idle SIT + IDLE: FullHeadSitIdle → FullSitIdle → SitIdle → ... → FullHeadIdle → FullIdle → Idle
KNEEL + IDLE: FullKneelIdle → KneelIdle → FullKneel → Kneel → FullIdle → Idle KNEEL + IDLE: FullHeadKneelIdle → FullKneelIdle → KneelIdle → ... → FullHeadIdle → FullIdle → Idle
SNEAK: FullSneak → Sneak → FullIdle → Idle SNEAK: FullHeadSneak → FullSneak → Sneak → FullHeadIdle → FullIdle → Idle
WALK: FullWalk → Walk → FullIdle → Idle WALK: FullHeadWalk → FullWalk → Walk → FullHeadIdle → FullIdle → Idle
STAND STRUGGLE: FullStruggle → Struggle → FullIdle → Idle STAND STRUGGLE: FullHeadStruggle → FullStruggle → Struggle → FullHeadIdle → FullIdle → Idle
STAND IDLE: FullIdle → Idle STAND IDLE: FullHeadIdle → FullIdle → Idle
``` ```
At each step, `FullHead` is tried first (full body + head), then `Full` (full body, head preserved), then standard (owned bones only).
In practice, most items only need `Idle`. Add `FullWalk` or `FullStruggle` when your item changes how the whole body moves. In practice, most items only need `Idle`. Add `FullWalk` or `FullStruggle` when your item changes how the whole body moves.
**Practical impact:** If you only provide `Idle`, your item works in every context. The player will hold the Idle pose while sitting, kneeling, sneaking, etc. It won't look perfect, but it will work. Add more animations over time to polish the experience. **Practical impact:** If you only provide `Idle`, your item works in every context. The player will hold the Idle pose while sitting, kneeling, sneaking, etc. It won't look perfect, but it will work. Add more animations over time to polish the experience.
@@ -394,18 +423,19 @@ Some items affect the entire body — not just their declared regions. A straitj
**Convention:** Prefix the animation name with `Full`. **Convention:** Prefix the animation name with `Full`.
| Standard Name | Full-Body Name | What Changes | | Standard Name | Full-Body Name | Full + Head | What Changes |
|--------------|---------------|-------------| |--------------|---------------|-------------|-------------|
| `Idle` | `FullIdle` | Owned bones + body lean, leg stance | | `Idle` | `FullIdle` | `FullHeadIdle` | Owned bones + body lean, leg stance (+ head if Head variant) |
| `Walk` | `FullWalk` | Owned bones + leg waddle, body sway | | `Walk` | `FullWalk` | `FullHeadWalk` | Owned bones + leg waddle, body sway (+ head if Head variant) |
| `Struggle` | `FullStruggle` | Owned bones + full-body thrashing | | `Struggle` | `FullStruggle` | `FullHeadStruggle` | Owned bones + full-body thrashing (+ head if Head variant) |
| `Sneak` | `FullSneak` | Owned bones + custom sneak posture | | `Sneak` | `FullSneak` | `FullHeadSneak` | Owned bones + custom sneak posture (+ head if Head variant) |
**In Blender:** **In Blender:**
``` ```
PlayerArmature|Idle ← region-only: just arms for handcuffs PlayerArmature|Idle ← region-only: just arms for handcuffs
PlayerArmature|FullWalk ← full-body: arms + legs waddle + body bob PlayerArmature|FullWalk ← full-body: arms + legs waddle + body bob
PlayerArmature|FullStruggle ← full-body: everything moves PlayerArmature|FullStruggle ← full-body: body + legs thrash (head free)
PlayerArmature|FullHeadStruggle ← full-body + head: everything moves including head
``` ```
**How the mod resolves this:** **How the mod resolves this:**
@@ -423,7 +453,9 @@ PlayerArmature|FullStruggle ← full-body: everything moves
| `Sneak` | Default sneak lean is fine | Your item changes how sneaking looks | | `Sneak` | Default sneak lean is fine | Your item changes how sneaking looks |
**Key points:** **Key points:**
- `Full` animations include keyframes for ALL bones you want to control (owned + free). - **Standard animations** (`Idle`, `Struggle`, `Walk`) only animate your item's **owned bones** (from `regions`). Any keyframes on other bones are ignored. This is safe — you can keyframe everything in Blender without worrying about side effects.
- **`Full` animations** (`FullIdle`, `FullStruggle`, `FullWalk`) additionally enable **free bones** (body, legs — bones not owned by any other equipped item). This is how you create waddle walks, full-body struggles, etc.
- **Head is protected by default.** In `Full` animations, the `head` bone is NOT enabled as a free bone — vanilla head tracking (mouse look) is preserved. To animate the head in a Full animation, add `Head` to the animation name: `FullHeadStruggle`, `FullHeadIdle`, etc. Items that own a head region (HEAD, EYES, EARS, MOUTH) always control head regardless of naming.
- Free bones in `Full` animations are only used when no other item owns them. - Free bones in `Full` animations are only used when no other item owns them.
- You can provide BOTH: `Idle` (region-only) and `FullWalk` (full-body). The mod picks the right one per context. - You can provide BOTH: `Idle` (region-only) and `FullWalk` (full-body). The mod picks the right one per context.
- `FullIdle` is rarely needed — most items only need a full-body version for movement animations. - `FullIdle` is rarely needed — most items only need a full-body version for movement animations.
@@ -609,6 +641,8 @@ In your JSON definition, separate the mesh from the animations:
### Export Settings ### Export Settings
**Set your Blender scene FPS to 20** (Scene Properties > Frame Rate > Custom = 20) before authoring animations. Minecraft ticks at 20 Hz; any source frame rate above 20 FPS will have frames silently deduplicated at load. See [Animation Frames](#animation-frames).
**File > Export > glTF 2.0 (.glb)** **File > Export > glTF 2.0 (.glb)**
| Setting | Value | Why | | Setting | Value | Why |
@@ -625,12 +659,14 @@ In your JSON definition, separate the mesh from the animations:
### Pre-Export Checklist ### Pre-Export Checklist
- [ ] Scene FPS set to **20** (Blender default is 24 — change it)
- [ ] Armature is named `PlayerArmature` - [ ] Armature is named `PlayerArmature`
- [ ] All 11 bones have correct names (case-sensitive) - [ ] All 11 bones have correct names (case-sensitive)
- [ ] Actions are named `PlayerArmature|Idle`, `PlayerArmature|Struggle`, etc. - [ ] Actions are named `PlayerArmature|Idle`, `PlayerArmature|Struggle`, etc.
- [ ] Mesh is weight-painted to skeleton bones only - [ ] Mesh is weight-painted to skeleton bones only
- [ ] Weights are normalized - [ ] Weights are normalized (the mod re-normalizes at load as a safety net, but authoring-normalized weights give the most predictable result)
- [ ] No orphan bones (extra bones not in the standard 11 are ignored but add file size) - [ ] Custom bones (if any) are parented to a standard bone in the hierarchy
- [ ] Your item mesh is named `Item` in Blender (recommended — ensures the mod picks the correct mesh if your file has multiple objects)
- [ ] Materials/textures are applied (the GLB bakes them in) - [ ] Materials/textures are applied (the GLB bakes them in)
- [ ] Scale is correct (1 Blender unit = 1 Minecraft block = 16 pixels) - [ ] Scale is correct (1 Blender unit = 1 Minecraft block = 16 pixels)
@@ -648,9 +684,6 @@ Every item needs a JSON file that declares its gameplay properties. The mod scan
"display_name": "Rope Gag", "display_name": "Rope Gag",
"model": "mycreator:models/gltf/rope_gag.glb", "model": "mycreator:models/gltf/rope_gag.glb",
"regions": ["MOUTH"], "regions": ["MOUTH"],
"animation_bones": {
"idle": []
},
"pose_priority": 10, "pose_priority": 10,
"escape_difficulty": 2, "escape_difficulty": 2,
"lockable": false "lockable": false
@@ -749,7 +782,6 @@ The `movement_style` changes how the player physically moves — slower speed, d
| `display_name` | string | Yes | Name shown in-game | | `display_name` | string | Yes | Name shown in-game |
| `model` | string | Yes | ResourceLocation of the GLB mesh | | `model` | string | Yes | ResourceLocation of the GLB mesh |
| `slim_model` | string | No | GLB for Alex-model players (3px arms) | | `slim_model` | string | No | GLB for Alex-model players (3px arms) |
| `texture` | string | No | Override texture (if not baked in GLB) |
| `animation_source` | string | No | GLB to read animations from (defaults to `model`) | | `animation_source` | string | No | GLB to read animations from (defaults to `model`) |
| `regions` | string[] | Yes | Body regions this item occupies | | `regions` | string[] | Yes | Body regions this item occupies |
| `blocked_regions` | string[] | No | Regions blocked for other items (defaults to `regions`) | | `blocked_regions` | string[] | No | Regions blocked for other items (defaults to `regions`) |
@@ -759,16 +791,19 @@ The `movement_style` changes how the player physically moves — slower speed, d
| `supports_color` | bool | No | Whether this item has tintable zones. Default: `false` | | `supports_color` | bool | No | Whether this item has tintable zones. Default: `false` |
| `tint_channels` | object | No | Default colors per tintable zone: `{"tintable_1": "#FF0000"}` | | `tint_channels` | object | No | Default colors per tintable zone: `{"tintable_1": "#FF0000"}` |
| `icon` | string | No | Inventory sprite model (see [Inventory Icons](#inventory-icons) below) | | `icon` | string | No | Inventory sprite model (see [Inventory Icons](#inventory-icons) below) |
| `animations` | string/object | No | `"auto"` (default) or explicit name mapping |
| `movement_style` | string | No | Movement restriction: `"waddle"`, `"shuffle"`, `"hop"`, or `"crawl"` | | `movement_style` | string | No | Movement restriction: `"waddle"`, `"shuffle"`, `"hop"`, or `"crawl"` |
| `movement_modifier` | object | No | Override speed/jump for the movement style (requires `movement_style`) | | `movement_modifier` | object | No | Override speed/jump for the movement style (requires `movement_style`) |
| `animation_bones` | object | Yes | Per-animation bone whitelist (see below) | | `creator` | string | No | Author/creator name, shown in the item tooltip |
| `animation_bones` | object | No | Per-animation bone whitelist (see below). If omitted, all owned bones are enabled for all animations. |
| `components` | object | No | Gameplay behavior components (see [Components](#components-gameplay-behaviors) below) |
### animation_bones (required) ### animation_bones (optional)
Declares which bones each named animation is allowed to control for this item. This enables fine-grained per-animation bone filtering: an item might own `body` via its regions but only want the "idle" animation to affect the arms. Fine-grained control over which bones each animation is allowed to affect. Most items don't need this — if omitted, all owned bones (from your `regions`) are enabled for all animations automatically.
**Format:** A JSON object where each key is an animation name (matching the GLB animation names) and each value is an array of bone names. **When to use it:** When your item owns bones via its regions but you only want specific animations to affect specific bones. For example, an item owning ARMS + TORSO might only want the "idle" pose to affect the arms, and only the "struggle" animation to also move the body.
**Format:** A JSON object where each key is an animation name and each value is an array of bone names.
**Valid bone names:** `head`, `body`, `rightArm`, `leftArm`, `rightLeg`, `leftLeg` **Valid bone names:** `head`, `body`, `rightArm`, `leftArm`, `rightLeg`, `leftLeg`
@@ -782,7 +817,85 @@ Declares which bones each named animation is allowed to control for this item. T
At runtime, the effective bones for a given animation clip are computed as the **intersection** of `animation_bones[clipName]` and the item's owned parts (from region conflict resolution). If the clip name is not listed in `animation_bones`, the item falls back to using all its owned parts. At runtime, the effective bones for a given animation clip are computed as the **intersection** of `animation_bones[clipName]` and the item's owned parts (from region conflict resolution). If the clip name is not listed in `animation_bones`, the item falls back to using all its owned parts.
This field is **required**. Items without `animation_bones` will be rejected by the parser. **If omitted entirely:** All owned bones are enabled for all animations. This is the correct default for most items — you only need `animation_bones` for advanced per-animation filtering.
### Components (Gameplay Behaviors)
Components add gameplay behaviors to your item without requiring Java code. Each component is a self-contained module you declare in the `"components"` block of your JSON definition.
**Format:** A JSON object where each key is a component name and each value is the component's configuration (an object, or `true` for defaults).
```json
"components": {
"lockable": { "lock_resistance": 200 },
"resistance": { "base": 150 },
"gagging": { "comprehension": 0.2, "range": 10.0 }
}
```
Items without the `"components"` field work normally — components are entirely optional.
#### Available Components
| Component | Description | Config Fields |
|-----------|-------------|---------------|
| `lockable` | Item can be locked with a padlock. Locked items cannot be unequipped. | `lock_resistance` (int, default: 250) — resistance added by the lock for struggle mechanics |
| `resistance` | Struggle resistance. Higher = harder to escape. | `base` (int, default: 100) — base resistance value |
| `gagging` | Muffles the wearer's speech. | `comprehension` (0.01.0, default: 0.2) — how much speech is understandable. `range` (float, default: 10.0) — max hearing distance in blocks |
| `blinding` | Applies a blindfold overlay to the wearer's screen. | `overlay` (string, optional) — custom overlay texture path. Omit for default |
| `shock` | Item can shock the wearer (manually or automatically). | `damage` (float, default: 2.0) — damage per shock. `auto_interval` (int, default: 0) — ticks between auto-shocks (0 = manual only) |
| `gps` | GPS tracking and safe zone enforcement. | `safe_zone_radius` (int, default: 50) — safe zone in blocks (0 = tracking only). `public_tracking` (bool, default: false) — anyone can track, not just owner |
| `choking` | Drains air, applies darkness/slowness, deals damage when activated. | `air_drain_per_tick` (int, default: 8) — air drained per tick. `non_lethal_for_master` (bool, default: true) — won't kill if worn by a master's pet |
| `adjustable` | Allows Y-offset adjustment via GUI slider. | `default` (float, default: 0.0), `min` (float, default: -4.0), `max` (float, default: 4.0), `step` (float, default: 0.25) — all in pixels (1px = 1/16 block) |
#### Example: Shock Collar with GPS
```json
{
"type": "tiedup:bondage_item",
"display_name": "GPS Shock Collar",
"model": "mycreator:models/gltf/gps_shock_collar.glb",
"regions": ["NECK"],
"pose_priority": 10,
"escape_difficulty": 5,
"components": {
"lockable": { "lock_resistance": 300 },
"resistance": { "base": 150 },
"shock": { "damage": 3.0, "auto_interval": 200 },
"gps": { "safe_zone_radius": 50 }
}
}
```
This collar can be locked (300 resistance to break the lock), has 150 base struggle resistance, shocks every 200 ticks (10 seconds) automatically, and enforces a 50-block safe zone.
#### Example: Adjustable Blindfold
```json
{
"type": "tiedup:bondage_item",
"display_name": "Leather Blindfold",
"model": "mycreator:models/gltf/blindfold.glb",
"regions": ["EYES"],
"animation_bones": {
"idle": ["head"]
},
"pose_priority": 10,
"escape_difficulty": 2,
"components": {
"blinding": {},
"resistance": { "base": 80 },
"adjustable": { "min": -2.0, "max": 2.0, "step": 0.5 }
}
}
```
#### Component Tips
- **You can combine any components.** A gag with `gagging` + `lockable` + `resistance` + `adjustable` is perfectly valid.
- **Omit components you don't need.** A decorative collar with no shock/GPS just omits those components entirely.
- **Default values are sensible.** `"lockable": {}` gives you standard lock behavior with default resistance. You only need to specify fields you want to customize.
- **Components don't affect rendering.** They are purely gameplay — your GLB model and animations are independent of which components you use.
### Pose Priority ### Pose Priority
@@ -931,14 +1044,48 @@ Use your own namespace (e.g., `mycreator`) to avoid conflicts with the base mod
--- ---
## Validation & Debugging
The mod includes built-in tools to help you catch issues with your GLB files.
### `/tiedup validate` Command
Run `/tiedup validate` in-game (client-side command) to see a diagnostic report for all loaded GLBs:
- **RED** errors — item won't work (missing GLB, invalid file, no skin)
- **YELLOW** warnings — item works but something looks wrong (bone typo, multiple meshes, no Idle animation)
- **GRAY** info — informational (custom bones detected, vertex count)
Filter by item: `/tiedup validate tiedup:leather_armbinder`
The validation runs automatically on every resource reload (F3+T). Check your game log for a summary line: `[GltfValidation] Validated N GLBs: X passed, Y with warnings, Z with errors`.
### Mesh Naming Convention
If your GLB contains multiple meshes, name your item mesh `Item` in Blender. The mod prioritizes a mesh named `Item` over other meshes. If no `Item` mesh is found, the last non-`Player` mesh is used (backward compatible, but may pick the wrong one in multi-mesh files).
### Parse-Time Warnings (watch the log)
Beyond the toast-based validator, the parser emits WARN-level log lines on load for specific malformations. Grep your `logs/latest.log` for `[GltfPipeline]` / `[FurnitureGltf]` to catch these:
| WARN message | Meaning | What to do |
|---|---|---|
| `Clamped N out-of-range joint indices in '<file>'` | The mesh references joint indices ≥ bone count. Clamped to joint 0 (root) to avoid a crash — affected vertices render at the root position, usually visibly wrong. | In Blender, select the mesh, `Weights > Limit Total` (set to 4), then re-normalize and re-export. |
| `WEIGHTS_0 array length N is not a multiple of 4` | Malformed skin data (not per-glTF-spec VEC4). Trailing orphan weights are ignored. | Re-export. If it persists, check your mesh for non-mesh attribute overrides in Blender's Object Data properties. |
| `GLB size X exceeds cap 52428800` | File too large (>50 MB cap). Parsing is refused; the asset won't render. | Decimate mesh, downsize textures, or split the model. Furniture meshes rarely need to exceed 200 KB. |
| `Accessor would read past BIN chunk` / `Accessor count * components overflows int` | Malformed or hostile GLB accessor declaring impossible sizes. Parse refused. | Re-export from Blender (not an authoring mistake — likely a corrupted export). |
---
## Common Mistakes ## Common Mistakes
### Skeleton Issues ### Skeleton Issues
| Mistake | Symptom | Fix | | Mistake | Symptom | Fix |
|---------|---------|-----| |---------|---------|-----|
| Bone name typo (`RightUpperArm` instead of `rightUpperArm`) | Mesh doesn't follow that bone | Names are **camelCase**, not PascalCase. Check exact spelling. | | Bone name typo (`RightUpperArm` instead of `rightUpperArm`) | WARN in log with suggestion: "did you mean 'rightUpperArm'?" — bone treated as custom | Names are **camelCase**, not PascalCase. Check exact spelling. Run `/tiedup validate` to see warnings. |
| Extra bones in the armature | No visible issue (ignored), larger file | Delete non-standard bones before export | | Both `body` and `torso` bones present | WARN in log: "maps to PlayerAnimator part 'body' already written" — only the first bone in the joint array drives the pose, the other is ignored | Use one or the other. Prefer `body`. Delete the redundant bone from the rig. |
| Extra bones in the armature | Custom bones follow their parent in rest pose | Intentional custom bones are fine (chains, decorations). Unintentional ones add file size — delete them. |
| Missing `PlayerArmature` root | Mesh renders at wrong position | Rename your armature root to `PlayerArmature` | | Missing `PlayerArmature` root | Mesh renders at wrong position | Rename your armature root to `PlayerArmature` |
| Animating `body` bone without TORSO region | Body keyframes used only if `body` is free (no other item owns it) | Declare TORSO/WAIST region if you always want to control body, or use `Full` animations for free-bone effects | | Animating `body` bone without TORSO region | Body keyframes used only if `body` is free (no other item owns it) | Declare TORSO/WAIST region if you always want to control body, or use `Full` animations for free-bone effects |
@@ -946,11 +1093,12 @@ Use your own namespace (e.g., `mycreator`) to avoid conflicts with the base mod
| Mistake | Symptom | Fix | | Mistake | Symptom | Fix |
|---------|---------|-----| |---------|---------|-----|
| Action not prefixed with `PlayerArmature\|` | Animation not found, falls back to first clip | Rename: `Idle``PlayerArmature\|Idle` | | Action not prefixed with `PlayerArmature\|` | Animation not found, falls back to first clip | Rename: `Idle``PlayerArmature\|Idle`. Note: the mod strips any `ArmatureName\|` prefix, so custom armature names also work. |
| Wrong case (`idle` instead of `Idle`) | Animation not found | Use exact PascalCase: `Idle`, `SitIdle`, `KneelStruggle` | | Wrong case (`idle` instead of `Idle`) | Animation not found | Use exact PascalCase: `Idle`, `SitIdle`, `KneelStruggle` |
| Variant gap (`.1`, `.2`, `.4` — missing `.3`) | Only .1 and .2 are used | Number sequentially with no gaps | | Variant gap (`.1`, `.2`, `.4` — missing `.3`) | Only .1 and .2 are used | Number sequentially with no gaps |
| Animating bones outside your regions | Keyframes silently ignored | Only animate bones in your declared regions | | Animating bones outside your regions | Keyframes silently ignored | Only animate bones in your declared regions |
| Multi-frame Idle | Works but wastes resources | Idle should be a single keyframe at frame 0 | | Bone rotated a quarter-turn around its vertical axis (yaw ≈ ±90°) | Jitter or sudden flip in pitch/roll at the boundary | Gimbal-lock in the Euler ZYX decomposition used to feed PlayerAnimator. Keep bone yaw within ±85° of forward; if you need a 90° yaw, add a few degrees of pitch or roll. |
| Animation looks choppy or loses keyframes | Source FPS > 20 — multiple source frames round to the same MC tick and all but the first are deduplicated | Set Blender's scene FPS to 20 and re-export. See [Animation Frames](#animation-frames) for the mapping table. |
### Weight Painting Issues ### Weight Painting Issues
@@ -958,7 +1106,7 @@ Use your own namespace (e.g., `mycreator`) to avoid conflicts with the base mod
|---------|---------|-----| |---------|---------|-----|
| Vertices not weighted to any bone | Part of mesh stays frozen in space | Weight paint everything to at least one bone | | Vertices not weighted to any bone | Part of mesh stays frozen in space | Weight paint everything to at least one bone |
| Weights not normalized | Mesh stretches or compresses oddly | Blender > Weights > Normalize All | | Weights not normalized | Mesh stretches or compresses oddly | Blender > Weights > Normalize All |
| Weighted to a non-standard bone | That part of mesh stays frozen | Only weight to the 11 standard bones | | Weighted to a non-standard bone | Mesh follows parent bone in rest pose | This is OK if intentional (custom bones). If not, re-weight to a standard bone. |
### JSON Issues ### JSON Issues
@@ -990,9 +1138,6 @@ A collar sits on the neck. It doesn't change the player's pose.
"display_name": "Leather Collar", "display_name": "Leather Collar",
"model": "mycreator:models/gltf/leather_collar.glb", "model": "mycreator:models/gltf/leather_collar.glb",
"regions": ["NECK"], "regions": ["NECK"],
"animation_bones": {
"idle": []
},
"pose_priority": 5, "pose_priority": 5,
"escape_difficulty": 3, "escape_difficulty": 3,
"lockable": true "lockable": true
@@ -1158,21 +1303,29 @@ Furniture_Armature|Shake ← whole frame vibrates
#### Player Seat Animations #### Player Seat Animations
Target the `Player_*` armatures. Blender exports them as `Player_main|AnimName`. Target the `Player_*` armatures. Blender exports them as `Player_main|AnimName`.
The mod resolves them as `{seatId}:{AnimName}`. The mod resolves them per seat ID (e.g., `Player_main|Idle` → seat `main`, clip `Idle`).
| Animation Name | When Played | Required? | | Animation Name | When Played | Required? |
|---------------|------------|-----------| |---------------|------------|-----------|
| `Idle` | Default seated pose | **Yes** (no fallback) | | `Idle` | Default seated pose (STATE_IDLE) | **Yes** — canonical fallback |
| `Struggle` | Player struggling to escape | Optional (stays in Idle) | | `Occupied` | At least one passenger is seated (STATE_OCCUPIED) | Optional (falls back to Idle) |
| `Enter` | Mount transition (one-shot, 1 second) | Optional (snaps to Idle if absent) | | `Struggle` | Player struggling to escape (STATE_STRUGGLE) | Optional (falls back to Occupied → Idle) |
| `Exit` | Dismount transition (one-shot, 1 second) | Optional (snaps to vanilla if absent) | | `Enter` | Mount transition (STATE_ENTERING, ~20 ticks) | Optional (falls back to Occupied → Idle) |
| `Exit` | Dismount transition (STATE_EXITING, ~20 ticks) | Optional (falls back to Occupied → Idle) |
| `LockClose` | Seat is being locked (STATE_LOCKING) | Optional (falls back to Occupied → Idle) |
| `LockOpen` | Seat is being unlocked (STATE_UNLOCKING) | Optional (falls back to Occupied → Idle) |
The mod plays the state-specific clip if authored. When a state transitions server-side, the pose updates automatically on all clients — no packet work required.
**Fallback chain:** state-specific clip → `Occupied` → first authored clip. This means: if you only author `Idle`, the player holds it for every state. Adding `Struggle` and `Enter` gets you polish on those states without breaking anything if you skip the rest.
Example in Blender's Action Editor: Example in Blender's Action Editor:
``` ```
Player_main|Idle → resolved as "main:Idle" ← arms spread, legs apart Player_main|Idle → seat "main" clip "Idle" ← arms spread, legs apart
Player_main|Struggle → resolved as "main:Struggle" ← pulling against restraints Player_main|Struggle → seat "main" clip "Struggle" ← pulling against restraints
Player_left|Idle → resolved as "left:Idle" ← head and arms through pillory Player_main|Enter → seat "main" clip "Enter" ← one-shot mount transition
Player_right|Idle → resolved as "right:Idle" ← same pose, other side Player_left|Idle seat "left" clip "Idle" head and arms through pillory
Player_right|Idle → seat "right" clip "Idle" ← same pose, other side
``` ```
**Key difference from body items:** Furniture player animations control **ALL 11 bones**, not just region-owned bones. The furniture overrides the player's entire pose for the blocked regions, and the remaining regions still show body item effects (gag, blindfold, etc.). **Key difference from body items:** Furniture player animations control **ALL 11 bones**, not just region-owned bones. The furniture overrides the player's entire pose for the blocked regions, and the remaining regions still show body item effects (gag, blindfold, etc.).
@@ -1436,7 +1589,7 @@ Two players can be locked side by side. The mod picks the seat nearest to where
### Monster Seat System (Planned) ### Monster Seat System (Planned)
The furniture system is built on a universal `ISeatProvider` interface that is **not limited to static furniture**. Any living entity (monster, NPC) can implement the same interface to hold players in constrained poses using the same mechanics: blocked regions, forced animations, lock/escape. The furniture system is built on an `ISeatProvider` interface currently implemented only by `EntityFurniture`. The design intent is that any living entity (monster, NPC) could implement the same interface to hold players in constrained poses using the same mechanics: blocked regions, forced animations, lock/escape — but no second implementation exists yet.
**Example use case:** A tentacle monster that grabs a player on attack — the player "rides" the monster, gets a forced pose (arms restrained), and must struggle to escape. The monster's GLB would contain a `Player_grab` armature with `Player_grab|Idle` and `Player_grab|Struggle` animations, following the exact same convention as furniture seats. **Example use case:** A tentacle monster that grabs a player on attack — the player "rides" the monster, gets a forced pose (arms restrained), and must struggle to escape. The monster's GLB would contain a `Player_grab` armature with `Player_grab|Idle` and `Player_grab|Struggle` animations, following the exact same convention as furniture seats.
@@ -1465,11 +1618,16 @@ NEVER DO:
GOOD TO KNOW: GOOD TO KNOW:
→ Only Idle is required. Everything else has fallbacks. → Only Idle is required. Everything else has fallbacks.
→ animation_bones is optional. Omit it and all owned bones work for all animations.
→ Templates let you skip animation entirely. → Templates let you skip animation entirely.
Free bones (not owned by any item) CAN be animated by your GLB. Custom bones are supported — add chain/ribbon/twist bones parented to standard bones.
→ Free bones (body, legs) can be animated using Full-prefixed animations (FullWalk, FullStruggle).
→ Head is protected — use FullHead prefix (FullHeadStruggle) to also animate the head.
→ Bones owned by another equipped item are always ignored. → Bones owned by another equipped item are always ignored.
→ The mod handles sitting, sneaking, walking — you don't have to. → The mod handles sitting, sneaking, walking — you don't have to.
→ Context GLBs in tiedup_contexts/ replace default postures. → Context GLBs in tiedup_contexts/ replace default postures.
→ Name your item mesh "Item" in Blender for explicit selection in multi-mesh files.
→ Run /tiedup validate in-game to check your GLBs for issues.
→ Slim model is optional. Steve mesh works on Alex (minor clipping). → Slim model is optional. Steve mesh works on Alex (minor clipping).
→ Textures bake into the GLB. No separate file needed. → Textures bake into the GLB. No separate file needed.
``` ```

View File

@@ -9,7 +9,6 @@ import net.minecraft.world.level.material.MapColor;
/** /**
* Cell Door Block - Iron-like door that cannot be opened by hand. * Cell Door Block - Iron-like door that cannot be opened by hand.
* *
* Phase 16: Blocks
* *
* Features: * Features:
* - Cannot be opened by clicking (requires redstone) * - Cannot be opened by clicking (requires redstone)

View File

@@ -37,7 +37,6 @@ import net.minecraft.world.phys.BlockHitResult;
/** /**
* Kidnap Bomb Block - TNT that applies bondage on explosion. * Kidnap Bomb Block - TNT that applies bondage on explosion.
* *
* Phase 16: Blocks
* *
* Features: * Features:
* - TNT-like block that can be ignited * - TNT-like block that can be ignited
@@ -61,9 +60,7 @@ public class BlockKidnapBomb
); );
} }
// ========================================
// BLOCK ENTITY // BLOCK ENTITY
// ========================================
@Nullable @Nullable
@Override @Override
@@ -82,9 +79,7 @@ public class BlockKidnapBomb
: null; : null;
} }
// ========================================
// EXPLOSION HANDLING // EXPLOSION HANDLING
// ========================================
@Override @Override
public void onCaughtFire( public void onCaughtFire(
@@ -139,9 +134,7 @@ public class BlockKidnapBomb
} }
} }
// ========================================
// LOADING ITEMS // LOADING ITEMS
// ========================================
@Override @Override
public InteractionResult use( public InteractionResult use(
@@ -199,9 +192,7 @@ public class BlockKidnapBomb
return InteractionResult.PASS; return InteractionResult.PASS;
} }
// ========================================
// DROPS WITH NBT // DROPS WITH NBT
// ========================================
@Override @Override
public List<ItemStack> getDrops( public List<ItemStack> getDrops(
@@ -225,9 +216,7 @@ public class BlockKidnapBomb
return List.of(stack); return List.of(stack);
} }
// ========================================
// TOOLTIP // TOOLTIP
// ========================================
@Override @Override
public void appendHoverText( public void appendHoverText(
@@ -255,7 +244,7 @@ public class BlockKidnapBomb
beTag.contains("collar") beTag.contains("collar")
) { ) {
tooltip.add( tooltip.add(
Component.literal("Loaded:").withStyle( Component.translatable("block.tiedup.kidnap_bomb.loaded").withStyle(
ChatFormatting.YELLOW ChatFormatting.YELLOW
) )
); );
@@ -293,12 +282,12 @@ public class BlockKidnapBomb
); );
} else { } else {
tooltip.add( tooltip.add(
Component.literal("Empty").withStyle(ChatFormatting.GREEN) Component.translatable("block.tiedup.kidnap_bomb.empty").withStyle(ChatFormatting.GREEN)
); );
} }
} else { } else {
tooltip.add( tooltip.add(
Component.literal("Empty").withStyle(ChatFormatting.GREEN) Component.translatable("block.tiedup.kidnap_bomb.empty").withStyle(ChatFormatting.GREEN)
); );
} }
} }

View File

@@ -43,7 +43,6 @@ import net.minecraft.world.phys.shapes.VoxelShape;
/** /**
* Rope Trap Block - Trap that ties up entities when they walk on it. * Rope Trap Block - Trap that ties up entities when they walk on it.
* *
* Phase 16: Blocks
* *
* Features: * Features:
* - Flat block (1 pixel tall) placed on solid surfaces * - Flat block (1 pixel tall) placed on solid surfaces
@@ -76,9 +75,7 @@ public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
); );
} }
// ========================================
// SHAPE AND RENDERING // SHAPE AND RENDERING
// ========================================
@Override @Override
public VoxelShape getShape( public VoxelShape getShape(
@@ -105,9 +102,7 @@ public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
return RenderShape.MODEL; return RenderShape.MODEL;
} }
// ========================================
// PLACEMENT RULES // PLACEMENT RULES
// ========================================
@Override @Override
public boolean canSurvive( public boolean canSurvive(
@@ -159,9 +154,7 @@ public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
); );
} }
// ========================================
// BLOCK ENTITY // BLOCK ENTITY
// ========================================
@Nullable @Nullable
@Override @Override
@@ -175,9 +168,7 @@ public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
return be instanceof TrapBlockEntity ? (TrapBlockEntity) be : null; return be instanceof TrapBlockEntity ? (TrapBlockEntity) be : null;
} }
// ========================================
// TRAP TRIGGER // TRAP TRIGGER
// ========================================
@Override @Override
public void entityInside( public void entityInside(
@@ -239,9 +230,7 @@ public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
); );
} }
// ========================================
// LOADING ITEMS // LOADING ITEMS
// ========================================
@Override @Override
public InteractionResult use( public InteractionResult use(
@@ -293,9 +282,7 @@ public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
return InteractionResult.PASS; return InteractionResult.PASS;
} }
// ========================================
// DROPS WITH NBT // DROPS WITH NBT
// ========================================
@Override @Override
public List<ItemStack> getDrops( public List<ItemStack> getDrops(
@@ -319,9 +306,7 @@ public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
return List.of(stack); return List.of(stack);
} }
// ========================================
// TOOLTIP // TOOLTIP
// ========================================
@Override @Override
public void appendHoverText( public void appendHoverText(
@@ -343,7 +328,7 @@ public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
// Check if armed // Check if armed
if (beTag.contains("bind")) { if (beTag.contains("bind")) {
tooltip.add( tooltip.add(
Component.literal("Armed").withStyle( Component.translatable("block.tiedup.trap.armed").withStyle(
ChatFormatting.DARK_RED ChatFormatting.DARK_RED
) )
); );
@@ -381,14 +366,14 @@ public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
); );
} else { } else {
tooltip.add( tooltip.add(
Component.literal("Disarmed").withStyle( Component.translatable("block.tiedup.trap.disarmed").withStyle(
ChatFormatting.GREEN ChatFormatting.GREEN
) )
); );
} }
} else { } else {
tooltip.add( tooltip.add(
Component.literal("Disarmed").withStyle(ChatFormatting.GREEN) Component.translatable("block.tiedup.trap.disarmed").withStyle(ChatFormatting.GREEN)
); );
} }
} }

View File

@@ -33,7 +33,6 @@ import net.minecraft.world.phys.BlockHitResult;
/** /**
* Trapped Chest Block - Chest that traps players when opened. * Trapped Chest Block - Chest that traps players when opened.
* *
* Phase 16: Blocks
* *
* Extends vanilla ChestBlock for proper chest behavior. * Extends vanilla ChestBlock for proper chest behavior.
* Sneak + right-click to load bondage items. * Sneak + right-click to load bondage items.
@@ -47,9 +46,7 @@ public class BlockTrappedChest extends ChestBlock implements ICanBeLoaded {
); );
} }
// ========================================
// BLOCK ENTITY // BLOCK ENTITY
// ========================================
@Override @Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
@@ -67,9 +64,7 @@ public class BlockTrappedChest extends ChestBlock implements ICanBeLoaded {
: null; : null;
} }
// ========================================
// INTERACTION - TRAP TRIGGER // INTERACTION - TRAP TRIGGER
// ========================================
@Override @Override
public InteractionResult use( public InteractionResult use(
@@ -151,9 +146,7 @@ public class BlockTrappedChest extends ChestBlock implements ICanBeLoaded {
return super.use(state, level, pos, player, hand, hit); return super.use(state, level, pos, player, hand, hit);
} }
// ========================================
// DROPS WITH NBT // DROPS WITH NBT
// ========================================
@Override @Override
public List<ItemStack> getDrops( public List<ItemStack> getDrops(
@@ -180,9 +173,7 @@ public class BlockTrappedChest extends ChestBlock implements ICanBeLoaded {
return drops; return drops;
} }
// ========================================
// TOOLTIP // TOOLTIP
// ========================================
@Override @Override
public void appendHoverText( public void appendHoverText(
@@ -209,7 +200,7 @@ public class BlockTrappedChest extends ChestBlock implements ICanBeLoaded {
beTag.contains("collar") beTag.contains("collar")
) { ) {
tooltip.add( tooltip.add(
Component.literal("Armed").withStyle( Component.translatable("block.tiedup.trap.armed").withStyle(
ChatFormatting.DARK_RED ChatFormatting.DARK_RED
) )
); );
@@ -240,14 +231,14 @@ public class BlockTrappedChest extends ChestBlock implements ICanBeLoaded {
); );
} else { } else {
tooltip.add( tooltip.add(
Component.literal("Disarmed").withStyle( Component.translatable("block.tiedup.trap.disarmed").withStyle(
ChatFormatting.GREEN ChatFormatting.GREEN
) )
); );
} }
} else { } else {
tooltip.add( tooltip.add(
Component.literal("Disarmed").withStyle(ChatFormatting.GREEN) Component.translatable("block.tiedup.trap.disarmed").withStyle(ChatFormatting.GREEN)
); );
} }
} }

View File

@@ -3,7 +3,6 @@ package com.tiedup.remake.blocks;
/** /**
* Marker interface for blocks that can have bondage items loaded into them. * Marker interface for blocks that can have bondage items loaded into them.
* *
* Phase 16: Blocks
* *
* Implemented by: * Implemented by:
* - BlockRopesTrap - applies items when entity walks on it * - BlockRopesTrap - applies items when entity walks on it

View File

@@ -19,7 +19,6 @@ import net.minecraftforge.registries.RegistryObject;
/** /**
* Mod Blocks Registration * Mod Blocks Registration
* *
* Phase 16: Blocks
* *
* Handles registration of all TiedUp blocks using DeferredRegister. * Handles registration of all TiedUp blocks using DeferredRegister.
* *
@@ -40,9 +39,7 @@ public class ModBlocks {
public static final DeferredRegister<Item> BLOCK_ITEMS = public static final DeferredRegister<Item> BLOCK_ITEMS =
DeferredRegister.create(ForgeRegistries.ITEMS, TiedUpMod.MOD_ID); DeferredRegister.create(ForgeRegistries.ITEMS, TiedUpMod.MOD_ID);
// ========================================
// PADDED BLOCKS // PADDED BLOCKS
// ========================================
/** /**
* Base padded block properties. * Base padded block properties.
@@ -83,9 +80,7 @@ public class ModBlocks {
) )
); );
// ========================================
// TRAP BLOCKS // TRAP BLOCKS
// ========================================
/** /**
* Rope Trap - Flat trap that ties up entities that walk on it. * Rope Trap - Flat trap that ties up entities that walk on it.
@@ -114,9 +109,7 @@ public class ModBlocks {
BlockTrappedChest::new BlockTrappedChest::new
); );
// ========================================
// DOOR BLOCKS // DOOR BLOCKS
// ========================================
/** /**
* Cell Door - Iron-like door that requires redstone to open. * Cell Door - Iron-like door that requires redstone to open.
@@ -125,9 +118,7 @@ public class ModBlocks {
public static final RegistryObject<BlockCellDoor> CELL_DOOR = public static final RegistryObject<BlockCellDoor> CELL_DOOR =
registerDoorBlock("cell_door", BlockCellDoor::new); registerDoorBlock("cell_door", BlockCellDoor::new);
// ========================================
// CELL SYSTEM BLOCKS // CELL SYSTEM BLOCKS
// ========================================
/** /**
* Marker Block - Invisible block for cell spawn points. * Marker Block - Invisible block for cell spawn points.
@@ -154,9 +145,7 @@ public class ModBlocks {
BlockCellCore::new BlockCellCore::new
); );
// ========================================
// REGISTRATION HELPERS // REGISTRATION HELPERS
// ========================================
/** /**
* Register a block and its corresponding BlockItem. * Register a block and its corresponding BlockItem.

View File

@@ -1,11 +1,14 @@
package com.tiedup.remake.blocks.entity; package com.tiedup.remake.blocks.entity;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.items.base.ItemBlindfold;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.items.base.ItemEarplugs;
import com.tiedup.remake.items.base.ItemGag;
import com.tiedup.remake.items.clothes.GenericClothes; import com.tiedup.remake.items.clothes.GenericClothes;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.BindModeHelper;
import com.tiedup.remake.v2.bondage.CollarHelper;
import com.tiedup.remake.v2.bondage.component.ComponentType;
import com.tiedup.remake.v2.bondage.component.GaggingComponent;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
@@ -20,7 +23,6 @@ import net.minecraft.world.level.block.state.BlockState;
/** /**
* Base BlockEntity for blocks that store bondage items. * Base BlockEntity for blocks that store bondage items.
* *
* Phase 16: Blocks
* *
* Stores up to 6 bondage items: * Stores up to 6 bondage items:
* - Bind (ropes, chains, straitjacket, etc.) * - Bind (ropes, chains, straitjacket, etc.)
@@ -42,9 +44,7 @@ public abstract class BondageItemBlockEntity
implements IBondageItemHolder implements IBondageItemHolder
{ {
// ========================================
// STORED ITEMS // STORED ITEMS
// ========================================
private ItemStack bind = ItemStack.EMPTY; private ItemStack bind = ItemStack.EMPTY;
private ItemStack gag = ItemStack.EMPTY; private ItemStack gag = ItemStack.EMPTY;
@@ -59,9 +59,7 @@ public abstract class BondageItemBlockEntity
*/ */
private final boolean offMode; private final boolean offMode;
// ========================================
// CONSTRUCTORS // CONSTRUCTORS
// ========================================
public BondageItemBlockEntity( public BondageItemBlockEntity(
BlockEntityType<?> type, BlockEntityType<?> type,
@@ -81,9 +79,7 @@ public abstract class BondageItemBlockEntity
this.offMode = offMode; this.offMode = offMode;
} }
// ========================================
// BIND // BIND
// ========================================
@Override @Override
public ItemStack getBind() { public ItemStack getBind() {
@@ -96,9 +92,7 @@ public abstract class BondageItemBlockEntity
this.setChangedAndSync(); this.setChangedAndSync();
} }
// ========================================
// GAG // GAG
// ========================================
@Override @Override
public ItemStack getGag() { public ItemStack getGag() {
@@ -111,9 +105,7 @@ public abstract class BondageItemBlockEntity
this.setChangedAndSync(); this.setChangedAndSync();
} }
// ========================================
// BLINDFOLD // BLINDFOLD
// ========================================
@Override @Override
public ItemStack getBlindfold() { public ItemStack getBlindfold() {
@@ -126,9 +118,7 @@ public abstract class BondageItemBlockEntity
this.setChangedAndSync(); this.setChangedAndSync();
} }
// ========================================
// EARPLUGS // EARPLUGS
// ========================================
@Override @Override
public ItemStack getEarplugs() { public ItemStack getEarplugs() {
@@ -141,9 +131,7 @@ public abstract class BondageItemBlockEntity
this.setChangedAndSync(); this.setChangedAndSync();
} }
// ========================================
// COLLAR // COLLAR
// ========================================
@Override @Override
public ItemStack getCollar() { public ItemStack getCollar() {
@@ -156,9 +144,7 @@ public abstract class BondageItemBlockEntity
this.setChangedAndSync(); this.setChangedAndSync();
} }
// ========================================
// CLOTHES // CLOTHES
// ========================================
@Override @Override
public ItemStack getClothes() { public ItemStack getClothes() {
@@ -171,9 +157,7 @@ public abstract class BondageItemBlockEntity
this.setChangedAndSync(); this.setChangedAndSync();
} }
// ========================================
// STATE // STATE
// ========================================
@Override @Override
public boolean isArmed() { public boolean isArmed() {
@@ -194,9 +178,7 @@ public abstract class BondageItemBlockEntity
this.setChangedAndSync(); this.setChangedAndSync();
} }
// ========================================
// NBT SERIALIZATION // NBT SERIALIZATION
// ========================================
@Override @Override
public void load(CompoundTag tag) { public void load(CompoundTag tag) {
@@ -212,55 +194,43 @@ public abstract class BondageItemBlockEntity
@Override @Override
public void readBondageData(CompoundTag tag) { public void readBondageData(CompoundTag tag) {
// Read bind with type validation // Read bind with type validation (V2 ARMS-region item)
if (tag.contains("bind")) { if (tag.contains("bind")) {
ItemStack bindStack = ItemStack.of(tag.getCompound("bind")); ItemStack bindStack = ItemStack.of(tag.getCompound("bind"));
if ( if (!bindStack.isEmpty() && BindModeHelper.isBindItem(bindStack)) {
!bindStack.isEmpty() && bindStack.getItem() instanceof ItemBind
) {
this.bind = bindStack; this.bind = bindStack;
} }
} }
// Read gag with type validation // Read gag with type validation (V2 GAGGING component)
if (tag.contains("gag")) { if (tag.contains("gag")) {
ItemStack gagStack = ItemStack.of(tag.getCompound("gag")); ItemStack gagStack = ItemStack.of(tag.getCompound("gag"));
if (!gagStack.isEmpty() && gagStack.getItem() instanceof ItemGag) { if (!gagStack.isEmpty()
&& DataDrivenBondageItem.getComponent(gagStack, ComponentType.GAGGING, GaggingComponent.class) != null) {
this.gag = gagStack; this.gag = gagStack;
} }
} }
// Read blindfold with type validation // Read blindfold with type validation (V2 EYES-region item)
if (tag.contains("blindfold")) { if (tag.contains("blindfold")) {
ItemStack blindfoldStack = ItemStack.of( ItemStack blindfoldStack = ItemStack.of(tag.getCompound("blindfold"));
tag.getCompound("blindfold") if (!blindfoldStack.isEmpty() && isDataDrivenForRegion(blindfoldStack, BodyRegionV2.EYES)) {
);
if (
!blindfoldStack.isEmpty() &&
blindfoldStack.getItem() instanceof ItemBlindfold
) {
this.blindfold = blindfoldStack; this.blindfold = blindfoldStack;
} }
} }
// Read earplugs with type validation // Read earplugs with type validation (V2 EARS-region item)
if (tag.contains("earplugs")) { if (tag.contains("earplugs")) {
ItemStack earplugsStack = ItemStack.of(tag.getCompound("earplugs")); ItemStack earplugsStack = ItemStack.of(tag.getCompound("earplugs"));
if ( if (!earplugsStack.isEmpty() && isDataDrivenForRegion(earplugsStack, BodyRegionV2.EARS)) {
!earplugsStack.isEmpty() &&
earplugsStack.getItem() instanceof ItemEarplugs
) {
this.earplugs = earplugsStack; this.earplugs = earplugsStack;
} }
} }
// Read collar with type validation // Read collar with type validation (V2 collar)
if (tag.contains("collar")) { if (tag.contains("collar")) {
ItemStack collarStack = ItemStack.of(tag.getCompound("collar")); ItemStack collarStack = ItemStack.of(tag.getCompound("collar"));
if ( if (!collarStack.isEmpty() && CollarHelper.isCollar(collarStack)) {
!collarStack.isEmpty() &&
collarStack.getItem() instanceof ItemCollar
) {
this.collar = collarStack; this.collar = collarStack;
} }
} }
@@ -300,9 +270,15 @@ public abstract class BondageItemBlockEntity
return tag; return tag;
} }
// ======================================== // V2 HELPERS
/** Check if a stack is a data-driven item occupying the given body region. */
private static boolean isDataDrivenForRegion(ItemStack stack, BodyRegionV2 region) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
return def != null && def.occupiedRegions().contains(region);
}
// NETWORK SYNC // NETWORK SYNC
// ========================================
/** /**
* Mark dirty and sync to clients. * Mark dirty and sync to clients.

View File

@@ -6,7 +6,6 @@ import net.minecraft.world.item.ItemStack;
/** /**
* Interface for BlockEntities that store bondage items. * Interface for BlockEntities that store bondage items.
* *
* Phase 16: Blocks
* *
* Defines the contract for storing and retrieving bondage items: * Defines the contract for storing and retrieving bondage items:
* - Bind (ropes, chains, etc.) * - Bind (ropes, chains, etc.)
@@ -19,51 +18,37 @@ import net.minecraft.world.item.ItemStack;
* Based on original ITileEntityBondageItemHolder from 1.12.2 * Based on original ITileEntityBondageItemHolder from 1.12.2
*/ */
public interface IBondageItemHolder { public interface IBondageItemHolder {
// ========================================
// BIND // BIND
// ========================================
ItemStack getBind(); ItemStack getBind();
void setBind(ItemStack bind); void setBind(ItemStack bind);
// ========================================
// GAG // GAG
// ========================================
ItemStack getGag(); ItemStack getGag();
void setGag(ItemStack gag); void setGag(ItemStack gag);
// ========================================
// BLINDFOLD // BLINDFOLD
// ========================================
ItemStack getBlindfold(); ItemStack getBlindfold();
void setBlindfold(ItemStack blindfold); void setBlindfold(ItemStack blindfold);
// ========================================
// EARPLUGS // EARPLUGS
// ========================================
ItemStack getEarplugs(); ItemStack getEarplugs();
void setEarplugs(ItemStack earplugs); void setEarplugs(ItemStack earplugs);
// ========================================
// COLLAR // COLLAR
// ========================================
ItemStack getCollar(); ItemStack getCollar();
void setCollar(ItemStack collar); void setCollar(ItemStack collar);
// ========================================
// CLOTHES // CLOTHES
// ========================================
ItemStack getClothes(); ItemStack getClothes();
void setClothes(ItemStack clothes); void setClothes(ItemStack clothes);
// ========================================
// NBT SERIALIZATION // NBT SERIALIZATION
// ========================================
/** /**
* Read bondage items from NBT. * Read bondage items from NBT.
@@ -78,9 +63,7 @@ public interface IBondageItemHolder {
*/ */
CompoundTag writeBondageData(CompoundTag tag); CompoundTag writeBondageData(CompoundTag tag);
// ========================================
// STATE // STATE
// ========================================
/** /**
* Check if this holder has any bondage items loaded. * Check if this holder has any bondage items loaded.

View File

@@ -6,7 +6,6 @@ import net.minecraft.world.level.block.state.BlockState;
/** /**
* BlockEntity for kidnap bomb blocks. * BlockEntity for kidnap bomb blocks.
* *
* Phase 16: Blocks
* *
* Stores bondage items that will be applied when the bomb explodes. * Stores bondage items that will be applied when the bomb explodes.
* Simple extension of BondageItemBlockEntity. * Simple extension of BondageItemBlockEntity.

View File

@@ -10,7 +10,6 @@ import net.minecraftforge.registries.RegistryObject;
/** /**
* Mod Block Entities Registration * Mod Block Entities Registration
* *
* Phase 16: Blocks
* *
* Handles registration of all TiedUp block entities using DeferredRegister. * Handles registration of all TiedUp block entities using DeferredRegister.
*/ */
@@ -23,9 +22,7 @@ public class ModBlockEntities {
TiedUpMod.MOD_ID TiedUpMod.MOD_ID
); );
// ========================================
// TRAP BLOCK ENTITIES // TRAP BLOCK ENTITIES
// ========================================
/** /**
* Trap block entity - stores bondage items for rope trap. * Trap block entity - stores bondage items for rope trap.
@@ -40,9 +37,7 @@ public class ModBlockEntities {
// LOW FIX: Removed BED BLOCK ENTITIES section - feature not implemented // LOW FIX: Removed BED BLOCK ENTITIES section - feature not implemented
// ========================================
// BOMB BLOCK ENTITIES // BOMB BLOCK ENTITIES
// ========================================
/** /**
* Kidnap bomb block entity - stores bondage items for explosion effect. * Kidnap bomb block entity - stores bondage items for explosion effect.
@@ -56,9 +51,7 @@ public class ModBlockEntities {
).build(null) ).build(null)
); );
// ========================================
// CHEST BLOCK ENTITIES // CHEST BLOCK ENTITIES
// ========================================
/** /**
* Trapped chest block entity - stores bondage items for when player opens it. * Trapped chest block entity - stores bondage items for when player opens it.
@@ -72,9 +65,7 @@ public class ModBlockEntities {
).build(null) ).build(null)
); );
// ========================================
// CELL SYSTEM BLOCK ENTITIES // CELL SYSTEM BLOCK ENTITIES
// ========================================
/** /**
* Marker block entity - stores cell UUID for cell system. * Marker block entity - stores cell UUID for cell system.

View File

@@ -6,7 +6,6 @@ import net.minecraft.world.level.block.state.BlockState;
/** /**
* BlockEntity for rope trap blocks. * BlockEntity for rope trap blocks.
* *
* Phase 16: Blocks
* *
* Stores bondage items that will be applied when an entity walks on the trap. * Stores bondage items that will be applied when an entity walks on the trap.
* Simple extension of BondageItemBlockEntity. * Simple extension of BondageItemBlockEntity.

View File

@@ -1,7 +1,14 @@
package com.tiedup.remake.blocks.entity; package com.tiedup.remake.blocks.entity;
import com.tiedup.remake.items.base.*;
import com.tiedup.remake.items.clothes.GenericClothes; import com.tiedup.remake.items.clothes.GenericClothes;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.BindModeHelper;
import com.tiedup.remake.v2.bondage.CollarHelper;
import com.tiedup.remake.v2.bondage.component.ComponentType;
import com.tiedup.remake.v2.bondage.component.GaggingComponent;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
@@ -15,7 +22,6 @@ import net.minecraft.world.level.block.state.BlockState;
/** /**
* BlockEntity for trapped chest blocks. * BlockEntity for trapped chest blocks.
* *
* Phase 16: Blocks
* *
* Extends ChestBlockEntity for proper chest behavior, * Extends ChestBlockEntity for proper chest behavior,
* but also stores bondage items for the trap. * but also stores bondage items for the trap.
@@ -37,9 +43,7 @@ public class TrappedChestBlockEntity
super(ModBlockEntities.TRAPPED_CHEST.get(), pos, state); super(ModBlockEntities.TRAPPED_CHEST.get(), pos, state);
} }
// ========================================
// BONDAGE ITEM HOLDER IMPLEMENTATION // BONDAGE ITEM HOLDER IMPLEMENTATION
// ========================================
@Override @Override
public ItemStack getBind() { public ItemStack getBind() {
@@ -48,7 +52,7 @@ public class TrappedChestBlockEntity
@Override @Override
public void setBind(ItemStack stack) { public void setBind(ItemStack stack) {
if (stack.isEmpty() || stack.getItem() instanceof ItemBind) { if (stack.isEmpty() || BindModeHelper.isBindItem(stack)) {
this.bind = stack; this.bind = stack;
setChangedAndSync(); setChangedAndSync();
} }
@@ -61,7 +65,8 @@ public class TrappedChestBlockEntity
@Override @Override
public void setGag(ItemStack stack) { public void setGag(ItemStack stack) {
if (stack.isEmpty() || stack.getItem() instanceof ItemGag) { if (stack.isEmpty()
|| DataDrivenBondageItem.getComponent(stack, ComponentType.GAGGING, GaggingComponent.class) != null) {
this.gag = stack; this.gag = stack;
setChangedAndSync(); setChangedAndSync();
} }
@@ -74,7 +79,7 @@ public class TrappedChestBlockEntity
@Override @Override
public void setBlindfold(ItemStack stack) { public void setBlindfold(ItemStack stack) {
if (stack.isEmpty() || stack.getItem() instanceof ItemBlindfold) { if (stack.isEmpty() || isDataDrivenForRegion(stack, BodyRegionV2.EYES)) {
this.blindfold = stack; this.blindfold = stack;
setChangedAndSync(); setChangedAndSync();
} }
@@ -87,7 +92,7 @@ public class TrappedChestBlockEntity
@Override @Override
public void setEarplugs(ItemStack stack) { public void setEarplugs(ItemStack stack) {
if (stack.isEmpty() || stack.getItem() instanceof ItemEarplugs) { if (stack.isEmpty() || isDataDrivenForRegion(stack, BodyRegionV2.EARS)) {
this.earplugs = stack; this.earplugs = stack;
setChangedAndSync(); setChangedAndSync();
} }
@@ -100,7 +105,7 @@ public class TrappedChestBlockEntity
@Override @Override
public void setCollar(ItemStack stack) { public void setCollar(ItemStack stack) {
if (stack.isEmpty() || stack.getItem() instanceof ItemCollar) { if (stack.isEmpty() || CollarHelper.isCollar(stack)) {
this.collar = stack; this.collar = stack;
setChangedAndSync(); setChangedAndSync();
} }
@@ -172,9 +177,7 @@ public class TrappedChestBlockEntity
return tag; return tag;
} }
// ========================================
// NBT SERIALIZATION // NBT SERIALIZATION
// ========================================
@Override @Override
public void load(CompoundTag tag) { public void load(CompoundTag tag) {
@@ -188,9 +191,15 @@ public class TrappedChestBlockEntity
writeBondageData(tag); writeBondageData(tag);
} }
// ======================================== // V2 HELPERS
/** Check if a stack is a data-driven item occupying the given body region. */
private static boolean isDataDrivenForRegion(ItemStack stack, BodyRegionV2 region) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
return def != null && def.occupiedRegions().contains(region);
}
// NETWORK SYNC // NETWORK SYNC
// ========================================
/** /**
* Mark dirty and sync to clients. * Mark dirty and sync to clients.

View File

@@ -7,7 +7,6 @@ import net.minecraft.world.item.ItemStack;
/** /**
* Represents a single bounty placed on a player. * Represents a single bounty placed on a player.
* *
* Phase 17: Bounty System
* *
* A bounty is created when a player (client) offers a reward for capturing * A bounty is created when a player (client) offers a reward for capturing
* another player (target). Anyone who delivers the target to the client * another player (target). Anyone who delivers the target to the client

View File

@@ -1,8 +1,8 @@
package com.tiedup.remake.bounty; package com.tiedup.remake.bounty;
import com.tiedup.remake.core.SettingsAccessor;
import com.tiedup.remake.core.SystemMessageManager; import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.core.SettingsAccessor;
import java.util.*; import java.util.*;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import net.minecraft.ChatFormatting; import net.minecraft.ChatFormatting;
@@ -20,7 +20,6 @@ import net.minecraft.world.level.saveddata.SavedData;
/** /**
* World-saved data manager for bounties. * World-saved data manager for bounties.
* *
* Phase 17: Bounty System
* *
* Manages all active bounties, handles expiration, delivery rewards, * Manages all active bounties, handles expiration, delivery rewards,
* and stores bounties for offline players. * and stores bounties for offline players.
@@ -383,9 +382,8 @@ public class BountyManager extends SavedData {
server server
.getPlayerList() .getPlayerList()
.broadcastSystemMessage( .broadcastSystemMessage(
Component.literal("[Bounty] " + message).withStyle( Component.translatable("msg.tiedup.bounty.broadcast", message)
ChatFormatting.GOLD .withStyle(ChatFormatting.GOLD),
),
false false
); );
} }

View File

@@ -2,8 +2,8 @@ package com.tiedup.remake.cells;
import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.base.ILockable; import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.prison.PrisonerManager; import com.tiedup.remake.prison.PrisonerManager;
import com.tiedup.remake.v2.bondage.CollarHelper;
import com.tiedup.remake.state.IRestrainable; import com.tiedup.remake.state.IRestrainable;
import com.tiedup.remake.util.KidnappedHelper; import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.v2.BodyRegionV2; import com.tiedup.remake.v2.BodyRegionV2;
@@ -94,7 +94,7 @@ public final class CampLifecycleManager {
); );
} else { } else {
// Offline: full escape via PrisonerService (no grace period needed) // Offline: full escape via PrisonerService (no grace period needed)
com.tiedup.remake.prison.service.PrisonerService.get().escape( com.tiedup.remake.prison.service.EscapeMonitorService.get().escape(
level, level,
prisonerId, prisonerId,
"camp death" "camp death"
@@ -231,7 +231,7 @@ public final class CampLifecycleManager {
} }
// Suppress collar removal alerts - this is a legitimate release (camp death) // Suppress collar removal alerts - this is a legitimate release (camp death)
ItemCollar.runWithSuppressedAlert(() -> { CollarHelper.runWithSuppressedAlert(() -> {
// Unlock collar if owned by the dead camp/trader // Unlock collar if owned by the dead camp/trader
unlockCollarIfOwnedBy(prisoner, state, traderUUID); unlockCollarIfOwnedBy(prisoner, state, traderUUID);
@@ -250,10 +250,8 @@ public final class CampLifecycleManager {
// Notify prisoner // Notify prisoner
prisoner.sendSystemMessage( prisoner.sendSystemMessage(
Component.literal("Your captor has died. You are FREE!").withStyle( Component.translatable("msg.tiedup.camp.captor_died")
ChatFormatting.GREEN, .withStyle(ChatFormatting.GREEN, ChatFormatting.BOLD)
ChatFormatting.BOLD
)
); );
// Grant grace period (5 minutes = 6000 ticks) // Grant grace period (5 minutes = 6000 ticks)
@@ -261,9 +259,8 @@ public final class CampLifecycleManager {
manager.release(prisoner.getUUID(), level.getGameTime(), 6000); manager.release(prisoner.getUUID(), level.getGameTime(), 6000);
prisoner.sendSystemMessage( prisoner.sendSystemMessage(
Component.literal( Component.translatable("msg.tiedup.camp.grace_period")
"You have 5 minutes of protection from kidnappers." .withStyle(ChatFormatting.AQUA)
).withStyle(ChatFormatting.AQUA)
); );
TiedUpMod.LOGGER.info( TiedUpMod.LOGGER.info(
@@ -285,8 +282,8 @@ public final class CampLifecycleManager {
return; return;
} }
if (collar.getItem() instanceof ItemCollar collarItem) { if (CollarHelper.isCollar(collar)) {
List<UUID> owners = collarItem.getOwners(collar); List<UUID> owners = CollarHelper.getOwners(collar);
// If the dead trader/camp is an owner, unlock the collar // If the dead trader/camp is an owner, unlock the collar
if (owners.contains(ownerUUID)) { if (owners.contains(ownerUUID)) {

View File

@@ -26,7 +26,11 @@ public final class CampMaidManager {
* @param currentTime The current game time * @param currentTime The current game time
* @param level The server level * @param level The server level
*/ */
public static void markMaidDead(UUID campId, long currentTime, ServerLevel level) { public static void markMaidDead(
UUID campId,
long currentTime,
ServerLevel level
) {
CampOwnership ownership = CampOwnership.get(level); CampOwnership ownership = CampOwnership.get(level);
CampOwnership.CampData data = ownership.getCamp(campId); CampOwnership.CampData data = ownership.getCamp(campId);
if (data == null || !data.isAlive()) { if (data == null || !data.isAlive()) {
@@ -93,7 +97,10 @@ public final class CampMaidManager {
* @param level The server level * @param level The server level
* @return List of camp IDs ready for maid respawn * @return List of camp IDs ready for maid respawn
*/ */
public static List<UUID> getCampsNeedingMaidRespawn(long currentTime, ServerLevel level) { public static List<UUID> getCampsNeedingMaidRespawn(
long currentTime,
ServerLevel level
) {
CampOwnership ownership = CampOwnership.get(level); CampOwnership ownership = CampOwnership.get(level);
List<UUID> result = new ArrayList<>(); List<UUID> result = new ArrayList<>();
for (CampOwnership.CampData data : ownership.getAllCamps()) { for (CampOwnership.CampData data : ownership.getAllCamps()) {

View File

@@ -572,5 +572,4 @@ public class CampOwnership extends SavedData {
return registry; return registry;
} }
} }

View File

@@ -640,7 +640,7 @@ public class CellRegistryV2 extends SavedData {
currentState == currentState ==
com.tiedup.remake.prison.PrisonerState.IMPRISONED com.tiedup.remake.prison.PrisonerState.IMPRISONED
) { ) {
com.tiedup.remake.prison.service.PrisonerService.get().escape( com.tiedup.remake.prison.service.EscapeMonitorService.get().escape(
level, level,
id, id,
"offline_cleanup" "offline_cleanup"
@@ -670,13 +670,7 @@ public class CellRegistryV2 extends SavedData {
if (server == null || ownerId == null) return; if (server == null || ownerId == null) return;
ServerPlayer owner = server.getPlayerList().getPlayer(ownerId); ServerPlayer owner = server.getPlayerList().getPlayer(ownerId);
if (owner != null) { if (owner != null) {
String template = SystemMessageManager.getTemplate(category); SystemMessageManager.sendTranslatable(owner, category, prisonerName);
String formattedMessage = String.format(template, prisonerName);
SystemMessageManager.sendToPlayer(
owner,
category,
formattedMessage
);
} }
} }

View File

@@ -22,7 +22,6 @@ import net.minecraft.world.level.saveddata.SavedData;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
/** /**
* Phase 2: SavedData registry for confiscated player inventories.
* *
* When a player is imprisoned: * When a player is imprisoned:
* 1. Their inventory is saved to NBT * 1. Their inventory is saved to NBT

View File

@@ -3,8 +3,8 @@ package com.tiedup.remake.client;
import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer; import com.mojang.blaze3d.vertex.VertexConsumer;
import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.GenericBind; import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.items.base.BindVariant; import com.tiedup.remake.v2.bondage.PoseTypeHelper;
import com.tiedup.remake.state.PlayerBindState; import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2; import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
@@ -139,17 +139,9 @@ public class FirstPersonMittensRenderer {
*/ */
private static boolean isBindHidingMittens(AbstractClientPlayer player) { private static boolean isBindHidingMittens(AbstractClientPlayer player) {
net.minecraft.world.item.ItemStack bindStack = net.minecraft.world.item.ItemStack bindStack =
V2EquipmentHelper.getInRegion( V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS);
player,
BodyRegionV2.ARMS
);
if (bindStack.isEmpty()) return false; if (bindStack.isEmpty()) return false;
if (bindStack.getItem() instanceof GenericBind bind) { PoseType poseType = PoseTypeHelper.getPoseType(bindStack);
BindVariant variant = bind.getVariant(); return poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK;
return (
variant == BindVariant.WRAP || variant == BindVariant.LATEX_SACK
);
}
return false;
} }
} }

View File

@@ -1,22 +1,21 @@
package com.tiedup.remake.client; package com.tiedup.remake.client;
import com.mojang.blaze3d.platform.InputConstants; import com.mojang.blaze3d.platform.InputConstants;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.client.gui.screens.AdjustmentScreen; import com.tiedup.remake.client.gui.screens.AdjustmentScreen;
import com.tiedup.remake.client.gui.screens.UnifiedBondageScreen; import com.tiedup.remake.client.gui.screens.UnifiedBondageScreen;
import com.tiedup.remake.items.base.ItemCollar; import com.tiedup.remake.core.SettingsAccessor;
import org.jetbrains.annotations.Nullable;
import com.tiedup.remake.core.ModConfig;
import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.base.ILockable; import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.v2.bondage.CollarHelper;
import com.tiedup.remake.network.ModNetwork; import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.action.PacketForceSeatModifier; import com.tiedup.remake.network.action.PacketForceSeatModifier;
import com.tiedup.remake.network.action.PacketStruggle; import com.tiedup.remake.network.action.PacketStruggle;
import com.tiedup.remake.network.action.PacketTighten; import com.tiedup.remake.network.action.PacketTighten;
import com.tiedup.remake.network.bounty.PacketRequestBounties; import com.tiedup.remake.network.bounty.PacketRequestBounties;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart;
import com.tiedup.remake.state.PlayerBindState; import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart;
import net.minecraft.ChatFormatting; import net.minecraft.ChatFormatting;
import net.minecraft.client.KeyMapping; import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
@@ -29,9 +28,9 @@ import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
import net.minecraftforge.event.TickEvent; import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.Mod;
import org.jetbrains.annotations.Nullable;
/** /**
* Phase 7: Client-side keybindings for TiedUp mod.
* *
* Manages key mappings and sends packets to server when keys are pressed. * Manages key mappings and sends packets to server when keys are pressed.
* *
@@ -227,7 +226,7 @@ public class ModKeybindings {
); );
} }
// Check struggle key - Phase 21: Flow based on bind/accessories // Check struggle key - Flow based on bind/accessories
while (STRUGGLE_KEY.consumeClick()) { while (STRUGGLE_KEY.consumeClick()) {
handleStruggleKey(); handleStruggleKey();
} }
@@ -284,7 +283,6 @@ public class ModKeybindings {
} }
/** /**
* Phase 21: Handle struggle key press with new flow.
* *
* Flow: * Flow:
* 1. If bind equipped: Send PacketStruggle to server (struggle against bind) * 1. If bind equipped: Send PacketStruggle to server (struggle against bind)
@@ -300,7 +298,11 @@ public class ModKeybindings {
} }
// V2 path: check if player has V2 equipment to struggle against // V2 path: check if player has V2 equipment to struggle against
if (com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.hasAnyEquipment(player)) { if (
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.hasAnyEquipment(
player
)
) {
handleV2Struggle(player); handleV2Struggle(player);
return; return;
} }
@@ -313,10 +315,11 @@ public class ModKeybindings {
// Check if player has bind equipped // Check if player has bind equipped
if (state.isTiedUp()) { if (state.isTiedUp()) {
// Has bind - struggle against it // Has bind - struggle against it
// Phase 2.5: Check if mini-game is enabled if (SettingsAccessor.isStruggleMiniGameEnabled()) {
if (ModConfig.SERVER.struggleMiniGameEnabled.get()) {
// New: Start struggle mini-game // New: Start struggle mini-game
ModNetwork.sendToServer(new PacketV2StruggleStart(BodyRegionV2.ARMS)); ModNetwork.sendToServer(
new PacketV2StruggleStart(BodyRegionV2.ARMS)
);
TiedUpMod.LOGGER.debug( TiedUpMod.LOGGER.debug(
"[CLIENT] Struggle key pressed - starting V2 struggle mini-game" "[CLIENT] Struggle key pressed - starting V2 struggle mini-game"
); );
@@ -359,7 +362,9 @@ public class ModKeybindings {
*/ */
private static void handleV2Struggle(Player player) { private static void handleV2Struggle(Player player) {
java.util.Map<com.tiedup.remake.v2.BodyRegionV2, ItemStack> equipped = java.util.Map<com.tiedup.remake.v2.BodyRegionV2, ItemStack> equipped =
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getAllEquipped(player); com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getAllEquipped(
player
);
if (equipped.isEmpty()) return; if (equipped.isEmpty()) return;
@@ -367,9 +372,15 @@ public class ModKeybindings {
com.tiedup.remake.v2.BodyRegionV2 bestRegion = null; com.tiedup.remake.v2.BodyRegionV2 bestRegion = null;
int bestPriority = Integer.MIN_VALUE; int bestPriority = Integer.MIN_VALUE;
for (java.util.Map.Entry<com.tiedup.remake.v2.BodyRegionV2, ItemStack> entry : equipped.entrySet()) { for (java.util.Map.Entry<
com.tiedup.remake.v2.BodyRegionV2,
ItemStack
> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue(); ItemStack stack = entry.getValue();
if (stack.getItem() instanceof com.tiedup.remake.v2.bondage.IV2BondageItem item) { if (
stack.getItem() instanceof
com.tiedup.remake.v2.bondage.IV2BondageItem item
) {
if (item.getPosePriority(stack) > bestPriority) { if (item.getPosePriority(stack) > bestPriority) {
bestPriority = item.getPosePriority(stack); bestPriority = item.getPosePriority(stack);
bestRegion = entry.getKey(); bestRegion = entry.getKey();
@@ -379,7 +390,9 @@ public class ModKeybindings {
if (bestRegion != null) { if (bestRegion != null) {
ModNetwork.sendToServer( ModNetwork.sendToServer(
new com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart(bestRegion) new com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart(
bestRegion
)
); );
TiedUpMod.LOGGER.debug( TiedUpMod.LOGGER.debug(
"[CLIENT] V2 Struggle key pressed - targeting region {}", "[CLIENT] V2 Struggle key pressed - targeting region {}",
@@ -406,12 +419,17 @@ public class ModKeybindings {
/** /**
* Returns true if the given entity has a collar in the NECK region that lists the player as an owner. * Returns true if the given entity has a collar in the NECK region that lists the player as an owner.
*/ */
private static boolean checkCollarOwnership(LivingEntity target, Player player) { private static boolean checkCollarOwnership(
ItemStack collarStack = com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion( LivingEntity target,
target, BodyRegionV2.NECK Player player
) {
ItemStack collarStack =
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion(
target,
BodyRegionV2.NECK
); );
if (!collarStack.isEmpty() && collarStack.getItem() instanceof ItemCollar collar) { if (!collarStack.isEmpty() && CollarHelper.isCollar(collarStack)) {
return collar.isOwner(collarStack, player); return CollarHelper.isOwner(collarStack, player);
} }
return false; return false;
} }

View File

@@ -7,31 +7,25 @@ import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn; import net.minecraftforge.api.distmarker.OnlyIn;
/** /**
* Central registry for player animation state tracking. * Client-side animation state tracking + world-unload cleanup facade.
* *
* <p>Holds per-player state maps that were previously scattered across * <p>Holds {@link #lastTiedState} (the per-player edge-detector used by
* AnimationTickHandler. Provides a single clearAll() entry point for * {@link com.tiedup.remake.client.animation.tick.AnimationTickHandler} to
* world unload cleanup. * spot the "just untied" transition) and chains cleanup via
* {@link #clearAll()} across every animation-related cache on world unload.</p>
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public final class AnimationStateRegistry { public final class AnimationStateRegistry {
/** Track last tied state per player */ /** Track last tied state per player (edge-detect on untie transition). */
static final Map<UUID, Boolean> lastTiedState = new ConcurrentHashMap<>(); static final Map<UUID, Boolean> lastTiedState = new ConcurrentHashMap<>();
/** Track last animation ID per player to avoid redundant updates */
static final Map<UUID, String> lastAnimId = new ConcurrentHashMap<>();
private AnimationStateRegistry() {} private AnimationStateRegistry() {}
public static Map<UUID, Boolean> getLastTiedState() { public static Map<UUID, Boolean> getLastTiedState() {
return lastTiedState; return lastTiedState;
} }
public static Map<UUID, String> getLastAnimId() {
return lastAnimId;
}
/** /**
* Clear all animation-related state in one call. * Clear all animation-related state in one call.
* Called on world unload to prevent memory leaks and stale data. * Called on world unload to prevent memory leaks and stale data.
@@ -39,7 +33,6 @@ public final class AnimationStateRegistry {
public static void clearAll() { public static void clearAll() {
// Animation state tracking // Animation state tracking
lastTiedState.clear(); lastTiedState.clear();
lastAnimId.clear();
// Animation managers // Animation managers
BondageAnimationManager.clearAll(); BondageAnimationManager.clearAll();
@@ -50,6 +43,9 @@ public final class AnimationStateRegistry {
// Render state // Render state
com.tiedup.remake.client.animation.render.DogPoseRenderHandler.clearState(); com.tiedup.remake.client.animation.render.DogPoseRenderHandler.clearState();
com.tiedup.remake.client.animation.render.PetBedRenderHandler.clearAll();
com.tiedup.remake.client.animation.render.HeldItemHideHandler.clearAll();
com.tiedup.remake.client.animation.render.PlayerArmHideEventHandler.clearAll();
// NPC animation state // NPC animation state
com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler.clearAll(); com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler.clearAll();

View File

@@ -48,8 +48,10 @@ public class BondageAnimationManager {
new ConcurrentHashMap<>(); new ConcurrentHashMap<>();
/** Cache of furniture ModifierLayers for NPC entities */ /** Cache of furniture ModifierLayers for NPC entities */
private static final Map<UUID, ModifierLayer<IAnimation>> npcFurnitureLayers = private static final Map<
new ConcurrentHashMap<>(); UUID,
ModifierLayer<IAnimation>
> npcFurnitureLayers = new ConcurrentHashMap<>();
/** Factory ID for PlayerAnimator item layer (players only) */ /** Factory ID for PlayerAnimator item layer (players only) */
private static final ResourceLocation FACTORY_ID = private static final ResourceLocation FACTORY_ID =
@@ -83,7 +85,8 @@ public class BondageAnimationManager {
* *
* <p>Uses ConcurrentHashMap for safe access from both client tick and render thread.</p> * <p>Uses ConcurrentHashMap for safe access from both client tick and render thread.</p>
*/ */
private static final Map<UUID, Integer> furnitureGraceTicks = new ConcurrentHashMap<>(); private static final Map<UUID, Integer> furnitureGraceTicks =
new ConcurrentHashMap<>();
/** /**
* Initialize the animation system. * Initialize the animation system.
@@ -119,13 +122,13 @@ public class BondageAnimationManager {
LOGGER.info( LOGGER.info(
"BondageAnimationManager: Factories registered — context (pri {}), item (pri {}), furniture (pri {})", "BondageAnimationManager: Factories registered — context (pri {}), item (pri {}), furniture (pri {})",
CONTEXT_LAYER_PRIORITY, ITEM_LAYER_PRIORITY, FURNITURE_LAYER_PRIORITY CONTEXT_LAYER_PRIORITY,
ITEM_LAYER_PRIORITY,
FURNITURE_LAYER_PRIORITY
); );
} }
// ========================================
// PLAY ANIMATION // PLAY ANIMATION
// ========================================
/** /**
* Play an animation on any entity (player or NPC). * Play an animation on any entity (player or NPC).
@@ -235,7 +238,10 @@ public class BondageAnimationManager {
* @param anim The KeyframeAnimation to play * @param anim The KeyframeAnimation to play
* @return true if animation started successfully * @return true if animation started successfully
*/ */
public static boolean playDirect(LivingEntity entity, KeyframeAnimation anim) { public static boolean playDirect(
LivingEntity entity,
KeyframeAnimation anim
) {
if (entity == null || anim == null || !entity.level().isClientSide()) { if (entity == null || anim == null || !entity.level().isClientSide()) {
return false; return false;
} }
@@ -255,9 +261,7 @@ public class BondageAnimationManager {
return false; return false;
} }
// ========================================
// STOP ANIMATION // STOP ANIMATION
// ========================================
/** /**
* Stop any currently playing animation on an entity. * Stop any currently playing animation on an entity.
@@ -276,9 +280,7 @@ public class BondageAnimationManager {
} }
} }
// ========================================
// LAYER MANAGEMENT // LAYER MANAGEMENT
// ========================================
/** /**
* Get the ModifierLayer for an entity (without creating). * Get the ModifierLayer for an entity (without creating).
@@ -351,8 +353,18 @@ public class BondageAnimationManager {
return null; return null;
} }
/** Per-player dedup set so we log the factory-access failure at most once per UUID. */
private static final java.util.Set<UUID> layerFailureLogged =
java.util.concurrent.ConcurrentHashMap.newKeySet();
/** /**
* Get the animation layer for a player from PlayerAnimationAccess. * Get the animation layer for a player from PlayerAnimationAccess.
*
* <p>Throws during the factory-race window for remote players (the factory
* hasn't yet initialized their associated data). This is the expected path
* 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") @SuppressWarnings("unchecked")
private static ModifierLayer<IAnimation> getPlayerLayer( private static ModifierLayer<IAnimation> getPlayerLayer(
@@ -365,11 +377,13 @@ public class BondageAnimationManager {
FACTORY_ID FACTORY_ID
); );
} catch (Exception e) { } catch (Exception e) {
LOGGER.error( if (layerFailureLogged.add(player.getUUID())) {
"Failed to get animation layer for player: {}", LOGGER.debug(
"Animation layer not yet available for player {} (will retry): {}",
player.getName().getString(), player.getName().getString(),
e e.toString()
); );
}
return null; return null;
} }
} }
@@ -398,9 +412,7 @@ public class BondageAnimationManager {
return npcLayers.get(player.getUUID()); 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. * Get the context animation layer for a player from PlayerAnimationAccess.
@@ -431,14 +443,13 @@ public class BondageAnimationManager {
LivingEntity entity LivingEntity entity
) { ) {
if (entity instanceof IAnimatedPlayer animated) { if (entity instanceof IAnimatedPlayer animated) {
return npcContextLayers.computeIfAbsent( return npcContextLayers.computeIfAbsent(entity.getUUID(), k -> {
entity.getUUID(),
k -> {
ModifierLayer<IAnimation> layer = new ModifierLayer<>(); ModifierLayer<IAnimation> layer = new ModifierLayer<>();
animated.getAnimationStack().addAnimLayer(CONTEXT_LAYER_PRIORITY, layer); animated
.getAnimationStack()
.addAnimLayer(CONTEXT_LAYER_PRIORITY, layer);
return layer; return layer;
} });
);
} }
return null; return null;
} }
@@ -496,9 +507,7 @@ public class BondageAnimationManager {
} }
} }
// ========================================
// FURNITURE LAYER (highest priority, for seat poses) // FURNITURE LAYER (highest priority, for seat poses)
// ========================================
/** /**
* Play a furniture animation on the furniture layer (highest priority). * Play a furniture animation on the furniture layer (highest priority).
@@ -512,21 +521,34 @@ public class BondageAnimationManager {
* @param animation the KeyframeAnimation from FurnitureAnimationContext * @param animation the KeyframeAnimation from FurnitureAnimationContext
* @return true if animation started successfully * @return true if animation started successfully
*/ */
public static boolean playFurniture(Player player, KeyframeAnimation animation) { public static boolean playFurniture(
if (player == null || animation == null || !player.level().isClientSide()) { Player player,
KeyframeAnimation animation
) {
if (
player == null ||
animation == null ||
!player.level().isClientSide()
) {
return false; return false;
} }
ModifierLayer<IAnimation> layer = getFurnitureLayer(player); ModifierLayer<IAnimation> layer = getOrCreateFurnitureLayer(player);
if (layer != null) { if (layer != null) {
layer.setAnimation(new KeyframeAnimationPlayer(animation)); layer.setAnimation(new KeyframeAnimationPlayer(animation));
// Reset grace ticks since we just started/refreshed the animation // Reset grace ticks since we just started/refreshed the animation
furnitureGraceTicks.remove(player.getUUID()); furnitureGraceTicks.remove(player.getUUID());
LOGGER.debug("Playing furniture animation on player: {}", player.getName().getString()); LOGGER.debug(
"Playing furniture animation on player: {}",
player.getName().getString()
);
return true; return true;
} }
LOGGER.warn("Furniture layer not available for player: {}", player.getName().getString()); LOGGER.warn(
"Furniture layer not available for player: {}",
player.getName().getString()
);
return false; return false;
} }
@@ -545,7 +567,10 @@ public class BondageAnimationManager {
layer.setAnimation(null); layer.setAnimation(null);
} }
furnitureGraceTicks.remove(player.getUUID()); furnitureGraceTicks.remove(player.getUUID());
LOGGER.debug("Stopped furniture animation on player: {}", player.getName().getString()); LOGGER.debug(
"Stopped furniture animation on player: {}",
player.getName().getString()
);
} }
/** /**
@@ -564,18 +589,22 @@ public class BondageAnimationManager {
} }
/** /**
* Get the furniture ModifierLayer for a player. * Get the furniture ModifierLayer for a player (READ-ONLY).
* Uses PlayerAnimationAccess for local/factory-registered players, * Uses PlayerAnimationAccess for local/factory-registered players,
* falls back to NPC cache for remote 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") @SuppressWarnings("unchecked")
@javax.annotation.Nullable @javax.annotation.Nullable
private static ModifierLayer<IAnimation> getFurnitureLayer(Player player) { private static ModifierLayer<IAnimation> getFurnitureLayer(Player player) {
if (player instanceof AbstractClientPlayer clientPlayer) { if (player instanceof AbstractClientPlayer clientPlayer) {
try { try {
ModifierLayer<IAnimation> layer = (ModifierLayer<IAnimation>) ModifierLayer<IAnimation> layer = (ModifierLayer<
PlayerAnimationAccess.getPlayerAssociatedData(clientPlayer) IAnimation
.get(FURNITURE_FACTORY_ID); >) PlayerAnimationAccess.getPlayerAssociatedData(
clientPlayer
).get(FURNITURE_FACTORY_ID);
if (layer != null) { if (layer != null) {
return layer; return layer;
} }
@@ -591,6 +620,61 @@ public class BondageAnimationManager {
return npcFurnitureLayers.get(player.getUUID()); 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.
* For remote players, creates a new layer on first call and caches it in
* {@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. * Safety tick for furniture animations. Call once per client tick per player.
* *
@@ -628,17 +712,18 @@ public class BondageAnimationManager {
// Player has furniture anim but no seat -- increment grace // Player has furniture anim but no seat -- increment grace
int ticks = furnitureGraceTicks.merge(uuid, 1, Integer::sum); int ticks = furnitureGraceTicks.merge(uuid, 1, Integer::sum);
if (ticks >= FURNITURE_GRACE_TICKS) { if (ticks >= FURNITURE_GRACE_TICKS) {
LOGGER.info("Removing stale furniture animation for player {} " LOGGER.info(
+ "(not riding ISeatProvider for {} ticks)", "Removing stale furniture animation for player {} " +
player.getName().getString(), ticks); "(not riding ISeatProvider for {} ticks)",
player.getName().getString(),
ticks
);
stopFurniture(player); stopFurniture(player);
} }
} }
} }
// ========================================
// FALLBACK ANIMATION HANDLING // FALLBACK ANIMATION HANDLING
// ========================================
/** /**
* Try to find a fallback animation ID when the requested one doesn't exist. * Try to find a fallback animation ID when the requested one doesn't exist.
@@ -696,9 +781,7 @@ public class BondageAnimationManager {
return null; return null;
} }
// ========================================
// CLEANUP // CLEANUP
// ========================================
/** /**
* Clean up animation layer for an NPC when it's removed. * Clean up animation layer for an NPC when it's removed.
@@ -706,9 +789,8 @@ 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. */
private static final Map<UUID, ModifierLayer<IAnimation>>[] ALL_NPC_CACHES = new Map[] { private static final Map<UUID, ModifierLayer<IAnimation>>[] ALL_NPC_CACHES =
npcLayers, npcContextLayers, npcFurnitureLayers new Map[] { npcLayers, npcContextLayers, npcFurnitureLayers };
};
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) {
@@ -718,6 +800,7 @@ public class BondageAnimationManager {
} }
} }
furnitureGraceTicks.remove(entityId); furnitureGraceTicks.remove(entityId);
layerFailureLogged.remove(entityId);
LOGGER.debug("Cleaned up animation layers for entity: {}", entityId); LOGGER.debug("Cleaned up animation layers for entity: {}", entityId);
} }
@@ -731,7 +814,7 @@ public class BondageAnimationManager {
cache.clear(); cache.clear();
} }
furnitureGraceTicks.clear(); furnitureGraceTicks.clear();
layerFailureLogged.clear();
LOGGER.info("Cleared all NPC animation layers"); LOGGER.info("Cleared all NPC animation layers");
} }
} }

View File

@@ -17,7 +17,6 @@ import net.minecraftforge.api.distmarker.OnlyIn;
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public enum AnimationContext { public enum AnimationContext {
STAND_IDLE("stand_idle", false), STAND_IDLE("stand_idle", false),
STAND_WALK("stand_walk", false), STAND_WALK("stand_walk", false),
STAND_SNEAK("stand_sneak", false), STAND_SNEAK("stand_sneak", false),

View File

@@ -50,15 +50,21 @@ public final class AnimationContextResolver {
* @param activeStyle the active movement style from client state, or null * @param activeStyle the active movement style from client state, or null
* @return the resolved animation context, never null * @return the resolved animation context, never null
*/ */
public static AnimationContext resolve(Player player, @Nullable PlayerBindState state, public static AnimationContext resolve(
@Nullable MovementStyle activeStyle) { Player player,
@Nullable PlayerBindState state,
@Nullable MovementStyle activeStyle
) {
boolean sitting = PetBedClientState.get(player.getUUID()) != 0; boolean sitting = PetBedClientState.get(player.getUUID()) != 0;
boolean struggling = state != null && state.isStruggling(); boolean struggling = state != null && state.isStruggling();
boolean sneaking = player.isCrouching(); boolean sneaking = player.isCrouching();
boolean moving = player.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6; boolean moving =
player.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
if (sitting) { if (sitting) {
return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE; return struggling
? AnimationContext.SIT_STRUGGLE
: AnimationContext.SIT_IDLE;
} }
if (struggling) { if (struggling) {
return AnimationContext.STAND_STRUGGLE; return AnimationContext.STAND_STRUGGLE;
@@ -78,12 +84,23 @@ public final class AnimationContextResolver {
/** /**
* Map a movement style + moving flag to the appropriate AnimationContext. * Map a movement style + moving flag to the appropriate AnimationContext.
*/ */
private static AnimationContext resolveStyleContext(MovementStyle style, boolean moving) { private static AnimationContext resolveStyleContext(
MovementStyle style,
boolean moving
) {
return switch (style) { return switch (style) {
case SHUFFLE -> moving ? AnimationContext.SHUFFLE_WALK : AnimationContext.SHUFFLE_IDLE; case SHUFFLE -> moving
case HOP -> moving ? AnimationContext.HOP_WALK : AnimationContext.HOP_IDLE; ? AnimationContext.SHUFFLE_WALK
case WADDLE -> moving ? AnimationContext.WADDLE_WALK : AnimationContext.WADDLE_IDLE; : AnimationContext.SHUFFLE_IDLE;
case CRAWL -> moving ? AnimationContext.CRAWL_MOVE : AnimationContext.CRAWL_IDLE; case HOP -> moving
? AnimationContext.HOP_WALK
: AnimationContext.HOP_IDLE;
case WADDLE -> moving
? AnimationContext.WADDLE_WALK
: AnimationContext.WADDLE_IDLE;
case CRAWL -> moving
? AnimationContext.CRAWL_MOVE
: AnimationContext.CRAWL_IDLE;
}; };
} }
@@ -99,13 +116,18 @@ public final class AnimationContextResolver {
boolean sitting = entity.isSitting(); boolean sitting = entity.isSitting();
boolean kneeling = entity.isKneeling(); boolean kneeling = entity.isKneeling();
boolean struggling = entity.isStruggling(); boolean struggling = entity.isStruggling();
boolean moving = entity.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6; boolean moving =
entity.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
if (sitting) { if (sitting) {
return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE; return struggling
? AnimationContext.SIT_STRUGGLE
: AnimationContext.SIT_IDLE;
} }
if (kneeling) { if (kneeling) {
return struggling ? AnimationContext.KNEEL_STRUGGLE : AnimationContext.KNEEL_IDLE; return struggling
? AnimationContext.KNEEL_STRUGGLE
: AnimationContext.KNEEL_IDLE;
} }
if (struggling) { if (struggling) {
return AnimationContext.STAND_STRUGGLE; return AnimationContext.STAND_STRUGGLE;

View File

@@ -6,10 +6,10 @@ import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
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 org.jetbrains.annotations.Nullable;
import org.slf4j.Logger; import org.slf4j.Logger;
/** /**
@@ -42,13 +42,15 @@ public final class ContextAnimationFactory {
* Cache keyed by "contextSuffix|ownedPartsHashCode". * Cache keyed by "contextSuffix|ownedPartsHashCode".
* Null values are stored as sentinels for missing animations to avoid repeated lookups. * Null values are stored as sentinels for missing animations to avoid repeated lookups.
*/ */
private static final Map<String, KeyframeAnimation> CACHE = new ConcurrentHashMap<>(); private static final Map<String, KeyframeAnimation> CACHE =
new ConcurrentHashMap<>();
/** /**
* Sentinel set used to track cache keys where the base animation was not found, * Sentinel set used to track cache keys where the base animation was not found,
* so we don't log the same warning repeatedly. * so we don't log the same warning repeatedly.
*/ */
private static final Set<String> MISSING_WARNED = ConcurrentHashMap.newKeySet(); private static final Set<String> MISSING_WARNED =
ConcurrentHashMap.newKeySet();
private ContextAnimationFactory() {} private ContextAnimationFactory() {}
@@ -65,8 +67,14 @@ public final class ContextAnimationFactory {
* @return the context animation with disabled parts suppressed, or null if not found * @return the context animation with disabled parts suppressed, or null if not found
*/ */
@Nullable @Nullable
public static KeyframeAnimation create(AnimationContext context, Set<String> disabledParts) { public static KeyframeAnimation create(
String cacheKey = context.getAnimationSuffix() + "|" + String.join(",", new java.util.TreeSet<>(disabledParts)); AnimationContext context,
Set<String> disabledParts
) {
String cacheKey =
context.getAnimationSuffix() +
"|" +
String.join(",", new java.util.TreeSet<>(disabledParts));
// computeIfAbsent cannot store null values, so we handle the missing case // computeIfAbsent cannot store null values, so we handle the missing case
// by checking the MISSING_WARNED set to avoid redundant work. // by checking the MISSING_WARNED set to avoid redundant work.
KeyframeAnimation cached = CACHE.get(cacheKey); KeyframeAnimation cached = CACHE.get(cacheKey);
@@ -77,7 +85,10 @@ public final class ContextAnimationFactory {
return null; return null;
} }
KeyframeAnimation result = buildContextAnimation(context, disabledParts); KeyframeAnimation result = buildContextAnimation(
context,
disabledParts
);
if (result != null) { if (result != null) {
CACHE.put(cacheKey, result); CACHE.put(cacheKey, result);
} else { } else {
@@ -100,8 +111,10 @@ public final class ContextAnimationFactory {
* </ol> * </ol>
*/ */
@Nullable @Nullable
private static KeyframeAnimation buildContextAnimation(AnimationContext context, private static KeyframeAnimation buildContextAnimation(
Set<String> disabledParts) { AnimationContext context,
Set<String> disabledParts
) {
String suffix = context.getAnimationSuffix(); String suffix = context.getAnimationSuffix();
// Priority 1: GLB-based context animation from ContextGlbRegistry // Priority 1: GLB-based context animation from ContextGlbRegistry
@@ -110,13 +123,17 @@ public final class ContextAnimationFactory {
// Priority 2: JSON-based context animation from PlayerAnimationRegistry // Priority 2: JSON-based context animation from PlayerAnimationRegistry
if (baseAnim == null) { if (baseAnim == null) {
ResourceLocation animId = ResourceLocation.fromNamespaceAndPath( ResourceLocation animId = ResourceLocation.fromNamespaceAndPath(
NAMESPACE, "context_" + suffix NAMESPACE,
"context_" + suffix
); );
baseAnim = PlayerAnimationRegistry.getAnimation(animId); baseAnim = PlayerAnimationRegistry.getAnimation(animId);
} }
if (baseAnim == null) { if (baseAnim == null) {
LOGGER.warn("[V2Animation] Context animation not found for suffix: {}", suffix); LOGGER.warn(
"[V2Animation] Context animation not found for suffix: {}",
suffix
);
return null; return null;
} }
@@ -140,8 +157,10 @@ public final class ContextAnimationFactory {
* <p>Unknown part names are silently ignored -- this can happen if the disabled parts set * <p>Unknown part names are silently ignored -- this can happen if the disabled parts set
* includes future bone names not present in the current context animation.</p> * includes future bone names not present in the current context animation.</p>
*/ */
private static void disableParts(KeyframeAnimation.AnimationBuilder builder, private static void disableParts(
Set<String> disabledParts) { KeyframeAnimation.AnimationBuilder builder,
Set<String> disabledParts
) {
for (String partName : disabledParts) { for (String partName : disabledParts) {
KeyframeAnimation.StateCollection part = builder.getPart(partName); KeyframeAnimation.StateCollection part = builder.getPart(partName);
if (part != null) { if (part != null) {

View File

@@ -8,7 +8,6 @@ import java.io.InputStream;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource; import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager; import net.minecraft.server.packs.resources.ResourceManager;
@@ -16,6 +15,7 @@ import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn; import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.Nullable;
/** /**
* Registry for context animations loaded from GLB files. * Registry for context animations loaded from GLB files.
@@ -70,10 +70,15 @@ public final class ContextGlbRegistry {
public static void reload(ResourceManager resourceManager) { public static void reload(ResourceManager resourceManager) {
Map<String, KeyframeAnimation> newRegistry = new HashMap<>(); Map<String, KeyframeAnimation> newRegistry = new HashMap<>();
Map<ResourceLocation, Resource> resources = resourceManager.listResources( Map<ResourceLocation, Resource> resources =
DIRECTORY, loc -> loc.getPath().endsWith(".glb")); resourceManager.listResources(DIRECTORY, loc ->
loc.getPath().endsWith(".glb")
);
for (Map.Entry<ResourceLocation, Resource> entry : resources.entrySet()) { for (Map.Entry<
ResourceLocation,
Resource
> entry : resources.entrySet()) {
ResourceLocation loc = entry.getKey(); ResourceLocation loc = entry.getKey();
Resource resource = entry.getValue(); Resource resource = entry.getValue();
@@ -89,15 +94,26 @@ public final class ContextGlbRegistry {
KeyframeAnimation anim = GltfPoseConverter.convert(data); KeyframeAnimation anim = GltfPoseConverter.convert(data);
newRegistry.put(suffix, anim); newRegistry.put(suffix, anim);
LOGGER.info("[GltfPipeline] Loaded context GLB: '{}' -> suffix '{}'", loc, suffix); LOGGER.info(
"[GltfPipeline] Loaded context GLB: '{}' -> suffix '{}'",
loc,
suffix
);
} catch (Exception e) { } catch (Exception e) {
LOGGER.error("[GltfPipeline] Failed to load context GLB: {}", loc, e); LOGGER.error(
"[GltfPipeline] Failed to load context GLB: {}",
loc,
e
);
} }
} }
// Atomic swap: render thread never sees a partially populated registry // Atomic swap: render thread never sees a partially populated registry
REGISTRY = Collections.unmodifiableMap(newRegistry); REGISTRY = Collections.unmodifiableMap(newRegistry);
LOGGER.info("[ContextGlb] Loaded {} context GLB animations", newRegistry.size()); LOGGER.info(
"[ContextGlb] Loaded {} context GLB animations",
newRegistry.size()
);
} }
/** /**

View File

@@ -6,10 +6,10 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
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 org.jetbrains.annotations.Nullable;
/** /**
* Resolves which named animation to play from a GLB file based on the current * Resolves which named animation to play from a GLB file based on the current
@@ -42,9 +42,12 @@ public final class GlbAnimationResolver {
* @return parsed GLB data, or null if loading failed * @return parsed GLB data, or null if loading failed
*/ */
@Nullable @Nullable
public static GltfData resolveAnimationData(ResourceLocation itemModelLoc, public static GltfData resolveAnimationData(
@Nullable ResourceLocation animationSource) { ResourceLocation itemModelLoc,
ResourceLocation source = animationSource != null ? animationSource : itemModelLoc; @Nullable ResourceLocation animationSource
) {
ResourceLocation source =
animationSource != null ? animationSource : itemModelLoc;
return GltfCache.get(source); return GltfCache.get(source);
} }
@@ -69,34 +72,43 @@ public final class GlbAnimationResolver {
String prefix = context.getGlbContextPrefix(); // "Sit", "Kneel", "Sneak", "Walk", "" String prefix = context.getGlbContextPrefix(); // "Sit", "Kneel", "Sneak", "Walk", ""
String variant = context.getGlbVariant(); // "Idle" or "Struggle" String variant = context.getGlbVariant(); // "Idle" or "Struggle"
// 1. Exact match: "FullSitIdle" then "SitIdle" (with variants) // 1. Exact match: "FullHeadSitIdle" then "FullSitIdle" then "SitIdle" (with variants)
// FullHead variants opt-in to head animation (see GltfPoseConverter.enableSelectiveParts)
String exact = prefix + variant; String exact = prefix + variant;
if (!exact.isEmpty()) { if (!exact.isEmpty()) {
String picked = pickWithVariants(data, "Full" + exact); String picked = pickWithVariants(data, "FullHead" + exact);
if (picked != null) return picked;
picked = pickWithVariants(data, "Full" + exact);
if (picked != null) return picked; if (picked != null) return picked;
picked = pickWithVariants(data, exact); picked = pickWithVariants(data, exact);
if (picked != null) return picked; if (picked != null) return picked;
} }
// 2. For struggles: try "FullStruggle" then "Struggle" (with variants) // 2. For struggles: try "FullHeadStruggle" then "FullStruggle" then "Struggle" (with variants)
if (context.isStruggling()) { if (context.isStruggling()) {
String picked = pickWithVariants(data, "FullStruggle"); String picked = pickWithVariants(data, "FullHeadStruggle");
if (picked != null) return picked;
picked = pickWithVariants(data, "FullStruggle");
if (picked != null) return picked; if (picked != null) return picked;
picked = pickWithVariants(data, "Struggle"); picked = pickWithVariants(data, "Struggle");
if (picked != null) return picked; if (picked != null) return picked;
} }
// 3. Context-only: "FullSit" then "Sit" (with variants) // 3. Context-only: "FullHead{prefix}" then "Full{prefix}" then "{prefix}" (with variants)
if (!prefix.isEmpty()) { if (!prefix.isEmpty()) {
String picked = pickWithVariants(data, "Full" + prefix); String picked = pickWithVariants(data, "FullHead" + prefix);
if (picked != null) return picked;
picked = pickWithVariants(data, "Full" + prefix);
if (picked != null) return picked; if (picked != null) return picked;
picked = pickWithVariants(data, prefix); picked = pickWithVariants(data, prefix);
if (picked != null) return picked; if (picked != null) return picked;
} }
// 4. Variant-only: "FullIdle" then "Idle" (with variants) // 4. Variant-only: "FullHeadIdle" then "FullIdle" then "Idle" (with variants)
{ {
String picked = pickWithVariants(data, "Full" + variant); String picked = pickWithVariants(data, "FullHead" + variant);
if (picked != null) return picked;
picked = pickWithVariants(data, "Full" + variant);
if (picked != null) return picked; if (picked != null) return picked;
picked = pickWithVariants(data, variant); picked = pickWithVariants(data, variant);
if (picked != null) return picked; if (picked != null) return picked;
@@ -146,6 +158,8 @@ public final class GlbAnimationResolver {
if (candidates.isEmpty()) return null; if (candidates.isEmpty()) return null;
if (candidates.size() == 1) return candidates.get(0); if (candidates.size() == 1) return candidates.get(0);
return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size())); return candidates.get(
ThreadLocalRandom.current().nextInt(candidates.size())
);
} }
} }

View File

@@ -6,11 +6,11 @@ import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition; import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry; import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import java.util.*; import java.util.*;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack; 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 org.jetbrains.annotations.Nullable;
/** /**
* Maps V2 body regions to PlayerAnimator part names. * Maps V2 body regions to PlayerAnimator part names.
@@ -29,7 +29,12 @@ public final class RegionBoneMapper {
/** All PlayerAnimator part names for the player model. */ /** All PlayerAnimator part names for the player model. */
public static final Set<String> ALL_PARTS = Set.of( public static final Set<String> ALL_PARTS = Set.of(
"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg" "head",
"body",
"rightArm",
"leftArm",
"rightLeg",
"leftLeg"
); );
/** /**
@@ -46,7 +51,6 @@ public final class RegionBoneMapper {
* the other item takes precedence (the bone goes to {@code otherParts}).</p> * the other item takes precedence (the bone goes to {@code otherParts}).</p>
*/ */
public record BoneOwnership(Set<String> thisParts, Set<String> otherParts) { public record BoneOwnership(Set<String> thisParts, Set<String> otherParts) {
/** /**
* Parts not owned by any item. These are "free" and can be animated * Parts not owned by any item. These are "free" and can be animated
* by the winning item IF the GLB contains keyframes for them. * by the winning item IF the GLB contains keyframes for them.
@@ -123,7 +127,9 @@ public final class RegionBoneMapper {
* @param equipped map from representative region to equipped ItemStack * @param equipped map from representative region to equipped ItemStack
* @return unmodifiable set of owned part name strings * @return unmodifiable set of owned part name strings
*/ */
public static Set<String> computeOwnedParts(Map<BodyRegionV2, ItemStack> equipped) { public static Set<String> computeOwnedParts(
Map<BodyRegionV2, ItemStack> equipped
) {
Set<String> owned = new HashSet<>(); Set<String> owned = new HashSet<>();
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) { for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue(); ItemStack stack = entry.getValue();
@@ -151,14 +157,18 @@ public final class RegionBoneMapper {
* @param winningItemStack the ItemStack of the highest-priority V2 item with a GLB model * @param winningItemStack the ItemStack of the highest-priority V2 item with a GLB model
* @return BoneOwnership describing this item's parts vs other items' parts * @return BoneOwnership describing this item's parts vs other items' parts
*/ */
public static BoneOwnership computePerItemParts(Map<BodyRegionV2, ItemStack> equipped, public static BoneOwnership computePerItemParts(
ItemStack winningItemStack) { Map<BodyRegionV2, ItemStack> equipped,
ItemStack winningItemStack
) {
Set<String> thisParts = new HashSet<>(); Set<String> thisParts = new HashSet<>();
Set<String> otherParts = new HashSet<>(); Set<String> otherParts = new HashSet<>();
// Track which ItemStacks we've already processed to avoid duplicate work // Track which ItemStacks we've already processed to avoid duplicate work
// (multiple regions can map to the same ItemStack) // (multiple regions can map to the same ItemStack)
Set<ItemStack> processed = Collections.newSetFromMap(new IdentityHashMap<>()); Set<ItemStack> processed = Collections.newSetFromMap(
new IdentityHashMap<>()
);
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) { for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue(); ItemStack stack = entry.getValue();
@@ -199,8 +209,11 @@ public final class RegionBoneMapper {
* @param winningItem the actual ItemStack reference (for identity comparison in * @param winningItem the actual ItemStack reference (for identity comparison in
* {@link #computePerItemParts}) * {@link #computePerItemParts})
*/ */
public record GlbModelResult(ResourceLocation modelLoc, @Nullable ResourceLocation animSource, public record GlbModelResult(
ItemStack winningItem) {} ResourceLocation modelLoc,
@Nullable ResourceLocation animSource,
ItemStack winningItem
) {}
/** /**
* Animation info for a single equipped V2 item. * Animation info for a single equipped V2 item.
@@ -213,9 +226,13 @@ public final class RegionBoneMapper {
* @param animationBones per-animation bone whitelist from the data-driven definition. * @param animationBones per-animation bone whitelist from the data-driven definition.
* Empty map for hardcoded items (no filtering applied). * Empty map for hardcoded items (no filtering applied).
*/ */
public record V2ItemAnimInfo(ResourceLocation modelLoc, @Nullable ResourceLocation animSource, public record V2ItemAnimInfo(
Set<String> ownedParts, int posePriority, ResourceLocation modelLoc,
Map<String, Set<String>> animationBones) {} @Nullable ResourceLocation animSource,
Set<String> ownedParts,
int posePriority,
Map<String, Set<String>> animationBones
) {}
/** /**
* Find the highest-priority V2 item with a GLB model in the equipped map. * Find the highest-priority V2 item with a GLB model in the equipped map.
@@ -230,7 +247,9 @@ public final class RegionBoneMapper {
* @return the winning item's model info, or null if no V2 item has a GLB model (V1 fallback) * @return the winning item's model info, or null if no V2 item has a GLB model (V1 fallback)
*/ */
@Nullable @Nullable
public static GlbModelResult resolveWinningItem(Map<BodyRegionV2, ItemStack> equipped) { public static GlbModelResult resolveWinningItem(
Map<BodyRegionV2, ItemStack> equipped
) {
ItemStack bestStack = null; ItemStack bestStack = null;
ResourceLocation bestModel = null; ResourceLocation bestModel = null;
int bestPriority = Integer.MIN_VALUE; int bestPriority = Integer.MIN_VALUE;
@@ -238,7 +257,10 @@ public final class RegionBoneMapper {
ItemStack stack = entry.getValue(); ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem v2Item) { if (stack.getItem() instanceof IV2BondageItem v2Item) {
ResourceLocation model = v2Item.getModelLocation(stack); ResourceLocation model = v2Item.getModelLocation(stack);
if (model != null && v2Item.getPosePriority(stack) > bestPriority) { if (
model != null &&
v2Item.getPosePriority(stack) > bestPriority
) {
bestPriority = v2Item.getPosePriority(stack); bestPriority = v2Item.getPosePriority(stack);
bestModel = model; bestModel = model;
bestStack = stack; bestStack = stack;
@@ -252,7 +274,9 @@ public final class RegionBoneMapper {
// (the model's own animations are used). // (the model's own animations are used).
ResourceLocation animSource = null; ResourceLocation animSource = null;
if (bestStack.getItem() instanceof DataDrivenBondageItem) { if (bestStack.getItem() instanceof DataDrivenBondageItem) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(bestStack); DataDrivenItemDefinition def = DataDrivenItemRegistry.get(
bestStack
);
if (def != null) { if (def != null) {
animSource = def.animationSource(); animSource = def.animationSource();
} }
@@ -273,13 +297,23 @@ public final class RegionBoneMapper {
* @return list of V2ItemAnimInfo, sorted by priority descending. Empty if no V2 items. * @return list of V2ItemAnimInfo, sorted by priority descending. Empty if no V2 items.
* The first element (if any) is the free-bone donor. * The first element (if any) is the free-bone donor.
*/ */
public static List<V2ItemAnimInfo> resolveAllV2Items(Map<BodyRegionV2, ItemStack> equipped) { public static List<V2ItemAnimInfo> resolveAllV2Items(
record ItemEntry(ItemStack stack, IV2BondageItem v2Item, ResourceLocation model, Map<BodyRegionV2, ItemStack> equipped
@Nullable ResourceLocation animSource, Set<String> rawParts, int priority, ) {
Map<String, Set<String>> animationBones) {} record ItemEntry(
ItemStack stack,
IV2BondageItem v2Item,
ResourceLocation model,
@Nullable ResourceLocation animSource,
Set<String> rawParts,
int priority,
Map<String, Set<String>> animationBones
) {}
List<ItemEntry> entries = new ArrayList<>(); List<ItemEntry> entries = new ArrayList<>();
Set<ItemStack> seen = Collections.newSetFromMap(new IdentityHashMap<>()); Set<ItemStack> seen = Collections.newSetFromMap(
new IdentityHashMap<>()
);
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) { for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue(); ItemStack stack = entry.getValue();
@@ -299,15 +333,26 @@ public final class RegionBoneMapper {
ResourceLocation animSource = null; ResourceLocation animSource = null;
Map<String, Set<String>> animBones = Map.of(); Map<String, Set<String>> animBones = Map.of();
if (stack.getItem() instanceof DataDrivenBondageItem) { if (stack.getItem() instanceof DataDrivenBondageItem) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); DataDrivenItemDefinition def = DataDrivenItemRegistry.get(
stack
);
if (def != null) { if (def != null) {
animSource = def.animationSource(); animSource = def.animationSource();
animBones = def.animationBones(); animBones = def.animationBones();
} }
} }
entries.add(new ItemEntry(stack, v2Item, model, animSource, rawParts, entries.add(
v2Item.getPosePriority(stack), animBones)); new ItemEntry(
stack,
v2Item,
model,
animSource,
rawParts,
v2Item.getPosePriority(stack),
animBones
)
);
} }
} }
@@ -323,8 +368,15 @@ public final class RegionBoneMapper {
ownedParts.removeAll(claimed); ownedParts.removeAll(claimed);
if (ownedParts.isEmpty()) continue; if (ownedParts.isEmpty()) continue;
claimed.addAll(ownedParts); claimed.addAll(ownedParts);
result.add(new V2ItemAnimInfo(e.model(), e.animSource(), result.add(
Collections.unmodifiableSet(ownedParts), e.priority(), e.animationBones())); new V2ItemAnimInfo(
e.model(),
e.animSource(),
Collections.unmodifiableSet(ownedParts),
e.priority(),
e.animationBones()
)
);
} }
return Collections.unmodifiableList(result); return Collections.unmodifiableList(result);

View File

@@ -1,13 +1,14 @@
package com.tiedup.remake.client.animation.render; package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.items.base.PoseType; import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.state.HumanChairHelper; import com.tiedup.remake.v2.bondage.PoseTypeHelper;
import com.tiedup.remake.util.HumanChairHelper;
import com.tiedup.remake.state.PlayerBindState; import com.tiedup.remake.state.PlayerBindState;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import com.tiedup.remake.v2.BodyRegionV2;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
@@ -35,11 +36,13 @@ import net.minecraftforge.fml.common.Mod;
public class DogPoseRenderHandler { public class DogPoseRenderHandler {
/** /**
* DOG pose state tracking per player. * DOG pose state per player, keyed by UUID (stable across dimension
* Stores: [0: smoothedTarget, 1: currentRot, 2: appliedDelta, 3: isMoving (0/1)] * change, unlike the int entity id which gets reassigned when the
* entity re-enters the level). Stores: [0: smoothedTarget, 1: currentRot,
* 2: appliedDelta, 3: isMoving (0/1)]
*/ */
private static final Int2ObjectMap<float[]> dogPoseState = private static final Map<UUID, float[]> dogPoseState =
new Int2ObjectOpenHashMap<>(); new ConcurrentHashMap<>();
// Array indices for dogPoseState // Array indices for dogPoseState
private static final int IDX_TARGET = 0; private static final int IDX_TARGET = 0;
@@ -51,16 +54,16 @@ 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. * Used by MixinPlayerModel to compensate head rotation.
*/ */
public static float getAppliedRotationDelta(int playerId) { public static float getAppliedRotationDelta(UUID playerUuid) {
float[] state = dogPoseState.get(playerId); float[] state = dogPoseState.get(playerUuid);
return state != null ? state[IDX_DELTA] : 0f; return state != null ? state[IDX_DELTA] : 0f;
} }
/** /**
* Check if a player is currently moving in DOG pose. * Check if a player is currently moving in DOG pose.
*/ */
public static boolean isDogPoseMoving(int playerId) { public static boolean isDogPoseMoving(UUID playerUuid) {
float[] state = dogPoseState.get(playerId); float[] state = dogPoseState.get(playerUuid);
return state != null && state[IDX_MOVING] > 0.5f; return state != null && state[IDX_MOVING] > 0.5f;
} }
@@ -72,6 +75,13 @@ public class DogPoseRenderHandler {
dogPoseState.clear(); dogPoseState.clear();
} }
/**
* Drop the state for a single entity leaving the level.
*/
public static void onEntityLeave(UUID entityUuid) {
dogPoseState.remove(entityUuid);
}
/** /**
* Before player render: Apply vertical offset and rotation for DOG/HUMAN_CHAIR poses. * Before player render: Apply vertical offset and rotation for DOG/HUMAN_CHAIR poses.
* HIGH priority ensures this runs before arm/item hiding handlers. * HIGH priority ensures this runs before arm/item hiding handlers.
@@ -93,14 +103,11 @@ public class DogPoseRenderHandler {
} }
ItemStack bindForPose = state.getEquipment(BodyRegionV2.ARMS); ItemStack bindForPose = state.getEquipment(BodyRegionV2.ARMS);
if ( if (bindForPose.isEmpty()) {
bindForPose.isEmpty() ||
!(bindForPose.getItem() instanceof ItemBind itemBind)
) {
return; return;
} }
PoseType bindPoseType = itemBind.getPoseType(); PoseType bindPoseType = PoseTypeHelper.getPoseType(bindForPose);
// Check for humanChairMode NBT override // Check for humanChairMode NBT override
bindPoseType = HumanChairHelper.resolveEffectivePose( bindPoseType = HumanChairHelper.resolveEffectivePose(
bindPoseType, bindPoseType,
@@ -118,15 +125,15 @@ public class DogPoseRenderHandler {
.getPoseStack() .getPoseStack()
.translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0); .translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0);
int playerId = player.getId(); UUID playerUuid = player.getUUID();
net.minecraft.world.phys.Vec3 movement = player.getDeltaMovement(); net.minecraft.world.phys.Vec3 movement = player.getDeltaMovement();
boolean isMoving = movement.horizontalDistanceSqr() > 0.0001; boolean isMoving = movement.horizontalDistanceSqr() > 0.0001;
// Get or create state - initialize to current body rotation // Get or create state - initialize to current body rotation
float[] s = dogPoseState.get(playerId); float[] s = dogPoseState.get(playerUuid);
if (s == null) { if (s == null) {
s = new float[] { player.yBodyRot, player.yBodyRot, 0f, 0f }; s = new float[] { player.yBodyRot, player.yBodyRot, 0f, 0f };
dogPoseState.put(playerId, s); dogPoseState.put(playerUuid, s);
} }
// Human chair: lock rotation state — body must not turn // Human chair: lock rotation state — body must not turn

View File

@@ -2,6 +2,8 @@ package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.PlayerBindState; import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer; import net.minecraft.client.player.LocalPlayer;
import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.Dist;
@@ -13,9 +15,20 @@ import net.minecraftforge.fml.common.Mod;
/** /**
* Hide first-person hand/item rendering based on bondage state. * Hide first-person hand/item rendering based on bondage state.
* *
* Behavior: * <p>Behavior:</p>
* - Tied up: Hide hands completely (hands are behind back) * <ul>
* - Mittens: Hide hands + items (Forge limitation - can't separate them) * <li><b>Tied up</b> (legacy V1 state): hide hands completely — hands are behind back</li>
* <li><b>Mittens</b> (legacy V1 item): hide hands + items (Forge limitation: RenderHandEvent
* controls hand + item together)</li>
* <li><b>V2 item in HANDS or ARMS region</b>: hide hands + items. An armbinder, handcuffs,
* gloves, or any item whose {@link com.tiedup.remake.v2.bondage.IV2BondageItem} declares
* HANDS/ARMS as an occupied or blocked region triggers this. Artists don't need to do
* anything special — declaring the region in the item JSON is enough.</li>
* </ul>
*
* <p>This is the pragmatic alternative to rendering the full GLB item in first-person
* (audit P1-05): the user decided that a player whose arms are restrained shouldn't see
* their arms at all, matching the third-person silhouette where the arms are bound.</p>
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber( @Mod.EventBusSubscriber(
@@ -38,13 +51,22 @@ public class FirstPersonHandHideHandler {
} }
PlayerBindState state = PlayerBindState.getInstance(player); PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) { if (state != null && (state.isTiedUp() || state.hasMittens())) {
// Legacy V1 state or item.
event.setCanceled(true);
return; return;
} }
// Tied or Mittens: hide hands completely // V2: any item occupying or blocking HANDS/ARMS hides both arms in first-person.
// (Forge limitation: RenderHandEvent controls hand + item together) // isRegionBlocked includes the blocked-regions whitelist from equipped items,
if (state.isTiedUp() || state.hasMittens()) { // so an armbinder on ARMS that also blocks HANDS hides both even if the HANDS
// slot itself is empty.
if (
V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.HANDS) ||
V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS) ||
V2EquipmentHelper.isRegionBlocked(player, BodyRegionV2.HANDS) ||
V2EquipmentHelper.isRegionBlocked(player, BodyRegionV2.ARMS)
) {
event.setCanceled(true); event.setCanceled(true);
} }
} }

View File

@@ -11,6 +11,7 @@ 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.client.event.RenderPlayerEvent; import net.minecraftforge.client.event.RenderPlayerEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.Mod;
@@ -37,8 +38,17 @@ public class HeldItemHideHandler {
private static final Int2ObjectMap<ItemStack[]> storedItems = private static final Int2ObjectMap<ItemStack[]> storedItems =
new Int2ObjectOpenHashMap<>(); new Int2ObjectOpenHashMap<>();
@SubscribeEvent // LOW priority + isCanceled guard: skip mutation when any earlier-
// priority canceller fired. Paired Post uses receiveCanceled = true
// and the storedItems map as a sentinel so held items still get
// restored even when Forge would otherwise skip Post on a cancelled
// Pre.
@SubscribeEvent(priority = EventPriority.LOW)
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) { public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
if (event.isCanceled()) {
return;
}
Player player = event.getEntity(); Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) { if (!(player instanceof AbstractClientPlayer)) {
return; return;
@@ -77,7 +87,7 @@ public class HeldItemHideHandler {
} }
} }
@SubscribeEvent @SubscribeEvent(receiveCanceled = true)
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) { public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
Player player = event.getEntity(); Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) { if (!(player instanceof AbstractClientPlayer)) {
@@ -90,4 +100,14 @@ public class HeldItemHideHandler {
player.setItemInHand(InteractionHand.OFF_HAND, items[1]); player.setItemInHand(InteractionHand.OFF_HAND, items[1]);
} }
} }
/** Drop tracked state for an entity leaving the level. */
public static void onEntityLeave(int entityId) {
storedItems.remove(entityId);
}
/** Drop all tracked state; called on world unload. */
public static void clearAll() {
storedItems.clear();
}
} }

View File

@@ -1,12 +1,15 @@
package com.tiedup.remake.client.animation.render; package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.client.state.PetBedClientState; import com.tiedup.remake.client.state.PetBedClientState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.items.base.PoseType; import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.state.HumanChairHelper; import com.tiedup.remake.v2.bondage.PoseTypeHelper;
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 java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
@@ -21,7 +24,10 @@ import net.minecraftforge.fml.common.Mod;
* Handles pet bed render adjustments (SIT and SLEEP modes). * Handles pet bed render adjustments (SIT and SLEEP modes).
* *
* <p>Applies vertical offset and forced standing pose for pet bed states. * <p>Applies vertical offset and forced standing pose for pet bed states.
* Runs at HIGH priority alongside DogPoseRenderHandler. * Runs at LOW priority — observes earlier cancellations from HIGH/NORMAL/LOW
* mods but precedes LOWEST-tier cancellers. The co-ordering with
* DogPoseRenderHandler is state-based (checking {@code isDogOrChairPose}),
* not priority-based.
* *
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility. * <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
*/ */
@@ -34,10 +40,31 @@ import net.minecraftforge.fml.common.Mod;
public class PetBedRenderHandler { public class PetBedRenderHandler {
/** /**
* Before player render: Apply vertical offset and forced pose for pet bed. * Players whose forced pose we mutated in {@link #onRenderPlayerPre}.
* {@link #onRenderPlayerPost} only restores the pose for players in this
* set, keeping the mutation/restore pair atomic even when another mod
* cancels Pre (so our Pre returned early without mutating) — otherwise
* Post would null-out a forced pose we never set, potentially clobbering
* state owned by another mod.
*/ */
@SubscribeEvent(priority = EventPriority.HIGH) private static final Set<UUID> FORCED_POSE_PLAYERS =
ConcurrentHashMap.newKeySet();
/**
* Before player render: Apply vertical offset and forced pose for pet bed.
*
* <p>LOW priority + {@code isCanceled} guard: skip mutation when any
* earlier-priority canceller fired. The paired Post uses
* {@code receiveCanceled = true} + {@link #FORCED_POSE_PLAYERS} so
* mutations still get restored even if a LOWEST-tier canceller runs
* after our Pre.</p>
*/
@SubscribeEvent(priority = EventPriority.LOW)
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) { public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
if (event.isCanceled()) {
return;
}
Player player = event.getEntity(); Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) { if (!(player instanceof AbstractClientPlayer)) {
return; return;
@@ -47,7 +74,7 @@ public class PetBedRenderHandler {
return; return;
} }
java.util.UUID petBedUuid = player.getUUID(); UUID petBedUuid = player.getUUID();
byte petBedMode = PetBedClientState.get(petBedUuid); byte petBedMode = PetBedClientState.get(petBedUuid);
if (petBedMode == 1 || petBedMode == 2) { if (petBedMode == 1 || petBedMode == 2) {
@@ -62,6 +89,7 @@ public class PetBedRenderHandler {
if (petBedMode == 2) { if (petBedMode == 2) {
// SLEEP: force STANDING pose to prevent vanilla sleeping rotation // SLEEP: force STANDING pose to prevent vanilla sleeping rotation
player.setForcedPose(net.minecraft.world.entity.Pose.STANDING); player.setForcedPose(net.minecraft.world.entity.Pose.STANDING);
FORCED_POSE_PLAYERS.add(petBedUuid);
// Compensate for vanilla sleeping Y offset // Compensate for vanilla sleeping Y offset
player player
@@ -83,11 +111,9 @@ public class PetBedRenderHandler {
PlayerBindState state = PlayerBindState.getInstance(player); PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) return false; if (state == null) return false;
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
if ( if (bind.isEmpty()) return false;
bind.isEmpty() || !(bind.getItem() instanceof ItemBind itemBind)
) return false;
PoseType pose = HumanChairHelper.resolveEffectivePose( PoseType pose = HumanChairHelper.resolveEffectivePose(
itemBind.getPoseType(), PoseTypeHelper.getPoseType(bind),
bind bind
); );
return pose == PoseType.DOG || pose == PoseType.HUMAN_CHAIR; return pose == PoseType.DOG || pose == PoseType.HUMAN_CHAIR;
@@ -95,17 +121,44 @@ public class PetBedRenderHandler {
/** /**
* After player render: Restore forced pose for pet bed SLEEP mode. * After player render: Restore forced pose for pet bed SLEEP mode.
*
* <p>Only restores when Pre actually mutated the pose (tracked via
* {@link #FORCED_POSE_PLAYERS}). If Pre was cancelled upstream or
* mode flipped between Pre and Post, we never touched this player's
* forced pose — so nulling it out here would clobber another mod's
* state. Symmetric with the LOWEST priority + cancel guard on Pre.</p>
*/ */
@SubscribeEvent @SubscribeEvent(receiveCanceled = true)
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) { public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
Player player = event.getEntity(); Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) { if (!(player instanceof AbstractClientPlayer)) {
return; return;
} }
byte petBedMode = PetBedClientState.get(player.getUUID()); UUID playerUuid = player.getUUID();
if (petBedMode == 2) { if (FORCED_POSE_PLAYERS.remove(playerUuid)) {
player.setForcedPose(null); player.setForcedPose(null);
} }
} }
/**
* Drain tracked state for an entity leaving the level.
* Called from {@code EntityCleanupHandler} to prevent stale UUIDs from
* lingering when players disconnect mid-render-cycle. Fires for every
* departing entity — non-player UUIDs are simply absent from the set,
* so {@code remove} is a cheap no-op.
*/
public static void onEntityLeave(UUID entityUuid) {
FORCED_POSE_PLAYERS.remove(entityUuid);
}
/**
* Drain all tracked state. Called from
* {@link com.tiedup.remake.client.animation.AnimationStateRegistry#clearAll}
* on world unload so a UUID added between Pre and a world-unload event
* doesn't linger into the next world.
*/
public static void clearAll() {
FORCED_POSE_PLAYERS.clear();
}
} }

View File

@@ -1,14 +1,16 @@
package com.tiedup.remake.client.animation.render; package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.client.renderer.layers.ClothesRenderHelper; import com.tiedup.remake.client.renderer.layers.ClothesRenderHelper;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.items.base.PoseType; import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.v2.bondage.PoseTypeHelper;
import com.tiedup.remake.items.clothes.ClothesProperties; import com.tiedup.remake.items.clothes.ClothesProperties;
import com.tiedup.remake.state.PlayerBindState; import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
import it.unimi.dsi.fastutil.ints.IntSet;
import net.minecraft.client.model.PlayerModel; import net.minecraft.client.model.PlayerModel;
import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
@@ -16,6 +18,7 @@ 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.client.event.RenderPlayerEvent; import net.minecraftforge.client.event.RenderPlayerEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.Mod;
@@ -40,18 +43,36 @@ public class PlayerArmHideEventHandler {
/** /**
* Stored layer visibility to restore after rendering. * Stored layer visibility to restore after rendering.
* Key: Player entity ID (int), Value: [hat, jacket, leftSleeve, rightSleeve, leftPants, rightPants] * Key: Player entity ID (int), Value: [hat, jacket, leftSleeve, rightSleeve, leftPants, rightPants].
* Presence in the map is also the sentinel for "Post must restore layers".
*/ */
private static final Int2ObjectMap<boolean[]> storedLayers = private static final Int2ObjectMap<boolean[]> storedLayers =
new Int2ObjectOpenHashMap<>(); new Int2ObjectOpenHashMap<>();
/**
* Entity ids whose arm visibility we hid in Pre, so Post only restores
* what we touched. Unconditional restore would clobber arm-hide state
* set by other mods on the shared {@link PlayerModel}.
*/
private static final IntSet hiddenArmEntities = new IntOpenHashSet();
/** /**
* Before player render: * Before player render:
* - Hide arms for wrap/latex_sack poses * - Hide arms for wrap/latex_sack poses
* - Hide outer layers based on clothes settings (Phase 19) * - Hide outer layers based on clothes settings
*
* <p>LOW priority + {@code isCanceled} guard: skip mutation when any
* earlier-priority canceller fired. Paired Post uses
* {@code receiveCanceled = true} + sentinel maps so mutations get
* restored even if a downstream canceller skips the normal Post path
* (Forge gates Post firing on the final canceled state).</p>
*/ */
@SubscribeEvent @SubscribeEvent(priority = EventPriority.LOW)
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) { public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
if (event.isCanceled()) {
return;
}
Player player = event.getEntity(); Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer clientPlayer)) { if (!(player instanceof AbstractClientPlayer clientPlayer)) {
return; return;
@@ -71,10 +92,8 @@ public class PlayerArmHideEventHandler {
// === HIDE ARMS (wrap/latex_sack poses) === // === HIDE ARMS (wrap/latex_sack poses) ===
if (state.hasArmsBound()) { if (state.hasArmsBound()) {
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
if ( if (!bind.isEmpty()) {
!bind.isEmpty() && bind.getItem() instanceof ItemBind itemBind PoseType poseType = PoseTypeHelper.getPoseType(bind);
) {
PoseType poseType = itemBind.getPoseType();
// Only hide arms for wrap/sack poses (arms are covered by the item) // Only hide arms for wrap/sack poses (arms are covered by the item)
if ( if (
@@ -84,11 +103,12 @@ public class PlayerArmHideEventHandler {
model.rightArm.visible = false; model.rightArm.visible = false;
model.leftSleeve.visible = false; model.leftSleeve.visible = false;
model.rightSleeve.visible = false; model.rightSleeve.visible = false;
hiddenArmEntities.add(player.getId());
} }
} }
} }
// === HIDE WEARER LAYERS (clothes settings) - Phase 19 === // === HIDE WEARER LAYERS (clothes settings) ===
ItemStack clothes = state.getEquipment(BodyRegionV2.TORSO); ItemStack clothes = state.getEquipment(BodyRegionV2.TORSO);
if (!clothes.isEmpty()) { if (!clothes.isEmpty()) {
ClothesProperties props = ClothesProperties props =
@@ -109,9 +129,12 @@ public class PlayerArmHideEventHandler {
} }
/** /**
* After player render: Restore arm visibility and layer visibility. * After player render: restore visibility only for state we actually
* mutated. {@code receiveCanceled=true} so we fire even when a
* downstream canceller cancelled the paired Pre — otherwise mutations
* stay applied forever.
*/ */
@SubscribeEvent @SubscribeEvent(receiveCanceled = true)
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) { public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
Player player = event.getEntity(); Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) { if (!(player instanceof AbstractClientPlayer)) {
@@ -120,16 +143,30 @@ public class PlayerArmHideEventHandler {
PlayerModel<?> model = event.getRenderer().getModel(); PlayerModel<?> model = event.getRenderer().getModel();
// === RESTORE ARM VISIBILITY === // === RESTORE ARM VISIBILITY (only if we hid them) ===
if (hiddenArmEntities.remove(player.getId())) {
model.leftArm.visible = true; model.leftArm.visible = true;
model.rightArm.visible = true; model.rightArm.visible = true;
model.leftSleeve.visible = true; model.leftSleeve.visible = true;
model.rightSleeve.visible = true; model.rightSleeve.visible = true;
}
// === RESTORE WEARER LAYERS - Phase 19 === // === RESTORE WEARER LAYERS ===
boolean[] savedLayers = storedLayers.remove(player.getId()); boolean[] savedLayers = storedLayers.remove(player.getId());
if (savedLayers != null) { if (savedLayers != null) {
ClothesRenderHelper.restoreWearerLayers(model, savedLayers); ClothesRenderHelper.restoreWearerLayers(model, savedLayers);
} }
} }
/** Drop tracked state for an entity leaving the level. */
public static void onEntityLeave(int entityId) {
storedLayers.remove(entityId);
hiddenArmEntities.remove(entityId);
}
/** Drop all tracked state; called on world unload. */
public static void clearAll() {
storedLayers.clear();
hiddenArmEntities.clear();
}
} }

View File

@@ -4,10 +4,8 @@ import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn; import net.minecraftforge.api.distmarker.OnlyIn;
/** /**
* Centralizes magic numbers used across render handlers. * Magic numbers shared across render handlers: DOG pose rotation
* * smoothing, head clamp limits, vertical offsets.
* <p>DOG pose rotation smoothing, head clamp limits, and vertical offsets
* that were previously scattered as unnamed literals.
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public final class RenderConstants { public final class RenderConstants {

View File

@@ -4,26 +4,24 @@ 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.PendingAnimationManager;
import com.tiedup.remake.client.animation.util.AnimationIdBuilder; 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.items.base.ItemBind; import com.tiedup.remake.util.HumanChairHelper;
import com.tiedup.remake.items.base.PoseType; import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2; import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment; import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.movement.MovementStyle; import com.tiedup.remake.v2.bondage.movement.MovementStyle;
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.state.HumanChairHelper;
import com.tiedup.remake.state.PlayerBindState;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
@@ -61,6 +59,29 @@ public class AnimationTickHandler {
/** Tick counter for periodic cleanup tasks */ /** Tick counter for periodic cleanup tasks */
private static int cleanupTickCounter = 0; private static int cleanupTickCounter = 0;
/**
* Per-player retry counter for the cold-cache furniture animation loop.
* A GLB without a {@code Player_*} armature (legacy V1-only furniture)
* can never yield a seat animation, so
* {@link BondageAnimationManager#hasFurnitureAnimation} stays false
* forever and the retry would spam at 20 Hz until dismount. Cap at
* {@link #MAX_FURNITURE_RETRIES}; reset on successful apply or on
* dismount so the next mount starts fresh.
*/
private static final Map<UUID, Integer> furnitureRetryCounters =
new ConcurrentHashMap<>();
private static final int MAX_FURNITURE_RETRIES = 60; // ~3 seconds at 20 Hz — covers slow-disk GLB load
/**
* Drain the retry counter for a specific entity leaving the level.
* Called from {@code EntityCleanupHandler.onEntityLeaveLevel} so a
* remote player getting unloaded (chunk unload, dimension change,
* kicked) doesn't leak a counter until the next world unload.
*/
public static void removeFurnitureRetry(UUID uuid) {
furnitureRetryCounters.remove(uuid);
}
/** /**
* 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. * Updates animations for all players when their bondage state changes.
@@ -92,6 +113,45 @@ public class AnimationTickHandler {
} }
// Safety: remove stale furniture animations for players no longer on seats // Safety: remove stale furniture animations for players no longer on seats
BondageAnimationManager.tickFurnitureSafety(player); 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);
}
} }
} }
@@ -109,8 +169,9 @@ public class AnimationTickHandler {
// Check if player has ANY V2 bondage item equipped (not just ARMS). // 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. // isTiedUp() only checks ARMS, but items on LEGS, HEAD, etc. also need animation.
boolean isTied = state != null && (state.isTiedUp() boolean isTied =
|| V2EquipmentHelper.hasAnyEquipment(player)); state != null &&
(state.isTiedUp() || V2EquipmentHelper.hasAnyEquipment(player));
boolean wasTied = boolean wasTied =
AnimationStateRegistry.getLastTiedState().getOrDefault(uuid, false); AnimationStateRegistry.getLastTiedState().getOrDefault(uuid, false);
@@ -175,9 +236,11 @@ public class AnimationTickHandler {
if (isTied) { if (isTied) {
// Resolve V2 equipped items // Resolve V2 equipped items
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(player); IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(
Map<BodyRegionV2, ItemStack> equipped = equipment != null player
? equipment.getAllEquipped() : Map.of(); );
Map<BodyRegionV2, ItemStack> equipped =
equipment != null ? equipment.getAllEquipped() : Map.of();
// Resolve ALL V2 items with GLB models and per-item bone ownership // Resolve ALL V2 items with GLB models and per-item bone ownership
java.util.List<RegionBoneMapper.V2ItemAnimInfo> v2Items = java.util.List<RegionBoneMapper.V2ItemAnimInfo> v2Items =
@@ -185,26 +248,31 @@ public class AnimationTickHandler {
if (!v2Items.isEmpty()) { if (!v2Items.isEmpty()) {
// V2 path: multi-item composite animation // V2 path: multi-item composite animation
java.util.Set<String> allOwnedParts = RegionBoneMapper.computeAllOwnedParts(v2Items); java.util.Set<String> allOwnedParts =
MovementStyle activeStyle = MovementStyleClientState.get(player.getUUID()); RegionBoneMapper.computeAllOwnedParts(v2Items);
AnimationContext context = AnimationContextResolver.resolve(player, state, activeStyle); MovementStyle activeStyle = MovementStyleClientState.get(
GltfAnimationApplier.applyMultiItemV2Animation(player, v2Items, context, allOwnedParts); player.getUUID()
// Clear V1 tracking so transition back works );
AnimationStateRegistry.getLastAnimId().remove(uuid); AnimationContext context = AnimationContextResolver.resolve(
} else { player,
// V1 fallback state,
if (GltfAnimationApplier.hasActiveState(player)) { 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); GltfAnimationApplier.clearV2Animation(player);
} }
String animId = buildAnimationId(player, state);
String lastId = AnimationStateRegistry.getLastAnimId().get(uuid);
if (!animId.equals(lastId)) {
boolean success = BondageAnimationManager.playAnimation(player, animId);
if (success) {
AnimationStateRegistry.getLastAnimId().put(uuid, animId);
}
}
}
} else if (wasTied) { } else if (wasTied) {
// Was tied, now free - stop all animations // Was tied, now free - stop all animations
if (GltfAnimationApplier.hasActiveState(player)) { if (GltfAnimationApplier.hasActiveState(player)) {
@@ -212,57 +280,12 @@ public class AnimationTickHandler {
} else { } else {
BondageAnimationManager.stopAnimation(player); BondageAnimationManager.stopAnimation(player);
} }
AnimationStateRegistry.getLastAnimId().remove(uuid);
} }
AnimationStateRegistry.getLastTiedState().put(uuid, isTied); AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
} }
/**
* Build animation ID from player's current state (V1 path).
*/
private static String buildAnimationId(
Player player,
PlayerBindState state
) {
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
PoseType poseType = PoseType.STANDARD;
if (bind.getItem() instanceof ItemBind itemBind) {
poseType = itemBind.getPoseType();
// Human chair mode: override DOG pose to HUMAN_CHAIR (straight limbs)
poseType = HumanChairHelper.resolveEffectivePose(poseType, bind);
}
// Derive bound state from V2 regions (works client-side, synced via capability)
boolean armsBound = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS);
boolean legsBound = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.LEGS);
// V1 fallback: if no V2 regions are set but player is tied, derive from ItemBind NBT
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) {
armsBound = ItemBind.hasArmsBound(bind);
legsBound = ItemBind.hasLegsBound(bind);
}
boolean isStruggling = state.isStruggling();
boolean isSneaking = player.isCrouching();
boolean isMoving =
player.getDeltaMovement().horizontalDistanceSqr() > 1e-6;
// Build animation ID with sneak and movement support
return AnimationIdBuilder.build(
poseType,
armsBound,
legsBound,
null,
isStruggling,
true,
isSneaking,
isMoving
);
}
/** /**
* Player logout event - cleanup animation data. * Player logout event - cleanup animation data.
*/ */
@@ -271,9 +294,9 @@ public class AnimationTickHandler {
if (event.getEntity().level().isClientSide()) { if (event.getEntity().level().isClientSide()) {
UUID uuid = event.getEntity().getUUID(); UUID uuid = event.getEntity().getUUID();
AnimationStateRegistry.getLastTiedState().remove(uuid); AnimationStateRegistry.getLastTiedState().remove(uuid);
AnimationStateRegistry.getLastAnimId().remove(uuid);
BondageAnimationManager.cleanup(uuid); BondageAnimationManager.cleanup(uuid);
GltfAnimationApplier.removeTracking(uuid); GltfAnimationApplier.removeTracking(uuid);
furnitureRetryCounters.remove(uuid);
} }
} }
@@ -290,6 +313,7 @@ public class AnimationTickHandler {
// DogPoseRenderHandler, MCAAnimationTickCache) // DogPoseRenderHandler, MCAAnimationTickCache)
// AnimationStateRegistry.clearAll() handles GltfAnimationApplier.clearAll() transitively // AnimationStateRegistry.clearAll() handles GltfAnimationApplier.clearAll() transitively
AnimationStateRegistry.clearAll(); AnimationStateRegistry.clearAll();
furnitureRetryCounters.clear();
// Non-animation client-side caches // Non-animation client-side caches
PetBedClientState.clearAll(); PetBedClientState.clearAll();

View File

@@ -1,8 +1,8 @@
package com.tiedup.remake.client.animation.tick; package com.tiedup.remake.client.animation.tick;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn; import net.minecraftforge.api.distmarker.OnlyIn;
@@ -12,12 +12,17 @@ import net.minecraftforge.api.distmarker.OnlyIn;
* multiple times per game tick. * multiple times per game tick.
* *
* <p>This is extracted from the mixin so it can be cleared on world unload * <p>This is extracted from the mixin so it can be cleared on world unload
* to prevent memory leaks. * to prevent memory leaks. Uses {@link ConcurrentHashMap} because reads
* happen on the render thread (from the mixin's {@code setupAnim} tail
* injection) while {@link #clear} is called from the main thread on world
* unload — a plain {@code HashMap} could observe a torn state or throw
* {@link java.util.ConcurrentModificationException} during the race.</p>
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public final class MCAAnimationTickCache { public final class MCAAnimationTickCache {
private static final Map<UUID, Integer> lastTickMap = new HashMap<>(); private static final Map<UUID, Integer> lastTickMap =
new ConcurrentHashMap<>();
private MCAAnimationTickCache() { private MCAAnimationTickCache() {
// Utility class // Utility class
@@ -48,4 +53,9 @@ public final class MCAAnimationTickCache {
public static void clear() { public static void clear() {
lastTickMap.clear(); lastTickMap.clear();
} }
/** Drop the tick-dedup entry for an entity leaving the level. */
public static void remove(UUID entityUuid) {
lastTickMap.remove(entityUuid);
}
} }

View File

@@ -9,14 +9,15 @@ import com.tiedup.remake.client.gltf.GltfAnimationApplier;
import com.tiedup.remake.entities.AbstractTiedUpNpc; import com.tiedup.remake.entities.AbstractTiedUpNpc;
import com.tiedup.remake.entities.EntityMaster; import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.entities.ai.master.MasterState; import com.tiedup.remake.entities.ai.master.MasterState;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.items.base.PoseType; import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.v2.bondage.BindModeHelper;
import com.tiedup.remake.v2.bondage.PoseTypeHelper;
import com.tiedup.remake.v2.BodyRegionV2; import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageEquipment; import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
@@ -45,10 +46,37 @@ import net.minecraftforge.fml.common.Mod;
public class NpcAnimationTickHandler { public class NpcAnimationTickHandler {
/** Track last animation ID per NPC to avoid redundant updates */ /** Track last animation ID per NPC to avoid redundant updates */
private static final Map<UUID, String> lastNpcAnimId = new ConcurrentHashMap<>(); private static final Map<UUID, String> lastNpcAnimId =
new ConcurrentHashMap<>();
/** /**
* Client tick: update animations for all loaded AbstractTiedUpNpc instances. * NPCs currently in a posed state (tied / sitting / kneeling). Values hold
* the live entity reference so the per-tick fast path doesn't need to
* resolve UUID → Entity (the client level doesn't expose a UUID index).
* Populated by {@link #fullSweep}; cleared by {@link #updateNpcAnimation}
* on pose exit, and by {@link #remove} on {@code EntityLeaveLevelEvent}.
*/
private static final Map<UUID, AbstractTiedUpNpc> ACTIVE_NPCS =
new ConcurrentHashMap<>();
/**
* Tick-count gate for the periodic full-entity sweep. A low-frequency
* O(N) fallback catches NPCs that entered the posed state via paths the
* fast path hasn't seen yet (e.g. just-spawned, just-loaded-into-chunk,
* state flipped by a packet we didn't mirror into ACTIVE_NPCS). 20 ticks
* ≈ 1 second — latency is invisible in practice.
*/
private static int sweepCounter = 0;
private static final int FULL_SWEEP_INTERVAL_TICKS = 20;
/**
* Client tick: update animations for posed NPCs.
*
* <p>Fast path (19 of every 20 ticks): iterate only {@link #ACTIVE_NPCS}
* — typically 15 entries — so the cost is O(|active|) instead of
* O(|all client entities|). Full sweep (every 20th tick): re-scan
* {@code entitiesForRendering()} to discover NPCs that entered the pose
* via an untracked path.</p>
*/ */
@SubscribeEvent @SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) { public static void onClientTick(TickEvent.ClientTickEvent event) {
@@ -61,6 +89,39 @@ public class NpcAnimationTickHandler {
return; return;
} }
sweepCounter++;
if (sweepCounter >= FULL_SWEEP_INTERVAL_TICKS) {
sweepCounter = 0;
fullSweep(mc);
} else {
fastTick();
}
}
/**
* Fast path: iterate only tracked posed NPCs. Entities that have died or
* been removed from the level are dropped from the set here so stale
* references don't linger between full sweeps.
*/
private static void fastTick() {
// ConcurrentHashMap.values() iterator is weakly consistent, so
// concurrent remove() during iteration (from updateNpcAnimation) is
// explicitly supported by the JDK contract.
for (AbstractTiedUpNpc npc : ACTIVE_NPCS.values()) {
if (!npc.isAlive() || npc.isRemoved()) {
ACTIVE_NPCS.remove(npc.getUUID());
continue;
}
updateNpcAnimation(npc);
}
}
/**
* Fallback sweep: O(N) over all rendered entities. Adds newly-posed NPCs
* to {@link #ACTIVE_NPCS} via {@link #updateNpcAnimation}; also runs the
* same logic for already-tracked NPCs, which is idempotent.
*/
private static void fullSweep(Minecraft mc) {
for (Entity entity : mc.level.entitiesForRendering()) { for (Entity entity : mc.level.entitiesForRendering()) {
if ( if (
entity instanceof AbstractTiedUpNpc damsel && entity instanceof AbstractTiedUpNpc damsel &&
@@ -80,8 +141,17 @@ public class NpcAnimationTickHandler {
* (base posture) and an item layer (GLB-driven bones). Sitting and kneeling are * (base posture) and an item layer (GLB-driven bones). Sitting and kneeling are
* handled by the context resolver, so the V2 path now covers all postures. * handled by the context resolver, so the V2 path now covers all postures.
* *
* <p>V1 fallback: if no V2 GLB model is found, falls back to JSON-based * <p>Legacy fallback: if no GLB model is found, falls back to JSON-based
* PlayerAnimator animations via {@link BondageAnimationManager}. * PlayerAnimator animations via {@link BondageAnimationManager}.
*
* <p><b>For future contributors</b>: this method is the sole writer of the
* {@link #ACTIVE_NPCS} fast-path set. Any new code that flips an NPC into a
* posed state (new packet handler, new AI transition, etc.) should call
* this method directly — otherwise the NPC will not be animated until the
* next 1 Hz full sweep picks it up (~1 s visible latency). If the worst-
* case latency matters for your use case, call
* {@link #updateNpcAnimation(AbstractTiedUpNpc)} yourself to register the
* NPC immediately.</p>
*/ */
private static void updateNpcAnimation(AbstractTiedUpNpc entity) { private static void updateNpcAnimation(AbstractTiedUpNpc entity) {
boolean inPose = boolean inPose =
@@ -89,23 +159,46 @@ public class NpcAnimationTickHandler {
UUID uuid = entity.getUUID(); UUID uuid = entity.getUUID();
// Track/untrack in ACTIVE_NPCS so the fast-tick path sees state
// transitions as soon as they're observed here. Idempotent put/remove
// — no double-tracking and no missed removal even if two code paths
// race to the same update.
if (inPose) {
ACTIVE_NPCS.put(uuid, entity);
} else {
ACTIVE_NPCS.remove(uuid);
}
if (inPose) { if (inPose) {
// Resolve V2 equipment map // Resolve V2 equipment map
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(entity); IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(
Map<BodyRegionV2, net.minecraft.world.item.ItemStack> equipped = equipment != null entity
? equipment.getAllEquipped() : Map.of(); );
RegionBoneMapper.GlbModelResult glbResult = RegionBoneMapper.resolveWinningItem(equipped); Map<BodyRegionV2, net.minecraft.world.item.ItemStack> equipped =
equipment != null ? equipment.getAllEquipped() : Map.of();
RegionBoneMapper.GlbModelResult glbResult =
RegionBoneMapper.resolveWinningItem(equipped);
if (glbResult != null) { if (glbResult != null) {
// V2 path: dual-layer animation with per-item bone ownership // V2 path: dual-layer animation with per-item bone ownership
RegionBoneMapper.BoneOwnership ownership = RegionBoneMapper.BoneOwnership ownership =
RegionBoneMapper.computePerItemParts(equipped, glbResult.winningItem()); RegionBoneMapper.computePerItemParts(
AnimationContext context = AnimationContextResolver.resolveNpc(entity); equipped,
GltfAnimationApplier.applyV2Animation(entity, glbResult.modelLoc(), glbResult.winningItem()
glbResult.animSource(), context, ownership); );
AnimationContext context = AnimationContextResolver.resolveNpc(
entity
);
GltfAnimationApplier.applyV2Animation(
entity,
glbResult.modelLoc(),
glbResult.animSource(),
context,
ownership
);
lastNpcAnimId.remove(uuid); lastNpcAnimId.remove(uuid);
} else { } else {
// V1 fallback: JSON-based PlayerAnimator animations // Legacy fallback: JSON-based PlayerAnimator animations
if (GltfAnimationApplier.hasActiveState(entity)) { if (GltfAnimationApplier.hasActiveState(entity)) {
GltfAnimationApplier.clearV2Animation(entity); GltfAnimationApplier.clearV2Animation(entity);
} }
@@ -118,7 +211,10 @@ public class NpcAnimationTickHandler {
} }
} }
} else { } else {
if (lastNpcAnimId.containsKey(uuid) || GltfAnimationApplier.hasActiveState(entity)) { if (
lastNpcAnimId.containsKey(uuid) ||
GltfAnimationApplier.hasActiveState(entity)
) {
if (GltfAnimationApplier.hasActiveState(entity)) { if (GltfAnimationApplier.hasActiveState(entity)) {
GltfAnimationApplier.clearV2Animation(entity); GltfAnimationApplier.clearV2Animation(entity);
} else { } else {
@@ -130,7 +226,7 @@ public class NpcAnimationTickHandler {
} }
/** /**
* Build animation ID for an NPC from its current state (V1 path). * Build animation ID for an NPC from its current state (legacy JSON path).
*/ */
private static String buildNpcAnimationId(AbstractTiedUpNpc entity) { private static String buildNpcAnimationId(AbstractTiedUpNpc entity) {
// Determine position prefix for SIT/KNEEL poses // Determine position prefix for SIT/KNEEL poses
@@ -141,23 +237,26 @@ public class NpcAnimationTickHandler {
positionPrefix = "kneel"; positionPrefix = "kneel";
} }
net.minecraft.world.item.ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS); net.minecraft.world.item.ItemStack bind = entity.getEquipment(
PoseType poseType = PoseType.STANDARD; BodyRegionV2.ARMS
boolean hasBind = false; );
PoseType poseType = PoseTypeHelper.getPoseType(bind);
if (bind.getItem() instanceof ItemBind itemBind) { boolean hasBind = BindModeHelper.isBindItem(bind);
poseType = itemBind.getPoseType();
hasBind = true;
}
// Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder) // Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder)
boolean armsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.ARMS); boolean armsBound = V2EquipmentHelper.isRegionOccupied(
boolean legsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.LEGS); entity,
BodyRegionV2.ARMS
);
boolean legsBound = V2EquipmentHelper.isRegionOccupied(
entity,
BodyRegionV2.LEGS
);
// V1 fallback: if no V2 regions set but NPC has a bind, derive from ItemBind NBT // Legacy fallback: if no V2 regions set but NPC has a bind, derive from bind mode NBT
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) { if (!armsBound && !legsBound && BindModeHelper.isBindItem(bind)) {
armsBound = ItemBind.hasArmsBound(bind); armsBound = BindModeHelper.hasArmsBound(bind);
legsBound = ItemBind.hasLegsBound(bind); legsBound = BindModeHelper.hasLegsBound(bind);
} }
boolean isStruggling = entity.isStruggling(); boolean isStruggling = entity.isStruggling();
@@ -194,5 +293,17 @@ public class NpcAnimationTickHandler {
*/ */
public static void clearAll() { public static void clearAll() {
lastNpcAnimId.clear(); lastNpcAnimId.clear();
ACTIVE_NPCS.clear();
}
/**
* Remove an individual NPC's animation state.
* Called from {@link com.tiedup.remake.client.events.EntityCleanupHandler}
* on {@code EntityLeaveLevelEvent} so the per-UUID map doesn't accumulate
* stale entries from dead/unloaded NPCs between world unloads.
*/
public static void remove(java.util.UUID uuid) {
lastNpcAnimId.remove(uuid);
ACTIVE_NPCS.remove(uuid);
} }
} }

View File

@@ -6,26 +6,24 @@ import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn; import net.minecraftforge.api.distmarker.OnlyIn;
/** /**
* Utility class for building animation ResourceLocation IDs. * Builds legacy JSON-animation ResourceLocation IDs from pose / bind-mode
* / variant components.
* *
* <p>Centralizes the logic for constructing animation file names. * <p><b>Legacy path.</b> The V2 player pipeline resolves animations from
* Used by BondageAnimationManager, NpcAnimationTickHandler, and AnimationTickHandler. * GLB models via {@code GltfAnimationApplier.applyV2Animation} +
* * {@code RegionBoneMapper} and does not touch this class. Only two
* <p>Animation naming convention: * callers remain:</p>
* <pre>
* {poseType}_{bindMode}_{variant}.json
*
* poseType: tied_up_basic | straitjacket | wrap | latex_sack
* bindMode: (empty for FULL) | _arms | _legs
* variant: _idle | _struggle | (empty for static)
* </pre>
*
* <p>Examples:
* <ul> * <ul>
* <li>tiedup:tied_up_basic_idle - STANDARD + FULL + idle</li> * <li>{@code NpcAnimationTickHandler} — JSON fallback when a tied
* <li>tiedup:straitjacket_arms_struggle - STRAITJACKET + ARMS + struggle</li> * NPC has no GLB-bearing item equipped.</li>
* <li>tiedup:wrap_idle - WRAP + FULL + idle</li> * <li>{@code MixinVillagerEntityBaseModelMCA} — MCA villagers use
* their own capability system and don't flow through the V2
* item registry, so they stay on the JSON animation path.</li>
* </ul> * </ul>
*
* <p>New code should not depend on this class. Animation naming:
* {@code {poseType}_{bindMode}_{variant}} (e.g.
* {@code tiedup:straitjacket_arms_struggle}).</p>
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public final class AnimationIdBuilder { public final class AnimationIdBuilder {
@@ -82,9 +80,7 @@ public final class AnimationIdBuilder {
return poseType.getBindTypeName(); return poseType.getBindTypeName();
} }
// ========================================
// Unified Build Method // Unified Build Method
// ========================================
/** /**
* Build animation ID string for entities. * Build animation ID string for entities.

View File

@@ -18,7 +18,6 @@ import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.Mod;
/** /**
* Phase 5: Blindfold Rendering
* *
* Based on the original TiedUp! mod (1.12.2) by Yuti & Marl Velius. * Based on the original TiedUp! mod (1.12.2) by Yuti & Marl Velius.
* *
@@ -109,9 +108,7 @@ public class BlindfoldRenderEventHandler {
// Set opacity: hardcore forces full opacity, otherwise use config // Set opacity: hardcore forces full opacity, otherwise use config
float opacity = hardcore float opacity = hardcore
? 1.0F ? 1.0F
: ModConfig.CLIENT.blindfoldOverlayOpacity : ModConfig.CLIENT.blindfoldOverlayOpacity.get().floatValue();
.get()
.floatValue();
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, opacity); RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, opacity);
RenderSystem.enableBlend(); RenderSystem.enableBlend();
RenderSystem.defaultBlendFunc(); RenderSystem.defaultBlendFunc();

View File

@@ -1,8 +1,19 @@
package com.tiedup.remake.client.events; package com.tiedup.remake.client.events;
import com.mojang.logging.LogUtils; import com.mojang.logging.LogUtils;
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.PendingAnimationManager;
import com.tiedup.remake.client.animation.render.DogPoseRenderHandler;
import com.tiedup.remake.client.animation.render.HeldItemHideHandler;
import com.tiedup.remake.client.animation.render.PetBedRenderHandler;
import com.tiedup.remake.client.animation.render.PlayerArmHideEventHandler;
import com.tiedup.remake.client.animation.tick.AnimationTickHandler;
import com.tiedup.remake.client.animation.tick.MCAAnimationTickCache;
import com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler;
import com.tiedup.remake.client.gltf.GltfAnimationApplier;
import com.tiedup.remake.client.state.MovementStyleClientState;
import com.tiedup.remake.client.state.PetBedClientState;
import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.core.TiedUpMod;
import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.event.entity.EntityLeaveLevelEvent; import net.minecraftforge.event.entity.EntityLeaveLevelEvent;
@@ -11,16 +22,10 @@ import net.minecraftforge.fml.common.Mod;
import org.slf4j.Logger; import org.slf4j.Logger;
/** /**
* Automatic cleanup handler for entity-related resources. * Fans out {@link EntityLeaveLevelEvent} to every per-entity state map on
* * the client — the single source of truth for "entity is gone, drop its
* <p>This handler automatically cleans up animation layers and pending animations * tracked state". Each target owns its own static map; this handler
* when entities leave the world, preventing memory leaks from stale cache entries. * ensures none of them leak entries for dead/unloaded entities.
*
* <p>Phase: Performance & Memory Management
*
* <p>Previously, cleanup had to be called manually via {@link BondageAnimationManager#cleanup(java.util.UUID)},
* which was error-prone and could lead to memory leaks if forgotten.
* This handler ensures cleanup happens automatically on entity removal.
*/ */
@Mod.EventBusSubscriber( @Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID, modid = TiedUpMod.MOD_ID,
@@ -56,15 +61,25 @@ public class EntityCleanupHandler {
return; return;
} }
// Clean up animation layers java.util.UUID uuid = event.getEntity().getUUID();
BondageAnimationManager.cleanup(event.getEntity().getUUID());
// Clean up pending animation queue BondageAnimationManager.cleanup(uuid);
PendingAnimationManager.remove(event.getEntity().getUUID()); PendingAnimationManager.remove(uuid);
GltfAnimationApplier.removeTracking(uuid);
NpcAnimationTickHandler.remove(uuid);
MovementStyleClientState.clear(uuid);
PetBedClientState.clear(uuid);
PetBedRenderHandler.onEntityLeave(uuid);
AnimationTickHandler.removeFurnitureRetry(uuid);
AnimationStateRegistry.getLastTiedState().remove(uuid);
DogPoseRenderHandler.onEntityLeave(uuid);
PlayerArmHideEventHandler.onEntityLeave(event.getEntity().getId());
HeldItemHideHandler.onEntityLeave(event.getEntity().getId());
MCAAnimationTickCache.remove(uuid);
LOGGER.debug( LOGGER.debug(
"Auto-cleaned animation resources for entity: {} (type: {})", "Auto-cleaned animation resources for entity: {} (type: {})",
event.getEntity().getUUID(), uuid,
event.getEntity().getClass().getSimpleName() event.getEntity().getClass().getSimpleName()
); );
} }

View File

@@ -1,11 +1,11 @@
package com.tiedup.remake.client.events; package com.tiedup.remake.client.events;
import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2; import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.items.ModItems; import com.tiedup.remake.v2.bondage.PoseTypeHelper;
import com.tiedup.remake.items.base.BindVariant;
import com.tiedup.remake.state.IBondageState; import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.KidnappedHelper; import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.v2.BodyRegionV2;
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;
@@ -138,7 +138,7 @@ public class LeashProxyClientHandler {
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS); ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
if ( if (
!bind.isEmpty() && !bind.isEmpty() &&
bind.getItem() == ModItems.getBind(BindVariant.DOGBINDER) PoseTypeHelper.getPoseType(bind) == PoseType.DOG
) { ) {
return DOGWALK_Y_OFFSET; return DOGWALK_Y_OFFSET;
} }

View File

@@ -1,13 +1,19 @@
package com.tiedup.remake.client.events; package com.tiedup.remake.client.events;
import com.tiedup.remake.items.base.*;
import com.tiedup.remake.network.ModNetwork; import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.network.selfbondage.PacketSelfBondage; import com.tiedup.remake.network.selfbondage.PacketSelfBondage;
import com.tiedup.remake.v2.bondage.BindModeHelper;
import com.tiedup.remake.v2.bondage.CollarHelper;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.component.BlindingComponent;
import com.tiedup.remake.v2.bondage.component.ComponentType;
import com.tiedup.remake.v2.bondage.component.GaggingComponent;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer; import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack; 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;
@@ -70,7 +76,7 @@ public class SelfBondageInputHandler {
if (!event.getLevel().isClientSide()) return; if (!event.getLevel().isClientSide()) return;
ItemStack stack = event.getItemStack(); ItemStack stack = event.getItemStack();
if (isSelfBondageItem(stack.getItem())) { if (isSelfBondageItem(stack)) {
event.setCanceled(true); event.setCanceled(true);
startSelfBondage(); startSelfBondage();
} }
@@ -87,11 +93,11 @@ public class SelfBondageInputHandler {
InteractionHand hand = InteractionHand.MAIN_HAND; InteractionHand hand = InteractionHand.MAIN_HAND;
ItemStack stack = player.getMainHandItem(); ItemStack stack = player.getMainHandItem();
if (!isSelfBondageItem(stack.getItem())) { if (!isSelfBondageItem(stack)) {
stack = player.getOffhandItem(); stack = player.getOffhandItem();
hand = InteractionHand.OFF_HAND; hand = InteractionHand.OFF_HAND;
if (!isSelfBondageItem(stack.getItem())) { if (!isSelfBondageItem(stack)) {
return; // No bondage item in either hand return; // No bondage item in either hand
} }
} }
@@ -130,7 +136,7 @@ public class SelfBondageInputHandler {
// Check if still holding bondage item in the active hand // Check if still holding bondage item in the active hand
ItemStack stack = player.getItemInHand(activeHand); ItemStack stack = player.getItemInHand(activeHand);
if (!isSelfBondageItem(stack.getItem())) { if (!isSelfBondageItem(stack)) {
stopSelfBondage(); stopSelfBondage();
return; return;
} }
@@ -153,27 +159,31 @@ public class SelfBondageInputHandler {
} }
/** /**
* Check if an item supports self-bondage. * Check if a stack supports self-bondage.
* Collar is explicitly excluded. * Collar is explicitly excluded.
*/ */
private static boolean isSelfBondageItem(Item item) { private static boolean isSelfBondageItem(ItemStack stack) {
// Collar cannot be self-equipped (V1 collar guard) if (stack.isEmpty()) return false;
if (item instanceof ItemCollar) {
// Collar cannot be self-equipped (V2 ownership component)
if (CollarHelper.isCollar(stack)) {
return false; return false;
} }
// V2 bondage items support self-bondage (left-click hold with tying duration) // V2 bondage items support self-bondage (left-click hold with tying duration)
if (item instanceof IV2BondageItem) { if (stack.getItem() instanceof IV2BondageItem) {
return true; return true;
} }
// V1 bondage items (legacy) // V2 data-driven items: check if it occupies any bondage region
return ( DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
item instanceof ItemBind || if (def != null) {
item instanceof ItemGag || return true;
item instanceof ItemBlindfold || }
item instanceof ItemMittens ||
item instanceof ItemEarplugs // V1 fallback: bind items
); return BindModeHelper.isBindItem(stack)
|| DataDrivenBondageItem.getComponent(stack, ComponentType.GAGGING, GaggingComponent.class) != null
|| DataDrivenBondageItem.getComponent(stack, ComponentType.BLINDING, BlindingComponent.class) != null;
} }
} }

View File

@@ -22,46 +22,37 @@ import org.joml.Vector3f;
/** /**
* Parser for binary .glb (glTF 2.0) files. * Parser for binary .glb (glTF 2.0) files.
* Extracts mesh geometry, skinning data, bone hierarchy, and animations. * Extracts mesh geometry, skinning data, bone hierarchy, and animations.
* Filters out meshes named "Player". * Filters out any mesh whose name starts with {@code "Player"} (the seat
* armature convention) — see {@link GlbParserUtils#isPlayerMesh}.
*/ */
public final class GlbParser { public final class GlbParser {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final int GLB_MAGIC = 0x46546C67; // "glTF"
private static final int GLB_VERSION = 2;
private static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
private static final int CHUNK_BIN = 0x004E4942; // "BIN\0"
private GlbParser() {} private GlbParser() {}
/** /**
* Parse a .glb file from an InputStream. * Parse a .glb file from an InputStream. Validates header, version, and total
* length (capped at {@link GlbParserUtils#MAX_GLB_SIZE}) before allocating chunk
* buffers.
* *
* @param input the input stream (will be fully read) * @param input the input stream (will be fully read)
* @param debugName name for log messages * @param debugName name for log messages
* @return parsed GltfData * @return parsed GltfData
* @throws IOException if the file is malformed or I/O fails * @throws IOException if the file is malformed, oversized, or truncated
*/ */
public static GltfData parse(InputStream input, String debugName) throws IOException { public static GltfData parse(InputStream input, String debugName)
byte[] allBytes = input.readAllBytes(); throws IOException {
ByteBuffer buf = ByteBuffer.wrap(allBytes).order(ByteOrder.LITTLE_ENDIAN); ByteBuffer buf = GlbParserUtils.readGlbSafely(input, debugName);
// -- Header --
int magic = buf.getInt();
if (magic != GLB_MAGIC) {
throw new IOException("Not a GLB file: " + debugName);
}
int version = buf.getInt();
if (version != GLB_VERSION) {
throw new IOException("Unsupported GLB version " + version + " in " + debugName);
}
int totalLength = buf.getInt();
// -- JSON chunk -- // -- JSON chunk --
int jsonChunkLength = buf.getInt(); int jsonChunkLength = GlbParserUtils.readChunkLength(
buf,
"JSON",
debugName
);
int jsonChunkType = buf.getInt(); int jsonChunkType = buf.getInt();
if (jsonChunkType != CHUNK_JSON) { if (jsonChunkType != GlbParserUtils.CHUNK_JSON) {
throw new IOException("Expected JSON chunk in " + debugName); throw new IOException("Expected JSON chunk in " + debugName);
} }
byte[] jsonBytes = new byte[jsonChunkLength]; byte[] jsonBytes = new byte[jsonChunkLength];
@@ -72,9 +63,13 @@ public final class GlbParser {
// -- BIN chunk -- // -- BIN chunk --
ByteBuffer binData = null; ByteBuffer binData = null;
if (buf.hasRemaining()) { if (buf.hasRemaining()) {
int binChunkLength = buf.getInt(); int binChunkLength = GlbParserUtils.readChunkLength(
buf,
"BIN",
debugName
);
int binChunkType = buf.getInt(); int binChunkType = buf.getInt();
if (binChunkType != CHUNK_BIN) { if (binChunkType != GlbParserUtils.CHUNK_BIN) {
throw new IOException("Expected BIN chunk in " + debugName); throw new IOException("Expected BIN chunk in " + debugName);
} }
byte[] binBytes = new byte[binChunkLength]; byte[] binBytes = new byte[binChunkLength];
@@ -98,25 +93,35 @@ public final class GlbParser {
JsonObject skin = skins.get(0).getAsJsonObject(); JsonObject skin = skins.get(0).getAsJsonObject();
JsonArray skinJoints = skin.getAsJsonArray("joints"); JsonArray skinJoints = skin.getAsJsonArray("joints");
// Filter skin joints to only include known deforming bones // Accept all skin joints (no filtering — custom bones are supported)
List<Integer> filteredJointNodes = new ArrayList<>(); List<Integer> allJointNodes = new ArrayList<>();
int[] skinJointRemap = new int[skinJoints.size()]; // old skin index -> new filtered index
java.util.Arrays.fill(skinJointRemap, -1);
for (int j = 0; j < skinJoints.size(); j++) { for (int j = 0; j < skinJoints.size(); j++) {
int nodeIdx = skinJoints.get(j).getAsInt(); int nodeIdx = skinJoints.get(j).getAsInt();
JsonObject node = nodes.get(nodeIdx).getAsJsonObject(); JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
String name = node.has("name") ? node.get("name").getAsString() : "joint_" + j; String rawName = node.has("name")
if (GltfBoneMapper.isKnownBone(name)) { ? node.get("name").getAsString()
skinJointRemap[j] = filteredJointNodes.size(); : "joint_" + j;
filteredJointNodes.add(nodeIdx); String name = GlbParserUtils.stripArmaturePrefix(rawName);
// Log info for non-MC bones
if (!GltfBoneMapper.isKnownBone(name)) {
String suggestion = GltfBoneMapper.suggestBoneName(name);
if (suggestion != null) {
LOGGER.warn(
"[GltfPipeline] Unknown bone '{}' in {} — did you mean '{}'? (treated as custom bone)",
name, debugName, suggestion
);
} else { } else {
LOGGER.debug("[GltfPipeline] Skipping non-deforming bone: '{}' (node {})", name, nodeIdx); LOGGER.info(
"[GltfPipeline] Custom bone '{}' in {} (will follow parent hierarchy in rest pose)",
name, debugName
);
} }
} }
allJointNodes.add(nodeIdx);
}
int jointCount = filteredJointNodes.size(); int jointCount = allJointNodes.size();
String[] jointNames = new String[jointCount]; String[] jointNames = new String[jointCount];
int[] parentJointIndices = new int[jointCount];
Quaternionf[] restRotations = new Quaternionf[jointCount]; Quaternionf[] restRotations = new Quaternionf[jointCount];
Vector3f[] restTranslations = new Vector3f[jointCount]; Vector3f[] restTranslations = new Vector3f[jointCount];
@@ -124,68 +129,43 @@ public final class GlbParser {
int[] nodeToJoint = new int[nodes.size()]; int[] nodeToJoint = new int[nodes.size()];
java.util.Arrays.fill(nodeToJoint, -1); java.util.Arrays.fill(nodeToJoint, -1);
for (int j = 0; j < jointCount; j++) { for (int j = 0; j < jointCount; j++) {
int nodeIdx = filteredJointNodes.get(j); int nodeIdx = allJointNodes.get(j);
nodeToJoint[nodeIdx] = j; nodeToJoint[nodeIdx] = j;
} }
// Read joint names, rest pose, and build parent mapping // Read joint names + rest pose
java.util.Arrays.fill(parentJointIndices, -1);
for (int j = 0; j < jointCount; j++) { for (int j = 0; j < jointCount; j++) {
int nodeIdx = filteredJointNodes.get(j); int nodeIdx = allJointNodes.get(j);
JsonObject node = nodes.get(nodeIdx).getAsJsonObject(); JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
jointNames[j] = node.has("name") ? node.get("name").getAsString() : "joint_" + j; String rawName = node.has("name")
? node.get("name").getAsString()
: "joint_" + j;
jointNames[j] = GlbParserUtils.stripArmaturePrefix(rawName);
// Rest rotation restRotations[j] = GlbParserUtils.readRestRotation(node);
if (node.has("rotation")) { restTranslations[j] = GlbParserUtils.readRestTranslation(node);
JsonArray r = node.getAsJsonArray("rotation"); }
restRotations[j] = new Quaternionf(
r.get(0).getAsFloat(), r.get(1).getAsFloat(), int[] parentJointIndices = GlbParserUtils.buildParentJointIndices(
r.get(2).getAsFloat(), r.get(3).getAsFloat() nodes,
nodeToJoint,
jointCount
); );
} else {
restRotations[j] = new Quaternionf(); // identity
}
// Rest translation
if (node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
restTranslations[j] = new Vector3f(
t.get(0).getAsFloat(), t.get(1).getAsFloat(), t.get(2).getAsFloat()
);
} else {
restTranslations[j] = new Vector3f();
}
}
// Build parent indices by traversing node children
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
if (node.has("children")) {
int parentJoint = nodeToJoint[ni];
JsonArray children = node.getAsJsonArray("children");
for (JsonElement child : children) {
int childNodeIdx = child.getAsInt();
int childJoint = nodeToJoint[childNodeIdx];
if (childJoint >= 0 && parentJoint >= 0) {
parentJointIndices[childJoint] = parentJoint;
}
}
}
}
// -- Inverse Bind Matrices -- // -- Inverse Bind Matrices --
// IBM accessor is indexed by original skin joint order, so we pick the filtered entries
Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount]; Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount];
if (skin.has("inverseBindMatrices")) { if (skin.has("inverseBindMatrices")) {
int ibmAccessor = skin.get("inverseBindMatrices").getAsInt(); int ibmAccessor = skin.get("inverseBindMatrices").getAsInt();
float[] ibmData = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, ibmAccessor); float[] ibmData = GlbParserUtils.readFloatAccessor(
for (int origJ = 0; origJ < skinJoints.size(); origJ++) { accessors,
int newJ = skinJointRemap[origJ]; bufferViews,
if (newJ >= 0) { binData,
inverseBindMatrices[newJ] = new Matrix4f(); ibmAccessor
inverseBindMatrices[newJ].set(ibmData, origJ * 16); );
} for (int j = 0; j < jointCount; j++) {
inverseBindMatrices[j] = new Matrix4f();
inverseBindMatrices[j].set(ibmData, j * 16);
} }
} else { } else {
for (int j = 0; j < jointCount; j++) { for (int j = 0; j < jointCount; j++) {
@@ -193,17 +173,35 @@ public final class GlbParser {
} }
} }
// -- Find mesh (ignore "Player" mesh, take LAST non-Player) -- // -- Find mesh by convention, then fallback --
// WORKAROUND: Takes the LAST non-Player mesh because modelers may leave prototype meshes // Priority: 1) mesh named "Item", 2) last non-Player mesh
// in the .glb. Revert to first non-Player mesh once modeler workflow is standardized.
int targetMeshIdx = -1; int targetMeshIdx = -1;
String selectedMeshName = null;
int nonPlayerCount = 0;
if (meshes != null) { if (meshes != null) {
for (int mi = 0; mi < meshes.size(); mi++) { for (int mi = 0; mi < meshes.size(); mi++) {
JsonObject mesh = meshes.get(mi).getAsJsonObject(); JsonObject mesh = meshes.get(mi).getAsJsonObject();
String meshName = mesh.has("name") ? mesh.get("name").getAsString() : ""; String meshName = mesh.has("name")
if (!"Player".equals(meshName)) { ? mesh.get("name").getAsString()
: "";
if ("Item".equals(meshName)) {
targetMeshIdx = mi; targetMeshIdx = mi;
selectedMeshName = meshName;
break; // Convention match — use it
} }
if (!GlbParserUtils.isPlayerMesh(meshName)) {
targetMeshIdx = mi;
selectedMeshName = meshName;
nonPlayerCount++;
}
}
if (nonPlayerCount > 1 && !"Item".equals(selectedMeshName)) {
LOGGER.warn(
"[GltfPipeline] {} non-Player meshes found in {} — using '{}'. "
+ "Name your mesh 'Item' for explicit selection.",
nonPlayerCount, debugName,
selectedMeshName != null ? selectedMeshName : "(unnamed)"
);
} }
} }
@@ -222,121 +220,31 @@ public final class GlbParser {
if (targetMeshIdx >= 0) { if (targetMeshIdx >= 0) {
JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject(); JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
JsonArray primitives = mesh.getAsJsonArray("primitives"); GlbParserUtils.PrimitiveParseResult r =
GlbParserUtils.parsePrimitives(
// -- Accumulate vertex data from ALL primitives -- mesh,
List<float[]> allPositions = new ArrayList<>(); accessors,
List<float[]> allNormals = new ArrayList<>(); bufferViews,
List<float[]> allTexCoords = new ArrayList<>(); binData,
List<int[]> allJoints = new ArrayList<>(); jointCount,
List<float[]> allWeights = new ArrayList<>(); /* readSkinning */true,
int cumulativeVertexCount = 0; materialNames,
debugName
for (int pi = 0; pi < primitives.size(); pi++) {
JsonObject primitive = primitives.get(pi).getAsJsonObject();
JsonObject attributes = primitive.getAsJsonObject("attributes");
// -- Read this primitive's vertex data --
float[] primPositions = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
attributes.get("POSITION").getAsInt()
); );
float[] primNormals = attributes.has("NORMAL") positions = r.positions;
? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("NORMAL").getAsInt()) normals = r.normals;
: new float[primPositions.length]; texCoords = r.texCoords;
float[] primTexCoords = attributes.has("TEXCOORD_0") indices = r.indices;
? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("TEXCOORD_0").getAsInt()) meshJoints = r.joints;
: new float[primPositions.length / 3 * 2]; weights = r.weights;
vertexCount = r.vertexCount;
int primVertexCount = primPositions.length / 3; parsedPrimitives.addAll(r.primitives);
// -- Read this primitive's indices (offset by cumulative vertex count) --
int[] primIndices;
if (primitive.has("indices")) {
primIndices = GlbParserUtils.readIntAccessor(
accessors, bufferViews, binData,
primitive.get("indices").getAsInt()
);
} else {
// Non-indexed: generate sequential indices
primIndices = new int[primVertexCount];
for (int i = 0; i < primVertexCount; i++) primIndices[i] = i;
}
// Offset indices by cumulative vertex count from prior primitives
if (cumulativeVertexCount > 0) {
for (int i = 0; i < primIndices.length; i++) {
primIndices[i] += cumulativeVertexCount;
}
}
// -- Read skinning attributes for this primitive --
int[] primJoints = new int[primVertexCount * 4];
float[] primWeights = new float[primVertexCount * 4];
if (attributes.has("JOINTS_0")) {
primJoints = GlbParserUtils.readIntAccessor(
accessors, bufferViews, binData,
attributes.get("JOINTS_0").getAsInt()
);
// Remap vertex joint indices from original skin order to filtered order
for (int i = 0; i < primJoints.length; i++) {
int origIdx = primJoints[i];
if (origIdx >= 0 && origIdx < skinJointRemap.length) {
primJoints[i] = skinJointRemap[origIdx] >= 0 ? skinJointRemap[origIdx] : 0;
} else {
primJoints[i] = 0;
}
}
}
if (attributes.has("WEIGHTS_0")) {
primWeights = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
attributes.get("WEIGHTS_0").getAsInt()
);
}
// -- Resolve material name and tint channel --
String matName = null;
if (primitive.has("material")) {
int matIdx = primitive.get("material").getAsInt();
if (matIdx >= 0 && matIdx < materialNames.length) {
matName = materialNames[matIdx];
}
}
boolean isTintable = matName != null && matName.startsWith("tintable_");
String tintChannel = isTintable ? matName : null;
parsedPrimitives.add(new GltfData.Primitive(primIndices, matName, isTintable, tintChannel));
allPositions.add(primPositions);
allNormals.add(primNormals);
allTexCoords.add(primTexCoords);
allJoints.add(primJoints);
allWeights.add(primWeights);
cumulativeVertexCount += primVertexCount;
}
// -- Flatten accumulated data into single arrays --
vertexCount = cumulativeVertexCount;
positions = GlbParserUtils.flattenFloats(allPositions);
normals = GlbParserUtils.flattenFloats(allNormals);
texCoords = GlbParserUtils.flattenFloats(allTexCoords);
meshJoints = GlbParserUtils.flattenInts(allJoints);
weights = GlbParserUtils.flattenFloats(allWeights);
// Build union of all primitive indices (for backward-compat indices() accessor)
int totalIndices = 0;
for (GltfData.Primitive p : parsedPrimitives) totalIndices += p.indices().length;
indices = new int[totalIndices];
int offset = 0;
for (GltfData.Primitive p : parsedPrimitives) {
System.arraycopy(p.indices(), 0, indices, offset, p.indices().length);
offset += p.indices().length;
}
} else { } else {
// Animation-only GLB: no mesh data // Animation-only GLB: no mesh data
LOGGER.info("[GltfPipeline] No mesh found in '{}' (animation-only GLB)", debugName); LOGGER.info(
"[GltfPipeline] No mesh found in '{}' (animation-only GLB)",
debugName
);
positions = new float[0]; positions = new float[0];
normals = new float[0]; normals = new float[0];
texCoords = new float[0]; texCoords = new float[0];
@@ -352,12 +260,18 @@ public final class GlbParser {
if (animations != null) { if (animations != null) {
for (int ai = 0; ai < animations.size(); ai++) { for (int ai = 0; ai < animations.size(); ai++) {
JsonObject anim = animations.get(ai).getAsJsonObject(); JsonObject anim = animations.get(ai).getAsJsonObject();
String animName = anim.has("name") ? anim.get("name").getAsString() : "animation_" + ai; String animName = anim.has("name")
// Strip the "ArmatureName|" prefix if present (Blender convention) ? anim.get("name").getAsString()
if (animName.contains("|")) { : "animation_" + ai;
animName = animName.substring(animName.lastIndexOf('|') + 1); animName = GlbParserUtils.stripArmaturePrefix(animName);
} GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
GltfData.AnimationClip clip = parseAnimation(anim, accessors, bufferViews, binData, nodeToJoint, jointCount); anim,
accessors,
bufferViews,
binData,
nodeToJoint,
jointCount
);
if (clip != null) { if (clip != null) {
allClips.put(animName, clip); allClips.put(animName, clip);
} }
@@ -365,19 +279,39 @@ public final class GlbParser {
} }
// Default animation = first clip (for backward compat) // Default animation = first clip (for backward compat)
GltfData.AnimationClip animClip = allClips.isEmpty() ? null : allClips.values().iterator().next(); GltfData.AnimationClip animClip = allClips.isEmpty()
? null
: allClips.values().iterator().next();
LOGGER.info("[GltfPipeline] Parsed '{}': vertices={}, indices={}, joints={}, animations={}", LOGGER.info(
debugName, vertexCount, indices.length, jointCount, allClips.size()); "[GltfPipeline] Parsed '{}': vertices={}, indices={}, joints={}, animations={}",
debugName,
vertexCount,
indices.length,
jointCount,
allClips.size()
);
for (String name : allClips.keySet()) { for (String name : allClips.keySet()) {
LOGGER.debug("[GltfPipeline] animation: '{}'", name); LOGGER.debug("[GltfPipeline] animation: '{}'", name);
} }
for (int j = 0; j < jointCount; j++) { for (int j = 0; j < jointCount; j++) {
Quaternionf rq = restRotations[j]; Quaternionf rq = restRotations[j];
Vector3f rt = restTranslations[j]; Vector3f rt = restTranslations[j];
LOGGER.debug(String.format("[GltfPipeline] joint[%d] = '%s', parent=%d, restQ=(%.3f,%.3f,%.3f,%.3f) restT=(%.3f,%.3f,%.3f)", LOGGER.debug(
j, jointNames[j], parentJointIndices[j], String.format(
rq.x, rq.y, rq.z, rq.w, rt.x, rt.y, rt.z)); "[GltfPipeline] joint[%d] = '%s', parent=%d, restQ=(%.3f,%.3f,%.3f,%.3f) restT=(%.3f,%.3f,%.3f)",
j,
jointNames[j],
parentJointIndices[j],
rq.x,
rq.y,
rq.z,
rq.w,
rt.x,
rt.y,
rt.z
)
);
} }
// Log animation translation channels for default clip (BEFORE MC conversion) // Log animation translation channels for default clip (BEFORE MC conversion)
@@ -387,16 +321,28 @@ public final class GlbParser {
if (j < animTrans.length && animTrans[j] != null) { if (j < animTrans.length && animTrans[j] != null) {
Vector3f at = animTrans[j][0]; // first frame Vector3f at = animTrans[j][0]; // first frame
Vector3f rt = restTranslations[j]; Vector3f rt = restTranslations[j];
LOGGER.debug(String.format( LOGGER.debug(
String.format(
"[GltfPipeline] joint[%d] '%s' has ANIM TRANSLATION: (%.4f,%.4f,%.4f) vs rest (%.4f,%.4f,%.4f) delta=(%.4f,%.4f,%.4f)", "[GltfPipeline] joint[%d] '%s' has ANIM TRANSLATION: (%.4f,%.4f,%.4f) vs rest (%.4f,%.4f,%.4f) delta=(%.4f,%.4f,%.4f)",
j, jointNames[j], j,
at.x, at.y, at.z, jointNames[j],
rt.x, rt.y, rt.z, at.x,
at.x - rt.x, at.y - rt.y, at.z - rt.z)); at.y,
at.z,
rt.x,
rt.y,
rt.z,
at.x - rt.x,
at.y - rt.y,
at.z - rt.z
)
);
} }
} }
} else { } else {
LOGGER.debug("[GltfPipeline] Default animation has NO translation channels"); LOGGER.debug(
"[GltfPipeline] Default animation has NO translation channels"
);
} }
// Save raw glTF rotations BEFORE coordinate conversion (for pose converter) // Save raw glTF rotations BEFORE coordinate conversion (for pose converter)
@@ -409,220 +355,56 @@ public final class GlbParser {
// Build raw copies of ALL animation clips (before MC conversion) // Build raw copies of ALL animation clips (before MC conversion)
Map<String, GltfData.AnimationClip> rawAllClips = new LinkedHashMap<>(); Map<String, GltfData.AnimationClip> rawAllClips = new LinkedHashMap<>();
for (Map.Entry<String, GltfData.AnimationClip> entry : allClips.entrySet()) { for (Map.Entry<
rawAllClips.put(entry.getKey(), GlbParserUtils.deepCopyClip(entry.getValue())); String,
GltfData.AnimationClip
> entry : allClips.entrySet()) {
rawAllClips.put(
entry.getKey(),
GlbParserUtils.deepCopyClip(entry.getValue())
);
} }
GltfData.AnimationClip rawAnimClip = rawAllClips.isEmpty() ? null : rawAllClips.values().iterator().next(); GltfData.AnimationClip rawAnimClip = rawAllClips.isEmpty()
? null
: rawAllClips.values().iterator().next();
// Convert from glTF coordinate system (Y-up, faces +Z) to MC (Y-up, faces -Z) // Convert from glTF coordinate system (Y-up, faces +Z) to MC (Y-up, faces -Z).
// This is a 180° rotation around Y: negate X and Z for all spatial data // 180° rotation around Z: negate X and Y for all spatial data.
// Convert ALL animation clips to MC space
for (GltfData.AnimationClip clip : allClips.values()) { for (GltfData.AnimationClip clip : allClips.values()) {
GlbParserUtils.convertAnimationToMinecraftSpace(clip, jointCount); GlbParserUtils.convertAnimationToMinecraftSpace(clip, jointCount);
} }
convertToMinecraftSpace(positions, normals, restTranslations, restRotations, GlbParserUtils.convertMeshToMinecraftSpace(
inverseBindMatrices, null, jointCount); // pass null — clips already converted above positions,
LOGGER.debug("[GltfPipeline] Converted all data to Minecraft coordinate space"); normals,
restTranslations,
restRotations,
inverseBindMatrices
);
LOGGER.debug(
"[GltfPipeline] Converted all data to Minecraft coordinate space"
);
return new GltfData( return new GltfData(
positions, normals, texCoords, positions,
indices, meshJoints, weights, normals,
jointNames, parentJointIndices, texCoords,
indices,
meshJoints,
weights,
jointNames,
parentJointIndices,
inverseBindMatrices, inverseBindMatrices,
restRotations, restTranslations, restRotations,
restTranslations,
rawRestRotations, rawRestRotations,
rawAnimClip, rawAnimClip,
animClip, animClip,
allClips, rawAllClips, allClips,
rawAllClips,
parsedPrimitives, parsedPrimitives,
vertexCount, jointCount vertexCount,
jointCount
); );
} }
// ---- Animation parsing ----
private static GltfData.AnimationClip parseAnimation(
JsonObject animation,
JsonArray accessors, JsonArray bufferViews,
ByteBuffer binData,
int[] nodeToJoint, int jointCount
) {
JsonArray channels = animation.getAsJsonArray("channels");
JsonArray samplers = animation.getAsJsonArray("samplers");
// Collect rotation and translation channels
List<Integer> rotJoints = new ArrayList<>();
List<float[]> rotTimestamps = new ArrayList<>();
List<Quaternionf[]> rotValues = new ArrayList<>();
List<Integer> transJoints = new ArrayList<>();
List<float[]> transTimestamps = new ArrayList<>();
List<Vector3f[]> transValues = new ArrayList<>();
for (JsonElement chElem : channels) {
JsonObject channel = chElem.getAsJsonObject();
JsonObject target = channel.getAsJsonObject("target");
String path = target.get("path").getAsString();
int nodeIdx = target.get("node").getAsInt();
if (nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0) continue;
int jointIdx = nodeToJoint[nodeIdx];
int samplerIdx = channel.get("sampler").getAsInt();
JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject();
float[] times = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
sampler.get("input").getAsInt()
);
if ("rotation".equals(path)) {
float[] quats = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
sampler.get("output").getAsInt()
);
Quaternionf[] qArr = new Quaternionf[times.length];
for (int i = 0; i < times.length; i++) {
qArr[i] = new Quaternionf(
quats[i * 4], quats[i * 4 + 1],
quats[i * 4 + 2], quats[i * 4 + 3]
);
}
rotJoints.add(jointIdx);
rotTimestamps.add(times);
rotValues.add(qArr);
} else if ("translation".equals(path)) {
float[] vecs = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
sampler.get("output").getAsInt()
);
Vector3f[] tArr = new Vector3f[times.length];
for (int i = 0; i < times.length; i++) {
tArr[i] = new Vector3f(
vecs[i * 3], vecs[i * 3 + 1], vecs[i * 3 + 2]
);
}
transJoints.add(jointIdx);
transTimestamps.add(times);
transValues.add(tArr);
}
}
if (rotJoints.isEmpty() && transJoints.isEmpty()) return null;
// Use the first available channel's timestamps as reference
float[] timestamps = !rotTimestamps.isEmpty()
? rotTimestamps.get(0)
: transTimestamps.get(0);
int frameCount = timestamps.length;
// Build per-joint rotation arrays (null if no animation for that joint)
Quaternionf[][] rotations = new Quaternionf[jointCount][];
for (int i = 0; i < rotJoints.size(); i++) {
int jIdx = rotJoints.get(i);
Quaternionf[] vals = rotValues.get(i);
rotations[jIdx] = new Quaternionf[frameCount];
for (int f = 0; f < frameCount; f++) {
rotations[jIdx][f] = f < vals.length ? vals[f] : vals[vals.length - 1];
}
}
// Build per-joint translation arrays (null if no animation for that joint)
Vector3f[][] translations = new Vector3f[jointCount][];
for (int i = 0; i < transJoints.size(); i++) {
int jIdx = transJoints.get(i);
Vector3f[] vals = transValues.get(i);
translations[jIdx] = new Vector3f[frameCount];
for (int f = 0; f < frameCount; f++) {
translations[jIdx][f] = f < vals.length
? new Vector3f(vals[f])
: new Vector3f(vals[vals.length - 1]);
}
}
// Log translation channels found
if (!transJoints.isEmpty()) {
LOGGER.debug("[GltfPipeline] Animation has {} translation channel(s)",
transJoints.size());
}
return new GltfData.AnimationClip(timestamps, rotations, translations, frameCount);
}
// ---- Coordinate system conversion ----
/**
* Convert all spatial data from glTF space to MC model-def space.
* The Blender-exported character faces -Z in glTF, same as MC model-def.
* Only X (right→left) and Y (up→down) differ between the two spaces.
* Equivalent to a 180° rotation around Z: negate X and Y components.
*
* For positions/normals/translations: (x,y,z) → (-x, -y, z)
* For quaternions: (x,y,z,w) → (-x, -y, z, w) (conjugation by 180° Z)
* For matrices: M → C * M * C where C = diag(-1, -1, 1, 1)
*/
private static void convertToMinecraftSpace(
float[] positions, float[] normals,
Vector3f[] restTranslations, Quaternionf[] restRotations,
Matrix4f[] inverseBindMatrices,
GltfData.AnimationClip animClip, int jointCount
) {
// Vertex positions: negate X and Y
for (int i = 0; i < positions.length; i += 3) {
positions[i] = -positions[i]; // X
positions[i + 1] = -positions[i + 1]; // Y
}
// Vertex normals: negate X and Y
for (int i = 0; i < normals.length; i += 3) {
normals[i] = -normals[i];
normals[i + 1] = -normals[i + 1];
}
// Rest translations: negate X and Y
for (Vector3f t : restTranslations) {
t.x = -t.x;
t.y = -t.y;
}
// Rest rotations: conjugate by 180° Z = negate qx and qy
for (Quaternionf q : restRotations) {
q.x = -q.x;
q.y = -q.y;
}
// Inverse bind matrices: C * M * C where C = diag(-1, -1, 1)
Matrix4f C = new Matrix4f().scaling(-1, -1, 1);
Matrix4f temp = new Matrix4f();
for (Matrix4f ibm : inverseBindMatrices) {
temp.set(C).mul(ibm).mul(C);
ibm.set(temp);
}
// Animation quaternions: same conjugation
if (animClip != null) {
Quaternionf[][] rotations = animClip.rotations();
for (int j = 0; j < jointCount; j++) {
if (j < rotations.length && rotations[j] != null) {
for (Quaternionf q : rotations[j]) {
q.x = -q.x;
q.y = -q.y;
}
}
}
// Animation translations: negate X and Y (same as rest translations)
Vector3f[][] translations = animClip.translations();
if (translations != null) {
for (int j = 0; j < jointCount; j++) {
if (j < translations.length && translations[j] != null) {
for (Vector3f t : translations[j]) {
t.x = -t.x;
t.y = -t.y;
}
}
}
}
}
}
} }

View File

@@ -6,22 +6,23 @@ import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
import com.tiedup.remake.client.animation.context.GlbAnimationResolver; import com.tiedup.remake.client.animation.context.GlbAnimationResolver;
import com.tiedup.remake.client.animation.context.RegionBoneMapper; import com.tiedup.remake.client.animation.context.RegionBoneMapper;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation; import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.TreeSet; import java.util.TreeSet;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.LivingEntity;
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 org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.Nullable;
/** /**
* V2 Animation Applier -- manages dual-layer animation for V2 bondage items. * V2 Animation Applier -- manages dual-layer animation for V2 bondage items.
@@ -55,26 +56,51 @@ public final class GltfAnimationApplier {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
/** /**
* Cache of converted item-layer KeyframeAnimations. * Cache of converted item-layer KeyframeAnimations, keyed by
* Keyed by "animSource#context#ownedPartsHash". * {@code animSource#context#ownedPartsHash}. LRU-bounded via
* Same GLB + same context + same owned parts = same KeyframeAnimation. * access-ordered {@link LinkedHashMap}: size capped at
* {@link #ITEM_ANIM_CACHE_MAX}, head (least-recently-used) evicted on
* overflow. Wrapped in {@link Collections#synchronizedMap} because
* {@code LinkedHashMap.get} mutates the iteration order. External
* iteration is not supported; use only {@code get}/{@code put}/{@code clear}.
*/ */
private static final Map<String, KeyframeAnimation> itemAnimCache = new ConcurrentHashMap<>(); private static final int ITEM_ANIM_CACHE_MAX = 256;
// Initial capacity (int)(cap / loadFactor) + 1 so the cap is reached
// without a rehash.
private static final int ITEM_ANIM_CACHE_INITIAL_CAPACITY =
(int) (ITEM_ANIM_CACHE_MAX / 0.75f) + 1;
private static final Map<String, KeyframeAnimation> itemAnimCache =
Collections.synchronizedMap(
new LinkedHashMap<String, KeyframeAnimation>(
ITEM_ANIM_CACHE_INITIAL_CAPACITY,
0.75f,
true // access-order
) {
@Override
protected boolean removeEldestEntry(
Map.Entry<String, KeyframeAnimation> eldest
) {
return size() > ITEM_ANIM_CACHE_MAX;
}
}
);
/** /**
* Track which composite state is currently active per entity, to avoid redundant replays. * Track which composite state is currently active per entity, to avoid redundant replays.
* Keyed by entity UUID, value is "animSource|context|sortedParts". * Keyed by entity UUID, value is "animSource|context|sortedParts".
*/ */
private static final Map<UUID, String> activeStateKeys = new ConcurrentHashMap<>(); private static final Map<UUID, String> activeStateKeys =
new ConcurrentHashMap<>();
/** Track cache keys where GLB loading failed, to avoid per-tick retries. */ /** Track cache keys where GLB loading failed, to avoid per-tick retries. */
private static final Set<String> failedLoadKeys = ConcurrentHashMap.newKeySet(); private static final Set<String> failedLoadKeys =
ConcurrentHashMap.newKeySet();
private GltfAnimationApplier() {} private GltfAnimationApplier() {}
// ========================================
// INIT (legacy) // INIT (legacy)
// ========================================
/** /**
* Legacy init method -- called by GltfClientSetup. * Legacy init method -- called by GltfClientSetup.
@@ -84,9 +110,7 @@ public final class GltfAnimationApplier {
// No-op: animation layers are managed by BondageAnimationManager // No-op: animation layers are managed by BondageAnimationManager
} }
// ========================================
// V2 DUAL-LAYER API // V2 DUAL-LAYER API
// ========================================
/** /**
* Apply the full V2 animation state: context layer + item layer. * Apply the full V2 animation state: context layer + item layer.
@@ -113,29 +137,38 @@ public final class GltfAnimationApplier {
* @param ownership bone ownership: which parts this item owns vs other items * @param ownership bone ownership: which parts this item owns vs other items
* @return true if the item layer animation was applied successfully * @return true if the item layer animation was applied successfully
*/ */
public static boolean applyV2Animation(LivingEntity entity, ResourceLocation modelLoc, public static boolean applyV2Animation(
LivingEntity entity,
ResourceLocation modelLoc,
@Nullable ResourceLocation animationSource, @Nullable ResourceLocation animationSource,
AnimationContext context, RegionBoneMapper.BoneOwnership ownership) { AnimationContext context,
RegionBoneMapper.BoneOwnership ownership
) {
if (entity == null || modelLoc == null) return false; if (entity == null || modelLoc == null) return false;
ResourceLocation animSource = animationSource != null ? animationSource : modelLoc; ResourceLocation animSource =
animationSource != null ? animationSource : modelLoc;
// Cache key includes both owned and enabled parts for full disambiguation // Cache key includes both owned and enabled parts for full disambiguation
String ownedKey = canonicalPartsKey(ownership.thisParts()); String ownedKey = canonicalPartsKey(ownership.thisParts());
String enabledKey = canonicalPartsKey(ownership.enabledParts()); String enabledKey = canonicalPartsKey(ownership.enabledParts());
String partsKey = ownedKey + ";" + enabledKey; String partsKey = ownedKey + ";" + enabledKey;
// Build composite state key to avoid redundant updates // Build composite state key to detect context changes.
// NOTE: This key does NOT include the variant name — that is resolved fresh
// each time the context changes, enabling random variant selection.
String stateKey = animSource + "|" + context.name() + "|" + partsKey; String stateKey = animSource + "|" + context.name() + "|" + partsKey;
String currentKey = activeStateKeys.get(entity.getUUID()); String currentKey = activeStateKeys.get(entity.getUUID());
if (stateKey.equals(currentKey)) { if (stateKey.equals(currentKey)) {
return true; // Already active, no-op return true; // Same context, same parts — no need to re-resolve
} }
// === Layer 1: Context animation (base body posture) === // === Layer 1: Context animation (base body posture) ===
// Parts owned by ANY item (this or others) are disabled on the context layer. // Parts owned by ANY item (this or others) are disabled on the context layer.
// Only free parts remain enabled on context. // Only free parts remain enabled on context.
KeyframeAnimation contextAnim = ContextAnimationFactory.create( KeyframeAnimation contextAnim = ContextAnimationFactory.create(
context, ownership.disabledOnContext()); context,
ownership.disabledOnContext()
);
if (contextAnim != null) { if (contextAnim != null) {
BondageAnimationManager.playContext(entity, contextAnim); BondageAnimationManager.playContext(entity, contextAnim);
} }
@@ -149,21 +182,50 @@ public final class GltfAnimationApplier {
return false; return false;
} }
KeyframeAnimation itemAnim = itemAnimCache.get(itemCacheKey); // Resolve animation data first (needed for variant resolution)
if (itemAnim == null) { GltfData animData = GlbAnimationResolver.resolveAnimationData(
GltfData animData = GlbAnimationResolver.resolveAnimationData(modelLoc, animationSource); modelLoc,
animationSource
);
if (animData == null) { if (animData == null) {
LOGGER.warn("[GltfPipeline] Failed to load animation GLB: {}", animSource); LOGGER.warn(
"[GltfPipeline] Failed to load animation GLB: {}",
animSource
);
failedLoadKeys.add(itemCacheKey); failedLoadKeys.add(itemCacheKey);
activeStateKeys.put(entity.getUUID(), stateKey); activeStateKeys.put(entity.getUUID(), stateKey);
return false; return false;
} }
// Resolve which named animation to use (with fallback chain + variant selection)
String glbAnimName = GlbAnimationResolver.resolve(animData, context); // Resolve which named animation to use (with fallback chain + variant selection).
// Pass both owned parts and enabled parts (owned + free) for selective enabling // This must happen BEFORE the cache lookup because variant selection is random —
// we want a fresh random pick each time the context changes, not a permanently
// cached first pick.
String glbAnimName = GlbAnimationResolver.resolve(
animData,
context
);
// Include the resolved animation name in the cache key so different variants
// (Struggle.1 vs Struggle.2) get separate cache entries.
String variantCacheKey = itemCacheKey + "#" + (glbAnimName != null ? glbAnimName : "default");
// Atomic get-or-compute under the map's monitor. Collections
// .synchronizedMap only synchronizes individual get/put calls, so a
// naive check-then-put races between concurrent converters and can
// both double-convert and trip removeEldestEntry with a stale size.
KeyframeAnimation itemAnim;
synchronized (itemAnimCache) {
itemAnim = itemAnimCache.get(variantCacheKey);
if (itemAnim == null) {
itemAnim = GltfPoseConverter.convertSelective( itemAnim = GltfPoseConverter.convertSelective(
animData, glbAnimName, ownership.thisParts(), ownership.enabledParts()); animData,
itemAnimCache.put(itemCacheKey, itemAnim); glbAnimName,
ownership.thisParts(),
ownership.enabledParts()
);
itemAnimCache.put(variantCacheKey, itemAnim);
}
} }
BondageAnimationManager.playDirect(entity, itemAnim); BondageAnimationManager.playDirect(entity, itemAnim);
@@ -185,16 +247,24 @@ public final class GltfAnimationApplier {
* @param allOwnedParts union of all owned parts across all items * @param allOwnedParts union of all owned parts across all items
* @return true if the composite animation was applied * @return true if the composite animation was applied
*/ */
public static boolean applyMultiItemV2Animation(LivingEntity entity, public static boolean applyMultiItemV2Animation(
LivingEntity entity,
List<RegionBoneMapper.V2ItemAnimInfo> items, List<RegionBoneMapper.V2ItemAnimInfo> items,
AnimationContext context, Set<String> allOwnedParts) { AnimationContext context,
Set<String> allOwnedParts
) {
if (entity == null || items.isEmpty()) return false; if (entity == null || items.isEmpty()) return false;
// Build composite state key // Build composite state key
StringBuilder keyBuilder = new StringBuilder(); StringBuilder keyBuilder = new StringBuilder();
for (RegionBoneMapper.V2ItemAnimInfo item : items) { for (RegionBoneMapper.V2ItemAnimInfo item : items) {
ResourceLocation src = item.animSource() != null ? item.animSource() : item.modelLoc(); ResourceLocation src =
keyBuilder.append(src).append(':').append(canonicalPartsKey(item.ownedParts())).append(';'); item.animSource() != null ? item.animSource() : item.modelLoc();
keyBuilder
.append(src)
.append(':')
.append(canonicalPartsKey(item.ownedParts()))
.append(';');
} }
keyBuilder.append(context.name()); keyBuilder.append(context.name());
String stateKey = keyBuilder.toString(); String stateKey = keyBuilder.toString();
@@ -205,44 +275,86 @@ public final class GltfAnimationApplier {
} }
// === Layer 1: Context animation === // === Layer 1: Context animation ===
KeyframeAnimation contextAnim = ContextAnimationFactory.create(context, allOwnedParts); KeyframeAnimation contextAnim = ContextAnimationFactory.create(
context,
allOwnedParts
);
if (contextAnim != null) { if (contextAnim != null) {
BondageAnimationManager.playContext(entity, contextAnim); BondageAnimationManager.playContext(entity, contextAnim);
} }
// === Layer 2: Composite item animation === // === Layer 2: Composite item animation ===
String compositeCacheKey = "multi#" + stateKey; // Pre-resolve animation data and variant names for all items BEFORE cache lookup.
// This ensures random variant selection happens fresh on each context change,
// and each variant combination gets its own cache entry.
record ResolvedItem(
GltfData animData,
String glbAnimName,
RegionBoneMapper.V2ItemAnimInfo info
) {}
List<ResolvedItem> resolvedItems = new ArrayList<>();
StringBuilder variantKeyBuilder = new StringBuilder("multi#").append(stateKey);
for (RegionBoneMapper.V2ItemAnimInfo item : items) {
ResourceLocation animSource =
item.animSource() != null ? item.animSource() : item.modelLoc();
GltfData animData = GlbAnimationResolver.resolveAnimationData(
item.modelLoc(), item.animSource()
);
if (animData == null) {
LOGGER.warn(
"[GltfPipeline] Failed to load GLB for multi-item: {}",
animSource
);
continue;
}
String glbAnimName = GlbAnimationResolver.resolve(animData, context);
resolvedItems.add(new ResolvedItem(animData, glbAnimName, item));
variantKeyBuilder.append('#')
.append(glbAnimName != null ? glbAnimName : "default");
}
String compositeCacheKey = variantKeyBuilder.toString();
if (failedLoadKeys.contains(compositeCacheKey)) { if (failedLoadKeys.contains(compositeCacheKey)) {
activeStateKeys.put(entity.getUUID(), stateKey); activeStateKeys.put(entity.getUUID(), stateKey);
return false; return false;
} }
KeyframeAnimation compositeAnim = itemAnimCache.get(compositeCacheKey); // Atomic get-or-compute under the map's monitor (see
// applyV2Animation). All current callers are render-thread so no
// contention in practice, but the synchronized wrap closes the
// window where two converters could race and clobber each other.
KeyframeAnimation compositeAnim;
synchronized (itemAnimCache) {
compositeAnim = itemAnimCache.get(compositeCacheKey);
}
if (compositeAnim == null) { if (compositeAnim == null) {
KeyframeAnimation.AnimationBuilder builder = KeyframeAnimation.AnimationBuilder builder =
new KeyframeAnimation.AnimationBuilder( new KeyframeAnimation.AnimationBuilder(
dev.kosmx.playerAnim.core.data.AnimationFormat.JSON_EMOTECRAFT); dev.kosmx.playerAnim.core.data.AnimationFormat.JSON_EMOTECRAFT
);
builder.beginTick = 0; builder.beginTick = 0;
builder.endTick = 1;
builder.stopTick = 1;
builder.isLooped = true; builder.isLooped = true;
builder.returnTick = 0; builder.returnTick = 0;
builder.name = "gltf_composite"; builder.name = "gltf_composite";
boolean anyLoaded = false; boolean anyLoaded = false;
int maxEndTick = 1;
Set<String> unionKeyframeParts = new HashSet<>();
boolean anyFullBody = false;
boolean anyFullHead = false;
for (int i = 0; i < items.size(); i++) { for (ResolvedItem resolved : resolvedItems) {
RegionBoneMapper.V2ItemAnimInfo item = items.get(i); RegionBoneMapper.V2ItemAnimInfo item = resolved.info();
ResourceLocation animSource = item.animSource() != null ? item.animSource() : item.modelLoc(); GltfData animData = resolved.animData();
String glbAnimName = resolved.glbAnimName();
ResourceLocation animSource =
item.animSource() != null ? item.animSource() : item.modelLoc();
GltfData animData = GlbAnimationResolver.resolveAnimationData(item.modelLoc(), item.animSource());
if (animData == null) {
LOGGER.warn("[GltfPipeline] Failed to load GLB for multi-item: {}", animSource);
continue;
}
String glbAnimName = GlbAnimationResolver.resolve(animData, context);
GltfData.AnimationClip rawClip; GltfData.AnimationClip rawClip;
if (glbAnimName != null) { if (glbAnimName != null) {
rawClip = animData.getRawAnimation(glbAnimName); rawClip = animData.getRawAnimation(glbAnimName);
@@ -267,12 +379,34 @@ public final class GltfAnimationApplier {
} }
} }
Set<String> itemKeyframeParts =
GltfPoseConverter.addBonesToBuilder( GltfPoseConverter.addBonesToBuilder(
builder, animData, rawClip, effectiveParts); builder, animData, rawClip, effectiveParts
);
unionKeyframeParts.addAll(itemKeyframeParts);
maxEndTick = Math.max(
maxEndTick,
GltfPoseConverter.computeEndTick(rawClip)
);
// FullX / FullHeadX opt-in: ANY item requesting it lifts the
// restriction for the composite. The animation name passed to
// the core helper uses the same "gltf_" prefix convention as
// the single-item path.
String prefixed = glbAnimName != null
? "gltf_" + glbAnimName
: null;
if (GltfPoseConverter.isFullBodyAnimName(prefixed)) {
anyFullBody = true;
}
if (GltfPoseConverter.isFullHeadAnimName(prefixed)) {
anyFullHead = true;
}
anyLoaded = true; anyLoaded = true;
LOGGER.debug("[GltfPipeline] Multi-item: {} -> owned={}, effective={}, anim={}", LOGGER.debug(
animSource, item.ownedParts(), effectiveParts, glbAnimName); "[GltfPipeline] Multi-item: {} -> owned={}, effective={}, anim={}",
animSource, item.ownedParts(), effectiveParts, glbAnimName
);
} }
if (!anyLoaded) { if (!anyLoaded) {
@@ -281,33 +415,40 @@ public final class GltfAnimationApplier {
return false; return false;
} }
// Enable only owned parts on the item layer. builder.endTick = maxEndTick;
// Free parts (head, body, etc. not owned by any item) are disabled here builder.stopTick = maxEndTick;
// so they pass through to the context layer / vanilla animation.
String[] allPartNames = {"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"}; // Selective-part enabling for the composite. Owned parts always on;
for (String partName : allPartNames) { // free parts (including head) opt-in only if ANY item declares a
KeyframeAnimation.StateCollection part = getPartByName(builder, partName); // FullX / FullHeadX animation AND has keyframes for that part.
if (part != null) { GltfPoseConverter.enableSelectivePartsComposite(
if (allOwnedParts.contains(partName)) { builder,
part.fullyEnablePart(false); allOwnedParts,
} else { unionKeyframeParts,
part.setEnabled(false); anyFullBody,
} anyFullHead
} );
}
compositeAnim = builder.build(); compositeAnim = builder.build();
synchronized (itemAnimCache) {
// Another thread may have computed the same key while we were
// building. Prefer its result to keep one instance per key,
// matching removeEldestEntry's accounting.
KeyframeAnimation winner = itemAnimCache.get(compositeCacheKey);
if (winner != null) {
compositeAnim = winner;
} else {
itemAnimCache.put(compositeCacheKey, compositeAnim); itemAnimCache.put(compositeCacheKey, compositeAnim);
} }
}
}
BondageAnimationManager.playDirect(entity, compositeAnim); BondageAnimationManager.playDirect(entity, compositeAnim);
activeStateKeys.put(entity.getUUID(), stateKey); activeStateKeys.put(entity.getUUID(), stateKey);
return true; return true;
} }
// ========================================
// CLEAR / QUERY // CLEAR / QUERY
// ========================================
/** /**
* Clear all V2 animation layers from an entity and remove tracking. * Clear all V2 animation layers from an entity and remove tracking.
@@ -342,9 +483,7 @@ public final class GltfAnimationApplier {
activeStateKeys.remove(entityId); activeStateKeys.remove(entityId);
} }
// ========================================
// CACHE MANAGEMENT // CACHE MANAGEMENT
// ========================================
/** /**
* Invalidate all cached item animations and tracking state. * Invalidate all cached item animations and tracking state.
@@ -373,54 +512,17 @@ public final class GltfAnimationApplier {
ContextAnimationFactory.clearCache(); ContextAnimationFactory.clearCache();
} }
// ========================================
// LEGACY F9 DEBUG TOGGLE
// ========================================
private static boolean debugEnabled = false;
/**
* Toggle debug mode via F9 key.
* When enabled, applies handcuffs V2 animation (rightArm + leftArm) to the local player
* using STAND_IDLE context. When disabled, clears all V2 animation.
*/
public static void toggle() {
debugEnabled = !debugEnabled;
LOGGER.info("[GltfPipeline] Debug toggle: {}", debugEnabled ? "ON" : "OFF");
AbstractClientPlayer player = Minecraft.getInstance().player;
if (player == null) return;
if (debugEnabled) {
ResourceLocation modelLoc = ResourceLocation.fromNamespaceAndPath(
"tiedup", "models/gltf/v2/handcuffs/cuffs_prototype.glb"
);
Set<String> armParts = Set.of("rightArm", "leftArm");
RegionBoneMapper.BoneOwnership debugOwnership =
new RegionBoneMapper.BoneOwnership(armParts, Set.of());
applyV2Animation(player, modelLoc, null, AnimationContext.STAND_IDLE, debugOwnership);
} else {
clearV2Animation(player);
}
}
/**
* Whether F9 debug mode is currently enabled.
*/
public static boolean isEnabled() {
return debugEnabled;
}
// ========================================
// INTERNAL // INTERNAL
// ========================================
/** /**
* Build cache key for item-layer animations. * Build cache key for item-layer animations.
* Format: "animSource#contextName#sortedParts" * Format: "animSource#contextName#sortedParts"
*/ */
private static String buildItemCacheKey(ResourceLocation animSource, private static String buildItemCacheKey(
AnimationContext context, String partsKey) { ResourceLocation animSource,
AnimationContext context,
String partsKey
) {
return animSource + "#" + context.name() + "#" + partsKey; return animSource + "#" + context.name() + "#" + partsKey;
} }
@@ -432,19 +534,4 @@ public final class GltfAnimationApplier {
return String.join(",", new TreeSet<>(ownedParts)); return String.join(",", new TreeSet<>(ownedParts));
} }
/**
* Look up an {@link KeyframeAnimation.StateCollection} by part name on a builder.
*/
private static KeyframeAnimation.StateCollection getPartByName(
KeyframeAnimation.AnimationBuilder builder, String name) {
return switch (name) {
case "head" -> builder.head;
case "body" -> builder.body;
case "rightArm" -> builder.rightArm;
case "leftArm" -> builder.leftArm;
case "rightLeg" -> builder.rightLeg;
case "leftLeg" -> builder.leftLeg;
default -> null;
};
}
} }

View File

@@ -7,6 +7,7 @@ import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.model.geom.ModelPart; import net.minecraft.client.model.geom.ModelPart;
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 org.jetbrains.annotations.Nullable;
/** /**
* Maps glTF bone names to Minecraft HumanoidModel parts. * Maps glTF bone names to Minecraft HumanoidModel parts.
@@ -20,16 +21,22 @@ public final class GltfBoneMapper {
/** Lower bones that represent bend (elbow/knee) */ /** Lower bones that represent bend (elbow/knee) */
private static final Set<String> LOWER_BONES = Set.of( private static final Set<String> LOWER_BONES = Set.of(
"leftLowerArm", "rightLowerArm", "leftLowerArm",
"leftLowerLeg", "rightLowerLeg" "rightLowerArm",
"leftLowerLeg",
"rightLowerLeg"
); );
/** Maps lower bone name -> corresponding upper bone name */ /** Maps lower bone name -> corresponding upper bone name */
private static final Map<String, String> LOWER_TO_UPPER = Map.of( private static final Map<String, String> LOWER_TO_UPPER = Map.of(
"leftLowerArm", "leftUpperArm", "leftLowerArm",
"rightLowerArm", "rightUpperArm", "leftUpperArm",
"leftLowerLeg", "leftUpperLeg", "rightLowerArm",
"rightLowerLeg", "rightUpperLeg" "rightUpperArm",
"leftLowerLeg",
"leftUpperLeg",
"rightLowerLeg",
"rightUpperLeg"
); );
static { static {
@@ -55,7 +62,10 @@ public final class GltfBoneMapper {
* @param boneName glTF bone name * @param boneName glTF bone name
* @return the ModelPart, or null if not mapped * @return the ModelPart, or null if not mapped
*/ */
public static ModelPart getModelPart(HumanoidModel<?> model, String boneName) { public static ModelPart getModelPart(
HumanoidModel<?> model,
String boneName
) {
String partName = BONE_TO_PART.get(boneName); String partName = BONE_TO_PART.get(boneName);
if (partName == null) return null; if (partName == null) return null;
@@ -101,4 +111,25 @@ public final class GltfBoneMapper {
public static boolean isKnownBone(String boneName) { public static boolean isKnownBone(String boneName) {
return BONE_TO_PART.containsKey(boneName); return BONE_TO_PART.containsKey(boneName);
} }
/**
* Get all known bone names for validation/suggestion purposes.
*/
public static Set<String> knownBoneNames() {
return BONE_TO_PART.keySet();
}
/**
* Suggest a known bone name for a case-insensitive match.
* Returns null if no case-insensitive match is found.
*/
@Nullable
public static String suggestBoneName(String unknownBone) {
for (String known : BONE_TO_PART.keySet()) {
if (known.equalsIgnoreCase(unknownBone)) {
return known;
}
}
return null;
}
} }

View File

@@ -2,6 +2,7 @@ package com.tiedup.remake.client.gltf;
import java.io.InputStream; import java.io.InputStream;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
@@ -13,26 +14,36 @@ import org.apache.logging.log4j.Logger;
/** /**
* Lazy-loading cache for parsed glTF data. * Lazy-loading cache for parsed glTF data.
* Loads .glb files via Minecraft's ResourceManager on first access. *
* <p>Loads .glb files via Minecraft's ResourceManager on first access.
* Cache values are {@link Optional}: empty means a previous load attempt
* failed and no retry will happen until {@link #clearCache()} is called.
* This mirrors {@link com.tiedup.remake.v2.furniture.client.FurnitureGltfCache
* FurnitureGltfCache}'s pattern so both caches have consistent semantics.</p>
*
* <p>Load is atomic via {@link Map#computeIfAbsent}: two concurrent first-misses
* for the same resource will parse the GLB exactly once.</p>
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public final class GltfCache { public final class GltfCache {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final Map<ResourceLocation, GltfData> CACHE = new ConcurrentHashMap<>(); private static final Map<ResourceLocation, Optional<GltfData>> CACHE =
new ConcurrentHashMap<>();
private GltfCache() {} private GltfCache() {}
/** /**
* Get parsed glTF data for a resource, loading it on first access. * Get parsed glTF data for a resource, loading it on first access.
* *
* @param location resource location of the .glb file (e.g. "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb") * @param location resource location of the .glb file
* @return parsed GltfData, or null if loading failed * @return parsed GltfData, or null if loading failed
*/ */
public static GltfData get(ResourceLocation location) { public static GltfData get(ResourceLocation location) {
GltfData cached = CACHE.get(location); return CACHE.computeIfAbsent(location, GltfCache::load).orElse(null);
if (cached != null) return cached; }
private static Optional<GltfData> load(ResourceLocation location) {
try { try {
Resource resource = Minecraft.getInstance() Resource resource = Minecraft.getInstance()
.getResourceManager() .getResourceManager()
@@ -40,17 +51,15 @@ public final class GltfCache {
.orElse(null); .orElse(null);
if (resource == null) { if (resource == null) {
LOGGER.error("[GltfPipeline] Resource not found: {}", location); LOGGER.error("[GltfPipeline] Resource not found: {}", location);
return null; return Optional.empty();
} }
try (InputStream is = resource.open()) { try (InputStream is = resource.open()) {
GltfData data = GlbParser.parse(is, location.toString()); GltfData data = GlbParser.parse(is, location.toString());
CACHE.put(location, data); return Optional.of(data);
return data;
} }
} catch (Exception e) { } catch (Exception e) {
LOGGER.error("[GltfPipeline] Failed to load GLB: {}", location, e); LOGGER.error("[GltfPipeline] Failed to load GLB: {}", location, e);
return null; return Optional.empty();
} }
} }

View File

@@ -1,47 +1,30 @@
package com.tiedup.remake.client.gltf; package com.tiedup.remake.client.gltf;
import com.mojang.blaze3d.platform.InputConstants;
import com.tiedup.remake.client.animation.context.ContextAnimationFactory; import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
import com.tiedup.remake.client.animation.context.ContextGlbRegistry; import com.tiedup.remake.client.animation.context.ContextGlbRegistry;
import com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemReloadListener;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.renderer.entity.player.PlayerRenderer;
import net.minecraft.server.packs.resources.ResourceManager; import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
import net.minecraft.util.profiling.ProfilerFiller; import net.minecraft.util.profiling.ProfilerFiller;
import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.EntityRenderersEvent;
import net.minecraftforge.client.event.RegisterClientReloadListenersEvent; import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
import net.minecraftforge.client.event.RegisterKeyMappingsEvent; import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent; import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
/** /**
* Forge event registration for the glTF pipeline. * MOD-bus setup for the generic glTF pipeline — init + cache-invalidation
* Registers keybind (F9), render layers, and animation factory. * reload listener. Bondage-specific registrations (render layer, item-aware
* reload listeners) live in {@code V2ClientSetup}.
*/ */
public final class GltfClientSetup { public final class GltfClientSetup {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final String KEY_CATEGORY = "key.categories.tiedup";
static final KeyMapping TOGGLE_KEY = new KeyMapping(
"key.tiedup.gltf_toggle",
InputConstants.Type.KEYSYM,
InputConstants.KEY_F9,
KEY_CATEGORY
);
private GltfClientSetup() {} private GltfClientSetup() {}
/**
* MOD bus event subscribers (FMLClientSetupEvent, RegisterKeyMappings, AddLayers).
*/
@Mod.EventBusSubscriber( @Mod.EventBusSubscriber(
modid = "tiedup", modid = "tiedup",
bus = Mod.EventBusSubscriber.Bus.MOD, bus = Mod.EventBusSubscriber.Bus.MOD,
@@ -58,69 +41,63 @@ public final class GltfClientSetup {
}); });
} }
@SubscribeEvent
public static void onRegisterKeybindings(RegisterKeyMappingsEvent event) {
event.register(TOGGLE_KEY);
LOGGER.info("[GltfPipeline] Keybind registered: F9");
}
@SuppressWarnings("unchecked")
@SubscribeEvent
public static void onAddLayers(EntityRenderersEvent.AddLayers event) {
// Add GltfRenderLayer (prototype/debug with F9 toggle) to player renderers
var defaultRenderer = event.getSkin("default");
if (defaultRenderer instanceof PlayerRenderer playerRenderer) {
playerRenderer.addLayer(new GltfRenderLayer(playerRenderer));
playerRenderer.addLayer(new V2BondageRenderLayer<>(playerRenderer));
LOGGER.info("[GltfPipeline] Render layers added to 'default' player renderer");
}
// Add both layers to slim player renderer (Alex)
var slimRenderer = event.getSkin("slim");
if (slimRenderer instanceof PlayerRenderer playerRenderer) {
playerRenderer.addLayer(new GltfRenderLayer(playerRenderer));
playerRenderer.addLayer(new V2BondageRenderLayer<>(playerRenderer));
LOGGER.info("[GltfPipeline] Render layers added to 'slim' player renderer");
}
}
/** /**
* Register resource reload listener to clear GLB caches on resource pack reload. * Register the generic GLB cache-clear reload listener.
* This ensures re-exported GLB models are picked up without restarting the game. *
* <p>{@code HIGH} priority so it fires before any downstream
* listener that consumes GLB state. Within this single {@code apply}
* block, the sub-order matters:</p>
* <ol>
* <li>Drop the three mutually-independent byte caches
* ({@code GltfCache}, {@code GltfAnimationApplier.invalidateCache},
* {@code GltfMeshRenderer.clearRenderTypeCache}).</li>
* <li>Reload {@code ContextGlbRegistry} before clearing
* {@code ContextAnimationFactory.clearCache()} — otherwise
* the next factory lookup rebuilds clips against the stale
* registry and caches the wrong data.</li>
* <li>Clear {@code FurnitureGltfCache} last.</li>
* </ol>
*
* <p>Bondage-specific listeners register at {@code LOW} priority from
* {@code V2ClientSetup.onRegisterReloadListeners}, so the cache-clear
* here is guaranteed to land first.</p>
*/ */
@SubscribeEvent @SubscribeEvent(priority = EventPriority.HIGH)
public static void onRegisterReloadListeners(RegisterClientReloadListenersEvent event) { public static void onRegisterReloadListeners(
event.registerReloadListener(new SimplePreparableReloadListener<Void>() { RegisterClientReloadListenersEvent event
) {
event.registerReloadListener(
new SimplePreparableReloadListener<Void>() {
@Override @Override
protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) { protected Void prepare(
ResourceManager resourceManager,
ProfilerFiller profiler
) {
return null; return null;
} }
@Override @Override
protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) { protected void apply(
Void nothing,
ResourceManager resourceManager,
ProfilerFiller profiler
) {
GltfCache.clearCache(); GltfCache.clearCache();
GltfAnimationApplier.invalidateCache(); GltfAnimationApplier.invalidateCache();
GltfMeshRenderer.clearRenderTypeCache(); GltfMeshRenderer.clearRenderTypeCache();
// Reload context GLB animations from resource packs FIRST,
// then clear the factory cache so it rebuilds against the
// new GLB registry (prevents stale JSON fallback caching).
ContextGlbRegistry.reload(resourceManager); ContextGlbRegistry.reload(resourceManager);
ContextAnimationFactory.clearCache(); ContextAnimationFactory.clearCache();
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear(); com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear();
LOGGER.info("[GltfPipeline] Caches cleared on resource reload"); LOGGER.info(
"[GltfPipeline] Caches cleared on resource reload"
);
} }
}); }
LOGGER.info("[GltfPipeline] Resource reload listener registered"); );
// Data-driven bondage item definitions (tiedup_items/*.json)
event.registerReloadListener(new DataDrivenItemReloadListener());
LOGGER.info("[GltfPipeline] Data-driven item reload listener registered");
} }
} }
/** /** FORGE bus event subscribers (client-side commands). */
* FORGE bus event subscribers (ClientTickEvent for keybind toggle).
*/
@Mod.EventBusSubscriber( @Mod.EventBusSubscriber(
modid = "tiedup", modid = "tiedup",
bus = Mod.EventBusSubscriber.Bus.FORGE, bus = Mod.EventBusSubscriber.Bus.FORGE,
@@ -129,12 +106,15 @@ public final class GltfClientSetup {
public static class ForgeBusEvents { public static class ForgeBusEvents {
@SubscribeEvent @SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) { public static void onRegisterClientCommands(
if (event.phase != TickEvent.Phase.END) return; net.minecraftforge.client.event.RegisterClientCommandsEvent event
) {
while (TOGGLE_KEY.consumeClick()) { com.tiedup.remake.commands.ValidateGlbCommand.register(
GltfAnimationApplier.toggle(); event.getDispatcher()
} );
LOGGER.info(
"[GltfPipeline] Client command /tiedup validate registered"
);
} }
} }
} }

View File

@@ -39,6 +39,7 @@ public final class GltfData {
// -- Raw glTF rotations (unconverted, for pose conversion) -- // -- Raw glTF rotations (unconverted, for pose conversion) --
private final Quaternionf[] rawGltfRestRotations; private final Quaternionf[] rawGltfRestRotations;
@Nullable @Nullable
private final AnimationClip rawGltfAnimation; private final AnimationClip rawGltfAnimation;
@@ -61,18 +62,25 @@ public final class GltfData {
* Full constructor with multiple named animations and per-primitive data. * Full constructor with multiple named animations and per-primitive data.
*/ */
public GltfData( public GltfData(
float[] positions, float[] normals, float[] texCoords, float[] positions,
int[] indices, int[] joints, float[] weights, float[] normals,
String[] jointNames, int[] parentJointIndices, float[] texCoords,
int[] indices,
int[] joints,
float[] weights,
String[] jointNames,
int[] parentJointIndices,
Matrix4f[] inverseBindMatrices, Matrix4f[] inverseBindMatrices,
Quaternionf[] restRotations, Vector3f[] restTranslations, Quaternionf[] restRotations,
Vector3f[] restTranslations,
Quaternionf[] rawGltfRestRotations, Quaternionf[] rawGltfRestRotations,
@Nullable AnimationClip rawGltfAnimation, @Nullable AnimationClip rawGltfAnimation,
@Nullable AnimationClip animation, @Nullable AnimationClip animation,
Map<String, AnimationClip> namedAnimations, Map<String, AnimationClip> namedAnimations,
Map<String, AnimationClip> rawNamedAnimations, Map<String, AnimationClip> rawNamedAnimations,
List<Primitive> primitives, List<Primitive> primitives,
int vertexCount, int jointCount int vertexCount,
int jointCount
) { ) {
this.positions = positions; this.positions = positions;
this.normals = normals; this.normals = normals;
@@ -88,8 +96,12 @@ public final class GltfData {
this.rawGltfRestRotations = rawGltfRestRotations; this.rawGltfRestRotations = rawGltfRestRotations;
this.rawGltfAnimation = rawGltfAnimation; this.rawGltfAnimation = rawGltfAnimation;
this.animation = animation; this.animation = animation;
this.namedAnimations = Collections.unmodifiableMap(new LinkedHashMap<>(namedAnimations)); this.namedAnimations = Collections.unmodifiableMap(
this.rawNamedAnimations = Collections.unmodifiableMap(new LinkedHashMap<>(rawNamedAnimations)); new LinkedHashMap<>(namedAnimations)
);
this.rawNamedAnimations = Collections.unmodifiableMap(
new LinkedHashMap<>(rawNamedAnimations)
);
this.primitives = List.copyOf(primitives); this.primitives = List.copyOf(primitives);
this.vertexCount = vertexCount; this.vertexCount = vertexCount;
this.jointCount = jointCount; this.jointCount = jointCount;
@@ -99,81 +111,175 @@ public final class GltfData {
* Legacy constructor for backward compatibility (single animation only). * Legacy constructor for backward compatibility (single animation only).
*/ */
public GltfData( public GltfData(
float[] positions, float[] normals, float[] texCoords, float[] positions,
int[] indices, int[] joints, float[] weights, float[] normals,
String[] jointNames, int[] parentJointIndices, float[] texCoords,
int[] indices,
int[] joints,
float[] weights,
String[] jointNames,
int[] parentJointIndices,
Matrix4f[] inverseBindMatrices, Matrix4f[] inverseBindMatrices,
Quaternionf[] restRotations, Vector3f[] restTranslations, Quaternionf[] restRotations,
Vector3f[] restTranslations,
Quaternionf[] rawGltfRestRotations, Quaternionf[] rawGltfRestRotations,
@Nullable AnimationClip rawGltfAnimation, @Nullable AnimationClip rawGltfAnimation,
@Nullable AnimationClip animation, @Nullable AnimationClip animation,
int vertexCount, int jointCount int vertexCount,
int jointCount
) { ) {
this(positions, normals, texCoords, indices, joints, weights, this(
jointNames, parentJointIndices, inverseBindMatrices, positions,
restRotations, restTranslations, rawGltfRestRotations, normals,
rawGltfAnimation, animation, texCoords,
new LinkedHashMap<>(), new LinkedHashMap<>(), indices,
joints,
weights,
jointNames,
parentJointIndices,
inverseBindMatrices,
restRotations,
restTranslations,
rawGltfRestRotations,
rawGltfAnimation,
animation,
new LinkedHashMap<>(),
new LinkedHashMap<>(),
List.of(new Primitive(indices, null, false, null)), List.of(new Primitive(indices, null, false, null)),
vertexCount, jointCount); vertexCount,
jointCount
);
}
public float[] positions() {
return positions;
}
public float[] normals() {
return normals;
}
public float[] texCoords() {
return texCoords;
}
public int[] indices() {
return indices;
}
public int[] joints() {
return joints;
}
public float[] weights() {
return weights;
}
public String[] jointNames() {
return jointNames;
}
public int[] parentJointIndices() {
return parentJointIndices;
}
public Matrix4f[] inverseBindMatrices() {
return inverseBindMatrices;
}
public Quaternionf[] restRotations() {
return restRotations;
}
public Vector3f[] restTranslations() {
return restTranslations;
}
public Quaternionf[] rawGltfRestRotations() {
return rawGltfRestRotations;
} }
public float[] positions() { return positions; }
public float[] normals() { return normals; }
public float[] texCoords() { return texCoords; }
public int[] indices() { return indices; }
public int[] joints() { return joints; }
public float[] weights() { return weights; }
public String[] jointNames() { return jointNames; }
public int[] parentJointIndices() { return parentJointIndices; }
public Matrix4f[] inverseBindMatrices() { return inverseBindMatrices; }
public Quaternionf[] restRotations() { return restRotations; }
public Vector3f[] restTranslations() { return restTranslations; }
public Quaternionf[] rawGltfRestRotations() { return rawGltfRestRotations; }
@Nullable @Nullable
public AnimationClip rawGltfAnimation() { return rawGltfAnimation; } public AnimationClip rawGltfAnimation() {
return rawGltfAnimation;
}
@Nullable @Nullable
public AnimationClip animation() { return animation; } public AnimationClip animation() {
public int vertexCount() { return vertexCount; } return animation;
public int jointCount() { return jointCount; } }
public int vertexCount() {
return vertexCount;
}
public int jointCount() {
return jointCount;
}
/** Per-primitive material and tint metadata. One entry per glTF primitive in the mesh. */ /** Per-primitive material and tint metadata. One entry per glTF primitive in the mesh. */
public List<Primitive> primitives() { return primitives; } public List<Primitive> primitives() {
return primitives;
}
/** All named animations in MC-converted space. Keys are animation names (e.g. "BasicPose", "Struggle"). */ /** All named animations in MC-converted space. Keys are animation names (e.g. "BasicPose", "Struggle"). */
public Map<String, AnimationClip> namedAnimations() { return namedAnimations; } public Map<String, AnimationClip> namedAnimations() {
return namedAnimations;
}
/** Get a specific named animation in MC-converted space, or null if not found. */ /** Get a specific named animation in MC-converted space, or null if not found. */
@Nullable @Nullable
public AnimationClip getAnimation(String name) { return namedAnimations.get(name); } public AnimationClip getAnimation(String name) {
return namedAnimations.get(name);
}
/** Get a specific named animation in raw glTF space, or null if not found. */ /** Get a specific named animation in raw glTF space, or null if not found. */
@Nullable @Nullable
public AnimationClip getRawAnimation(String name) { return rawNamedAnimations.get(name); } public AnimationClip getRawAnimation(String name) {
return rawNamedAnimations.get(name);
}
/** /**
* Animation clip: per-bone timestamps, quaternion rotations, and optional translations. * Animation clip: per-bone timestamps, quaternion rotations, and optional translations.
*/ */
public static final class AnimationClip { public static final class AnimationClip {
private final float[] timestamps; // shared timestamps private final float[] timestamps; // shared timestamps
private final Quaternionf[][] rotations; // [jointIndex][frameIndex], null if no anim private final Quaternionf[][] rotations; // [jointIndex][frameIndex], null if no anim
@Nullable @Nullable
private final Vector3f[][] translations; // [jointIndex][frameIndex], null if no anim private final Vector3f[][] translations; // [jointIndex][frameIndex], null if no anim
private final int frameCount; private final int frameCount;
public AnimationClip(float[] timestamps, Quaternionf[][] rotations, public AnimationClip(
@Nullable Vector3f[][] translations, int frameCount) { float[] timestamps,
Quaternionf[][] rotations,
@Nullable Vector3f[][] translations,
int frameCount
) {
this.timestamps = timestamps; this.timestamps = timestamps;
this.rotations = rotations; this.rotations = rotations;
this.translations = translations; this.translations = translations;
this.frameCount = frameCount; this.frameCount = frameCount;
} }
public float[] timestamps() { return timestamps; } public float[] timestamps() {
public Quaternionf[][] rotations() { return rotations; } return timestamps;
}
public Quaternionf[][] rotations() {
return rotations;
}
@Nullable @Nullable
public Vector3f[][] translations() { return translations; } public Vector3f[][] translations() {
public int frameCount() { return frameCount; } return translations;
}
public int frameCount() {
return frameCount;
}
} }
/** /**

View File

@@ -1,5 +1,6 @@
package com.tiedup.remake.client.gltf; package com.tiedup.remake.client.gltf;
import com.mojang.blaze3d.systems.RenderSystem;
import dev.kosmx.playerAnim.core.util.Pair; import dev.kosmx.playerAnim.core.util.Pair;
import dev.kosmx.playerAnim.impl.IAnimatedPlayer; import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
import dev.kosmx.playerAnim.impl.animation.AnimationApplier; import dev.kosmx.playerAnim.impl.animation.AnimationApplier;
@@ -42,6 +43,22 @@ public final class GltfLiveBoneReader {
private GltfLiveBoneReader() {} private GltfLiveBoneReader() {}
// Scratch pools for joint-matrix computation. Render-thread-only
// (asserted below). Pre-populated Matrix4f slots are reused via
// set() / identity() / mul(). See GltfSkinningEngine for the twin pool.
private static Matrix4f[] scratchJointMatrices = new Matrix4f[0];
private static Matrix4f[] scratchWorldTransforms = new Matrix4f[0];
private static final Matrix4f scratchLocal = new Matrix4f();
private static Matrix4f[] ensureScratch(Matrix4f[] current, int needed) {
if (current.length >= needed) return current;
Matrix4f[] next = new Matrix4f[needed];
int i = 0;
for (; i < current.length; i++) next[i] = current[i];
for (; i < needed; i++) next[i] = new Matrix4f();
return next;
}
/** /**
* Compute joint matrices by reading live skeleton state from the HumanoidModel. * Compute joint matrices by reading live skeleton state from the HumanoidModel.
* <p> * <p>
@@ -57,16 +74,27 @@ public final class GltfLiveBoneReader {
* @param model the HumanoidModel after PlayerAnimator has applied rotations * @param model the HumanoidModel after PlayerAnimator has applied rotations
* @param data parsed glTF data (MC-converted) * @param data parsed glTF data (MC-converted)
* @param entity the living entity being rendered * @param entity the living entity being rendered
* @return array of joint matrices ready for skinning, or null on failure * @return live reference to an internal scratch buffer (or null on failure).
* Caller MUST consume before the next call to any {@code compute*}
* method on this class; do not store.
*/ */
public static Matrix4f[] computeJointMatricesFromModel( public static Matrix4f[] computeJointMatricesFromModel(
HumanoidModel<?> model, GltfData data, LivingEntity entity HumanoidModel<?> model,
GltfData data,
LivingEntity entity
) { ) {
if (model == null || data == null || entity == null) return null; if (model == null || data == null || entity == null) return null;
assert RenderSystem.isOnRenderThread()
: "GltfLiveBoneReader.computeJointMatricesFromModel must run on the render thread (scratch buffers are not thread-safe)";
int jointCount = data.jointCount(); int jointCount = data.jointCount();
Matrix4f[] jointMatrices = new Matrix4f[jointCount]; scratchJointMatrices = ensureScratch(scratchJointMatrices, jointCount);
Matrix4f[] worldTransforms = new Matrix4f[jointCount]; scratchWorldTransforms = ensureScratch(
scratchWorldTransforms,
jointCount
);
Matrix4f[] jointMatrices = scratchJointMatrices;
Matrix4f[] worldTransforms = scratchWorldTransforms;
int[] parents = data.parentJointIndices(); int[] parents = data.parentJointIndices();
String[] jointNames = data.jointNames(); String[] jointNames = data.jointNames();
@@ -83,14 +111,19 @@ public final class GltfLiveBoneReader {
if (GltfBoneMapper.isLowerBone(boneName)) { if (GltfBoneMapper.isLowerBone(boneName)) {
// --- Lower bone: reconstruct from bend values --- // --- Lower bone: reconstruct from bend values ---
localRot = computeLowerBoneLocalRotation( localRot = computeLowerBoneLocalRotation(
boneName, j, restRotations, emote boneName,
j,
restRotations,
emote
); );
} else if (hasUniqueModelPart(boneName)) { } else if (hasUniqueModelPart(boneName)) {
// --- Upper bone with a unique ModelPart --- // --- Upper bone with a unique ModelPart ---
ModelPart part = GltfBoneMapper.getModelPart(model, boneName); ModelPart part = GltfBoneMapper.getModelPart(model, boneName);
if (part != null) { if (part != null) {
localRot = computeUpperBoneLocalRotation( localRot = computeUpperBoneLocalRotation(
part, j, restRotations part,
j,
restRotations
); );
} else { } else {
// Fallback: use rest rotation // Fallback: use rest rotation
@@ -102,20 +135,22 @@ public final class GltfLiveBoneReader {
} }
// Build local transform: translate(restTranslation) * rotate(localRot) // Build local transform: translate(restTranslation) * rotate(localRot)
Matrix4f local = new Matrix4f(); scratchLocal.identity();
local.translate(restTranslations[j]); scratchLocal.translate(restTranslations[j]);
local.rotate(localRot); scratchLocal.rotate(localRot);
// Compose with parent to get world transform // Compose with parent to get world transform.
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) { // Same semantics as pre-refactor: treat as root when parent hasn't
worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local); // been processed yet (parents[j] >= j was a null in the old array).
Matrix4f world = worldTransforms[j];
if (parents[j] >= 0 && parents[j] < j) {
world.set(worldTransforms[parents[j]]).mul(scratchLocal);
} else { } else {
worldTransforms[j] = new Matrix4f(local); world.set(scratchLocal);
} }
// Final joint matrix = worldTransform * inverseBindMatrix // Final joint matrix = worldTransform * inverseBindMatrix
jointMatrices[j] = new Matrix4f(worldTransforms[j]) jointMatrices[j].set(world).mul(data.inverseBindMatrices()[j]);
.mul(data.inverseBindMatrices()[j]);
} }
return jointMatrices; return jointMatrices;
@@ -138,11 +173,16 @@ public final class GltfLiveBoneReader {
* the frame relationship. * the frame relationship.
*/ */
private static Quaternionf computeUpperBoneLocalRotation( private static Quaternionf computeUpperBoneLocalRotation(
ModelPart part, int jointIndex, ModelPart part,
int jointIndex,
Quaternionf[] restRotations Quaternionf[] restRotations
) { ) {
// Reconstruct the MC-frame delta from ModelPart euler angles. // Reconstruct the MC-frame delta from ModelPart euler angles.
Quaternionf delta = new Quaternionf().rotationZYX(part.zRot, part.yRot, part.xRot); Quaternionf delta = new Quaternionf().rotationZYX(
part.zRot,
part.yRot,
part.xRot
);
// Local rotation = delta applied on top of the local rest rotation. // Local rotation = delta applied on top of the local rest rotation.
return new Quaternionf(delta).mul(restRotations[jointIndex]); return new Quaternionf(delta).mul(restRotations[jointIndex]);
} }
@@ -160,7 +200,8 @@ public final class GltfLiveBoneReader {
* No de-parenting needed — same reasoning as upper bones. * No de-parenting needed — same reasoning as upper bones.
*/ */
private static Quaternionf computeLowerBoneLocalRotation( private static Quaternionf computeLowerBoneLocalRotation(
String boneName, int jointIndex, String boneName,
int jointIndex,
Quaternionf[] restRotations, Quaternionf[] restRotations,
AnimationApplier emote AnimationApplier emote
) { ) {
@@ -183,11 +224,16 @@ public final class GltfLiveBoneReader {
float halfAngle = bendValue * 0.5f; float halfAngle = bendValue * 0.5f;
float s = (float) Math.sin(halfAngle); float s = (float) Math.sin(halfAngle);
Quaternionf bendQuat = new Quaternionf( Quaternionf bendQuat = new Quaternionf(
ax * s, 0, az * s, (float) Math.cos(halfAngle) ax * s,
0,
az * s,
(float) Math.cos(halfAngle)
); );
// Local rotation = bend delta applied on top of local rest rotation // Local rotation = bend delta applied on top of local rest rotation
return new Quaternionf(bendQuat).mul(restRotations[jointIndex]); return new Quaternionf(bendQuat).mul(
restRotations[jointIndex]
);
} }
} }
} }
@@ -220,9 +266,9 @@ public final class GltfLiveBoneReader {
return switch (boneName) { return switch (boneName) {
case "head" -> true; case "head" -> true;
case "leftUpperArm" -> true; case "leftUpperArm" -> true;
case "rightUpperArm"-> true; case "rightUpperArm" -> true;
case "leftUpperLeg" -> true; case "leftUpperLeg" -> true;
case "rightUpperLeg"-> true; case "rightUpperLeg" -> true;
default -> false; // body, torso, lower bones, unknown default -> false; // body, torso, lower bones, unknown
}; };
} }
@@ -236,8 +282,11 @@ public final class GltfLiveBoneReader {
try { try {
return animated.playerAnimator_getAnimation(); return animated.playerAnimator_getAnimation();
} catch (Exception e) { } catch (Exception e) {
LOGGER.debug("[GltfPipeline] Could not get AnimationApplier for {}: {}", LOGGER.debug(
entity.getClass().getSimpleName(), e.getMessage()); "[GltfPipeline] Could not get AnimationApplier for {}: {}",
entity.getClass().getSimpleName(),
e.getMessage()
);
} }
} }
return null; return null;

View File

@@ -4,7 +4,6 @@ import com.mojang.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer; import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.blaze3d.vertex.VertexFormat; import com.mojang.blaze3d.vertex.VertexFormat;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.renderer.MultiBufferSource; import net.minecraft.client.renderer.MultiBufferSource;
@@ -25,13 +24,17 @@ import org.joml.Vector4f;
public final class GltfMeshRenderer extends RenderStateShard { public final class GltfMeshRenderer extends RenderStateShard {
private static final ResourceLocation WHITE_TEXTURE = private static final ResourceLocation WHITE_TEXTURE =
ResourceLocation.fromNamespaceAndPath("tiedup", "models/obj/shared/white.png"); ResourceLocation.fromNamespaceAndPath(
"tiedup",
"models/obj/shared/white.png"
);
/** Cached default RenderType (white texture). Created once, reused every frame. */ /** Cached default RenderType (white texture). Nulled by clearRenderTypeCache on reload. */
private static RenderType cachedDefaultRenderType; private static volatile RenderType cachedDefaultRenderType;
/** Cache for texture-specific RenderTypes, keyed by ResourceLocation. */ /** Cache for texture-specific RenderTypes, keyed by ResourceLocation. */
private static final Map<ResourceLocation, RenderType> RENDER_TYPE_CACHE = new ConcurrentHashMap<>(); private static final Map<ResourceLocation, RenderType> RENDER_TYPE_CACHE =
new ConcurrentHashMap<>();
private GltfMeshRenderer() { private GltfMeshRenderer() {
super("tiedup_gltf_renderer", () -> {}, () -> {}); super("tiedup_gltf_renderer", () -> {}, () -> {});
@@ -61,15 +64,21 @@ public final class GltfMeshRenderer extends RenderStateShard {
* @param texture the texture ResourceLocation * @param texture the texture ResourceLocation
* @return the cached or newly created RenderType * @return the cached or newly created RenderType
*/ */
private static RenderType getRenderTypeForTexture(ResourceLocation texture) { private static RenderType getRenderTypeForTexture(
return RENDER_TYPE_CACHE.computeIfAbsent(texture, ResourceLocation texture
GltfMeshRenderer::createTriangleRenderType); ) {
return RENDER_TYPE_CACHE.computeIfAbsent(
texture,
GltfMeshRenderer::createTriangleRenderType
);
} }
/** /**
* Create a TRIANGLES-mode RenderType for glTF mesh rendering with the given texture. * Create a TRIANGLES-mode RenderType for glTF mesh rendering with the given texture.
*/ */
private static RenderType createTriangleRenderType(ResourceLocation texture) { private static RenderType createTriangleRenderType(
ResourceLocation texture
) {
RenderType.CompositeState state = RenderType.CompositeState.builder() RenderType.CompositeState state = RenderType.CompositeState.builder()
.setShaderState(RENDERTYPE_ENTITY_CUTOUT_NO_CULL_SHADER) .setShaderState(RENDERTYPE_ENTITY_CUTOUT_NO_CULL_SHADER)
.setTextureState( .setTextureState(
@@ -112,12 +121,22 @@ public final class GltfMeshRenderer extends RenderStateShard {
* @param packedOverlay packed overlay value * @param packedOverlay packed overlay value
*/ */
public static void renderSkinned( public static void renderSkinned(
GltfData data, Matrix4f[] jointMatrices, GltfData data,
PoseStack poseStack, MultiBufferSource buffer, Matrix4f[] jointMatrices,
int packedLight, int packedOverlay PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
int packedOverlay
) { ) {
renderSkinnedInternal(data, jointMatrices, poseStack, buffer, renderSkinnedInternal(
packedLight, packedOverlay, getDefaultRenderType()); data,
jointMatrices,
poseStack,
buffer,
packedLight,
packedOverlay,
getDefaultRenderType()
);
} }
/** /**
@@ -132,56 +151,111 @@ public final class GltfMeshRenderer extends RenderStateShard {
* @param texture the texture to use for rendering * @param texture the texture to use for rendering
*/ */
public static void renderSkinned( public static void renderSkinned(
GltfData data, Matrix4f[] jointMatrices, GltfData data,
PoseStack poseStack, MultiBufferSource buffer, Matrix4f[] jointMatrices,
int packedLight, int packedOverlay, PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
int packedOverlay,
ResourceLocation texture ResourceLocation texture
) { ) {
renderSkinnedInternal(data, jointMatrices, poseStack, buffer, renderSkinnedInternal(
packedLight, packedOverlay, getRenderTypeForTexture(texture)); data,
jointMatrices,
poseStack,
buffer,
packedLight,
packedOverlay,
getRenderTypeForTexture(texture)
);
} }
/** /**
* Internal rendering implementation shared by both overloads. * Internal rendering implementation shared by both overloads. Emits every
* index with a single flat white color — no per-primitive metadata is read.
*/ */
private static void renderSkinnedInternal( private static void renderSkinnedInternal(
GltfData data, Matrix4f[] jointMatrices, GltfData data,
PoseStack poseStack, MultiBufferSource buffer, Matrix4f[] jointMatrices,
int packedLight, int packedOverlay, PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
int packedOverlay,
RenderType renderType RenderType renderType
) { ) {
Matrix4f pose = poseStack.last().pose(); Matrix4f pose = poseStack.last().pose();
Matrix3f normalMat = poseStack.last().normal(); Matrix3f normalMat = poseStack.last().normal();
VertexConsumer vc = buffer.getBuffer(renderType); VertexConsumer vc = buffer.getBuffer(renderType);
int[] indices = data.indices();
float[] texCoords = data.texCoords(); float[] texCoords = data.texCoords();
VertexScratch s = new VertexScratch();
float[] outPos = new float[3]; for (int idx : data.indices()) {
float[] outNormal = new float[3]; emitVertex(
vc, pose, normalMat, data, jointMatrices, idx, texCoords,
255, 255, 255, packedLight, packedOverlay, s
);
}
}
// Pre-allocate scratch vectors outside the loop to avoid per-vertex allocations /**
Vector4f tmpPos = new Vector4f(); * Scratch buffers reused across every vertex emission in a single render
Vector4f tmpNorm = new Vector4f(); * call. Kept as a tiny value class so the two public loops share the same
* pre-alloc pattern without duplicating four local variables.
*/
private static final class VertexScratch {
for (int idx : indices) { final float[] outPos = new float[3];
// Skin this vertex final float[] outNormal = new float[3];
GltfSkinningEngine.skinVertex(data, idx, jointMatrices, outPos, outNormal, tmpPos, tmpNorm); final Vector4f tmpPos = new Vector4f();
final Vector4f tmpNorm = new Vector4f();
}
// UV coordinates /**
* Skin {@code idx} and push one vertex into {@code vc}. Extracted from
* {@link #renderSkinnedInternal} and {@link #renderSkinnedTinted} so both
* loops share the vertex-format contract — if the format ever changes,
* the edit happens in one place.
*/
private static void emitVertex(
VertexConsumer vc,
Matrix4f pose,
Matrix3f normalMat,
GltfData data,
Matrix4f[] jointMatrices,
int idx,
float[] texCoords,
int r,
int g,
int b,
int packedLight,
int packedOverlay,
VertexScratch s
) {
GltfSkinningEngine.skinVertex(
data,
idx,
jointMatrices,
s.outPos,
s.outNormal,
s.tmpPos,
s.tmpNorm
);
float u = texCoords[idx * 2]; float u = texCoords[idx * 2];
float v = texCoords[idx * 2 + 1]; float v = texCoords[idx * 2 + 1];
vc
vc.vertex(pose, outPos[0], outPos[1], outPos[2]) .vertex(pose, s.outPos[0], s.outPos[1], s.outPos[2])
.color(255, 255, 255, 255) .color(r, g, b, 255)
.uv(u, 1.0f - v) .uv(u, 1.0f - v)
.overlayCoords(packedOverlay) .overlayCoords(packedOverlay)
.uv2(packedLight) .uv2(packedLight)
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2]) .normal(
normalMat,
s.outNormal[0],
s.outNormal[1],
s.outNormal[2]
)
.endVertex(); .endVertex();
} }
}
/** /**
* Render a skinned glTF mesh with per-primitive tint colors. * Render a skinned glTF mesh with per-primitive tint colors.
@@ -205,27 +279,22 @@ public final class GltfMeshRenderer extends RenderStateShard {
* @param tintColors channel name to RGB int (0xRRGGBB); empty map = white everywhere * @param tintColors channel name to RGB int (0xRRGGBB); empty map = white everywhere
*/ */
public static void renderSkinnedTinted( public static void renderSkinnedTinted(
GltfData data, Matrix4f[] jointMatrices, GltfData data,
PoseStack poseStack, MultiBufferSource buffer, Matrix4f[] jointMatrices,
int packedLight, int packedOverlay, PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
int packedOverlay,
RenderType renderType, RenderType renderType,
Map<String, Integer> tintColors Map<String, Integer> tintColors
) { ) {
Matrix4f pose = poseStack.last().pose(); Matrix4f pose = poseStack.last().pose();
Matrix3f normalMat = poseStack.last().normal(); Matrix3f normalMat = poseStack.last().normal();
VertexConsumer vc = buffer.getBuffer(renderType); VertexConsumer vc = buffer.getBuffer(renderType);
float[] texCoords = data.texCoords(); float[] texCoords = data.texCoords();
VertexScratch s = new VertexScratch();
float[] outPos = new float[3]; for (GltfData.Primitive prim : data.primitives()) {
float[] outNormal = new float[3];
Vector4f tmpPos = new Vector4f();
Vector4f tmpNorm = new Vector4f();
List<GltfData.Primitive> primitives = data.primitives();
for (GltfData.Primitive prim : primitives) {
// Determine color for this primitive
int r = 255, g = 255, b = 255; int r = 255, g = 255, b = 255;
if (prim.tintable() && prim.tintChannel() != null) { if (prim.tintable() && prim.tintChannel() != null) {
Integer colorInt = tintColors.get(prim.tintChannel()); Integer colorInt = tintColors.get(prim.tintChannel());
@@ -235,20 +304,11 @@ public final class GltfMeshRenderer extends RenderStateShard {
b = colorInt & 0xFF; b = colorInt & 0xFF;
} }
} }
for (int idx : prim.indices()) { for (int idx : prim.indices()) {
GltfSkinningEngine.skinVertex(data, idx, jointMatrices, outPos, outNormal, tmpPos, tmpNorm); emitVertex(
vc, pose, normalMat, data, jointMatrices, idx, texCoords,
float u = texCoords[idx * 2]; r, g, b, packedLight, packedOverlay, s
float v = texCoords[idx * 2 + 1]; );
vc.vertex(pose, outPos[0], outPos[1], outPos[2])
.color(r, g, b, 255)
.uv(u, 1.0f - v)
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2])
.endVertex();
} }
} }
} }

View File

@@ -5,11 +5,11 @@ import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import dev.kosmx.playerAnim.core.util.Ease; import dev.kosmx.playerAnim.core.util.Ease;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import org.jetbrains.annotations.Nullable;
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 org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.Nullable;
import org.joml.Quaternionf; import org.joml.Quaternionf;
import org.joml.Vector3f; import org.joml.Vector3f;
@@ -30,8 +30,59 @@ public final class GltfPoseConverter {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final int TICKS_PER_SECOND = 20;
private static final Ease DEFAULT_EASE = Ease.LINEAR;
private GltfPoseConverter() {} private GltfPoseConverter() {}
/**
* Compute the end tick (inclusive) of a clip's keyframe timeline, relative
* to the clip's first timestamp. Returns 1 for null or empty clips (minimum
* valid builder endTick). glTF timestamps are in seconds; MC ticks are 20 Hz.
*
* <p>The baseline subtraction ensures clips authored with an NLA onset
* ({@code timestamps[0] > 0}) don't leave tick range {@code [0, firstTick)}
* undefined on each loop — the clip is always timeline-normalized to start
* at tick 0.</p>
*/
public static int computeEndTick(@Nullable GltfData.AnimationClip clip) {
if (
clip == null ||
clip.frameCount() == 0 ||
clip.timestamps().length == 0
) {
return 1;
}
float[] times = clip.timestamps();
int lastIdx = Math.min(times.length - 1, clip.frameCount() - 1);
float baseline = times[0];
return Math.max(
1,
Math.round((times[lastIdx] - baseline) * TICKS_PER_SECOND)
);
}
/**
* Convert a frame index to an MC tick based on the clip's timestamps,
* relative to {@code baselineSeconds} (typically {@code timestamps[0]}).
*/
private static int frameToTick(
@Nullable GltfData.AnimationClip clip,
int frameIndex,
float baselineSeconds
) {
if (clip == null) return 0;
float[] times = clip.timestamps();
if (frameIndex >= times.length) return 0;
return Math.round((times[frameIndex] - baselineSeconds) * TICKS_PER_SECOND);
}
/** Return the timestamp baseline for the clip, or 0 if absent. */
private static float timelineBaseline(@Nullable GltfData.AnimationClip clip) {
if (clip == null || clip.timestamps().length == 0) return 0f;
return clip.timestamps()[0];
}
/** /**
* Convert a GltfData's rest pose (or first animation frame) to a KeyframeAnimation. * Convert a GltfData's rest pose (or first animation frame) to a KeyframeAnimation.
* Uses the default (first) animation clip. * Uses the default (first) animation clip.
@@ -52,10 +103,16 @@ public final class GltfPoseConverter {
* @param animationName the name of the animation to convert (e.g. "Struggle", "Idle") * @param animationName the name of the animation to convert (e.g. "Struggle", "Idle")
* @return a static looping KeyframeAnimation suitable for PlayerAnimator * @return a static looping KeyframeAnimation suitable for PlayerAnimator
*/ */
public static KeyframeAnimation convert(GltfData data, String animationName) { public static KeyframeAnimation convert(
GltfData data,
String animationName
) {
GltfData.AnimationClip rawClip = data.getRawAnimation(animationName); GltfData.AnimationClip rawClip = data.getRawAnimation(animationName);
if (rawClip == null) { if (rawClip == null) {
LOGGER.warn("[GltfPipeline] Animation '{}' not found, falling back to default", animationName); LOGGER.warn(
"[GltfPipeline] Animation '{}' not found, falling back to default",
animationName
);
return convert(data); return convert(data);
} }
return convertClip(data, rawClip, "gltf_" + animationName); return convertClip(data, rawClip, "gltf_" + animationName);
@@ -76,8 +133,12 @@ public final class GltfPoseConverter {
* are only enabled if the GLB has keyframes for them * are only enabled if the GLB has keyframes for them
* @return KeyframeAnimation with selective parts active * @return KeyframeAnimation with selective parts active
*/ */
public static KeyframeAnimation convertSelective(GltfData data, @Nullable String animationName, public static KeyframeAnimation convertSelective(
Set<String> ownedParts, Set<String> enabledParts) { GltfData data,
@Nullable String animationName,
Set<String> ownedParts,
Set<String> enabledParts
) {
GltfData.AnimationClip rawClip; GltfData.AnimationClip rawClip;
String animName; String animName;
if (animationName != null) { if (animationName != null) {
@@ -90,7 +151,13 @@ public final class GltfPoseConverter {
if (rawClip == null) { if (rawClip == null) {
rawClip = data.rawGltfAnimation(); rawClip = data.rawGltfAnimation();
} }
return convertClipSelective(data, rawClip, animName, ownedParts, enabledParts); return convertClipSelective(
data,
rawClip,
animName,
ownedParts,
enabledParts
);
} }
/** /**
@@ -105,85 +172,203 @@ public final class GltfPoseConverter {
* @param ownedParts parts the item explicitly owns (always enabled) * @param ownedParts parts the item explicitly owns (always enabled)
* @param enabledParts parts the item may animate (owned + free) * @param enabledParts parts the item may animate (owned + free)
*/ */
private static KeyframeAnimation convertClipSelective(GltfData data, GltfData.AnimationClip rawClip, private static KeyframeAnimation convertClipSelective(
String animName, Set<String> ownedParts, Set<String> enabledParts) { GltfData data,
@Nullable GltfData.AnimationClip rawClip,
String animName,
Set<String> ownedParts,
Set<String> enabledParts
) {
KeyframeAnimation.AnimationBuilder builder = KeyframeAnimation.AnimationBuilder builder =
new KeyframeAnimation.AnimationBuilder(AnimationFormat.JSON_EMOTECRAFT); new KeyframeAnimation.AnimationBuilder(
AnimationFormat.JSON_EMOTECRAFT
);
int endTick = computeEndTick(rawClip);
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
builder.beginTick = 0; builder.beginTick = 0;
builder.endTick = 1; builder.endTick = endTick;
builder.stopTick = 1; builder.stopTick = endTick;
builder.isLooped = true; builder.isLooped = true;
builder.returnTick = 0; builder.returnTick = 0;
builder.name = animName; builder.name = animName;
// Track which PlayerAnimator part names received actual animation data.
// Joint-level; not frame-dependent — we detect once on frame 0.
Set<String> partsWithKeyframes = new HashSet<>();
// Tick deduplication: MC runs at 20 Hz. Source clips authored at higher
// rates (24/30/60 FPS Blender) produce multiple frames that round to the
// same tick; emit once per unique tick (keep the first) so artists see
// deterministic behavior rather than relying on PlayerAnimator's "last
// inserted wins" semantic. ARTIST_GUIDE: author at 20 FPS for 1:1.
float baseline = timelineBaseline(rawClip);
int lastTick = Integer.MIN_VALUE;
for (int f = 0; f < frameCount; f++) {
int tick = frameToTick(rawClip, f, baseline);
if (tick == lastTick) continue;
applyFrameToBuilder(
builder,
data,
rawClip,
f,
tick,
DEFAULT_EASE,
/* ownedFilter */null,
/* keyframeCollector */f == 0 ? partsWithKeyframes : null
);
lastTick = tick;
}
// Selective: enable owned parts always, free parts only for "Full" animations
// that explicitly opt into full-body control.
enableSelectiveParts(
builder,
ownedParts,
enabledParts,
partsWithKeyframes,
animName
);
KeyframeAnimation anim = builder.build();
LOGGER.debug(
"[GltfPipeline] Converted selective animation '{}' ({} frames, endTick={}, owned={}, enabled={}, withKeyframes={})",
animName,
frameCount,
endTick,
ownedParts,
enabledParts,
partsWithKeyframes
);
return anim;
}
/**
* Apply a single frame's delta rotations for every known bone to the builder,
* writing one keyframe per bone at {@code tick}.
*
* @param ownedFilter if non-null, only bones whose animPart is in this
* set are written (shared-builder multi-item path)
* @param keyframeCollector if non-null, parts that have explicit rotation or
* translation channels are added to this set
*/
private static void applyFrameToBuilder(
KeyframeAnimation.AnimationBuilder builder,
GltfData data,
@Nullable GltfData.AnimationClip rawClip,
int frameIndex,
int tick,
Ease ease,
@Nullable Set<String> ownedFilter,
@Nullable Set<String> keyframeCollector
) {
String[] jointNames = data.jointNames(); String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations(); Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
// Track which PlayerAnimator part names received actual animation data // Two known bones can map to the same PlayerAnimator part (e.g.
Set<String> partsWithKeyframes = new HashSet<>(); // `body` + `torso` → "body"). Both would write to the same
// StateCollection and the second write silently wins; instead,
// first-in-array-order wins and subsequent collisions are skipped.
// Lower bones don't conflict with upper bones (separate axis).
Set<String> claimedUpperParts = new java.util.HashSet<>();
for (int j = 0; j < data.jointCount(); j++) { for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j]; String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue; if (!GltfBoneMapper.isKnownBone(boneName)) continue;
// Check if this joint has explicit animation data (not just rest pose fallback). String animPart = GltfBoneMapper.getAnimPartName(boneName);
// A bone counts as explicitly animated if it has rotation OR translation keyframes. if (animPart == null) continue;
boolean hasExplicitAnim = rawClip != null && (
(j < rawClip.rotations().length && rawClip.rotations()[j] != null)
|| (rawClip.translations() != null
&& j < rawClip.translations().length
&& rawClip.translations()[j] != null)
);
Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j); boolean isLower = GltfBoneMapper.isLowerBone(boneName);
// Apply ownedFilter BEFORE claiming the slot: a bone that this item
// doesn't own must not reserve the upper-part slot, otherwise a
// later owned bone mapping to the same slot gets spuriously
// rejected by the collision check below.
if (ownedFilter != null) {
if (!ownedFilter.contains(animPart)) continue;
// For lower bones, also require the upper bone's part to be owned.
if (isLower) {
String upper = GltfBoneMapper.getUpperBoneFor(boneName);
if (upper != null) {
String upperPart = GltfBoneMapper.getAnimPartName(upper);
if (
upperPart == null ||
!ownedFilter.contains(upperPart)
) continue;
}
}
}
if (!isLower && !claimedUpperParts.add(animPart)) {
// Another upper bone already claimed this PlayerAnimator part.
// Skip the duplicate write so HashMap iteration order can't
// silently flip which bone drives the pose.
if (frameIndex == 0) {
LOGGER.warn(
"[GltfPipeline] Bone '{}' maps to PlayerAnimator part '{}' already written by an earlier bone — ignoring. Use only one of them in the GLB.",
boneName,
animPart
);
}
continue;
}
Quaternionf animQ = getRawAnimQuaternion(
rawClip,
rawRestRotations,
j,
frameIndex
);
Quaternionf restQ = rawRestRotations[j]; Quaternionf restQ = rawRestRotations[j];
// delta_local = inverse(rest_q) * anim_q (in bone-local frame) // delta_local = inverse(rest_q) * anim_q (bone-local frame)
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ); Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
// delta_parent = rest * delta_local * inv(rest)
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest) Quaternionf deltaParent = new Quaternionf(restQ)
Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal) .mul(deltaLocal)
.mul(new Quaternionf(restQ).invert()); .mul(new Quaternionf(restQ).invert());
// glTF parent frame → MC model-def frame: 180° around Z (negate qx, qy).
// Convert from glTF parent frame to MC model-def frame.
// 180deg rotation around Z (X and Y differ): negate qx and qy.
Quaternionf deltaQ = new Quaternionf(deltaParent); Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x; deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y; deltaQ.y = -deltaQ.y;
if (GltfBoneMapper.isLowerBone(boneName)) { if (isLower) {
convertLowerBone(builder, boneName, deltaQ); convertLowerBone(builder, boneName, deltaQ, tick, ease);
} else { } else {
convertUpperBone(builder, boneName, deltaQ); convertUpperBone(builder, boneName, deltaQ, tick, ease);
} }
// Record which PlayerAnimator part received data if (keyframeCollector != null) {
// Translation-only channels count as "explicit": a pure-
// translation animation (e.g. a rigid-body bounce) still
// feeds keyframes to PlayerAnimator, so its part must be
// claimed for composite merging.
boolean hasExplicitAnim =
rawClip != null &&
((j < rawClip.rotations().length &&
rawClip.rotations()[j] != null) ||
(rawClip.translations() != null &&
j < rawClip.translations().length &&
rawClip.translations()[j] != null));
if (hasExplicitAnim) { if (hasExplicitAnim) {
String animPart = GltfBoneMapper.getAnimPartName(boneName); keyframeCollector.add(animPart);
if (animPart != null) { if (isLower) {
partsWithKeyframes.add(animPart); String upper = GltfBoneMapper.getUpperBoneFor(boneName);
} if (upper != null) {
// For lower bones, the keyframe data goes to the upper bone's part String upperPart = GltfBoneMapper.getAnimPartName(
if (GltfBoneMapper.isLowerBone(boneName)) { upper
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); );
if (upperBone != null) { if (upperPart != null) keyframeCollector.add(
String upperPart = GltfBoneMapper.getAnimPartName(upperBone); upperPart
if (upperPart != null) { );
partsWithKeyframes.add(upperPart);
} }
} }
} }
} }
} }
// Selective: enable owned parts always, free parts only if they have keyframes
enableSelectiveParts(builder, ownedParts, enabledParts, partsWithKeyframes);
KeyframeAnimation anim = builder.build();
LOGGER.debug("[GltfPipeline] Converted selective animation '{}' (owned: {}, enabled: {}, withKeyframes: {})",
animName, ownedParts, enabledParts, partsWithKeyframes);
return anim;
} }
/** /**
@@ -201,65 +386,29 @@ public final class GltfPoseConverter {
*/ */
public static Set<String> addBonesToBuilder( public static Set<String> addBonesToBuilder(
KeyframeAnimation.AnimationBuilder builder, KeyframeAnimation.AnimationBuilder builder,
GltfData data, @Nullable GltfData.AnimationClip rawClip, GltfData data,
Set<String> ownedParts) { @Nullable GltfData.AnimationClip rawClip,
Set<String> ownedParts
String[] jointNames = data.jointNames(); ) {
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
Set<String> partsWithKeyframes = new HashSet<>(); Set<String> partsWithKeyframes = new HashSet<>();
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
float baseline = timelineBaseline(rawClip);
int lastTick = Integer.MIN_VALUE;
for (int j = 0; j < data.jointCount(); j++) { for (int f = 0; f < frameCount; f++) {
String boneName = jointNames[j]; int tick = frameToTick(rawClip, f, baseline);
if (!GltfBoneMapper.isKnownBone(boneName)) continue; if (tick == lastTick) continue;
applyFrameToBuilder(
// Only process bones that belong to this item's owned parts builder,
String animPart = GltfBoneMapper.getAnimPartName(boneName); data,
if (animPart == null || !ownedParts.contains(animPart)) continue; rawClip,
f,
// For lower bones, check if the UPPER bone's part is owned tick,
// (lower bone keyframes go to the upper bone's StateCollection) DEFAULT_EASE,
if (GltfBoneMapper.isLowerBone(boneName)) { ownedParts,
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); f == 0 ? partsWithKeyframes : null
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(upperBone);
if (upperPart == null || !ownedParts.contains(upperPart)) continue;
}
}
boolean hasExplicitAnim = rawClip != null && (
(j < rawClip.rotations().length && rawClip.rotations()[j] != null)
|| (rawClip.translations() != null
&& j < rawClip.translations().length
&& rawClip.translations()[j] != null)
); );
lastTick = tick;
Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j);
Quaternionf restQ = rawRestRotations[j];
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
if (hasExplicitAnim) {
partsWithKeyframes.add(animPart);
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(upperBone);
if (upperPart != null) partsWithKeyframes.add(upperPart);
}
}
}
} }
return partsWithKeyframes; return partsWithKeyframes;
@@ -281,158 +430,176 @@ public final class GltfPoseConverter {
* @return a static looping KeyframeAnimation with all parts enabled * @return a static looping KeyframeAnimation with all parts enabled
*/ */
public static KeyframeAnimation convertWithSkeleton( public static KeyframeAnimation convertWithSkeleton(
GltfData skeleton, GltfData.AnimationClip clip, String animName) { GltfData skeleton,
GltfData.AnimationClip clip,
String animName
) {
return convertClip(skeleton, clip, animName); return convertClip(skeleton, clip, animName);
} }
/** /**
* Internal: convert a specific raw animation clip to a KeyframeAnimation. * Internal: convert a specific raw animation clip to a KeyframeAnimation.
*/ */
private static KeyframeAnimation convertClip(GltfData data, GltfData.AnimationClip rawClip, String animName) { private static KeyframeAnimation convertClip(
GltfData data,
@Nullable GltfData.AnimationClip rawClip,
String animName
) {
KeyframeAnimation.AnimationBuilder builder = KeyframeAnimation.AnimationBuilder builder =
new KeyframeAnimation.AnimationBuilder(AnimationFormat.JSON_EMOTECRAFT); new KeyframeAnimation.AnimationBuilder(
AnimationFormat.JSON_EMOTECRAFT
);
int endTick = computeEndTick(rawClip);
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
float baseline = timelineBaseline(rawClip);
int lastTick = Integer.MIN_VALUE;
builder.beginTick = 0; builder.beginTick = 0;
builder.endTick = 1; builder.endTick = endTick;
builder.stopTick = 1; builder.stopTick = endTick;
builder.isLooped = true; builder.isLooped = true;
builder.returnTick = 0; builder.returnTick = 0;
builder.name = animName; builder.name = animName;
String[] jointNames = data.jointNames(); for (int f = 0; f < frameCount; f++) {
Quaternionf[] rawRestRotations = data.rawGltfRestRotations(); int tick = frameToTick(rawClip, f, baseline);
if (tick == lastTick) continue;
for (int j = 0; j < data.jointCount(); j++) { applyFrameToBuilder(
String boneName = jointNames[j]; builder,
data,
if (!GltfBoneMapper.isKnownBone(boneName)) continue; rawClip,
f,
Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j); tick,
Quaternionf restQ = rawRestRotations[j]; DEFAULT_EASE,
/* ownedFilter */null,
// delta_local = inverse(rest_q) * anim_q (in bone-local frame) /* keyframeCollector */null
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ); );
lastTick = tick;
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
// Simplifies algebraically to: animQ * inv(restQ)
Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
// Convert from glTF parent frame to MC model-def frame.
// 180° rotation around Z (X and Y differ): negate qx and qy.
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
LOGGER.debug(String.format(
"[GltfPipeline] Bone '%s': restQ=(%.3f,%.3f,%.3f,%.3f) animQ=(%.3f,%.3f,%.3f,%.3f) deltaQ=(%.3f,%.3f,%.3f,%.3f)",
boneName,
restQ.x, restQ.y, restQ.z, restQ.w,
animQ.x, animQ.y, animQ.z, animQ.w,
deltaQ.x, deltaQ.y, deltaQ.z, deltaQ.w));
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
} }
builder.fullyEnableParts(); builder.fullyEnableParts();
KeyframeAnimation anim = builder.build(); KeyframeAnimation anim = builder.build();
LOGGER.debug("[GltfPipeline] Converted glTF animation '{}' to KeyframeAnimation", animName); LOGGER.debug(
"[GltfPipeline] Converted glTF animation '{}' ({} frames, endTick={})",
animName,
frameCount,
endTick
);
return anim; return anim;
} }
/** /**
* Get the raw animation quaternion for a joint from a specific clip. * Get the raw animation quaternion for a joint at a specific frame.
* Falls back to rest rotation if the clip is null or has no data for this joint. * Falls back to rest rotation if the clip is null, has no data for this joint,
* or has an empty channel. Clamps frameIndex to the last available frame if
* the joint's channel is shorter than the shared timestamps array.
*/ */
private static Quaternionf getRawAnimQuaternion( private static Quaternionf getRawAnimQuaternion(
GltfData.AnimationClip rawClip, Quaternionf[] rawRestRotations, int jointIndex @Nullable GltfData.AnimationClip rawClip,
Quaternionf[] rawRestRotations,
int jointIndex,
int frameIndex
) { ) {
if (rawClip != null && jointIndex < rawClip.rotations().length if (
&& rawClip.rotations()[jointIndex] != null) { rawClip != null &&
return rawClip.rotations()[jointIndex][0]; // first frame jointIndex < rawClip.rotations().length &&
rawClip.rotations()[jointIndex] != null
) {
Quaternionf[] channel = rawClip.rotations()[jointIndex];
if (channel.length > 0) {
int safeFrame = Math.min(frameIndex, channel.length - 1);
return channel[safeFrame];
} }
return rawRestRotations[jointIndex]; // fallback to rest }
// Defensive: under a well-formed GLB, jointCount == restRotations.length
// (guaranteed by the parser). This guard keeps us from AIOOBE-ing if
// that invariant is ever broken by a future parser path.
if (jointIndex >= rawRestRotations.length) {
return new Quaternionf();
}
return rawRestRotations[jointIndex];
} }
private static void convertUpperBone( private static void convertUpperBone(
KeyframeAnimation.AnimationBuilder builder, KeyframeAnimation.AnimationBuilder builder,
String boneName, Quaternionf deltaQ String boneName,
Quaternionf deltaQ,
int tick,
Ease ease
) { ) {
// Decompose delta quaternion to Euler ZYX // "ZYX" is rotation order, not storage: euler.{x,y,z} hold the X/Y/Z
// JOML's getEulerAnglesZYX stores: euler.x = X rotation, euler.y = Y rotation, euler.z = Z rotation // Euler angles for a R = Rz·Ry·Rx decomposition. Gimbal lock at the
// (the "ZYX" refers to rotation ORDER, not storage order) // middle axis (euler.y = ±90°); see ARTIST_GUIDE.md Common Mistakes.
Vector3f euler = new Vector3f(); Vector3f euler = new Vector3f();
deltaQ.getEulerAnglesZYX(euler); deltaQ.getEulerAnglesZYX(euler);
float pitch = euler.x; // X rotation (pitch) float pitch = euler.x;
float yaw = euler.y; // Y rotation (yaw) float yaw = euler.y;
float roll = euler.z; // Z rotation (roll) float roll = euler.z;
LOGGER.debug(String.format(
"[GltfPipeline] Upper bone '%s': pitch=%.1f° yaw=%.1f° roll=%.1f°",
boneName,
Math.toDegrees(pitch),
Math.toDegrees(yaw),
Math.toDegrees(roll)));
// Get the StateCollection for this body part
String animPart = GltfBoneMapper.getAnimPartName(boneName); String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null) return; if (animPart == null) return;
KeyframeAnimation.StateCollection part = getPartByName(builder, animPart); KeyframeAnimation.StateCollection part = getPartByName(
builder,
animPart
);
if (part == null) return; if (part == null) return;
part.pitch.addKeyFrame(0, pitch, Ease.CONSTANT); part.pitch.addKeyFrame(tick, pitch, ease);
part.yaw.addKeyFrame(0, yaw, Ease.CONSTANT); part.yaw.addKeyFrame(tick, yaw, ease);
part.roll.addKeyFrame(0, roll, Ease.CONSTANT); part.roll.addKeyFrame(tick, roll, ease);
} }
private static void convertLowerBone( private static void convertLowerBone(
KeyframeAnimation.AnimationBuilder builder, KeyframeAnimation.AnimationBuilder builder,
String boneName, Quaternionf deltaQ String boneName,
Quaternionf deltaQ,
int tick,
Ease ease
) { ) {
// Extract bend angle and axis from the delta quaternion // Canonicalize q: q and -q represent the same rotation. Always pick the
float angle = 2.0f * (float) Math.acos( // hemisphere with w >= 0 so consecutive frames don't pop across the
Math.min(1.0, Math.abs(deltaQ.w)) // double-cover boundary when interpolating.
); float qx = deltaQ.x;
float qy = deltaQ.y;
float qz = deltaQ.z;
float qw = deltaQ.w;
if (qw < 0) {
qx = -qx;
qy = -qy;
qz = -qz;
qw = -qw;
}
// Now qw is in [0, 1]. Rotation angle = 2 * acos(qw), in [0, π].
float angle = 2.0f * (float) Math.acos(Math.min(1.0f, qw));
// Determine bend direction from axis
float bendDirection = 0.0f; float bendDirection = 0.0f;
if (deltaQ.x * deltaQ.x + deltaQ.z * deltaQ.z > 0.001f) { if (qx * qx + qz * qz > 0.001f) {
bendDirection = (float) Math.atan2(deltaQ.z, deltaQ.x); bendDirection = (float) Math.atan2(qz, qx);
} }
// Sign: if w is negative, the angle wraps
if (deltaQ.w < 0) {
angle = -angle;
}
LOGGER.debug(String.format(
"[GltfPipeline] Lower bone '%s': bendAngle=%.1f° bendDir=%.1f°",
boneName,
Math.toDegrees(angle),
Math.toDegrees(bendDirection)));
// Apply bend to the upper bone's StateCollection
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone == null) return; if (upperBone == null) return;
String animPart = GltfBoneMapper.getAnimPartName(upperBone); String animPart = GltfBoneMapper.getAnimPartName(upperBone);
if (animPart == null) return; if (animPart == null) return;
KeyframeAnimation.StateCollection part = getPartByName(builder, animPart); KeyframeAnimation.StateCollection part = getPartByName(
builder,
animPart
);
if (part == null || !part.isBendable) return; if (part == null || !part.isBendable) return;
part.bend.addKeyFrame(0, angle, Ease.CONSTANT); part.bend.addKeyFrame(tick, angle, ease);
part.bendDirection.addKeyFrame(0, bendDirection, Ease.CONSTANT); part.bendDirection.addKeyFrame(tick, bendDirection, ease);
} }
private static KeyframeAnimation.StateCollection getPartByName( private static KeyframeAnimation.StateCollection getPartByName(
KeyframeAnimation.AnimationBuilder builder, String name KeyframeAnimation.AnimationBuilder builder,
String name
) { ) {
return switch (name) { return switch (name) {
case "head" -> builder.head; case "head" -> builder.head;
@@ -450,32 +617,133 @@ public final class GltfPoseConverter {
* *
* <ul> * <ul>
* <li>Owned parts: always enabled (the item controls these bones)</li> * <li>Owned parts: always enabled (the item controls these bones)</li>
* <li>Free parts WITH keyframes: enabled (the GLB has animation data for them)</li> * <li>Free parts WITH keyframes AND "Full" animation: enabled (explicit opt-in to full-body)</li>
* <li>Free parts WITHOUT keyframes: disabled (no data to animate, pass through to context)</li> * <li>Free parts without "Full" prefix: disabled (prevents accidental bone hijacking)</li>
* <li>Other items' parts: disabled (pass through to their own layer)</li> * <li>Other items' parts: disabled (pass through to their own layer)</li>
* </ul> * </ul>
* *
* <p>The "Full" prefix convention (FullIdle, FullStruggle, FullWalk) is the artist's
* explicit declaration that this animation is designed to control the entire body,
* not just the item's owned regions. Without this prefix, free bones are never enabled,
* even if the GLB contains keyframes for them. This prevents accidental bone hijacking
* when an artist keyframes all bones in Blender by default.</p>
*
* @param builder the animation builder with keyframes already added * @param builder the animation builder with keyframes already added
* @param ownedParts parts the item explicitly owns (always enabled) * @param ownedParts parts the item explicitly owns (always enabled)
* @param enabledParts parts the item may animate (owned + free) * @param enabledParts parts the item may animate (owned + free)
* @param partsWithKeyframes parts that received actual animation data from the GLB * @param partsWithKeyframes parts that received actual animation data from the GLB
* @param animName resolved animation name (checked for "Full" prefix)
*/ */
private static void enableSelectiveParts( private static void enableSelectiveParts(
KeyframeAnimation.AnimationBuilder builder, KeyframeAnimation.AnimationBuilder builder,
Set<String> ownedParts, Set<String> enabledParts, Set<String> ownedParts,
Set<String> partsWithKeyframes) { Set<String> enabledParts,
String[] allParts = {"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"}; Set<String> partsWithKeyframes,
for (String partName : allParts) { String animName
KeyframeAnimation.StateCollection part = getPartByName(builder, partName); ) {
boolean isFullBodyAnimation = isFullBodyAnimName(animName);
boolean allowFreeHead = isFullHeadAnimName(animName);
enableSelectivePartsCore(
builder,
ownedParts,
enabledParts,
partsWithKeyframes,
isFullBodyAnimation,
allowFreeHead
);
}
/**
* Check whether a resolved-and-prefixed animation name (e.g. {@code "gltf_FullStruggle"})
* declares opt-in to full-body free-bone animation. See the "Full" prefix
* convention in {@link #enableSelectiveParts}.
*/
public static boolean isFullBodyAnimName(@Nullable String animName) {
return animName != null && animName.startsWith("gltf_Full");
}
/**
* Check whether a resolved-and-prefixed animation name opts in to head
* animation as a free bone (e.g. {@code "gltf_FullHeadStruggle"}). Head is
* protected by default to preserve vanilla head-tracking on bondage items
* that don't specifically want to animate it.
*/
public static boolean isFullHeadAnimName(@Nullable String animName) {
return isFullBodyAnimName(animName) &&
animName.startsWith("gltf_FullHead");
}
/**
* Composite variant of {@link #enableSelectiveParts} used by the multi-item
* path. Callers (e.g. {@code GltfAnimationApplier.applyMultiItemV2Animation})
* compute the three aggregates themselves: {@code allOwnedParts} is the
* union of owned regions across all items, {@code partsWithKeyframes} is
* the union of keyframe parts returned by each {@link #addBonesToBuilder}
* call, and the two Full/FullHead flags should be true if ANY item in the
* composite resolved to a {@code FullX}/{@code FullHeadX} animation name.
*/
public static void enableSelectivePartsComposite(
KeyframeAnimation.AnimationBuilder builder,
Set<String> allOwnedParts,
Set<String> partsWithKeyframes,
boolean isFullBodyAnimation,
boolean allowFreeHead
) {
// In the composite path every animation part is implicitly in
// enabledParts — if a FullX animation has keyframes for it, we want it
// enabled. Pass ALL_PARTS as the enabled set so the single-item
// opt-out path is a no-op.
enableSelectivePartsCore(
builder,
allOwnedParts,
ALL_PARTS_SET,
partsWithKeyframes,
isFullBodyAnimation,
allowFreeHead
);
}
private static final Set<String> ALL_PARTS_SET = Set.of(
"head",
"body",
"rightArm",
"leftArm",
"rightLeg",
"leftLeg"
);
private static void enableSelectivePartsCore(
KeyframeAnimation.AnimationBuilder builder,
Set<String> ownedParts,
Set<String> enabledParts,
Set<String> partsWithKeyframes,
boolean isFullBodyAnimation,
boolean allowFreeHead
) {
for (String partName : ALL_PARTS_SET) {
KeyframeAnimation.StateCollection part = getPartByName(
builder,
partName
);
if (part != null) { if (part != null) {
if (ownedParts.contains(partName)) { if (ownedParts.contains(partName)) {
// Always enable owned parts — the item controls these bones // Always enable owned parts — the item controls these bones
part.fullyEnablePart(false); part.fullyEnablePart(false);
} else if (enabledParts.contains(partName) && partsWithKeyframes.contains(partName)) { } else if (
// Free part WITH keyframes: enable so the GLB animation drives it isFullBodyAnimation &&
enabledParts.contains(partName) &&
partsWithKeyframes.contains(partName) &&
(!"head".equals(partName) || allowFreeHead)
) {
// Full-body animation: free part WITH keyframes — enable.
// The "Full" prefix is the artist's explicit opt-in to animate
// bones outside their declared regions.
// Head is protected by default (preserves vanilla head tracking).
// Use "Head" in the animation name (e.g., FullHeadStruggle) to
// explicitly opt-in to head control for that animation.
part.fullyEnablePart(false); part.fullyEnablePart(false);
} else { } else {
// Other item's part, or free part without keyframes: disable. // Non-Full animation, other item's part, or free part without keyframes.
// Disabled parts pass through to the lower-priority context layer. // Disabled parts pass through to the lower-priority context layer.
part.setEnabled(false); part.setEnabled(false);
} }

View File

@@ -1,94 +0,0 @@
package com.tiedup.remake.client.gltf;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.PlayerModel;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.entity.RenderLayerParent;
import net.minecraft.client.renderer.entity.layers.RenderLayer;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.joml.Matrix4f;
/**
* RenderLayer that renders the glTF mesh (handcuffs) on the player.
* Only active when enabled and only renders on the local player.
* <p>
* Uses the live skinning path: reads live skeleton from HumanoidModel
* via {@link GltfLiveBoneReader}, following PlayerAnimator + bendy-lib rotations.
* Falls back to GLB-internal skinning via {@link GltfSkinningEngine} if live reading fails.
*/
@OnlyIn(Dist.CLIENT)
public class GltfRenderLayer
extends RenderLayer<AbstractClientPlayer, PlayerModel<AbstractClientPlayer>> {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final ResourceLocation CUFFS_MODEL =
ResourceLocation.fromNamespaceAndPath(
"tiedup", "models/gltf/v2/handcuffs/cuffs_prototype.glb"
);
public GltfRenderLayer(
RenderLayerParent<AbstractClientPlayer, PlayerModel<AbstractClientPlayer>> renderer
) {
super(renderer);
}
/**
* The Y translate offset to place the glTF mesh in the MC PoseStack.
* <p>
* After LivingEntityRenderer's scale(-1,-1,1) + translate(0,-1.501,0),
* the PoseStack origin is at the model top (1.501 blocks above feet), Y-down.
* The glTF mesh (MC-converted) has feet at Y=0 and head at Y≈-1.5.
* Translating by 1.501 maps glTF feet to PoseStack feet and head to top.
*/
private static final float ALIGNMENT_Y = 1.501f;
@Override
public void render(
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
AbstractClientPlayer entity,
float limbSwing,
float limbSwingAmount,
float partialTick,
float ageInTicks,
float netHeadYaw,
float headPitch
) {
if (!GltfAnimationApplier.isEnabled()) return;
if (entity != Minecraft.getInstance().player) return;
GltfData data = GltfCache.get(CUFFS_MODEL);
if (data == null) return;
// Live path: read skeleton from HumanoidModel (after PlayerAnimator)
PlayerModel<AbstractClientPlayer> parentModel = this.getParentModel();
Matrix4f[] joints = GltfLiveBoneReader.computeJointMatricesFromModel(
parentModel, data, entity
);
if (joints == null) {
// Fallback to GLB-internal path if live reading fails
joints = GltfSkinningEngine.computeJointMatrices(data);
}
poseStack.pushPose();
// Align glTF mesh with MC model (feet-to-feet alignment)
poseStack.translate(0, ALIGNMENT_Y, 0);
GltfMeshRenderer.renderSkinned(
data, joints, poseStack, buffer,
packedLight,
net.minecraft.client.renderer.entity.LivingEntityRenderer
.getOverlayCoords(entity, 0.0f)
);
poseStack.popPose();
}
}

View File

@@ -1,5 +1,6 @@
package com.tiedup.remake.client.gltf; package com.tiedup.remake.client.gltf;
import com.mojang.blaze3d.systems.RenderSystem;
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 org.joml.Matrix4f; import org.joml.Matrix4f;
@@ -11,21 +12,46 @@ import org.joml.Vector4f;
* CPU-based Linear Blend Skinning (LBS) engine. * CPU-based Linear Blend Skinning (LBS) engine.
* Computes joint matrices purely from glTF data (rest translations + animation rotations). * Computes joint matrices purely from glTF data (rest translations + animation rotations).
* All data is in MC-converted space (consistent with IBMs and vertex positions). * All data is in MC-converted space (consistent with IBMs and vertex positions).
*
* <p><b>Scratch pool</b>: the {@code computeJointMatrices*} methods return a
* reference to an internal grow-on-demand buffer. The caller MUST consume the
* returned array before the next call to any {@code compute*} method on this
* class. Storing the reference across frames produces corrupted output on the
* next call.</p>
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public final class GltfSkinningEngine { public final class GltfSkinningEngine {
private GltfSkinningEngine() {} private GltfSkinningEngine() {}
// Scratch pools for joint-matrix computation. Single-threaded access from
// the render thread only (asserted at call sites). Pre-populated Matrix4f
// slots are reused via set()/identity()/mul() instead of new Matrix4f(...).
private static Matrix4f[] scratchJointMatrices = new Matrix4f[0];
private static Matrix4f[] scratchWorldTransforms = new Matrix4f[0];
private static final Matrix4f scratchLocal = new Matrix4f();
private static Matrix4f[] ensureScratch(Matrix4f[] current, int needed) {
if (current.length >= needed) return current;
Matrix4f[] next = new Matrix4f[needed];
int i = 0;
for (; i < current.length; i++) next[i] = current[i];
for (; i < needed; i++) next[i] = new Matrix4f();
return next;
}
/** /**
* Compute joint matrices from glTF animation/rest data (default animation). * Compute joint matrices from glTF animation/rest data (default animation).
* Each joint matrix = worldTransform * inverseBindMatrix. * Each joint matrix = worldTransform * inverseBindMatrix.
* Uses MC-converted glTF data throughout for consistency. * Uses MC-converted glTF data throughout for consistency.
* *
* @param data parsed glTF data (MC-converted) * @param data parsed glTF data (MC-converted)
* @return array of joint matrices ready for skinning * @return live reference to an internal scratch buffer. Caller MUST consume
* before the next call to any {@code compute*} method; do not store.
*/ */
public static Matrix4f[] computeJointMatrices(GltfData data) { public static Matrix4f[] computeJointMatrices(GltfData data) {
assert RenderSystem.isOnRenderThread()
: "GltfSkinningEngine.computeJointMatrices must run on the render thread (scratch buffers are not thread-safe)";
return computeJointMatricesFromClip(data, data.animation()); return computeJointMatricesFromClip(data, data.animation());
} }
@@ -40,33 +66,45 @@ public final class GltfSkinningEngine {
* @param data the parsed glTF data (MC-converted) * @param data the parsed glTF data (MC-converted)
* @param clip the animation clip to sample (null = rest pose for all joints) * @param clip the animation clip to sample (null = rest pose for all joints)
* @param time time in frame-space (0.0 = first frame, N-1 = last frame) * @param time time in frame-space (0.0 = first frame, N-1 = last frame)
* @return interpolated joint matrices ready for skinning * @return live reference to an internal scratch buffer. Caller MUST consume
* before the next call to any {@code compute*} method; do not store.
*/ */
public static Matrix4f[] computeJointMatricesAnimated( public static Matrix4f[] computeJointMatricesAnimated(
GltfData data, GltfData.AnimationClip clip, float time GltfData data,
GltfData.AnimationClip clip,
float time
) { ) {
assert RenderSystem.isOnRenderThread()
: "GltfSkinningEngine.computeJointMatricesAnimated must run on the render thread (scratch buffers are not thread-safe)";
int jointCount = data.jointCount(); int jointCount = data.jointCount();
Matrix4f[] jointMatrices = new Matrix4f[jointCount]; scratchJointMatrices = ensureScratch(scratchJointMatrices, jointCount);
Matrix4f[] worldTransforms = new Matrix4f[jointCount]; scratchWorldTransforms = ensureScratch(
scratchWorldTransforms,
jointCount
);
Matrix4f[] jointMatrices = scratchJointMatrices;
Matrix4f[] worldTransforms = scratchWorldTransforms;
int[] parents = data.parentJointIndices(); int[] parents = data.parentJointIndices();
for (int j = 0; j < jointCount; j++) { for (int j = 0; j < jointCount; j++) {
// Build local transform: translate(interpT) * rotate(interpQ) // Build local transform: translate(interpT) * rotate(interpQ)
Matrix4f local = new Matrix4f(); scratchLocal.identity();
local.translate(getInterpolatedTranslation(data, clip, j, time)); scratchLocal.translate(getInterpolatedTranslation(data, clip, j, time));
local.rotate(getInterpolatedRotation(data, clip, j, time)); scratchLocal.rotate(getInterpolatedRotation(data, clip, j, time));
// Compose with parent // Compose with parent. Same semantics as the previous allocating
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) { // code path: only use the parent when its index is already processed
worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local); // (parents[j] < j). Out-of-order/root → treat as identity parent.
Matrix4f world = worldTransforms[j];
if (parents[j] >= 0 && parents[j] < j) {
world.set(worldTransforms[parents[j]]).mul(scratchLocal);
} else { } else {
worldTransforms[j] = new Matrix4f(local); world.set(scratchLocal);
} }
// Final joint matrix = worldTransform * inverseBindMatrix // Final joint matrix = worldTransform * inverseBindMatrix
jointMatrices[j] = new Matrix4f(worldTransforms[j]) jointMatrices[j].set(world).mul(data.inverseBindMatrices()[j]);
.mul(data.inverseBindMatrices()[j]);
} }
return jointMatrices; return jointMatrices;
@@ -75,29 +113,39 @@ public final class GltfSkinningEngine {
/** /**
* Internal: compute joint matrices from a specific animation clip. * Internal: compute joint matrices from a specific animation clip.
*/ */
private static Matrix4f[] computeJointMatricesFromClip(GltfData data, GltfData.AnimationClip clip) { private static Matrix4f[] computeJointMatricesFromClip(
GltfData data,
GltfData.AnimationClip clip
) {
assert RenderSystem.isOnRenderThread()
: "GltfSkinningEngine.computeJointMatricesFromClip must run on the render thread (scratch buffers are not thread-safe)";
int jointCount = data.jointCount(); int jointCount = data.jointCount();
Matrix4f[] jointMatrices = new Matrix4f[jointCount]; scratchJointMatrices = ensureScratch(scratchJointMatrices, jointCount);
Matrix4f[] worldTransforms = new Matrix4f[jointCount]; scratchWorldTransforms = ensureScratch(
scratchWorldTransforms,
jointCount
);
Matrix4f[] jointMatrices = scratchJointMatrices;
Matrix4f[] worldTransforms = scratchWorldTransforms;
int[] parents = data.parentJointIndices(); int[] parents = data.parentJointIndices();
for (int j = 0; j < jointCount; j++) { for (int j = 0; j < jointCount; j++) {
// Build local transform: translate(animT or restT) * rotate(animQ or restQ) // Build local transform: translate(animT or restT) * rotate(animQ or restQ)
Matrix4f local = new Matrix4f(); scratchLocal.identity();
local.translate(getAnimTranslation(data, clip, j)); scratchLocal.translate(getAnimTranslation(data, clip, j));
local.rotate(getAnimRotation(data, clip, j)); scratchLocal.rotate(getAnimRotation(data, clip, j));
// Compose with parent // Compose with parent — see note in computeJointMatricesAnimated.
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) { Matrix4f world = worldTransforms[j];
worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local); if (parents[j] >= 0 && parents[j] < j) {
world.set(worldTransforms[parents[j]]).mul(scratchLocal);
} else { } else {
worldTransforms[j] = new Matrix4f(local); world.set(scratchLocal);
} }
// Final joint matrix = worldTransform * inverseBindMatrix // Final joint matrix = worldTransform * inverseBindMatrix
jointMatrices[j] = new Matrix4f(worldTransforms[j]) jointMatrices[j].set(world).mul(data.inverseBindMatrices()[j]);
.mul(data.inverseBindMatrices()[j]);
} }
return jointMatrices; return jointMatrices;
@@ -105,11 +153,20 @@ public final class GltfSkinningEngine {
/** /**
* Get the animation rotation for a joint (MC-converted). * Get the animation rotation for a joint (MC-converted).
* Falls back to rest rotation if no animation. * Falls back to rest rotation if no animation or the channel is empty.
*/ */
private static Quaternionf getAnimRotation(GltfData data, GltfData.AnimationClip clip, int jointIndex) { private static Quaternionf getAnimRotation(
if (clip != null && jointIndex < clip.rotations().length GltfData data,
&& clip.rotations()[jointIndex] != null) { GltfData.AnimationClip clip,
int jointIndex
) {
if (
clip != null &&
clip.rotations() != null &&
jointIndex < clip.rotations().length &&
clip.rotations()[jointIndex] != null &&
clip.rotations()[jointIndex].length > 0
) {
return clip.rotations()[jointIndex][0]; // first frame return clip.rotations()[jointIndex][0]; // first frame
} }
return data.restRotations()[jointIndex]; return data.restRotations()[jointIndex];
@@ -117,12 +174,21 @@ public final class GltfSkinningEngine {
/** /**
* Get the animation translation for a joint (MC-converted). * Get the animation translation for a joint (MC-converted).
* Falls back to rest translation if no animation translation exists. * Falls back to rest translation if no animation translation exists or the
* channel is empty.
*/ */
private static Vector3f getAnimTranslation(GltfData data, GltfData.AnimationClip clip, int jointIndex) { private static Vector3f getAnimTranslation(
if (clip != null && clip.translations() != null GltfData data,
&& jointIndex < clip.translations().length GltfData.AnimationClip clip,
&& clip.translations()[jointIndex] != null) { int jointIndex
) {
if (
clip != null &&
clip.translations() != null &&
jointIndex < clip.translations().length &&
clip.translations()[jointIndex] != null &&
clip.translations()[jointIndex].length > 0
) {
return clip.translations()[jointIndex][0]; // first frame return clip.translations()[jointIndex][0]; // first frame
} }
return data.restTranslations()[jointIndex]; return data.restTranslations()[jointIndex];
@@ -144,10 +210,16 @@ public final class GltfSkinningEngine {
* @return new Quaternionf with the interpolated rotation (never mutates source data) * @return new Quaternionf with the interpolated rotation (never mutates source data)
*/ */
private static Quaternionf getInterpolatedRotation( private static Quaternionf getInterpolatedRotation(
GltfData data, GltfData.AnimationClip clip, int jointIndex, float time GltfData data,
GltfData.AnimationClip clip,
int jointIndex,
float time
) {
if (
clip == null ||
jointIndex >= clip.rotations().length ||
clip.rotations()[jointIndex] == null
) { ) {
if (clip == null || jointIndex >= clip.rotations().length
|| clip.rotations()[jointIndex] == null) {
// No animation data for this joint -- use rest pose (copy to avoid mutation) // No animation data for this joint -- use rest pose (copy to avoid mutation)
Quaternionf rest = data.restRotations()[jointIndex]; Quaternionf rest = data.restRotations()[jointIndex];
return new Quaternionf(rest); return new Quaternionf(rest);
@@ -187,11 +259,17 @@ public final class GltfSkinningEngine {
* @return new Vector3f with the interpolated translation (never mutates source data) * @return new Vector3f with the interpolated translation (never mutates source data)
*/ */
private static Vector3f getInterpolatedTranslation( private static Vector3f getInterpolatedTranslation(
GltfData data, GltfData.AnimationClip clip, int jointIndex, float time GltfData data,
GltfData.AnimationClip clip,
int jointIndex,
float time
) {
if (
clip == null ||
clip.translations() == null ||
jointIndex >= clip.translations().length ||
clip.translations()[jointIndex] == null
) { ) {
if (clip == null || clip.translations() == null
|| jointIndex >= clip.translations().length
|| clip.translations()[jointIndex] == null) {
// No animation data for this joint -- use rest pose (copy to avoid mutation) // No animation data for this joint -- use rest pose (copy to avoid mutation)
Vector3f rest = data.restTranslations()[jointIndex]; Vector3f rest = data.restTranslations()[jointIndex];
return new Vector3f(rest); return new Vector3f(rest);
@@ -232,9 +310,13 @@ public final class GltfSkinningEngine {
* @param tmpNorm pre-allocated scratch Vector4f for normal transforms * @param tmpNorm pre-allocated scratch Vector4f for normal transforms
*/ */
public static void skinVertex( public static void skinVertex(
GltfData data, int vertexIdx, Matrix4f[] jointMatrices, GltfData data,
float[] outPos, float[] outNormal, int vertexIdx,
Vector4f tmpPos, Vector4f tmpNorm Matrix4f[] jointMatrices,
float[] outPos,
float[] outNormal,
Vector4f tmpPos,
Vector4f tmpNorm
) { ) {
float[] positions = data.positions(); float[] positions = data.positions();
float[] normals = data.normals(); float[] normals = data.normals();
@@ -252,13 +334,23 @@ public final class GltfSkinningEngine {
float nz = normals[vertexIdx * 3 + 2]; float nz = normals[vertexIdx * 3 + 2];
// LBS: v_skinned = Σ(w[i] * jointMatrix[j[i]] * v_rest) // LBS: v_skinned = Σ(w[i] * jointMatrix[j[i]] * v_rest)
float sx = 0, sy = 0, sz = 0; float sx = 0,
float snx = 0, sny = 0, snz = 0; sy = 0,
sz = 0;
float snx = 0,
sny = 0,
snz = 0;
int jointCount = data.jointCount();
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
int ji = joints[vertexIdx * 4 + i]; int ji = joints[vertexIdx * 4 + i];
float w = weights[vertexIdx * 4 + i]; float w = weights[vertexIdx * 4 + i];
if (w <= 0.0f || ji >= jointMatrices.length) continue; // Guard against jointCount, NOT jointMatrices.length. The scratch
// pool (P2-04) means jointMatrices may be longer than jointCount,
// with trailing slots holding stale matrices from the previous
// item's skeleton. Tightens the bound back to the pre-scratch
// semantics. Closes B-batch review RISK-E01.
if (w <= 0.0f || ji >= jointCount) continue;
Matrix4f jm = jointMatrices[ji]; Matrix4f jm = jointMatrices[ji];

View File

@@ -0,0 +1,20 @@
package com.tiedup.remake.client.gltf.diagnostic;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.jetbrains.annotations.Nullable;
/**
* A single diagnostic finding from GLB validation.
*/
@OnlyIn(Dist.CLIENT)
public record GlbDiagnostic(
ResourceLocation source,
@Nullable ResourceLocation itemDef,
Severity severity,
String code,
String message
) {
public enum Severity { ERROR, WARNING, INFO }
}

View File

@@ -0,0 +1,49 @@
package com.tiedup.remake.client.gltf.diagnostic;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Static client-side registry of GLB validation results.
* Populated during resource reload, queried by /tiedup validate command.
*/
@OnlyIn(Dist.CLIENT)
public final class GlbDiagnosticRegistry {
private static final Map<ResourceLocation, GlbValidationResult> results =
new LinkedHashMap<>();
private GlbDiagnosticRegistry() {}
public static void clear() {
results.clear();
}
public static void addResult(GlbValidationResult result) {
results.put(result.source(), result);
}
public static Collection<GlbValidationResult> getAll() {
return Collections.unmodifiableCollection(results.values());
}
public static List<GlbValidationResult> getErrors() {
return results.values().stream()
.filter(r -> !r.passed())
.toList();
}
public static GlbValidationResult get(ResourceLocation source) {
return results.get(source);
}
public static int size() {
return results.size();
}
}

View File

@@ -0,0 +1,26 @@
package com.tiedup.remake.client.gltf.diagnostic;
import java.util.List;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Validation result for a single GLB file.
*/
@OnlyIn(Dist.CLIENT)
public record GlbValidationResult(
ResourceLocation source,
List<GlbDiagnostic> diagnostics,
boolean passed
) {
/** Create a result, computing passed from diagnostics. */
public static GlbValidationResult of(
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) {
boolean passed = diagnostics.stream()
.noneMatch(d -> d.severity() == GlbDiagnostic.Severity.ERROR);
return new GlbValidationResult(source, List.copyOf(diagnostics), passed);
}
}

View File

@@ -0,0 +1,535 @@
package com.tiedup.remake.client.gltf.diagnostic;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.tiedup.remake.client.gltf.GltfBoneMapper;
import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnostic.Severity;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Lightweight structural validator for GLB files.
*
* <p>Reads only the 12-byte header and the JSON chunk (first chunk) — never
* touches the binary mesh data. Produces a list of {@link GlbDiagnostic}
* findings that can range from hard errors (invalid header, missing skin) to
* informational notes (custom bone names).</p>
*
* <p>All methods are static; the class cannot be instantiated.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class GlbValidator {
// GLB binary format constants are shared with the runtime parsers to
// prevent divergence. See GlbParserUtils for the canonical definitions.
private static final int GLB_MAGIC =
com.tiedup.remake.client.gltf.GlbParserUtils.GLB_MAGIC;
private static final int GLB_VERSION =
com.tiedup.remake.client.gltf.GlbParserUtils.GLB_VERSION;
private static final int CHUNK_JSON =
com.tiedup.remake.client.gltf.GlbParserUtils.CHUNK_JSON;
private GlbValidator() {}
/**
* Validate a GLB file by reading its header and JSON chunk.
*
* @param input the raw GLB byte stream (will be fully consumed)
* @param source resource location of the GLB file (used in diagnostics)
* @return a validation result containing all findings
*/
public static GlbValidationResult validate(
InputStream input,
ResourceLocation source
) {
List<GlbDiagnostic> diagnostics = new ArrayList<>();
JsonObject root;
try {
root = readJsonChunk(input, source, diagnostics);
} catch (Exception e) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
"Failed to read GLB header or JSON chunk: " + e.getMessage()
));
return GlbValidationResult.of(source, diagnostics);
}
// If readJsonChunk added an ERROR, root will be null
if (root == null) {
return GlbValidationResult.of(source, diagnostics);
}
validateSkins(root, source, diagnostics);
validateMeshes(root, source, diagnostics);
validateAnimations(root, source, diagnostics);
return GlbValidationResult.of(source, diagnostics);
}
// ------------------------------------------------------------------ //
// Header + JSON chunk extraction //
// ------------------------------------------------------------------ //
/** Maximum GLB file size the validator will accept (shared with runtime parsers). */
private static final long MAX_GLB_SIZE =
com.tiedup.remake.client.gltf.GlbParserUtils.MAX_GLB_SIZE;
/**
* Parse the GLB header and extract the JSON chunk root object.
* Returns null and adds ERROR diagnostics on failure.
*/
private static JsonObject readJsonChunk(
InputStream input,
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) throws Exception {
// Read the 12-byte header first to check totalLength before
// committing to readAllBytes (OOM guard for malformed GLBs).
byte[] header = input.readNBytes(12);
if (header.length < 12) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
"File too small to contain a GLB header (" + header.length + " bytes)"
));
return null;
}
ByteBuffer headerBuf = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);
// -- Header (12 bytes) --
int magic = headerBuf.getInt();
if (magic != GLB_MAGIC) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
String.format("Bad magic number: 0x%08X (expected 0x%08X)", magic, GLB_MAGIC)
));
return null;
}
int version = headerBuf.getInt();
if (version != GLB_VERSION) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
"Unsupported GLB version " + version + " (expected " + GLB_VERSION + ")"
));
return null;
}
int totalLength = headerBuf.getInt();
// OOM guard: reject files that declare a size exceeding the cap.
// totalLength is a signed int, so treat negative values as > 2 GB.
if (totalLength < 0 || Integer.toUnsignedLong(totalLength) > MAX_GLB_SIZE) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "GLB_TOO_LARGE",
"GLB declares totalLength=" + Integer.toUnsignedLong(totalLength)
+ " bytes which exceeds the " + (MAX_GLB_SIZE / (1024 * 1024))
+ " MB safety cap — aborting validation"
));
return null;
}
// Now read the remainder (totalLength includes the 12-byte header)
int remaining = totalLength - 12;
if (remaining < 0) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
"GLB totalLength (" + totalLength + ") is smaller than the header itself"
));
return null;
}
byte[] restBytes = input.readNBytes(remaining);
ByteBuffer buf = ByteBuffer.wrap(restBytes).order(ByteOrder.LITTLE_ENDIAN);
// -- First chunk must be JSON --
if (buf.remaining() < 8) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
"File truncated: no chunk header after GLB header"
));
return null;
}
int jsonChunkLength = buf.getInt();
int jsonChunkType = buf.getInt();
if (jsonChunkType != CHUNK_JSON) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
String.format(
"First chunk is not JSON: type 0x%08X (expected 0x%08X)",
jsonChunkType, CHUNK_JSON
)
));
return null;
}
if (buf.remaining() < jsonChunkLength) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
"File truncated: JSON chunk declares " + jsonChunkLength
+ " bytes but only " + buf.remaining() + " remain"
));
return null;
}
byte[] jsonBytes = new byte[jsonChunkLength];
buf.get(jsonBytes);
String jsonStr = new String(jsonBytes, StandardCharsets.UTF_8);
return JsonParser.parseString(jsonStr).getAsJsonObject();
}
// ------------------------------------------------------------------ //
// Skin / bone validation //
// ------------------------------------------------------------------ //
private static void validateSkins(
JsonObject root,
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) {
if (!root.has("skins")) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "NO_SKINS",
"GLB has no 'skins' array — skinned mesh rendering requires at least one skin"
));
return;
}
JsonArray skins = root.getAsJsonArray("skins");
if (skins.size() == 0) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "NO_SKINS",
"GLB 'skins' array is empty — skinned mesh rendering requires at least one skin"
));
return;
}
JsonArray nodes = root.has("nodes") ? root.getAsJsonArray("nodes") : null;
if (nodes == null) return;
// Iterate every skin. Furniture GLBs contain multiple (one per
// Player_* seat armature + one for the mesh itself); validating
// only skin 0 let a broken seat skin crash at load time after
// passing validation.
for (int si = 0; si < skins.size(); si++) {
validateSkin(skins.get(si).getAsJsonObject(), si, root, nodes, source, diagnostics);
}
}
private static void validateSkin(
JsonObject skin,
int skinIndex,
JsonObject root,
JsonArray nodes,
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) {
if (!skin.has("joints")) return;
JsonArray joints = skin.getAsJsonArray("joints");
String skinLabel = "skin " + skinIndex;
for (int j = 0; j < joints.size(); j++) {
int nodeIdx = joints.get(j).getAsInt();
if (nodeIdx < 0 || nodeIdx >= nodes.size()) {
continue;
}
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
if (!node.has("name")) {
continue;
}
String rawName = node.get("name").getAsString();
String boneName = com.tiedup.remake.client.gltf.GlbParserUtils.stripArmaturePrefix(
rawName
);
if (GltfBoneMapper.isKnownBone(boneName)) continue;
String suggestion = GltfBoneMapper.suggestBoneName(boneName);
if (suggestion != null) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "BONE_TYPO_SUGGESTION",
skinLabel + ": bone '" + boneName + "' is not recognized — did you mean '"
+ suggestion + "'?"
));
} else {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.INFO, "UNKNOWN_BONE",
skinLabel + ": bone '" + boneName + "' is not a standard MC bone (treated as custom bone)"
));
}
}
// IBM accessor element count + type checks. A short accessor throws
// AIOOBE when GlbParser builds per-joint matrices; an over-long one
// wastes memory. Missing IBM entirely: glTF spec substitutes identity,
// almost always an authoring bug (renders in bind pose at origin).
if (!skin.has("inverseBindMatrices")) {
// Zero-joint skins already trip earlier diagnostics; the
// "0 joints but no IBM" warning adds nothing.
if (joints.size() > 0) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "IBM_MISSING",
skinLabel + " has " + joints.size() + " joints but no inverseBindMatrices " +
"— runtime will substitute identity, mesh will render in bind pose at origin"
));
}
} else if (root.has("accessors")) {
int ibmAccIdx = skin.get("inverseBindMatrices").getAsInt();
JsonArray accessors = root.getAsJsonArray("accessors");
if (ibmAccIdx >= 0 && ibmAccIdx < accessors.size()) {
JsonObject ibmAcc = accessors
.get(ibmAccIdx)
.getAsJsonObject();
int ibmCount = ibmAcc.has("count")
? ibmAcc.get("count").getAsInt()
: -1;
String ibmType = ibmAcc.has("type")
? ibmAcc.get("type").getAsString()
: "";
if (!ibmAcc.has("type")) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "IBM_MISSING_TYPE",
skinLabel + " inverseBindMatrices accessor has no 'type' field — " +
"re-export with the skin's armature selected"
));
} else if (!"MAT4".equals(ibmType)) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "IBM_WRONG_TYPE",
skinLabel + " inverseBindMatrices type is '" + ibmType +
"', expected MAT4 — re-export with the skin's armature selected"
));
}
if (ibmCount >= 0 && ibmCount != joints.size()) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "IBM_COUNT_MISMATCH",
skinLabel + " inverseBindMatrices has " + ibmCount +
" entries, skin has " + joints.size() + " joints — " +
"re-export with the skin's armature selected"
));
}
}
}
}
// ------------------------------------------------------------------ //
// Mesh validation //
// ------------------------------------------------------------------ //
private static void validateMeshes(
JsonObject root,
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) {
if (!root.has("meshes")) {
return;
}
JsonArray meshes = root.getAsJsonArray("meshes");
// Count non-Player meshes
int nonPlayerCount = 0;
for (int mi = 0; mi < meshes.size(); mi++) {
JsonObject mesh = meshes.get(mi).getAsJsonObject();
String meshName = mesh.has("name")
? mesh.get("name").getAsString()
: "";
if (!com.tiedup.remake.client.gltf.GlbParserUtils.isPlayerMesh(meshName)) {
nonPlayerCount++;
}
}
if (nonPlayerCount > 1) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "MULTI_MESH_AMBIGUITY",
nonPlayerCount + " non-Player meshes found — name your item mesh 'Item' "
+ "for explicit selection"
));
}
// Check WEIGHTS_0 on the mesh that GlbParser would actually select:
// 1) mesh named "Item", 2) last non-Player mesh.
JsonObject targetMesh = null;
String targetMeshName = null;
for (int mi = 0; mi < meshes.size(); mi++) {
JsonObject mesh = meshes.get(mi).getAsJsonObject();
String meshName = mesh.has("name")
? mesh.get("name").getAsString()
: "";
if ("Item".equals(meshName)) {
targetMesh = mesh;
targetMeshName = meshName;
break; // Convention match — same as GlbParser
}
if (!com.tiedup.remake.client.gltf.GlbParserUtils.isPlayerMesh(meshName)) {
targetMesh = mesh;
targetMeshName = meshName;
}
}
if (targetMesh != null && targetMesh.has("primitives")) {
JsonArray primitives = targetMesh.getAsJsonArray("primitives");
if (primitives.size() > 0) {
JsonObject firstPrimitive = primitives.get(0).getAsJsonObject();
if (firstPrimitive.has("attributes")) {
JsonObject attributes = firstPrimitive.getAsJsonObject("attributes");
String meshLabel = targetMeshName != null
? "'" + targetMeshName + "'"
: "(unnamed)";
if (!attributes.has("WEIGHTS_0")) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "NO_WEIGHTS",
"Selected mesh " + meshLabel
+ " first primitive has no WEIGHTS_0 attribute "
+ "— skinning will not work correctly"
));
}
// Without JOINTS_0 every vertex implicitly binds to joint
// 0 (root), so the item renders attached to the root bone
// regardless of how the artist weighted it in Blender.
if (!attributes.has("JOINTS_0")) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "NO_JOINTS",
"Selected mesh " + meshLabel
+ " first primitive has no JOINTS_0 attribute "
+ "— vertices will all bind to root joint (bind pose render)"
));
}
// Weight-sum authoring check intentionally omitted: the
// only cheap signal available here (WEIGHTS_0 accessor
// min/max) is per-component across all vertices, which
// cannot be summed to reconstruct any single vertex's
// weight total (the min of component 0 comes from a
// different vertex than the min of component 1). The
// earlier heuristic produced both false positives
// (legitimate 1-influence meshes) and false negatives
// (mixed meshes where one vertex totals 0.9). The parser
// still normalizes weights at load, so the runtime path
// is safe; a proper authoring check would need to decode
// the BIN chunk and scan each vertex tuple. Deferred.
}
}
}
}
// ------------------------------------------------------------------ //
// Animation validation //
// ------------------------------------------------------------------ //
private static void validateAnimations(
JsonObject root,
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) {
if (!root.has("animations")) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "NO_IDLE_ANIMATION",
"GLB has no 'animations' array — no Idle animation found"
));
return;
}
JsonArray animations = root.getAsJsonArray("animations");
if (animations.size() == 0) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "NO_IDLE_ANIMATION",
"GLB 'animations' array is empty — no Idle animation found"
));
return;
}
boolean hasIdle = false;
for (int ai = 0; ai < animations.size(); ai++) {
JsonObject anim = animations.get(ai).getAsJsonObject();
if (!anim.has("name")) {
continue;
}
String animName = com.tiedup.remake.client.gltf.GlbParserUtils.stripArmaturePrefix(
anim.get("name").getAsString()
);
if ("Idle".equals(animName)) {
hasIdle = true;
break;
}
}
if (!hasIdle) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "NO_IDLE_ANIMATION",
"No animation named 'Idle' found — the default rest pose may not display correctly"
));
}
// Animation channel target ∈ skin.joints: any channel that targets a
// node NOT in the first skin's joints is silently dropped by the parser
// (GlbParserUtils.parseAnimation: `nodeToJoint[nodeIdx] < 0 → skip`).
// Most commonly: artist keyframes the Armature root object instead of
// its bones. Warn so the artist can fix it in Blender.
validateAnimationTargets(root, animations, source, diagnostics);
}
private static void validateAnimationTargets(
JsonObject root,
JsonArray animations,
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) {
if (!root.has("skins")) return;
JsonArray skins = root.getAsJsonArray("skins");
if (skins.size() == 0) return;
JsonObject skin = skins.get(0).getAsJsonObject();
if (!skin.has("joints")) return;
JsonArray joints = skin.getAsJsonArray("joints");
java.util.Set<Integer> jointNodes = new java.util.HashSet<>();
for (int j = 0; j < joints.size(); j++) {
jointNodes.add(joints.get(j).getAsInt());
}
int droppedChannels = 0;
int totalChannels = 0;
for (int ai = 0; ai < animations.size(); ai++) {
JsonObject anim = animations.get(ai).getAsJsonObject();
if (!anim.has("channels")) continue;
JsonArray channels = anim.getAsJsonArray("channels");
for (int ci = 0; ci < channels.size(); ci++) {
JsonObject ch = channels.get(ci).getAsJsonObject();
if (!ch.has("target")) continue;
JsonObject target = ch.getAsJsonObject("target");
if (!target.has("node")) continue;
totalChannels++;
int nodeIdx = target.get("node").getAsInt();
if (!jointNodes.contains(nodeIdx)) {
droppedChannels++;
}
}
}
if (droppedChannels > 0) {
diagnostics.add(new GlbDiagnostic(
source,
null,
Severity.WARNING,
"ANIM_CHANNEL_NOT_IN_SKIN",
droppedChannels +
" / " +
totalChannels +
" animation channel(s) target node(s) outside skin.joints — " +
"these channels will be silently dropped by the runtime. " +
"Keyframe the armature's bones, not the Armature object itself."
));
}
}
}

View File

@@ -20,7 +20,6 @@ import net.minecraftforge.fml.common.Mod;
* Overlay that shows a progress bar for tying/untying/struggling actions. * Overlay that shows a progress bar for tying/untying/struggling actions.
* Displayed above the hotbar when an action is in progress. * Displayed above the hotbar when an action is in progress.
* *
* Phase 16: GUI Revamp - Progress bar overlay
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT) @Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
@@ -135,7 +134,7 @@ public class ProgressOverlay {
*/ */
private static ProgressInfo getActiveProgress(PlayerBindState state) { private static ProgressInfo getActiveProgress(PlayerBindState state) {
// Check client tying task // Check client tying task
PlayerStateTask tyingTask = state.getClientTyingTask(); PlayerStateTask tyingTask = state.tasks().getClientTyingTask();
if (tyingTask != null && !tyingTask.isOutdated()) { if (tyingTask != null && !tyingTask.isOutdated()) {
float progress = tyingTask.getProgress(); float progress = tyingTask.getProgress();
Component text = getTyingText(tyingTask); Component text = getTyingText(tyingTask);
@@ -143,7 +142,7 @@ public class ProgressOverlay {
} }
// Check client untying task // Check client untying task
PlayerStateTask untyingTask = state.getClientUntyingTask(); PlayerStateTask untyingTask = state.tasks().getClientUntyingTask();
if (untyingTask != null && !untyingTask.isOutdated()) { if (untyingTask != null && !untyingTask.isOutdated()) {
float progress = untyingTask.getProgress(); float progress = untyingTask.getProgress();
Component text = getUntyingText(untyingTask); Component text = getUntyingText(untyingTask);
@@ -151,7 +150,7 @@ public class ProgressOverlay {
} }
// Check client feeding task // Check client feeding task
PlayerStateTask feedingTask = state.getClientFeedingTask(); PlayerStateTask feedingTask = state.tasks().getClientFeedingTask();
if (feedingTask != null && !feedingTask.isOutdated()) { if (feedingTask != null && !feedingTask.isOutdated()) {
float progress = feedingTask.getProgress(); float progress = feedingTask.getProgress();
Component text = getFeedingText(feedingTask); Component text = getFeedingText(feedingTask);

View File

@@ -18,7 +18,6 @@ import net.minecraftforge.fml.common.Mod;
* Overlay that shows status icons when player is restrained. * Overlay that shows status icons when player is restrained.
* Icons appear in top-left corner showing current bondage state. * Icons appear in top-left corner showing current bondage state.
* *
* Phase 16: GUI Revamp - Status indicator overlay
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT) @Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)

View File

@@ -1,10 +1,10 @@
package com.tiedup.remake.client.gui.overlays; package com.tiedup.remake.client.gui.overlays;
import com.tiedup.remake.client.ModKeybindings; import com.tiedup.remake.client.ModKeybindings;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.IBondageState; import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.KidnappedHelper; import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.v2.BodyRegionV2;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.Entity;

View File

@@ -2,8 +2,8 @@ package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.network.ModNetwork; import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.item.PacketAdjustItem; import com.tiedup.remake.network.item.PacketAdjustItem;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.state.PlayerBindState; import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.LivingEntity;
@@ -16,7 +16,6 @@ import net.minecraftforge.api.distmarker.OnlyIn;
* Screen for adjusting Y position of the player's own gags and blindfolds. * Screen for adjusting Y position of the player's own gags and blindfolds.
* Shows 3D preview of player with real-time adjustment. * Shows 3D preview of player with real-time adjustment.
* *
* Phase 16b: GUI Refactoring - Simplified using BaseAdjustmentScreen
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public class AdjustmentScreen extends BaseAdjustmentScreen { public class AdjustmentScreen extends BaseAdjustmentScreen {

View File

@@ -198,12 +198,12 @@ public abstract class BaseAdjustmentScreen extends BaseScreen {
int totalWidth = buttonWidth * 3 + MARGIN_S * 2; int totalWidth = buttonWidth * 3 + MARGIN_S * 2;
int actionStartX = this.leftPos + (this.imageWidth - totalWidth) / 2; int actionStartX = this.leftPos + (this.imageWidth - totalWidth) / 2;
resetButton = Button.builder(Component.literal("0"), b -> resetValue()) resetButton = Button.builder(Component.translatable("gui.tiedup.adjustment.btn.reset"), b -> resetValue())
.bounds(actionStartX, actionY, buttonWidth, BUTTON_HEIGHT) .bounds(actionStartX, actionY, buttonWidth, BUTTON_HEIGHT)
.build(); .build();
this.addRenderableWidget(resetButton); this.addRenderableWidget(resetButton);
decrementButton = Button.builder(Component.literal("-0.25"), b -> decrementButton = Button.builder(Component.translatable("gui.tiedup.adjustment.btn.decrement"), b ->
slider.decrement() slider.decrement()
) )
.bounds( .bounds(
@@ -215,7 +215,7 @@ public abstract class BaseAdjustmentScreen extends BaseScreen {
.build(); .build();
this.addRenderableWidget(decrementButton); this.addRenderableWidget(decrementButton);
incrementButton = Button.builder(Component.literal("+0.25"), b -> incrementButton = Button.builder(Component.translatable("gui.tiedup.adjustment.btn.increment"), b ->
slider.increment() slider.increment()
) )
.bounds( .bounds(
@@ -235,14 +235,14 @@ public abstract class BaseAdjustmentScreen extends BaseScreen {
int totalWidth = buttonWidth * 3 + MARGIN_S * 2; int totalWidth = buttonWidth * 3 + MARGIN_S * 2;
int scaleStartX = this.leftPos + (this.imageWidth - totalWidth) / 2; int scaleStartX = this.leftPos + (this.imageWidth - totalWidth) / 2;
scaleResetButton = Button.builder(Component.literal("1x"), b -> scaleResetButton = Button.builder(Component.translatable("gui.tiedup.adjustment.btn.scale_reset"), b ->
applyScale(AdjustmentHelper.DEFAULT_SCALE) applyScale(AdjustmentHelper.DEFAULT_SCALE)
) )
.bounds(scaleStartX, scaleY, buttonWidth, BUTTON_HEIGHT) .bounds(scaleStartX, scaleY, buttonWidth, BUTTON_HEIGHT)
.build(); .build();
this.addRenderableWidget(scaleResetButton); this.addRenderableWidget(scaleResetButton);
scaleDecrementButton = Button.builder(Component.literal("-0.1"), b -> scaleDecrementButton = Button.builder(Component.translatable("gui.tiedup.adjustment.btn.scale_decrement"), b ->
applyScale(currentScaleValue - AdjustmentHelper.SCALE_STEP) applyScale(currentScaleValue - AdjustmentHelper.SCALE_STEP)
) )
.bounds( .bounds(
@@ -254,7 +254,7 @@ public abstract class BaseAdjustmentScreen extends BaseScreen {
.build(); .build();
this.addRenderableWidget(scaleDecrementButton); this.addRenderableWidget(scaleDecrementButton);
scaleIncrementButton = Button.builder(Component.literal("+0.1"), b -> scaleIncrementButton = Button.builder(Component.translatable("gui.tiedup.adjustment.btn.scale_increment"), b ->
applyScale(currentScaleValue + AdjustmentHelper.SCALE_STEP) applyScale(currentScaleValue + AdjustmentHelper.SCALE_STEP)
) )
.bounds( .bounds(

View File

@@ -9,7 +9,6 @@ import com.tiedup.remake.network.cell.PacketOpenCellManager.CellSyncData;
import com.tiedup.remake.network.cell.PacketOpenCellManager.PrisonerInfo; import com.tiedup.remake.network.cell.PacketOpenCellManager.PrisonerInfo;
import com.tiedup.remake.network.cell.PacketRenameCell; import com.tiedup.remake.network.cell.PacketRenameCell;
import java.util.List; import java.util.List;
import org.jetbrains.annotations.Nullable;
import net.minecraft.ChatFormatting; import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.Button;
@@ -17,6 +16,7 @@ import net.minecraft.client.gui.components.EditBox;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
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 org.jetbrains.annotations.Nullable;
/** /**
* Screen for managing player-owned cells. * Screen for managing player-owned cells.
@@ -565,7 +565,7 @@ public class CellManagerScreen extends BaseScreen {
if (cell.prisoners.isEmpty()) { if (cell.prisoners.isEmpty()) {
graphics.drawString( graphics.drawString(
this.font, this.font,
Component.literal(" \u2514\u2500 ") Component.translatable("gui.tiedup.cell_manager.tree_prefix")
.append( .append(
Component.translatable( Component.translatable(
"gui.tiedup.cell_manager.label.empty" "gui.tiedup.cell_manager.label.empty"

View File

@@ -8,13 +8,13 @@ import com.tiedup.remake.network.cell.PacketAssignCellToCollar;
import com.tiedup.remake.network.cell.PacketOpenCellSelector.CellOption; import com.tiedup.remake.network.cell.PacketOpenCellSelector.CellOption;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.jetbrains.annotations.Nullable;
import net.minecraft.ChatFormatting; import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.Button;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
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 org.jetbrains.annotations.Nullable;
/** /**
* Modal screen for selecting a cell to assign to a collar. * Modal screen for selecting a cell to assign to a collar.

View File

@@ -234,9 +234,7 @@ public class CommandWandScreen extends Screen {
addBottomButtons(); addBottomButtons();
} }
// =========================================================================
// Status Tab // Status Tab
// =========================================================================
/** Calculate Y position after all rendered status content (needs, mood, cell, home) */ /** Calculate Y position after all rendered status content (needs, mood, cell, home) */
private int getStatusButtonsY(int startY) { private int getStatusButtonsY(int startY) {
@@ -332,9 +330,7 @@ public class CommandWandScreen extends Screen {
this.addRenderableWidget(threatenBtn); this.addRenderableWidget(threatenBtn);
} }
// =========================================================================
// Commands Tab // Commands Tab
// =========================================================================
private static final int SECTION_HEADER_H = 16; // header line + text + padding private static final int SECTION_HEADER_H = 16; // header line + text + padding
@@ -382,9 +378,7 @@ public class CommandWandScreen extends Screen {
); );
} }
// =========================================================================
// Jobs Tab // Jobs Tab
// =========================================================================
private void buildJobsTab(int contentX, int contentWidth, int y) { private void buildJobsTab(int contentX, int contentWidth, int y) {
int thirdWidth = (contentWidth - BTN_SPACING * 2) / 3; int thirdWidth = (contentWidth - BTN_SPACING * 2) / 3;
@@ -463,9 +457,7 @@ public class CommandWandScreen extends Screen {
); );
} }
// =========================================================================
// Bottom Buttons (all tabs) // Bottom Buttons (all tabs)
// =========================================================================
private void addBottomButtons() { private void addBottomButtons() {
int bottomBtnWidth = 90; int bottomBtnWidth = 90;
@@ -506,9 +498,7 @@ public class CommandWandScreen extends Screen {
this.addRenderableWidget(stopBtn); this.addRenderableWidget(stopBtn);
} }
// =========================================================================
// Button helpers // Button helpers
// =========================================================================
private void addCommandButton(int x, int y, NpcCommand command, int width) { private void addCommandButton(int x, int y, NpcCommand command, int width) {
boolean isActive = activeCommand.equals(command.name()); boolean isActive = activeCommand.equals(command.name());
@@ -558,12 +548,9 @@ public class CommandWandScreen extends Screen {
} }
private void addFollowDistanceCycleButton(int x, int y, int width) { private void addFollowDistanceCycleButton(int x, int y, int width) {
String modeText = switch (followDistanceMode) { Component modeLabel = Component.translatable(
case "HEEL" -> "H"; "gui.tiedup.command_wand.follow_distance.abbrev." + followDistanceMode.toLowerCase()
case "CLOSE" -> "C"; );
case "FAR" -> "F";
default -> "?";
};
Component tooltip = Component.translatable( Component tooltip = Component.translatable(
"gui.tiedup.command_wand.follow_distance.tooltip" "gui.tiedup.command_wand.follow_distance.tooltip"
@@ -581,7 +568,7 @@ public class CommandWandScreen extends Screen {
) )
); );
Button btn = Button.builder(Component.literal(modeText), b -> Button btn = Button.builder(modeLabel, b ->
cycleFollowDistance() cycleFollowDistance()
) )
.bounds(x, y, width, BUTTON_HEIGHT) .bounds(x, y, width, BUTTON_HEIGHT)
@@ -667,9 +654,7 @@ public class CommandWandScreen extends Screen {
this.addRenderableWidget(btn); this.addRenderableWidget(btn);
} }
// =========================================================================
// Network actions // Network actions
// =========================================================================
private void sendCommand(NpcCommand command) { private void sendCommand(NpcCommand command) {
ModNetwork.sendToServer( ModNetwork.sendToServer(
@@ -692,7 +677,9 @@ public class CommandWandScreen extends Screen {
} }
private void toggleAutoRest() { private void toggleAutoRest() {
ModNetwork.sendToServer(PacketNpcCommand.toggleAutoRest(entityUUID, true)); ModNetwork.sendToServer(
PacketNpcCommand.toggleAutoRest(entityUUID, true)
);
} }
private void openInventory() { private void openInventory() {
@@ -728,9 +715,7 @@ public class CommandWandScreen extends Screen {
return -1; return -1;
} }
// =========================================================================
// Render // Render
// =========================================================================
@Override @Override
public void render( public void render(
@@ -805,9 +790,7 @@ public class CommandWandScreen extends Screen {
super.render(graphics, mouseX, mouseY, partialTick); super.render(graphics, mouseX, mouseY, partialTick);
} }
// =========================================================================
// Render: Status Tab // Render: Status Tab
// =========================================================================
private void renderStatusContent( private void renderStatusContent(
GuiGraphics graphics, GuiGraphics graphics,
@@ -871,7 +854,7 @@ public class CommandWandScreen extends Screen {
) { ) {
graphics.renderTooltip( graphics.renderTooltip(
this.font, this.font,
Component.literal((int) hunger + "%"), Component.translatable("gui.tiedup.command_wand.percent", (int) hunger),
mouseX, mouseX,
mouseY mouseY
); );
@@ -885,7 +868,7 @@ public class CommandWandScreen extends Screen {
) { ) {
graphics.renderTooltip( graphics.renderTooltip(
this.font, this.font,
Component.literal((int) rest + "%"), Component.translatable("gui.tiedup.command_wand.percent", (int) rest),
mouseX, mouseX,
mouseY mouseY
); );
@@ -1029,9 +1012,7 @@ public class CommandWandScreen extends Screen {
} }
} }
// =========================================================================
// Render: Commands Tab // Render: Commands Tab
// =========================================================================
private void renderCommandContent( private void renderCommandContent(
GuiGraphics graphics, GuiGraphics graphics,
@@ -1059,9 +1040,7 @@ public class CommandWandScreen extends Screen {
); );
} }
// =========================================================================
// Render: Jobs Tab // Render: Jobs Tab
// =========================================================================
private void renderJobsContent( private void renderJobsContent(
GuiGraphics graphics, GuiGraphics graphics,
@@ -1119,9 +1098,7 @@ public class CommandWandScreen extends Screen {
); );
} }
// =========================================================================
// Render helpers // Render helpers
// =========================================================================
private void renderSectionHeader( private void renderSectionHeader(
GuiGraphics graphics, GuiGraphics graphics,

View File

@@ -18,8 +18,6 @@ import net.minecraftforge.api.distmarker.OnlyIn;
* GUI screen for interactive conversations with NPCs. * GUI screen for interactive conversations with NPCs.
* Displays available conversation topics with effectiveness indicators. * Displays available conversation topics with effectiveness indicators.
* *
* Phase 5: Enhanced Conversation System
* Phase 2: Refactored to extend BaseInteractionScreen
* *
* DISABLED: Conversation system not in use. Kept because PacketEndConversationS2C * DISABLED: Conversation system not in use. Kept because PacketEndConversationS2C
* references this class in an instanceof check. * references this class in an instanceof check.

View File

@@ -15,7 +15,6 @@ import net.minecraftforge.api.distmarker.OnlyIn;
import org.lwjgl.glfw.GLFW; import org.lwjgl.glfw.GLFW;
/** /**
* Phase 2.5: Client-side GUI for Lockpick mini-game (Skyrim-style).
* *
* Features: * Features:
* - Sweet spot is HIDDEN (uniform gray bar) * - Sweet spot is HIDDEN (uniform gray bar)

View File

@@ -216,24 +216,24 @@ public class MerchantTradingScreen extends BaseScreen {
int goldIngots = countItemInInventory(player, Items.GOLD_INGOT); int goldIngots = countItemInInventory(player, Items.GOLD_INGOT);
int goldNuggets = countItemInInventory(player, Items.GOLD_NUGGET); int goldNuggets = countItemInInventory(player, Items.GOLD_NUGGET);
Component goldText = Component.literal("Your Gold: ") Component goldText = Component.translatable("gui.tiedup.merchant.your_gold")
.append( .append(
Component.literal(goldIngots + "x ").withStyle(style -> Component.translatable("gui.tiedup.merchant.gold_amount", goldIngots).withStyle(style ->
style.withColor(0xFFFFD700) style.withColor(0xFFFFD700)
) )
) )
.append( .append(
Component.literal("").withStyle(style -> Component.translatable("gui.tiedup.merchant.gold_icon").withStyle(style ->
style.withColor(0xFFFFD700) style.withColor(0xFFFFD700)
) )
) )
.append( .append(
Component.literal("+ " + goldNuggets + "x ").withStyle( Component.translatable("gui.tiedup.merchant.nugget_amount", goldNuggets).withStyle(
style -> style.withColor(0xFFFFA500) style -> style.withColor(0xFFFFA500)
) )
) )
.append( .append(
Component.literal("").withStyle(style -> Component.translatable("gui.tiedup.merchant.nugget_icon").withStyle(style ->
style.withColor(0xFFFFA500) style.withColor(0xFFFFA500)
) )
); );

View File

@@ -4,13 +4,13 @@ import com.tiedup.remake.client.gui.util.GuiTextureHelper;
import com.tiedup.remake.client.gui.widgets.EntityPreviewWidget; import com.tiedup.remake.client.gui.widgets.EntityPreviewWidget;
import com.tiedup.remake.entities.EntityDamsel; import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.entities.NpcInventoryMenu; import com.tiedup.remake.entities.NpcInventoryMenu;
import org.jetbrains.annotations.Nullable;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Inventory;
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 org.jetbrains.annotations.Nullable;
/** /**
* Screen for viewing and managing NPC inventory. * Screen for viewing and managing NPC inventory.
@@ -53,7 +53,7 @@ public class NpcInventoryScreen
Inventory playerInventory, Inventory playerInventory,
Component title Component title
) { ) {
super(menu, playerInventory, Component.literal(menu.getNpcName())); super(menu, playerInventory, Component.translatable("gui.tiedup.npc_inventory.title_name", menu.getNpcName()));
// Calculate rows from NPC inventory size // Calculate rows from NPC inventory size
this.npcRows = (menu.getNpcSlotCount() + 8) / 9; this.npcRows = (menu.getNpcSlotCount() + 8) / 9;

View File

@@ -22,7 +22,6 @@ import net.minecraftforge.api.distmarker.OnlyIn;
* - Ask to be untied * - Ask to be untied
* - End conversation * - End conversation
* *
* Phase 2: Refactored to extend BaseInteractionScreen
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public class PetRequestScreen extends BaseInteractionScreen { public class PetRequestScreen extends BaseInteractionScreen {

View File

@@ -2,8 +2,8 @@ package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.network.ModNetwork; import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.item.PacketAdjustRemote; import com.tiedup.remake.network.item.PacketAdjustRemote;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.state.IBondageState; import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.UUID; import java.util.UUID;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.LivingEntity;
@@ -15,7 +15,6 @@ import net.minecraftforge.api.distmarker.OnlyIn;
* Screen for remotely adjusting Y position of a slave's gags and blindfolds. * Screen for remotely adjusting Y position of a slave's gags and blindfolds.
* Similar to AdjustmentScreen but operates on a slave entity. * Similar to AdjustmentScreen but operates on a slave entity.
* *
* Phase 16b: GUI Refactoring - Simplified using BaseAdjustmentScreen
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public class RemoteAdjustmentScreen extends BaseAdjustmentScreen { public class RemoteAdjustmentScreen extends BaseAdjustmentScreen {

View File

@@ -1,16 +1,16 @@
package com.tiedup.remake.client.gui.screens; package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.client.gui.util.GuiColors; import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.client.gui.util.GuiLayoutConstants; import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
import com.tiedup.remake.client.gui.widgets.SlaveEntryWidget; import com.tiedup.remake.client.gui.widgets.SlaveEntryWidget;
import com.tiedup.remake.items.base.ItemCollar; import com.tiedup.remake.v2.bondage.CollarHelper;
import com.tiedup.remake.network.ModNetwork; import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.slave.PacketSlaveAction; import com.tiedup.remake.network.slave.PacketSlaveAction;
import com.tiedup.remake.state.IBondageState; import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.state.PlayerBindState; import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.state.PlayerCaptorManager; import com.tiedup.remake.state.PlayerCaptorManager;
import com.tiedup.remake.util.KidnappedHelper; import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -142,9 +142,11 @@ public class SlaveManagementScreen extends BaseScreen {
IBondageState kidnapped = KidnappedHelper.getKidnappedState(entity); IBondageState kidnapped = KidnappedHelper.getKidnappedState(entity);
if (kidnapped != null && kidnapped.hasCollar()) { if (kidnapped != null && kidnapped.hasCollar()) {
ItemStack collarStack = kidnapped.getEquipment(BodyRegionV2.NECK); ItemStack collarStack = kidnapped.getEquipment(
if (collarStack.getItem() instanceof ItemCollar collar) { BodyRegionV2.NECK
if (collar.isOwner(collarStack, player)) { );
if (CollarHelper.isCollar(collarStack)) {
if (CollarHelper.isOwner(collarStack, player)) {
addSlaveEntry(kidnapped); addSlaveEntry(kidnapped);
addedUUIDs.add(entity.getUUID()); addedUUIDs.add(entity.getUUID());
} }

View File

@@ -70,7 +70,10 @@ public class UnifiedBondageScreen extends BaseScreen {
this(ActionPanel.ScreenMode.MASTER, target); this(ActionPanel.ScreenMode.MASTER, target);
} }
private UnifiedBondageScreen(ActionPanel.ScreenMode mode, LivingEntity target) { private UnifiedBondageScreen(
ActionPanel.ScreenMode mode,
LivingEntity target
) {
super(Component.translatable("gui.tiedup.unified_bondage")); super(Component.translatable("gui.tiedup.unified_bondage"));
this.mode = mode; this.mode = mode;
this.targetEntity = target; this.targetEntity = target;
@@ -78,17 +81,29 @@ public class UnifiedBondageScreen extends BaseScreen {
} }
private LivingEntity getTarget() { private LivingEntity getTarget() {
return (mode == ActionPanel.ScreenMode.SELF) ? minecraft.player : targetEntity; return (mode == ActionPanel.ScreenMode.SELF)
? minecraft.player
: targetEntity;
} }
@Override @Override
protected int getPreferredWidth() { protected int getPreferredWidth() {
return GuiLayoutConstants.getResponsiveWidth(this.width, 0.65f, 420, 600); return GuiLayoutConstants.getResponsiveWidth(
this.width,
0.65f,
420,
600
);
} }
@Override @Override
protected int getPreferredHeight() { protected int getPreferredHeight() {
return GuiLayoutConstants.getResponsiveHeight(this.height, 0.75f, 350, 500); return GuiLayoutConstants.getResponsiveHeight(
this.height,
0.75f,
350,
500
);
} }
@Override @Override
@@ -109,7 +124,10 @@ public class UnifiedBondageScreen extends BaseScreen {
} }
} }
int contentTop = topPos + GuiLayoutConstants.TITLE_HEIGHT + GuiLayoutConstants.MARGIN_M; int contentTop =
topPos +
GuiLayoutConstants.TITLE_HEIGHT +
GuiLayoutConstants.MARGIN_M;
// === Tab Bar === // === Tab Bar ===
tabBar = new RegionTabBar(leftPos + 2, contentTop, imageWidth - 4); tabBar = new RegionTabBar(leftPos + 2, contentTop, imageWidth - 4);
@@ -119,15 +137,24 @@ public class UnifiedBondageScreen extends BaseScreen {
int belowTabs = contentTop + 30; int belowTabs = contentTop + 30;
int statusBarHeight = 46; int statusBarHeight = 46;
int mainContentHeight = imageHeight - (belowTabs - topPos) - statusBarHeight - GuiLayoutConstants.MARGIN_S; int mainContentHeight =
imageHeight -
(belowTabs - topPos) -
statusBarHeight -
GuiLayoutConstants.MARGIN_S;
// === Preview (Left, 40%) === // === Preview (Left, 40%) ===
int previewWidth = (int)((imageWidth - GuiLayoutConstants.MARGIN_M * 3) * 0.40f); int previewWidth = (int) ((imageWidth -
GuiLayoutConstants.MARGIN_M * 3) *
0.40f);
LivingEntity target = getTarget(); LivingEntity target = getTarget();
if (target != null) { if (target != null) {
preview = new EntityPreviewWidget( preview = new EntityPreviewWidget(
leftPos + GuiLayoutConstants.MARGIN_M, belowTabs, leftPos + GuiLayoutConstants.MARGIN_M,
previewWidth, mainContentHeight, target belowTabs,
previewWidth,
mainContentHeight,
target
); );
preview.setAutoRotate(true); preview.setAutoRotate(true);
preview.setAutoRotateSpeed(0.3f); preview.setAutoRotateSpeed(0.3f);
@@ -136,28 +163,42 @@ public class UnifiedBondageScreen extends BaseScreen {
} }
// === Right panel area === // === Right panel area ===
int rightX = leftPos + GuiLayoutConstants.MARGIN_M + previewWidth + GuiLayoutConstants.MARGIN_M; int rightX =
int rightWidth = imageWidth - (rightX - leftPos) - GuiLayoutConstants.MARGIN_M; leftPos +
GuiLayoutConstants.MARGIN_M +
previewWidth +
GuiLayoutConstants.MARGIN_M;
int rightWidth =
imageWidth - (rightX - leftPos) - GuiLayoutConstants.MARGIN_M;
// Action panel height // Action panel height
int actionPanelHeight = 84; int actionPanelHeight = 84;
int slotsHeight = mainContentHeight - actionPanelHeight - GuiLayoutConstants.MARGIN_S; int slotsHeight =
mainContentHeight - actionPanelHeight - GuiLayoutConstants.MARGIN_S;
// === Region Slots === // === Region Slots ===
buildSlots(rightX, belowTabs, rightWidth, slotsHeight); buildSlots(rightX, belowTabs, rightWidth, slotsHeight);
// === Action Panel === // === Action Panel ===
actionPanel = new ActionPanel(rightX, belowTabs + slotsHeight + GuiLayoutConstants.MARGIN_S, actionPanel = new ActionPanel(
rightWidth, actionPanelHeight); rightX,
belowTabs + slotsHeight + GuiLayoutConstants.MARGIN_S,
rightWidth,
actionPanelHeight
);
actionPanel.setMode(mode); actionPanel.setMode(mode);
actionPanel.setTargetEntity(getTarget()); actionPanel.setTargetEntity(getTarget());
actionPanel.setKeyInfo(keyUUID, isMasterKey); actionPanel.setKeyInfo(keyUUID, isMasterKey);
actionPanel.setOnAdjustRequested(region -> { actionPanel.setOnAdjustRequested(region -> {
if (AdjustmentScreen.canOpen()) minecraft.setScreen(new AdjustmentScreen()); if (AdjustmentScreen.canOpen()) minecraft.setScreen(
new AdjustmentScreen()
);
}); });
actionPanel.setOnEquipRequested(this::openPicker); actionPanel.setOnEquipRequested(this::openPicker);
actionPanel.setOnCellAssignRequested(() -> { actionPanel.setOnCellAssignRequested(() -> {
if (targetEntityUUID != null) ModNetwork.sendToServer(new PacketRequestCellList(targetEntityUUID)); if (targetEntityUUID != null) ModNetwork.sendToServer(
new PacketRequestCellList(targetEntityUUID)
);
}); });
actionPanel.setOnCloseRequested(this::onClose); actionPanel.setOnCloseRequested(this::onClose);
actionPanel.clearContext(); actionPanel.clearContext();
@@ -165,7 +206,12 @@ public class UnifiedBondageScreen extends BaseScreen {
// === Status Bar === // === Status Bar ===
int statusY = topPos + imageHeight - statusBarHeight; int statusY = topPos + imageHeight - statusBarHeight;
statusBar = new StatusBarWidget(leftPos, statusY, imageWidth, statusBarHeight); statusBar = new StatusBarWidget(
leftPos,
statusY,
imageWidth,
statusBarHeight
);
statusBar.setMode(mode); statusBar.setMode(mode);
statusBar.setTargetEntity(getTarget()); statusBar.setTargetEntity(getTarget());
statusBar.setOnCloseClicked(this::onClose); statusBar.setOnCloseClicked(this::onClose);
@@ -193,7 +239,9 @@ public class UnifiedBondageScreen extends BaseScreen {
if (stack.getItem() instanceof ItemKey) { if (stack.getItem() instanceof ItemKey) {
return stack; // Regular key takes priority return stack; // Regular key takes priority
} }
if (masterKeyStack.isEmpty() && stack.is(ModItems.MASTER_KEY.get())) { if (
masterKeyStack.isEmpty() && stack.is(ModItems.MASTER_KEY.get())
) {
masterKeyStack = stack; // Remember master key as fallback masterKeyStack = stack; // Remember master key as fallback
} }
} }
@@ -212,8 +260,17 @@ public class UnifiedBondageScreen extends BaseScreen {
BodyRegionV2 region = regions[i]; BodyRegionV2 region = regions[i];
int slotY = y + i * slotHeight; int slotY = y + i * slotHeight;
RegionSlotWidget slot = new RegionSlotWidget(x, slotY, width, slotHeight - 2, RegionSlotWidget slot = new RegionSlotWidget(
region, () -> target != null ? V2EquipmentHelper.getInRegion(target, region) : ItemStack.EMPTY); x,
slotY,
width,
slotHeight - 2,
region,
() ->
target != null
? V2EquipmentHelper.getInRegion(target, region)
: ItemStack.EMPTY
);
slot.setOnClick(this::onSlotClicked); slot.setOnClick(this::onSlotClicked);
slot.setShowEquipButton(true); slot.setShowEquipButton(true);
slot.setOnEquipClick(s -> openPicker(s.getRegion())); slot.setOnEquipClick(s -> openPicker(s.getRegion()));
@@ -245,15 +302,30 @@ public class UnifiedBondageScreen extends BaseScreen {
actionPanel.clearContext(); actionPanel.clearContext();
// Recalculate layout for slots // Recalculate layout for slots
int contentTop = topPos + GuiLayoutConstants.TITLE_HEIGHT + GuiLayoutConstants.MARGIN_M; int contentTop =
topPos +
GuiLayoutConstants.TITLE_HEIGHT +
GuiLayoutConstants.MARGIN_M;
int belowTabs = contentTop + 30; int belowTabs = contentTop + 30;
int statusBarHeight = 46; int statusBarHeight = 46;
int mainContentHeight = imageHeight - (belowTabs - topPos) - statusBarHeight - GuiLayoutConstants.MARGIN_S; int mainContentHeight =
int previewWidth = (int)((imageWidth - GuiLayoutConstants.MARGIN_M * 3) * 0.40f); imageHeight -
int rightX = leftPos + GuiLayoutConstants.MARGIN_M + previewWidth + GuiLayoutConstants.MARGIN_M; (belowTabs - topPos) -
int rightWidth = imageWidth - (rightX - leftPos) - GuiLayoutConstants.MARGIN_M; statusBarHeight -
GuiLayoutConstants.MARGIN_S;
int previewWidth = (int) ((imageWidth -
GuiLayoutConstants.MARGIN_M * 3) *
0.40f);
int rightX =
leftPos +
GuiLayoutConstants.MARGIN_M +
previewWidth +
GuiLayoutConstants.MARGIN_M;
int rightWidth =
imageWidth - (rightX - leftPos) - GuiLayoutConstants.MARGIN_M;
int actionPanelHeight = 84; int actionPanelHeight = 84;
int slotsHeight = mainContentHeight - actionPanelHeight - GuiLayoutConstants.MARGIN_S; int slotsHeight =
mainContentHeight - actionPanelHeight - GuiLayoutConstants.MARGIN_S;
buildSlots(rightX, belowTabs, rightWidth, slotsHeight); buildSlots(rightX, belowTabs, rightWidth, slotsHeight);
autoSelectFirstOccupied(); autoSelectFirstOccupied();
@@ -284,14 +356,23 @@ public class UnifiedBondageScreen extends BaseScreen {
} }
private void openPicker(BodyRegionV2 region) { private void openPicker(BodyRegionV2 region) {
pickerOverlay.open(region, mode == ActionPanel.ScreenMode.SELF, this.width, this.height); pickerOverlay.open(
region,
mode == ActionPanel.ScreenMode.SELF,
this.width,
this.height
);
} }
private void onPickerItemSelected(BodyRegionV2 region, int inventorySlot) { private void onPickerItemSelected(BodyRegionV2 region, int inventorySlot) {
if (mode == ActionPanel.ScreenMode.SELF) { if (mode == ActionPanel.ScreenMode.SELF) {
ModNetwork.sendToServer(new PacketV2SelfEquip(region, inventorySlot)); ModNetwork.sendToServer(
new PacketV2SelfEquip(region, inventorySlot)
);
} else { } else {
ModNetwork.sendToServer(new PacketMasterEquip(targetEntityUUID, region, inventorySlot)); ModNetwork.sendToServer(
new PacketMasterEquip(targetEntityUUID, region, inventorySlot)
);
} }
refreshCountdown = 10; // Refresh after server processes refreshCountdown = 10; // Refresh after server processes
} }
@@ -314,30 +395,65 @@ public class UnifiedBondageScreen extends BaseScreen {
} }
@Override @Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { public void render(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
this.renderBackground(graphics); this.renderBackground(graphics);
// MC-style raised panel // MC-style raised panel
GuiRenderUtil.drawMCPanel(graphics, leftPos, topPos, imageWidth, imageHeight); GuiRenderUtil.drawMCPanel(
graphics,
leftPos,
topPos,
imageWidth,
imageHeight
);
// Title (dark text, vanilla style) // Title (dark text, vanilla style)
String titleText = this.title.getString(); String titleText = this.title.getString();
if (mode == ActionPanel.ScreenMode.MASTER && targetEntity != null) { if (mode == ActionPanel.ScreenMode.MASTER && targetEntity != null) {
titleText += " \u2014 " + targetEntity.getName().getString(); titleText += " \u2014 " + targetEntity.getName().getString();
} }
GuiRenderUtil.drawCenteredStringNoShadow(graphics, font, titleText, GuiRenderUtil.drawCenteredStringNoShadow(
leftPos + imageWidth / 2, topPos + GuiLayoutConstants.MARGIN_M, TITLE_COLOR); graphics,
font,
titleText,
leftPos + imageWidth / 2,
topPos + GuiLayoutConstants.MARGIN_M,
TITLE_COLOR
);
// Mode badge (top-right) — sober gray badge // Mode badge (top-right) — sober gray badge
int badgeWidth = 90; int badgeWidth = 90;
int badgeX = leftPos + imageWidth - badgeWidth - GuiLayoutConstants.MARGIN_M; int badgeX =
leftPos + imageWidth - badgeWidth - GuiLayoutConstants.MARGIN_M;
int badgeY = topPos + GuiLayoutConstants.MARGIN_S; int badgeY = topPos + GuiLayoutConstants.MARGIN_S;
int badgeBg = mode == ActionPanel.ScreenMode.MASTER ? MODE_MASTER_BG : MODE_SELF_BG; int badgeBg =
graphics.fill(badgeX, badgeY, badgeX + badgeWidth, badgeY + 16, badgeBg); mode == ActionPanel.ScreenMode.MASTER
String badgeText = mode == ActionPanel.ScreenMode.MASTER ? MODE_MASTER_BG
: MODE_SELF_BG;
graphics.fill(
badgeX,
badgeY,
badgeX + badgeWidth,
badgeY + 16,
badgeBg
);
String badgeText =
mode == ActionPanel.ScreenMode.MASTER
? Component.translatable("gui.tiedup.mode.master").getString() ? Component.translatable("gui.tiedup.mode.master").getString()
: Component.translatable("gui.tiedup.mode.self").getString(); : Component.translatable("gui.tiedup.mode.self").getString();
GuiRenderUtil.drawCenteredStringNoShadow(graphics, font, badgeText, badgeX + badgeWidth / 2, badgeY + 4, GuiRenderUtil.MC_TEXT_DARK); GuiRenderUtil.drawCenteredStringNoShadow(
graphics,
font,
badgeText,
badgeX + badgeWidth / 2,
badgeY + 4,
GuiRenderUtil.MC_TEXT_DARK
);
// Render all widgets // Render all widgets
super.render(graphics, mouseX, mouseY, partialTick); super.render(graphics, mouseX, mouseY, partialTick);

View File

@@ -6,7 +6,6 @@ import com.tiedup.remake.v2.BodyRegionV2;
* Color constants for TiedUp! GUI elements. * Color constants for TiedUp! GUI elements.
* All colors are in ARGB format (0xAARRGGBB). * All colors are in ARGB format (0xAARRGGBB).
* *
* Phase 16: GUI Revamp
*/ */
public class GuiColors { public class GuiColors {

View File

@@ -26,7 +26,13 @@ public final class GuiRenderUtil {
/** /**
* Draw a vanilla MC-style raised panel (light gray with 3D beveled borders). * Draw a vanilla MC-style raised panel (light gray with 3D beveled borders).
*/ */
public static void drawMCPanel(GuiGraphics graphics, int x, int y, int width, int height) { public static void drawMCPanel(
GuiGraphics graphics,
int x,
int y,
int width,
int height
) {
// Fill // Fill
graphics.fill(x, y, x + width, y + height, MC_PANEL_BG); graphics.fill(x, y, x + width, y + height, MC_PANEL_BG);
// Top highlight (outer white, inner light) // Top highlight (outer white, inner light)
@@ -36,17 +42,41 @@ public final class GuiRenderUtil {
graphics.fill(x, y, x + 1, y + height, MC_HIGHLIGHT_OUTER); graphics.fill(x, y, x + 1, y + height, MC_HIGHLIGHT_OUTER);
graphics.fill(x + 1, y + 1, x + 2, y + height - 1, MC_HIGHLIGHT_INNER); graphics.fill(x + 1, y + 1, x + 2, y + height - 1, MC_HIGHLIGHT_INNER);
// Bottom shadow // Bottom shadow
graphics.fill(x, y + height - 1, x + width, y + height, MC_SHADOW_OUTER); graphics.fill(
graphics.fill(x + 1, y + height - 2, x + width - 1, y + height - 1, MC_SHADOW_INNER); x,
y + height - 1,
x + width,
y + height,
MC_SHADOW_OUTER
);
graphics.fill(
x + 1,
y + height - 2,
x + width - 1,
y + height - 1,
MC_SHADOW_INNER
);
// Right shadow // Right shadow
graphics.fill(x + width - 1, y, x + width, y + height, MC_SHADOW_OUTER); graphics.fill(x + width - 1, y, x + width, y + height, MC_SHADOW_OUTER);
graphics.fill(x + width - 2, y + 1, x + width - 1, y + height - 1, MC_SHADOW_INNER); graphics.fill(
x + width - 2,
y + 1,
x + width - 1,
y + height - 1,
MC_SHADOW_INNER
);
} }
/** /**
* Draw a vanilla MC-style sunken panel (inverted 3D borders — dark outside, light inside). * Draw a vanilla MC-style sunken panel (inverted 3D borders — dark outside, light inside).
*/ */
public static void drawMCSunkenPanel(GuiGraphics graphics, int x, int y, int width, int height) { public static void drawMCSunkenPanel(
GuiGraphics graphics,
int x,
int y,
int width,
int height
) {
graphics.fill(x, y, x + width, y + height, MC_SLOT_BG); graphics.fill(x, y, x + width, y + height, MC_SLOT_BG);
// Top shadow (dark) // Top shadow (dark)
graphics.fill(x, y, x + width, y + 1, MC_SHADOW_OUTER); graphics.fill(x, y, x + width, y + 1, MC_SHADOW_OUTER);
@@ -55,39 +85,95 @@ public final class GuiRenderUtil {
graphics.fill(x, y, x + 1, y + height, MC_SHADOW_OUTER); graphics.fill(x, y, x + 1, y + height, MC_SHADOW_OUTER);
graphics.fill(x + 1, y + 1, x + 2, y + height - 1, MC_SHADOW_INNER); graphics.fill(x + 1, y + 1, x + 2, y + height - 1, MC_SHADOW_INNER);
// Bottom highlight (light) // Bottom highlight (light)
graphics.fill(x, y + height - 1, x + width, y + height, MC_HIGHLIGHT_OUTER); graphics.fill(
graphics.fill(x + 1, y + height - 2, x + width - 1, y + height - 1, MC_HIGHLIGHT_INNER); x,
y + height - 1,
x + width,
y + height,
MC_HIGHLIGHT_OUTER
);
graphics.fill(
x + 1,
y + height - 2,
x + width - 1,
y + height - 1,
MC_HIGHLIGHT_INNER
);
// Right highlight (light) // Right highlight (light)
graphics.fill(x + width - 1, y, x + width, y + height, MC_HIGHLIGHT_OUTER); graphics.fill(
graphics.fill(x + width - 2, y + 1, x + width - 1, y + height - 1, MC_HIGHLIGHT_INNER); x + width - 1,
y,
x + width,
y + height,
MC_HIGHLIGHT_OUTER
);
graphics.fill(
x + width - 2,
y + 1,
x + width - 1,
y + height - 1,
MC_HIGHLIGHT_INNER
);
} }
/** /**
* Draw a vanilla MC-style sunken slot. * Draw a vanilla MC-style sunken slot.
*/ */
public static void drawMCSlot(GuiGraphics graphics, int x, int y, int width, int height) { public static void drawMCSlot(
GuiGraphics graphics,
int x,
int y,
int width,
int height
) {
graphics.fill(x, y, x + width, y + height, MC_SLOT_BG); graphics.fill(x, y, x + width, y + height, MC_SLOT_BG);
// Top shadow // Top shadow
graphics.fill(x, y, x + width, y + 1, MC_SHADOW_OUTER); graphics.fill(x, y, x + width, y + 1, MC_SHADOW_OUTER);
// Left shadow // Left shadow
graphics.fill(x, y, x + 1, y + height, MC_SHADOW_OUTER); graphics.fill(x, y, x + 1, y + height, MC_SHADOW_OUTER);
// Bottom highlight // Bottom highlight
graphics.fill(x, y + height - 1, x + width, y + height, MC_HIGHLIGHT_OUTER); graphics.fill(
x,
y + height - 1,
x + width,
y + height,
MC_HIGHLIGHT_OUTER
);
// Right highlight // Right highlight
graphics.fill(x + width - 1, y, x + width, y + height, MC_HIGHLIGHT_OUTER); graphics.fill(
x + width - 1,
y,
x + width,
y + height,
MC_HIGHLIGHT_OUTER
);
} }
/** /**
* Draw vanilla-style slot hover overlay (white semi-transparent). * Draw vanilla-style slot hover overlay (white semi-transparent).
*/ */
public static void drawSlotHover(GuiGraphics graphics, int x, int y, int width, int height) { public static void drawSlotHover(
GuiGraphics graphics,
int x,
int y,
int width,
int height
) {
graphics.fill(x + 1, y + 1, x + width - 1, y + height - 1, 0x80FFFFFF); graphics.fill(x + 1, y + 1, x + width - 1, y + height - 1, 0x80FFFFFF);
} }
/** /**
* Draw a vanilla MC-style button (raised 3D appearance). * Draw a vanilla MC-style button (raised 3D appearance).
*/ */
public static void drawMCButton(GuiGraphics graphics, int x, int y, int width, int height, boolean hovered, boolean enabled) { public static void drawMCButton(
GuiGraphics graphics,
int x,
int y,
int width,
int height,
boolean hovered,
boolean enabled
) {
int bg = enabled ? (hovered ? 0xFFA0A0A0 : MC_SLOT_BG) : 0xFF606060; int bg = enabled ? (hovered ? 0xFFA0A0A0 : MC_SLOT_BG) : 0xFF606060;
graphics.fill(x, y, x + width, y + height, bg); graphics.fill(x, y, x + width, y + height, bg);
if (enabled) { if (enabled) {
@@ -96,9 +182,21 @@ public final class GuiRenderUtil {
// Left highlight // Left highlight
graphics.fill(x, y, x + 1, y + height, MC_HIGHLIGHT_OUTER); graphics.fill(x, y, x + 1, y + height, MC_HIGHLIGHT_OUTER);
// Bottom shadow // Bottom shadow
graphics.fill(x, y + height - 1, x + width, y + height, MC_SHADOW_OUTER); graphics.fill(
x,
y + height - 1,
x + width,
y + height,
MC_SHADOW_OUTER
);
// Right shadow // Right shadow
graphics.fill(x + width - 1, y, x + width, y + height, MC_SHADOW_OUTER); graphics.fill(
x + width - 1,
y,
x + width,
y + height,
MC_SHADOW_OUTER
);
} else { } else {
// Flat dark border for disabled // Flat dark border for disabled
graphics.fill(x, y, x + width, y + 1, 0xFF505050); graphics.fill(x, y, x + width, y + 1, 0xFF505050);
@@ -111,7 +209,13 @@ public final class GuiRenderUtil {
/** /**
* Draw a selected slot highlight border (gold/yellow). * Draw a selected slot highlight border (gold/yellow).
*/ */
public static void drawSelectedBorder(GuiGraphics graphics, int x, int y, int width, int height) { public static void drawSelectedBorder(
GuiGraphics graphics,
int x,
int y,
int width,
int height
) {
int gold = 0xFFFFD700; int gold = 0xFFFFD700;
// Top // Top
graphics.fill(x, y, x + width, y + 1, gold); graphics.fill(x, y, x + width, y + 1, gold);
@@ -131,9 +235,23 @@ public final class GuiRenderUtil {
* Draw centered text WITHOUT shadow (vanilla drawCenteredString always adds shadow). * Draw centered text WITHOUT shadow (vanilla drawCenteredString always adds shadow).
* Use this for dark text on light MC panels. * Use this for dark text on light MC panels.
*/ */
public static void drawCenteredStringNoShadow(GuiGraphics graphics, net.minecraft.client.gui.Font font, String text, int centerX, int y, int color) { public static void drawCenteredStringNoShadow(
GuiGraphics graphics,
net.minecraft.client.gui.Font font,
String text,
int centerX,
int y,
int color
) {
int textWidth = font.width(text); int textWidth = font.width(text);
graphics.drawString(font, text, centerX - textWidth / 2, y, color, false); graphics.drawString(
font,
text,
centerX - textWidth / 2,
y,
color,
false
);
} }
/** /**

View File

@@ -7,7 +7,7 @@ import com.tiedup.remake.items.ItemLockpick;
import com.tiedup.remake.items.ModItems; import com.tiedup.remake.items.ModItems;
import com.tiedup.remake.items.base.IHasResistance; import com.tiedup.remake.items.base.IHasResistance;
import com.tiedup.remake.items.base.ILockable; import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.items.base.ItemCollar; import com.tiedup.remake.v2.bondage.CollarHelper;
import com.tiedup.remake.network.ModNetwork; import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.action.PacketSetKnifeCutTarget; import com.tiedup.remake.network.action.PacketSetKnifeCutTarget;
import com.tiedup.remake.network.minigame.PacketLockpickMiniGameStart; import com.tiedup.remake.network.minigame.PacketLockpickMiniGameStart;
@@ -43,7 +43,10 @@ import net.minecraftforge.api.distmarker.OnlyIn;
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public class ActionPanel extends AbstractWidget { public class ActionPanel extends AbstractWidget {
public enum ScreenMode { SELF, MASTER } public enum ScreenMode {
SELF,
MASTER,
}
private record ActionEntry( private record ActionEntry(
String labelKey, String labelKey,
@@ -83,22 +86,38 @@ public class ActionPanel extends AbstractWidget {
private int hoveredIndex = -1; private int hoveredIndex = -1;
public ActionPanel(int x, int y, int width, int height) { public ActionPanel(int x, int y, int width, int height) {
super(x, y, width, height, Component.literal("Actions")); super(x, y, width, height, Component.translatable("gui.tiedup.action_panel"));
}
public void setMode(ScreenMode mode) {
this.mode = mode;
} }
public void setMode(ScreenMode mode) { this.mode = mode; }
public void setTargetEntity(LivingEntity entity) { public void setTargetEntity(LivingEntity entity) {
this.targetEntity = entity; this.targetEntity = entity;
this.targetEntityUUID = entity != null ? entity.getUUID() : null; this.targetEntityUUID = entity != null ? entity.getUUID() : null;
} }
public void setKeyInfo(UUID keyUUID, boolean isMasterKey) { public void setKeyInfo(UUID keyUUID, boolean isMasterKey) {
this.keyUUID = keyUUID; this.keyUUID = keyUUID;
this.isMasterKey = isMasterKey; this.isMasterKey = isMasterKey;
} }
public void setOnAdjustRequested(Consumer<BodyRegionV2> cb) { this.onAdjustRequested = cb; }
public void setOnEquipRequested(Consumer<BodyRegionV2> cb) { this.onEquipRequested = cb; } public void setOnAdjustRequested(Consumer<BodyRegionV2> cb) {
public void setOnCellAssignRequested(Runnable cb) { this.onCellAssignRequested = cb; } this.onAdjustRequested = cb;
public void setOnCloseRequested(Runnable cb) { this.onCloseRequested = cb; } }
public void setOnEquipRequested(Consumer<BodyRegionV2> cb) {
this.onEquipRequested = cb;
}
public void setOnCellAssignRequested(Runnable cb) {
this.onCellAssignRequested = cb;
}
public void setOnCloseRequested(Runnable cb) {
this.onCloseRequested = cb;
}
/** /**
* Update the action panel context. Call when the selected slot changes. * Update the action panel context. Call when the selected slot changes.
@@ -142,7 +161,9 @@ public class ActionPanel extends AbstractWidget {
if (stack.getItem() instanceof ItemKey) { if (stack.getItem() instanceof ItemKey) {
return stack; // Regular key takes priority return stack; // Regular key takes priority
} }
if (masterKeyStack.isEmpty() && stack.is(ModItems.MASTER_KEY.get())) { if (
masterKeyStack.isEmpty() && stack.is(ModItems.MASTER_KEY.get())
) {
masterKeyStack = stack; // Remember master key as fallback masterKeyStack = stack; // Remember master key as fallback
} }
} }
@@ -151,110 +172,214 @@ public class ActionPanel extends AbstractWidget {
private void buildSelfActions(Player player) { private void buildSelfActions(Player player) {
boolean isEmpty = selectedItem.isEmpty(); boolean isEmpty = selectedItem.isEmpty();
boolean armsOccupied = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS); boolean armsOccupied = V2EquipmentHelper.isRegionOccupied(
boolean handsOccupied = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.HANDS); player,
BodyRegionV2.ARMS
);
boolean handsOccupied = V2EquipmentHelper.isRegionOccupied(
player,
BodyRegionV2.HANDS
);
boolean armsFree = !armsOccupied; boolean armsFree = !armsOccupied;
boolean handsFree = !handsOccupied; boolean handsFree = !handsOccupied;
boolean isLocked = false; boolean isLocked = false;
boolean isLockable = false; boolean isLockable = false;
boolean isJammed = false; boolean isJammed = false;
boolean hasMittens = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.HANDS); boolean hasMittens = V2EquipmentHelper.isRegionOccupied(
player,
BodyRegionV2.HANDS
);
if (!isEmpty && selectedItem.getItem() instanceof ILockable lockable) { if (!isEmpty && selectedItem.getItem() instanceof ILockable lockable) {
isLocked = lockable.isLocked(selectedItem); isLocked = lockable.isLocked(selectedItem);
isLockable = lockable.isLockable(selectedItem); isLockable = lockable.isLockable(selectedItem);
isJammed = lockable.isJammed(selectedItem); isJammed = lockable.isJammed(selectedItem);
} }
boolean hasLockpick = !ItemLockpick.findLockpickInInventory(player).isEmpty(); boolean hasLockpick = !ItemLockpick.findLockpickInInventory(
player
).isEmpty();
boolean hasKnife = !GenericKnife.findKnifeInInventory(player).isEmpty(); boolean hasKnife = !GenericKnife.findKnifeInInventory(player).isEmpty();
ItemStack foundKey = findKeyInInventory(player); ItemStack foundKey = findKeyInInventory(player);
boolean hasKey = !foundKey.isEmpty(); boolean hasKey = !foundKey.isEmpty();
boolean foundKeyIsMaster = hasKey && foundKey.is(ModItems.MASTER_KEY.get()); boolean foundKeyIsMaster =
hasKey && foundKey.is(ModItems.MASTER_KEY.get());
if (isEmpty) { if (isEmpty) {
// Equip action for empty slot // Equip action for empty slot
actions.add(new ActionEntry("gui.tiedup.action.equip", true, null, actions.add(
() -> { if (onEquipRequested != null) onEquipRequested.accept(selectedRegion); })); new ActionEntry("gui.tiedup.action.equip", true, null, () -> {
if (onEquipRequested != null) onEquipRequested.accept(
selectedRegion
);
})
);
return; return;
} }
// Remove // Remove
boolean canRemove = armsFree && handsFree && !isLocked && selectedRegion != BodyRegionV2.ARMS; boolean canRemove =
String removeReason = isLocked ? "gui.tiedup.reason.locked" : armsFree &&
!armsFree ? "gui.tiedup.reason.arms_bound" : handsFree &&
!handsFree ? "gui.tiedup.reason.hands_bound" : !isLocked &&
selectedRegion == BodyRegionV2.ARMS ? "gui.tiedup.reason.use_struggle" : null; selectedRegion != BodyRegionV2.ARMS;
actions.add(new ActionEntry("gui.tiedup.action.remove", canRemove, removeReason, String removeReason = isLocked
() -> ModNetwork.sendToServer(new PacketV2SelfRemove(selectedRegion)))); ? "gui.tiedup.reason.locked"
: !armsFree
? "gui.tiedup.reason.arms_bound"
: !handsFree
? "gui.tiedup.reason.hands_bound"
: selectedRegion == BodyRegionV2.ARMS
? "gui.tiedup.reason.use_struggle"
: null;
actions.add(
new ActionEntry(
"gui.tiedup.action.remove",
canRemove,
removeReason,
() ->
ModNetwork.sendToServer(
new PacketV2SelfRemove(selectedRegion)
)
)
);
// Struggle (locked items only) // Struggle (locked items only)
if (isLocked) { if (isLocked) {
actions.add(new ActionEntry("gui.tiedup.action.struggle", true, null, actions.add(
new ActionEntry(
"gui.tiedup.action.struggle",
true,
null,
() -> { () -> {
ModNetwork.sendToServer(new PacketV2StruggleStart(selectedRegion)); ModNetwork.sendToServer(
new PacketV2StruggleStart(selectedRegion)
);
if (onCloseRequested != null) onCloseRequested.run(); if (onCloseRequested != null) onCloseRequested.run();
})); }
)
);
} }
// Lockpick // Lockpick
if (isLocked) { if (isLocked) {
boolean canPick = hasLockpick && !hasMittens && !isJammed; boolean canPick = hasLockpick && !hasMittens && !isJammed;
String pickReason = !hasLockpick ? "gui.tiedup.reason.no_lockpick" : String pickReason = !hasLockpick
hasMittens ? "gui.tiedup.reason.mittens" : ? "gui.tiedup.reason.no_lockpick"
isJammed ? "gui.tiedup.reason.jammed" : null; : hasMittens
actions.add(new ActionEntry("gui.tiedup.action.lockpick", canPick, pickReason, ? "gui.tiedup.reason.mittens"
: isJammed
? "gui.tiedup.reason.jammed"
: null;
actions.add(
new ActionEntry(
"gui.tiedup.action.lockpick",
canPick,
pickReason,
() -> { () -> {
ModNetwork.sendToServer(new PacketLockpickMiniGameStart(selectedRegion)); ModNetwork.sendToServer(
new PacketLockpickMiniGameStart(selectedRegion)
);
if (onCloseRequested != null) onCloseRequested.run(); if (onCloseRequested != null) onCloseRequested.run();
})); }
)
);
} }
// Cut // Cut
if (isLocked) { if (isLocked) {
boolean canCut = hasKnife && !hasMittens; boolean canCut = hasKnife && !hasMittens;
String cutReason = !hasKnife ? "gui.tiedup.reason.no_knife" : String cutReason = !hasKnife
hasMittens ? "gui.tiedup.reason.mittens" : null; ? "gui.tiedup.reason.no_knife"
actions.add(new ActionEntry("gui.tiedup.action.cut", canCut, cutReason, : hasMittens
? "gui.tiedup.reason.mittens"
: null;
actions.add(
new ActionEntry(
"gui.tiedup.action.cut",
canCut,
cutReason,
() -> { () -> {
ModNetwork.sendToServer(new PacketSetKnifeCutTarget(selectedRegion)); ModNetwork.sendToServer(
new PacketSetKnifeCutTarget(selectedRegion)
);
if (onCloseRequested != null) onCloseRequested.run(); if (onCloseRequested != null) onCloseRequested.run();
})); }
)
);
} }
// Adjust (MOUTH, EYES only) // Adjust (MOUTH, EYES only)
if (selectedRegion == BodyRegionV2.MOUTH || selectedRegion == BodyRegionV2.EYES) { if (
actions.add(new ActionEntry("gui.tiedup.action.adjust", true, null, selectedRegion == BodyRegionV2.MOUTH ||
() -> { if (onAdjustRequested != null) onAdjustRequested.accept(selectedRegion); })); selectedRegion == BodyRegionV2.EYES
) {
actions.add(
new ActionEntry("gui.tiedup.action.adjust", true, null, () -> {
if (onAdjustRequested != null) onAdjustRequested.accept(
selectedRegion
);
})
);
} }
// Lock (self, with key, arms free) // Lock (self, with key, arms free)
if (isLockable && !isLocked) { if (isLockable && !isLocked) {
boolean canLock = hasKey && armsFree; boolean canLock = hasKey && armsFree;
String lockReason = !hasKey ? "gui.tiedup.reason.no_key" : String lockReason = !hasKey
!armsFree ? "gui.tiedup.reason.arms_bound" : null; ? "gui.tiedup.reason.no_key"
actions.add(new ActionEntry("gui.tiedup.action.lock", canLock, lockReason, : !armsFree
() -> ModNetwork.sendToServer(new PacketV2SelfLock(selectedRegion)))); ? "gui.tiedup.reason.arms_bound"
: null;
actions.add(
new ActionEntry(
"gui.tiedup.action.lock",
canLock,
lockReason,
() ->
ModNetwork.sendToServer(
new PacketV2SelfLock(selectedRegion)
)
)
);
} }
// Unlock (self, with matching key, arms free) // Unlock (self, with matching key, arms free)
if (isLocked) { if (isLocked) {
boolean keyMatches = false; boolean keyMatches = false;
if (hasKey && selectedItem.getItem() instanceof ILockable lockable) { if (
hasKey && selectedItem.getItem() instanceof ILockable lockable
) {
if (foundKeyIsMaster) { if (foundKeyIsMaster) {
keyMatches = true; // Master key matches all locks keyMatches = true; // Master key matches all locks
} else if (foundKey.getItem() instanceof ItemKey itemKey) { } else if (foundKey.getItem() instanceof ItemKey itemKey) {
UUID lockKeyUUID = lockable.getLockedByKeyUUID(selectedItem); UUID lockKeyUUID = lockable.getLockedByKeyUUID(
selectedItem
);
UUID foundKeyUUID = itemKey.getKeyUUID(foundKey); UUID foundKeyUUID = itemKey.getKeyUUID(foundKey);
keyMatches = lockKeyUUID == null || lockKeyUUID.equals(foundKeyUUID); keyMatches =
lockKeyUUID == null || lockKeyUUID.equals(foundKeyUUID);
} }
} }
boolean canUnlock = hasKey && armsFree && keyMatches; boolean canUnlock = hasKey && armsFree && keyMatches;
String unlockReason = !hasKey ? "gui.tiedup.reason.no_key" : String unlockReason = !hasKey
!armsFree ? "gui.tiedup.reason.arms_bound" : ? "gui.tiedup.reason.no_key"
!keyMatches ? "gui.tiedup.reason.wrong_key" : null; : !armsFree
actions.add(new ActionEntry("gui.tiedup.action.unlock", canUnlock, unlockReason, ? "gui.tiedup.reason.arms_bound"
() -> ModNetwork.sendToServer(new PacketV2SelfUnlock(selectedRegion)))); : !keyMatches
? "gui.tiedup.reason.wrong_key"
: null;
actions.add(
new ActionEntry(
"gui.tiedup.action.unlock",
canUnlock,
unlockReason,
() ->
ModNetwork.sendToServer(
new PacketV2SelfUnlock(selectedRegion)
)
)
);
} }
} }
@@ -269,71 +394,169 @@ public class ActionPanel extends AbstractWidget {
} }
if (isEmpty) { if (isEmpty) {
actions.add(new ActionEntry("gui.tiedup.action.equip", true, null, actions.add(
() -> { if (onEquipRequested != null) onEquipRequested.accept(selectedRegion); })); new ActionEntry("gui.tiedup.action.equip", true, null, () -> {
if (onEquipRequested != null) onEquipRequested.accept(
selectedRegion
);
})
);
return; return;
} }
// Remove // Remove
actions.add(new ActionEntry("gui.tiedup.action.remove", !isLocked, actions.add(
new ActionEntry(
"gui.tiedup.action.remove",
!isLocked,
isLocked ? "gui.tiedup.reason.locked" : null, isLocked ? "gui.tiedup.reason.locked" : null,
() -> ModNetwork.sendToServer(new PacketSlaveItemManage( () ->
targetEntityUUID, selectedRegion, PacketSlaveItemManage.Action.REMOVE, keyUUID, isMasterKey)))); ModNetwork.sendToServer(
new PacketSlaveItemManage(
targetEntityUUID,
selectedRegion,
PacketSlaveItemManage.Action.REMOVE,
keyUUID,
isMasterKey
)
)
)
);
// Lock // Lock
if (isLockable && !isLocked) { if (isLockable && !isLocked) {
actions.add(new ActionEntry("gui.tiedup.action.lock", true, null, actions.add(
() -> ModNetwork.sendToServer(new PacketSlaveItemManage( new ActionEntry("gui.tiedup.action.lock", true, null, () ->
targetEntityUUID, selectedRegion, PacketSlaveItemManage.Action.LOCK, keyUUID, isMasterKey)))); ModNetwork.sendToServer(
new PacketSlaveItemManage(
targetEntityUUID,
selectedRegion,
PacketSlaveItemManage.Action.LOCK,
keyUUID,
isMasterKey
)
)
)
);
} }
// Unlock // Unlock
if (isLocked) { if (isLocked) {
actions.add(new ActionEntry("gui.tiedup.action.unlock", true, null, actions.add(
() -> ModNetwork.sendToServer(new PacketSlaveItemManage( new ActionEntry("gui.tiedup.action.unlock", true, null, () ->
targetEntityUUID, selectedRegion, PacketSlaveItemManage.Action.UNLOCK, keyUUID, isMasterKey)))); ModNetwork.sendToServer(
new PacketSlaveItemManage(
targetEntityUUID,
selectedRegion,
PacketSlaveItemManage.Action.UNLOCK,
keyUUID,
isMasterKey
)
)
)
);
} }
// Adjust (MOUTH, EYES) // Adjust (MOUTH, EYES)
if (selectedRegion == BodyRegionV2.MOUTH || selectedRegion == BodyRegionV2.EYES) { if (
actions.add(new ActionEntry("gui.tiedup.action.adjust", true, null, selectedRegion == BodyRegionV2.MOUTH ||
() -> { if (onAdjustRequested != null) onAdjustRequested.accept(selectedRegion); })); selectedRegion == BodyRegionV2.EYES
) {
actions.add(
new ActionEntry("gui.tiedup.action.adjust", true, null, () -> {
if (onAdjustRequested != null) onAdjustRequested.accept(
selectedRegion
);
})
);
} }
// Bondage Service toggle (NECK collar only, prison configured) // Bondage Service toggle (NECK collar only, prison configured)
if (selectedRegion == BodyRegionV2.NECK && selectedItem.getItem() instanceof ItemCollar collar) { if (
if (collar.hasCellAssigned(selectedItem)) { selectedRegion == BodyRegionV2.NECK &&
boolean svcEnabled = collar.isBondageServiceEnabled(selectedItem); CollarHelper.isCollar(selectedItem)
String svcKey = svcEnabled ? "gui.tiedup.action.svc_off" : "gui.tiedup.action.svc_on"; ) {
actions.add(new ActionEntry(svcKey, true, null, if (CollarHelper.hasCellAssigned(selectedItem)) {
() -> ModNetwork.sendToServer(new PacketSlaveItemManage( boolean svcEnabled = CollarHelper.isBondageServiceEnabled(
targetEntityUUID, selectedRegion, selectedItem
PacketSlaveItemManage.Action.TOGGLE_BONDAGE_SERVICE, keyUUID, isMasterKey)))); );
String svcKey = svcEnabled
? "gui.tiedup.action.svc_off"
: "gui.tiedup.action.svc_on";
actions.add(
new ActionEntry(svcKey, true, null, () ->
ModNetwork.sendToServer(
new PacketSlaveItemManage(
targetEntityUUID,
selectedRegion,
PacketSlaveItemManage.Action.TOGGLE_BONDAGE_SERVICE,
keyUUID,
isMasterKey
)
)
)
);
} }
// Cell assign // Cell assign
actions.add(new ActionEntry("gui.tiedup.action.cell_assign", true, null, actions.add(
() -> { if (onCellAssignRequested != null) onCellAssignRequested.run(); })); new ActionEntry(
"gui.tiedup.action.cell_assign",
true,
null,
() -> {
if (
onCellAssignRequested != null
) onCellAssignRequested.run();
}
)
);
} }
} }
@Override @Override
protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { protected void renderWidget(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
// MC-style sunken panel background // MC-style sunken panel background
GuiRenderUtil.drawMCSunkenPanel(graphics, getX(), getY(), width, height); GuiRenderUtil.drawMCSunkenPanel(
graphics,
getX(),
getY(),
width,
height
);
// Title // Title
String title; String title;
if (selectedRegion == null) { if (selectedRegion == null) {
title = Component.translatable("gui.tiedup.action.no_selection").getString(); title = Component.translatable(
"gui.tiedup.action.no_selection"
).getString();
} else if (selectedItem.isEmpty()) { } else if (selectedItem.isEmpty()) {
title = Component.translatable("gui.tiedup.action.title_empty", title = Component.translatable(
Component.translatable("gui.tiedup.region." + selectedRegion.name().toLowerCase())).getString(); "gui.tiedup.action.title_empty",
Component.translatable(
"gui.tiedup.region." + selectedRegion.name().toLowerCase()
)
).getString();
} else { } else {
title = (mode == ScreenMode.MASTER ? "\u265B " : "") + selectedItem.getHoverName().getString(); title =
(mode == ScreenMode.MASTER ? "\u265B " : "") +
selectedItem.getHoverName().getString();
} }
graphics.drawString(mc.font, title, getX() + PADDING, getY() + PADDING, TITLE_COLOR, false); graphics.drawString(
mc.font,
title,
getX() + PADDING,
getY() + PADDING,
TITLE_COLOR,
false
);
// Action buttons grid (MC-style buttons) // Action buttons grid (MC-style buttons)
hoveredIndex = -1; hoveredIndex = -1;
@@ -347,14 +570,27 @@ public class ActionPanel extends AbstractWidget {
int btnX = startX + col * (BUTTON_WIDTH + BUTTON_SPACING); int btnX = startX + col * (BUTTON_WIDTH + BUTTON_SPACING);
int btnY = startY + row * (BUTTON_HEIGHT + 4); int btnY = startY + row * (BUTTON_HEIGHT + 4);
boolean hovered = mouseX >= btnX && mouseX < btnX + BUTTON_WIDTH boolean hovered =
&& mouseY >= btnY && mouseY < btnY + BUTTON_HEIGHT; mouseX >= btnX &&
mouseX < btnX + BUTTON_WIDTH &&
mouseY >= btnY &&
mouseY < btnY + BUTTON_HEIGHT;
if (hovered) hoveredIndex = i; if (hovered) hoveredIndex = i;
int textColor = action.enabled() ? TEXT_ENABLED : TEXT_DISABLED; int textColor = action.enabled() ? TEXT_ENABLED : TEXT_DISABLED;
GuiRenderUtil.drawMCButton(graphics, btnX, btnY, BUTTON_WIDTH, BUTTON_HEIGHT, hovered, action.enabled()); GuiRenderUtil.drawMCButton(
graphics,
btnX,
btnY,
BUTTON_WIDTH,
BUTTON_HEIGHT,
hovered,
action.enabled()
);
String label = Component.translatable(action.labelKey()).getString(); String label = Component.translatable(
action.labelKey()
).getString();
int textX = btnX + (BUTTON_WIDTH - mc.font.width(label)) / 2; int textX = btnX + (BUTTON_WIDTH - mc.font.width(label)) / 2;
int textY = btnY + (BUTTON_HEIGHT - mc.font.lineHeight) / 2; int textY = btnY + (BUTTON_HEIGHT - mc.font.lineHeight) / 2;
graphics.drawString(mc.font, label, textX, textY, textColor, false); graphics.drawString(mc.font, label, textX, textY, textColor, false);
@@ -363,10 +599,16 @@ public class ActionPanel extends AbstractWidget {
// Tooltip for disabled button // Tooltip for disabled button
if (hoveredIndex >= 0 && hoveredIndex < actions.size()) { if (hoveredIndex >= 0 && hoveredIndex < actions.size()) {
ActionEntry hoverAction = actions.get(hoveredIndex); ActionEntry hoverAction = actions.get(hoveredIndex);
if (!hoverAction.enabled() && hoverAction.disabledReasonKey() != null) { if (
graphics.renderTooltip(mc.font, !hoverAction.enabled() &&
hoverAction.disabledReasonKey() != null
) {
graphics.renderTooltip(
mc.font,
Component.translatable(hoverAction.disabledReasonKey()), Component.translatable(hoverAction.disabledReasonKey()),
mouseX, mouseY); mouseX,
mouseY
);
} }
} }
} }
@@ -386,8 +628,12 @@ public class ActionPanel extends AbstractWidget {
int btnX = startX + col * (BUTTON_WIDTH + BUTTON_SPACING); int btnX = startX + col * (BUTTON_WIDTH + BUTTON_SPACING);
int btnY = startY + row * (BUTTON_HEIGHT + 4); int btnY = startY + row * (BUTTON_HEIGHT + 4);
if (mouseX >= btnX && mouseX < btnX + BUTTON_WIDTH if (
&& mouseY >= btnY && mouseY < btnY + BUTTON_HEIGHT) { mouseX >= btnX &&
mouseX < btnX + BUTTON_WIDTH &&
mouseY >= btnY &&
mouseY < btnY + BUTTON_HEIGHT
) {
if (action.enabled() && action.onClick() != null) { if (action.enabled() && action.onClick() != null) {
action.onClick().run(); action.onClick().run();
playDownSound(mc.getSoundManager()); playDownSound(mc.getSoundManager());
@@ -401,6 +647,9 @@ public class ActionPanel extends AbstractWidget {
@Override @Override
protected void updateWidgetNarration(NarrationElementOutput output) { protected void updateWidgetNarration(NarrationElementOutput output) {
output.add(NarratedElementType.TITLE, Component.translatable("gui.tiedup.action_panel")); output.add(
NarratedElementType.TITLE,
Component.translatable("gui.tiedup.action_panel")
);
} }
} }

View File

@@ -19,7 +19,6 @@ import net.minecraftforge.api.distmarker.OnlyIn;
* Vertical slider widget for adjusting Y position of items. * Vertical slider widget for adjusting Y position of items.
* Displays current value and supports mouse drag and scroll wheel. * Displays current value and supports mouse drag and scroll wheel.
* *
* Phase 16b: GUI Refactoring - Fixed alignment and layout
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public class AdjustmentSlider extends AbstractWidget { public class AdjustmentSlider extends AbstractWidget {

View File

@@ -39,16 +39,14 @@ public final class CellListRenderer {
) { ) {
graphics.drawCenteredString( graphics.drawCenteredString(
font, font,
Component.translatable(noItemsKey) Component.translatable(noItemsKey).withStyle(ChatFormatting.GRAY),
.withStyle(ChatFormatting.GRAY),
centerX, centerX,
startY + 20, startY + 20,
GuiColors.TEXT_DISABLED GuiColors.TEXT_DISABLED
); );
graphics.drawCenteredString( graphics.drawCenteredString(
font, font,
Component.translatable(hintKey) Component.translatable(hintKey).withStyle(ChatFormatting.ITALIC),
.withStyle(ChatFormatting.ITALIC),
centerX, centerX,
startY + 35, startY + 35,
GuiColors.TEXT_DISABLED GuiColors.TEXT_DISABLED

View File

@@ -18,7 +18,6 @@ import org.joml.Quaternionf;
* Widget that displays a 3D preview of a LivingEntity. * Widget that displays a 3D preview of a LivingEntity.
* Supports mouse drag rotation and optional auto-rotation. * Supports mouse drag rotation and optional auto-rotation.
* *
* Phase 16: GUI Revamp - Reusable entity preview widget
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public class EntityPreviewWidget extends AbstractWidget { public class EntityPreviewWidget extends AbstractWidget {

View File

@@ -51,19 +51,32 @@ public class ItemPickerOverlay extends AbstractWidget {
private int screenHeight; private int screenHeight;
public ItemPickerOverlay() { public ItemPickerOverlay() {
super(0, 0, 0, 0, Component.literal("Item Picker")); super(0, 0, 0, 0, Component.translatable("gui.tiedup.item_picker"));
this.active = false; this.active = false;
this.visible = false; this.visible = false;
} }
public void setOnItemSelected(BiConsumer<BodyRegionV2, Integer> cb) { this.onItemSelected = cb; } public void setOnItemSelected(BiConsumer<BodyRegionV2, Integer> cb) {
public void setOnCancelled(Runnable cb) { this.onCancelled = cb; } this.onItemSelected = cb;
public boolean isOverlayVisible() { return visible; } }
public void setOnCancelled(Runnable cb) {
this.onCancelled = cb;
}
public boolean isOverlayVisible() {
return visible;
}
/** /**
* Open the picker overlay for a specific region. * Open the picker overlay for a specific region.
*/ */
public void open(BodyRegionV2 region, boolean selfMode, int screenWidth, int screenHeight) { public void open(
BodyRegionV2 region,
boolean selfMode,
int screenWidth,
int screenHeight
) {
this.targetRegion = region; this.targetRegion = region;
this.isSelfMode = selfMode; this.isSelfMode = selfMode;
this.screenWidth = screenWidth; this.screenWidth = screenWidth;
@@ -89,23 +102,45 @@ public class ItemPickerOverlay extends AbstractWidget {
for (int i = 0; i < player.getInventory().getContainerSize(); i++) { for (int i = 0; i < player.getInventory().getContainerSize(); i++) {
ItemStack stack = player.getInventory().getItem(i); ItemStack stack = player.getInventory().getItem(i);
if (stack.isEmpty()) continue; if (stack.isEmpty()) continue;
if (!(stack.getItem() instanceof IV2BondageItem bondageItem)) continue; if (
if (!bondageItem.getOccupiedRegions(stack).contains(targetRegion)) continue; !(stack.getItem() instanceof IV2BondageItem bondageItem)
) continue;
if (
!bondageItem.getOccupiedRegions(stack).contains(targetRegion)
) continue;
entries.add(new PickerEntry(stack, i)); entries.add(new PickerEntry(stack, i));
} }
} }
// Panel bounds (centered on screen) // Panel bounds (centered on screen)
private int getPanelWidth() { return Math.min(280, screenWidth - 40); } private int getPanelWidth() {
return Math.min(280, screenWidth - 40);
}
private int getPanelHeight() { private int getPanelHeight() {
int contentH = entries.size() * ENTRY_HEIGHT + CANCEL_BTN_HEIGHT + PADDING * 3 + 20; int contentH =
entries.size() * ENTRY_HEIGHT +
CANCEL_BTN_HEIGHT +
PADDING * 3 +
20;
return Math.min(contentH, screenHeight - 60); return Math.min(contentH, screenHeight - 60);
} }
private int getPanelX() { return (screenWidth - getPanelWidth()) / 2; }
private int getPanelY() { return (screenHeight - getPanelHeight()) / 2; } private int getPanelX() {
return (screenWidth - getPanelWidth()) / 2;
}
private int getPanelY() {
return (screenHeight - getPanelHeight()) / 2;
}
@Override @Override
protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { protected void renderWidget(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
if (!visible) return; if (!visible) return;
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
@@ -121,13 +156,30 @@ public class ItemPickerOverlay extends AbstractWidget {
GuiRenderUtil.drawMCPanel(graphics, panelX, panelY, panelW, panelH); GuiRenderUtil.drawMCPanel(graphics, panelX, panelY, panelW, panelH);
// Title (dark text, vanilla style) // Title (dark text, vanilla style)
String title = Component.translatable("gui.tiedup.picker.title", String title = Component.translatable(
Component.translatable("gui.tiedup.region." + targetRegion.name().toLowerCase())).getString(); "gui.tiedup.picker.title",
graphics.drawString(mc.font, title, panelX + PADDING, panelY + PADDING, GuiRenderUtil.MC_TEXT_DARK, false); Component.translatable(
"gui.tiedup.region." + targetRegion.name().toLowerCase()
)
).getString();
graphics.drawString(
mc.font,
title,
panelX + PADDING,
panelY + PADDING,
GuiRenderUtil.MC_TEXT_DARK,
false
);
// Entries // Entries
int listY = panelY + PADDING + mc.font.lineHeight + 8; int listY = panelY + PADDING + mc.font.lineHeight + 8;
int maxVisible = (panelH - PADDING * 3 - mc.font.lineHeight - 8 - CANCEL_BTN_HEIGHT) / ENTRY_HEIGHT; int maxVisible =
(panelH -
PADDING * 3 -
mc.font.lineHeight -
8 -
CANCEL_BTN_HEIGHT) /
ENTRY_HEIGHT;
for (int i = 0; i < Math.min(entries.size(), maxVisible); i++) { for (int i = 0; i < Math.min(entries.size(), maxVisible); i++) {
int idx = i + scrollOffset; int idx = i + scrollOffset;
@@ -137,56 +189,123 @@ public class ItemPickerOverlay extends AbstractWidget {
int entryX = panelX + PADDING; int entryX = panelX + PADDING;
int entryW = panelW - PADDING * 2; int entryW = panelW - PADDING * 2;
boolean hovered = mouseX >= entryX && mouseX < entryX + entryW boolean hovered =
&& mouseY >= entryY && mouseY < entryY + ENTRY_HEIGHT; mouseX >= entryX &&
mouseX < entryX + entryW &&
mouseY >= entryY &&
mouseY < entryY + ENTRY_HEIGHT;
boolean isArmsWarning = (armsWarningSlot == entry.inventorySlot); boolean isArmsWarning = (armsWarningSlot == entry.inventorySlot);
// MC-style slot for each entry // MC-style slot for each entry
GuiRenderUtil.drawMCSlot(graphics, entryX, entryY, entryW, ENTRY_HEIGHT - 2); GuiRenderUtil.drawMCSlot(
graphics,
entryX,
entryY,
entryW,
ENTRY_HEIGHT - 2
);
// Gold border for ARMS warning confirmation // Gold border for ARMS warning confirmation
if (isArmsWarning) { if (isArmsWarning) {
GuiRenderUtil.drawSelectedBorder(graphics, entryX, entryY, entryW, ENTRY_HEIGHT - 2); GuiRenderUtil.drawSelectedBorder(
graphics,
entryX,
entryY,
entryW,
ENTRY_HEIGHT - 2
);
} }
// Vanilla hover overlay (white semi-transparent) // Vanilla hover overlay (white semi-transparent)
if (hovered && !isArmsWarning) { if (hovered && !isArmsWarning) {
GuiRenderUtil.drawSlotHover(graphics, entryX, entryY, entryW, ENTRY_HEIGHT - 2); GuiRenderUtil.drawSlotHover(
graphics,
entryX,
entryY,
entryW,
ENTRY_HEIGHT - 2
);
} }
// Item icon (16x16) // Item icon (16x16)
graphics.renderItem(entry.stack, entryX + 4, entryY + (ENTRY_HEIGHT - 18) / 2); graphics.renderItem(
entry.stack,
entryX + 4,
entryY + (ENTRY_HEIGHT - 18) / 2
);
// Item name // Item name
String name = entry.stack.getHoverName().getString(); String name = entry.stack.getHoverName().getString();
graphics.drawString(mc.font, name, entryX + 24, entryY + (ENTRY_HEIGHT - mc.font.lineHeight) / 2, graphics.drawString(
GuiRenderUtil.MC_TEXT_DARK, false); mc.font,
name,
entryX + 24,
entryY + (ENTRY_HEIGHT - mc.font.lineHeight) / 2,
GuiRenderUtil.MC_TEXT_DARK,
false
);
} }
// ARMS warning text // ARMS warning text
if (armsWarningSlot >= 0) { if (armsWarningSlot >= 0) {
String warning = Component.translatable("gui.tiedup.picker.arms_warning").getString(); String warning = Component.translatable(
int warningY = listY + Math.min(entries.size(), maxVisible) * ENTRY_HEIGHT + 2; "gui.tiedup.picker.arms_warning"
graphics.drawString(mc.font, warning, panelX + PADDING, warningY, WARNING_COLOR, false); ).getString();
int warningY =
listY + Math.min(entries.size(), maxVisible) * ENTRY_HEIGHT + 2;
graphics.drawString(
mc.font,
warning,
panelX + PADDING,
warningY,
WARNING_COLOR,
false
);
} }
// Empty state // Empty state
if (entries.isEmpty()) { if (entries.isEmpty()) {
String empty = Component.translatable("gui.tiedup.picker.empty").getString(); String empty = Component.translatable(
GuiRenderUtil.drawCenteredStringNoShadow(graphics, mc.font, empty, "gui.tiedup.picker.empty"
panelX + panelW / 2, listY + 20, GuiRenderUtil.MC_TEXT_GRAY); ).getString();
GuiRenderUtil.drawCenteredStringNoShadow(
graphics,
mc.font,
empty,
panelX + panelW / 2,
listY + 20,
GuiRenderUtil.MC_TEXT_GRAY
);
} }
// Cancel button (MC-style) // Cancel button (MC-style)
int cancelX = panelX + (panelW - CANCEL_BTN_WIDTH) / 2; int cancelX = panelX + (panelW - CANCEL_BTN_WIDTH) / 2;
int cancelY = panelY + panelH - CANCEL_BTN_HEIGHT - PADDING; int cancelY = panelY + panelH - CANCEL_BTN_HEIGHT - PADDING;
boolean cancelHovered = mouseX >= cancelX && mouseX < cancelX + CANCEL_BTN_WIDTH boolean cancelHovered =
&& mouseY >= cancelY && mouseY < cancelY + CANCEL_BTN_HEIGHT; mouseX >= cancelX &&
GuiRenderUtil.drawMCButton(graphics, cancelX, cancelY, CANCEL_BTN_WIDTH, CANCEL_BTN_HEIGHT, cancelHovered, true); mouseX < cancelX + CANCEL_BTN_WIDTH &&
String cancelText = Component.translatable("gui.tiedup.cancel").getString(); mouseY >= cancelY &&
GuiRenderUtil.drawCenteredStringNoShadow(graphics, mc.font, cancelText, mouseY < cancelY + CANCEL_BTN_HEIGHT;
cancelX + CANCEL_BTN_WIDTH / 2, cancelY + (CANCEL_BTN_HEIGHT - mc.font.lineHeight) / 2, GuiRenderUtil.drawMCButton(
GuiRenderUtil.MC_TEXT_DARK); graphics,
cancelX,
cancelY,
CANCEL_BTN_WIDTH,
CANCEL_BTN_HEIGHT,
cancelHovered,
true
);
String cancelText = Component.translatable(
"gui.tiedup.cancel"
).getString();
GuiRenderUtil.drawCenteredStringNoShadow(
graphics,
mc.font,
cancelText,
cancelX + CANCEL_BTN_WIDTH / 2,
cancelY + (CANCEL_BTN_HEIGHT - mc.font.lineHeight) / 2,
GuiRenderUtil.MC_TEXT_DARK
);
} }
@Override @Override
@@ -202,8 +321,12 @@ public class ItemPickerOverlay extends AbstractWidget {
// Cancel button // Cancel button
int cancelX = panelX + (panelW - CANCEL_BTN_WIDTH) / 2; int cancelX = panelX + (panelW - CANCEL_BTN_WIDTH) / 2;
int cancelY = panelY + panelH - CANCEL_BTN_HEIGHT - PADDING; int cancelY = panelY + panelH - CANCEL_BTN_HEIGHT - PADDING;
if (mouseX >= cancelX && mouseX < cancelX + CANCEL_BTN_WIDTH if (
&& mouseY >= cancelY && mouseY < cancelY + CANCEL_BTN_HEIGHT) { mouseX >= cancelX &&
mouseX < cancelX + CANCEL_BTN_WIDTH &&
mouseY >= cancelY &&
mouseY < cancelY + CANCEL_BTN_HEIGHT
) {
close(); close();
if (onCancelled != null) onCancelled.run(); if (onCancelled != null) onCancelled.run();
return true; return true;
@@ -211,7 +334,13 @@ public class ItemPickerOverlay extends AbstractWidget {
// Entry clicks // Entry clicks
int listY = panelY + PADDING + mc.font.lineHeight + 8; int listY = panelY + PADDING + mc.font.lineHeight + 8;
int maxVisible = (panelH - PADDING * 3 - mc.font.lineHeight - 8 - CANCEL_BTN_HEIGHT) / ENTRY_HEIGHT; int maxVisible =
(panelH -
PADDING * 3 -
mc.font.lineHeight -
8 -
CANCEL_BTN_HEIGHT) /
ENTRY_HEIGHT;
for (int i = 0; i < Math.min(entries.size(), maxVisible); i++) { for (int i = 0; i < Math.min(entries.size(), maxVisible); i++) {
int idx = i + scrollOffset; int idx = i + scrollOffset;
@@ -221,21 +350,30 @@ public class ItemPickerOverlay extends AbstractWidget {
int entryX = panelX + PADDING; int entryX = panelX + PADDING;
int entryW = panelW - PADDING * 2; int entryW = panelW - PADDING * 2;
if (mouseX >= entryX && mouseX < entryX + entryW if (
&& mouseY >= entryY && mouseY < entryY + ENTRY_HEIGHT) { mouseX >= entryX &&
mouseX < entryX + entryW &&
mouseY >= entryY &&
mouseY < entryY + ENTRY_HEIGHT
) {
// ARMS self-equip warning: double-click confirmation // ARMS self-equip warning: double-click confirmation
if (isSelfMode && targetRegion == BodyRegionV2.ARMS) { if (isSelfMode && targetRegion == BodyRegionV2.ARMS) {
if (armsWarningSlot == entry.inventorySlot) { if (armsWarningSlot == entry.inventorySlot) {
// Second click — confirm // Second click — confirm
if (onItemSelected != null) onItemSelected.accept(targetRegion, entry.inventorySlot); if (onItemSelected != null) onItemSelected.accept(
targetRegion,
entry.inventorySlot
);
close(); close();
} else { } else {
// First click — show warning // First click — show warning
armsWarningSlot = entry.inventorySlot; armsWarningSlot = entry.inventorySlot;
} }
} else { } else {
if (onItemSelected != null) onItemSelected.accept(targetRegion, entry.inventorySlot); if (onItemSelected != null) onItemSelected.accept(
targetRegion,
entry.inventorySlot
);
close(); close();
} }
playDownSound(mc.getSoundManager()); playDownSound(mc.getSoundManager());
@@ -244,8 +382,12 @@ public class ItemPickerOverlay extends AbstractWidget {
} }
// Click outside panel = cancel // Click outside panel = cancel
if (mouseX < panelX || mouseX > panelX + panelW if (
|| mouseY < panelY || mouseY > panelY + panelH) { mouseX < panelX ||
mouseX > panelX + panelW ||
mouseY < panelY ||
mouseY > panelY + panelH
) {
close(); close();
if (onCancelled != null) onCancelled.run(); if (onCancelled != null) onCancelled.run();
return true; return true;
@@ -258,9 +400,18 @@ public class ItemPickerOverlay extends AbstractWidget {
public boolean mouseScrolled(double mouseX, double mouseY, double delta) { public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
if (!visible) return false; if (!visible) return false;
int panelH = getPanelHeight(); int panelH = getPanelHeight();
int maxVisible = (panelH - PADDING * 3 - Minecraft.getInstance().font.lineHeight - 8 - CANCEL_BTN_HEIGHT) / ENTRY_HEIGHT; int maxVisible =
(panelH -
PADDING * 3 -
Minecraft.getInstance().font.lineHeight -
8 -
CANCEL_BTN_HEIGHT) /
ENTRY_HEIGHT;
int maxScroll = Math.max(0, entries.size() - maxVisible); int maxScroll = Math.max(0, entries.size() - maxVisible);
scrollOffset = Math.max(0, Math.min(maxScroll, scrollOffset - (int) delta)); scrollOffset = Math.max(
0,
Math.min(maxScroll, scrollOffset - (int) delta)
);
return true; return true;
} }
@@ -268,7 +419,8 @@ public class ItemPickerOverlay extends AbstractWidget {
public boolean keyPressed(int keyCode, int scanCode, int modifiers) { public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (!visible) return false; if (!visible) return false;
// ESC closes overlay // ESC closes overlay
if (keyCode == 256) { // GLFW_KEY_ESCAPE if (keyCode == 256) {
// GLFW_KEY_ESCAPE
close(); close();
if (onCancelled != null) onCancelled.run(); if (onCancelled != null) onCancelled.run();
return true; return true;
@@ -278,7 +430,17 @@ public class ItemPickerOverlay extends AbstractWidget {
@Override @Override
protected void updateWidgetNarration(NarrationElementOutput output) { protected void updateWidgetNarration(NarrationElementOutput output) {
output.add(NarratedElementType.TITLE, Component.translatable("gui.tiedup.picker.title", output.add(
targetRegion != null ? Component.translatable("gui.tiedup.region." + targetRegion.name().toLowerCase()) : "")); NarratedElementType.TITLE,
Component.translatable(
"gui.tiedup.picker.title",
targetRegion != null
? Component.translatable(
"gui.tiedup.region." +
targetRegion.name().toLowerCase()
)
: ""
)
);
} }
} }

View File

@@ -231,8 +231,12 @@ public class RegionSlotWidget extends AbstractWidget {
if (!showEquipButton || !itemGetter.get().isEmpty()) return false; if (!showEquipButton || !itemGetter.get().isEmpty()) return false;
int bx = getEquipButtonX(); int bx = getEquipButtonX();
int by = getEquipButtonY(); int by = getEquipButtonY();
return mouseX >= bx && mouseX < bx + EQUIP_BUTTON_WIDTH return (
&& mouseY >= by && mouseY < by + EQUIP_BUTTON_HEIGHT; mouseX >= bx &&
mouseX < bx + EQUIP_BUTTON_WIDTH &&
mouseY >= by &&
mouseY < by + EQUIP_BUTTON_HEIGHT
);
} }
@Override @Override
@@ -249,19 +253,32 @@ public class RegionSlotWidget extends AbstractWidget {
boolean adjustHovered = isAdjustButtonHovered(mouseX, mouseY); boolean adjustHovered = isAdjustButtonHovered(mouseX, mouseY);
boolean removeHovered = isRemoveButtonHovered(mouseX, mouseY); boolean removeHovered = isRemoveButtonHovered(mouseX, mouseY);
boolean equipHovered = isEquipButtonHovered(mouseX, mouseY); boolean equipHovered = isEquipButtonHovered(mouseX, mouseY);
boolean anyButtonHovered = adjustHovered || removeHovered || equipHovered; boolean anyButtonHovered =
adjustHovered || removeHovered || equipHovered;
// MC-style sunken slot background // MC-style sunken slot background
GuiRenderUtil.drawMCSlot(graphics, getX(), getY(), width, height); GuiRenderUtil.drawMCSlot(graphics, getX(), getY(), width, height);
// Selected: gold highlight border on top of the slot // Selected: gold highlight border on top of the slot
if (selected) { if (selected) {
GuiRenderUtil.drawSelectedBorder(graphics, getX(), getY(), width, height); GuiRenderUtil.drawSelectedBorder(
graphics,
getX(),
getY(),
width,
height
);
} }
// Hover overlay (vanilla white semi-transparent) // Hover overlay (vanilla white semi-transparent)
if (hovered && !anyButtonHovered && !selected) { if (hovered && !anyButtonHovered && !selected) {
GuiRenderUtil.drawSlotHover(graphics, getX(), getY(), width, height); GuiRenderUtil.drawSlotHover(
graphics,
getX(),
getY(),
width,
height
);
} }
// Region icon (uniform dark gray square) // Region icon (uniform dark gray square)
@@ -325,22 +342,43 @@ public class RegionSlotWidget extends AbstractWidget {
); );
// Resistance bar (for items that implement IHasResistance) // Resistance bar (for items that implement IHasResistance)
if (hasItem && stack.getItem() instanceof IHasResistance resistanceItem) { if (
hasItem && stack.getItem() instanceof IHasResistance resistanceItem
) {
Player player = mc.player; Player player = mc.player;
if (player != null) { if (player != null) {
int current = resistanceItem.getCurrentResistance(stack, player); int current = resistanceItem.getCurrentResistance(
stack,
player
);
int base = resistanceItem.getBaseResistance(player); int base = resistanceItem.getBaseResistance(player);
if (base > 0) { if (base > 0) {
float ratio = Math.max(0f, Math.min(1f, (float) current / base)); float ratio = Math.max(
0f,
Math.min(1f, (float) current / base)
);
int barX = getX() + width - RESISTANCE_BAR_WIDTH - PADDING; int barX = getX() + width - RESISTANCE_BAR_WIDTH - PADDING;
int barY = getY() + height - RESISTANCE_BAR_HEIGHT - PADDING; int barY =
getY() + height - RESISTANCE_BAR_HEIGHT - PADDING;
// Sunken bar background // Sunken bar background
graphics.fill(barX, barY, barX + RESISTANCE_BAR_WIDTH, barY + RESISTANCE_BAR_HEIGHT, 0xFF373737); graphics.fill(
barX,
barY,
barX + RESISTANCE_BAR_WIDTH,
barY + RESISTANCE_BAR_HEIGHT,
0xFF373737
);
// Colored fill: red below 30%, green otherwise // Colored fill: red below 30%, green otherwise
int fillColor = (ratio < 0.30f) ? 0xFFFF4444 : 0xFF44CC44; int fillColor = (ratio < 0.30f) ? 0xFFFF4444 : 0xFF44CC44;
int fillWidth = Math.round(RESISTANCE_BAR_WIDTH * ratio); int fillWidth = Math.round(RESISTANCE_BAR_WIDTH * ratio);
if (fillWidth > 0) { if (fillWidth > 0) {
graphics.fill(barX, barY, barX + fillWidth, barY + RESISTANCE_BAR_HEIGHT, fillColor); graphics.fill(
barX,
barY,
barX + fillWidth,
barY + RESISTANCE_BAR_HEIGHT,
fillColor
);
} }
} }
} }
@@ -350,8 +388,18 @@ public class RegionSlotWidget extends AbstractWidget {
if (!hasItem && showEquipButton) { if (!hasItem && showEquipButton) {
int bx = getEquipButtonX(); int bx = getEquipButtonX();
int by = getEquipButtonY(); int by = getEquipButtonY();
GuiRenderUtil.drawMCButton(graphics, bx, by, EQUIP_BUTTON_WIDTH, EQUIP_BUTTON_HEIGHT, equipHovered, true); GuiRenderUtil.drawMCButton(
String equipLabel = Component.translatable("gui.tiedup.equip").getString(); graphics,
bx,
by,
EQUIP_BUTTON_WIDTH,
EQUIP_BUTTON_HEIGHT,
equipHovered,
true
);
String equipLabel = Component.translatable(
"gui.tiedup.equip"
).getString();
GuiRenderUtil.drawCenteredStringNoShadow( GuiRenderUtil.drawCenteredStringNoShadow(
graphics, graphics,
mc.font, mc.font,
@@ -366,7 +414,15 @@ public class RegionSlotWidget extends AbstractWidget {
if (showAdjustButton && hasItem) { if (showAdjustButton && hasItem) {
int buttonX = getAdjustButtonX(); int buttonX = getAdjustButtonX();
int buttonY = getY() + (height - ADJUST_BUTTON_SIZE) / 2; int buttonY = getY() + (height - ADJUST_BUTTON_SIZE) / 2;
GuiRenderUtil.drawMCButton(graphics, buttonX, buttonY, ADJUST_BUTTON_SIZE, ADJUST_BUTTON_SIZE, adjustHovered, true); GuiRenderUtil.drawMCButton(
graphics,
buttonX,
buttonY,
ADJUST_BUTTON_SIZE,
ADJUST_BUTTON_SIZE,
adjustHovered,
true
);
// Gear icon placeholder // Gear icon placeholder
GuiRenderUtil.drawCenteredStringNoShadow( GuiRenderUtil.drawCenteredStringNoShadow(
@@ -383,10 +439,23 @@ public class RegionSlotWidget extends AbstractWidget {
if (showRemoveButton && hasItem) { if (showRemoveButton && hasItem) {
int buttonX = getRemoveButtonX(); int buttonX = getRemoveButtonX();
int buttonY = getY() + (height - REMOVE_BUTTON_SIZE) / 2; int buttonY = getY() + (height - REMOVE_BUTTON_SIZE) / 2;
GuiRenderUtil.drawMCButton(graphics, buttonX, buttonY, REMOVE_BUTTON_SIZE, REMOVE_BUTTON_SIZE, removeHovered, true); GuiRenderUtil.drawMCButton(
graphics,
buttonX,
buttonY,
REMOVE_BUTTON_SIZE,
REMOVE_BUTTON_SIZE,
removeHovered,
true
);
// Red-tinted overlay // Red-tinted overlay
graphics.fill(buttonX + 1, buttonY + 1, buttonX + REMOVE_BUTTON_SIZE - 1, buttonY + REMOVE_BUTTON_SIZE - 1, graphics.fill(
removeHovered ? 0x40FF0000 : 0x20FF0000); buttonX + 1,
buttonY + 1,
buttonX + REMOVE_BUTTON_SIZE - 1,
buttonY + REMOVE_BUTTON_SIZE - 1,
removeHovered ? 0x40FF0000 : 0x20FF0000
);
// X icon // X icon
GuiRenderUtil.drawCenteredStringNoShadow( GuiRenderUtil.drawCenteredStringNoShadow(

View File

@@ -26,11 +26,31 @@ public class RegionTabBar extends AbstractWidget {
* Body zone tabs grouping the 14 BodyRegionV2 values. * Body zone tabs grouping the 14 BodyRegionV2 values.
*/ */
public enum BodyTab { public enum BodyTab {
HEAD("gui.tiedup.tab.head", BodyRegionV2.HEAD, BodyRegionV2.EYES, BodyRegionV2.EARS, BodyRegionV2.MOUTH), HEAD(
"gui.tiedup.tab.head",
BodyRegionV2.HEAD,
BodyRegionV2.EYES,
BodyRegionV2.EARS,
BodyRegionV2.MOUTH
),
UPPER("gui.tiedup.tab.upper", BodyRegionV2.NECK, BodyRegionV2.TORSO), UPPER("gui.tiedup.tab.upper", BodyRegionV2.NECK, BodyRegionV2.TORSO),
ARMS("gui.tiedup.tab.arms", BodyRegionV2.ARMS, BodyRegionV2.HANDS, BodyRegionV2.FINGERS), ARMS(
LOWER("gui.tiedup.tab.lower", BodyRegionV2.WAIST, BodyRegionV2.LEGS, BodyRegionV2.FEET), "gui.tiedup.tab.arms",
SPECIAL("gui.tiedup.tab.special", BodyRegionV2.TAIL, BodyRegionV2.WINGS); BodyRegionV2.ARMS,
BodyRegionV2.HANDS,
BodyRegionV2.FINGERS
),
LOWER(
"gui.tiedup.tab.lower",
BodyRegionV2.WAIST,
BodyRegionV2.LEGS,
BodyRegionV2.FEET
),
SPECIAL(
"gui.tiedup.tab.special",
BodyRegionV2.TAIL,
BodyRegionV2.WINGS
);
private final String translationKey; private final String translationKey;
private final Set<BodyRegionV2> regions; private final Set<BodyRegionV2> regions;
@@ -41,13 +61,20 @@ public class RegionTabBar extends AbstractWidget {
for (BodyRegionV2 r : regions) this.regions.add(r); for (BodyRegionV2 r : regions) this.regions.add(r);
} }
public String getTranslationKey() { return translationKey; } public String getTranslationKey() {
public Set<BodyRegionV2> getRegions() { return regions; } return translationKey;
}
public Set<BodyRegionV2> getRegions() {
return regions;
}
/** Check if any region in this tab has an equipped item on the entity. */ /** Check if any region in this tab has an equipped item on the entity. */
public boolean hasEquippedItems(LivingEntity entity) { public boolean hasEquippedItems(LivingEntity entity) {
for (BodyRegionV2 region : regions) { for (BodyRegionV2 region : regions) {
if (V2EquipmentHelper.isRegionOccupied(entity, region)) return true; if (
V2EquipmentHelper.isRegionOccupied(entity, region)
) return true;
} }
return false; return false;
} }
@@ -71,12 +98,20 @@ public class RegionTabBar extends AbstractWidget {
private LivingEntity targetEntity; private LivingEntity targetEntity;
public RegionTabBar(int x, int y, int width) { public RegionTabBar(int x, int y, int width) {
super(x, y, width, TAB_HEIGHT, Component.literal("Tab Bar")); super(x, y, width, TAB_HEIGHT, Component.translatable("gui.tiedup.tab_bar.label"));
} }
public void setOnTabChanged(Consumer<BodyTab> callback) { this.onTabChanged = callback; } public void setOnTabChanged(Consumer<BodyTab> callback) {
public void setTargetEntity(LivingEntity entity) { this.targetEntity = entity; } this.onTabChanged = callback;
public BodyTab getActiveTab() { return activeTab; } }
public void setTargetEntity(LivingEntity entity) {
this.targetEntity = entity;
}
public BodyTab getActiveTab() {
return activeTab;
}
public void setActiveTab(BodyTab tab) { public void setActiveTab(BodyTab tab) {
if (this.activeTab != tab) { if (this.activeTab != tab) {
@@ -86,7 +121,12 @@ public class RegionTabBar extends AbstractWidget {
} }
@Override @Override
protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { protected void renderWidget(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
// Background bar // Background bar
@@ -99,34 +139,89 @@ public class RegionTabBar extends AbstractWidget {
BodyTab tab = tabs[i]; BodyTab tab = tabs[i];
int tabX = getX() + i * (tabWidth + TAB_SPACING); int tabX = getX() + i * (tabWidth + TAB_SPACING);
boolean isActive = (tab == activeTab); boolean isActive = (tab == activeTab);
boolean isHovered = mouseX >= tabX && mouseX < tabX + tabWidth boolean isHovered =
&& mouseY >= getY() && mouseY < getY() + height; mouseX >= tabX &&
mouseX < tabX + tabWidth &&
mouseY >= getY() &&
mouseY < getY() + height;
int bgColor = isActive ? BG_ACTIVE : (isHovered ? BG_HOVER : BG_INACTIVE); int bgColor = isActive
graphics.fill(tabX, getY(), tabX + tabWidth, getY() + height, bgColor); ? BG_ACTIVE
: (isHovered ? BG_HOVER : BG_INACTIVE);
graphics.fill(
tabX,
getY(),
tabX + tabWidth,
getY() + height,
bgColor
);
if (isActive) { if (isActive) {
// Active tab: raised 3D look, no bottom border (connects to panel below) // Active tab: raised 3D look, no bottom border (connects to panel below)
// Top highlight // Top highlight
graphics.fill(tabX, getY(), tabX + tabWidth, getY() + 1, GuiRenderUtil.MC_HIGHLIGHT_OUTER); graphics.fill(
tabX,
getY(),
tabX + tabWidth,
getY() + 1,
GuiRenderUtil.MC_HIGHLIGHT_OUTER
);
// Left highlight // Left highlight
graphics.fill(tabX, getY(), tabX + 1, getY() + height, GuiRenderUtil.MC_HIGHLIGHT_OUTER); graphics.fill(
tabX,
getY(),
tabX + 1,
getY() + height,
GuiRenderUtil.MC_HIGHLIGHT_OUTER
);
// Right shadow // Right shadow
graphics.fill(tabX + tabWidth - 1, getY(), tabX + tabWidth, getY() + height, GuiRenderUtil.MC_SHADOW_OUTER); graphics.fill(
tabX + tabWidth - 1,
getY(),
tabX + tabWidth,
getY() + height,
GuiRenderUtil.MC_SHADOW_OUTER
);
} else { } else {
// Inactive tab: full 3D sunken borders // Inactive tab: full 3D sunken borders
// Top shadow // Top shadow
graphics.fill(tabX, getY(), tabX + tabWidth, getY() + 1, GuiRenderUtil.MC_SHADOW_OUTER); graphics.fill(
tabX,
getY(),
tabX + tabWidth,
getY() + 1,
GuiRenderUtil.MC_SHADOW_OUTER
);
// Left shadow // Left shadow
graphics.fill(tabX, getY(), tabX + 1, getY() + height, GuiRenderUtil.MC_SHADOW_OUTER); graphics.fill(
tabX,
getY(),
tabX + 1,
getY() + height,
GuiRenderUtil.MC_SHADOW_OUTER
);
// Bottom highlight // Bottom highlight
graphics.fill(tabX, getY() + height - 1, tabX + tabWidth, getY() + height, GuiRenderUtil.MC_HIGHLIGHT_OUTER); graphics.fill(
tabX,
getY() + height - 1,
tabX + tabWidth,
getY() + height,
GuiRenderUtil.MC_HIGHLIGHT_OUTER
);
// Right highlight // Right highlight
graphics.fill(tabX + tabWidth - 1, getY(), tabX + tabWidth, getY() + height, GuiRenderUtil.MC_HIGHLIGHT_OUTER); graphics.fill(
tabX + tabWidth - 1,
getY(),
tabX + tabWidth,
getY() + height,
GuiRenderUtil.MC_HIGHLIGHT_OUTER
);
} }
// Tab label // Tab label
String label = Component.translatable(tab.getTranslationKey()).getString(); String label = Component.translatable(
tab.getTranslationKey()
).getString();
int textColor = isActive ? TEXT_ACTIVE : TEXT_INACTIVE; int textColor = isActive ? TEXT_ACTIVE : TEXT_INACTIVE;
int textX = tabX + (tabWidth - mc.font.width(label)) / 2; int textX = tabX + (tabWidth - mc.font.width(label)) / 2;
int textY = getY() + (height - mc.font.lineHeight) / 2; int textY = getY() + (height - mc.font.lineHeight) / 2;
@@ -136,8 +231,13 @@ public class RegionTabBar extends AbstractWidget {
if (targetEntity != null && tab.hasEquippedItems(targetEntity)) { if (targetEntity != null && tab.hasEquippedItems(targetEntity)) {
int dotX = tabX + tabWidth - DOT_RADIUS - 4; int dotX = tabX + tabWidth - DOT_RADIUS - 4;
int dotY = getY() + 4; int dotY = getY() + 4;
graphics.fill(dotX - DOT_RADIUS, dotY - DOT_RADIUS, graphics.fill(
dotX + DOT_RADIUS, dotY + DOT_RADIUS, 0xFFCCCCCC); dotX - DOT_RADIUS,
dotY - DOT_RADIUS,
dotX + DOT_RADIUS,
dotY + DOT_RADIUS,
0xFFCCCCCC
);
} }
} }
} }
@@ -162,8 +262,12 @@ public class RegionTabBar extends AbstractWidget {
@Override @Override
protected void updateWidgetNarration(NarrationElementOutput output) { protected void updateWidgetNarration(NarrationElementOutput output) {
output.add(NarratedElementType.TITLE, output.add(
Component.translatable("gui.tiedup.tab_bar", NarratedElementType.TITLE,
Component.translatable(activeTab.getTranslationKey()))); Component.translatable(
"gui.tiedup.tab_bar",
Component.translatable(activeTab.getTranslationKey())
)
);
} }
} }

View File

@@ -1,13 +1,12 @@
package com.tiedup.remake.client.gui.widgets; package com.tiedup.remake.client.gui.widgets;
import static com.tiedup.remake.client.gui.util.GuiLayoutConstants.*; import static com.tiedup.remake.client.gui.util.GuiLayoutConstants.*;
import com.tiedup.remake.v2.BodyRegionV2;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.tiedup.remake.client.gui.util.GuiColors; import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.items.ItemGpsCollar; import com.tiedup.remake.v2.bondage.CollarHelper;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.state.IBondageState; import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -370,9 +369,8 @@ public class SlaveEntryWidget
// GPS zone status (right of health) // GPS zone status (right of health)
if (hasGPSCollar()) { if (hasGPSCollar()) {
ItemStack collarStack = slave.getEquipment(BodyRegionV2.NECK); ItemStack collarStack = slave.getEquipment(BodyRegionV2.NECK);
if (collarStack.getItem() instanceof ItemGpsCollar gps) { if (CollarHelper.hasGPS(collarStack)) {
boolean inSafeZone = isInAnySafeZone( boolean inSafeZone = isInAnySafeZone(
gps,
collarStack, collarStack,
entity entity
); );
@@ -560,33 +558,35 @@ public class SlaveEntryWidget
private boolean hasShockCollar() { private boolean hasShockCollar() {
if (!slave.hasCollar()) return false; if (!slave.hasCollar()) return false;
ItemStack collar = slave.getEquipment(BodyRegionV2.NECK); ItemStack collar = slave.getEquipment(BodyRegionV2.NECK);
return ( return CollarHelper.canShock(collar);
collar.getItem() instanceof ItemCollar itemCollar &&
itemCollar.canShock()
);
} }
private boolean hasGPSCollar() { private boolean hasGPSCollar() {
if (!slave.hasCollar()) return false; if (!slave.hasCollar()) return false;
ItemStack collar = slave.getEquipment(BodyRegionV2.NECK); ItemStack collar = slave.getEquipment(BodyRegionV2.NECK);
return ( return CollarHelper.hasGPS(collar);
collar.getItem() instanceof ItemCollar itemCollar &&
itemCollar.hasGPS()
);
} }
private boolean isInAnySafeZone( private boolean isInAnySafeZone(
ItemGpsCollar gps,
ItemStack collarStack, ItemStack collarStack,
LivingEntity entity LivingEntity entity
) { ) {
if (!gps.isActive(collarStack)) return true; if (!CollarHelper.isActive(collarStack)) return true;
var safeSpots = gps.getSafeSpots(collarStack); // Read safe spots from NBT
net.minecraft.nbt.CompoundTag tag = collarStack.getTag();
if (tag == null || !tag.contains("safeSpots", net.minecraft.nbt.Tag.TAG_LIST)) return true;
net.minecraft.nbt.ListTag safeSpots = tag.getList("safeSpots", net.minecraft.nbt.Tag.TAG_COMPOUND);
if (safeSpots.isEmpty()) return true; if (safeSpots.isEmpty()) return true;
for (var spot : safeSpots) { for (int i = 0; i < safeSpots.size(); i++) {
if (spot.isInside(entity)) { net.minecraft.nbt.CompoundTag spot = safeSpots.getCompound(i);
double x = spot.getDouble("x");
double y = spot.getDouble("y");
double z = spot.getDouble("z");
int radius = spot.contains("radius") ? spot.getInt("radius") : 50;
double dist = entity.distanceToSqr(x, y, z);
if (dist <= (double) radius * radius) {
return true; return true;
} }
} }

View File

@@ -40,21 +40,40 @@ public class StatusBarWidget extends AbstractWidget {
private static final int CLOSE_BTN_HEIGHT = 22; private static final int CLOSE_BTN_HEIGHT = 22;
public StatusBarWidget(int x, int y, int width, int height) { public StatusBarWidget(int x, int y, int width, int height) {
super(x, y, width, height, Component.literal("Status Bar")); super(x, y, width, height, Component.translatable("gui.tiedup.status_bar"));
} }
public void setMode(ActionPanel.ScreenMode mode) { this.mode = mode; } public void setMode(ActionPanel.ScreenMode mode) {
public void setTargetEntity(LivingEntity entity) { this.targetEntity = entity; } this.mode = mode;
public void setOnCloseClicked(Runnable cb) { this.onCloseClicked = cb; } }
public void setTargetEntity(LivingEntity entity) {
this.targetEntity = entity;
}
public void setOnCloseClicked(Runnable cb) {
this.onCloseClicked = cb;
}
@Override @Override
protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { protected void renderWidget(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
Player player = mc.player; Player player = mc.player;
if (player == null) return; if (player == null) return;
// MC-style sunken inset panel (slightly darker than main) // MC-style sunken inset panel (slightly darker than main)
GuiRenderUtil.drawMCSunkenPanel(graphics, getX(), getY(), width, height); GuiRenderUtil.drawMCSunkenPanel(
graphics,
getX(),
getY(),
width,
height
);
int textY1 = getY() + PADDING; int textY1 = getY() + PADDING;
int textY2 = textY1 + mc.font.lineHeight + 2; int textY2 = textY1 + mc.font.lineHeight + 2;
@@ -68,46 +87,104 @@ public class StatusBarWidget extends AbstractWidget {
// Close button (right side, MC-style button) // Close button (right side, MC-style button)
int closeBtnX = getX() + width - CLOSE_BTN_WIDTH - PADDING; int closeBtnX = getX() + width - CLOSE_BTN_WIDTH - PADDING;
int closeBtnY = getY() + (height - CLOSE_BTN_HEIGHT) / 2; int closeBtnY = getY() + (height - CLOSE_BTN_HEIGHT) / 2;
boolean closeHovered = mouseX >= closeBtnX && mouseX < closeBtnX + CLOSE_BTN_WIDTH boolean closeHovered =
&& mouseY >= closeBtnY && mouseY < closeBtnY + CLOSE_BTN_HEIGHT; mouseX >= closeBtnX &&
GuiRenderUtil.drawMCButton(graphics, closeBtnX, closeBtnY, CLOSE_BTN_WIDTH, CLOSE_BTN_HEIGHT, closeHovered, true); mouseX < closeBtnX + CLOSE_BTN_WIDTH &&
String closeText = Component.translatable("gui.tiedup.close_esc").getString(); mouseY >= closeBtnY &&
GuiRenderUtil.drawCenteredStringNoShadow(graphics, mc.font, closeText, mouseY < closeBtnY + CLOSE_BTN_HEIGHT;
closeBtnX + CLOSE_BTN_WIDTH / 2, closeBtnY + (CLOSE_BTN_HEIGHT - mc.font.lineHeight) / 2, GuiRenderUtil.drawMCButton(
GuiRenderUtil.MC_TEXT_DARK); graphics,
closeBtnX,
closeBtnY,
CLOSE_BTN_WIDTH,
CLOSE_BTN_HEIGHT,
closeHovered,
true
);
String closeText = Component.translatable(
"gui.tiedup.close_esc"
).getString();
GuiRenderUtil.drawCenteredStringNoShadow(
graphics,
mc.font,
closeText,
closeBtnX + CLOSE_BTN_WIDTH / 2,
closeBtnY + (CLOSE_BTN_HEIGHT - mc.font.lineHeight) / 2,
GuiRenderUtil.MC_TEXT_DARK
);
} }
private void renderSelfStatus(GuiGraphics graphics, Player player, int y1, int y2) { private void renderSelfStatus(
GuiGraphics graphics,
Player player,
int y1,
int y2
) {
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
int x = getX() + PADDING; int x = getX() + PADDING;
// Tool status line 1 // Tool status line 1
ItemStack lockpick = ItemLockpick.findLockpickInInventory(player); ItemStack lockpick = ItemLockpick.findLockpickInInventory(player);
String pickText = lockpick.isEmpty() String pickText = lockpick.isEmpty()
? Component.translatable("gui.tiedup.status.no_lockpick").getString() ? Component.translatable(
: Component.translatable("gui.tiedup.status.lockpick_uses", "gui.tiedup.status.no_lockpick"
lockpick.getMaxDamage() - lockpick.getDamageValue()).getString(); ).getString()
graphics.drawString(mc.font, pickText, x, y1, GuiRenderUtil.MC_TEXT_DARK, false); : Component.translatable(
"gui.tiedup.status.lockpick_uses",
lockpick.getMaxDamage() - lockpick.getDamageValue()
).getString();
graphics.drawString(
mc.font,
pickText,
x,
y1,
GuiRenderUtil.MC_TEXT_DARK,
false
);
ItemStack knife = GenericKnife.findKnifeInInventory(player); ItemStack knife = GenericKnife.findKnifeInInventory(player);
String knifeText = knife.isEmpty() String knifeText = knife.isEmpty()
? Component.translatable("gui.tiedup.status.no_knife").getString() ? Component.translatable("gui.tiedup.status.no_knife").getString()
: Component.translatable("gui.tiedup.status.knife_uses", : Component.translatable(
knife.getMaxDamage() - knife.getDamageValue()).getString(); "gui.tiedup.status.knife_uses",
graphics.drawString(mc.font, knifeText, x + 150, y1, GuiRenderUtil.MC_TEXT_DARK, false); knife.getMaxDamage() - knife.getDamageValue()
).getString();
graphics.drawString(
mc.font,
knifeText,
x + 150,
y1,
GuiRenderUtil.MC_TEXT_DARK,
false
);
// Arms resistance line 2 // Arms resistance line 2
ItemStack armsBind = V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS); ItemStack armsBind = V2EquipmentHelper.getInRegion(
if (!armsBind.isEmpty() && armsBind.getItem() instanceof IHasResistance res) { player,
BodyRegionV2.ARMS
);
if (
!armsBind.isEmpty() &&
armsBind.getItem() instanceof IHasResistance res
) {
int curr = res.getCurrentResistance(armsBind, player); int curr = res.getCurrentResistance(armsBind, player);
int max = res.getBaseResistance(player); int max = res.getBaseResistance(player);
String resText = Component.translatable("gui.tiedup.status.arms_resistance", curr, max).getString(); String resText = Component.translatable(
"gui.tiedup.status.arms_resistance",
curr,
max
).getString();
int color = curr < max * 0.3 ? GuiColors.ERROR : GuiColors.SUCCESS; int color = curr < max * 0.3 ? GuiColors.ERROR : GuiColors.SUCCESS;
graphics.drawString(mc.font, resText, x, y2, color, false); graphics.drawString(mc.font, resText, x, y2, color, false);
} }
} }
private void renderMasterStatus(GuiGraphics graphics, Player player, int y1, int y2) { private void renderMasterStatus(
GuiGraphics graphics,
Player player,
int y1,
int y2
) {
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
int x = getX() + PADDING; int x = getX() + PADDING;
@@ -127,18 +204,38 @@ public class StatusBarWidget extends AbstractWidget {
String keyText; String keyText;
if (keyStack.isEmpty()) { if (keyStack.isEmpty()) {
keyText = Component.translatable("gui.tiedup.status.no_key").getString(); keyText = Component.translatable(
"gui.tiedup.status.no_key"
).getString();
} else { } else {
keyText = Component.translatable("gui.tiedup.status.key_info", keyText = Component.translatable(
keyStack.getHoverName().getString()).getString(); "gui.tiedup.status.key_info",
keyStack.getHoverName().getString()
).getString();
} }
graphics.drawString(mc.font, keyText, x, y1, GuiRenderUtil.MC_TEXT_DARK, false); graphics.drawString(
mc.font,
keyText,
x,
y1,
GuiRenderUtil.MC_TEXT_DARK,
false
);
// Target info // Target info
if (targetEntity != null) { if (targetEntity != null) {
String targetText = Component.translatable("gui.tiedup.status.target_info", String targetText = Component.translatable(
targetEntity.getName().getString()).getString(); "gui.tiedup.status.target_info",
graphics.drawString(mc.font, targetText, x, y2, GuiRenderUtil.MC_TEXT_DARK, false); targetEntity.getName().getString()
).getString();
graphics.drawString(
mc.font,
targetText,
x,
y2,
GuiRenderUtil.MC_TEXT_DARK,
false
);
} }
} }
@@ -148,8 +245,12 @@ public class StatusBarWidget extends AbstractWidget {
int closeBtnX = getX() + width - CLOSE_BTN_WIDTH - PADDING; int closeBtnX = getX() + width - CLOSE_BTN_WIDTH - PADDING;
int closeBtnY = getY() + (height - CLOSE_BTN_HEIGHT) / 2; int closeBtnY = getY() + (height - CLOSE_BTN_HEIGHT) / 2;
if (mouseX >= closeBtnX && mouseX < closeBtnX + CLOSE_BTN_WIDTH if (
&& mouseY >= closeBtnY && mouseY < closeBtnY + CLOSE_BTN_HEIGHT) { mouseX >= closeBtnX &&
mouseX < closeBtnX + CLOSE_BTN_WIDTH &&
mouseY >= closeBtnY &&
mouseY < closeBtnY + CLOSE_BTN_HEIGHT
) {
if (onCloseClicked != null) onCloseClicked.run(); if (onCloseClicked != null) onCloseClicked.run();
playDownSound(Minecraft.getInstance().getSoundManager()); playDownSound(Minecraft.getInstance().getSoundManager());
return true; return true;
@@ -159,6 +260,9 @@ public class StatusBarWidget extends AbstractWidget {
@Override @Override
protected void updateWidgetNarration(NarrationElementOutput output) { protected void updateWidgetNarration(NarrationElementOutput output) {
output.add(NarratedElementType.TITLE, Component.translatable("gui.tiedup.status_bar")); output.add(
NarratedElementType.TITLE,
Component.translatable("gui.tiedup.status_bar")
);
} }
} }

View File

@@ -9,11 +9,12 @@ import com.tiedup.remake.entities.AbstractTiedUpNpc;
import com.tiedup.remake.entities.EntityKidnapperArcher; import com.tiedup.remake.entities.EntityKidnapperArcher;
import com.tiedup.remake.entities.EntityMaster; import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.entities.ai.master.MasterState; import com.tiedup.remake.entities.ai.master.MasterState;
import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.v2.bondage.BindModeHelper;
import com.tiedup.remake.v2.bondage.PoseTypeHelper;
import com.tiedup.remake.items.clothes.GenericClothes;
import com.tiedup.remake.v2.BodyRegionV2; import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper; import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.items.clothes.GenericClothes;
import dev.kosmx.playerAnim.core.impl.AnimationProcessor; import dev.kosmx.playerAnim.core.impl.AnimationProcessor;
import dev.kosmx.playerAnim.core.util.SetableSupplier; import dev.kosmx.playerAnim.core.util.SetableSupplier;
import dev.kosmx.playerAnim.impl.Helper; import dev.kosmx.playerAnim.impl.Helper;
@@ -33,8 +34,6 @@ import org.slf4j.Logger;
/** /**
* Model for AbstractTiedUpNpc - Humanoid female NPC. * Model for AbstractTiedUpNpc - Humanoid female NPC.
* *
* Phase 14.2.3: Rendering system
* Phase 19: Extends PlayerModel for full layer support (hat, jacket, sleeves, pants)
* *
* Features: * Features:
* - Extends PlayerModel for player-like rendering with outer layers * - Extends PlayerModel for player-like rendering with outer layers
@@ -151,9 +150,7 @@ public class DamselModel
} }
} }
// ========================================
// IMutableModel Implementation // IMutableModel Implementation
// ========================================
@Override @Override
public void setEmoteSupplier(SetableSupplier<AnimationProcessor> supplier) { public void setEmoteSupplier(SetableSupplier<AnimationProcessor> supplier) {
@@ -174,8 +171,6 @@ public class DamselModel
* - Tied up: Arms behind back, legs frozen (or variant pose based on bind type) * - Tied up: Arms behind back, legs frozen (or variant pose based on bind type)
* - Free: Normal humanoid animations * - Free: Normal humanoid animations
* *
* Phase 15: Different poses for different bind types (straitjacket, wrap, latex_sack)
* Phase 15.1: Hide arms for wrap/latex_sack (matching original mod)
* *
* @param entity AbstractTiedUpNpc instance * @param entity AbstractTiedUpNpc instance
* @param limbSwing Limb swing animation value * @param limbSwing Limb swing animation value
@@ -193,7 +188,6 @@ public class DamselModel
float netHeadYaw, float netHeadYaw,
float headPitch float headPitch
) { ) {
// Phase 18: Handle archer arm poses BEFORE super call
// Only show bow animation when in ranged mode (has active shooting target) // Only show bow animation when in ranged mode (has active shooting target)
if (entity instanceof EntityKidnapperArcher archer) { if (entity instanceof EntityKidnapperArcher archer) {
if (archer.isInRangedMode()) { if (archer.isInRangedMode()) {
@@ -222,7 +216,7 @@ public class DamselModel
// Arms // Arms
this.leftArm.visible = true; this.leftArm.visible = true;
this.rightArm.visible = true; this.rightArm.visible = true;
// Outer layers (Phase 19) // Outer layers
this.hat.visible = true; this.hat.visible = true;
this.jacket.visible = true; this.jacket.visible = true;
this.leftSleeve.visible = true; this.leftSleeve.visible = true;
@@ -237,11 +231,7 @@ public class DamselModel
if (inPose) { if (inPose) {
ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS); ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS);
PoseType poseType = PoseType.STANDARD; PoseType poseType = PoseTypeHelper.getPoseType(bind);
if (bind.getItem() instanceof ItemBind itemBind) {
poseType = itemBind.getPoseType();
}
// Hide arms for wrap/latex_sack poses // Hide arms for wrap/latex_sack poses
if (poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK) { if (poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK) {
@@ -259,9 +249,7 @@ public class DamselModel
PoseType currentPoseType = PoseType.STANDARD; PoseType currentPoseType = PoseType.STANDARD;
if (inPose) { if (inPose) {
ItemStack bindForPoseType = entity.getEquipment(BodyRegionV2.ARMS); ItemStack bindForPoseType = entity.getEquipment(BodyRegionV2.ARMS);
if (bindForPoseType.getItem() instanceof ItemBind itemBindForType) { currentPoseType = PoseTypeHelper.getPoseType(bindForPoseType);
currentPoseType = itemBindForType.getPoseType();
}
} }
// Check if this is a Master in human chair mode (head should look around freely) // Check if this is a Master in human chair mode (head should look around freely)
@@ -302,7 +290,7 @@ public class DamselModel
); );
} }
// Sync outer layers to their parents (Phase 19) // Sync outer layers to their parents
this.hat.copyFrom(this.head); this.hat.copyFrom(this.head);
this.jacket.copyFrom(this.body); this.jacket.copyFrom(this.body);
this.leftSleeve.copyFrom(this.leftArm); this.leftSleeve.copyFrom(this.leftArm);
@@ -313,19 +301,23 @@ public class DamselModel
// Animation not yet active (1-frame delay) - apply static pose as fallback // Animation not yet active (1-frame delay) - apply static pose as fallback
// This ensures immediate visual feedback when bind is applied // This ensures immediate visual feedback when bind is applied
ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS); ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS);
PoseType fallbackPoseType = PoseType.STANDARD; PoseType fallbackPoseType = PoseTypeHelper.getPoseType(bind);
if (bind.getItem() instanceof ItemBind itemBind) {
fallbackPoseType = itemBind.getPoseType();
}
// Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder) // Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder)
boolean armsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.ARMS); boolean armsBound = V2EquipmentHelper.isRegionOccupied(
boolean legsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.LEGS); entity,
BodyRegionV2.ARMS
);
boolean legsBound = V2EquipmentHelper.isRegionOccupied(
entity,
BodyRegionV2.LEGS
);
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) { if (
armsBound = ItemBind.hasArmsBound(bind); !armsBound && !legsBound && BindModeHelper.isBindItem(bind)
legsBound = ItemBind.hasLegsBound(bind); ) {
armsBound = BindModeHelper.hasArmsBound(bind);
legsBound = BindModeHelper.hasLegsBound(bind);
} }
// Apply static pose directly to model parts // Apply static pose directly to model parts
@@ -374,7 +366,6 @@ public class DamselModel
this.rightPants.copyFrom(this.rightLeg); this.rightPants.copyFrom(this.rightLeg);
} }
// Phase 19: Hide wearer's outer layers based on clothes settings
// This MUST happen after super.setupAnim() which can reset visibility // This MUST happen after super.setupAnim() which can reset visibility
hideWearerLayersForClothes(entity); hideWearerLayersForClothes(entity);
} }

View File

@@ -44,7 +44,6 @@ public class DamselRenderer
/** /**
* Create renderer. * Create renderer.
* *
* Phase 19: Uses vanilla ModelLayers.PLAYER for full layer support (jacket, sleeves, pants).
*/ */
public DamselRenderer(EntityRendererProvider.Context context) { public DamselRenderer(EntityRendererProvider.Context context) {
super( super(
@@ -86,14 +85,15 @@ public class DamselRenderer
} }
// Add V2 bondage render layer (GLB-based V2 equipment rendering) // Add V2 bondage render layer (GLB-based V2 equipment rendering)
this.addLayer(new com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer<>(this)); this.addLayer(
new com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer<>(this)
);
} }
/** /**
* Render the entity. * Render the entity.
* Uses entity's hasSlimArms() for model selection. * Uses entity's hasSlimArms() for model selection.
* *
* Phase 19: Wearer layer hiding is now handled in DamselModel.setupAnim()
* to ensure it happens after visibility resets. * to ensure it happens after visibility resets.
* *
* DOG pose: X rotation is applied in setupRotations() AFTER Y rotation, * DOG pose: X rotation is applied in setupRotations() AFTER Y rotation,

View File

@@ -16,7 +16,6 @@ import net.minecraft.util.Mth;
/** /**
* Renderer for EntityKidnapBomb. * Renderer for EntityKidnapBomb.
* *
* Phase 16: Blocks
* *
* Renders the primed kidnap bomb using our custom block texture. * Renders the primed kidnap bomb using our custom block texture.
*/ */

View File

@@ -21,7 +21,7 @@ import org.joml.Matrix4f;
*/ */
public class NpcFishingBobberRenderer extends EntityRenderer<NpcFishingBobber> { public class NpcFishingBobberRenderer extends EntityRenderer<NpcFishingBobber> {
private static final ResourceLocation TEXTURE = new ResourceLocation( private static final ResourceLocation TEXTURE = ResourceLocation.parse(
"textures/entity/fishing_hook.png" "textures/entity/fishing_hook.png"
); );
private static final RenderType RENDER_TYPE = RenderType.entityCutout( private static final RenderType RENDER_TYPE = RenderType.entityCutout(

View File

@@ -9,7 +9,6 @@ import net.minecraftforge.api.distmarker.OnlyIn;
/** /**
* Renderer for EntityRopeArrow. * Renderer for EntityRopeArrow.
* Phase 15: Uses vanilla arrow texture for simplicity.
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public class RopeArrowRenderer extends ArrowRenderer<EntityRopeArrow> { public class RopeArrowRenderer extends ArrowRenderer<EntityRopeArrow> {

View File

@@ -8,7 +8,6 @@ import com.tiedup.remake.items.clothes.ClothesProperties;
import com.tiedup.remake.items.clothes.GenericClothes; import com.tiedup.remake.items.clothes.GenericClothes;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.UUID; import java.util.UUID;
import org.jetbrains.annotations.Nullable;
import net.minecraft.client.model.HumanoidModel; import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.model.PlayerModel; import net.minecraft.client.model.PlayerModel;
import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.client.player.AbstractClientPlayer;
@@ -19,6 +18,7 @@ import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack; 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 org.jetbrains.annotations.Nullable;
/** /**
* Helper class for rendering clothes with dynamic textures. * Helper class for rendering clothes with dynamic textures.

View File

@@ -5,9 +5,9 @@ import java.util.EnumSet;
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 org.jetbrains.annotations.Nullable;
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 org.jetbrains.annotations.Nullable;
/** /**
* Client-side cache for storing clothes configuration of remote players. * Client-side cache for storing clothes configuration of remote players.

View File

@@ -15,7 +15,6 @@ import net.minecraftforge.api.distmarker.OnlyIn;
* *
* Used by SlaveManagementScreen to display owned slaves without spatial queries. * Used by SlaveManagementScreen to display owned slaves without spatial queries.
* *
* Phase 17: Terminology Refactoring - Global Collar Registry
*/ */
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public class CollarRegistryClient { public class CollarRegistryClient {

View File

@@ -21,7 +21,8 @@ import org.jetbrains.annotations.Nullable;
@OnlyIn(Dist.CLIENT) @OnlyIn(Dist.CLIENT)
public class MovementStyleClientState { public class MovementStyleClientState {
private static final Map<UUID, MovementStyle> styles = new ConcurrentHashMap<>(); private static final Map<UUID, MovementStyle> styles =
new ConcurrentHashMap<>();
/** /**
* Set the active movement style for a player. * Set the active movement style for a player.

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