Compare commits
109 Commits
3a1082dc38
...
refactor/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc6a62a6e5 | ||
| d391b892aa | |||
|
|
355e2936c9 | ||
| 17815873ac | |||
|
|
b5ae04a1f1 | ||
|
|
fe36a1a47e | ||
| cc8adfe015 | |||
|
|
f37600783a | ||
|
|
168c0675bb | ||
|
|
a3287b7db8 | ||
|
|
e56e6dd551 | ||
|
|
806a1e732d | ||
|
|
3d57d83a5b | ||
|
|
229fc66340 | ||
|
|
b0766fecc6 | ||
|
|
9dfd2d1724 | ||
|
|
3f6e04edb0 | ||
|
|
ca4cbcad12 | ||
|
|
17269f51f8 | ||
|
|
c0c53f9504 | ||
|
|
eb759fefff | ||
|
|
8af58b5dd5 | ||
|
|
d7f8bf6c72 | ||
|
|
6dad447c05 | ||
|
|
7ef85b4e92 | ||
|
|
ad74d320be | ||
| 5788f39d9f | |||
|
|
27c86bc831 | ||
|
|
f4aa5ffdc5 | ||
| ea14fc2cec | |||
|
|
4e136cff96 | ||
| 683eeec11f | |||
|
|
fd60086322 | ||
|
|
9b2c5dec8e | ||
|
|
371a138b71 | ||
|
|
d75b74f9f9 | ||
| bce0598059 | |||
|
|
6d56024c7e | ||
|
|
8823c671d7 | ||
|
|
6d9d6b4b81 | ||
|
|
706172fb9a | ||
|
|
3aaf92b788 | ||
|
|
69f52eacf3 | ||
| a4fc05b503 | |||
|
|
7444853840 | ||
|
|
22d79a452b | ||
| 70f85b58a6 | |||
|
|
c34bac11b0 | ||
|
|
fa5cfb913c | ||
|
|
70965c2dda | ||
|
|
0662739fe0 | ||
| ac72f6aae7 | |||
|
|
d3bdb026f3 | ||
|
|
c1e1f56058 | ||
|
|
f945e9449b | ||
| cc0ce89de5 | |||
|
|
db407ee68f | ||
|
|
d6bb030ad7 | ||
|
|
199bf00aef | ||
| 4a3ff438c2 | |||
|
|
4d128124e6 | ||
|
|
3df979ceee | ||
|
|
a572513640 | ||
|
|
dfa7024e21 | ||
|
|
9302b6ccaf | ||
|
|
099cd0d984 | ||
| fccb99ef9a | |||
|
|
b04497b5a1 | ||
|
|
3515c89f82 | ||
|
|
3d61c9e9e6 | ||
| 52d1044e9a | |||
|
|
530b86a9a7 | ||
|
|
258223bf68 | ||
|
|
679d7033f9 | ||
| 8bfd97ba57 | |||
|
|
df56ebb6bc | ||
|
|
b97bdf367e | ||
|
|
eb7f06bfc8 | ||
|
|
5c4e4c2352 | ||
|
|
eee4825aba | ||
|
|
b359c6be35 | ||
|
|
19cc69985d | ||
|
|
737a4fd59b | ||
|
|
b79225d684 | ||
|
|
751bad418d | ||
|
|
b81d3eed95 | ||
| fa4c332a10 | |||
|
|
7bd840705a | ||
|
|
90bc890b95 | ||
|
|
185ac63a44 | ||
|
|
dcc8493e5e | ||
|
|
bfcc20d242 | ||
|
|
3a81bb6e12 | ||
|
|
bb589d44f8 | ||
|
|
456335e0dd | ||
|
|
bb209bcd8e | ||
|
|
1327e3bfc3 | ||
|
|
dbacef66d5 | ||
|
|
231522c68e | ||
|
|
84f4c3a53f | ||
|
|
caeb4469b1 | ||
|
|
3a1f401ccf | ||
|
|
a781dad597 | ||
|
|
750be66d80 | ||
|
|
1b70041c36 | ||
|
|
b8a0d839f5 | ||
|
|
edfc3c6506 | ||
| 3fe3e16e0a | |||
|
|
e17998933c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,3 +45,4 @@ build_output.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
docs/
|
||||
|
||||
@@ -105,7 +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.
|
||||
|
||||
**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.
|
||||
The 3D models are the **property of their creators**; if their names are listed, please ask them for permission otherwise me.
|
||||
|
||||
## Status
|
||||
|
||||
|
||||
24
build.gradle
24
build.gradle
@@ -104,6 +104,7 @@ minecraft {
|
||||
|
||||
// Mixin config arg
|
||||
args '-mixin.config=tiedup.mixins.json'
|
||||
args '-mixin.config=tiedup-compat.mixins.json'
|
||||
}
|
||||
|
||||
server {
|
||||
@@ -116,6 +117,7 @@ minecraft {
|
||||
|
||||
// Mixin config arg
|
||||
args '-mixin.config=tiedup.mixins.json'
|
||||
args '-mixin.config=tiedup-compat.mixins.json'
|
||||
}
|
||||
|
||||
// Additional client instances for multiplayer testing
|
||||
@@ -155,6 +157,7 @@ sourceSets.main.resources { srcDir 'src/generated/resources' }
|
||||
mixin {
|
||||
add sourceSets.main, 'tiedup.refmap.json'
|
||||
config 'tiedup.mixins.json'
|
||||
config 'tiedup-compat.mixins.json'
|
||||
}
|
||||
|
||||
repositories {
|
||||
@@ -228,11 +231,30 @@ dependencies {
|
||||
// The group id is ignored when searching -- in this case, it is "blank"
|
||||
// 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:
|
||||
// http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.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.
|
||||
// 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.
|
||||
@@ -263,7 +285,7 @@ tasks.named('jar', Jar).configure {
|
||||
'Implementation-Version' : project.jar.archiveVersion,
|
||||
'Implementation-Vendor' : mod_authors,
|
||||
'Implementation-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"),
|
||||
'MixinConfigs' : 'tiedup.mixins.json'
|
||||
'MixinConfigs' : 'tiedup.mixins.json,tiedup-compat.mixins.json'
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
7. [Animations](#animations) — item poses, fallback chain, variants, context animations
|
||||
8. [Animation Templates](#animation-templates)
|
||||
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)
|
||||
12. [Common Mistakes](#common-mistakes)
|
||||
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.
|
||||
|
||||
**Everything else follows this rule:**
|
||||
- Your item **always** controls bones in its declared regions.
|
||||
- Your item **can also** animate free bones (not owned by any other equipped item).
|
||||
- **Standard animations** (`Idle`, `Struggle`, `Walk`): your item controls ONLY bones in its declared regions. Keyframes on other bones are ignored.
|
||||
- **`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.
|
||||
|
||||
| Bone | Who Controls It |
|
||||
|------|----------------|
|
||||
| `body` / `torso` | Context layer by default. Your item if it owns TORSO or WAIST, or if `body` is free. |
|
||||
| `head` | Vanilla head tracking by default. Your item if it owns HEAD, EYES, EARS, or MOUTH. |
|
||||
| Arms (`*UpperArm`, `*LowerArm`) | Vanilla by default. Your item if it owns ARMS or HANDS. |
|
||||
| Legs (`*UpperLeg`, `*LowerLeg`) | Context layer by default. Your item if it owns LEGS or FEET. |
|
||||
| `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. 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. Also available as free bones in `Full` animations. |
|
||||
| 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:
|
||||
- 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
|
||||
|
||||
### Guidelines
|
||||
@@ -183,7 +184,8 @@ Weight paint your mesh to the skeleton bones it should follow.
|
||||
|
||||
### 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`.
|
||||
- **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.
|
||||
@@ -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 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`.
|
||||
- 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_2": "#C0C0C0"
|
||||
},
|
||||
"animation_bones": {
|
||||
"idle": []
|
||||
},
|
||||
"pose_priority": 10,
|
||||
"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 |
|
||||
| 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.
|
||||
|
||||
```
|
||||
Frame 0: Pose all owned bones → done.
|
||||
```
|
||||
**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.
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -329,13 +352,13 @@ Beyond `Idle`, you can provide animations for specific contexts. All are optiona
|
||||
|
||||
| Animation Name | Context | Notes |
|
||||
|----------------|---------|-------|
|
||||
| `Idle` | Standing still | **Required.** Single-frame pose. |
|
||||
| `Struggle` | Player is struggling | Multi-frame loop. 20-40 frames recommended. |
|
||||
| `Idle` | Standing still | **Required.** Single-frame or looped. Frame 0 = base pose. |
|
||||
| `Struggle` | Player is struggling | Multi-frame loop recommended. 20-40 frames. |
|
||||
| `Walk` | Player is walking | Multi-frame loop synced to walk speed. |
|
||||
| `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. |
|
||||
| `KneelIdle` | Kneeling | Single-frame pose. |
|
||||
| `KneelIdle` | Kneeling | Single-frame or looped. |
|
||||
| `KneelStruggle` | Kneeling + struggling | 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:
|
||||
FullSitStruggle → SitStruggle → FullStruggle → Struggle
|
||||
→ FullSit → Sit → FullStruggle → Struggle → FullIdle → Idle
|
||||
FullHeadSitStruggle → FullSitStruggle → SitStruggle
|
||||
→ FullHeadStruggle → FullStruggle → Struggle
|
||||
→ FullHeadSit → FullSit → Sit
|
||||
→ FullHeadIdle → FullIdle → Idle
|
||||
|
||||
KNEEL + STRUGGLE:
|
||||
FullKneelStruggle → KneelStruggle → FullStruggle → Struggle
|
||||
→ FullKneel → Kneel → FullStruggle → Struggle → FullIdle → Idle
|
||||
FullHeadKneelStruggle → FullKneelStruggle → KneelStruggle
|
||||
→ FullHeadStruggle → FullStruggle → Struggle
|
||||
→ FullHeadKneel → FullKneel → Kneel
|
||||
→ FullHeadIdle → FullIdle → Idle
|
||||
|
||||
SIT + IDLE: FullSitIdle → SitIdle → FullSit → Sit → FullIdle → Idle
|
||||
KNEEL + IDLE: FullKneelIdle → KneelIdle → FullKneel → Kneel → FullIdle → Idle
|
||||
SNEAK: FullSneak → Sneak → FullIdle → Idle
|
||||
WALK: FullWalk → Walk → FullIdle → Idle
|
||||
STAND STRUGGLE: FullStruggle → Struggle → FullIdle → Idle
|
||||
STAND IDLE: FullIdle → Idle
|
||||
SIT + IDLE: FullHeadSitIdle → FullSitIdle → SitIdle → ... → FullHeadIdle → FullIdle → Idle
|
||||
KNEEL + IDLE: FullHeadKneelIdle → FullKneelIdle → KneelIdle → ... → FullHeadIdle → FullIdle → Idle
|
||||
SNEAK: FullHeadSneak → FullSneak → Sneak → FullHeadIdle → FullIdle → Idle
|
||||
WALK: FullHeadWalk → FullWalk → Walk → FullHeadIdle → FullIdle → Idle
|
||||
STAND STRUGGLE: FullHeadStruggle → FullStruggle → Struggle → FullHeadIdle → 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.
|
||||
|
||||
**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`.
|
||||
|
||||
| Standard Name | Full-Body Name | What Changes |
|
||||
|--------------|---------------|-------------|
|
||||
| `Idle` | `FullIdle` | Owned bones + body lean, leg stance |
|
||||
| `Walk` | `FullWalk` | Owned bones + leg waddle, body sway |
|
||||
| `Struggle` | `FullStruggle` | Owned bones + full-body thrashing |
|
||||
| `Sneak` | `FullSneak` | Owned bones + custom sneak posture |
|
||||
| Standard Name | Full-Body Name | Full + Head | What Changes |
|
||||
|--------------|---------------|-------------|-------------|
|
||||
| `Idle` | `FullIdle` | `FullHeadIdle` | Owned bones + body lean, leg stance (+ head if Head variant) |
|
||||
| `Walk` | `FullWalk` | `FullHeadWalk` | Owned bones + leg waddle, body sway (+ head if Head variant) |
|
||||
| `Struggle` | `FullStruggle` | `FullHeadStruggle` | Owned bones + full-body thrashing (+ head if Head variant) |
|
||||
| `Sneak` | `FullSneak` | `FullHeadSneak` | Owned bones + custom sneak posture (+ head if Head variant) |
|
||||
|
||||
**In Blender:**
|
||||
```
|
||||
PlayerArmature|Idle ← region-only: just arms for handcuffs
|
||||
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:**
|
||||
@@ -423,7 +453,9 @@ PlayerArmature|FullStruggle ← full-body: everything moves
|
||||
| `Sneak` | Default sneak lean is fine | Your item changes how sneaking looks |
|
||||
|
||||
**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.
|
||||
- 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.
|
||||
@@ -609,6 +641,8 @@ In your JSON definition, separate the mesh from the animations:
|
||||
|
||||
### 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)**
|
||||
|
||||
| Setting | Value | Why |
|
||||
@@ -625,12 +659,14 @@ In your JSON definition, separate the mesh from the animations:
|
||||
|
||||
### Pre-Export Checklist
|
||||
|
||||
- [ ] Scene FPS set to **20** (Blender default is 24 — change it)
|
||||
- [ ] Armature is named `PlayerArmature`
|
||||
- [ ] All 11 bones have correct names (case-sensitive)
|
||||
- [ ] Actions are named `PlayerArmature|Idle`, `PlayerArmature|Struggle`, etc.
|
||||
- [ ] Mesh is weight-painted to skeleton bones only
|
||||
- [ ] Weights are normalized
|
||||
- [ ] No orphan bones (extra bones not in the standard 11 are ignored but add file size)
|
||||
- [ ] Weights are normalized (the mod re-normalizes at load as a safety net, but authoring-normalized weights give the most predictable result)
|
||||
- [ ] 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)
|
||||
- [ ] 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",
|
||||
"model": "mycreator:models/gltf/rope_gag.glb",
|
||||
"regions": ["MOUTH"],
|
||||
"animation_bones": {
|
||||
"idle": []
|
||||
},
|
||||
"pose_priority": 10,
|
||||
"escape_difficulty": 2,
|
||||
"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 |
|
||||
| `model` | string | Yes | ResourceLocation of the GLB mesh |
|
||||
| `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`) |
|
||||
| `regions` | string[] | Yes | Body regions this item occupies |
|
||||
| `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` |
|
||||
| `tint_channels` | object | No | Default colors per tintable zone: `{"tintable_1": "#FF0000"}` |
|
||||
| `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_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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.0–1.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
|
||||
|
||||
@@ -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
|
||||
|
||||
### Skeleton Issues
|
||||
|
||||
| Mistake | Symptom | Fix |
|
||||
|---------|---------|-----|
|
||||
| Bone name typo (`RightUpperArm` instead of `rightUpperArm`) | Mesh doesn't follow that bone | Names are **camelCase**, not PascalCase. Check exact spelling. |
|
||||
| Extra bones in the armature | No visible issue (ignored), larger file | Delete non-standard bones before export |
|
||||
| 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. |
|
||||
| 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` |
|
||||
| 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 |
|
||||
|---------|---------|-----|
|
||||
| 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` |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -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 |
|
||||
| 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
|
||||
|
||||
@@ -990,9 +1138,6 @@ A collar sits on the neck. It doesn't change the player's pose.
|
||||
"display_name": "Leather Collar",
|
||||
"model": "mycreator:models/gltf/leather_collar.glb",
|
||||
"regions": ["NECK"],
|
||||
"animation_bones": {
|
||||
"idle": []
|
||||
},
|
||||
"pose_priority": 5,
|
||||
"escape_difficulty": 3,
|
||||
"lockable": true
|
||||
@@ -1158,21 +1303,29 @@ Furniture_Armature|Shake ← whole frame vibrates
|
||||
#### Player Seat Animations
|
||||
|
||||
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? |
|
||||
|---------------|------------|-----------|
|
||||
| `Idle` | Default seated pose | **Yes** (no fallback) |
|
||||
| `Struggle` | Player struggling to escape | Optional (stays in Idle) |
|
||||
| `Enter` | Mount transition (one-shot, 1 second) | Optional (snaps to Idle if absent) |
|
||||
| `Exit` | Dismount transition (one-shot, 1 second) | Optional (snaps to vanilla if absent) |
|
||||
| `Idle` | Default seated pose (STATE_IDLE) | **Yes** — canonical fallback |
|
||||
| `Occupied` | At least one passenger is seated (STATE_OCCUPIED) | Optional (falls back to Idle) |
|
||||
| `Struggle` | Player struggling to escape (STATE_STRUGGLE) | Optional (falls back to Occupied → Idle) |
|
||||
| `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:
|
||||
```
|
||||
Player_main|Idle → resolved as "main:Idle" ← arms spread, legs apart
|
||||
Player_main|Struggle → resolved as "main:Struggle" ← pulling against restraints
|
||||
Player_left|Idle → resolved as "left:Idle" ← head and arms through pillory
|
||||
Player_right|Idle → resolved as "right:Idle" ← same pose, other side
|
||||
Player_main|Idle → seat "main" clip "Idle" ← arms spread, legs apart
|
||||
Player_main|Struggle → seat "main" clip "Struggle" ← pulling against restraints
|
||||
Player_main|Enter → seat "main" clip "Enter" ← one-shot mount transition
|
||||
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.).
|
||||
@@ -1436,7 +1589,7 @@ Two players can be locked side by side. The mod picks the seat nearest to where
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1465,11 +1618,16 @@ NEVER DO:
|
||||
|
||||
GOOD TO KNOW:
|
||||
→ 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.
|
||||
→ 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.
|
||||
→ The mod handles sitting, sneaking, walking — you don't have to.
|
||||
→ 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).
|
||||
→ Textures bake into the GLB. No separate file needed.
|
||||
```
|
||||
|
||||
@@ -244,7 +244,7 @@ public class BlockKidnapBomb
|
||||
beTag.contains("collar")
|
||||
) {
|
||||
tooltip.add(
|
||||
Component.literal("Loaded:").withStyle(
|
||||
Component.translatable("block.tiedup.kidnap_bomb.loaded").withStyle(
|
||||
ChatFormatting.YELLOW
|
||||
)
|
||||
);
|
||||
@@ -282,12 +282,12 @@ public class BlockKidnapBomb
|
||||
);
|
||||
} else {
|
||||
tooltip.add(
|
||||
Component.literal("Empty").withStyle(ChatFormatting.GREEN)
|
||||
Component.translatable("block.tiedup.kidnap_bomb.empty").withStyle(ChatFormatting.GREEN)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tooltip.add(
|
||||
Component.literal("Empty").withStyle(ChatFormatting.GREEN)
|
||||
Component.translatable("block.tiedup.kidnap_bomb.empty").withStyle(ChatFormatting.GREEN)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +328,7 @@ public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
|
||||
// Check if armed
|
||||
if (beTag.contains("bind")) {
|
||||
tooltip.add(
|
||||
Component.literal("Armed").withStyle(
|
||||
Component.translatable("block.tiedup.trap.armed").withStyle(
|
||||
ChatFormatting.DARK_RED
|
||||
)
|
||||
);
|
||||
@@ -366,14 +366,14 @@ public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
|
||||
);
|
||||
} else {
|
||||
tooltip.add(
|
||||
Component.literal("Disarmed").withStyle(
|
||||
Component.translatable("block.tiedup.trap.disarmed").withStyle(
|
||||
ChatFormatting.GREEN
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tooltip.add(
|
||||
Component.literal("Disarmed").withStyle(ChatFormatting.GREEN)
|
||||
Component.translatable("block.tiedup.trap.disarmed").withStyle(ChatFormatting.GREEN)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ public class BlockTrappedChest extends ChestBlock implements ICanBeLoaded {
|
||||
beTag.contains("collar")
|
||||
) {
|
||||
tooltip.add(
|
||||
Component.literal("Armed").withStyle(
|
||||
Component.translatable("block.tiedup.trap.armed").withStyle(
|
||||
ChatFormatting.DARK_RED
|
||||
)
|
||||
);
|
||||
@@ -231,14 +231,14 @@ public class BlockTrappedChest extends ChestBlock implements ICanBeLoaded {
|
||||
);
|
||||
} else {
|
||||
tooltip.add(
|
||||
Component.literal("Disarmed").withStyle(
|
||||
Component.translatable("block.tiedup.trap.disarmed").withStyle(
|
||||
ChatFormatting.GREEN
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tooltip.add(
|
||||
Component.literal("Disarmed").withStyle(ChatFormatting.GREEN)
|
||||
Component.translatable("block.tiedup.trap.disarmed").withStyle(ChatFormatting.GREEN)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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.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 net.minecraft.core.BlockPos;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
@@ -191,55 +194,43 @@ public abstract class BondageItemBlockEntity
|
||||
|
||||
@Override
|
||||
public void readBondageData(CompoundTag tag) {
|
||||
// Read bind with type validation
|
||||
// Read bind with type validation (V2 ARMS-region item)
|
||||
if (tag.contains("bind")) {
|
||||
ItemStack bindStack = ItemStack.of(tag.getCompound("bind"));
|
||||
if (
|
||||
!bindStack.isEmpty() && bindStack.getItem() instanceof ItemBind
|
||||
) {
|
||||
if (!bindStack.isEmpty() && BindModeHelper.isBindItem(bindStack)) {
|
||||
this.bind = bindStack;
|
||||
}
|
||||
}
|
||||
|
||||
// Read gag with type validation
|
||||
// Read gag with type validation (V2 GAGGING component)
|
||||
if (tag.contains("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;
|
||||
}
|
||||
}
|
||||
|
||||
// Read blindfold with type validation
|
||||
// Read blindfold with type validation (V2 EYES-region item)
|
||||
if (tag.contains("blindfold")) {
|
||||
ItemStack blindfoldStack = ItemStack.of(
|
||||
tag.getCompound("blindfold")
|
||||
);
|
||||
if (
|
||||
!blindfoldStack.isEmpty() &&
|
||||
blindfoldStack.getItem() instanceof ItemBlindfold
|
||||
) {
|
||||
ItemStack blindfoldStack = ItemStack.of(tag.getCompound("blindfold"));
|
||||
if (!blindfoldStack.isEmpty() && isDataDrivenForRegion(blindfoldStack, BodyRegionV2.EYES)) {
|
||||
this.blindfold = blindfoldStack;
|
||||
}
|
||||
}
|
||||
|
||||
// Read earplugs with type validation
|
||||
// Read earplugs with type validation (V2 EARS-region item)
|
||||
if (tag.contains("earplugs")) {
|
||||
ItemStack earplugsStack = ItemStack.of(tag.getCompound("earplugs"));
|
||||
if (
|
||||
!earplugsStack.isEmpty() &&
|
||||
earplugsStack.getItem() instanceof ItemEarplugs
|
||||
) {
|
||||
if (!earplugsStack.isEmpty() && isDataDrivenForRegion(earplugsStack, BodyRegionV2.EARS)) {
|
||||
this.earplugs = earplugsStack;
|
||||
}
|
||||
}
|
||||
|
||||
// Read collar with type validation
|
||||
// Read collar with type validation (V2 collar)
|
||||
if (tag.contains("collar")) {
|
||||
ItemStack collarStack = ItemStack.of(tag.getCompound("collar"));
|
||||
if (
|
||||
!collarStack.isEmpty() &&
|
||||
collarStack.getItem() instanceof ItemCollar
|
||||
) {
|
||||
if (!collarStack.isEmpty() && CollarHelper.isCollar(collarStack)) {
|
||||
this.collar = collarStack;
|
||||
}
|
||||
}
|
||||
@@ -279,6 +270,14 @@ public abstract class BondageItemBlockEntity
|
||||
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
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
package com.tiedup.remake.blocks.entity;
|
||||
|
||||
import com.tiedup.remake.items.base.*;
|
||||
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 net.minecraft.core.BlockPos;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
@@ -45,7 +52,7 @@ public class TrappedChestBlockEntity
|
||||
|
||||
@Override
|
||||
public void setBind(ItemStack stack) {
|
||||
if (stack.isEmpty() || stack.getItem() instanceof ItemBind) {
|
||||
if (stack.isEmpty() || BindModeHelper.isBindItem(stack)) {
|
||||
this.bind = stack;
|
||||
setChangedAndSync();
|
||||
}
|
||||
@@ -58,7 +65,8 @@ public class TrappedChestBlockEntity
|
||||
|
||||
@Override
|
||||
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;
|
||||
setChangedAndSync();
|
||||
}
|
||||
@@ -71,7 +79,7 @@ public class TrappedChestBlockEntity
|
||||
|
||||
@Override
|
||||
public void setBlindfold(ItemStack stack) {
|
||||
if (stack.isEmpty() || stack.getItem() instanceof ItemBlindfold) {
|
||||
if (stack.isEmpty() || isDataDrivenForRegion(stack, BodyRegionV2.EYES)) {
|
||||
this.blindfold = stack;
|
||||
setChangedAndSync();
|
||||
}
|
||||
@@ -84,7 +92,7 @@ public class TrappedChestBlockEntity
|
||||
|
||||
@Override
|
||||
public void setEarplugs(ItemStack stack) {
|
||||
if (stack.isEmpty() || stack.getItem() instanceof ItemEarplugs) {
|
||||
if (stack.isEmpty() || isDataDrivenForRegion(stack, BodyRegionV2.EARS)) {
|
||||
this.earplugs = stack;
|
||||
setChangedAndSync();
|
||||
}
|
||||
@@ -97,7 +105,7 @@ public class TrappedChestBlockEntity
|
||||
|
||||
@Override
|
||||
public void setCollar(ItemStack stack) {
|
||||
if (stack.isEmpty() || stack.getItem() instanceof ItemCollar) {
|
||||
if (stack.isEmpty() || CollarHelper.isCollar(stack)) {
|
||||
this.collar = stack;
|
||||
setChangedAndSync();
|
||||
}
|
||||
@@ -183,6 +191,14 @@ public class TrappedChestBlockEntity
|
||||
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
|
||||
|
||||
/**
|
||||
|
||||
@@ -382,9 +382,8 @@ public class BountyManager extends SavedData {
|
||||
server
|
||||
.getPlayerList()
|
||||
.broadcastSystemMessage(
|
||||
Component.literal("[Bounty] " + message).withStyle(
|
||||
ChatFormatting.GOLD
|
||||
),
|
||||
Component.translatable("msg.tiedup.bounty.broadcast", message)
|
||||
.withStyle(ChatFormatting.GOLD),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package com.tiedup.remake.cells;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.base.ILockable;
|
||||
import com.tiedup.remake.items.base.ItemCollar;
|
||||
import com.tiedup.remake.prison.PrisonerManager;
|
||||
import com.tiedup.remake.v2.bondage.CollarHelper;
|
||||
import com.tiedup.remake.state.IRestrainable;
|
||||
import com.tiedup.remake.util.KidnappedHelper;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
@@ -94,7 +94,7 @@ public final class CampLifecycleManager {
|
||||
);
|
||||
} else {
|
||||
// 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,
|
||||
prisonerId,
|
||||
"camp death"
|
||||
@@ -231,7 +231,7 @@ public final class CampLifecycleManager {
|
||||
}
|
||||
|
||||
// Suppress collar removal alerts - this is a legitimate release (camp death)
|
||||
ItemCollar.runWithSuppressedAlert(() -> {
|
||||
CollarHelper.runWithSuppressedAlert(() -> {
|
||||
// Unlock collar if owned by the dead camp/trader
|
||||
unlockCollarIfOwnedBy(prisoner, state, traderUUID);
|
||||
|
||||
@@ -250,10 +250,8 @@ public final class CampLifecycleManager {
|
||||
|
||||
// Notify prisoner
|
||||
prisoner.sendSystemMessage(
|
||||
Component.literal("Your captor has died. You are FREE!").withStyle(
|
||||
ChatFormatting.GREEN,
|
||||
ChatFormatting.BOLD
|
||||
)
|
||||
Component.translatable("msg.tiedup.camp.captor_died")
|
||||
.withStyle(ChatFormatting.GREEN, ChatFormatting.BOLD)
|
||||
);
|
||||
|
||||
// Grant grace period (5 minutes = 6000 ticks)
|
||||
@@ -261,9 +259,8 @@ public final class CampLifecycleManager {
|
||||
manager.release(prisoner.getUUID(), level.getGameTime(), 6000);
|
||||
|
||||
prisoner.sendSystemMessage(
|
||||
Component.literal(
|
||||
"You have 5 minutes of protection from kidnappers."
|
||||
).withStyle(ChatFormatting.AQUA)
|
||||
Component.translatable("msg.tiedup.camp.grace_period")
|
||||
.withStyle(ChatFormatting.AQUA)
|
||||
);
|
||||
|
||||
TiedUpMod.LOGGER.info(
|
||||
@@ -285,8 +282,8 @@ public final class CampLifecycleManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
List<UUID> owners = collarItem.getOwners(collar);
|
||||
if (CollarHelper.isCollar(collar)) {
|
||||
List<UUID> owners = CollarHelper.getOwners(collar);
|
||||
|
||||
// If the dead trader/camp is an owner, unlock the collar
|
||||
if (owners.contains(ownerUUID)) {
|
||||
|
||||
@@ -640,7 +640,7 @@ public class CellRegistryV2 extends SavedData {
|
||||
currentState ==
|
||||
com.tiedup.remake.prison.PrisonerState.IMPRISONED
|
||||
) {
|
||||
com.tiedup.remake.prison.service.PrisonerService.get().escape(
|
||||
com.tiedup.remake.prison.service.EscapeMonitorService.get().escape(
|
||||
level,
|
||||
id,
|
||||
"offline_cleanup"
|
||||
@@ -670,13 +670,7 @@ public class CellRegistryV2 extends SavedData {
|
||||
if (server == null || ownerId == null) return;
|
||||
ServerPlayer owner = server.getPlayerList().getPlayer(ownerId);
|
||||
if (owner != null) {
|
||||
String template = SystemMessageManager.getTemplate(category);
|
||||
String formattedMessage = String.format(template, prisonerName);
|
||||
SystemMessageManager.sendToPlayer(
|
||||
owner,
|
||||
category,
|
||||
formattedMessage
|
||||
);
|
||||
SystemMessageManager.sendTranslatable(owner, category, prisonerName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package com.tiedup.remake.client;
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.GenericBind;
|
||||
import com.tiedup.remake.items.base.BindVariant;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.v2.bondage.PoseTypeHelper;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
@@ -141,12 +141,7 @@ public class FirstPersonMittensRenderer {
|
||||
net.minecraft.world.item.ItemStack bindStack =
|
||||
V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS);
|
||||
if (bindStack.isEmpty()) return false;
|
||||
if (bindStack.getItem() instanceof GenericBind bind) {
|
||||
BindVariant variant = bind.getVariant();
|
||||
return (
|
||||
variant == BindVariant.WRAP || variant == BindVariant.LATEX_SACK
|
||||
);
|
||||
}
|
||||
return false;
|
||||
PoseType poseType = PoseTypeHelper.getPoseType(bindStack);
|
||||
return poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ package com.tiedup.remake.client;
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import com.tiedup.remake.client.gui.screens.AdjustmentScreen;
|
||||
import com.tiedup.remake.client.gui.screens.UnifiedBondageScreen;
|
||||
import com.tiedup.remake.core.ModConfig;
|
||||
import com.tiedup.remake.core.SettingsAccessor;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
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.action.PacketForceSeatModifier;
|
||||
import com.tiedup.remake.network.action.PacketStruggle;
|
||||
@@ -315,7 +315,7 @@ public class ModKeybindings {
|
||||
// Check if player has bind equipped
|
||||
if (state.isTiedUp()) {
|
||||
// Has bind - struggle against it
|
||||
if (ModConfig.SERVER.struggleMiniGameEnabled.get()) {
|
||||
if (SettingsAccessor.isStruggleMiniGameEnabled()) {
|
||||
// New: Start struggle mini-game
|
||||
ModNetwork.sendToServer(
|
||||
new PacketV2StruggleStart(BodyRegionV2.ARMS)
|
||||
@@ -428,11 +428,8 @@ public class ModKeybindings {
|
||||
target,
|
||||
BodyRegionV2.NECK
|
||||
);
|
||||
if (
|
||||
!collarStack.isEmpty() &&
|
||||
collarStack.getItem() instanceof ItemCollar collar
|
||||
) {
|
||||
return collar.isOwner(collarStack, player);
|
||||
if (!collarStack.isEmpty() && CollarHelper.isCollar(collarStack)) {
|
||||
return CollarHelper.isOwner(collarStack, player);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,31 +7,25 @@ import net.minecraftforge.api.distmarker.Dist;
|
||||
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
|
||||
* AnimationTickHandler. Provides a single clearAll() entry point for
|
||||
* world unload cleanup.
|
||||
* <p>Holds {@link #lastTiedState} (the per-player edge-detector used by
|
||||
* {@link com.tiedup.remake.client.animation.tick.AnimationTickHandler} to
|
||||
* spot the "just untied" transition) and chains cleanup via
|
||||
* {@link #clearAll()} across every animation-related cache on world unload.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
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<>();
|
||||
|
||||
/** Track last animation ID per player to avoid redundant updates */
|
||||
static final Map<UUID, String> lastAnimId = new ConcurrentHashMap<>();
|
||||
|
||||
private AnimationStateRegistry() {}
|
||||
|
||||
public static Map<UUID, Boolean> getLastTiedState() {
|
||||
return lastTiedState;
|
||||
}
|
||||
|
||||
public static Map<UUID, String> getLastAnimId() {
|
||||
return lastAnimId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all animation-related state in one call.
|
||||
* Called on world unload to prevent memory leaks and stale data.
|
||||
@@ -39,7 +33,6 @@ public final class AnimationStateRegistry {
|
||||
public static void clearAll() {
|
||||
// Animation state tracking
|
||||
lastTiedState.clear();
|
||||
lastAnimId.clear();
|
||||
|
||||
// Animation managers
|
||||
BondageAnimationManager.clearAll();
|
||||
@@ -50,6 +43,9 @@ public final class AnimationStateRegistry {
|
||||
|
||||
// Render state
|
||||
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
|
||||
com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler.clearAll();
|
||||
|
||||
@@ -353,8 +353,18 @@ public class BondageAnimationManager {
|
||||
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.
|
||||
*
|
||||
* <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")
|
||||
private static ModifierLayer<IAnimation> getPlayerLayer(
|
||||
@@ -367,11 +377,13 @@ public class BondageAnimationManager {
|
||||
FACTORY_ID
|
||||
);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error(
|
||||
"Failed to get animation layer for player: {}",
|
||||
if (layerFailureLogged.add(player.getUUID())) {
|
||||
LOGGER.debug(
|
||||
"Animation layer not yet available for player {} (will retry): {}",
|
||||
player.getName().getString(),
|
||||
e
|
||||
e.toString()
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -521,7 +533,7 @@ public class BondageAnimationManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
|
||||
ModifierLayer<IAnimation> layer = getOrCreateFurnitureLayer(player);
|
||||
if (layer != null) {
|
||||
layer.setAnimation(new KeyframeAnimationPlayer(animation));
|
||||
// Reset grace ticks since we just started/refreshed the animation
|
||||
@@ -577,9 +589,11 @@ 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,
|
||||
* 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")
|
||||
@javax.annotation.Nullable
|
||||
@@ -606,6 +620,61 @@ public class BondageAnimationManager {
|
||||
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.
|
||||
*
|
||||
@@ -731,6 +800,7 @@ public class BondageAnimationManager {
|
||||
}
|
||||
}
|
||||
furnitureGraceTicks.remove(entityId);
|
||||
layerFailureLogged.remove(entityId);
|
||||
LOGGER.debug("Cleaned up animation layers for entity: {}", entityId);
|
||||
}
|
||||
|
||||
@@ -744,6 +814,7 @@ public class BondageAnimationManager {
|
||||
cache.clear();
|
||||
}
|
||||
furnitureGraceTicks.clear();
|
||||
layerFailureLogged.clear();
|
||||
LOGGER.info("Cleared all NPC animation layers");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,34 +72,43 @@ public final class GlbAnimationResolver {
|
||||
String prefix = context.getGlbContextPrefix(); // "Sit", "Kneel", "Sneak", "Walk", ""
|
||||
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;
|
||||
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;
|
||||
picked = pickWithVariants(data, exact);
|
||||
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()) {
|
||||
String picked = pickWithVariants(data, "FullStruggle");
|
||||
String picked = pickWithVariants(data, "FullHeadStruggle");
|
||||
if (picked != null) return picked;
|
||||
picked = pickWithVariants(data, "FullStruggle");
|
||||
if (picked != null) return picked;
|
||||
picked = pickWithVariants(data, "Struggle");
|
||||
if (picked != null) return picked;
|
||||
}
|
||||
|
||||
// 3. Context-only: "FullSit" then "Sit" (with variants)
|
||||
// 3. Context-only: "FullHead{prefix}" then "Full{prefix}" then "{prefix}" (with variants)
|
||||
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;
|
||||
picked = pickWithVariants(data, prefix);
|
||||
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;
|
||||
picked = pickWithVariants(data, variant);
|
||||
if (picked != null) return picked;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
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.v2.BodyRegionV2;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||
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.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
@@ -35,11 +36,13 @@ import net.minecraftforge.fml.common.Mod;
|
||||
public class DogPoseRenderHandler {
|
||||
|
||||
/**
|
||||
* DOG pose state tracking per player.
|
||||
* Stores: [0: smoothedTarget, 1: currentRot, 2: appliedDelta, 3: isMoving (0/1)]
|
||||
* DOG pose state per player, keyed by UUID (stable across dimension
|
||||
* 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 =
|
||||
new Int2ObjectOpenHashMap<>();
|
||||
private static final Map<UUID, float[]> dogPoseState =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
// Array indices for dogPoseState
|
||||
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.
|
||||
* Used by MixinPlayerModel to compensate head rotation.
|
||||
*/
|
||||
public static float getAppliedRotationDelta(int playerId) {
|
||||
float[] state = dogPoseState.get(playerId);
|
||||
public static float getAppliedRotationDelta(UUID playerUuid) {
|
||||
float[] state = dogPoseState.get(playerUuid);
|
||||
return state != null ? state[IDX_DELTA] : 0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a player is currently moving in DOG pose.
|
||||
*/
|
||||
public static boolean isDogPoseMoving(int playerId) {
|
||||
float[] state = dogPoseState.get(playerId);
|
||||
public static boolean isDogPoseMoving(UUID playerUuid) {
|
||||
float[] state = dogPoseState.get(playerUuid);
|
||||
return state != null && state[IDX_MOVING] > 0.5f;
|
||||
}
|
||||
|
||||
@@ -72,6 +75,13 @@ public class DogPoseRenderHandler {
|
||||
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.
|
||||
* HIGH priority ensures this runs before arm/item hiding handlers.
|
||||
@@ -93,14 +103,11 @@ public class DogPoseRenderHandler {
|
||||
}
|
||||
|
||||
ItemStack bindForPose = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (
|
||||
bindForPose.isEmpty() ||
|
||||
!(bindForPose.getItem() instanceof ItemBind itemBind)
|
||||
) {
|
||||
if (bindForPose.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PoseType bindPoseType = itemBind.getPoseType();
|
||||
PoseType bindPoseType = PoseTypeHelper.getPoseType(bindForPose);
|
||||
// Check for humanChairMode NBT override
|
||||
bindPoseType = HumanChairHelper.resolveEffectivePose(
|
||||
bindPoseType,
|
||||
@@ -118,15 +125,15 @@ public class DogPoseRenderHandler {
|
||||
.getPoseStack()
|
||||
.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();
|
||||
boolean isMoving = movement.horizontalDistanceSqr() > 0.0001;
|
||||
|
||||
// Get or create state - initialize to current body rotation
|
||||
float[] s = dogPoseState.get(playerId);
|
||||
float[] s = dogPoseState.get(playerUuid);
|
||||
if (s == null) {
|
||||
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
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
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.player.LocalPlayer;
|
||||
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.
|
||||
*
|
||||
* Behavior:
|
||||
* - Tied up: Hide hands completely (hands are behind back)
|
||||
* - Mittens: Hide hands + items (Forge limitation - can't separate them)
|
||||
* <p>Behavior:</p>
|
||||
* <ul>
|
||||
* <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)
|
||||
@Mod.EventBusSubscriber(
|
||||
@@ -38,13 +51,22 @@ public class FirstPersonHandHideHandler {
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
if (state != null && (state.isTiedUp() || state.hasMittens())) {
|
||||
// Legacy V1 state or item.
|
||||
event.setCanceled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tied or Mittens: hide hands completely
|
||||
// (Forge limitation: RenderHandEvent controls hand + item together)
|
||||
if (state.isTiedUp() || state.hasMittens()) {
|
||||
// V2: any item occupying or blocking HANDS/ARMS hides both arms in first-person.
|
||||
// isRegionBlocked includes the blocked-regions whitelist from equipped items,
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.EventPriority;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
@@ -37,8 +38,17 @@ public class HeldItemHideHandler {
|
||||
private static final Int2ObjectMap<ItemStack[]> storedItems =
|
||||
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) {
|
||||
if (event.isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
@@ -77,7 +87,7 @@ public class HeldItemHideHandler {
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
@SubscribeEvent(receiveCanceled = true)
|
||||
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
@@ -90,4 +100,14 @@ public class HeldItemHideHandler {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.client.state.PetBedClientState;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
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.v2.BodyRegionV2;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
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).
|
||||
*
|
||||
* <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.
|
||||
*/
|
||||
@@ -34,10 +40,31 @@ import net.minecraftforge.fml.common.Mod;
|
||||
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) {
|
||||
if (event.isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
@@ -47,7 +74,7 @@ public class PetBedRenderHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
java.util.UUID petBedUuid = player.getUUID();
|
||||
UUID petBedUuid = player.getUUID();
|
||||
byte petBedMode = PetBedClientState.get(petBedUuid);
|
||||
|
||||
if (petBedMode == 1 || petBedMode == 2) {
|
||||
@@ -62,6 +89,7 @@ public class PetBedRenderHandler {
|
||||
if (petBedMode == 2) {
|
||||
// SLEEP: force STANDING pose to prevent vanilla sleeping rotation
|
||||
player.setForcedPose(net.minecraft.world.entity.Pose.STANDING);
|
||||
FORCED_POSE_PLAYERS.add(petBedUuid);
|
||||
|
||||
// Compensate for vanilla sleeping Y offset
|
||||
player
|
||||
@@ -83,11 +111,9 @@ public class PetBedRenderHandler {
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) return false;
|
||||
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (
|
||||
bind.isEmpty() || !(bind.getItem() instanceof ItemBind itemBind)
|
||||
) return false;
|
||||
if (bind.isEmpty()) return false;
|
||||
PoseType pose = HumanChairHelper.resolveEffectivePose(
|
||||
itemBind.getPoseType(),
|
||||
PoseTypeHelper.getPoseType(bind),
|
||||
bind
|
||||
);
|
||||
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.
|
||||
*
|
||||
* <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) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
byte petBedMode = PetBedClientState.get(player.getUUID());
|
||||
if (petBedMode == 2) {
|
||||
UUID playerUuid = player.getUUID();
|
||||
if (FORCED_POSE_PLAYERS.remove(playerUuid)) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,15 @@ package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.client.renderer.layers.ClothesRenderHelper;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
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.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||
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.player.AbstractClientPlayer;
|
||||
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.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.EventPriority;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
@@ -40,18 +43,36 @@ public class PlayerArmHideEventHandler {
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
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:
|
||||
* - Hide arms for wrap/latex_sack poses
|
||||
* - 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) {
|
||||
if (event.isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer clientPlayer)) {
|
||||
return;
|
||||
@@ -71,10 +92,8 @@ public class PlayerArmHideEventHandler {
|
||||
// === HIDE ARMS (wrap/latex_sack poses) ===
|
||||
if (state.hasArmsBound()) {
|
||||
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (
|
||||
!bind.isEmpty() && bind.getItem() instanceof ItemBind itemBind
|
||||
) {
|
||||
PoseType poseType = itemBind.getPoseType();
|
||||
if (!bind.isEmpty()) {
|
||||
PoseType poseType = PoseTypeHelper.getPoseType(bind);
|
||||
|
||||
// Only hide arms for wrap/sack poses (arms are covered by the item)
|
||||
if (
|
||||
@@ -84,6 +103,7 @@ public class PlayerArmHideEventHandler {
|
||||
model.rightArm.visible = false;
|
||||
model.leftSleeve.visible = false;
|
||||
model.rightSleeve.visible = false;
|
||||
hiddenArmEntities.add(player.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
@@ -120,11 +143,13 @@ public class PlayerArmHideEventHandler {
|
||||
|
||||
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.rightArm.visible = true;
|
||||
model.leftSleeve.visible = true;
|
||||
model.rightSleeve.visible = true;
|
||||
}
|
||||
|
||||
// === RESTORE WEARER LAYERS ===
|
||||
boolean[] savedLayers = storedLayers.remove(player.getId());
|
||||
@@ -132,4 +157,16 @@ public class PlayerArmHideEventHandler {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Centralizes magic numbers used across render handlers.
|
||||
*
|
||||
* <p>DOG pose rotation smoothing, head clamp limits, and vertical offsets
|
||||
* that were previously scattered as unnamed literals.
|
||||
* Magic numbers shared across render handlers: DOG pose rotation
|
||||
* smoothing, head clamp limits, vertical offsets.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class RenderConstants {
|
||||
|
||||
@@ -7,16 +7,13 @@ import com.tiedup.remake.client.animation.PendingAnimationManager;
|
||||
import com.tiedup.remake.client.animation.context.AnimationContext;
|
||||
import com.tiedup.remake.client.animation.context.AnimationContextResolver;
|
||||
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
|
||||
import com.tiedup.remake.client.animation.util.AnimationIdBuilder;
|
||||
import com.tiedup.remake.client.events.CellHighlightHandler;
|
||||
import com.tiedup.remake.client.events.LeashProxyClientHandler;
|
||||
import com.tiedup.remake.client.gltf.GltfAnimationApplier;
|
||||
import com.tiedup.remake.client.state.ClothesClientCache;
|
||||
import com.tiedup.remake.client.state.MovementStyleClientState;
|
||||
import com.tiedup.remake.client.state.PetBedClientState;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.state.HumanChairHelper;
|
||||
import com.tiedup.remake.util.HumanChairHelper;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
@@ -24,6 +21,7 @@ import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
@@ -61,6 +59,29 @@ public class AnimationTickHandler {
|
||||
/** Tick counter for periodic cleanup tasks */
|
||||
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.
|
||||
* 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
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,30 +264,15 @@ public class AnimationTickHandler {
|
||||
context,
|
||||
allOwnedParts
|
||||
);
|
||||
// Clear V1 tracking so transition back works
|
||||
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||
} else {
|
||||
// V1 fallback
|
||||
if (GltfAnimationApplier.hasActiveState(player)) {
|
||||
} 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);
|
||||
}
|
||||
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) {
|
||||
// Was tied, now free - stop all animations
|
||||
if (GltfAnimationApplier.hasActiveState(player)) {
|
||||
@@ -235,63 +280,12 @@ public class AnimationTickHandler {
|
||||
} else {
|
||||
BondageAnimationManager.stopAnimation(player);
|
||||
}
|
||||
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -300,9 +294,9 @@ public class AnimationTickHandler {
|
||||
if (event.getEntity().level().isClientSide()) {
|
||||
UUID uuid = event.getEntity().getUUID();
|
||||
AnimationStateRegistry.getLastTiedState().remove(uuid);
|
||||
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||
BondageAnimationManager.cleanup(uuid);
|
||||
GltfAnimationApplier.removeTracking(uuid);
|
||||
furnitureRetryCounters.remove(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +313,7 @@ public class AnimationTickHandler {
|
||||
// DogPoseRenderHandler, MCAAnimationTickCache)
|
||||
// AnimationStateRegistry.clearAll() handles GltfAnimationApplier.clearAll() transitively
|
||||
AnimationStateRegistry.clearAll();
|
||||
furnitureRetryCounters.clear();
|
||||
|
||||
// Non-animation client-side caches
|
||||
PetBedClientState.clearAll();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.tiedup.remake.client.animation.tick;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
@@ -12,12 +12,17 @@ import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
* multiple times per game tick.
|
||||
*
|
||||
* <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)
|
||||
public final class MCAAnimationTickCache {
|
||||
|
||||
private static final Map<UUID, Integer> lastTickMap = new HashMap<>();
|
||||
private static final Map<UUID, Integer> lastTickMap =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
private MCAAnimationTickCache() {
|
||||
// Utility class
|
||||
@@ -48,4 +53,9 @@ public final class MCAAnimationTickCache {
|
||||
public static void clear() {
|
||||
lastTickMap.clear();
|
||||
}
|
||||
|
||||
/** Drop the tick-dedup entry for an entity leaving the level. */
|
||||
public static void remove(UUID entityUuid) {
|
||||
lastTickMap.remove(entityUuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ import com.tiedup.remake.client.gltf.GltfAnimationApplier;
|
||||
import com.tiedup.remake.entities.AbstractTiedUpNpc;
|
||||
import com.tiedup.remake.entities.EntityMaster;
|
||||
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.v2.bondage.BindModeHelper;
|
||||
import com.tiedup.remake.v2.bondage.PoseTypeHelper;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
@@ -49,7 +50,33 @@ public class NpcAnimationTickHandler {
|
||||
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 1–5 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
|
||||
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||
@@ -62,6 +89,39 @@ public class NpcAnimationTickHandler {
|
||||
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()) {
|
||||
if (
|
||||
entity instanceof AbstractTiedUpNpc damsel &&
|
||||
@@ -81,8 +141,17 @@ public class NpcAnimationTickHandler {
|
||||
* (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.
|
||||
*
|
||||
* <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}.
|
||||
*
|
||||
* <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) {
|
||||
boolean inPose =
|
||||
@@ -90,6 +159,16 @@ public class NpcAnimationTickHandler {
|
||||
|
||||
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) {
|
||||
// Resolve V2 equipment map
|
||||
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(
|
||||
@@ -119,7 +198,7 @@ public class NpcAnimationTickHandler {
|
||||
);
|
||||
lastNpcAnimId.remove(uuid);
|
||||
} else {
|
||||
// V1 fallback: JSON-based PlayerAnimator animations
|
||||
// Legacy fallback: JSON-based PlayerAnimator animations
|
||||
if (GltfAnimationApplier.hasActiveState(entity)) {
|
||||
GltfAnimationApplier.clearV2Animation(entity);
|
||||
}
|
||||
@@ -147,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) {
|
||||
// Determine position prefix for SIT/KNEEL poses
|
||||
@@ -161,13 +240,8 @@ public class NpcAnimationTickHandler {
|
||||
net.minecraft.world.item.ItemStack bind = entity.getEquipment(
|
||||
BodyRegionV2.ARMS
|
||||
);
|
||||
PoseType poseType = PoseType.STANDARD;
|
||||
boolean hasBind = false;
|
||||
|
||||
if (bind.getItem() instanceof ItemBind itemBind) {
|
||||
poseType = itemBind.getPoseType();
|
||||
hasBind = true;
|
||||
}
|
||||
PoseType poseType = PoseTypeHelper.getPoseType(bind);
|
||||
boolean hasBind = BindModeHelper.isBindItem(bind);
|
||||
|
||||
// Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder)
|
||||
boolean armsBound = V2EquipmentHelper.isRegionOccupied(
|
||||
@@ -179,10 +253,10 @@ public class NpcAnimationTickHandler {
|
||||
BodyRegionV2.LEGS
|
||||
);
|
||||
|
||||
// V1 fallback: if no V2 regions set but NPC has a bind, derive from ItemBind NBT
|
||||
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) {
|
||||
armsBound = ItemBind.hasArmsBound(bind);
|
||||
legsBound = ItemBind.hasLegsBound(bind);
|
||||
// Legacy fallback: if no V2 regions set but NPC has a bind, derive from bind mode NBT
|
||||
if (!armsBound && !legsBound && BindModeHelper.isBindItem(bind)) {
|
||||
armsBound = BindModeHelper.hasArmsBound(bind);
|
||||
legsBound = BindModeHelper.hasLegsBound(bind);
|
||||
}
|
||||
|
||||
boolean isStruggling = entity.isStruggling();
|
||||
@@ -219,5 +293,17 @@ public class NpcAnimationTickHandler {
|
||||
*/
|
||||
public static void clearAll() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,26 +6,24 @@ import net.minecraftforge.api.distmarker.Dist;
|
||||
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.
|
||||
* Used by BondageAnimationManager, NpcAnimationTickHandler, and AnimationTickHandler.
|
||||
*
|
||||
* <p>Animation naming convention:
|
||||
* <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:
|
||||
* <p><b>Legacy path.</b> The V2 player pipeline resolves animations from
|
||||
* GLB models via {@code GltfAnimationApplier.applyV2Animation} +
|
||||
* {@code RegionBoneMapper} and does not touch this class. Only two
|
||||
* callers remain:</p>
|
||||
* <ul>
|
||||
* <li>tiedup:tied_up_basic_idle - STANDARD + FULL + idle</li>
|
||||
* <li>tiedup:straitjacket_arms_struggle - STRAITJACKET + ARMS + struggle</li>
|
||||
* <li>tiedup:wrap_idle - WRAP + FULL + idle</li>
|
||||
* <li>{@code NpcAnimationTickHandler} — JSON fallback when a tied
|
||||
* NPC has no GLB-bearing item equipped.</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>
|
||||
*
|
||||
* <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)
|
||||
public final class AnimationIdBuilder {
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
package com.tiedup.remake.client.events;
|
||||
|
||||
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.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 net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.event.entity.EntityLeaveLevelEvent;
|
||||
@@ -11,16 +22,10 @@ import net.minecraftforge.fml.common.Mod;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Automatic cleanup handler for entity-related resources.
|
||||
*
|
||||
* <p>This handler automatically cleans up animation layers and pending animations
|
||||
* when entities leave the world, preventing memory leaks from stale cache entries.
|
||||
*
|
||||
* <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.
|
||||
* Fans out {@link EntityLeaveLevelEvent} to every per-entity state map on
|
||||
* the client — the single source of truth for "entity is gone, drop its
|
||||
* tracked state". Each target owns its own static map; this handler
|
||||
* ensures none of them leak entries for dead/unloaded entities.
|
||||
*/
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
@@ -56,15 +61,25 @@ public class EntityCleanupHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up animation layers
|
||||
BondageAnimationManager.cleanup(event.getEntity().getUUID());
|
||||
java.util.UUID uuid = event.getEntity().getUUID();
|
||||
|
||||
// Clean up pending animation queue
|
||||
PendingAnimationManager.remove(event.getEntity().getUUID());
|
||||
BondageAnimationManager.cleanup(uuid);
|
||||
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(
|
||||
"Auto-cleaned animation resources for entity: {} (type: {})",
|
||||
event.getEntity().getUUID(),
|
||||
uuid,
|
||||
event.getEntity().getClass().getSimpleName()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.tiedup.remake.client.events;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.items.base.BindVariant;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.v2.bondage.PoseTypeHelper;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
import com.tiedup.remake.util.KidnappedHelper;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
@@ -138,7 +138,7 @@ public class LeashProxyClientHandler {
|
||||
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (
|
||||
!bind.isEmpty() &&
|
||||
bind.getItem() == ModItems.getBind(BindVariant.DOGBINDER)
|
||||
PoseTypeHelper.getPoseType(bind) == PoseType.DOG
|
||||
) {
|
||||
return DOGWALK_Y_OFFSET;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package com.tiedup.remake.client.events;
|
||||
|
||||
import com.tiedup.remake.items.base.*;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
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.player.LocalPlayer;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
@@ -70,7 +76,7 @@ public class SelfBondageInputHandler {
|
||||
if (!event.getLevel().isClientSide()) return;
|
||||
|
||||
ItemStack stack = event.getItemStack();
|
||||
if (isSelfBondageItem(stack.getItem())) {
|
||||
if (isSelfBondageItem(stack)) {
|
||||
event.setCanceled(true);
|
||||
startSelfBondage();
|
||||
}
|
||||
@@ -87,11 +93,11 @@ public class SelfBondageInputHandler {
|
||||
InteractionHand hand = InteractionHand.MAIN_HAND;
|
||||
ItemStack stack = player.getMainHandItem();
|
||||
|
||||
if (!isSelfBondageItem(stack.getItem())) {
|
||||
if (!isSelfBondageItem(stack)) {
|
||||
stack = player.getOffhandItem();
|
||||
hand = InteractionHand.OFF_HAND;
|
||||
|
||||
if (!isSelfBondageItem(stack.getItem())) {
|
||||
if (!isSelfBondageItem(stack)) {
|
||||
return; // No bondage item in either hand
|
||||
}
|
||||
}
|
||||
@@ -130,7 +136,7 @@ public class SelfBondageInputHandler {
|
||||
|
||||
// Check if still holding bondage item in the active hand
|
||||
ItemStack stack = player.getItemInHand(activeHand);
|
||||
if (!isSelfBondageItem(stack.getItem())) {
|
||||
if (!isSelfBondageItem(stack)) {
|
||||
stopSelfBondage();
|
||||
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.
|
||||
*/
|
||||
private static boolean isSelfBondageItem(Item item) {
|
||||
// Collar cannot be self-equipped (V1 collar guard)
|
||||
if (item instanceof ItemCollar) {
|
||||
private static boolean isSelfBondageItem(ItemStack stack) {
|
||||
if (stack.isEmpty()) return false;
|
||||
|
||||
// Collar cannot be self-equipped (V2 ownership component)
|
||||
if (CollarHelper.isCollar(stack)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// V2 bondage items support self-bondage (left-click hold with tying duration)
|
||||
if (item instanceof IV2BondageItem) {
|
||||
if (stack.getItem() instanceof IV2BondageItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// V1 bondage items (legacy)
|
||||
return (
|
||||
item instanceof ItemBind ||
|
||||
item instanceof ItemGag ||
|
||||
item instanceof ItemBlindfold ||
|
||||
item instanceof ItemMittens ||
|
||||
item instanceof ItemEarplugs
|
||||
);
|
||||
// V2 data-driven items: check if it occupies any bondage region
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
if (def != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// V1 fallback: bind items
|
||||
return BindModeHelper.isBindItem(stack)
|
||||
|| DataDrivenBondageItem.getComponent(stack, ComponentType.GAGGING, GaggingComponent.class) != null
|
||||
|| DataDrivenBondageItem.getComponent(stack, ComponentType.BLINDING, BlindingComponent.class) != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,51 +22,37 @@ import org.joml.Vector3f;
|
||||
/**
|
||||
* Parser for binary .glb (glTF 2.0) files.
|
||||
* 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 {
|
||||
|
||||
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() {}
|
||||
|
||||
/**
|
||||
* 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 debugName name for log messages
|
||||
* @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 {
|
||||
byte[] allBytes = input.readAllBytes();
|
||||
ByteBuffer buf = ByteBuffer.wrap(allBytes).order(
|
||||
ByteOrder.LITTLE_ENDIAN
|
||||
);
|
||||
|
||||
// -- 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();
|
||||
ByteBuffer buf = GlbParserUtils.readGlbSafely(input, debugName);
|
||||
|
||||
// -- JSON chunk --
|
||||
int jsonChunkLength = buf.getInt();
|
||||
int jsonChunkLength = GlbParserUtils.readChunkLength(
|
||||
buf,
|
||||
"JSON",
|
||||
debugName
|
||||
);
|
||||
int jsonChunkType = buf.getInt();
|
||||
if (jsonChunkType != CHUNK_JSON) {
|
||||
if (jsonChunkType != GlbParserUtils.CHUNK_JSON) {
|
||||
throw new IOException("Expected JSON chunk in " + debugName);
|
||||
}
|
||||
byte[] jsonBytes = new byte[jsonChunkLength];
|
||||
@@ -77,9 +63,13 @@ public final class GlbParser {
|
||||
// -- BIN chunk --
|
||||
ByteBuffer binData = null;
|
||||
if (buf.hasRemaining()) {
|
||||
int binChunkLength = buf.getInt();
|
||||
int binChunkLength = GlbParserUtils.readChunkLength(
|
||||
buf,
|
||||
"BIN",
|
||||
debugName
|
||||
);
|
||||
int binChunkType = buf.getInt();
|
||||
if (binChunkType != CHUNK_BIN) {
|
||||
if (binChunkType != GlbParserUtils.CHUNK_BIN) {
|
||||
throw new IOException("Expected BIN chunk in " + debugName);
|
||||
}
|
||||
byte[] binBytes = new byte[binChunkLength];
|
||||
@@ -103,31 +93,35 @@ public final class GlbParser {
|
||||
JsonObject skin = skins.get(0).getAsJsonObject();
|
||||
JsonArray skinJoints = skin.getAsJsonArray("joints");
|
||||
|
||||
// Filter skin joints to only include known deforming bones
|
||||
List<Integer> filteredJointNodes = new ArrayList<>();
|
||||
int[] skinJointRemap = new int[skinJoints.size()]; // old skin index -> new filtered index
|
||||
java.util.Arrays.fill(skinJointRemap, -1);
|
||||
// Accept all skin joints (no filtering — custom bones are supported)
|
||||
List<Integer> allJointNodes = new ArrayList<>();
|
||||
for (int j = 0; j < skinJoints.size(); j++) {
|
||||
int nodeIdx = skinJoints.get(j).getAsInt();
|
||||
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
|
||||
String name = node.has("name")
|
||||
String rawName = node.has("name")
|
||||
? node.get("name").getAsString()
|
||||
: "joint_" + j;
|
||||
if (GltfBoneMapper.isKnownBone(name)) {
|
||||
skinJointRemap[j] = filteredJointNodes.size();
|
||||
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 {
|
||||
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];
|
||||
int[] parentJointIndices = new int[jointCount];
|
||||
Quaternionf[] restRotations = new Quaternionf[jointCount];
|
||||
Vector3f[] restTranslations = new Vector3f[jointCount];
|
||||
|
||||
@@ -135,64 +129,31 @@ public final class GlbParser {
|
||||
int[] nodeToJoint = new int[nodes.size()];
|
||||
java.util.Arrays.fill(nodeToJoint, -1);
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
int nodeIdx = filteredJointNodes.get(j);
|
||||
int nodeIdx = allJointNodes.get(j);
|
||||
nodeToJoint[nodeIdx] = j;
|
||||
}
|
||||
|
||||
// Read joint names, rest pose, and build parent mapping
|
||||
java.util.Arrays.fill(parentJointIndices, -1);
|
||||
// Read joint names + rest pose
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
int nodeIdx = filteredJointNodes.get(j);
|
||||
int nodeIdx = allJointNodes.get(j);
|
||||
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
|
||||
|
||||
jointNames[j] = node.has("name")
|
||||
String rawName = node.has("name")
|
||||
? node.get("name").getAsString()
|
||||
: "joint_" + j;
|
||||
jointNames[j] = GlbParserUtils.stripArmaturePrefix(rawName);
|
||||
|
||||
// Rest rotation
|
||||
if (node.has("rotation")) {
|
||||
JsonArray r = node.getAsJsonArray("rotation");
|
||||
restRotations[j] = new Quaternionf(
|
||||
r.get(0).getAsFloat(),
|
||||
r.get(1).getAsFloat(),
|
||||
r.get(2).getAsFloat(),
|
||||
r.get(3).getAsFloat()
|
||||
restRotations[j] = GlbParserUtils.readRestRotation(node);
|
||||
restTranslations[j] = GlbParserUtils.readRestTranslation(node);
|
||||
}
|
||||
|
||||
int[] parentJointIndices = GlbParserUtils.buildParentJointIndices(
|
||||
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 --
|
||||
// IBM accessor is indexed by original skin joint order, so we pick the filtered entries
|
||||
Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount];
|
||||
if (skin.has("inverseBindMatrices")) {
|
||||
int ibmAccessor = skin.get("inverseBindMatrices").getAsInt();
|
||||
@@ -202,12 +163,9 @@ public final class GlbParser {
|
||||
binData,
|
||||
ibmAccessor
|
||||
);
|
||||
for (int origJ = 0; origJ < skinJoints.size(); origJ++) {
|
||||
int newJ = skinJointRemap[origJ];
|
||||
if (newJ >= 0) {
|
||||
inverseBindMatrices[newJ] = new Matrix4f();
|
||||
inverseBindMatrices[newJ].set(ibmData, origJ * 16);
|
||||
}
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
inverseBindMatrices[j] = new Matrix4f();
|
||||
inverseBindMatrices[j].set(ibmData, j * 16);
|
||||
}
|
||||
} else {
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
@@ -215,19 +173,35 @@ public final class GlbParser {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Find mesh (ignore "Player" mesh, take LAST non-Player) --
|
||||
// WORKAROUND: Takes the LAST non-Player mesh because modelers may leave prototype meshes
|
||||
// in the .glb. Revert to first non-Player mesh once modeler workflow is standardized.
|
||||
// -- Find mesh by convention, then fallback --
|
||||
// Priority: 1) mesh named "Item", 2) last non-Player mesh
|
||||
int targetMeshIdx = -1;
|
||||
String selectedMeshName = null;
|
||||
int nonPlayerCount = 0;
|
||||
if (meshes != 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 (!"Player".equals(meshName)) {
|
||||
if ("Item".equals(meshName)) {
|
||||
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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,155 +220,25 @@ public final class GlbParser {
|
||||
|
||||
if (targetMeshIdx >= 0) {
|
||||
JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
|
||||
JsonArray primitives = mesh.getAsJsonArray("primitives");
|
||||
|
||||
// -- Accumulate vertex data from ALL primitives --
|
||||
List<float[]> allPositions = new ArrayList<>();
|
||||
List<float[]> allNormals = new ArrayList<>();
|
||||
List<float[]> allTexCoords = new ArrayList<>();
|
||||
List<int[]> allJoints = new ArrayList<>();
|
||||
List<float[]> allWeights = new ArrayList<>();
|
||||
int cumulativeVertexCount = 0;
|
||||
|
||||
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(
|
||||
GlbParserUtils.PrimitiveParseResult r =
|
||||
GlbParserUtils.parsePrimitives(
|
||||
mesh,
|
||||
accessors,
|
||||
bufferViews,
|
||||
binData,
|
||||
attributes.get("POSITION").getAsInt()
|
||||
jointCount,
|
||||
/* readSkinning */true,
|
||||
materialNames,
|
||||
debugName
|
||||
);
|
||||
float[] primNormals = attributes.has("NORMAL")
|
||||
? GlbParserUtils.readFloatAccessor(
|
||||
accessors,
|
||||
bufferViews,
|
||||
binData,
|
||||
attributes.get("NORMAL").getAsInt()
|
||||
)
|
||||
: new float[primPositions.length];
|
||||
float[] primTexCoords = attributes.has("TEXCOORD_0")
|
||||
? GlbParserUtils.readFloatAccessor(
|
||||
accessors,
|
||||
bufferViews,
|
||||
binData,
|
||||
attributes.get("TEXCOORD_0").getAsInt()
|
||||
)
|
||||
: new float[(primPositions.length / 3) * 2];
|
||||
|
||||
int primVertexCount = primPositions.length / 3;
|
||||
|
||||
// -- 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;
|
||||
}
|
||||
positions = r.positions;
|
||||
normals = r.normals;
|
||||
texCoords = r.texCoords;
|
||||
indices = r.indices;
|
||||
meshJoints = r.joints;
|
||||
weights = r.weights;
|
||||
vertexCount = r.vertexCount;
|
||||
parsedPrimitives.addAll(r.primitives);
|
||||
} else {
|
||||
// Animation-only GLB: no mesh data
|
||||
LOGGER.info(
|
||||
@@ -419,13 +263,8 @@ public final class GlbParser {
|
||||
String animName = anim.has("name")
|
||||
? anim.get("name").getAsString()
|
||||
: "animation_" + ai;
|
||||
// Strip the "ArmatureName|" prefix if present (Blender convention)
|
||||
if (animName.contains("|")) {
|
||||
animName = animName.substring(
|
||||
animName.lastIndexOf('|') + 1
|
||||
);
|
||||
}
|
||||
GltfData.AnimationClip clip = parseAnimation(
|
||||
animName = GlbParserUtils.stripArmaturePrefix(animName);
|
||||
GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
|
||||
anim,
|
||||
accessors,
|
||||
bufferViews,
|
||||
@@ -529,21 +368,18 @@ public final class GlbParser {
|
||||
? null
|
||||
: rawAllClips.values().iterator().next();
|
||||
|
||||
// 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
|
||||
// Convert ALL animation clips to MC space
|
||||
// Convert from glTF coordinate system (Y-up, faces +Z) to MC (Y-up, faces -Z).
|
||||
// 180° rotation around Z: negate X and Y for all spatial data.
|
||||
for (GltfData.AnimationClip clip : allClips.values()) {
|
||||
GlbParserUtils.convertAnimationToMinecraftSpace(clip, jointCount);
|
||||
}
|
||||
convertToMinecraftSpace(
|
||||
GlbParserUtils.convertMeshToMinecraftSpace(
|
||||
positions,
|
||||
normals,
|
||||
restTranslations,
|
||||
restRotations,
|
||||
inverseBindMatrices,
|
||||
null,
|
||||
jointCount
|
||||
); // pass null — clips already converted above
|
||||
inverseBindMatrices
|
||||
);
|
||||
LOGGER.debug(
|
||||
"[GltfPipeline] Converted all data to Minecraft coordinate space"
|
||||
);
|
||||
@@ -571,216 +407,4 @@ public final class GlbParser {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.List;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
@@ -27,8 +33,161 @@ public final class GlbParserUtils {
|
||||
public static final int UNSIGNED_INT = 5125;
|
||||
public static final int FLOAT = 5126;
|
||||
|
||||
/** Maximum allowed GLB file size to prevent OOM from malformed/hostile assets. */
|
||||
public static final int MAX_GLB_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
// GLB binary format constants (shared between parsers and validator).
|
||||
public static final int GLB_MAGIC = 0x46546C67; // "glTF"
|
||||
public static final int GLB_VERSION = 2;
|
||||
public static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
|
||||
public static final int CHUNK_BIN = 0x004E4942; // "BIN\0"
|
||||
|
||||
private GlbParserUtils() {}
|
||||
|
||||
/**
|
||||
* Safely read a GLB stream into a little-endian ByteBuffer positioned past the
|
||||
* 12-byte header, after validating magic, version, and total length.
|
||||
*
|
||||
* <p>Protects downstream parsers from OOM and negative-length crashes on malformed
|
||||
* or hostile resource packs. Files larger than {@link #MAX_GLB_SIZE} are rejected.</p>
|
||||
*
|
||||
* @param input the input stream (will be read)
|
||||
* @param debugName name included in diagnostic messages
|
||||
* @return a buffer positioned at the start of the first chunk, with
|
||||
* remaining bytes exactly equal to {@code totalLength - 12}
|
||||
* @throws IOException on bad header, size cap exceeded, or truncation
|
||||
*/
|
||||
public static ByteBuffer readGlbSafely(InputStream input, String debugName)
|
||||
throws IOException {
|
||||
byte[] header = input.readNBytes(12);
|
||||
if (header.length < 12) {
|
||||
throw new IOException("GLB truncated in header: " + debugName);
|
||||
}
|
||||
ByteBuffer hdr = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);
|
||||
int magic = hdr.getInt();
|
||||
if (magic != GLB_MAGIC) {
|
||||
throw new IOException("Not a GLB file: " + debugName);
|
||||
}
|
||||
int version = hdr.getInt();
|
||||
if (version != GLB_VERSION) {
|
||||
throw new IOException(
|
||||
"Unsupported GLB version " + version + " in " + debugName
|
||||
);
|
||||
}
|
||||
int totalLength = hdr.getInt();
|
||||
if (totalLength < 12) {
|
||||
throw new IOException(
|
||||
"GLB total length " + totalLength + " too small in " + debugName
|
||||
);
|
||||
}
|
||||
if (totalLength > MAX_GLB_SIZE) {
|
||||
throw new IOException(
|
||||
"GLB size " +
|
||||
totalLength +
|
||||
" exceeds cap " +
|
||||
MAX_GLB_SIZE +
|
||||
" in " +
|
||||
debugName
|
||||
);
|
||||
}
|
||||
int bodyLen = totalLength - 12;
|
||||
byte[] body = input.readNBytes(bodyLen);
|
||||
if (body.length < bodyLen) {
|
||||
throw new IOException(
|
||||
"GLB truncated: expected " +
|
||||
bodyLen +
|
||||
" body bytes, got " +
|
||||
body.length +
|
||||
" in " +
|
||||
debugName
|
||||
);
|
||||
}
|
||||
return ByteBuffer.wrap(body).order(ByteOrder.LITTLE_ENDIAN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize per-vertex skinning weights so each 4-tuple sums to 1.0.
|
||||
*
|
||||
* <p>Blender auto-weights + float quantization often produce sums slightly ≠ 1
|
||||
* (commonly 0.98–1.02). LBS without normalization scales the vertex by that
|
||||
* factor — tiny error per vertex, visible drift over a full mesh.
|
||||
* Tuples that sum to effectively zero (no influence) are left alone so
|
||||
* downstream code can treat them as "un-skinned" if needed.</p>
|
||||
*
|
||||
* <p>Modifies the array in place. Call once at parse time; zero per-frame cost.</p>
|
||||
*/
|
||||
public static void normalizeWeights(float[] weights) {
|
||||
if (weights == null) return;
|
||||
if (weights.length % 4 != 0) {
|
||||
// WEIGHTS_0 is VEC4 per glTF spec; a non-multiple-of-4 array is malformed.
|
||||
// We still process the well-formed prefix.
|
||||
org.apache.logging.log4j.LogManager.getLogger(
|
||||
"GltfPipeline"
|
||||
).warn(
|
||||
"[GltfPipeline] WEIGHTS_0 array length {} is not a multiple of 4 (malformed); trailing {} values ignored",
|
||||
weights.length,
|
||||
weights.length % 4
|
||||
);
|
||||
}
|
||||
for (int i = 0; i + 3 < weights.length; i += 4) {
|
||||
float sum =
|
||||
weights[i] + weights[i + 1] + weights[i + 2] + weights[i + 3];
|
||||
if (sum > 1.0e-6f && Math.abs(sum - 1.0f) > 1.0e-4f) {
|
||||
float inv = 1.0f / sum;
|
||||
weights[i] *= inv;
|
||||
weights[i + 1] *= inv;
|
||||
weights[i + 2] *= inv;
|
||||
weights[i + 3] *= inv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp joint indices into the valid range [0, jointCount), remapping
|
||||
* out-of-range indices to 0 (root). <b>Mutates {@code joints} in place.</b>
|
||||
* Returns the number of clamps performed so the caller can log a single
|
||||
* warning when a file is malformed.
|
||||
*/
|
||||
public static int clampJointIndices(int[] joints, int jointCount) {
|
||||
if (joints == null) return 0;
|
||||
int clamped = 0;
|
||||
for (int i = 0; i < joints.length; i++) {
|
||||
if (joints[i] < 0 || joints[i] >= jointCount) {
|
||||
joints[i] = 0;
|
||||
clamped++;
|
||||
}
|
||||
}
|
||||
return clamped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a chunk length from the buffer and validate it fits within remaining bytes.
|
||||
* @throws IOException if length is negative or exceeds remaining
|
||||
*/
|
||||
public static int readChunkLength(
|
||||
ByteBuffer buf,
|
||||
String chunkName,
|
||||
String debugName
|
||||
) throws IOException {
|
||||
int len = buf.getInt();
|
||||
if (len < 0) {
|
||||
throw new IOException(
|
||||
"Negative " + chunkName + " chunk length in " + debugName
|
||||
);
|
||||
}
|
||||
if (len > buf.remaining() - 4) {
|
||||
// -4 for the chunk-type field that follows
|
||||
throw new IOException(
|
||||
chunkName +
|
||||
" chunk length " +
|
||||
len +
|
||||
" exceeds remaining bytes in " +
|
||||
debugName
|
||||
);
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
// ---- Material name parsing ----
|
||||
|
||||
/**
|
||||
@@ -99,16 +258,25 @@ public final class GlbParserUtils {
|
||||
? bv.get("byteStride").getAsInt()
|
||||
: 0;
|
||||
|
||||
int totalElements = count * components;
|
||||
float[] result = new float[totalElements];
|
||||
|
||||
int componentSize = componentByteSize(componentType);
|
||||
int stride = byteStride > 0 ? byteStride : components * componentSize;
|
||||
int totalElements = validateAccessorBounds(
|
||||
count,
|
||||
components,
|
||||
componentSize,
|
||||
byteOffset,
|
||||
stride,
|
||||
binData.capacity()
|
||||
);
|
||||
float[] result = new float[totalElements];
|
||||
|
||||
// Seek once per element; the sequential reads in readComponentAsFloat
|
||||
// advance the buffer through the components. Explicit per-component
|
||||
// seeks are redundant because component c+1 is already at the right
|
||||
// offset after reading c.
|
||||
for (int i = 0; i < count; i++) {
|
||||
int pos = byteOffset + i * stride;
|
||||
binData.position(byteOffset + i * stride);
|
||||
for (int c = 0; c < components; c++) {
|
||||
binData.position(pos + c * componentSize);
|
||||
result[i * components + c] = readComponentAsFloat(
|
||||
binData,
|
||||
componentType
|
||||
@@ -142,16 +310,22 @@ public final class GlbParserUtils {
|
||||
? bv.get("byteStride").getAsInt()
|
||||
: 0;
|
||||
|
||||
int totalElements = count * components;
|
||||
int[] result = new int[totalElements];
|
||||
|
||||
int componentSize = componentByteSize(componentType);
|
||||
int stride = byteStride > 0 ? byteStride : components * componentSize;
|
||||
int totalElements = validateAccessorBounds(
|
||||
count,
|
||||
components,
|
||||
componentSize,
|
||||
byteOffset,
|
||||
stride,
|
||||
binData.capacity()
|
||||
);
|
||||
int[] result = new int[totalElements];
|
||||
|
||||
// Seek once per element — see readFloatAccessor comment.
|
||||
for (int i = 0; i < count; i++) {
|
||||
int pos = byteOffset + i * stride;
|
||||
binData.position(byteOffset + i * stride);
|
||||
for (int c = 0; c < components; c++) {
|
||||
binData.position(pos + c * componentSize);
|
||||
result[i * components + c] = readComponentAsInt(
|
||||
binData,
|
||||
componentType
|
||||
@@ -162,6 +336,77 @@ public final class GlbParserUtils {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject malformed/hostile accessors before allocating. Prevents OOM from a
|
||||
* JSON declaring e.g. {@code count=5e8, type=MAT4} (~8 GB) within a legitimately
|
||||
* sized GLB. Checks (all must hold):
|
||||
* <ul>
|
||||
* <li>{@code count >= 0} and {@code components >= 1}</li>
|
||||
* <li>{@code count * components} doesn't overflow int</li>
|
||||
* <li>{@code byteOffset + stride * (count - 1) + components * componentSize <= binCapacity}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @return {@code count * components} (the allocation size)
|
||||
*/
|
||||
private static int validateAccessorBounds(
|
||||
int count,
|
||||
int components,
|
||||
int componentSize,
|
||||
int byteOffset,
|
||||
int stride,
|
||||
int binCapacity
|
||||
) {
|
||||
if (count < 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"Accessor count must be non-negative: " + count
|
||||
);
|
||||
}
|
||||
if (components < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"Accessor components must be >= 1: " + components
|
||||
);
|
||||
}
|
||||
if (byteOffset < 0 || stride < 0 || componentSize < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"Accessor has negative byteOffset/stride or zero componentSize"
|
||||
);
|
||||
}
|
||||
int totalElements;
|
||||
try {
|
||||
totalElements = Math.multiplyExact(count, components);
|
||||
} catch (ArithmeticException overflow) {
|
||||
throw new IllegalArgumentException(
|
||||
"Accessor count * components overflows int: " +
|
||||
count +
|
||||
" * " +
|
||||
components
|
||||
);
|
||||
}
|
||||
if (count == 0) {
|
||||
return 0;
|
||||
}
|
||||
// Bytes required: byteOffset + stride*(count-1) + components*componentSize
|
||||
long lastElementStart =
|
||||
(long) byteOffset + (long) stride * (long) (count - 1);
|
||||
long elementBytes = (long) components * (long) componentSize;
|
||||
long required = lastElementStart + elementBytes;
|
||||
if (required > binCapacity) {
|
||||
throw new IllegalArgumentException(
|
||||
"Accessor would read past BIN chunk: needs " +
|
||||
required +
|
||||
" bytes, buffer has " +
|
||||
binCapacity
|
||||
);
|
||||
}
|
||||
return totalElements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one component as a normalized float in [−1, 1] (signed) or [0, 1]
|
||||
* (unsigned). {@code UNSIGNED_INT} is defensive — glTF 2.0 §3.6.2.3 only
|
||||
* lists BYTE/UBYTE/SHORT/USHORT as valid normalized types; an out-of-spec
|
||||
* exporter hitting that branch gets a best-effort divide by 0xFFFFFFFF.
|
||||
*/
|
||||
public static float readComponentAsFloat(
|
||||
ByteBuffer buf,
|
||||
int componentType
|
||||
@@ -261,8 +506,543 @@ public final class GlbParserUtils {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Naming conventions ----
|
||||
|
||||
/**
|
||||
* True when a mesh name follows the {@code Player*} convention — typically
|
||||
* a player-armature mesh or seat-armature mesh that the item/furniture
|
||||
* pipelines must skip. Null-safe.
|
||||
*
|
||||
* <p>Historically {@link GlbParser} and {@link
|
||||
* com.tiedup.remake.client.gltf.diagnostic.GlbValidator GlbValidator}
|
||||
* used {@code "Player".equals(name)} while
|
||||
* {@code FurnitureGlbParser} used {@code startsWith("Player")}, so a mesh
|
||||
* named {@code "Player_foo"} was accepted by the item pipeline but
|
||||
* rejected by the furniture pipeline. Consolidated on the more defensive
|
||||
* {@code startsWith} variant — matches the artist guide's
|
||||
* {@code Player_*} seat armature naming convention.</p>
|
||||
*/
|
||||
public static boolean isPlayerMesh(@Nullable String meshName) {
|
||||
return meshName != null && meshName.startsWith("Player");
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a Blender-style armature prefix from a bone or animation name.
|
||||
* {@code "ArmatureName|bone"} → {@code "bone"}. Keeps everything after
|
||||
* the last {@code |}. Returns the input unchanged if it has no pipe,
|
||||
* and returns null if the input is null.
|
||||
*
|
||||
* <p>All joint/animation/validator reads must go through this helper —
|
||||
* a site that stores the raw prefixed name silently breaks artists
|
||||
* with non-default armature names.</p>
|
||||
*/
|
||||
public static String stripArmaturePrefix(@Nullable String name) {
|
||||
if (name == null) return null;
|
||||
int pipeIdx = name.lastIndexOf('|');
|
||||
return pipeIdx >= 0 ? name.substring(pipeIdx + 1) : name;
|
||||
}
|
||||
|
||||
// ---- Node rest pose extraction ----
|
||||
|
||||
/**
|
||||
* Read a node's rest rotation from its glTF JSON representation. Returns
|
||||
* identity quaternion (0, 0, 0, 1) when no {@code rotation} field is present.
|
||||
*
|
||||
* <p>Extracted from 3 call sites in {@link GlbParser} /
|
||||
* {@link com.tiedup.remake.v2.furniture.client.FurnitureGlbParser
|
||||
* FurnitureGlbParser} that all had identical logic. Each parser's per-joint
|
||||
* loop now delegates here to avoid silent drift.</p>
|
||||
*/
|
||||
public static Quaternionf readRestRotation(JsonObject node) {
|
||||
if (node != null && node.has("rotation")) {
|
||||
JsonArray r = node.getAsJsonArray("rotation");
|
||||
return new Quaternionf(
|
||||
r.get(0).getAsFloat(),
|
||||
r.get(1).getAsFloat(),
|
||||
r.get(2).getAsFloat(),
|
||||
r.get(3).getAsFloat()
|
||||
);
|
||||
}
|
||||
return new Quaternionf(); // identity
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a node's rest translation. Returns the zero vector when no
|
||||
* {@code translation} field is present. See {@link #readRestRotation}.
|
||||
*/
|
||||
public static Vector3f readRestTranslation(JsonObject node) {
|
||||
if (node != null && node.has("translation")) {
|
||||
JsonArray t = node.getAsJsonArray("translation");
|
||||
return new Vector3f(
|
||||
t.get(0).getAsFloat(),
|
||||
t.get(1).getAsFloat(),
|
||||
t.get(2).getAsFloat()
|
||||
);
|
||||
}
|
||||
return new Vector3f();
|
||||
}
|
||||
|
||||
// ---- Bone hierarchy ----
|
||||
|
||||
/**
|
||||
* Build the parent-joint index array by traversing the glTF node children.
|
||||
*
|
||||
* @param nodes the top-level {@code nodes} array from the glTF JSON
|
||||
* @param nodeToJoint mapping: {@code nodeIdx → jointIdx} (use {@code -1}
|
||||
* for nodes that are not part of the skin)
|
||||
* @param jointCount the size of the resulting array
|
||||
* @return array of size {@code jointCount} where index {@code j} holds the
|
||||
* parent joint index, or {@code -1} for roots
|
||||
*/
|
||||
public static int[] buildParentJointIndices(
|
||||
JsonArray nodes,
|
||||
int[] nodeToJoint,
|
||||
int jointCount
|
||||
) {
|
||||
int[] parentJointIndices = new int[jointCount];
|
||||
for (int j = 0; j < jointCount; j++) parentJointIndices[j] = -1;
|
||||
|
||||
for (int ni = 0; ni < nodes.size(); ni++) {
|
||||
JsonObject node = nodes.get(ni).getAsJsonObject();
|
||||
if (!node.has("children")) continue;
|
||||
int parentJoint = nodeToJoint[ni];
|
||||
JsonArray children = node.getAsJsonArray("children");
|
||||
for (JsonElement child : children) {
|
||||
int childNodeIdx = child.getAsInt();
|
||||
// Malformed GLBs may declare a child index outside `nodes`;
|
||||
// silently skip rather than AIOOBE.
|
||||
if (childNodeIdx < 0 || childNodeIdx >= nodeToJoint.length) {
|
||||
continue;
|
||||
}
|
||||
int childJoint = nodeToJoint[childNodeIdx];
|
||||
if (childJoint >= 0 && parentJoint >= 0) {
|
||||
parentJointIndices[childJoint] = parentJoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
return parentJointIndices;
|
||||
}
|
||||
|
||||
// ---- Animation parsing ----
|
||||
|
||||
/**
|
||||
* Parse a single {@code glTF animation} JSON object into an
|
||||
* {@link GltfData.AnimationClip}. Returns {@code null} when the animation
|
||||
* has no channels that map to the current skin.
|
||||
*
|
||||
* <p>Skin-specific filtering is encoded in {@code nodeToJoint}: channels
|
||||
* targeting a node with a {@code -1} mapping are skipped.</p>
|
||||
*
|
||||
* @param animation a single entry from the root {@code animations} array
|
||||
* @param accessors the root {@code accessors} array
|
||||
* @param bufferViews the root {@code bufferViews} array
|
||||
* @param binData the BIN chunk buffer
|
||||
* @param nodeToJoint mapping from glTF node index to joint index; use
|
||||
* {@code -1} for nodes not in the skin
|
||||
* @param jointCount size of the skin
|
||||
* @return parsed clip, or {@code null} if no channels matched the skin
|
||||
*/
|
||||
@Nullable
|
||||
public 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");
|
||||
|
||||
java.util.List<Integer> rotJoints = new java.util.ArrayList<>();
|
||||
java.util.List<float[]> rotTimestamps = new java.util.ArrayList<>();
|
||||
java.util.List<Quaternionf[]> rotValues = new java.util.ArrayList<>();
|
||||
|
||||
java.util.List<Integer> transJoints = new java.util.ArrayList<>();
|
||||
java.util.List<float[]> transTimestamps = new java.util.ArrayList<>();
|
||||
java.util.List<Vector3f[]> transValues = new java.util.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 = readFloatAccessor(
|
||||
accessors,
|
||||
bufferViews,
|
||||
binData,
|
||||
sampler.get("input").getAsInt()
|
||||
);
|
||||
|
||||
if ("rotation".equals(path)) {
|
||||
float[] quats = 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 = 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;
|
||||
|
||||
float[] timestamps = !rotTimestamps.isEmpty()
|
||||
? rotTimestamps.get(0)
|
||||
: transTimestamps.get(0);
|
||||
int frameCount = timestamps.length;
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
return new GltfData.AnimationClip(
|
||||
timestamps,
|
||||
rotations,
|
||||
translations,
|
||||
frameCount
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Primitive mesh parsing ----
|
||||
|
||||
/**
|
||||
* Result of parsing a mesh's primitives: flat per-attribute arrays plus
|
||||
* per-primitive metadata. All array lengths are in sync with
|
||||
* {@link #vertexCount}.
|
||||
*
|
||||
* <p>When the parsing request had {@code readSkinning = false} (mesh-only
|
||||
* path used by {@code FurnitureGlbParser.buildMeshOnlyGltfData}), the
|
||||
* {@link #joints} and {@link #weights} arrays are empty.</p>
|
||||
*/
|
||||
public static final class PrimitiveParseResult {
|
||||
|
||||
public final float[] positions;
|
||||
public final float[] normals;
|
||||
public final float[] texCoords;
|
||||
public final int[] indices;
|
||||
public final int[] joints;
|
||||
public final float[] weights;
|
||||
public final List<GltfData.Primitive> primitives;
|
||||
public final int vertexCount;
|
||||
|
||||
PrimitiveParseResult(
|
||||
float[] positions,
|
||||
float[] normals,
|
||||
float[] texCoords,
|
||||
int[] indices,
|
||||
int[] joints,
|
||||
float[] weights,
|
||||
List<GltfData.Primitive> primitives,
|
||||
int vertexCount
|
||||
) {
|
||||
this.positions = positions;
|
||||
this.normals = normals;
|
||||
this.texCoords = texCoords;
|
||||
this.indices = indices;
|
||||
this.joints = joints;
|
||||
this.weights = weights;
|
||||
this.primitives = primitives;
|
||||
this.vertexCount = vertexCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every primitive of a mesh, accumulate per-attribute buffers, and
|
||||
* flatten the result.
|
||||
*
|
||||
* <p>Invariants:</p>
|
||||
* <ul>
|
||||
* <li>POSITION is required. NORMAL and TEXCOORD_0 are optional and
|
||||
* default to zero-filled arrays of the correct size.</li>
|
||||
* <li>Per-primitive indices are offset by the running
|
||||
* {@code cumulativeVertexCount} so the flat arrays index
|
||||
* correctly.</li>
|
||||
* <li>{@code JOINTS_0} is read, out-of-range indices clamped to 0 with
|
||||
* a WARN log (once per file, via {@link #clampJointIndices}).</li>
|
||||
* <li>{@code WEIGHTS_0} is read and normalized per-vertex.</li>
|
||||
* <li>Material tintability: name prefix {@code "tintable_"} → per-channel
|
||||
* {@link GltfData.Primitive} entry.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param mesh a single entry from the root {@code meshes} array
|
||||
* @param accessors root {@code accessors} array
|
||||
* @param bufferViews root {@code bufferViews} array
|
||||
* @param binData the BIN chunk buffer
|
||||
* @param jointCount skin joint count (used to clamp JOINTS_0); pass
|
||||
* {@code 0} when {@code readSkinning} is false
|
||||
* @param readSkinning true to read JOINTS_0 / WEIGHTS_0, false for
|
||||
* mesh-only (furniture placer fallback)
|
||||
* @param materialNames material name lookup (from
|
||||
* {@link #parseMaterialNames}); may contain nulls
|
||||
* @param debugName file/resource name for diagnostics
|
||||
* @return the consolidated parse result
|
||||
*/
|
||||
public static PrimitiveParseResult parsePrimitives(
|
||||
JsonObject mesh,
|
||||
JsonArray accessors,
|
||||
JsonArray bufferViews,
|
||||
ByteBuffer binData,
|
||||
int jointCount,
|
||||
boolean readSkinning,
|
||||
String[] materialNames,
|
||||
String debugName
|
||||
) {
|
||||
JsonArray primitives = mesh.getAsJsonArray("primitives");
|
||||
|
||||
java.util.List<float[]> allPositions = new java.util.ArrayList<>();
|
||||
java.util.List<float[]> allNormals = new java.util.ArrayList<>();
|
||||
java.util.List<float[]> allTexCoords = new java.util.ArrayList<>();
|
||||
java.util.List<int[]> allJoints = new java.util.ArrayList<>();
|
||||
java.util.List<float[]> allWeights = new java.util.ArrayList<>();
|
||||
java.util.List<GltfData.Primitive> parsedPrimitives =
|
||||
new java.util.ArrayList<>();
|
||||
int cumulativeVertexCount = 0;
|
||||
|
||||
for (int pi = 0; pi < primitives.size(); pi++) {
|
||||
JsonObject primitive = primitives.get(pi).getAsJsonObject();
|
||||
JsonObject attributes = primitive.getAsJsonObject("attributes");
|
||||
|
||||
float[] primPositions = readFloatAccessor(
|
||||
accessors,
|
||||
bufferViews,
|
||||
binData,
|
||||
attributes.get("POSITION").getAsInt()
|
||||
);
|
||||
float[] primNormals = attributes.has("NORMAL")
|
||||
? readFloatAccessor(
|
||||
accessors,
|
||||
bufferViews,
|
||||
binData,
|
||||
attributes.get("NORMAL").getAsInt()
|
||||
)
|
||||
: new float[primPositions.length];
|
||||
float[] primTexCoords = attributes.has("TEXCOORD_0")
|
||||
? readFloatAccessor(
|
||||
accessors,
|
||||
bufferViews,
|
||||
binData,
|
||||
attributes.get("TEXCOORD_0").getAsInt()
|
||||
)
|
||||
: new float[(primPositions.length / 3) * 2];
|
||||
|
||||
int primVertexCount = primPositions.length / 3;
|
||||
|
||||
int[] primIndices;
|
||||
if (primitive.has("indices")) {
|
||||
primIndices = readIntAccessor(
|
||||
accessors,
|
||||
bufferViews,
|
||||
binData,
|
||||
primitive.get("indices").getAsInt()
|
||||
);
|
||||
} else {
|
||||
primIndices = new int[primVertexCount];
|
||||
for (int i = 0; i < primVertexCount; i++) primIndices[i] = i;
|
||||
}
|
||||
if (cumulativeVertexCount > 0) {
|
||||
for (int i = 0; i < primIndices.length; i++) {
|
||||
primIndices[i] += cumulativeVertexCount;
|
||||
}
|
||||
}
|
||||
|
||||
int[] primJoints = new int[primVertexCount * 4];
|
||||
float[] primWeights = new float[primVertexCount * 4];
|
||||
if (readSkinning) {
|
||||
if (attributes.has("JOINTS_0")) {
|
||||
primJoints = readIntAccessor(
|
||||
accessors,
|
||||
bufferViews,
|
||||
binData,
|
||||
attributes.get("JOINTS_0").getAsInt()
|
||||
);
|
||||
int clamped = clampJointIndices(primJoints, jointCount);
|
||||
if (clamped > 0) {
|
||||
org.apache.logging.log4j.LogManager.getLogger(
|
||||
"GltfPipeline"
|
||||
).warn(
|
||||
"[GltfPipeline] Clamped {} out-of-range joint indices in '{}'",
|
||||
clamped,
|
||||
debugName
|
||||
);
|
||||
}
|
||||
}
|
||||
if (attributes.has("WEIGHTS_0")) {
|
||||
primWeights = readFloatAccessor(
|
||||
accessors,
|
||||
bufferViews,
|
||||
binData,
|
||||
attributes.get("WEIGHTS_0").getAsInt()
|
||||
);
|
||||
normalizeWeights(primWeights);
|
||||
}
|
||||
}
|
||||
|
||||
String matName = null;
|
||||
if (primitive.has("material")) {
|
||||
int matIdx = primitive.get("material").getAsInt();
|
||||
if (
|
||||
matIdx >= 0 &&
|
||||
materialNames != null &&
|
||||
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);
|
||||
if (readSkinning) {
|
||||
allJoints.add(primJoints);
|
||||
allWeights.add(primWeights);
|
||||
}
|
||||
cumulativeVertexCount += primVertexCount;
|
||||
}
|
||||
|
||||
int totalIndices = 0;
|
||||
for (GltfData.Primitive p : parsedPrimitives)
|
||||
totalIndices += p.indices().length;
|
||||
int[] 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;
|
||||
}
|
||||
|
||||
return new PrimitiveParseResult(
|
||||
flattenFloats(allPositions),
|
||||
flattenFloats(allNormals),
|
||||
flattenFloats(allTexCoords),
|
||||
indices,
|
||||
readSkinning ? flattenInts(allJoints) : new int[0],
|
||||
readSkinning ? flattenFloats(allWeights) : new float[0],
|
||||
parsedPrimitives,
|
||||
cumulativeVertexCount
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Coordinate system conversion ----
|
||||
|
||||
/**
|
||||
* Convert mesh-level spatial data from glTF space to Minecraft model-def
|
||||
* space. glTF and MC model-def both face -Z; only X (right→left) and Y
|
||||
* (up→down) differ. Equivalent to a 180° rotation around Z: negate X and Y.
|
||||
*
|
||||
* <p>For animation data, see {@link #convertAnimationToMinecraftSpace}.</p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>Vertex positions / normals: (x, y, z) → (-x, -y, z)</li>
|
||||
* <li>Rest translations: same negation</li>
|
||||
* <li>Rest rotations (quaternions): negate qx and qy (conjugation by 180° Z)</li>
|
||||
* <li>Inverse bind matrices: M → C·M·C where C = diag(-1, -1, 1)</li>
|
||||
* </ul>
|
||||
*/
|
||||
public static void convertMeshToMinecraftSpace(
|
||||
float[] positions,
|
||||
float[] normals,
|
||||
Vector3f[] restTranslations,
|
||||
Quaternionf[] restRotations,
|
||||
Matrix4f[] inverseBindMatrices
|
||||
) {
|
||||
for (int i = 0; i < positions.length; i += 3) {
|
||||
positions[i] = -positions[i];
|
||||
positions[i + 1] = -positions[i + 1];
|
||||
}
|
||||
for (int i = 0; i < normals.length; i += 3) {
|
||||
normals[i] = -normals[i];
|
||||
normals[i + 1] = -normals[i + 1];
|
||||
}
|
||||
for (Vector3f t : restTranslations) {
|
||||
t.x = -t.x;
|
||||
t.y = -t.y;
|
||||
}
|
||||
for (Quaternionf q : restRotations) {
|
||||
q.x = -q.x;
|
||||
q.y = -q.y;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an animation clip's rotations and translations to MC space.
|
||||
* Negate qx/qy for rotations and negate tx/ty for translations.
|
||||
|
||||
@@ -6,15 +6,16 @@ import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
|
||||
import com.tiedup.remake.client.animation.context.GlbAnimationResolver;
|
||||
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
|
||||
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
@@ -55,12 +56,36 @@ public final class GltfAnimationApplier {
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
|
||||
/**
|
||||
* Cache of converted item-layer KeyframeAnimations.
|
||||
* Keyed by "animSource#context#ownedPartsHash".
|
||||
* Same GLB + same context + same owned parts = same KeyframeAnimation.
|
||||
* Cache of converted item-layer KeyframeAnimations, keyed by
|
||||
* {@code animSource#context#ownedPartsHash}. LRU-bounded via
|
||||
* 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 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 =
|
||||
new ConcurrentHashMap<>();
|
||||
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.
|
||||
@@ -128,11 +153,13 @@ public final class GltfAnimationApplier {
|
||||
String enabledKey = canonicalPartsKey(ownership.enabledParts());
|
||||
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 currentKey = activeStateKeys.get(entity.getUUID());
|
||||
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) ===
|
||||
@@ -155,8 +182,7 @@ public final class GltfAnimationApplier {
|
||||
return false;
|
||||
}
|
||||
|
||||
KeyframeAnimation itemAnim = itemAnimCache.get(itemCacheKey);
|
||||
if (itemAnim == null) {
|
||||
// Resolve animation data first (needed for variant resolution)
|
||||
GltfData animData = GlbAnimationResolver.resolveAnimationData(
|
||||
modelLoc,
|
||||
animationSource
|
||||
@@ -170,19 +196,36 @@ public final class GltfAnimationApplier {
|
||||
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||
return false;
|
||||
}
|
||||
// Resolve which named animation to use (with fallback chain + variant selection)
|
||||
|
||||
// Resolve which named animation to use (with fallback chain + variant selection).
|
||||
// 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
|
||||
);
|
||||
// Pass both owned parts and enabled parts (owned + free) for selective enabling
|
||||
|
||||
// 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(
|
||||
animData,
|
||||
glbAnimName,
|
||||
ownership.thisParts(),
|
||||
ownership.enabledParts()
|
||||
);
|
||||
itemAnimCache.put(itemCacheKey, itemAnim);
|
||||
itemAnimCache.put(variantCacheKey, itemAnim);
|
||||
}
|
||||
}
|
||||
|
||||
BondageAnimationManager.playDirect(entity, itemAnim);
|
||||
@@ -241,38 +284,24 @@ public final class GltfAnimationApplier {
|
||||
}
|
||||
|
||||
// === 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
|
||||
) {}
|
||||
|
||||
if (failedLoadKeys.contains(compositeCacheKey)) {
|
||||
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||
return false;
|
||||
}
|
||||
List<ResolvedItem> resolvedItems = new ArrayList<>();
|
||||
StringBuilder variantKeyBuilder = new StringBuilder("multi#").append(stateKey);
|
||||
|
||||
KeyframeAnimation compositeAnim = itemAnimCache.get(compositeCacheKey);
|
||||
if (compositeAnim == null) {
|
||||
KeyframeAnimation.AnimationBuilder builder =
|
||||
new KeyframeAnimation.AnimationBuilder(
|
||||
dev.kosmx.playerAnim.core.data.AnimationFormat.JSON_EMOTECRAFT
|
||||
);
|
||||
builder.beginTick = 0;
|
||||
builder.endTick = 1;
|
||||
builder.stopTick = 1;
|
||||
builder.isLooped = true;
|
||||
builder.returnTick = 0;
|
||||
builder.name = "gltf_composite";
|
||||
|
||||
boolean anyLoaded = false;
|
||||
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
RegionBoneMapper.V2ItemAnimInfo item = items.get(i);
|
||||
for (RegionBoneMapper.V2ItemAnimInfo item : items) {
|
||||
ResourceLocation animSource =
|
||||
item.animSource() != null
|
||||
? item.animSource()
|
||||
: item.modelLoc();
|
||||
item.animSource() != null ? item.animSource() : item.modelLoc();
|
||||
|
||||
GltfData animData = GlbAnimationResolver.resolveAnimationData(
|
||||
item.modelLoc(),
|
||||
item.animSource()
|
||||
item.modelLoc(), item.animSource()
|
||||
);
|
||||
if (animData == null) {
|
||||
LOGGER.warn(
|
||||
@@ -282,10 +311,50 @@ public final class GltfAnimationApplier {
|
||||
continue;
|
||||
}
|
||||
|
||||
String glbAnimName = GlbAnimationResolver.resolve(
|
||||
animData,
|
||||
context
|
||||
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)) {
|
||||
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
KeyframeAnimation.AnimationBuilder builder =
|
||||
new KeyframeAnimation.AnimationBuilder(
|
||||
dev.kosmx.playerAnim.core.data.AnimationFormat.JSON_EMOTECRAFT
|
||||
);
|
||||
builder.beginTick = 0;
|
||||
builder.isLooped = true;
|
||||
builder.returnTick = 0;
|
||||
builder.name = "gltf_composite";
|
||||
|
||||
boolean anyLoaded = false;
|
||||
int maxEndTick = 1;
|
||||
Set<String> unionKeyframeParts = new HashSet<>();
|
||||
boolean anyFullBody = false;
|
||||
boolean anyFullHead = false;
|
||||
|
||||
for (ResolvedItem resolved : resolvedItems) {
|
||||
RegionBoneMapper.V2ItemAnimInfo item = resolved.info();
|
||||
GltfData animData = resolved.animData();
|
||||
String glbAnimName = resolved.glbAnimName();
|
||||
ResourceLocation animSource =
|
||||
item.animSource() != null ? item.animSource() : item.modelLoc();
|
||||
|
||||
GltfData.AnimationClip rawClip;
|
||||
if (glbAnimName != null) {
|
||||
rawClip = animData.getRawAnimation(glbAnimName);
|
||||
@@ -300,9 +369,7 @@ public final class GltfAnimationApplier {
|
||||
// if the item declares per-animation bone filtering.
|
||||
Set<String> effectiveParts = item.ownedParts();
|
||||
if (glbAnimName != null && !item.animationBones().isEmpty()) {
|
||||
Set<String> override = item
|
||||
.animationBones()
|
||||
.get(glbAnimName);
|
||||
Set<String> override = item.animationBones().get(glbAnimName);
|
||||
if (override != null) {
|
||||
Set<String> filtered = new HashSet<>(override);
|
||||
filtered.retainAll(item.ownedParts());
|
||||
@@ -312,20 +379,33 @@ public final class GltfAnimationApplier {
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> itemKeyframeParts =
|
||||
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;
|
||||
|
||||
LOGGER.debug(
|
||||
"[GltfPipeline] Multi-item: {} -> owned={}, effective={}, anim={}",
|
||||
animSource,
|
||||
item.ownedParts(),
|
||||
effectiveParts,
|
||||
glbAnimName
|
||||
animSource, item.ownedParts(), effectiveParts, glbAnimName
|
||||
);
|
||||
}
|
||||
|
||||
@@ -335,34 +415,33 @@ public final class GltfAnimationApplier {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enable only owned parts on the item layer.
|
||||
// Free parts (head, body, etc. not owned by any item) are disabled here
|
||||
// so they pass through to the context layer / vanilla animation.
|
||||
String[] allPartNames = {
|
||||
"head",
|
||||
"body",
|
||||
"rightArm",
|
||||
"leftArm",
|
||||
"rightLeg",
|
||||
"leftLeg",
|
||||
};
|
||||
for (String partName : allPartNames) {
|
||||
KeyframeAnimation.StateCollection part = getPartByName(
|
||||
builder.endTick = maxEndTick;
|
||||
builder.stopTick = maxEndTick;
|
||||
|
||||
// Selective-part enabling for the composite. Owned parts always on;
|
||||
// free parts (including head) opt-in only if ANY item declares a
|
||||
// FullX / FullHeadX animation AND has keyframes for that part.
|
||||
GltfPoseConverter.enableSelectivePartsComposite(
|
||||
builder,
|
||||
partName
|
||||
allOwnedParts,
|
||||
unionKeyframeParts,
|
||||
anyFullBody,
|
||||
anyFullHead
|
||||
);
|
||||
if (part != null) {
|
||||
if (allOwnedParts.contains(partName)) {
|
||||
part.fullyEnablePart(false);
|
||||
} else {
|
||||
part.setEnabled(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BondageAnimationManager.playDirect(entity, compositeAnim);
|
||||
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||
@@ -433,52 +512,6 @@ public final class GltfAnimationApplier {
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -501,21 +534,4 @@ public final class GltfAnimationApplier {
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import net.minecraft.client.model.HumanoidModel;
|
||||
import net.minecraft.client.model.geom.ModelPart;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Maps glTF bone names to Minecraft HumanoidModel parts.
|
||||
@@ -110,4 +111,25 @@ public final class GltfBoneMapper {
|
||||
public static boolean isKnownBone(String 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.tiedup.remake.client.gltf;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
@@ -13,13 +14,21 @@ import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* 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)
|
||||
public final class GltfCache {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
private static final Map<ResourceLocation, GltfData> CACHE =
|
||||
private static final Map<ResourceLocation, Optional<GltfData>> CACHE =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
private GltfCache() {}
|
||||
@@ -27,13 +36,14 @@ public final class GltfCache {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public static GltfData get(ResourceLocation location) {
|
||||
GltfData cached = CACHE.get(location);
|
||||
if (cached != null) return cached;
|
||||
return CACHE.computeIfAbsent(location, GltfCache::load).orElse(null);
|
||||
}
|
||||
|
||||
private static Optional<GltfData> load(ResourceLocation location) {
|
||||
try {
|
||||
Resource resource = Minecraft.getInstance()
|
||||
.getResourceManager()
|
||||
@@ -41,17 +51,15 @@ public final class GltfCache {
|
||||
.orElse(null);
|
||||
if (resource == null) {
|
||||
LOGGER.error("[GltfPipeline] Resource not found: {}", location);
|
||||
return null;
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try (InputStream is = resource.open()) {
|
||||
GltfData data = GlbParser.parse(is, location.toString());
|
||||
CACHE.put(location, data);
|
||||
return data;
|
||||
return Optional.of(data);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[GltfPipeline] Failed to load GLB: {}", location, e);
|
||||
return null;
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
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.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.SimplePreparableReloadListener;
|
||||
import net.minecraft.util.profiling.ProfilerFiller;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.client.event.EntityRenderersEvent;
|
||||
import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
|
||||
import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
|
||||
import net.minecraftforge.event.TickEvent;
|
||||
import net.minecraftforge.eventbus.api.EventPriority;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
|
||||
@@ -22,26 +15,16 @@ import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Forge event registration for the glTF pipeline.
|
||||
* Registers keybind (F9), render layers, and animation factory.
|
||||
* MOD-bus setup for the generic glTF pipeline — init + cache-invalidation
|
||||
* reload listener. Bondage-specific registrations (render layer, item-aware
|
||||
* reload listeners) live in {@code V2ClientSetup}.
|
||||
*/
|
||||
public final class GltfClientSetup {
|
||||
|
||||
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() {}
|
||||
|
||||
/**
|
||||
* MOD bus event subscribers (FMLClientSetupEvent, RegisterKeyMappings, AddLayers).
|
||||
*/
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = "tiedup",
|
||||
bus = Mod.EventBusSubscriber.Bus.MOD,
|
||||
@@ -58,47 +41,28 @@ 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.
|
||||
* This ensures re-exported GLB models are picked up without restarting the game.
|
||||
* Register the generic GLB cache-clear reload listener.
|
||||
*
|
||||
* <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
|
||||
) {
|
||||
@@ -121,9 +85,6 @@ public final class GltfClientSetup {
|
||||
GltfCache.clearCache();
|
||||
GltfAnimationApplier.invalidateCache();
|
||||
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);
|
||||
ContextAnimationFactory.clearCache();
|
||||
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear();
|
||||
@@ -133,19 +94,10 @@ public final class GltfClientSetup {
|
||||
}
|
||||
}
|
||||
);
|
||||
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 (ClientTickEvent for keybind toggle).
|
||||
*/
|
||||
/** FORGE bus event subscribers (client-side commands). */
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = "tiedup",
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
@@ -154,12 +106,15 @@ public final class GltfClientSetup {
|
||||
public static class ForgeBusEvents {
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||
if (event.phase != TickEvent.Phase.END) return;
|
||||
|
||||
while (TOGGLE_KEY.consumeClick()) {
|
||||
GltfAnimationApplier.toggle();
|
||||
}
|
||||
public static void onRegisterClientCommands(
|
||||
net.minecraftforge.client.event.RegisterClientCommandsEvent event
|
||||
) {
|
||||
com.tiedup.remake.commands.ValidateGlbCommand.register(
|
||||
event.getDispatcher()
|
||||
);
|
||||
LOGGER.info(
|
||||
"[GltfPipeline] Client command /tiedup validate registered"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import dev.kosmx.playerAnim.core.util.Pair;
|
||||
import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
|
||||
import dev.kosmx.playerAnim.impl.animation.AnimationApplier;
|
||||
@@ -42,6 +43,22 @@ public final class 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.
|
||||
* <p>
|
||||
@@ -57,7 +74,9 @@ public final class GltfLiveBoneReader {
|
||||
* @param model the HumanoidModel after PlayerAnimator has applied rotations
|
||||
* @param data parsed glTF data (MC-converted)
|
||||
* @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(
|
||||
HumanoidModel<?> model,
|
||||
@@ -65,10 +84,17 @@ public final class GltfLiveBoneReader {
|
||||
LivingEntity entity
|
||||
) {
|
||||
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();
|
||||
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
|
||||
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
|
||||
scratchJointMatrices = ensureScratch(scratchJointMatrices, jointCount);
|
||||
scratchWorldTransforms = ensureScratch(
|
||||
scratchWorldTransforms,
|
||||
jointCount
|
||||
);
|
||||
Matrix4f[] jointMatrices = scratchJointMatrices;
|
||||
Matrix4f[] worldTransforms = scratchWorldTransforms;
|
||||
|
||||
int[] parents = data.parentJointIndices();
|
||||
String[] jointNames = data.jointNames();
|
||||
@@ -109,23 +135,22 @@ public final class GltfLiveBoneReader {
|
||||
}
|
||||
|
||||
// Build local transform: translate(restTranslation) * rotate(localRot)
|
||||
Matrix4f local = new Matrix4f();
|
||||
local.translate(restTranslations[j]);
|
||||
local.rotate(localRot);
|
||||
scratchLocal.identity();
|
||||
scratchLocal.translate(restTranslations[j]);
|
||||
scratchLocal.rotate(localRot);
|
||||
|
||||
// Compose with parent to get world transform
|
||||
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
|
||||
worldTransforms[j] = new Matrix4f(
|
||||
worldTransforms[parents[j]]
|
||||
).mul(local);
|
||||
// Compose with parent to get world transform.
|
||||
// Same semantics as pre-refactor: treat as root when parent hasn't
|
||||
// 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 {
|
||||
worldTransforms[j] = new Matrix4f(local);
|
||||
world.set(scratchLocal);
|
||||
}
|
||||
|
||||
// Final joint matrix = worldTransform * inverseBindMatrix
|
||||
jointMatrices[j] = new Matrix4f(worldTransforms[j]).mul(
|
||||
data.inverseBindMatrices()[j]
|
||||
);
|
||||
jointMatrices[j].set(world).mul(data.inverseBindMatrices()[j]);
|
||||
}
|
||||
|
||||
return jointMatrices;
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.mojang.blaze3d.vertex.DefaultVertexFormat;
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import com.mojang.blaze3d.vertex.VertexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
@@ -30,8 +29,8 @@ public final class GltfMeshRenderer extends RenderStateShard {
|
||||
"models/obj/shared/white.png"
|
||||
);
|
||||
|
||||
/** Cached default RenderType (white texture). Created once, reused every frame. */
|
||||
private static RenderType cachedDefaultRenderType;
|
||||
/** Cached default RenderType (white texture). Nulled by clearRenderTypeCache on reload. */
|
||||
private static volatile RenderType cachedDefaultRenderType;
|
||||
|
||||
/** Cache for texture-specific RenderTypes, keyed by ResourceLocation. */
|
||||
private static final Map<ResourceLocation, RenderType> RENDER_TYPE_CACHE =
|
||||
@@ -172,7 +171,8 @@ public final class GltfMeshRenderer extends RenderStateShard {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
GltfData data,
|
||||
@@ -185,45 +185,77 @@ public final class GltfMeshRenderer extends RenderStateShard {
|
||||
) {
|
||||
Matrix4f pose = poseStack.last().pose();
|
||||
Matrix3f normalMat = poseStack.last().normal();
|
||||
|
||||
VertexConsumer vc = buffer.getBuffer(renderType);
|
||||
|
||||
int[] indices = data.indices();
|
||||
float[] texCoords = data.texCoords();
|
||||
VertexScratch s = new VertexScratch();
|
||||
|
||||
float[] outPos = new float[3];
|
||||
float[] outNormal = new float[3];
|
||||
for (int idx : data.indices()) {
|
||||
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();
|
||||
Vector4f tmpNorm = new Vector4f();
|
||||
/**
|
||||
* Scratch buffers reused across every vertex emission in a single render
|
||||
* 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) {
|
||||
// Skin this vertex
|
||||
final float[] outPos = new float[3];
|
||||
final float[] outNormal = new float[3];
|
||||
final Vector4f tmpPos = new Vector4f();
|
||||
final Vector4f tmpNorm = new Vector4f();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
outPos,
|
||||
outNormal,
|
||||
tmpPos,
|
||||
tmpNorm
|
||||
s.outPos,
|
||||
s.outNormal,
|
||||
s.tmpPos,
|
||||
s.tmpNorm
|
||||
);
|
||||
|
||||
// UV coordinates
|
||||
float u = texCoords[idx * 2];
|
||||
float v = texCoords[idx * 2 + 1];
|
||||
|
||||
vc
|
||||
.vertex(pose, outPos[0], outPos[1], outPos[2])
|
||||
.color(255, 255, 255, 255)
|
||||
.vertex(pose, s.outPos[0], s.outPos[1], s.outPos[2])
|
||||
.color(r, g, b, 255)
|
||||
.uv(u, 1.0f - v)
|
||||
.overlayCoords(packedOverlay)
|
||||
.uv2(packedLight)
|
||||
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2])
|
||||
.normal(
|
||||
normalMat,
|
||||
s.outNormal[0],
|
||||
s.outNormal[1],
|
||||
s.outNormal[2]
|
||||
)
|
||||
.endVertex();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a skinned glTF mesh with per-primitive tint colors.
|
||||
@@ -258,22 +290,12 @@ public final class GltfMeshRenderer extends RenderStateShard {
|
||||
) {
|
||||
Matrix4f pose = poseStack.last().pose();
|
||||
Matrix3f normalMat = poseStack.last().normal();
|
||||
|
||||
VertexConsumer vc = buffer.getBuffer(renderType);
|
||||
float[] texCoords = data.texCoords();
|
||||
VertexScratch s = new VertexScratch();
|
||||
|
||||
float[] outPos = new float[3];
|
||||
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;
|
||||
for (GltfData.Primitive prim : data.primitives()) {
|
||||
int r = 255, g = 255, b = 255;
|
||||
if (prim.tintable() && prim.tintChannel() != null) {
|
||||
Integer colorInt = tintColors.get(prim.tintChannel());
|
||||
if (colorInt != null) {
|
||||
@@ -282,29 +304,11 @@ public final class GltfMeshRenderer extends RenderStateShard {
|
||||
b = colorInt & 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
for (int idx : prim.indices()) {
|
||||
GltfSkinningEngine.skinVertex(
|
||||
data,
|
||||
idx,
|
||||
jointMatrices,
|
||||
outPos,
|
||||
outNormal,
|
||||
tmpPos,
|
||||
tmpNorm
|
||||
emitVertex(
|
||||
vc, pose, normalMat, data, jointMatrices, idx, texCoords,
|
||||
r, g, b, packedLight, packedOverlay, s
|
||||
);
|
||||
|
||||
float u = texCoords[idx * 2];
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,59 @@ public final class GltfPoseConverter {
|
||||
|
||||
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() {}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Uses the default (first) animation clip.
|
||||
@@ -123,7 +174,7 @@ public final class GltfPoseConverter {
|
||||
*/
|
||||
private static KeyframeAnimation convertClipSelective(
|
||||
GltfData data,
|
||||
GltfData.AnimationClip rawClip,
|
||||
@Nullable GltfData.AnimationClip rawClip,
|
||||
String animName,
|
||||
Set<String> ownedParts,
|
||||
Set<String> enabledParts
|
||||
@@ -133,25 +184,168 @@ public final class GltfPoseConverter {
|
||||
AnimationFormat.JSON_EMOTECRAFT
|
||||
);
|
||||
|
||||
int endTick = computeEndTick(rawClip);
|
||||
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
|
||||
|
||||
builder.beginTick = 0;
|
||||
builder.endTick = 1;
|
||||
builder.stopTick = 1;
|
||||
builder.endTick = endTick;
|
||||
builder.stopTick = endTick;
|
||||
builder.isLooped = true;
|
||||
builder.returnTick = 0;
|
||||
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();
|
||||
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
|
||||
|
||||
// Track which PlayerAnimator part names received actual animation data
|
||||
Set<String> partsWithKeyframes = new HashSet<>();
|
||||
// Two known bones can map to the same PlayerAnimator part (e.g.
|
||||
// `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++) {
|
||||
String boneName = jointNames[j];
|
||||
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
|
||||
|
||||
// Check if this joint has explicit animation data (not just rest pose fallback).
|
||||
// A bone counts as explicitly animated if it has rotation OR translation keyframes.
|
||||
String animPart = GltfBoneMapper.getAnimPartName(boneName);
|
||||
if (animPart == null) continue;
|
||||
|
||||
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];
|
||||
|
||||
// delta_local = inverse(rest_q) * anim_q (bone-local frame)
|
||||
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
|
||||
// delta_parent = rest * delta_local * inv(rest)
|
||||
Quaternionf deltaParent = new Quaternionf(restQ)
|
||||
.mul(deltaLocal)
|
||||
.mul(new Quaternionf(restQ).invert());
|
||||
// glTF parent frame → MC model-def frame: 180° around Z (negate qx, qy).
|
||||
Quaternionf deltaQ = new Quaternionf(deltaParent);
|
||||
deltaQ.x = -deltaQ.x;
|
||||
deltaQ.y = -deltaQ.y;
|
||||
|
||||
if (isLower) {
|
||||
convertLowerBone(builder, boneName, deltaQ, tick, ease);
|
||||
} else {
|
||||
convertUpperBone(builder, boneName, deltaQ, tick, ease);
|
||||
}
|
||||
|
||||
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 &&
|
||||
@@ -159,72 +353,22 @@ public final class GltfPoseConverter {
|
||||
(rawClip.translations() != null &&
|
||||
j < rawClip.translations().length &&
|
||||
rawClip.translations()[j] != null));
|
||||
|
||||
Quaternionf animQ = getRawAnimQuaternion(
|
||||
rawClip,
|
||||
rawRestRotations,
|
||||
j
|
||||
);
|
||||
Quaternionf restQ = rawRestRotations[j];
|
||||
|
||||
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
|
||||
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
|
||||
|
||||
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
|
||||
Quaternionf deltaParent = new Quaternionf(restQ)
|
||||
.mul(deltaLocal)
|
||||
.mul(new Quaternionf(restQ).invert());
|
||||
|
||||
// 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);
|
||||
deltaQ.x = -deltaQ.x;
|
||||
deltaQ.y = -deltaQ.y;
|
||||
|
||||
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||
convertLowerBone(builder, boneName, deltaQ);
|
||||
} else {
|
||||
convertUpperBone(builder, boneName, deltaQ);
|
||||
}
|
||||
|
||||
// Record which PlayerAnimator part received data
|
||||
if (hasExplicitAnim) {
|
||||
String animPart = GltfBoneMapper.getAnimPartName(boneName);
|
||||
if (animPart != null) {
|
||||
partsWithKeyframes.add(animPart);
|
||||
}
|
||||
// For lower bones, the keyframe data goes to the upper bone's part
|
||||
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||
if (upperBone != null) {
|
||||
keyframeCollector.add(animPart);
|
||||
if (isLower) {
|
||||
String upper = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||
if (upper != null) {
|
||||
String upperPart = GltfBoneMapper.getAnimPartName(
|
||||
upperBone
|
||||
upper
|
||||
);
|
||||
if (upperPart != null) {
|
||||
partsWithKeyframes.add(upperPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selective: enable owned parts always, free parts only if they have keyframes
|
||||
enableSelectiveParts(
|
||||
builder,
|
||||
ownedParts,
|
||||
enabledParts,
|
||||
partsWithKeyframes
|
||||
if (upperPart != null) keyframeCollector.add(
|
||||
upperPart
|
||||
);
|
||||
|
||||
KeyframeAnimation anim = builder.build();
|
||||
LOGGER.debug(
|
||||
"[GltfPipeline] Converted selective animation '{}' (owned: {}, enabled: {}, withKeyframes: {})",
|
||||
animName,
|
||||
ownedParts,
|
||||
enabledParts,
|
||||
partsWithKeyframes
|
||||
);
|
||||
return anim;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,76 +390,25 @@ public final class GltfPoseConverter {
|
||||
@Nullable GltfData.AnimationClip rawClip,
|
||||
Set<String> ownedParts
|
||||
) {
|
||||
String[] jointNames = data.jointNames();
|
||||
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
|
||||
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++) {
|
||||
String boneName = jointNames[j];
|
||||
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
|
||||
|
||||
// Only process bones that belong to this item's owned parts
|
||||
String animPart = GltfBoneMapper.getAnimPartName(boneName);
|
||||
if (animPart == null || !ownedParts.contains(animPart)) continue;
|
||||
|
||||
// For lower bones, check if the UPPER bone's part is owned
|
||||
// (lower bone keyframes go to the upper bone's StateCollection)
|
||||
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||
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));
|
||||
|
||||
Quaternionf animQ = getRawAnimQuaternion(
|
||||
for (int f = 0; f < frameCount; f++) {
|
||||
int tick = frameToTick(rawClip, f, baseline);
|
||||
if (tick == lastTick) continue;
|
||||
applyFrameToBuilder(
|
||||
builder,
|
||||
data,
|
||||
rawClip,
|
||||
rawRestRotations,
|
||||
j
|
||||
f,
|
||||
tick,
|
||||
DEFAULT_EASE,
|
||||
ownedParts,
|
||||
f == 0 ? partsWithKeyframes : null
|
||||
);
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastTick = tick;
|
||||
}
|
||||
|
||||
return partsWithKeyframes;
|
||||
@@ -349,7 +442,7 @@ public final class GltfPoseConverter {
|
||||
*/
|
||||
private static KeyframeAnimation convertClip(
|
||||
GltfData data,
|
||||
GltfData.AnimationClip rawClip,
|
||||
@Nullable GltfData.AnimationClip rawClip,
|
||||
String animName
|
||||
) {
|
||||
KeyframeAnimation.AnimationBuilder builder =
|
||||
@@ -357,123 +450,94 @@ public final class GltfPoseConverter {
|
||||
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.endTick = 1;
|
||||
builder.stopTick = 1;
|
||||
builder.endTick = endTick;
|
||||
builder.stopTick = endTick;
|
||||
builder.isLooped = true;
|
||||
builder.returnTick = 0;
|
||||
builder.name = animName;
|
||||
|
||||
String[] jointNames = data.jointNames();
|
||||
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
|
||||
|
||||
for (int j = 0; j < data.jointCount(); j++) {
|
||||
String boneName = jointNames[j];
|
||||
|
||||
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
|
||||
|
||||
Quaternionf animQ = getRawAnimQuaternion(
|
||||
for (int f = 0; f < frameCount; f++) {
|
||||
int tick = frameToTick(rawClip, f, baseline);
|
||||
if (tick == lastTick) continue;
|
||||
applyFrameToBuilder(
|
||||
builder,
|
||||
data,
|
||||
rawClip,
|
||||
rawRestRotations,
|
||||
j
|
||||
f,
|
||||
tick,
|
||||
DEFAULT_EASE,
|
||||
/* ownedFilter */null,
|
||||
/* keyframeCollector */null
|
||||
);
|
||||
Quaternionf restQ = rawRestRotations[j];
|
||||
|
||||
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
|
||||
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
|
||||
|
||||
// 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);
|
||||
}
|
||||
lastTick = tick;
|
||||
}
|
||||
|
||||
builder.fullyEnableParts();
|
||||
|
||||
KeyframeAnimation anim = builder.build();
|
||||
LOGGER.debug(
|
||||
"[GltfPipeline] Converted glTF animation '{}' to KeyframeAnimation",
|
||||
animName
|
||||
"[GltfPipeline] Converted glTF animation '{}' ({} frames, endTick={})",
|
||||
animName,
|
||||
frameCount,
|
||||
endTick
|
||||
);
|
||||
return anim;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw animation quaternion for a joint from a specific clip.
|
||||
* Falls back to rest rotation if the clip is null or has no data for this joint.
|
||||
* Get the raw animation quaternion for a joint at a specific frame.
|
||||
* 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(
|
||||
GltfData.AnimationClip rawClip,
|
||||
@Nullable GltfData.AnimationClip rawClip,
|
||||
Quaternionf[] rawRestRotations,
|
||||
int jointIndex
|
||||
int jointIndex,
|
||||
int frameIndex
|
||||
) {
|
||||
if (
|
||||
rawClip != null &&
|
||||
jointIndex < rawClip.rotations().length &&
|
||||
rawClip.rotations()[jointIndex] != null
|
||||
) {
|
||||
return rawClip.rotations()[jointIndex][0]; // first frame
|
||||
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(
|
||||
KeyframeAnimation.AnimationBuilder builder,
|
||||
String boneName,
|
||||
Quaternionf deltaQ
|
||||
Quaternionf deltaQ,
|
||||
int tick,
|
||||
Ease ease
|
||||
) {
|
||||
// Decompose delta quaternion to Euler ZYX
|
||||
// JOML's getEulerAnglesZYX stores: euler.x = X rotation, euler.y = Y rotation, euler.z = Z rotation
|
||||
// (the "ZYX" refers to rotation ORDER, not storage order)
|
||||
// "ZYX" is rotation order, not storage: euler.{x,y,z} hold the X/Y/Z
|
||||
// Euler angles for a R = Rz·Ry·Rx decomposition. Gimbal lock at the
|
||||
// middle axis (euler.y = ±90°); see ARTIST_GUIDE.md Common Mistakes.
|
||||
Vector3f euler = new Vector3f();
|
||||
deltaQ.getEulerAnglesZYX(euler);
|
||||
float pitch = euler.x; // X rotation (pitch)
|
||||
float yaw = euler.y; // Y rotation (yaw)
|
||||
float roll = euler.z; // Z rotation (roll)
|
||||
float pitch = euler.x;
|
||||
float yaw = euler.y;
|
||||
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);
|
||||
if (animPart == null) return;
|
||||
|
||||
@@ -483,41 +547,40 @@ public final class GltfPoseConverter {
|
||||
);
|
||||
if (part == null) return;
|
||||
|
||||
part.pitch.addKeyFrame(0, pitch, Ease.CONSTANT);
|
||||
part.yaw.addKeyFrame(0, yaw, Ease.CONSTANT);
|
||||
part.roll.addKeyFrame(0, roll, Ease.CONSTANT);
|
||||
part.pitch.addKeyFrame(tick, pitch, ease);
|
||||
part.yaw.addKeyFrame(tick, yaw, ease);
|
||||
part.roll.addKeyFrame(tick, roll, ease);
|
||||
}
|
||||
|
||||
private static void convertLowerBone(
|
||||
KeyframeAnimation.AnimationBuilder builder,
|
||||
String boneName,
|
||||
Quaternionf deltaQ
|
||||
Quaternionf deltaQ,
|
||||
int tick,
|
||||
Ease ease
|
||||
) {
|
||||
// Extract bend angle and axis from the delta quaternion
|
||||
float angle =
|
||||
2.0f * (float) Math.acos(Math.min(1.0, Math.abs(deltaQ.w)));
|
||||
// Canonicalize q: q and -q represent the same rotation. Always pick the
|
||||
// hemisphere with w >= 0 so consecutive frames don't pop across the
|
||||
// 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;
|
||||
if (deltaQ.x * deltaQ.x + deltaQ.z * deltaQ.z > 0.001f) {
|
||||
bendDirection = (float) Math.atan2(deltaQ.z, deltaQ.x);
|
||||
if (qx * qx + qz * qz > 0.001f) {
|
||||
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);
|
||||
if (upperBone == null) return;
|
||||
|
||||
@@ -530,8 +593,8 @@ public final class GltfPoseConverter {
|
||||
);
|
||||
if (part == null || !part.isBendable) return;
|
||||
|
||||
part.bend.addKeyFrame(0, angle, Ease.CONSTANT);
|
||||
part.bendDirection.addKeyFrame(0, bendDirection, Ease.CONSTANT);
|
||||
part.bend.addKeyFrame(tick, angle, ease);
|
||||
part.bendDirection.addKeyFrame(tick, bendDirection, ease);
|
||||
}
|
||||
|
||||
private static KeyframeAnimation.StateCollection getPartByName(
|
||||
@@ -554,31 +617,110 @@ public final class GltfPoseConverter {
|
||||
*
|
||||
* <ul>
|
||||
* <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 WITHOUT keyframes: disabled (no data to animate, pass through to context)</li>
|
||||
* <li>Free parts WITH keyframes AND "Full" animation: enabled (explicit opt-in to full-body)</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>
|
||||
* </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 ownedParts parts the item explicitly owns (always enabled)
|
||||
* @param enabledParts parts the item may animate (owned + free)
|
||||
* @param partsWithKeyframes parts that received actual animation data from the GLB
|
||||
* @param animName resolved animation name (checked for "Full" prefix)
|
||||
*/
|
||||
private static void enableSelectiveParts(
|
||||
KeyframeAnimation.AnimationBuilder builder,
|
||||
Set<String> ownedParts,
|
||||
Set<String> enabledParts,
|
||||
Set<String> partsWithKeyframes
|
||||
Set<String> partsWithKeyframes,
|
||||
String animName
|
||||
) {
|
||||
String[] allParts = {
|
||||
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",
|
||||
};
|
||||
for (String partName : allParts) {
|
||||
"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
|
||||
@@ -588,13 +730,20 @@ public final class GltfPoseConverter {
|
||||
// Always enable owned parts — the item controls these bones
|
||||
part.fullyEnablePart(false);
|
||||
} else if (
|
||||
isFullBodyAnimation &&
|
||||
enabledParts.contains(partName) &&
|
||||
partsWithKeyframes.contains(partName)
|
||||
partsWithKeyframes.contains(partName) &&
|
||||
(!"head".equals(partName) || allowFreeHead)
|
||||
) {
|
||||
// Free part WITH keyframes: enable so the GLB animation drives it
|
||||
// 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);
|
||||
} 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.
|
||||
part.setEnabled(false);
|
||||
}
|
||||
|
||||
@@ -1,106 +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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.joml.Matrix4f;
|
||||
@@ -11,21 +12,46 @@ import org.joml.Vector4f;
|
||||
* CPU-based Linear Blend Skinning (LBS) engine.
|
||||
* Computes joint matrices purely from glTF data (rest translations + animation rotations).
|
||||
* 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)
|
||||
public final class 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).
|
||||
* Each joint matrix = worldTransform * inverseBindMatrix.
|
||||
* Uses MC-converted glTF data throughout for consistency.
|
||||
*
|
||||
* @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) {
|
||||
assert RenderSystem.isOnRenderThread()
|
||||
: "GltfSkinningEngine.computeJointMatrices must run on the render thread (scratch buffers are not thread-safe)";
|
||||
return computeJointMatricesFromClip(data, data.animation());
|
||||
}
|
||||
|
||||
@@ -40,38 +66,45 @@ public final class GltfSkinningEngine {
|
||||
* @param data the parsed glTF data (MC-converted)
|
||||
* @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)
|
||||
* @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(
|
||||
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();
|
||||
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
|
||||
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
|
||||
scratchJointMatrices = ensureScratch(scratchJointMatrices, jointCount);
|
||||
scratchWorldTransforms = ensureScratch(
|
||||
scratchWorldTransforms,
|
||||
jointCount
|
||||
);
|
||||
Matrix4f[] jointMatrices = scratchJointMatrices;
|
||||
Matrix4f[] worldTransforms = scratchWorldTransforms;
|
||||
|
||||
int[] parents = data.parentJointIndices();
|
||||
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
// Build local transform: translate(interpT) * rotate(interpQ)
|
||||
Matrix4f local = new Matrix4f();
|
||||
local.translate(getInterpolatedTranslation(data, clip, j, time));
|
||||
local.rotate(getInterpolatedRotation(data, clip, j, time));
|
||||
scratchLocal.identity();
|
||||
scratchLocal.translate(getInterpolatedTranslation(data, clip, j, time));
|
||||
scratchLocal.rotate(getInterpolatedRotation(data, clip, j, time));
|
||||
|
||||
// Compose with parent
|
||||
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
|
||||
worldTransforms[j] = new Matrix4f(
|
||||
worldTransforms[parents[j]]
|
||||
).mul(local);
|
||||
// Compose with parent. Same semantics as the previous allocating
|
||||
// code path: only use the parent when its index is already processed
|
||||
// (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 {
|
||||
worldTransforms[j] = new Matrix4f(local);
|
||||
world.set(scratchLocal);
|
||||
}
|
||||
|
||||
// Final joint matrix = worldTransform * inverseBindMatrix
|
||||
jointMatrices[j] = new Matrix4f(worldTransforms[j]).mul(
|
||||
data.inverseBindMatrices()[j]
|
||||
);
|
||||
jointMatrices[j].set(world).mul(data.inverseBindMatrices()[j]);
|
||||
}
|
||||
|
||||
return jointMatrices;
|
||||
@@ -84,31 +117,35 @@ public final class GltfSkinningEngine {
|
||||
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();
|
||||
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
|
||||
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
|
||||
scratchJointMatrices = ensureScratch(scratchJointMatrices, jointCount);
|
||||
scratchWorldTransforms = ensureScratch(
|
||||
scratchWorldTransforms,
|
||||
jointCount
|
||||
);
|
||||
Matrix4f[] jointMatrices = scratchJointMatrices;
|
||||
Matrix4f[] worldTransforms = scratchWorldTransforms;
|
||||
|
||||
int[] parents = data.parentJointIndices();
|
||||
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
// Build local transform: translate(animT or restT) * rotate(animQ or restQ)
|
||||
Matrix4f local = new Matrix4f();
|
||||
local.translate(getAnimTranslation(data, clip, j));
|
||||
local.rotate(getAnimRotation(data, clip, j));
|
||||
scratchLocal.identity();
|
||||
scratchLocal.translate(getAnimTranslation(data, clip, j));
|
||||
scratchLocal.rotate(getAnimRotation(data, clip, j));
|
||||
|
||||
// Compose with parent
|
||||
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
|
||||
worldTransforms[j] = new Matrix4f(
|
||||
worldTransforms[parents[j]]
|
||||
).mul(local);
|
||||
// Compose with parent — see note in computeJointMatricesAnimated.
|
||||
Matrix4f world = worldTransforms[j];
|
||||
if (parents[j] >= 0 && parents[j] < j) {
|
||||
world.set(worldTransforms[parents[j]]).mul(scratchLocal);
|
||||
} else {
|
||||
worldTransforms[j] = new Matrix4f(local);
|
||||
world.set(scratchLocal);
|
||||
}
|
||||
|
||||
// Final joint matrix = worldTransform * inverseBindMatrix
|
||||
jointMatrices[j] = new Matrix4f(worldTransforms[j]).mul(
|
||||
data.inverseBindMatrices()[j]
|
||||
);
|
||||
jointMatrices[j].set(world).mul(data.inverseBindMatrices()[j]);
|
||||
}
|
||||
|
||||
return jointMatrices;
|
||||
@@ -116,7 +153,7 @@ public final class GltfSkinningEngine {
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -125,8 +162,10 @@ public final class GltfSkinningEngine {
|
||||
) {
|
||||
if (
|
||||
clip != null &&
|
||||
clip.rotations() != null &&
|
||||
jointIndex < clip.rotations().length &&
|
||||
clip.rotations()[jointIndex] != null
|
||||
clip.rotations()[jointIndex] != null &&
|
||||
clip.rotations()[jointIndex].length > 0
|
||||
) {
|
||||
return clip.rotations()[jointIndex][0]; // first frame
|
||||
}
|
||||
@@ -135,7 +174,8 @@ public final class GltfSkinningEngine {
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -146,7 +186,8 @@ public final class GltfSkinningEngine {
|
||||
clip != null &&
|
||||
clip.translations() != null &&
|
||||
jointIndex < clip.translations().length &&
|
||||
clip.translations()[jointIndex] != null
|
||||
clip.translations()[jointIndex] != null &&
|
||||
clip.translations()[jointIndex].length > 0
|
||||
) {
|
||||
return clip.translations()[jointIndex][0]; // first frame
|
||||
}
|
||||
@@ -300,10 +341,16 @@ public final class GltfSkinningEngine {
|
||||
sny = 0,
|
||||
snz = 0;
|
||||
|
||||
int jointCount = data.jointCount();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int ji = joints[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];
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ public class ProgressOverlay {
|
||||
*/
|
||||
private static ProgressInfo getActiveProgress(PlayerBindState state) {
|
||||
// Check client tying task
|
||||
PlayerStateTask tyingTask = state.getClientTyingTask();
|
||||
PlayerStateTask tyingTask = state.tasks().getClientTyingTask();
|
||||
if (tyingTask != null && !tyingTask.isOutdated()) {
|
||||
float progress = tyingTask.getProgress();
|
||||
Component text = getTyingText(tyingTask);
|
||||
@@ -142,7 +142,7 @@ public class ProgressOverlay {
|
||||
}
|
||||
|
||||
// Check client untying task
|
||||
PlayerStateTask untyingTask = state.getClientUntyingTask();
|
||||
PlayerStateTask untyingTask = state.tasks().getClientUntyingTask();
|
||||
if (untyingTask != null && !untyingTask.isOutdated()) {
|
||||
float progress = untyingTask.getProgress();
|
||||
Component text = getUntyingText(untyingTask);
|
||||
@@ -150,7 +150,7 @@ public class ProgressOverlay {
|
||||
}
|
||||
|
||||
// Check client feeding task
|
||||
PlayerStateTask feedingTask = state.getClientFeedingTask();
|
||||
PlayerStateTask feedingTask = state.tasks().getClientFeedingTask();
|
||||
if (feedingTask != null && !feedingTask.isOutdated()) {
|
||||
float progress = feedingTask.getProgress();
|
||||
Component text = getFeedingText(feedingTask);
|
||||
|
||||
@@ -198,12 +198,12 @@ public abstract class BaseAdjustmentScreen extends BaseScreen {
|
||||
int totalWidth = buttonWidth * 3 + MARGIN_S * 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)
|
||||
.build();
|
||||
this.addRenderableWidget(resetButton);
|
||||
|
||||
decrementButton = Button.builder(Component.literal("-0.25"), b ->
|
||||
decrementButton = Button.builder(Component.translatable("gui.tiedup.adjustment.btn.decrement"), b ->
|
||||
slider.decrement()
|
||||
)
|
||||
.bounds(
|
||||
@@ -215,7 +215,7 @@ public abstract class BaseAdjustmentScreen extends BaseScreen {
|
||||
.build();
|
||||
this.addRenderableWidget(decrementButton);
|
||||
|
||||
incrementButton = Button.builder(Component.literal("+0.25"), b ->
|
||||
incrementButton = Button.builder(Component.translatable("gui.tiedup.adjustment.btn.increment"), b ->
|
||||
slider.increment()
|
||||
)
|
||||
.bounds(
|
||||
@@ -235,14 +235,14 @@ public abstract class BaseAdjustmentScreen extends BaseScreen {
|
||||
int totalWidth = buttonWidth * 3 + MARGIN_S * 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)
|
||||
)
|
||||
.bounds(scaleStartX, scaleY, buttonWidth, BUTTON_HEIGHT)
|
||||
.build();
|
||||
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)
|
||||
)
|
||||
.bounds(
|
||||
@@ -254,7 +254,7 @@ public abstract class BaseAdjustmentScreen extends BaseScreen {
|
||||
.build();
|
||||
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)
|
||||
)
|
||||
.bounds(
|
||||
|
||||
@@ -565,7 +565,7 @@ public class CellManagerScreen extends BaseScreen {
|
||||
if (cell.prisoners.isEmpty()) {
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
Component.literal(" \u2514\u2500 ")
|
||||
Component.translatable("gui.tiedup.cell_manager.tree_prefix")
|
||||
.append(
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_manager.label.empty"
|
||||
|
||||
@@ -548,12 +548,9 @@ public class CommandWandScreen extends Screen {
|
||||
}
|
||||
|
||||
private void addFollowDistanceCycleButton(int x, int y, int width) {
|
||||
String modeText = switch (followDistanceMode) {
|
||||
case "HEEL" -> "H";
|
||||
case "CLOSE" -> "C";
|
||||
case "FAR" -> "F";
|
||||
default -> "?";
|
||||
};
|
||||
Component modeLabel = Component.translatable(
|
||||
"gui.tiedup.command_wand.follow_distance.abbrev." + followDistanceMode.toLowerCase()
|
||||
);
|
||||
|
||||
Component tooltip = Component.translatable(
|
||||
"gui.tiedup.command_wand.follow_distance.tooltip"
|
||||
@@ -571,7 +568,7 @@ public class CommandWandScreen extends Screen {
|
||||
)
|
||||
);
|
||||
|
||||
Button btn = Button.builder(Component.literal(modeText), b ->
|
||||
Button btn = Button.builder(modeLabel, b ->
|
||||
cycleFollowDistance()
|
||||
)
|
||||
.bounds(x, y, width, BUTTON_HEIGHT)
|
||||
@@ -857,7 +854,7 @@ public class CommandWandScreen extends Screen {
|
||||
) {
|
||||
graphics.renderTooltip(
|
||||
this.font,
|
||||
Component.literal((int) hunger + "%"),
|
||||
Component.translatable("gui.tiedup.command_wand.percent", (int) hunger),
|
||||
mouseX,
|
||||
mouseY
|
||||
);
|
||||
@@ -871,7 +868,7 @@ public class CommandWandScreen extends Screen {
|
||||
) {
|
||||
graphics.renderTooltip(
|
||||
this.font,
|
||||
Component.literal((int) rest + "%"),
|
||||
Component.translatable("gui.tiedup.command_wand.percent", (int) rest),
|
||||
mouseX,
|
||||
mouseY
|
||||
);
|
||||
|
||||
@@ -216,24 +216,24 @@ public class MerchantTradingScreen extends BaseScreen {
|
||||
int goldIngots = countItemInInventory(player, Items.GOLD_INGOT);
|
||||
int goldNuggets = countItemInInventory(player, Items.GOLD_NUGGET);
|
||||
|
||||
Component goldText = Component.literal("Your Gold: ")
|
||||
Component goldText = Component.translatable("gui.tiedup.merchant.your_gold")
|
||||
.append(
|
||||
Component.literal(goldIngots + "x ").withStyle(style ->
|
||||
Component.translatable("gui.tiedup.merchant.gold_amount", goldIngots).withStyle(style ->
|
||||
style.withColor(0xFFFFD700)
|
||||
)
|
||||
)
|
||||
.append(
|
||||
Component.literal("⚜ ").withStyle(style ->
|
||||
Component.translatable("gui.tiedup.merchant.gold_icon").withStyle(style ->
|
||||
style.withColor(0xFFFFD700)
|
||||
)
|
||||
)
|
||||
.append(
|
||||
Component.literal("+ " + goldNuggets + "x ").withStyle(
|
||||
Component.translatable("gui.tiedup.merchant.nugget_amount", goldNuggets).withStyle(
|
||||
style -> style.withColor(0xFFFFA500)
|
||||
)
|
||||
)
|
||||
.append(
|
||||
Component.literal("✦").withStyle(style ->
|
||||
Component.translatable("gui.tiedup.merchant.nugget_icon").withStyle(style ->
|
||||
style.withColor(0xFFFFA500)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ public class NpcInventoryScreen
|
||||
Inventory playerInventory,
|
||||
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
|
||||
this.npcRows = (menu.getNpcSlotCount() + 8) / 9;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.tiedup.remake.client.gui.screens;
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
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.slave.PacketSlaveAction;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
@@ -145,8 +145,8 @@ public class SlaveManagementScreen extends BaseScreen {
|
||||
ItemStack collarStack = kidnapped.getEquipment(
|
||||
BodyRegionV2.NECK
|
||||
);
|
||||
if (collarStack.getItem() instanceof ItemCollar collar) {
|
||||
if (collar.isOwner(collarStack, player)) {
|
||||
if (CollarHelper.isCollar(collarStack)) {
|
||||
if (CollarHelper.isOwner(collarStack, player)) {
|
||||
addSlaveEntry(kidnapped);
|
||||
addedUUIDs.add(entity.getUUID());
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import com.tiedup.remake.items.ItemLockpick;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.items.base.IHasResistance;
|
||||
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.action.PacketSetKnifeCutTarget;
|
||||
import com.tiedup.remake.network.minigame.PacketLockpickMiniGameStart;
|
||||
@@ -86,7 +86,7 @@ public class ActionPanel extends AbstractWidget {
|
||||
private int hoveredIndex = -1;
|
||||
|
||||
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) {
|
||||
@@ -474,10 +474,10 @@ public class ActionPanel extends AbstractWidget {
|
||||
// Bondage Service toggle (NECK collar only, prison configured)
|
||||
if (
|
||||
selectedRegion == BodyRegionV2.NECK &&
|
||||
selectedItem.getItem() instanceof ItemCollar collar
|
||||
CollarHelper.isCollar(selectedItem)
|
||||
) {
|
||||
if (collar.hasCellAssigned(selectedItem)) {
|
||||
boolean svcEnabled = collar.isBondageServiceEnabled(
|
||||
if (CollarHelper.hasCellAssigned(selectedItem)) {
|
||||
boolean svcEnabled = CollarHelper.isBondageServiceEnabled(
|
||||
selectedItem
|
||||
);
|
||||
String svcKey = svcEnabled
|
||||
|
||||
@@ -51,7 +51,7 @@ public class ItemPickerOverlay extends AbstractWidget {
|
||||
private int screenHeight;
|
||||
|
||||
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.visible = false;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ public class RegionTabBar extends AbstractWidget {
|
||||
private LivingEntity targetEntity;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -4,8 +4,7 @@ import static com.tiedup.remake.client.gui.util.GuiLayoutConstants.*;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.items.ItemGpsCollar;
|
||||
import com.tiedup.remake.items.base.ItemCollar;
|
||||
import com.tiedup.remake.v2.bondage.CollarHelper;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import java.util.ArrayList;
|
||||
@@ -370,9 +369,8 @@ public class SlaveEntryWidget
|
||||
// GPS zone status (right of health)
|
||||
if (hasGPSCollar()) {
|
||||
ItemStack collarStack = slave.getEquipment(BodyRegionV2.NECK);
|
||||
if (collarStack.getItem() instanceof ItemGpsCollar gps) {
|
||||
if (CollarHelper.hasGPS(collarStack)) {
|
||||
boolean inSafeZone = isInAnySafeZone(
|
||||
gps,
|
||||
collarStack,
|
||||
entity
|
||||
);
|
||||
@@ -560,33 +558,35 @@ public class SlaveEntryWidget
|
||||
private boolean hasShockCollar() {
|
||||
if (!slave.hasCollar()) return false;
|
||||
ItemStack collar = slave.getEquipment(BodyRegionV2.NECK);
|
||||
return (
|
||||
collar.getItem() instanceof ItemCollar itemCollar &&
|
||||
itemCollar.canShock()
|
||||
);
|
||||
return CollarHelper.canShock(collar);
|
||||
}
|
||||
|
||||
private boolean hasGPSCollar() {
|
||||
if (!slave.hasCollar()) return false;
|
||||
ItemStack collar = slave.getEquipment(BodyRegionV2.NECK);
|
||||
return (
|
||||
collar.getItem() instanceof ItemCollar itemCollar &&
|
||||
itemCollar.hasGPS()
|
||||
);
|
||||
return CollarHelper.hasGPS(collar);
|
||||
}
|
||||
|
||||
private boolean isInAnySafeZone(
|
||||
ItemGpsCollar gps,
|
||||
ItemStack collarStack,
|
||||
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;
|
||||
|
||||
for (var spot : safeSpots) {
|
||||
if (spot.isInside(entity)) {
|
||||
for (int i = 0; i < safeSpots.size(); i++) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ public class StatusBarWidget extends AbstractWidget {
|
||||
private static final int CLOSE_BTN_HEIGHT = 22;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -9,8 +9,9 @@ import com.tiedup.remake.entities.AbstractTiedUpNpc;
|
||||
import com.tiedup.remake.entities.EntityKidnapperArcher;
|
||||
import com.tiedup.remake.entities.EntityMaster;
|
||||
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.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.bondage.capability.V2EquipmentHelper;
|
||||
@@ -230,11 +231,7 @@ public class DamselModel
|
||||
|
||||
if (inPose) {
|
||||
ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS);
|
||||
PoseType poseType = PoseType.STANDARD;
|
||||
|
||||
if (bind.getItem() instanceof ItemBind itemBind) {
|
||||
poseType = itemBind.getPoseType();
|
||||
}
|
||||
PoseType poseType = PoseTypeHelper.getPoseType(bind);
|
||||
|
||||
// Hide arms for wrap/latex_sack poses
|
||||
if (poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK) {
|
||||
@@ -252,9 +249,7 @@ public class DamselModel
|
||||
PoseType currentPoseType = PoseType.STANDARD;
|
||||
if (inPose) {
|
||||
ItemStack bindForPoseType = entity.getEquipment(BodyRegionV2.ARMS);
|
||||
if (bindForPoseType.getItem() instanceof ItemBind itemBindForType) {
|
||||
currentPoseType = itemBindForType.getPoseType();
|
||||
}
|
||||
currentPoseType = PoseTypeHelper.getPoseType(bindForPoseType);
|
||||
}
|
||||
|
||||
// Check if this is a Master in human chair mode (head should look around freely)
|
||||
@@ -306,11 +301,7 @@ public class DamselModel
|
||||
// Animation not yet active (1-frame delay) - apply static pose as fallback
|
||||
// This ensures immediate visual feedback when bind is applied
|
||||
ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS);
|
||||
PoseType fallbackPoseType = PoseType.STANDARD;
|
||||
|
||||
if (bind.getItem() instanceof ItemBind itemBind) {
|
||||
fallbackPoseType = itemBind.getPoseType();
|
||||
}
|
||||
PoseType fallbackPoseType = PoseTypeHelper.getPoseType(bind);
|
||||
|
||||
// Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder)
|
||||
boolean armsBound = V2EquipmentHelper.isRegionOccupied(
|
||||
@@ -323,10 +314,10 @@ public class DamselModel
|
||||
);
|
||||
|
||||
if (
|
||||
!armsBound && !legsBound && bind.getItem() instanceof ItemBind
|
||||
!armsBound && !legsBound && BindModeHelper.isBindItem(bind)
|
||||
) {
|
||||
armsBound = ItemBind.hasArmsBound(bind);
|
||||
legsBound = ItemBind.hasLegsBound(bind);
|
||||
armsBound = BindModeHelper.hasArmsBound(bind);
|
||||
legsBound = BindModeHelper.hasLegsBound(bind);
|
||||
}
|
||||
|
||||
// Apply static pose directly to model parts
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.joml.Matrix4f;
|
||||
*/
|
||||
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"
|
||||
);
|
||||
private static final RenderType RENDER_TYPE = RenderType.entityCutout(
|
||||
|
||||
@@ -69,8 +69,8 @@ public class BountyCommand {
|
||||
// Cannot bounty yourself
|
||||
if (player.getUUID().equals(target.getUUID())) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
"You cannot put a bounty on yourself!"
|
||||
Component.translatable(
|
||||
"command.tiedup.bounty.cannot_self"
|
||||
).withStyle(ChatFormatting.RED)
|
||||
);
|
||||
return 0;
|
||||
@@ -80,8 +80,8 @@ public class BountyCommand {
|
||||
IBondageState playerState = KidnappedHelper.getKidnappedState(player);
|
||||
if (playerState != null && playerState.isTiedUp()) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
"You cannot create bounties while tied up!"
|
||||
Component.translatable(
|
||||
"command.tiedup.bounty.tied_up"
|
||||
).withStyle(ChatFormatting.RED)
|
||||
);
|
||||
return 0;
|
||||
@@ -96,8 +96,8 @@ public class BountyCommand {
|
||||
player.serverLevel().getGameRules()
|
||||
);
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
"Maximum number (" + max + ") of active bounties reached!"
|
||||
Component.translatable(
|
||||
"command.tiedup.bounty.max_reached", max
|
||||
).withStyle(ChatFormatting.RED)
|
||||
);
|
||||
return 0;
|
||||
@@ -107,8 +107,8 @@ public class BountyCommand {
|
||||
ItemStack heldItem = player.getMainHandItem();
|
||||
if (heldItem.isEmpty()) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
"You must hold an item as the reward!"
|
||||
Component.translatable(
|
||||
"command.tiedup.bounty.must_hold_item"
|
||||
).withStyle(ChatFormatting.RED)
|
||||
);
|
||||
return 0;
|
||||
@@ -143,8 +143,8 @@ public class BountyCommand {
|
||||
// Notify player
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Bounty created on " + target.getName().getString() + "!"
|
||||
Component.translatable(
|
||||
"command.tiedup.bounty.created", target.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
false
|
||||
);
|
||||
@@ -153,12 +153,10 @@ public class BountyCommand {
|
||||
player.server
|
||||
.getPlayerList()
|
||||
.broadcastSystemMessage(
|
||||
Component.literal(
|
||||
"[Bounty] " +
|
||||
player.getName().getString() +
|
||||
" has put a bounty on " +
|
||||
target.getName().getString() +
|
||||
"!"
|
||||
Component.translatable(
|
||||
"command.tiedup.bounty.broadcast",
|
||||
player.getName().getString(),
|
||||
target.getName().getString()
|
||||
).withStyle(ChatFormatting.GOLD),
|
||||
false
|
||||
);
|
||||
|
||||
@@ -117,9 +117,9 @@ public class CaptivityDebugCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"=== Captivity Debug Info ===\n" + debugInfo
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
Component.translatable(
|
||||
"command.tiedup.debug.prisoner_header"
|
||||
).append(Component.literal("\n" + debugInfo)).withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
|
||||
@@ -128,7 +128,7 @@ public class CaptivityDebugCommand {
|
||||
ctx
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("Error: " + e.getMessage()).withStyle(
|
||||
Component.translatable("command.tiedup.debug.error", e.getMessage()).withStyle(
|
||||
ChatFormatting.RED
|
||||
)
|
||||
);
|
||||
@@ -149,8 +149,8 @@ public class CaptivityDebugCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Checking captivity system..."
|
||||
Component.translatable(
|
||||
"command.tiedup.debug.validate_checking"
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
true
|
||||
);
|
||||
@@ -163,9 +163,7 @@ public class CaptivityDebugCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(debugInfo).withStyle(
|
||||
ChatFormatting.GREEN
|
||||
),
|
||||
Component.literal(debugInfo).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
|
||||
@@ -174,8 +172,8 @@ public class CaptivityDebugCommand {
|
||||
ctx
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal(
|
||||
"Error during validation: " + e.getMessage()
|
||||
Component.translatable(
|
||||
"command.tiedup.debug.error", e.getMessage()
|
||||
).withStyle(ChatFormatting.RED)
|
||||
);
|
||||
return 0; // Failure
|
||||
@@ -193,8 +191,8 @@ public class CaptivityDebugCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Repair functionality has been simplified with the new PrisonerManager system."
|
||||
Component.translatable(
|
||||
"command.tiedup.debug.repair_simplified"
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
true
|
||||
);
|
||||
@@ -203,8 +201,8 @@ public class CaptivityDebugCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"The new system maintains consistency automatically."
|
||||
Component.translatable(
|
||||
"command.tiedup.debug.repair_auto"
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
@@ -214,7 +212,7 @@ public class CaptivityDebugCommand {
|
||||
ctx
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("Error: " + e.getMessage()).withStyle(
|
||||
Component.translatable("command.tiedup.debug.error", e.getMessage()).withStyle(
|
||||
ChatFormatting.RED
|
||||
)
|
||||
);
|
||||
@@ -251,8 +249,8 @@ public class CaptivityDebugCommand {
|
||||
ctx
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal(
|
||||
"No camp found with ID prefix: " + campIdPrefix
|
||||
Component.translatable(
|
||||
"command.tiedup.debug.camp_not_found", campIdPrefix
|
||||
).withStyle(ChatFormatting.RED)
|
||||
);
|
||||
return 0;
|
||||
@@ -322,7 +320,7 @@ public class CaptivityDebugCommand {
|
||||
ctx
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("Error: " + e.getMessage()).withStyle(
|
||||
Component.translatable("command.tiedup.debug.error", e.getMessage()).withStyle(
|
||||
ChatFormatting.RED
|
||||
)
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.tiedup.remake.items.ItemAdminWand;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.arguments.EntityArgument;
|
||||
@@ -110,7 +111,7 @@ public class CellCommand {
|
||||
|
||||
// Must be a player
|
||||
if (!(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("Must be a player"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.error.must_be_player"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -121,9 +122,7 @@ public class CellCommand {
|
||||
UUID selectedCellId = getSelectedCellFromWand(player);
|
||||
if (selectedCellId == null) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
"No cell selected. Use the Admin Wand on a Cell Core first."
|
||||
)
|
||||
Component.translatable("command.tiedup.cell.no_selection")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -131,7 +130,7 @@ public class CellCommand {
|
||||
CellDataV2 cell = registry.getCell(selectedCellId);
|
||||
if (cell == null) {
|
||||
source.sendFailure(
|
||||
Component.literal("Selected cell no longer exists")
|
||||
Component.translatable("command.tiedup.cell.no_longer_exists")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -142,7 +141,7 @@ public class CellCommand {
|
||||
existingCell != null && !existingCell.getId().equals(selectedCellId)
|
||||
) {
|
||||
source.sendFailure(
|
||||
Component.literal("Cell name '" + name + "' already exists")
|
||||
Component.translatable("command.tiedup.cell.name_exists", name)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -161,9 +160,7 @@ public class CellCommand {
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Named cell '" + name + "' and linked to you"
|
||||
),
|
||||
Component.translatable("command.tiedup.cell.named", name).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -182,20 +179,20 @@ public class CellCommand {
|
||||
Collection<CellDataV2> cells = registry.getAllCells();
|
||||
if (cells.isEmpty()) {
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("No cells registered"),
|
||||
() -> Component.translatable("command.tiedup.cell.none_registered"),
|
||||
false
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("=== Cells (" + cells.size() + ") ==="),
|
||||
() -> Component.translatable("command.tiedup.cell.list_header", cells.size()).withStyle(ChatFormatting.GOLD),
|
||||
false
|
||||
);
|
||||
|
||||
for (CellDataV2 cell : cells) {
|
||||
String info = formatCellInfo(cell, serverLevel);
|
||||
source.sendSuccess(() -> Component.literal(info), false);
|
||||
source.sendSuccess(() -> Component.literal(info).withStyle(ChatFormatting.GRAY), false);
|
||||
}
|
||||
|
||||
return 1;
|
||||
@@ -217,9 +214,7 @@ public class CellCommand {
|
||||
if (cells.isEmpty()) {
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
owner.getName().getString() + " has no cells"
|
||||
),
|
||||
Component.translatable("command.tiedup.cell.no_cells_for_owner", owner.getName().getString()),
|
||||
false
|
||||
);
|
||||
return 1;
|
||||
@@ -227,19 +222,17 @@ public class CellCommand {
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"=== Cells owned by " +
|
||||
owner.getName().getString() +
|
||||
" (" +
|
||||
cells.size() +
|
||||
") ==="
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.cell.list_owner_header",
|
||||
owner.getName().getString(),
|
||||
cells.size()
|
||||
).withStyle(ChatFormatting.GOLD),
|
||||
false
|
||||
);
|
||||
|
||||
for (CellDataV2 cell : cells) {
|
||||
String info = formatCellInfo(cell, serverLevel);
|
||||
source.sendSuccess(() -> Component.literal(info), false);
|
||||
source.sendSuccess(() -> Component.literal(info).withStyle(ChatFormatting.GRAY), false);
|
||||
}
|
||||
|
||||
return 1;
|
||||
@@ -257,7 +250,7 @@ public class CellCommand {
|
||||
|
||||
// Must be a player
|
||||
if (!(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("Must be a player"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.error.must_be_player"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -267,9 +260,7 @@ public class CellCommand {
|
||||
UUID selectedCellId = getSelectedCellFromWand(player);
|
||||
if (selectedCellId == null) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
"No cell selected. Use the Admin Wand on a Cell Core first."
|
||||
)
|
||||
Component.translatable("command.tiedup.cell.no_selection")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -277,7 +268,7 @@ public class CellCommand {
|
||||
CellDataV2 cell = registry.getCell(selectedCellId);
|
||||
if (cell == null) {
|
||||
source.sendFailure(
|
||||
Component.literal("Selected cell no longer exists")
|
||||
Component.translatable("command.tiedup.cell.no_longer_exists")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -300,7 +291,7 @@ public class CellCommand {
|
||||
CellDataV2 cell = registry.getCellByName(name);
|
||||
if (cell == null) {
|
||||
source.sendFailure(
|
||||
Component.literal("Cell '" + name + "' not found")
|
||||
Component.translatable("command.tiedup.cell.not_found", name)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -321,7 +312,7 @@ public class CellCommand {
|
||||
|
||||
// Must be a player
|
||||
if (!(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("Must be a player"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.error.must_be_player"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -331,9 +322,7 @@ public class CellCommand {
|
||||
UUID selectedCellId = getSelectedCellFromWand(player);
|
||||
if (selectedCellId == null) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
"No cell selected. Use the Admin Wand on a Cell Core first."
|
||||
)
|
||||
Component.translatable("command.tiedup.cell.no_selection")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -341,7 +330,7 @@ public class CellCommand {
|
||||
CellDataV2 cell = registry.getCell(selectedCellId);
|
||||
if (cell == null) {
|
||||
source.sendFailure(
|
||||
Component.literal("Selected cell no longer exists")
|
||||
Component.translatable("command.tiedup.cell.no_longer_exists")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -365,7 +354,7 @@ public class CellCommand {
|
||||
}
|
||||
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("Deleted cell '" + cellName + "'"),
|
||||
() -> Component.translatable("command.tiedup.cell.deleted", cellName).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -438,24 +427,16 @@ public class CellCommand {
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Reset " +
|
||||
finalResetCount +
|
||||
" spawn markers (found " +
|
||||
finalSpawnMarkerCount +
|
||||
" total spawn markers in " +
|
||||
radius +
|
||||
" block radius)"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.cell.reset_spawns", finalResetCount, finalSpawnMarkerCount, radius
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
|
||||
if (resetCount > 0) {
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"You can now save the structure - NPCs will spawn when it's placed."
|
||||
),
|
||||
Component.translatable("command.tiedup.cell.reset_spawns_hint").withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
}
|
||||
@@ -541,25 +522,25 @@ public class CellCommand {
|
||||
String nameDisplay =
|
||||
cell.getName() != null ? cell.getName() : "(unnamed)";
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("=== Cell: " + nameDisplay + " ==="),
|
||||
() -> Component.translatable("command.tiedup.cell.info_header", nameDisplay).withStyle(ChatFormatting.GOLD),
|
||||
false
|
||||
);
|
||||
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("ID: " + cell.getId().toString()),
|
||||
() -> Component.translatable("command.tiedup.cell.info_id", cell.getId().toString()).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("State: " + cell.getState()),
|
||||
() -> Component.translatable("command.tiedup.cell.info_state", cell.getState().toString()).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Core Position: " + cell.getCorePos().toShortString()
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.cell.info_core_pos", cell.getCorePos().toShortString()
|
||||
).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
|
||||
@@ -567,9 +548,9 @@ public class CellCommand {
|
||||
if (cell.getSpawnPoint() != null) {
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Spawn Point: " + cell.getSpawnPoint().toShortString()
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.cell.info_spawn_point", cell.getSpawnPoint().toShortString()
|
||||
).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
}
|
||||
@@ -584,18 +565,16 @@ public class CellCommand {
|
||||
owner != null ? owner.getName().getString() : "(offline)";
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Owner: " +
|
||||
ownerName +
|
||||
" (" +
|
||||
cell.getOwnerId().toString().substring(0, 8) +
|
||||
"...)"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.cell.info_owner",
|
||||
ownerName,
|
||||
cell.getOwnerId().toString().substring(0, 8) + "..."
|
||||
).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("Owner: (world-generated)"),
|
||||
() -> Component.translatable("command.tiedup.cell.info_owner_world").withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
}
|
||||
@@ -603,16 +582,16 @@ public class CellCommand {
|
||||
// Geometry
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Interior blocks: " + cell.getInteriorBlocks().size()
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.cell.info_interior", cell.getInteriorBlocks().size()
|
||||
).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Wall blocks: " + cell.getWallBlocks().size()
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.cell.info_walls", cell.getWallBlocks().size()
|
||||
).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
|
||||
@@ -620,16 +599,11 @@ public class CellCommand {
|
||||
if (!cell.getBreachedPositions().isEmpty()) {
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Breaches: " +
|
||||
cell.getBreachedPositions().size() +
|
||||
" (" +
|
||||
String.format(
|
||||
"%.1f",
|
||||
cell.getBreachPercentage() * 100
|
||||
) +
|
||||
"%)"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.cell.info_breaches",
|
||||
cell.getBreachedPositions().size(),
|
||||
String.format("%.1f", cell.getBreachPercentage() * 100)
|
||||
).withStyle(ChatFormatting.RED),
|
||||
false
|
||||
);
|
||||
}
|
||||
@@ -637,19 +611,19 @@ public class CellCommand {
|
||||
// Features
|
||||
if (!cell.getBeds().isEmpty()) {
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("Beds: " + cell.getBeds().size()),
|
||||
() -> Component.translatable("command.tiedup.cell.info_beds", cell.getBeds().size()).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
}
|
||||
if (!cell.getAnchors().isEmpty()) {
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("Anchors: " + cell.getAnchors().size()),
|
||||
() -> Component.translatable("command.tiedup.cell.info_anchors", cell.getAnchors().size()).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
}
|
||||
if (!cell.getDoors().isEmpty()) {
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("Doors: " + cell.getDoors().size()),
|
||||
() -> Component.translatable("command.tiedup.cell.info_doors", cell.getDoors().size()).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
}
|
||||
@@ -657,9 +631,9 @@ public class CellCommand {
|
||||
// Prisoners
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Prisoners: " + cell.getPrisonerCount() + "/4"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.cell.info_prisoners", cell.getPrisonerCount()
|
||||
).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
for (UUID prisonerId : cell.getPrisonerIds()) {
|
||||
@@ -670,7 +644,7 @@ public class CellCommand {
|
||||
String prisonerName =
|
||||
prisoner != null ? prisoner.getName().getString() : "(offline)";
|
||||
source.sendSuccess(
|
||||
() -> Component.literal(" - " + prisonerName),
|
||||
() -> Component.literal(" - " + prisonerName).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.tiedup.remake.cells.CellDataV2;
|
||||
import com.tiedup.remake.cells.CellRegistryV2;
|
||||
import com.tiedup.remake.items.base.ItemCollar;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.bondage.CollarHelper;
|
||||
import com.tiedup.remake.util.teleport.Position;
|
||||
import com.tiedup.remake.util.teleport.TeleportHelper;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.arguments.EntityArgument;
|
||||
@@ -152,30 +153,26 @@ public class CollarCommand {
|
||||
ItemStack collar = getPlayerCollar(target);
|
||||
if (collar.isEmpty()) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " does not have a collar"
|
||||
)
|
||||
Component.translatable("command.tiedup.collar_cmd.no_collar", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (source.getEntity() instanceof ServerPlayer executor) {
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
collarItem.addOwner(collar, executor);
|
||||
if (CollarHelper.isCollar(collar)) {
|
||||
CollarHelper.addOwner(collar, executor);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aClaimed " +
|
||||
target.getName().getString() +
|
||||
"'s collar"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.collar_cmd.claimed", target.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
source.sendFailure(Component.literal("Failed to claim collar"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.collar_cmd.claim_failed"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -187,30 +184,26 @@ public class CollarCommand {
|
||||
ItemStack collar = getPlayerCollar(target);
|
||||
if (collar.isEmpty()) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " does not have a collar"
|
||||
)
|
||||
Component.translatable("command.tiedup.collar_cmd.no_collar", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (source.getEntity() instanceof ServerPlayer executor) {
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
collarItem.removeOwner(collar, executor.getUUID());
|
||||
if (CollarHelper.isCollar(collar)) {
|
||||
CollarHelper.removeOwner(collar, executor.getUUID());
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aRemoved your ownership from " +
|
||||
target.getName().getString() +
|
||||
"'s collar"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.collar_cmd.unclaimed", target.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
source.sendFailure(Component.literal("Failed to unclaim collar"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.collar_cmd.unclaim_failed"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -223,20 +216,18 @@ public class CollarCommand {
|
||||
ItemStack collar = getPlayerCollar(target);
|
||||
if (collar.isEmpty()) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " does not have a collar"
|
||||
)
|
||||
Component.translatable("command.tiedup.collar_cmd.no_collar", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
collarItem.setNickname(collar, name);
|
||||
if (CollarHelper.isCollar(collar)) {
|
||||
CollarHelper.setNickname(collar, name);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aSet collar nickname to '" + name + "'"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.collar_cmd.renamed", name
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -254,24 +245,20 @@ public class CollarCommand {
|
||||
ItemStack collar = getPlayerCollar(target);
|
||||
if (collar.isEmpty()) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " does not have a collar"
|
||||
)
|
||||
Component.translatable("command.tiedup.collar_cmd.no_collar", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
collarItem.addOwner(collar, owner);
|
||||
if (CollarHelper.isCollar(collar)) {
|
||||
CollarHelper.addOwner(collar, owner);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aAdded " +
|
||||
owner.getName().getString() +
|
||||
" as owner of " +
|
||||
target.getName().getString() +
|
||||
"'s collar"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.collar_cmd.owner_added",
|
||||
owner.getName().getString(),
|
||||
target.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -289,24 +276,20 @@ public class CollarCommand {
|
||||
ItemStack collar = getPlayerCollar(target);
|
||||
if (collar.isEmpty()) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " does not have a collar"
|
||||
)
|
||||
Component.translatable("command.tiedup.collar_cmd.no_collar", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
collarItem.removeOwner(collar, owner.getUUID());
|
||||
if (CollarHelper.isCollar(collar)) {
|
||||
CollarHelper.removeOwner(collar, owner.getUUID());
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aRemoved " +
|
||||
owner.getName().getString() +
|
||||
" as owner of " +
|
||||
target.getName().getString() +
|
||||
"'s collar"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.collar_cmd.owner_removed",
|
||||
owner.getName().getString(),
|
||||
target.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -329,9 +312,7 @@ public class CollarCommand {
|
||||
ItemStack collar = getPlayerCollar(target);
|
||||
if (collar.isEmpty()) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " does not have a collar"
|
||||
)
|
||||
Component.translatable("command.tiedup.collar_cmd.no_collar", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -343,22 +324,20 @@ public class CollarCommand {
|
||||
|
||||
if (cell == null) {
|
||||
source.sendFailure(
|
||||
Component.literal("Cell '" + cellName + "' not found")
|
||||
Component.translatable("command.tiedup.collar_cmd.cell_not_found", cellName)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
collarItem.setCellId(collar, cell.getId());
|
||||
if (CollarHelper.isCollar(collar)) {
|
||||
CollarHelper.setCellId(collar, cell.getId());
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aAssigned cell '" +
|
||||
cellName +
|
||||
"' to " +
|
||||
target.getName().getString() +
|
||||
"'s collar"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.collar_cmd.cell_assigned",
|
||||
cellName,
|
||||
target.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -381,29 +360,27 @@ public class CollarCommand {
|
||||
ItemStack collar = getPlayerCollar(target);
|
||||
if (collar.isEmpty()) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " does not have a collar"
|
||||
)
|
||||
Component.translatable("command.tiedup.collar_cmd.no_collar", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
if (!collarItem.hasCellAssigned(collar)) {
|
||||
if (CollarHelper.isCollar(collar)) {
|
||||
if (!CollarHelper.hasCellAssigned(collar)) {
|
||||
source.sendFailure(
|
||||
Component.literal("No cell assigned to collar")
|
||||
Component.translatable("command.tiedup.collar_cmd.no_cell_assigned")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get cell position and teleport
|
||||
java.util.UUID cellId = collarItem.getCellId(collar);
|
||||
java.util.UUID cellId = CollarHelper.getCellId(collar);
|
||||
ServerLevel serverLevel = source.getLevel();
|
||||
CellDataV2 cell = CellRegistryV2.get(serverLevel).getCell(cellId);
|
||||
|
||||
if (cell == null) {
|
||||
source.sendFailure(
|
||||
Component.literal("Assigned cell no longer exists")
|
||||
Component.translatable("command.tiedup.collar_cmd.cell_deleted")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -420,12 +397,11 @@ public class CollarCommand {
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aTeleported " +
|
||||
target.getName().getString() +
|
||||
" to cell at " +
|
||||
Component.translatable(
|
||||
"command.tiedup.collar_cmd.teleported",
|
||||
target.getName().getString(),
|
||||
cell.getCorePos().toShortString()
|
||||
),
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -442,42 +418,35 @@ public class CollarCommand {
|
||||
ItemStack collar = getPlayerCollar(target);
|
||||
if (collar.isEmpty()) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " does not have a collar"
|
||||
)
|
||||
Component.translatable("command.tiedup.collar_cmd.no_collar", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
if (CollarHelper.isCollar(collar)) {
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§6=== Collar Info for " +
|
||||
target.getName().getString() +
|
||||
" ==="
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.collar_cmd.info_header",
|
||||
target.getName().getString()
|
||||
).withStyle(ChatFormatting.GOLD),
|
||||
false
|
||||
);
|
||||
|
||||
String nickname = collarItem.getNickname(collar);
|
||||
String nickname = CollarHelper.getNickname(collar);
|
||||
String nicknameDisplay = (nickname == null || nickname.isEmpty()) ? "None" : nickname;
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§7Nickname: §f" +
|
||||
(nickname.isEmpty() ? "None" : nickname)
|
||||
),
|
||||
Component.translatable("command.tiedup.collar_cmd.info_nickname", nicknameDisplay).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§7Has Owner: §f" + collarItem.hasOwner(collar)
|
||||
),
|
||||
Component.translatable("command.tiedup.collar_cmd.info_has_owner", String.valueOf(CollarHelper.hasOwner(collar))).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
// Cell assignment
|
||||
java.util.UUID cellId = collarItem.getCellId(collar);
|
||||
java.util.UUID cellId = CollarHelper.getCellId(collar);
|
||||
if (cellId != null) {
|
||||
ServerLevel serverLevel = source.getLevel();
|
||||
CellRegistryV2 registry = CellRegistryV2.get(serverLevel);
|
||||
@@ -489,32 +458,33 @@ public class CollarCommand {
|
||||
: cellId.toString().substring(0, 8) + "...";
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§7Assigned Cell: §a" +
|
||||
cellDisplay +
|
||||
" §7@ " +
|
||||
Component.translatable(
|
||||
"command.tiedup.collar_cmd.info_cell",
|
||||
cellDisplay,
|
||||
cell.getCorePos().toShortString()
|
||||
),
|
||||
).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("§7Assigned Cell: §c(deleted)"),
|
||||
() -> Component.translatable("command.tiedup.collar_cmd.info_cell_deleted").withStyle(ChatFormatting.RED),
|
||||
false
|
||||
);
|
||||
}
|
||||
} else {
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("§7Assigned Cell: §fNone"),
|
||||
() -> Component.translatable("command.tiedup.collar_cmd.info_cell_none").withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
boolean locked = collar.getItem() instanceof com.tiedup.remake.items.base.ILockable lockable
|
||||
&& lockable.isLocked(collar);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§7Locked: §f" + collarItem.isLocked(collar)
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.collar_cmd.info_locked", String.valueOf(locked)
|
||||
).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ public final class CommandHelper {
|
||||
if (source.getEntity() instanceof ServerPlayer player) {
|
||||
return Optional.of(player);
|
||||
}
|
||||
source.sendFailure(Component.literal("Must be a player"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.error.must_be_player"));
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.arguments.EntityArgument;
|
||||
@@ -89,7 +90,7 @@ public class KeyCommand {
|
||||
|
||||
ItemStack key = getHeldKey(player);
|
||||
if (key.isEmpty()) {
|
||||
source.sendFailure(Component.literal("You must hold a collar key"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.key.must_hold_key"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -101,7 +102,7 @@ public class KeyCommand {
|
||||
!tag.getUUID(TAG_OWNER).equals(player.getUUID())
|
||||
) {
|
||||
source.sendFailure(
|
||||
Component.literal("This key is already claimed by someone else")
|
||||
Component.translatable("command.tiedup.key.already_claimed")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -110,7 +111,7 @@ public class KeyCommand {
|
||||
tag.putString(TAG_OWNER_NAME, player.getName().getString());
|
||||
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("§aYou have claimed this key"),
|
||||
() -> Component.translatable("command.tiedup.key.claimed").withStyle(ChatFormatting.GREEN),
|
||||
false
|
||||
);
|
||||
|
||||
@@ -129,19 +130,19 @@ public class KeyCommand {
|
||||
|
||||
ItemStack key = getHeldKey(player);
|
||||
if (key.isEmpty()) {
|
||||
source.sendFailure(Component.literal("You must hold a collar key"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.key.must_hold_key"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
CompoundTag tag = key.getOrCreateTag();
|
||||
|
||||
if (!tag.hasUUID(TAG_OWNER)) {
|
||||
source.sendFailure(Component.literal("This key is not claimed"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.key.not_claimed"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!tag.getUUID(TAG_OWNER).equals(player.getUUID())) {
|
||||
source.sendFailure(Component.literal("You do not own this key"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.key.not_owner"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -149,7 +150,7 @@ public class KeyCommand {
|
||||
tag.remove(TAG_OWNER_NAME);
|
||||
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("§aYou have unclaimed this key"),
|
||||
() -> Component.translatable("command.tiedup.key.unclaimed").withStyle(ChatFormatting.GREEN),
|
||||
false
|
||||
);
|
||||
|
||||
@@ -170,7 +171,7 @@ public class KeyCommand {
|
||||
|
||||
ItemStack key = getHeldKey(player);
|
||||
if (key.isEmpty()) {
|
||||
source.sendFailure(Component.literal("You must hold a collar key"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.key.must_hold_key"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -181,7 +182,7 @@ public class KeyCommand {
|
||||
tag.hasUUID(TAG_OWNER) &&
|
||||
!tag.getUUID(TAG_OWNER).equals(player.getUUID())
|
||||
) {
|
||||
source.sendFailure(Component.literal("You do not own this key"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.key.not_owner"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -190,9 +191,9 @@ public class KeyCommand {
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aAssigned key to " + target.getName().getString()
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.key.assigned", target.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
false
|
||||
);
|
||||
|
||||
@@ -211,7 +212,7 @@ public class KeyCommand {
|
||||
|
||||
ItemStack key = getHeldKey(player);
|
||||
if (key.isEmpty()) {
|
||||
source.sendFailure(Component.literal("You must hold a collar key"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.key.must_hold_key"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -222,7 +223,7 @@ public class KeyCommand {
|
||||
tag.hasUUID(TAG_OWNER) &&
|
||||
!tag.getUUID(TAG_OWNER).equals(player.getUUID())
|
||||
) {
|
||||
source.sendFailure(Component.literal("You do not own this key"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.key.not_owner"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -231,9 +232,9 @@ public class KeyCommand {
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aKey is now " + (isPublic ? "public" : "private")
|
||||
),
|
||||
Component.translatable(
|
||||
isPublic ? "command.tiedup.key.now_public" : "command.tiedup.key.now_private"
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
false
|
||||
);
|
||||
|
||||
@@ -252,40 +253,40 @@ public class KeyCommand {
|
||||
|
||||
ItemStack key = getHeldKey(player);
|
||||
if (key.isEmpty()) {
|
||||
source.sendFailure(Component.literal("You must hold a collar key"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.key.must_hold_key"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
CompoundTag tag = key.getOrCreateTag();
|
||||
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("§6=== Key Info ==="),
|
||||
() -> Component.translatable("command.tiedup.key.info_header").withStyle(ChatFormatting.GOLD),
|
||||
false
|
||||
);
|
||||
|
||||
String ownerName = tag.getString(TAG_OWNER_NAME);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§7Owner: §f" +
|
||||
(ownerName.isEmpty() ? "Not claimed" : ownerName)
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.key.info_owner",
|
||||
ownerName.isEmpty() ? "Not claimed" : ownerName
|
||||
).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
|
||||
String targetName = tag.getString(TAG_TARGET_NAME);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§7Assigned to: §f" +
|
||||
(targetName.isEmpty() ? "Not assigned" : targetName)
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.key.info_assigned",
|
||||
targetName.isEmpty() ? "Not assigned" : targetName
|
||||
).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
|
||||
boolean isPublic = tag.getBoolean(TAG_PUBLIC);
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("§7Public: §f" + (isPublic ? "Yes" : "No")),
|
||||
() -> Component.translatable("command.tiedup.key.info_public", isPublic ? "Yes" : "No").withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@ import com.mojang.brigadier.CommandDispatcher;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.items.base.BindVariant;
|
||||
import com.tiedup.remake.items.base.BlindfoldVariant;
|
||||
import com.tiedup.remake.items.base.EarplugsVariant;
|
||||
import com.tiedup.remake.items.base.GagVariant;
|
||||
import com.tiedup.remake.items.base.KnifeVariant;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.network.chat.Component;
|
||||
@@ -82,53 +81,32 @@ public class KidnapSetCommand {
|
||||
int given = 0;
|
||||
|
||||
// Binds
|
||||
given += giveItem(
|
||||
player,
|
||||
new ItemStack(ModItems.getBind(BindVariant.ROPES), 8)
|
||||
);
|
||||
given += giveItem(
|
||||
player,
|
||||
new ItemStack(ModItems.getBind(BindVariant.CHAIN), 4)
|
||||
);
|
||||
given += giveItem(
|
||||
player,
|
||||
new ItemStack(ModItems.getBind(BindVariant.LEATHER_STRAPS), 4)
|
||||
);
|
||||
given += giveDataDrivenItems(player, "ropes", 8);
|
||||
given += giveDataDrivenItems(player, "chain", 4);
|
||||
given += giveDataDrivenItems(player, "leather_straps", 4);
|
||||
|
||||
// Gags
|
||||
given += giveItem(
|
||||
player,
|
||||
new ItemStack(ModItems.getGag(GagVariant.CLOTH_GAG), 4)
|
||||
);
|
||||
given += giveItem(
|
||||
player,
|
||||
new ItemStack(ModItems.getGag(GagVariant.BALL_GAG), 4)
|
||||
);
|
||||
given += giveItem(
|
||||
player,
|
||||
new ItemStack(ModItems.getGag(GagVariant.TAPE_GAG), 4)
|
||||
);
|
||||
given += giveDataDrivenItems(player, "cloth_gag", 4);
|
||||
given += giveDataDrivenItems(player, "ball_gag", 4);
|
||||
given += giveDataDrivenItems(player, "tape_gag", 4);
|
||||
|
||||
// Blindfolds
|
||||
given += giveItem(
|
||||
player,
|
||||
new ItemStack(ModItems.getBlindfold(BlindfoldVariant.CLASSIC), 4)
|
||||
);
|
||||
given += giveItem(
|
||||
player,
|
||||
new ItemStack(ModItems.getBlindfold(BlindfoldVariant.MASK), 2)
|
||||
);
|
||||
given += giveDataDrivenItems(player, "classic_blindfold", 4);
|
||||
given += giveDataDrivenItems(player, "blindfold_mask", 2);
|
||||
|
||||
// Collars
|
||||
given += giveItem(
|
||||
player,
|
||||
new ItemStack(ModItems.CLASSIC_COLLAR.get(), 4)
|
||||
);
|
||||
given += giveItem(
|
||||
player,
|
||||
new ItemStack(ModItems.SHOCK_COLLAR.get(), 2)
|
||||
);
|
||||
given += giveItem(player, new ItemStack(ModItems.GPS_COLLAR.get(), 2));
|
||||
// Collars (data-driven)
|
||||
ItemStack classicCollars = com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem.createStack(
|
||||
net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("tiedup", "classic_collar"));
|
||||
classicCollars.setCount(4);
|
||||
given += giveItem(player, classicCollars);
|
||||
ItemStack shockCollars = com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem.createStack(
|
||||
net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("tiedup", "shock_collar"));
|
||||
shockCollars.setCount(2);
|
||||
given += giveItem(player, shockCollars);
|
||||
ItemStack gpsCollars = com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem.createStack(
|
||||
net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("tiedup", "gps_collar"));
|
||||
gpsCollars.setCount(2);
|
||||
given += giveItem(player, gpsCollars);
|
||||
|
||||
// Tools
|
||||
given += giveItem(
|
||||
@@ -155,10 +133,7 @@ public class KidnapSetCommand {
|
||||
given += giveItem(player, new ItemStack(ModItems.MASTER_KEY.get(), 1));
|
||||
|
||||
// Earplugs
|
||||
given += giveItem(
|
||||
player,
|
||||
new ItemStack(ModItems.getEarplugs(EarplugsVariant.CLASSIC), 4)
|
||||
);
|
||||
given += giveDataDrivenItems(player, "classic_earplugs", 4);
|
||||
|
||||
// Rope arrows
|
||||
given += giveItem(player, new ItemStack(ModItems.ROPE_ARROW.get(), 16));
|
||||
@@ -173,15 +148,27 @@ public class KidnapSetCommand {
|
||||
int finalGiven = given;
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aGave kidnap set (" + finalGiven + " item stacks)"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.kidnapset.gave", finalGiven
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
|
||||
return finalGiven;
|
||||
}
|
||||
|
||||
private static int giveDataDrivenItems(ServerPlayer player, String itemName, int count) {
|
||||
int given = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
ItemStack stack = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", itemName));
|
||||
if (!stack.isEmpty()) {
|
||||
giveItem(player, stack);
|
||||
given++;
|
||||
}
|
||||
}
|
||||
return given;
|
||||
}
|
||||
|
||||
private static int giveItem(ServerPlayer player, ItemStack stack) {
|
||||
if (!player.getInventory().add(stack)) {
|
||||
// Drop on ground if inventory full
|
||||
@@ -219,13 +206,11 @@ public class KidnapSetCommand {
|
||||
int finalReloaded = reloaded;
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aReloaded " +
|
||||
(type.equals("all") ? "all data files" : type) +
|
||||
" (" +
|
||||
finalReloaded +
|
||||
" files)"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.kidnapreload.reloaded",
|
||||
type.equals("all") ? "all data files" : type,
|
||||
finalReloaded
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
|
||||
|
||||
@@ -7,14 +7,13 @@ import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.tiedup.remake.entities.*;
|
||||
import com.tiedup.remake.entities.skins.EliteKidnapperSkinManager;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.items.base.BindVariant;
|
||||
import com.tiedup.remake.items.base.BlindfoldVariant;
|
||||
import com.tiedup.remake.items.base.EarplugsVariant;
|
||||
import com.tiedup.remake.items.base.GagVariant;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.arguments.EntityArgument;
|
||||
@@ -201,7 +200,7 @@ public class NPCCommand {
|
||||
z = player.getZ();
|
||||
} else {
|
||||
source.sendFailure(
|
||||
Component.literal("Must specify a player or be a player")
|
||||
Component.translatable("command.tiedup.error.must_be_player")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -221,15 +220,15 @@ public class NPCCommand {
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aSpawned Kidnapper at " + formatPos(x, y, z)
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.npc.spawned_kidnapper", formatPos(x, y, z)
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
source.sendFailure(Component.literal("Failed to spawn Kidnapper"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.npc.spawn_failed_kidnapper"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -247,11 +246,7 @@ public class NPCCommand {
|
||||
);
|
||||
if (variant == null) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
"Unknown elite variant: " +
|
||||
name +
|
||||
". Available: suki, carol, athena, evelyn"
|
||||
)
|
||||
Component.translatable("command.tiedup.npc.unknown_variant", name)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -268,7 +263,7 @@ public class NPCCommand {
|
||||
z = player.getZ();
|
||||
} else {
|
||||
source.sendFailure(
|
||||
Component.literal("Must specify a player or be a player")
|
||||
Component.translatable("command.tiedup.error.must_be_player")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -291,19 +286,16 @@ public class NPCCommand {
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aSpawned Elite Kidnapper '" +
|
||||
variant.defaultName() +
|
||||
"' at " +
|
||||
formatPos(x, y, z)
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.npc.spawned_elite", variant.defaultName(), formatPos(x, y, z)
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
source.sendFailure(
|
||||
Component.literal("Failed to spawn Elite Kidnapper")
|
||||
Component.translatable("command.tiedup.npc.spawn_failed_elite")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -327,7 +319,7 @@ public class NPCCommand {
|
||||
z = player.getZ();
|
||||
} else {
|
||||
source.sendFailure(
|
||||
Component.literal("Must specify a player or be a player")
|
||||
Component.translatable("command.tiedup.error.must_be_player")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -348,16 +340,16 @@ public class NPCCommand {
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aSpawned Archer Kidnapper at " + formatPos(x, y, z)
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.npc.spawned_archer", formatPos(x, y, z)
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
source.sendFailure(
|
||||
Component.literal("Failed to spawn Archer Kidnapper")
|
||||
Component.translatable("command.tiedup.npc.spawn_failed_archer")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -381,7 +373,7 @@ public class NPCCommand {
|
||||
z = player.getZ();
|
||||
} else {
|
||||
source.sendFailure(
|
||||
Component.literal("Must specify a player or be a player")
|
||||
Component.translatable("command.tiedup.error.must_be_player")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -401,15 +393,15 @@ public class NPCCommand {
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aSpawned Damsel at " + formatPos(x, y, z)
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.npc.spawned_damsel", formatPos(x, y, z)
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
source.sendFailure(Component.literal("Failed to spawn Damsel"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.npc.spawn_failed_damsel"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -443,9 +435,9 @@ public class NPCCommand {
|
||||
int finalKilled = killed;
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aKilled " + finalKilled + " mod NPCs in radius " + radius
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.npc.killed", finalKilled, radius
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
|
||||
@@ -493,7 +485,7 @@ public class NPCCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("No mod NPC found within 10 blocks")
|
||||
Component.translatable("command.tiedup.npc.no_npc_nearby")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -501,18 +493,18 @@ public class NPCCommand {
|
||||
if (npc.isTiedUp()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.literal("NPC is already tied up"));
|
||||
.sendFailure(Component.translatable("command.tiedup.npc.already_tied"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
npc.equip(
|
||||
BodyRegionV2.ARMS,
|
||||
new ItemStack(ModItems.getBind(BindVariant.ROPES))
|
||||
DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "ropes"))
|
||||
);
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() -> Component.literal("§aTied up " + npc.getKidnappedName()),
|
||||
() -> Component.translatable("command.tiedup.npc.tied", npc.getKidnappedName()).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -524,7 +516,7 @@ public class NPCCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("No mod NPC found within 10 blocks")
|
||||
Component.translatable("command.tiedup.npc.no_npc_nearby")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -532,18 +524,18 @@ public class NPCCommand {
|
||||
if (npc.isGagged()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.literal("NPC is already gagged"));
|
||||
.sendFailure(Component.translatable("command.tiedup.npc.already_gagged"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
npc.equip(
|
||||
BodyRegionV2.MOUTH,
|
||||
new ItemStack(ModItems.getGag(GagVariant.CLOTH_GAG))
|
||||
DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "cloth_gag"))
|
||||
);
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() -> Component.literal("§aGagged " + npc.getKidnappedName()),
|
||||
() -> Component.translatable("command.tiedup.npc.gagged", npc.getKidnappedName()).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -557,7 +549,7 @@ public class NPCCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("No mod NPC found within 10 blocks")
|
||||
Component.translatable("command.tiedup.npc.no_npc_nearby")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -565,21 +557,20 @@ public class NPCCommand {
|
||||
if (npc.isBlindfolded()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.literal("NPC is already blindfolded"));
|
||||
.sendFailure(Component.translatable("command.tiedup.npc.already_blindfolded"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
npc.equip(
|
||||
BodyRegionV2.EYES,
|
||||
new ItemStack(ModItems.getBlindfold(BlindfoldVariant.CLASSIC))
|
||||
DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "classic_blindfold"))
|
||||
);
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aBlindfolded " + npc.getKidnappedName()
|
||||
),
|
||||
Component.translatable("command.tiedup.npc.blindfolded", npc.getKidnappedName()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -591,7 +582,7 @@ public class NPCCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("No mod NPC found within 10 blocks")
|
||||
Component.translatable("command.tiedup.npc.no_npc_nearby")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -599,18 +590,18 @@ public class NPCCommand {
|
||||
if (npc.hasCollar()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.literal("NPC already has a collar"));
|
||||
.sendFailure(Component.translatable("command.tiedup.npc.already_collared"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
npc.equip(
|
||||
BodyRegionV2.NECK,
|
||||
new ItemStack(ModItems.CLASSIC_COLLAR.get())
|
||||
DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "classic_collar"))
|
||||
);
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() -> Component.literal("§aCollared " + npc.getKidnappedName()),
|
||||
() -> Component.translatable("command.tiedup.npc.collared", npc.getKidnappedName()).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -622,7 +613,7 @@ public class NPCCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("No mod NPC found within 10 blocks")
|
||||
Component.translatable("command.tiedup.npc.no_npc_nearby")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -631,7 +622,7 @@ public class NPCCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() -> Component.literal("§aUntied " + npc.getKidnappedName()),
|
||||
() -> Component.translatable("command.tiedup.npc.untied", npc.getKidnappedName()).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -645,7 +636,7 @@ public class NPCCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("No mod NPC found within 10 blocks")
|
||||
Component.translatable("command.tiedup.npc.no_npc_nearby")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -656,11 +647,11 @@ public class NPCCommand {
|
||||
com.tiedup.remake.entities.AbstractTiedUpNpc npcEntity
|
||||
) {
|
||||
npcEntity.applyBondage(
|
||||
new ItemStack(ModItems.getBind(BindVariant.ROPES)),
|
||||
new ItemStack(ModItems.getGag(GagVariant.CLOTH_GAG)),
|
||||
new ItemStack(ModItems.getBlindfold(BlindfoldVariant.CLASSIC)),
|
||||
new ItemStack(ModItems.getEarplugs(EarplugsVariant.CLASSIC)),
|
||||
new ItemStack(ModItems.CLASSIC_COLLAR.get()),
|
||||
DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "ropes")),
|
||||
DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "cloth_gag")),
|
||||
DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "classic_blindfold")),
|
||||
DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "classic_earplugs")),
|
||||
DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "classic_collar")),
|
||||
ItemStack.EMPTY // No clothes
|
||||
);
|
||||
}
|
||||
@@ -669,9 +660,9 @@ public class NPCCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aFully restrained " + npc.getKidnappedName()
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.npc.fully_restrained", npc.getKidnappedName()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -685,7 +676,7 @@ public class NPCCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("No mod NPC found within 10 blocks")
|
||||
Component.translatable("command.tiedup.npc.no_npc_nearby")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -694,68 +685,67 @@ public class NPCCommand {
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§6=== NPC State: " + npc.getKidnappedName() + " ==="
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.npc.state_header", npc.getKidnappedName()
|
||||
).withStyle(ChatFormatting.GOLD),
|
||||
false
|
||||
);
|
||||
|
||||
if (npc instanceof EntityDamsel damsel) {
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal("§eVariant: §f" + damsel.getVariantId()),
|
||||
Component.translatable("command.tiedup.npc.state_variant", damsel.getVariantId()).withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§eSlim Arms: " +
|
||||
(damsel.hasSlimArms() ? "§aYes" : "§7No")
|
||||
),
|
||||
Component.translatable("command.tiedup.npc.state_slim_arms",
|
||||
Component.translatable(damsel.hasSlimArms() ? "command.tiedup.yes" : "command.tiedup.no")
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§eTied Up: " + (npc.isTiedUp() ? "§aYes" : "§7No")
|
||||
),
|
||||
Component.translatable("command.tiedup.npc.state_tied",
|
||||
Component.translatable(npc.isTiedUp() ? "command.tiedup.yes" : "command.tiedup.no")
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§eGagged: " + (npc.isGagged() ? "§aYes" : "§7No")
|
||||
),
|
||||
Component.translatable("command.tiedup.npc.state_gagged",
|
||||
Component.translatable(npc.isGagged() ? "command.tiedup.yes" : "command.tiedup.no")
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§eBlindfolded: " + (npc.isBlindfolded() ? "§aYes" : "§7No")
|
||||
),
|
||||
Component.translatable("command.tiedup.npc.state_blindfolded",
|
||||
Component.translatable(npc.isBlindfolded() ? "command.tiedup.yes" : "command.tiedup.no")
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§eHas Collar: " + (npc.hasCollar() ? "§aYes" : "§7No")
|
||||
),
|
||||
Component.translatable("command.tiedup.npc.state_collar",
|
||||
Component.translatable(npc.hasCollar() ? "command.tiedup.yes" : "command.tiedup.no")
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§eHas Earplugs: " + (npc.hasEarplugs() ? "§aYes" : "§7No")
|
||||
),
|
||||
Component.translatable("command.tiedup.npc.state_earplugs",
|
||||
Component.translatable(npc.hasEarplugs() ? "command.tiedup.yes" : "command.tiedup.no")
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§eIs Captive: " + (npc.isCaptive() ? "§aYes" : "§7No")
|
||||
),
|
||||
Component.translatable("command.tiedup.npc.state_captive",
|
||||
Component.translatable(npc.isCaptive() ? "command.tiedup.yes" : "command.tiedup.no")
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ public class SocialCommand {
|
||||
ServerPlayer target = EntityArgument.getPlayer(context, "player");
|
||||
|
||||
if (player.getUUID().equals(target.getUUID())) {
|
||||
source.sendFailure(Component.literal("You cannot block yourself"));
|
||||
source.sendFailure(Component.translatable("command.tiedup.social.cannot_block_self"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -170,9 +170,7 @@ public class SocialCommand {
|
||||
|
||||
if (data.isBlocked(player.getUUID(), target.getUUID())) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " is already blocked"
|
||||
)
|
||||
Component.translatable("command.tiedup.social.already_blocked", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -180,7 +178,7 @@ public class SocialCommand {
|
||||
data.addBlock(player.getUUID(), target.getUUID());
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal("§aBlocked " + target.getName().getString()),
|
||||
Component.translatable("command.tiedup.social.blocked", target.getName().getString()).withStyle(ChatFormatting.GREEN),
|
||||
false
|
||||
);
|
||||
|
||||
@@ -208,9 +206,7 @@ public class SocialCommand {
|
||||
|
||||
if (!data.isBlocked(player.getUUID(), target.getUUID())) {
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " is not blocked"
|
||||
)
|
||||
Component.translatable("command.tiedup.social.not_blocked", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -218,9 +214,7 @@ public class SocialCommand {
|
||||
data.removeBlock(player.getUUID(), target.getUUID());
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aUnblocked " + target.getName().getString()
|
||||
),
|
||||
Component.translatable("command.tiedup.social.unblocked", target.getName().getString()).withStyle(ChatFormatting.GREEN),
|
||||
false
|
||||
);
|
||||
|
||||
@@ -245,19 +239,13 @@ public class SocialCommand {
|
||||
if (blocked) {
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§c" + target.getName().getString() + " has blocked you"
|
||||
),
|
||||
Component.translatable("command.tiedup.social.has_blocked_you", target.getName().getString()).withStyle(ChatFormatting.RED),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§a" +
|
||||
target.getName().getString() +
|
||||
" has not blocked you"
|
||||
),
|
||||
Component.translatable("command.tiedup.social.has_not_blocked_you", target.getName().getString()).withStyle(ChatFormatting.GREEN),
|
||||
false
|
||||
);
|
||||
}
|
||||
@@ -300,11 +288,7 @@ public class SocialCommand {
|
||||
if (lastUse != null && now - lastUse < NORP_COOLDOWN_MS) {
|
||||
long remaining = (NORP_COOLDOWN_MS - (now - lastUse)) / 1000;
|
||||
source.sendFailure(
|
||||
Component.literal(
|
||||
"Please wait " +
|
||||
remaining +
|
||||
" seconds before using /norp again"
|
||||
)
|
||||
Component.translatable("command.tiedup.social.norp_cooldown", remaining)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -315,7 +299,7 @@ public class SocialCommand {
|
||||
// Broadcast to all players
|
||||
Component message = Component.literal("")
|
||||
.append(
|
||||
Component.literal("[NoRP] ").withStyle(
|
||||
Component.translatable("command.tiedup.social.norp_prefix").withStyle(
|
||||
ChatFormatting.RED,
|
||||
ChatFormatting.BOLD
|
||||
)
|
||||
@@ -326,8 +310,8 @@ public class SocialCommand {
|
||||
)
|
||||
)
|
||||
.append(
|
||||
Component.literal(
|
||||
" has announced non-consent to current RP"
|
||||
Component.translatable(
|
||||
"command.tiedup.social.norp_announcement"
|
||||
).withStyle(ChatFormatting.RED)
|
||||
);
|
||||
|
||||
@@ -411,7 +395,7 @@ public class SocialCommand {
|
||||
SocialData data = SocialData.get(sender.serverLevel());
|
||||
if (data.isBlocked(target.getUUID(), sender.getUUID())) {
|
||||
source.sendFailure(
|
||||
Component.literal("This player has blocked you")
|
||||
Component.translatable("command.tiedup.social.pm_blocked")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -419,8 +403,8 @@ public class SocialCommand {
|
||||
// Send to target (earplug-aware)
|
||||
Component toTarget = Component.literal("")
|
||||
.append(
|
||||
Component.literal(
|
||||
"[PM from " + sender.getName().getString() + "] "
|
||||
Component.translatable(
|
||||
"command.tiedup.social.pm_from", sender.getName().getString()
|
||||
).withStyle(ChatFormatting.LIGHT_PURPLE)
|
||||
)
|
||||
.append(Component.literal(message).withStyle(ChatFormatting.WHITE));
|
||||
@@ -429,8 +413,8 @@ public class SocialCommand {
|
||||
// Confirm to sender (always show - they're the one sending)
|
||||
Component toSender = Component.literal("")
|
||||
.append(
|
||||
Component.literal(
|
||||
"[PM to " + target.getName().getString() + "] "
|
||||
Component.translatable(
|
||||
"command.tiedup.social.pm_to", target.getName().getString()
|
||||
).withStyle(ChatFormatting.GRAY)
|
||||
)
|
||||
.append(Component.literal(message).withStyle(ChatFormatting.WHITE));
|
||||
@@ -458,15 +442,13 @@ public class SocialCommand {
|
||||
|
||||
if (distance == 0) {
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("§aTalk area disabled (global chat)"),
|
||||
() -> Component.translatable("command.tiedup.social.talkarea_disabled").withStyle(ChatFormatting.GREEN),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"§aTalk area set to " + distance + " blocks"
|
||||
),
|
||||
Component.translatable("command.tiedup.social.talkarea_set", distance).withStyle(ChatFormatting.GREEN),
|
||||
false
|
||||
);
|
||||
}
|
||||
@@ -490,12 +472,12 @@ public class SocialCommand {
|
||||
if (talkArea == 0) {
|
||||
source.sendSuccess(
|
||||
() ->
|
||||
Component.literal("Talk area: §edisabled §7(global chat)"),
|
||||
Component.translatable("command.tiedup.social.talkinfo_disabled").withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("Talk area: §e" + talkArea + " blocks"),
|
||||
() -> Component.translatable("command.tiedup.social.talkinfo_distance", talkArea).withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
133
src/main/java/com/tiedup/remake/commands/ValidateGlbCommand.java
Normal file
133
src/main/java/com/tiedup/remake/commands/ValidateGlbCommand.java
Normal file
@@ -0,0 +1,133 @@
|
||||
package com.tiedup.remake.commands;
|
||||
|
||||
import com.mojang.brigadier.CommandDispatcher;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnostic;
|
||||
import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnosticRegistry;
|
||||
import com.tiedup.remake.client.gltf.diagnostic.GlbValidationResult;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Client-only command: /tiedup validate [item_id]
|
||||
*
|
||||
* Displays GLB validation diagnostics in chat.
|
||||
* Registered via {@link net.minecraftforge.client.event.RegisterClientCommandsEvent}
|
||||
* on the FORGE bus (never on the server).
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class ValidateGlbCommand {
|
||||
|
||||
private ValidateGlbCommand() {}
|
||||
|
||||
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
|
||||
dispatcher.register(
|
||||
Commands.literal("tiedup")
|
||||
.then(Commands.literal("validate")
|
||||
.executes(ctx -> validateAll(ctx.getSource()))
|
||||
.then(Commands.argument("item_id", StringArgumentType.string())
|
||||
.executes(ctx -> validateOne(
|
||||
ctx.getSource(),
|
||||
StringArgumentType.getString(ctx, "item_id")
|
||||
))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static int validateAll(CommandSourceStack source) {
|
||||
var all = GlbDiagnosticRegistry.getAll();
|
||||
if (all.isEmpty()) {
|
||||
source.sendSuccess(
|
||||
() -> Component.literal(
|
||||
"[TiedUp] No GLB validation results. "
|
||||
+ "Try reloading resources (F3+T)."
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int totalDiags = 0;
|
||||
for (GlbValidationResult result : all) {
|
||||
if (result.diagnostics().isEmpty()) continue;
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("--- " + result.source() + " ---")
|
||||
.withStyle(
|
||||
result.passed()
|
||||
? ChatFormatting.GREEN
|
||||
: ChatFormatting.RED
|
||||
),
|
||||
false
|
||||
);
|
||||
for (GlbDiagnostic d : result.diagnostics()) {
|
||||
source.sendSuccess(() -> formatDiagnostic(d), false);
|
||||
totalDiags++;
|
||||
}
|
||||
}
|
||||
|
||||
int count = totalDiags;
|
||||
source.sendSuccess(
|
||||
() -> Component.literal(
|
||||
"[TiedUp] " + count + " diagnostic(s) across "
|
||||
+ GlbDiagnosticRegistry.size() + " GLBs"
|
||||
).withStyle(ChatFormatting.GRAY),
|
||||
false
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int validateOne(CommandSourceStack source, String itemId) {
|
||||
ResourceLocation loc = ResourceLocation.tryParse(itemId);
|
||||
if (loc == null) {
|
||||
source.sendFailure(
|
||||
Component.literal("Invalid resource location: " + itemId)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (GlbValidationResult result : GlbDiagnosticRegistry.getAll()) {
|
||||
boolean match = result.source().equals(loc);
|
||||
if (!match) {
|
||||
match = result.diagnostics().stream()
|
||||
.anyMatch(d -> loc.equals(d.itemDef()));
|
||||
}
|
||||
if (match) {
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("--- " + result.source() + " ---")
|
||||
.withStyle(
|
||||
result.passed()
|
||||
? ChatFormatting.GREEN
|
||||
: ChatFormatting.RED
|
||||
),
|
||||
false
|
||||
);
|
||||
for (GlbDiagnostic d : result.diagnostics()) {
|
||||
source.sendSuccess(() -> formatDiagnostic(d), false);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
source.sendFailure(
|
||||
Component.literal("No validation results for: " + itemId)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Component formatDiagnostic(GlbDiagnostic d) {
|
||||
ChatFormatting color = switch (d.severity()) {
|
||||
case ERROR -> ChatFormatting.RED;
|
||||
case WARNING -> ChatFormatting.YELLOW;
|
||||
case INFO -> ChatFormatting.GRAY;
|
||||
};
|
||||
return Component.literal(
|
||||
" [" + d.severity() + "] " + d.code() + ": " + d.message()
|
||||
).withStyle(color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package com.tiedup.remake.commands.subcommands;
|
||||
|
||||
import com.mojang.brigadier.arguments.FloatArgumentType;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.tiedup.remake.items.base.AdjustmentHelper;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.tiedup.remake.commands.CommandHelper;
|
||||
import com.tiedup.remake.v2.bondage.CollarHelper;
|
||||
import com.tiedup.remake.core.SystemMessageManager;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.arguments.EntityArgument;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
@SuppressWarnings("null")
|
||||
public class AccessoryCommands {
|
||||
|
||||
public static void register(LiteralArgumentBuilder<CommandSourceStack> root) {
|
||||
// /tiedup putearplugs <player>
|
||||
root.then(
|
||||
Commands.literal("putearplugs")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(AccessoryCommands::putearplugs)
|
||||
)
|
||||
);
|
||||
// /tiedup takeearplugs <player>
|
||||
root.then(
|
||||
Commands.literal("takeearplugs")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(AccessoryCommands::takeearplugs)
|
||||
)
|
||||
);
|
||||
// /tiedup putclothes <player>
|
||||
root.then(
|
||||
Commands.literal("putclothes")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(AccessoryCommands::putclothes)
|
||||
)
|
||||
);
|
||||
// /tiedup takeclothes <player>
|
||||
root.then(
|
||||
Commands.literal("takeclothes")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(AccessoryCommands::takeclothes)
|
||||
)
|
||||
);
|
||||
// /tiedup fullyrestrain <player>
|
||||
root.then(
|
||||
Commands.literal("fullyrestrain")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(AccessoryCommands::fullyrestrain)
|
||||
)
|
||||
);
|
||||
// /tiedup adjust <player> <type:gag|blindfold|all> <value>
|
||||
root.then(
|
||||
Commands.literal("adjust")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument("player", EntityArgument.player()).then(
|
||||
Commands.argument("type", StringArgumentType.word())
|
||||
.suggests((ctx, builder) -> {
|
||||
builder.suggest("gag");
|
||||
builder.suggest("blindfold");
|
||||
builder.suggest("all");
|
||||
return builder.buildFuture();
|
||||
})
|
||||
.then(
|
||||
Commands.argument(
|
||||
"value",
|
||||
FloatArgumentType.floatArg(-4.0f, 4.0f)
|
||||
).executes(AccessoryCommands::adjust)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static int putearplugs(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (state.hasEarplugs()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.accessory.already_earplugs",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ItemStack earplugs = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "classic_earplugs"));
|
||||
state.putEarplugsOn(earplugs);
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.accessory.earplugs_on",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendToTarget(
|
||||
context.getSource().getEntity(),
|
||||
targetPlayer,
|
||||
SystemMessageManager.MessageCategory.EARPLUGS_ON
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int takeearplugs(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!state.hasEarplugs()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.accessory.no_earplugs",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
state.takeEarplugsOff();
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.accessory.earplugs_removed",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendToPlayer(
|
||||
targetPlayer,
|
||||
SystemMessageManager.MessageCategory.INFO,
|
||||
"Your earplugs have been removed!"
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int putclothes(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (state.hasClothes()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.accessory.already_clothes",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ItemStack clothes = new ItemStack(ModItems.CLOTHES.get());
|
||||
state.putClothesOn(clothes);
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.accessory.clothes_on",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int takeclothes(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!state.hasClothes()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.accessory.no_clothes",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ItemStack removed = state.takeClothesOff();
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
if (!removed.isEmpty()) {
|
||||
targetPlayer.drop(removed, false);
|
||||
}
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.accessory.clothes_removed",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int fullyrestrain(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int applied = 0;
|
||||
|
||||
if (!state.isTiedUp()) {
|
||||
ItemStack ropes = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "ropes"));
|
||||
state.putBindOn(ropes);
|
||||
applied++;
|
||||
}
|
||||
|
||||
if (!state.isGagged()) {
|
||||
ItemStack gag = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "cloth_gag"));
|
||||
state.putGagOn(gag);
|
||||
applied++;
|
||||
}
|
||||
|
||||
if (!state.isBlindfolded()) {
|
||||
ItemStack blindfold = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "classic_blindfold"));
|
||||
state.putBlindfoldOn(blindfold);
|
||||
applied++;
|
||||
}
|
||||
|
||||
if (!state.hasCollar()) {
|
||||
ItemStack collar = com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem.createStack(net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("tiedup", "classic_collar"));
|
||||
if (
|
||||
context.getSource().getEntity() instanceof ServerPlayer executor
|
||||
) {
|
||||
CollarHelper.addOwner(collar, executor);
|
||||
}
|
||||
state.putCollarOn(collar);
|
||||
applied++;
|
||||
}
|
||||
|
||||
if (!state.hasEarplugs()) {
|
||||
ItemStack earplugs = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "classic_earplugs"));
|
||||
state.putEarplugsOn(earplugs);
|
||||
applied++;
|
||||
}
|
||||
|
||||
if (applied == 0) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.accessory.already_restrained",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
int finalApplied = applied;
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.accessory.fully_restrained",
|
||||
targetPlayer.getName().getString(),
|
||||
finalApplied
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendToPlayer(
|
||||
targetPlayer,
|
||||
SystemMessageManager.MessageCategory.INFO,
|
||||
"You have been fully restrained!"
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int adjust(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
String type = StringArgumentType.getString(context, "type");
|
||||
float value = FloatArgumentType.getFloat(context, "value");
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
boolean adjustedGag = false;
|
||||
boolean adjustedBlindfold = false;
|
||||
|
||||
if (type.equals("gag") || type.equals("all")) {
|
||||
ItemStack gag = state.getEquipment(
|
||||
com.tiedup.remake.v2.BodyRegionV2.MOUTH
|
||||
);
|
||||
if (!gag.isEmpty()) {
|
||||
AdjustmentHelper.setAdjustment(gag, value);
|
||||
adjustedGag = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (type.equals("blindfold") || type.equals("all")) {
|
||||
ItemStack blindfold = state.getEquipment(
|
||||
com.tiedup.remake.v2.BodyRegionV2.EYES
|
||||
);
|
||||
if (!blindfold.isEmpty()) {
|
||||
AdjustmentHelper.setAdjustment(blindfold, value);
|
||||
adjustedBlindfold = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!type.equals("gag") &&
|
||||
!type.equals("blindfold") &&
|
||||
!type.equals("all")
|
||||
) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable("command.tiedup.accessory.adjust_invalid_type")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!adjustedGag && !adjustedBlindfold) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.accessory.nothing_to_adjust",
|
||||
targetPlayer.getName().getString(),
|
||||
type
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
String items =
|
||||
adjustedGag && adjustedBlindfold
|
||||
? "gag and blindfold"
|
||||
: adjustedGag
|
||||
? "gag"
|
||||
: "blindfold";
|
||||
String valueStr = String.format("%.2f", value);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.accessory.adjusted",
|
||||
items,
|
||||
targetPlayer.getName().getString(),
|
||||
valueStr
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.tiedup.remake.commands.subcommands;
|
||||
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.tiedup.remake.commands.CommandHelper;
|
||||
import com.tiedup.remake.core.SystemMessageManager;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.arguments.EntityArgument;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
@SuppressWarnings("null")
|
||||
public class BindCommands {
|
||||
|
||||
public static void register(LiteralArgumentBuilder<CommandSourceStack> root) {
|
||||
// /tiedup tie <player>
|
||||
root.then(
|
||||
Commands.literal("tie")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(BindCommands::tie)
|
||||
)
|
||||
);
|
||||
// /tiedup untie <player>
|
||||
root.then(
|
||||
Commands.literal("untie")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(BindCommands::untie)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static int tie(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (state.isTiedUp()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.bind.already_tied",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ItemStack ropes = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "ropes"));
|
||||
state.putBindOn(ropes);
|
||||
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.bind.tied",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendTiedUp(
|
||||
context.getSource().getEntity(),
|
||||
targetPlayer
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int untie(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (
|
||||
!state.isTiedUp() &&
|
||||
!state.isGagged() &&
|
||||
!state.isBlindfolded() &&
|
||||
!state.hasCollar()
|
||||
) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.bind.not_restrained",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
boolean removed = false;
|
||||
if (state.isTiedUp()) {
|
||||
state.takeBindOff();
|
||||
removed = true;
|
||||
}
|
||||
if (state.isGagged()) {
|
||||
state.takeGagOff();
|
||||
removed = true;
|
||||
}
|
||||
if (state.isBlindfolded()) {
|
||||
state.takeBlindfoldOff();
|
||||
removed = true;
|
||||
}
|
||||
if (state.hasCollar()) {
|
||||
state.takeCollarOff();
|
||||
removed = true;
|
||||
}
|
||||
if (state.hasEarplugs()) {
|
||||
state.takeEarplugsOff();
|
||||
removed = true;
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.bind.freed",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendFreed(targetPlayer);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.tiedup.remake.commands.subcommands;
|
||||
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.tiedup.remake.commands.CommandHelper;
|
||||
import com.tiedup.remake.core.SystemMessageManager;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.arguments.EntityArgument;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
@SuppressWarnings("null")
|
||||
public class BlindfoldCommands {
|
||||
|
||||
public static void register(LiteralArgumentBuilder<CommandSourceStack> root) {
|
||||
// /tiedup blindfold <player>
|
||||
root.then(
|
||||
Commands.literal("blindfold")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(BlindfoldCommands::blindfold)
|
||||
)
|
||||
);
|
||||
// /tiedup unblind <player>
|
||||
root.then(
|
||||
Commands.literal("unblind")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(BlindfoldCommands::unblind)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static int blindfold(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (state.isBlindfolded()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.blindfold.already_blindfolded",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ItemStack blindfold = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "classic_blindfold"));
|
||||
state.putBlindfoldOn(blindfold);
|
||||
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.blindfold.blindfolded",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendToTarget(
|
||||
context.getSource().getEntity(),
|
||||
targetPlayer,
|
||||
SystemMessageManager.MessageCategory.BLINDFOLDED
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int unblind(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!state.isBlindfolded()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.blindfold.not_blindfolded",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
state.takeBlindfoldOff();
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.blindfold.removed",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendToTarget(
|
||||
context.getSource().getEntity(),
|
||||
targetPlayer,
|
||||
SystemMessageManager.MessageCategory.UNBLINDFOLDED
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
||||
package com.tiedup.remake.commands.subcommands;
|
||||
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.sync.PacketSyncBindState;
|
||||
import com.tiedup.remake.v2.bondage.CollarHelper;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.tiedup.remake.commands.CommandHelper;
|
||||
import com.tiedup.remake.core.SystemMessageManager;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.arguments.EntityArgument;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
@SuppressWarnings("null")
|
||||
public class CollarCommands {
|
||||
|
||||
public static void register(LiteralArgumentBuilder<CommandSourceStack> root) {
|
||||
// /tiedup collar <player>
|
||||
root.then(
|
||||
Commands.literal("collar")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(CollarCommands::collar)
|
||||
)
|
||||
);
|
||||
// /tiedup takecollar <player>
|
||||
root.then(
|
||||
Commands.literal("takecollar")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(CollarCommands::takecollar)
|
||||
)
|
||||
);
|
||||
// /tiedup enslave <player>
|
||||
root.then(
|
||||
Commands.literal("enslave")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(CollarCommands::enslave)
|
||||
)
|
||||
);
|
||||
// /tiedup free <player>
|
||||
root.then(
|
||||
Commands.literal("free")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(CollarCommands::free)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static int collar(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (state.hasCollar()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.collar.already_collared",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ItemStack collar = com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem.createStack(net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("tiedup", "classic_collar"));
|
||||
|
||||
if (context.getSource().getEntity() instanceof ServerPlayer executor) {
|
||||
CollarHelper.addOwner(collar, executor);
|
||||
}
|
||||
|
||||
state.putCollarOn(collar);
|
||||
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.collar.collared",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendToTarget(
|
||||
context.getSource().getEntity(),
|
||||
targetPlayer,
|
||||
SystemMessageManager.MessageCategory.COLLARED
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int takecollar(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!state.hasCollar()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.collar.no_collar",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
state.takeCollarOff();
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.collar.removed",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendToTarget(
|
||||
context.getSource().getEntity(),
|
||||
targetPlayer,
|
||||
SystemMessageManager.MessageCategory.UNCOLLARED
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int enslave(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// First fully restrain
|
||||
if (!state.isTiedUp()) {
|
||||
ItemStack ropes = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "ropes"));
|
||||
state.putBindOn(ropes);
|
||||
}
|
||||
if (!state.isGagged()) {
|
||||
ItemStack gag = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "cloth_gag"));
|
||||
state.putGagOn(gag);
|
||||
}
|
||||
if (!state.isBlindfolded()) {
|
||||
ItemStack blindfold = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "classic_blindfold"));
|
||||
state.putBlindfoldOn(blindfold);
|
||||
}
|
||||
if (!state.hasCollar()) {
|
||||
ItemStack collar = com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem.createStack(net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("tiedup", "classic_collar"));
|
||||
if (
|
||||
context.getSource().getEntity() instanceof ServerPlayer executor
|
||||
) {
|
||||
CollarHelper.addOwner(collar, executor);
|
||||
}
|
||||
state.putCollarOn(collar);
|
||||
}
|
||||
if (!state.hasEarplugs()) {
|
||||
ItemStack earplugs = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "classic_earplugs"));
|
||||
state.putEarplugsOn(earplugs);
|
||||
}
|
||||
|
||||
// Capture target (this makes them a captive)
|
||||
if (context.getSource().getEntity() instanceof ServerPlayer master) {
|
||||
PlayerBindState masterState = PlayerBindState.getInstance(master);
|
||||
if (masterState != null && masterState.getCaptorManager() != null) {
|
||||
masterState.getCaptorManager().addCaptive(state);
|
||||
}
|
||||
}
|
||||
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.collar.enslaved",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendEnslaved(
|
||||
context.getSource().getEntity(),
|
||||
targetPlayer
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int free(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!state.isCaptive()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.collar.not_captured",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
state.free(true);
|
||||
|
||||
PacketSyncBindState statePacket = PacketSyncBindState.fromPlayer(
|
||||
targetPlayer
|
||||
);
|
||||
if (statePacket != null) {
|
||||
ModNetwork.sendToPlayer(statePacket, targetPlayer);
|
||||
}
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.collar.freed",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendFreed(targetPlayer);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.tiedup.remake.commands.CommandHelper;
|
||||
import com.tiedup.remake.prison.PrisonerManager;
|
||||
import com.tiedup.remake.prison.RansomRecord;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.arguments.EntityArgument;
|
||||
@@ -77,9 +78,8 @@ public class DebtSubCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
target.getName().getString() +
|
||||
" has no debt record."
|
||||
Component.translatable(
|
||||
"command.tiedup.debt.no_record", target.getName().getString()
|
||||
),
|
||||
false
|
||||
);
|
||||
@@ -94,16 +94,11 @@ public class DebtSubCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
target.getName().getString() +
|
||||
" \u2014 Debt: " +
|
||||
total +
|
||||
" | Paid: " +
|
||||
paid +
|
||||
" | Remaining: " +
|
||||
remaining +
|
||||
" emeralds"
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.debt.show",
|
||||
target.getName().getString(),
|
||||
total, paid, remaining
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
false
|
||||
);
|
||||
return 1;
|
||||
@@ -121,9 +116,7 @@ public class DebtSubCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " has no debt record."
|
||||
)
|
||||
Component.translatable("command.tiedup.debt.no_record", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -133,13 +126,10 @@ public class DebtSubCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Set " +
|
||||
target.getName().getString() +
|
||||
"'s total debt to " +
|
||||
amount +
|
||||
" emeralds."
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.debt.set",
|
||||
target.getName().getString(), amount
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -157,9 +147,7 @@ public class DebtSubCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " has no debt record."
|
||||
)
|
||||
Component.translatable("command.tiedup.debt.no_record", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -169,14 +157,10 @@ public class DebtSubCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Added " +
|
||||
amount +
|
||||
" emeralds to " +
|
||||
target.getName().getString() +
|
||||
"'s debt. Remaining: " +
|
||||
ransom.getRemainingDebt()
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.debt.added",
|
||||
amount, target.getName().getString(), ransom.getRemainingDebt()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
@@ -194,9 +178,7 @@ public class DebtSubCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal(
|
||||
target.getName().getString() + " has no debt record."
|
||||
)
|
||||
Component.translatable("command.tiedup.debt.no_record", target.getName().getString())
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -207,15 +189,10 @@ public class DebtSubCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Removed " +
|
||||
amount +
|
||||
" emeralds from " +
|
||||
target.getName().getString() +
|
||||
"'s debt. Remaining: " +
|
||||
ransom.getRemainingDebt() +
|
||||
(paid ? " (PAID OFF!)" : "")
|
||||
),
|
||||
Component.translatable(
|
||||
paid ? "command.tiedup.debt.removed_paid" : "command.tiedup.debt.removed",
|
||||
amount, target.getName().getString(), ransom.getRemainingDebt()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
return 1;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.tiedup.remake.commands.subcommands;
|
||||
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.tiedup.remake.commands.CommandHelper;
|
||||
import com.tiedup.remake.core.SystemMessageManager;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.arguments.EntityArgument;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
@SuppressWarnings("null")
|
||||
public class GagCommands {
|
||||
|
||||
public static void register(LiteralArgumentBuilder<CommandSourceStack> root) {
|
||||
// /tiedup gag <player>
|
||||
root.then(
|
||||
Commands.literal("gag")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(GagCommands::gag)
|
||||
)
|
||||
);
|
||||
// /tiedup ungag <player>
|
||||
root.then(
|
||||
Commands.literal("ungag")
|
||||
.requires(CommandHelper.REQUIRES_OP)
|
||||
.then(
|
||||
Commands.argument(
|
||||
"player",
|
||||
EntityArgument.player()
|
||||
).executes(GagCommands::ungag)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static int gag(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (state.isGagged()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.gag.already_gagged",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ItemStack gag = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "cloth_gag"));
|
||||
state.putGagOn(gag);
|
||||
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.gag.gagged",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendGagged(
|
||||
context.getSource().getEntity(),
|
||||
targetPlayer
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int ungag(CommandContext<CommandSourceStack> context)
|
||||
throws CommandSyntaxException {
|
||||
ServerPlayer targetPlayer = EntityArgument.getPlayer(context, "player");
|
||||
PlayerBindState state = PlayerBindState.getInstance(targetPlayer);
|
||||
|
||||
if (state == null) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(Component.translatable("command.tiedup.error.no_state"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!state.isGagged()) {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.translatable(
|
||||
"command.tiedup.gag.not_gagged",
|
||||
targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
state.takeGagOff();
|
||||
CommandHelper.syncPlayerState(targetPlayer, state);
|
||||
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.translatable(
|
||||
"command.tiedup.gag.removed",
|
||||
targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendToTarget(
|
||||
context.getSource().getEntity(),
|
||||
targetPlayer,
|
||||
SystemMessageManager.MessageCategory.UNGAGGED
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.tiedup.remake.cells.ConfiscatedInventoryRegistry;
|
||||
import com.tiedup.remake.commands.CommandHelper;
|
||||
import com.tiedup.remake.core.SystemMessageManager;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.arguments.EntityArgument;
|
||||
@@ -56,9 +57,8 @@ public class InventorySubCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal(
|
||||
targetPlayer.getName().getString() +
|
||||
" has no confiscated inventory to restore"
|
||||
Component.translatable(
|
||||
"command.tiedup.inventory.no_confiscated", targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
@@ -71,10 +71,9 @@ public class InventorySubCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"\u00a7aRestored confiscated inventory to " +
|
||||
targetPlayer.getName().getString()
|
||||
),
|
||||
Component.translatable(
|
||||
"command.tiedup.inventory.restored", targetPlayer.getName().getString()
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
true
|
||||
);
|
||||
SystemMessageManager.sendToPlayer(
|
||||
@@ -88,9 +87,8 @@ public class InventorySubCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal(
|
||||
"Failed to restore inventory for " +
|
||||
targetPlayer.getName().getString()
|
||||
Component.translatable(
|
||||
"command.tiedup.inventory.restore_failed", targetPlayer.getName().getString()
|
||||
)
|
||||
);
|
||||
return 0;
|
||||
|
||||
@@ -77,7 +77,7 @@ public class MasterTestSubCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("Failed to create Master entity")
|
||||
Component.translatable("command.tiedup.master.spawn_failed")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -108,10 +108,8 @@ public class MasterTestSubCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Spawned Master '" +
|
||||
finalName +
|
||||
"' \u2014 you are now their pet."
|
||||
Component.translatable(
|
||||
"command.tiedup.master.spawned", finalName
|
||||
),
|
||||
true
|
||||
);
|
||||
@@ -133,7 +131,7 @@ public class MasterTestSubCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("No Master NPC found within 20 blocks")
|
||||
Component.translatable("command.tiedup.master.no_master_nearby")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -157,8 +155,8 @@ public class MasterTestSubCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Forced " + finalName + " into HUMAN_CHAIR state"
|
||||
Component.translatable(
|
||||
"command.tiedup.master.forced_state", finalName, "HUMAN_CHAIR"
|
||||
),
|
||||
true
|
||||
);
|
||||
@@ -183,7 +181,7 @@ public class MasterTestSubCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("Unknown MasterState: " + taskName)
|
||||
Component.translatable("command.tiedup.master.unknown_state", taskName)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -193,7 +191,7 @@ public class MasterTestSubCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendFailure(
|
||||
Component.literal("No Master NPC found within 20 blocks")
|
||||
Component.translatable("command.tiedup.master.no_master_nearby")
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
@@ -217,12 +215,8 @@ public class MasterTestSubCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Forced " +
|
||||
finalName +
|
||||
" into " +
|
||||
targetState.name() +
|
||||
" state"
|
||||
Component.translatable(
|
||||
"command.tiedup.master.forced_state", finalName, targetState.name()
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
@@ -87,8 +87,8 @@ public class TestAnimSubCommand {
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() ->
|
||||
Component.literal(
|
||||
"Playing animation '" + anim + "' on " + name
|
||||
Component.translatable(
|
||||
"command.tiedup.testanim.playing", anim, name
|
||||
),
|
||||
false
|
||||
);
|
||||
@@ -116,7 +116,7 @@ public class TestAnimSubCommand {
|
||||
context
|
||||
.getSource()
|
||||
.sendSuccess(
|
||||
() -> Component.literal("Stopped animation on " + name),
|
||||
() -> Component.translatable("command.tiedup.testanim.stopped", name),
|
||||
false
|
||||
);
|
||||
return 1;
|
||||
|
||||
@@ -2,11 +2,12 @@ package com.tiedup.remake.compat.mca.capability;
|
||||
|
||||
import com.tiedup.remake.compat.mca.MCABondageManager;
|
||||
import com.tiedup.remake.compat.mca.MCACompat;
|
||||
import com.tiedup.remake.items.base.IHasBlindingEffect;
|
||||
import com.tiedup.remake.items.base.IHasGaggingEffect;
|
||||
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.items.base.IHasResistance;
|
||||
import com.tiedup.remake.items.base.ILockable;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.state.ICaptor;
|
||||
import com.tiedup.remake.state.IRestrainable;
|
||||
import com.tiedup.remake.state.IRestrainableEntity;
|
||||
@@ -277,16 +278,15 @@ public class MCAKidnappedAdapter implements IRestrainable {
|
||||
@Override
|
||||
public boolean hasGaggingEffect() {
|
||||
ItemStack gag = cap.getGag();
|
||||
return !gag.isEmpty() && gag.getItem() instanceof IHasGaggingEffect;
|
||||
if (gag.isEmpty()) return false;
|
||||
return DataDrivenBondageItem.getComponent(gag, ComponentType.GAGGING, GaggingComponent.class) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasBlindingEffect() {
|
||||
ItemStack blindfold = cap.getBlindfold();
|
||||
return (
|
||||
!blindfold.isEmpty() &&
|
||||
blindfold.getItem() instanceof IHasBlindingEffect
|
||||
);
|
||||
if (blindfold.isEmpty()) return false;
|
||||
return DataDrivenBondageItem.getComponent(blindfold, ComponentType.BLINDING, BlindingComponent.class) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -215,7 +215,7 @@ public class MCACompatEvents {
|
||||
IBondageState state = MCACompat.getKidnappedState(livingTarget);
|
||||
if (state != null) {
|
||||
// Priority: Untie > Ungag > Unblindfold > etc.
|
||||
// This mimics EntityDamsel logic or ItemBind.interactLivingEntity logic for untying
|
||||
// This mimics EntityDamsel logic or TyingInteractionHelper logic for untying
|
||||
|
||||
// No collar ownership check — any player can untie MCA villagers by design
|
||||
// (MCA villagers use a separate relationship system, not TiedUp collars)
|
||||
|
||||
@@ -9,16 +9,32 @@ import net.minecraftforge.common.ForgeConfigSpec;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Centralized accessor for mod settings that resolves the GameRules vs ModConfig priority.
|
||||
* Centralized accessor for all TiedUp! mod settings.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. GameRules (if a world is loaded and rules are available) - these are per-world overrides
|
||||
* 2. ModConfig (if config is loaded) - these are the user's intended defaults
|
||||
* 3. Hardcoded fallback - safe defaults if neither system is available yet
|
||||
* <h2>Three-Tier Resolution</h2>
|
||||
* <ul>
|
||||
* <li><b>Tier 1 — GameRules</b> (per-world, overridable via {@code /gamerule}): ~24 settings.
|
||||
* These take priority when a world is loaded and {@code GameRules} is available.</li>
|
||||
* <li><b>Tier 2 — ModConfig</b> (server {@code .toml} defaults): ~78+ settings.
|
||||
* Read via {@link #safeGet} to tolerate early access before the config spec is loaded.</li>
|
||||
* <li><b>Tier 3 — Hardcoded fallbacks</b>: safe defaults returned when neither Tier 1
|
||||
* nor Tier 2 is available (e.g., during mod construction).</li>
|
||||
* </ul>
|
||||
*
|
||||
* This class exists because GameRules defaults (set at registration time before config loads)
|
||||
* can diverge from ModConfig defaults. Without this accessor, ModConfig spawn rate values
|
||||
* were completely ignored (BUG-001).
|
||||
* <h2>Rule</h2>
|
||||
* <p><b>All game code should read settings through this class, never directly from
|
||||
* {@link ModConfig} or {@link ModGameRules}.</b> This ensures a single choke-point
|
||||
* for defaults, null-safety, and future GameRule promotion of any setting.</p>
|
||||
*
|
||||
* <p>Settings that only exist in ModConfig (no GameRule equivalent) use parameterless
|
||||
* getters. Settings that have a GameRule override accept a {@code @Nullable GameRules}
|
||||
* parameter.</p>
|
||||
*
|
||||
* <p>Client-side config ({@code ModConfig.CLIENT.*}) is excluded — it is read-only
|
||||
* and single-source, so no accessor is needed.</p>
|
||||
*
|
||||
* @see ModConfig.ServerConfig
|
||||
* @see ModGameRules
|
||||
*/
|
||||
public class SettingsAccessor {
|
||||
|
||||
@@ -157,12 +173,8 @@ public class SettingsAccessor {
|
||||
* <li>"beam_cuffs" -> "chain"</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>BUG-003 fix:</b> Previously, {@code IHasResistance.getBaseResistance()}
|
||||
* called {@code ModGameRules.getResistance()} which only knew 4 types (rope, gag,
|
||||
* blindfold, collar) and returned hardcoded 100 for the other 10 types. Meanwhile
|
||||
* {@code BindVariant.getResistance()} read from ModConfig which had all 14 types.
|
||||
* This caused a display-vs-struggle desync (display: 250, struggle: 100).
|
||||
* Now both paths use this method.
|
||||
* <p>Single source of truth for bind resistance — both the display
|
||||
* layer and the struggle minigame resolve here so they can't drift.</p>
|
||||
*
|
||||
* @param bindType The raw item name or config key
|
||||
* @return Resistance value from config, or 100 as fallback
|
||||
@@ -207,8 +219,7 @@ public class SettingsAccessor {
|
||||
/**
|
||||
* Normalize a raw bind item name to its config key.
|
||||
*
|
||||
* <p>Replicates the mapping from
|
||||
* {@link com.tiedup.remake.items.base.BindVariant#getResistance()} so that
|
||||
* <p>Normalizes raw item names to config keys so that
|
||||
* every call site resolves to the same config entry.
|
||||
*
|
||||
* @param bindType Raw item name (e.g., "ropes", "vine_seed", "collar")
|
||||
@@ -532,6 +543,377 @@ public class SettingsAccessor {
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Minigame Toggles ====================
|
||||
|
||||
/**
|
||||
* Check if the struggle mini-game is enabled.
|
||||
* When disabled, struggle actions are rejected or use instant logic.
|
||||
*
|
||||
* @return true if the struggle mini-game is enabled (default true)
|
||||
*/
|
||||
public static boolean isStruggleMiniGameEnabled() {
|
||||
return safeGet(() -> ModConfig.SERVER.struggleMiniGameEnabled.get(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the lockpick mini-game is enabled.
|
||||
* When disabled, lockpick actions are rejected or use instant logic.
|
||||
*
|
||||
* @return true if the lockpick mini-game is enabled (default true)
|
||||
*/
|
||||
public static boolean isLockpickMiniGameEnabled() {
|
||||
return safeGet(() -> ModConfig.SERVER.lockpickMiniGameEnabled.get(), true);
|
||||
}
|
||||
|
||||
// ==================== Combat & Weapons ====================
|
||||
|
||||
/**
|
||||
* Get the whip damage amount.
|
||||
*
|
||||
* @return Damage as float (default 2.0)
|
||||
*/
|
||||
public static float getWhipDamage() {
|
||||
return safeGet(() -> ModConfig.SERVER.whipDamage.get(), 2.0).floatValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resistance decrease per whip hit.
|
||||
*
|
||||
* @return Resistance decrease amount (default 15)
|
||||
*/
|
||||
public static int getWhipResistanceDecrease() {
|
||||
return safeGet(() -> ModConfig.SERVER.whipResistanceDecrease.get(), 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the taser stun effect duration in ticks.
|
||||
*
|
||||
* @return Duration in ticks (default 100 = 5 seconds)
|
||||
*/
|
||||
public static int getTaserStunDuration() {
|
||||
return safeGet(() -> ModConfig.SERVER.taserStunDuration.get(), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the swim speed multiplier applied to tied players.
|
||||
*
|
||||
* @return Multiplier (0.0-1.0, default 0.5)
|
||||
*/
|
||||
public static double getTiedSwimSpeedMultiplier() {
|
||||
return safeGet(() -> ModConfig.SERVER.tiedSwimSpeedMultiplier.get(), 0.5);
|
||||
}
|
||||
|
||||
// ==================== Lockpick ====================
|
||||
|
||||
/**
|
||||
* Get the lockpick success chance (0-100).
|
||||
*
|
||||
* @return Success chance percentage (default 25)
|
||||
*/
|
||||
public static int getLockpickSuccessChance() {
|
||||
return safeGet(() -> ModConfig.SERVER.lockpickSuccessChance.get(), 25);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lockpick jam chance (0.0-100.0).
|
||||
*
|
||||
* @return Jam chance percentage as double (default 2.5)
|
||||
*/
|
||||
public static double getLockpickJamChance() {
|
||||
return safeGet(() -> ModConfig.SERVER.lockpickJamChance.get(), 2.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lockpick break chance (0-100).
|
||||
*
|
||||
* @return Break chance percentage (default 15)
|
||||
*/
|
||||
public static int getLockpickBreakChance() {
|
||||
return safeGet(() -> ModConfig.SERVER.lockpickBreakChance.get(), 15);
|
||||
}
|
||||
|
||||
// ==================== Rag ====================
|
||||
|
||||
/**
|
||||
* Get the default rag wet time in ticks.
|
||||
*
|
||||
* @return Wet time in ticks (default 6000 = 5 minutes)
|
||||
*/
|
||||
public static int getRagWetTime() {
|
||||
return safeGet(() -> ModConfig.SERVER.ragWetTime.get(), 6000);
|
||||
}
|
||||
|
||||
// ==================== Arrows ====================
|
||||
|
||||
/**
|
||||
* Get the rope arrow bind chance for non-archer shooters (0-100).
|
||||
*
|
||||
* @return Bind chance percentage (default 50)
|
||||
*/
|
||||
public static int getRopeArrowBindChance() {
|
||||
return safeGet(() -> ModConfig.SERVER.ropeArrowBindChance.get(), 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the archer's base bind chance per arrow hit (0-100).
|
||||
*
|
||||
* @return Base bind chance percentage (default 10)
|
||||
*/
|
||||
public static int getArcherArrowBindChanceBase() {
|
||||
return safeGet(() -> ModConfig.SERVER.archerArrowBindChanceBase.get(), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the additional bind chance per previous hit for archers (0-100).
|
||||
*
|
||||
* @return Per-hit chance increase percentage (default 10)
|
||||
*/
|
||||
public static int getArcherArrowBindChancePerHit() {
|
||||
return safeGet(() -> ModConfig.SERVER.archerArrowBindChancePerHit.get(), 10);
|
||||
}
|
||||
|
||||
// ==================== Kidnap Bomb ====================
|
||||
|
||||
/**
|
||||
* Get the kidnap bomb fuse duration in ticks.
|
||||
*
|
||||
* @return Fuse in ticks (default 80 = 4 seconds)
|
||||
*/
|
||||
public static int getKidnapBombFuse() {
|
||||
return safeGet(() -> ModConfig.SERVER.kidnapBombFuse.get(), 80);
|
||||
}
|
||||
|
||||
// ==================== Merchant ====================
|
||||
|
||||
/**
|
||||
* Get the merchant hostile cooldown duration in ticks.
|
||||
*
|
||||
* @return Duration in ticks (default 6000 = 5 minutes)
|
||||
*/
|
||||
public static int getMerchantHostileDuration() {
|
||||
return safeGet(() -> ModConfig.SERVER.merchantHostileDuration.get(), 6000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum number of random trades a merchant generates.
|
||||
*
|
||||
* @return Minimum trade count (default 8)
|
||||
*/
|
||||
public static int getMerchantMinTrades() {
|
||||
return safeGet(() -> ModConfig.SERVER.merchantMinTrades.get(), 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum number of random trades a merchant generates.
|
||||
*
|
||||
* @return Maximum trade count (default 12)
|
||||
*/
|
||||
public static int getMerchantMaxTrades() {
|
||||
return safeGet(() -> ModConfig.SERVER.merchantMaxTrades.get(), 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tier 1 minimum price in nuggets.
|
||||
* @return Min price (default 9)
|
||||
*/
|
||||
public static int getTier1PriceMin() {
|
||||
return safeGet(() -> ModConfig.SERVER.tier1PriceMin.get(), 9);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tier 1 maximum price in nuggets.
|
||||
* @return Max price (default 18)
|
||||
*/
|
||||
public static int getTier1PriceMax() {
|
||||
return safeGet(() -> ModConfig.SERVER.tier1PriceMax.get(), 18);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tier 2 minimum price in nuggets.
|
||||
* @return Min price (default 27)
|
||||
*/
|
||||
public static int getTier2PriceMin() {
|
||||
return safeGet(() -> ModConfig.SERVER.tier2PriceMin.get(), 27);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tier 2 maximum price in nuggets.
|
||||
* @return Max price (default 45)
|
||||
*/
|
||||
public static int getTier2PriceMax() {
|
||||
return safeGet(() -> ModConfig.SERVER.tier2PriceMax.get(), 45);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tier 3 minimum price in nuggets.
|
||||
* @return Min price (default 54)
|
||||
*/
|
||||
public static int getTier3PriceMin() {
|
||||
return safeGet(() -> ModConfig.SERVER.tier3PriceMin.get(), 54);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tier 3 maximum price in nuggets.
|
||||
* @return Max price (default 90)
|
||||
*/
|
||||
public static int getTier3PriceMax() {
|
||||
return safeGet(() -> ModConfig.SERVER.tier3PriceMax.get(), 90);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tier 4 minimum price in nuggets.
|
||||
* @return Min price (default 90)
|
||||
*/
|
||||
public static int getTier4PriceMin() {
|
||||
return safeGet(() -> ModConfig.SERVER.tier4PriceMin.get(), 90);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tier 4 maximum price in nuggets.
|
||||
* @return Max price (default 180)
|
||||
*/
|
||||
public static int getTier4PriceMax() {
|
||||
return safeGet(() -> ModConfig.SERVER.tier4PriceMax.get(), 180);
|
||||
}
|
||||
|
||||
// ==================== Dialogue ====================
|
||||
|
||||
/**
|
||||
* Get the dialogue broadcast radius in blocks.
|
||||
*
|
||||
* @return Radius in blocks (default 20)
|
||||
*/
|
||||
public static int getDialogueRadius() {
|
||||
return safeGet(() -> ModConfig.SERVER.dialogueRadius.get(), 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the dialogue cooldown in ticks.
|
||||
*
|
||||
* @return Cooldown in ticks (default 100 = 5 seconds)
|
||||
*/
|
||||
public static int getDialogueCooldown() {
|
||||
return safeGet(() -> ModConfig.SERVER.dialogueCooldown.get(), 100);
|
||||
}
|
||||
|
||||
// ==================== Labor ====================
|
||||
|
||||
/**
|
||||
* Get the labor rest duration in seconds.
|
||||
*
|
||||
* @return Rest duration in seconds (default 120)
|
||||
*/
|
||||
public static int getLaborRestSeconds() {
|
||||
return safeGet(() -> ModConfig.SERVER.laborRestSeconds.get(), 120);
|
||||
}
|
||||
|
||||
// ==================== Solo Mode / Master Spawn ====================
|
||||
|
||||
/**
|
||||
* Check if Master NPC spawning is enabled.
|
||||
*
|
||||
* @return true if Master can spawn (default true)
|
||||
*/
|
||||
public static boolean isEnableMasterSpawn() {
|
||||
return safeGet(() -> ModConfig.SERVER.enableMasterSpawn.get(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if solo mode fallback behavior is enabled.
|
||||
* When enabled, kidnappers in solo worlds use alternative captive handling.
|
||||
*
|
||||
* @return true if solo fallback is enabled (default true)
|
||||
*/
|
||||
public static boolean isEnableSoloFallback() {
|
||||
return safeGet(() -> ModConfig.SERVER.enableSoloFallback.get(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the solo mode timeout in seconds before the kidnapper gives up waiting.
|
||||
*
|
||||
* @return Timeout in seconds (default 120)
|
||||
*/
|
||||
public static int getSoloTimeoutSeconds() {
|
||||
return safeGet(() -> ModConfig.SERVER.soloTimeoutSeconds.get(), 120);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the probability (0.0-1.0) that a kidnapper keeps the captive in solo mode.
|
||||
*
|
||||
* @return Keep chance (default 0.6)
|
||||
*/
|
||||
public static double getSoloKeepChance() {
|
||||
return safeGet(() -> ModConfig.SERVER.soloKeepChance.get(), 0.6);
|
||||
}
|
||||
|
||||
// ==================== Abandon Behavior ====================
|
||||
|
||||
/**
|
||||
* Check if kidnapper abandonment keeps the blindfold on the captive.
|
||||
*
|
||||
* @return true if blindfold is kept (default true)
|
||||
*/
|
||||
public static boolean isAbandonKeepsBlindfold() {
|
||||
return safeGet(() -> ModConfig.SERVER.abandonKeepsBlindfold.get(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if kidnapper abandonment keeps binds on the captive.
|
||||
*
|
||||
* @return true if binds are kept (default true)
|
||||
*/
|
||||
public static boolean isAbandonKeepsBinds() {
|
||||
return safeGet(() -> ModConfig.SERVER.abandonKeepsBinds.get(), true);
|
||||
}
|
||||
|
||||
// ==================== Gag Settings ====================
|
||||
|
||||
/**
|
||||
* Get the configured comprehension factor for a gag material.
|
||||
* Falls back to the material's hardcoded default if the config key is missing.
|
||||
*
|
||||
* @param materialKey Lowercase gag material name (e.g., "cloth", "ball")
|
||||
* @param defaultValue The material's built-in default comprehension
|
||||
* @return Comprehension factor as float
|
||||
*/
|
||||
public static float getGagComprehension(String materialKey, float defaultValue) {
|
||||
return safeGet(
|
||||
() -> {
|
||||
if (ModConfig.SERVER == null) return defaultValue;
|
||||
Map<String, ForgeConfigSpec.DoubleValue> map =
|
||||
ModConfig.SERVER.gagComprehension;
|
||||
if (map != null && map.containsKey(materialKey)) {
|
||||
return map.get(materialKey).get().floatValue();
|
||||
}
|
||||
return defaultValue;
|
||||
},
|
||||
defaultValue
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured talk range for a gag material.
|
||||
* Falls back to the material's hardcoded default if the config key is missing.
|
||||
*
|
||||
* @param materialKey Lowercase gag material name (e.g., "cloth", "ball")
|
||||
* @param defaultValue The material's built-in default talk range
|
||||
* @return Talk range as double
|
||||
*/
|
||||
public static double getGagTalkRange(String materialKey, double defaultValue) {
|
||||
return safeGet(
|
||||
() -> {
|
||||
if (ModConfig.SERVER == null) return defaultValue;
|
||||
Map<String, ForgeConfigSpec.DoubleValue> map =
|
||||
ModConfig.SERVER.gagRange;
|
||||
if (map != null && map.containsKey(materialKey)) {
|
||||
return map.get(materialKey).get();
|
||||
}
|
||||
return defaultValue;
|
||||
},
|
||||
defaultValue
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,120 +138,17 @@ public class SystemMessageManager {
|
||||
ERROR, // Generic error
|
||||
}
|
||||
|
||||
// MESSAGE TEMPLATES
|
||||
// TRANSLATION KEYS
|
||||
|
||||
/**
|
||||
* Get the raw message template for a category.
|
||||
* Use this when you need to customize the message.
|
||||
* Get the translation key for a category.
|
||||
* Keys follow the pattern: msg.tiedup.system.<category_lowercase>
|
||||
*
|
||||
* @param category The message category
|
||||
* @return The template string (may contain %s placeholders)
|
||||
* @return The translation key (for use with Component.translatable)
|
||||
*/
|
||||
public static String getTemplate(MessageCategory category) {
|
||||
return getMessageTemplate(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message template for a category.
|
||||
* Use %s for entity name placeholder.
|
||||
*/
|
||||
private static String getMessageTemplate(MessageCategory category) {
|
||||
return switch (category) {
|
||||
// Restraint actions
|
||||
case BEING_TIED -> "%s is tying you up!";
|
||||
case TIED_UP -> "%s tied you up, you can't move!";
|
||||
case BEING_GAGGED -> "%s is gagging you!";
|
||||
case GAGGED -> "%s gagged you, you can't speak!";
|
||||
case BEING_BLINDFOLDED -> "%s is blindfolding you!";
|
||||
case BLINDFOLDED -> "%s blindfolded you, you can't see!";
|
||||
case BEING_COLLARED -> "%s is putting a collar on you!";
|
||||
case COLLARED -> "%s collared you!";
|
||||
case EARPLUGS_ON -> "%s put earplugs on you!";
|
||||
case MITTENS_ON -> "%s put mittens on you!";
|
||||
case ENSLAVED -> "You have been enslaved by %s!";
|
||||
// Restraint actions (kidnapper's perspective)
|
||||
case TYING_TARGET -> "You are tying %s...";
|
||||
case TIED_TARGET -> "You tied %s!";
|
||||
case GAGGING_TARGET -> "You are gagging %s...";
|
||||
case GAGGED_TARGET -> "You gagged %s!";
|
||||
case BLINDFOLDING_TARGET -> "You are blindfolding %s...";
|
||||
case BLINDFOLDED_TARGET -> "You blindfolded %s!";
|
||||
case COLLARING_TARGET -> "You are collaring %s...";
|
||||
case COLLARED_TARGET -> "You collared %s!";
|
||||
// Release actions
|
||||
case UNTIED -> "%s untied you!";
|
||||
case UNGAGGED -> "%s removed your gag!";
|
||||
case UNBLINDFOLDED -> "%s removed your blindfold!";
|
||||
case UNCOLLARED -> "%s removed your collar!";
|
||||
case FREED -> "You have been freed!";
|
||||
// Struggle
|
||||
case STRUGGLE_SUCCESS -> "You feel the ropes loosening...";
|
||||
case STRUGGLE_FAIL -> "You struggle against the ropes, but they hold tight.";
|
||||
case STRUGGLE_BROKE_FREE -> "You broke free!";
|
||||
case STRUGGLE_SHOCKED -> "You were shocked for struggling!";
|
||||
case STRUGGLE_COLLAR_SUCCESS -> "You manage to damage the lock!";
|
||||
case STRUGGLE_COLLAR_FAIL -> "You try to reach the lock, but can't get a good grip.";
|
||||
// Restrictions (Tied)
|
||||
case CANT_MOVE -> "You can't move while tied!";
|
||||
case CANT_ATTACK_TIED -> "You can't attack while tied!";
|
||||
case CANT_USE_ITEM_TIED -> "You can't use items while tied!";
|
||||
case CANT_OPEN_INVENTORY -> "You can't open inventory while tied!";
|
||||
case CANT_INTERACT_TIED -> "You can't interact while tied!";
|
||||
case CANT_SPEAK -> "You can't speak while gagged!";
|
||||
case CANT_SEE -> "You can't see while blindfolded!";
|
||||
case CANT_BREAK_TIED -> "You can't break blocks while tied!";
|
||||
case CANT_PLACE_TIED -> "You can't place blocks while tied!";
|
||||
case NO_ELYTRA -> "You can't fly with elytra while tied!";
|
||||
// Restrictions (Mittens)
|
||||
case CANT_ATTACK_MITTENS -> "You can't attack with mittens on!";
|
||||
case CANT_USE_ITEM_MITTENS -> "You can't use items with mittens on!";
|
||||
case CANT_INTERACT_MITTENS -> "You can't interact with mittens on!";
|
||||
case CANT_BREAK_MITTENS -> "You can't break blocks with mittens on!";
|
||||
case CANT_PLACE_MITTENS -> "You can't place blocks with mittens on!";
|
||||
// Slave system
|
||||
case SLAVE_COMMAND -> "Your master commands: %s";
|
||||
case SLAVE_SHOCK -> "You've been shocked!";
|
||||
case GPS_ZONE_VIOLATION -> "You've been shocked! Return back to your allowed area!";
|
||||
case GPS_OWNER_ALERT -> "ALERT: %s is outside the safe zone!";
|
||||
case SLAVE_JOB_ASSIGNED -> "Job assigned: bring %s";
|
||||
case SLAVE_JOB_COMPLETE -> "Job complete! You are free.";
|
||||
case SLAVE_JOB_FAILED -> "Job failed!";
|
||||
case SLAVE_JOB_LAST_CHANCE -> "LAST CHANCE! Next failure means death!";
|
||||
case SLAVE_JOB_KILLED -> "You were executed for failing your task.";
|
||||
// Tighten
|
||||
case BINDS_TIGHTENED -> "%s tightened your binds!";
|
||||
// Tools & Items
|
||||
case KEY_CLAIMED -> "Key claimed and linked to %s!";
|
||||
case KEY_NOT_OWNER -> "You don't own this key!";
|
||||
case KEY_WRONG_TARGET -> "This key doesn't fit this collar!";
|
||||
case LOCATOR_CLAIMED -> "Locator claimed!";
|
||||
case LOCATOR_NOT_OWNER -> "You don't own this locator!";
|
||||
case LOCATOR_DETECTED -> "Target detected: %s";
|
||||
case SHOCKER_CLAIMED -> "Shocker claimed!";
|
||||
case SHOCKER_NOT_OWNER -> "You don't own this shocker!";
|
||||
case SHOCKER_MODE_SET -> "Shocker mode: %s";
|
||||
case SHOCKER_TRIGGERED -> "Shocked %s!";
|
||||
case RAG_DRY -> "The rag is dry - soak it first";
|
||||
case RAG_SOAKED -> "You soaked the rag with chloroform";
|
||||
case RAG_EVAPORATED -> "The chloroform has evaporated";
|
||||
// Bounty
|
||||
case BOUNTY_CREATED -> "Bounty created on %s!";
|
||||
case BOUNTY_CLAIMED -> "You claimed the bounty on %s!";
|
||||
case BOUNTY_EXPIRED -> "Bounty on %s expired";
|
||||
// Cell System
|
||||
case PRISONER_ARRIVED -> "%s has been placed in your cell";
|
||||
case PRISONER_ESCAPED -> "%s has escaped from your cell!";
|
||||
case PRISONER_RELEASED -> "%s has been released from your cell";
|
||||
case CELL_BREACH -> "Your cell wall has been breached!";
|
||||
case CELL_ASSIGNED -> "You have been assigned to %s's cell";
|
||||
case CELL_CREATED -> "Cell created successfully";
|
||||
case CELL_DELETED -> "Cell deleted";
|
||||
case CELL_RENAMED -> "Cell renamed to: %s";
|
||||
// Generic
|
||||
case INFO -> "%s";
|
||||
case WARNING -> "%s";
|
||||
case ERROR -> "%s";
|
||||
};
|
||||
public static String getTranslationKey(MessageCategory category) {
|
||||
return "msg.tiedup.system." + category.name().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -373,11 +270,11 @@ public class SystemMessageManager {
|
||||
|
||||
/**
|
||||
* Send a system message to a player's action bar.
|
||||
* Uses category template with entity name.
|
||||
* Uses translatable category with entity name as argument.
|
||||
*
|
||||
* @param player The player to send to
|
||||
* @param category The message category
|
||||
* @param actor The entity performing the action (for %s replacement)
|
||||
* @param actor The entity performing the action (for %1$s replacement)
|
||||
*/
|
||||
public static void sendToPlayer(
|
||||
Player player,
|
||||
@@ -387,14 +284,23 @@ public class SystemMessageManager {
|
||||
if (player == null) return;
|
||||
|
||||
String actorName = actor != null ? getEntityName(actor) : "Someone";
|
||||
String message = String.format(getMessageTemplate(category), actorName);
|
||||
MutableComponent component = Component.translatable(
|
||||
getTranslationKey(category), actorName
|
||||
).withStyle(style -> style.withColor(getCategoryColor(category)));
|
||||
|
||||
sendToPlayer(player, message, getCategoryColor(category));
|
||||
player.displayClientMessage(component, true);
|
||||
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[SystemMessage] -> {}: {} ({})",
|
||||
player.getName().getString(),
|
||||
getTranslationKey(category),
|
||||
actorName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a system message to a player's action bar.
|
||||
* Uses category template without entity (for messages that don't need one).
|
||||
* Uses translatable category without arguments.
|
||||
*
|
||||
* @param player The player to send to
|
||||
* @param category The message category
|
||||
@@ -402,12 +308,22 @@ public class SystemMessageManager {
|
||||
public static void sendToPlayer(Player player, MessageCategory category) {
|
||||
if (player == null) return;
|
||||
|
||||
String message = getMessageTemplate(category);
|
||||
sendToPlayer(player, message, getCategoryColor(category));
|
||||
MutableComponent component = Component.translatable(
|
||||
getTranslationKey(category)
|
||||
).withStyle(style -> style.withColor(getCategoryColor(category)));
|
||||
|
||||
player.displayClientMessage(component, true);
|
||||
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[SystemMessage] -> {}: {}",
|
||||
player.getName().getString(),
|
||||
getTranslationKey(category)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a custom system message to a player's action bar.
|
||||
* Uses literal text (for dynamic/non-translatable messages).
|
||||
*
|
||||
* @param player The player to send to
|
||||
* @param message The message to send
|
||||
@@ -420,12 +336,10 @@ public class SystemMessageManager {
|
||||
) {
|
||||
if (player == null || message == null) return;
|
||||
|
||||
// Works on both client and server
|
||||
MutableComponent component = Component.literal(message).withStyle(
|
||||
style -> style.withColor(color)
|
||||
);
|
||||
|
||||
// true = action bar (above hotbar), false = chat
|
||||
player.displayClientMessage(component, true);
|
||||
|
||||
TiedUpMod.LOGGER.debug(
|
||||
@@ -437,6 +351,7 @@ public class SystemMessageManager {
|
||||
|
||||
/**
|
||||
* Send a custom system message to a player's CHAT.
|
||||
* Uses literal text (for dynamic/non-translatable messages).
|
||||
*
|
||||
* @param player The player to send to
|
||||
* @param message The message to send
|
||||
@@ -453,7 +368,6 @@ public class SystemMessageManager {
|
||||
style -> style.withColor(color)
|
||||
);
|
||||
|
||||
// false = chat
|
||||
player.displayClientMessage(component, false);
|
||||
|
||||
TiedUpMod.LOGGER.debug(
|
||||
@@ -464,19 +378,21 @@ public class SystemMessageManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a system message to a player's CHAT using a category.
|
||||
* Send a system message to a player's CHAT using a translatable category.
|
||||
*/
|
||||
public static void sendChatToPlayer(
|
||||
Player player,
|
||||
MessageCategory category
|
||||
) {
|
||||
if (player == null) return;
|
||||
String message = getMessageTemplate(category);
|
||||
sendChatToPlayer(player, message, getCategoryColor(category));
|
||||
MutableComponent component = Component.translatable(
|
||||
getTranslationKey(category)
|
||||
).withStyle(style -> style.withColor(getCategoryColor(category)));
|
||||
player.displayClientMessage(component, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a system message to a player's CHAT using a category and actor.
|
||||
* Send a system message to a player's CHAT using a translatable category and actor.
|
||||
*/
|
||||
public static void sendChatToPlayer(
|
||||
Player player,
|
||||
@@ -485,8 +401,10 @@ public class SystemMessageManager {
|
||||
) {
|
||||
if (player == null) return;
|
||||
String actorName = actor != null ? getEntityName(actor) : "Someone";
|
||||
String message = String.format(getMessageTemplate(category), actorName);
|
||||
sendChatToPlayer(player, message, getCategoryColor(category));
|
||||
MutableComponent component = Component.translatable(
|
||||
getTranslationKey(category), actorName
|
||||
).withStyle(style -> style.withColor(getCategoryColor(category)));
|
||||
player.displayClientMessage(component, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -494,7 +412,7 @@ public class SystemMessageManager {
|
||||
*
|
||||
* @param player The player to send to
|
||||
* @param category The category (for color)
|
||||
* @param customMessage The custom message text
|
||||
* @param customMessage The custom message text (literal, not translatable)
|
||||
*/
|
||||
public static void sendToPlayer(
|
||||
Player player,
|
||||
@@ -505,7 +423,27 @@ public class SystemMessageManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message with resistance info appended.
|
||||
* Send a translatable message with string arguments.
|
||||
* Uses the category's translation key and color.
|
||||
*
|
||||
* @param player The player to send to
|
||||
* @param category The message category
|
||||
* @param args Arguments for the translation (replaces %1$s, %2$s, etc.)
|
||||
*/
|
||||
public static void sendTranslatable(
|
||||
Player player,
|
||||
MessageCategory category,
|
||||
Object... args
|
||||
) {
|
||||
if (player == null) return;
|
||||
MutableComponent component = Component.translatable(
|
||||
getTranslationKey(category), args
|
||||
).withStyle(style -> style.withColor(getCategoryColor(category)));
|
||||
player.displayClientMessage(component, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a translatable message with resistance info appended.
|
||||
*
|
||||
* @param player The player to send to
|
||||
* @param category The message category
|
||||
@@ -518,9 +456,13 @@ public class SystemMessageManager {
|
||||
) {
|
||||
if (player == null) return;
|
||||
|
||||
String message =
|
||||
getMessageTemplate(category) + " (Resistance: " + resistance + ")";
|
||||
sendToPlayer(player, message, getCategoryColor(category));
|
||||
MutableComponent component = Component.translatable(
|
||||
getTranslationKey(category)
|
||||
).append(
|
||||
Component.translatable("msg.tiedup.system.resistance_suffix", resistance)
|
||||
).withStyle(style -> style.withColor(getCategoryColor(category)));
|
||||
|
||||
player.displayClientMessage(component, true);
|
||||
}
|
||||
|
||||
// SEND METHODS - TO NEARBY PLAYERS
|
||||
@@ -695,15 +637,10 @@ public class SystemMessageManager {
|
||||
*/
|
||||
public static void sendJobAssigned(Player player, String itemName) {
|
||||
if (player == null) return;
|
||||
String message = String.format(
|
||||
getMessageTemplate(MessageCategory.SLAVE_JOB_ASSIGNED),
|
||||
itemName
|
||||
);
|
||||
sendToPlayer(
|
||||
player,
|
||||
message,
|
||||
getCategoryColor(MessageCategory.SLAVE_JOB_ASSIGNED)
|
||||
);
|
||||
MutableComponent component = Component.translatable(
|
||||
getTranslationKey(MessageCategory.SLAVE_JOB_ASSIGNED), itemName
|
||||
).withStyle(style -> style.withColor(getCategoryColor(MessageCategory.SLAVE_JOB_ASSIGNED)));
|
||||
player.displayClientMessage(component, true);
|
||||
}
|
||||
|
||||
// UTILITY
|
||||
|
||||
@@ -581,6 +581,14 @@ public class TiedUpMod {
|
||||
LOGGER.info(
|
||||
"Registered FurnitureServerReloadListener for data-driven furniture definitions"
|
||||
);
|
||||
|
||||
// Data-driven room theme definitions (server-side, from data/<namespace>/tiedup_room_themes/)
|
||||
event.addListener(
|
||||
new com.tiedup.remake.worldgen.RoomThemeReloadListener()
|
||||
);
|
||||
LOGGER.info(
|
||||
"Registered RoomThemeReloadListener for data-driven room themes"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.tiedup.remake.dialogue;
|
||||
import static com.tiedup.remake.util.GameConstants.*;
|
||||
|
||||
import com.tiedup.remake.dialogue.EmotionalContext.EmotionType;
|
||||
import com.tiedup.remake.items.base.ItemGag;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
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.util.GagMaterial;
|
||||
import com.tiedup.remake.util.PhoneticMapper;
|
||||
import com.tiedup.remake.util.SyllableAnalyzer;
|
||||
@@ -58,8 +60,11 @@ public class GagTalkManager {
|
||||
) {
|
||||
LivingEntity entity = kidnapped.asLivingEntity();
|
||||
GagMaterial material = GagMaterial.CLOTH;
|
||||
if (gagStack.getItem() instanceof ItemGag gag) {
|
||||
material = gag.getGagMaterial();
|
||||
// V2: check data-driven GaggingComponent first
|
||||
GaggingComponent gaggingComp = DataDrivenBondageItem.getComponent(
|
||||
gagStack, ComponentType.GAGGING, GaggingComponent.class);
|
||||
if (gaggingComp != null && gaggingComp.getMaterial() != null) {
|
||||
material = gaggingComp.getMaterial();
|
||||
}
|
||||
|
||||
// 1. EFFET DE SUFFOCATION (Si message trop long)
|
||||
@@ -514,8 +519,13 @@ public class GagTalkManager {
|
||||
}
|
||||
|
||||
GagMaterial material = GagMaterial.CLOTH;
|
||||
if (gagStack != null && gagStack.getItem() instanceof ItemGag gag) {
|
||||
material = gag.getGagMaterial();
|
||||
if (gagStack != null && !gagStack.isEmpty()) {
|
||||
// V2: check data-driven GaggingComponent first
|
||||
GaggingComponent comp = DataDrivenBondageItem.getComponent(
|
||||
gagStack, ComponentType.GAGGING, GaggingComponent.class);
|
||||
if (comp != null && comp.getMaterial() != null) {
|
||||
material = comp.getMaterial();
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder muffled = new StringBuilder();
|
||||
|
||||
@@ -5,9 +5,9 @@ import com.tiedup.remake.dialogue.DialogueBridge;
|
||||
import com.tiedup.remake.entities.EntityMaster;
|
||||
import com.tiedup.remake.entities.ai.master.MasterPlaceBlockGoal;
|
||||
import com.tiedup.remake.entities.ai.master.MasterState;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.items.base.BindVariant;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import com.tiedup.remake.network.master.PacketOpenPetRequestMenu;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
@@ -51,7 +51,7 @@ public class PetRequestManager {
|
||||
double dist = master.distanceTo(pet);
|
||||
if (dist > MAX_DISTANCE) {
|
||||
pet.sendSystemMessage(
|
||||
Component.literal("You are too far from your Master to talk.")
|
||||
Component.translatable("entity.tiedup.pet.too_far_to_talk")
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -95,7 +95,7 @@ public class PetRequestManager {
|
||||
double dist = master.distanceTo(pet);
|
||||
if (dist > MAX_DISTANCE) {
|
||||
pet.sendSystemMessage(
|
||||
Component.literal("You are too far from your Master.")
|
||||
Component.translatable("entity.tiedup.pet.too_far_from_master")
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -109,7 +109,7 @@ public class PetRequestManager {
|
||||
|
||||
// Display what the player "says"
|
||||
pet.sendSystemMessage(
|
||||
Component.literal("You: " + request.getPlayerText())
|
||||
Component.translatable("entity.tiedup.pet.you_say", request.getPlayerText())
|
||||
);
|
||||
|
||||
// Handle specific request
|
||||
@@ -171,9 +171,7 @@ public class PetRequestManager {
|
||||
|
||||
// Put dogbind on player (if not already tied)
|
||||
if (!state.isTiedUp()) {
|
||||
ItemStack dogbind = new ItemStack(
|
||||
ModItems.getBind(BindVariant.DOGBINDER)
|
||||
);
|
||||
ItemStack dogbind = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "dogbinder"));
|
||||
state.equip(BodyRegionV2.ARMS, dogbind);
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[PetRequestManager] Equipped dogbind on {} for walk",
|
||||
@@ -228,7 +226,7 @@ public class PetRequestManager {
|
||||
}
|
||||
|
||||
// Master equips armbinder on pet (classic pet play restraint)
|
||||
ItemStack bind = new ItemStack(ModItems.getBind(BindVariant.ARMBINDER));
|
||||
ItemStack bind = DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "armbinder"));
|
||||
state.equip(BodyRegionV2.ARMS, bind);
|
||||
|
||||
DialogueBridge.talkTo(master, pet, "petplay.tie_accept");
|
||||
|
||||
@@ -2,16 +2,19 @@ package com.tiedup.remake.dispenser;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.items.base.*;
|
||||
import net.minecraft.world.level.block.DispenserBlock;
|
||||
|
||||
/**
|
||||
* Registration class for all TiedUp dispenser behaviors.
|
||||
*
|
||||
* Allows dispensers to:
|
||||
* - Equip bondage items (binds, gags, blindfolds, collars, earplugs, clothes) on entities
|
||||
* - Equip bondage items (via data-driven V2 system) on entities
|
||||
* - Shoot rope arrows
|
||||
*
|
||||
* Note: V1 per-variant dispenser registrations have been removed.
|
||||
* Data-driven bondage items use a single universal dispenser behavior
|
||||
* registered via DataDrivenBondageItem system.
|
||||
*
|
||||
* Based on original behaviors package from 1.12.2
|
||||
*/
|
||||
public class DispenserBehaviors {
|
||||
@@ -25,72 +28,15 @@ public class DispenserBehaviors {
|
||||
"[DispenserBehaviors] Registering dispenser behaviors..."
|
||||
);
|
||||
|
||||
registerBindBehaviors();
|
||||
registerGagBehaviors();
|
||||
registerBlindfoldBehaviors();
|
||||
registerCollarBehaviors();
|
||||
registerEarplugsBehaviors();
|
||||
registerClothesBehaviors();
|
||||
registerRopeArrowBehavior();
|
||||
registerBondageItemBehavior();
|
||||
|
||||
TiedUpMod.LOGGER.info(
|
||||
"[DispenserBehaviors] Dispenser behaviors registered!"
|
||||
);
|
||||
}
|
||||
|
||||
private static void registerBindBehaviors() {
|
||||
var behavior = GenericBondageDispenseBehavior.forBind();
|
||||
for (BindVariant variant : BindVariant.values()) {
|
||||
DispenserBlock.registerBehavior(
|
||||
ModItems.getBind(variant),
|
||||
behavior
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static void registerGagBehaviors() {
|
||||
var behavior = GenericBondageDispenseBehavior.forGag();
|
||||
for (GagVariant variant : GagVariant.values()) {
|
||||
DispenserBlock.registerBehavior(ModItems.getGag(variant), behavior);
|
||||
}
|
||||
DispenserBlock.registerBehavior(ModItems.MEDICAL_GAG.get(), behavior);
|
||||
DispenserBlock.registerBehavior(ModItems.HOOD.get(), behavior);
|
||||
}
|
||||
|
||||
private static void registerBlindfoldBehaviors() {
|
||||
var behavior = GenericBondageDispenseBehavior.forBlindfold();
|
||||
for (BlindfoldVariant variant : BlindfoldVariant.values()) {
|
||||
DispenserBlock.registerBehavior(
|
||||
ModItems.getBlindfold(variant),
|
||||
behavior
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static void registerCollarBehaviors() {
|
||||
var behavior = GenericBondageDispenseBehavior.forCollar();
|
||||
DispenserBlock.registerBehavior(
|
||||
ModItems.CLASSIC_COLLAR.get(),
|
||||
behavior
|
||||
);
|
||||
DispenserBlock.registerBehavior(ModItems.SHOCK_COLLAR.get(), behavior);
|
||||
DispenserBlock.registerBehavior(
|
||||
ModItems.SHOCK_COLLAR_AUTO.get(),
|
||||
behavior
|
||||
);
|
||||
DispenserBlock.registerBehavior(ModItems.GPS_COLLAR.get(), behavior);
|
||||
}
|
||||
|
||||
private static void registerEarplugsBehaviors() {
|
||||
var behavior = GenericBondageDispenseBehavior.forEarplugs();
|
||||
for (EarplugsVariant variant : EarplugsVariant.values()) {
|
||||
DispenserBlock.registerBehavior(
|
||||
ModItems.getEarplugs(variant),
|
||||
behavior
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static void registerClothesBehaviors() {
|
||||
DispenserBlock.registerBehavior(
|
||||
ModItems.CLOTHES.get(),
|
||||
@@ -98,6 +44,17 @@ public class DispenserBehaviors {
|
||||
);
|
||||
}
|
||||
|
||||
private static void registerBondageItemBehavior() {
|
||||
// Single registration for the V2 data-driven item singleton.
|
||||
// GenericBondageDispenseBehavior inspects the stack's definition to determine behavior.
|
||||
if (com.tiedup.remake.v2.bondage.V2BondageItems.DATA_DRIVEN_ITEM != null) {
|
||||
DispenserBlock.registerBehavior(
|
||||
com.tiedup.remake.v2.bondage.V2BondageItems.DATA_DRIVEN_ITEM.get(),
|
||||
GenericBondageDispenseBehavior.forAnyDataDriven()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static void registerRopeArrowBehavior() {
|
||||
DispenserBlock.registerBehavior(
|
||||
ModItems.ROPE_ARROW.get(),
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
package com.tiedup.remake.dispenser;
|
||||
|
||||
import com.tiedup.remake.items.base.*;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
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 java.util.function.BiConsumer;
|
||||
import java.util.function.Predicate;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/**
|
||||
* Generic dispenser behavior for equipping bondage items.
|
||||
* Replaces individual BindDispenseBehavior, GagDispenseBehavior, etc.
|
||||
* Uses V2 data-driven item detection instead of V1 class checks.
|
||||
*
|
||||
* Use factory methods to create instances for each bondage type.
|
||||
*/
|
||||
@@ -18,23 +23,23 @@ public class GenericBondageDispenseBehavior
|
||||
extends EquipBondageDispenseBehavior
|
||||
{
|
||||
|
||||
private final Class<? extends Item> itemClass;
|
||||
private final Predicate<ItemStack> itemCheck;
|
||||
private final Predicate<IBondageState> canEquipCheck;
|
||||
private final BiConsumer<IBondageState, ItemStack> equipAction;
|
||||
|
||||
private GenericBondageDispenseBehavior(
|
||||
Class<? extends Item> itemClass,
|
||||
Predicate<ItemStack> itemCheck,
|
||||
Predicate<IBondageState> canEquipCheck,
|
||||
BiConsumer<IBondageState, ItemStack> equipAction
|
||||
) {
|
||||
this.itemClass = itemClass;
|
||||
this.itemCheck = itemCheck;
|
||||
this.canEquipCheck = canEquipCheck;
|
||||
this.equipAction = equipAction;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isValidItem(ItemStack stack) {
|
||||
return !stack.isEmpty() && itemClass.isInstance(stack.getItem());
|
||||
return !stack.isEmpty() && itemCheck.test(stack);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -51,9 +56,35 @@ public class GenericBondageDispenseBehavior
|
||||
|
||||
// Factory Methods
|
||||
|
||||
/** Universal behavior for the V2 data-driven item singleton. Dispatches by region. */
|
||||
public static GenericBondageDispenseBehavior forAnyDataDriven() {
|
||||
return new GenericBondageDispenseBehavior(
|
||||
stack -> DataDrivenItemRegistry.get(stack) != null,
|
||||
state -> true, // let equip() handle the check
|
||||
(state, stack) -> {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
if (def == null) return;
|
||||
java.util.Set<BodyRegionV2> regions = def.occupiedRegions();
|
||||
if (regions.contains(BodyRegionV2.ARMS) && !state.isTiedUp()) {
|
||||
state.equip(BodyRegionV2.ARMS, stack);
|
||||
} else if (regions.contains(BodyRegionV2.MOUTH) && !state.isGagged()) {
|
||||
state.equip(BodyRegionV2.MOUTH, stack);
|
||||
} else if (regions.contains(BodyRegionV2.EYES) && !state.isBlindfolded()) {
|
||||
state.equip(BodyRegionV2.EYES, stack);
|
||||
} else if (regions.contains(BodyRegionV2.NECK) && !state.hasCollar()) {
|
||||
state.equip(BodyRegionV2.NECK, stack);
|
||||
} else if (regions.contains(BodyRegionV2.EARS) && !state.hasEarplugs()) {
|
||||
state.equip(BodyRegionV2.EARS, stack);
|
||||
} else if (regions.contains(BodyRegionV2.HANDS) && !state.hasMittens()) {
|
||||
state.equip(BodyRegionV2.HANDS, stack);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public static GenericBondageDispenseBehavior forBind() {
|
||||
return new GenericBondageDispenseBehavior(
|
||||
ItemBind.class,
|
||||
BindModeHelper::isBindItem,
|
||||
state -> !state.isTiedUp(),
|
||||
(s, i) -> s.equip(BodyRegionV2.ARMS, i)
|
||||
);
|
||||
@@ -61,7 +92,7 @@ public class GenericBondageDispenseBehavior
|
||||
|
||||
public static GenericBondageDispenseBehavior forGag() {
|
||||
return new GenericBondageDispenseBehavior(
|
||||
ItemGag.class,
|
||||
stack -> DataDrivenBondageItem.getComponent(stack, ComponentType.GAGGING, GaggingComponent.class) != null,
|
||||
state -> !state.isGagged(),
|
||||
(s, i) -> s.equip(BodyRegionV2.MOUTH, i)
|
||||
);
|
||||
@@ -69,7 +100,10 @@ public class GenericBondageDispenseBehavior
|
||||
|
||||
public static GenericBondageDispenseBehavior forBlindfold() {
|
||||
return new GenericBondageDispenseBehavior(
|
||||
ItemBlindfold.class,
|
||||
stack -> {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null && def.occupiedRegions().contains(BodyRegionV2.EYES);
|
||||
},
|
||||
state -> !state.isBlindfolded(),
|
||||
(s, i) -> s.equip(BodyRegionV2.EYES, i)
|
||||
);
|
||||
@@ -77,7 +111,7 @@ public class GenericBondageDispenseBehavior
|
||||
|
||||
public static GenericBondageDispenseBehavior forCollar() {
|
||||
return new GenericBondageDispenseBehavior(
|
||||
ItemCollar.class,
|
||||
CollarHelper::isCollar,
|
||||
state -> !state.hasCollar(),
|
||||
(s, i) -> s.equip(BodyRegionV2.NECK, i)
|
||||
);
|
||||
@@ -85,7 +119,10 @@ public class GenericBondageDispenseBehavior
|
||||
|
||||
public static GenericBondageDispenseBehavior forEarplugs() {
|
||||
return new GenericBondageDispenseBehavior(
|
||||
ItemEarplugs.class,
|
||||
stack -> {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null && def.occupiedRegions().contains(BodyRegionV2.EARS);
|
||||
},
|
||||
state -> !state.hasEarplugs(),
|
||||
(s, i) -> s.equip(BodyRegionV2.EARS, i)
|
||||
);
|
||||
|
||||
@@ -4,7 +4,12 @@ import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.entities.damsel.components.*;
|
||||
import com.tiedup.remake.entities.skins.Gender;
|
||||
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.v2.bondage.PoseTypeHelper;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import com.tiedup.remake.v2.bondage.component.ComponentType;
|
||||
import com.tiedup.remake.v2.bondage.component.GaggingComponent;
|
||||
import com.tiedup.remake.v2.bondage.component.BlindingComponent;
|
||||
import com.tiedup.remake.state.ICaptor;
|
||||
import com.tiedup.remake.state.IRestrainable;
|
||||
import com.tiedup.remake.state.IRestrainableEntity;
|
||||
@@ -455,16 +460,8 @@ public abstract class AbstractTiedUpNpc
|
||||
*/
|
||||
public boolean isDogPose() {
|
||||
ItemStack bind = this.getEquipment(BodyRegionV2.ARMS);
|
||||
if (
|
||||
bind.getItem() instanceof
|
||||
com.tiedup.remake.items.base.ItemBind itemBind
|
||||
) {
|
||||
return (
|
||||
itemBind.getPoseType() ==
|
||||
com.tiedup.remake.items.base.PoseType.DOG
|
||||
);
|
||||
}
|
||||
return false;
|
||||
if (bind.isEmpty()) return false;
|
||||
return PoseTypeHelper.getPoseType(bind) == com.tiedup.remake.items.base.PoseType.DOG;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -679,12 +676,10 @@ public abstract class AbstractTiedUpNpc
|
||||
// Exception: collar owner can leash even if not tied
|
||||
if (this.hasCollar()) {
|
||||
ItemStack collar = this.getEquipment(BodyRegionV2.NECK);
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
if (collarItem.getOwners(collar).contains(player.getUUID())) {
|
||||
if (CollarHelper.isOwner(collar, player)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -801,20 +796,14 @@ public abstract class AbstractTiedUpNpc
|
||||
public boolean hasGaggingEffect() {
|
||||
ItemStack gag = this.getEquipment(BodyRegionV2.MOUTH);
|
||||
if (gag.isEmpty()) return false;
|
||||
return (
|
||||
gag.getItem() instanceof
|
||||
com.tiedup.remake.items.base.IHasGaggingEffect
|
||||
);
|
||||
return DataDrivenBondageItem.getComponent(gag, ComponentType.GAGGING, GaggingComponent.class) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasBlindingEffect() {
|
||||
ItemStack blindfold = this.getEquipment(BodyRegionV2.EYES);
|
||||
if (blindfold.isEmpty()) return false;
|
||||
return (
|
||||
blindfold.getItem() instanceof
|
||||
com.tiedup.remake.items.base.IHasBlindingEffect
|
||||
);
|
||||
return DataDrivenBondageItem.getComponent(blindfold, ComponentType.BLINDING, BlindingComponent.class) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -990,9 +979,9 @@ public abstract class AbstractTiedUpNpc
|
||||
ItemStack collar = this.getEquipment(BodyRegionV2.NECK);
|
||||
if (collar.isEmpty()) return false;
|
||||
|
||||
if (!(collar.getItem() instanceof ItemCollar itemCollar)) return false;
|
||||
if (!CollarHelper.isCollar(collar)) return false;
|
||||
|
||||
java.util.UUID cellId = itemCollar.getCellId(collar);
|
||||
java.util.UUID cellId = CollarHelper.getCellId(collar);
|
||||
if (cellId == null) return false;
|
||||
|
||||
// Get cell position from registry
|
||||
@@ -1096,9 +1085,7 @@ public abstract class AbstractTiedUpNpc
|
||||
public boolean hasShockCollar() {
|
||||
ItemStack collar = this.getEquipment(BodyRegionV2.NECK);
|
||||
if (collar.isEmpty()) return false;
|
||||
return (
|
||||
collar.getItem() instanceof com.tiedup.remake.items.ItemShockCollar
|
||||
);
|
||||
return com.tiedup.remake.v2.bondage.CollarHelper.canShock(collar);
|
||||
}
|
||||
|
||||
// BONDAGE SERVICE (delegated to BondageManager)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.tiedup.remake.entities;
|
||||
|
||||
import com.tiedup.remake.core.SystemMessageManager;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.items.base.BindVariant;
|
||||
import com.tiedup.remake.items.base.GagVariant;
|
||||
import com.tiedup.remake.items.base.ItemCollar;
|
||||
import com.tiedup.remake.v2.bondage.CollarHelper;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.util.MessageDispatcher;
|
||||
import com.tiedup.remake.util.teleport.Position;
|
||||
@@ -53,14 +52,11 @@ public class BondageServiceHandler {
|
||||
if (!npc.hasCollar()) return false;
|
||||
|
||||
ItemStack collar = npc.getEquipment(BodyRegionV2.NECK);
|
||||
if (collar.getItem() instanceof ItemCollar itemCollar) {
|
||||
return (
|
||||
itemCollar.hasCellAssigned(collar) &&
|
||||
itemCollar.isBondageServiceEnabled(collar)
|
||||
CollarHelper.hasCellAssigned(collar) &&
|
||||
CollarHelper.isBondageServiceEnabled(collar)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the custom bondage service message from the collar.
|
||||
@@ -70,13 +66,11 @@ public class BondageServiceHandler {
|
||||
public String getMessage() {
|
||||
if (npc.hasCollar()) {
|
||||
ItemStack collar = npc.getEquipment(BodyRegionV2.NECK);
|
||||
if (collar.getItem() instanceof ItemCollar itemCollar) {
|
||||
String message = itemCollar.getServiceSentence(collar);
|
||||
String message = CollarHelper.getServiceSentence(collar);
|
||||
if (message != null && !message.isEmpty()) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
return DEFAULT_MESSAGE;
|
||||
}
|
||||
|
||||
@@ -119,9 +113,9 @@ public class BondageServiceHandler {
|
||||
*/
|
||||
private void capturePlayer(Player player) {
|
||||
ItemStack collar = npc.getEquipment(BodyRegionV2.NECK);
|
||||
if (!(collar.getItem() instanceof ItemCollar itemCollar)) return;
|
||||
if (!CollarHelper.isCollar(collar)) return;
|
||||
|
||||
java.util.UUID cellId = itemCollar.getCellId(collar);
|
||||
java.util.UUID cellId = CollarHelper.getCellId(collar);
|
||||
if (cellId == null) return;
|
||||
|
||||
// Get cell position from registry
|
||||
@@ -141,7 +135,7 @@ public class BondageServiceHandler {
|
||||
);
|
||||
|
||||
// Warn masters if configured
|
||||
warnOwners(player, itemCollar, collar);
|
||||
warnOwners(player, collar);
|
||||
|
||||
// Get player's kidnapped state
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
@@ -149,18 +143,18 @@ public class BondageServiceHandler {
|
||||
// Apply bondage
|
||||
state.equip(
|
||||
BodyRegionV2.ARMS,
|
||||
new ItemStack(ModItems.getBind(BindVariant.ROPES))
|
||||
DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "ropes"))
|
||||
);
|
||||
state.equip(
|
||||
BodyRegionV2.MOUTH,
|
||||
new ItemStack(ModItems.getGag(GagVariant.BALL_GAG))
|
||||
DataDrivenBondageItem.createStack(ResourceLocation.fromNamespaceAndPath("tiedup", "ball_gag"))
|
||||
);
|
||||
|
||||
// Teleport to cell
|
||||
state.teleportToPosition(cellPosition);
|
||||
|
||||
// Tie to pole if configured on collar
|
||||
if (itemCollar.shouldTieToPole(collar)) {
|
||||
if (CollarHelper.shouldTieToPole(collar)) {
|
||||
state.tieToClosestPole(3);
|
||||
}
|
||||
}
|
||||
@@ -178,10 +172,9 @@ public class BondageServiceHandler {
|
||||
*/
|
||||
private void warnOwners(
|
||||
Player capturedPlayer,
|
||||
ItemCollar itemCollar,
|
||||
ItemStack collarStack
|
||||
) {
|
||||
if (!itemCollar.shouldWarnMasters(collarStack)) {
|
||||
if (!CollarHelper.shouldWarnMasters(collarStack)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,7 +184,7 @@ public class BondageServiceHandler {
|
||||
capturedPlayer.getName().getString() +
|
||||
" via bondage service!";
|
||||
|
||||
for (UUID ownerUUID : itemCollar.getOwners(collarStack)) {
|
||||
for (UUID ownerUUID : CollarHelper.getOwners(collarStack)) {
|
||||
Player owner = npc.level().getPlayerByUUID(ownerUUID);
|
||||
if (owner != null) {
|
||||
SystemMessageManager.sendChatToPlayer(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.tiedup.remake.entities;
|
||||
|
||||
import com.tiedup.remake.core.ModConfig;
|
||||
import com.tiedup.remake.core.SettingsAccessor;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.dialogue.EntityDialogueManager;
|
||||
import java.util.UUID;
|
||||
@@ -179,7 +179,7 @@ public class DamselRewardTracker {
|
||||
// Thank the savior via dialogue
|
||||
damsel.talkToPlayersInRadius(
|
||||
EntityDialogueManager.DialogueCategory.DAMSEL_FREED,
|
||||
ModConfig.SERVER.dialogueRadius.get()
|
||||
SettingsAccessor.getDialogueRadius()
|
||||
);
|
||||
|
||||
TiedUpMod.LOGGER.info(
|
||||
|
||||
@@ -5,7 +5,7 @@ import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.entities.damsel.components.*;
|
||||
import com.tiedup.remake.entities.skins.DamselSkinManager;
|
||||
import com.tiedup.remake.entities.skins.Gender;
|
||||
import com.tiedup.remake.items.base.ItemCollar;
|
||||
import com.tiedup.remake.v2.bondage.CollarHelper;
|
||||
import com.tiedup.remake.state.ICaptor;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import java.util.UUID;
|
||||
@@ -527,8 +527,8 @@ public class EntityDamsel
|
||||
if (!this.hasCollar()) return false;
|
||||
|
||||
ItemStack collar = this.getEquipment(BodyRegionV2.NECK);
|
||||
if (!(collar.getItem() instanceof ItemCollar collarItem)) return false;
|
||||
if (!collarItem.getOwners(collar).contains(commander.getUUID())) {
|
||||
if (!CollarHelper.isCollar(collar)) return false;
|
||||
if (!CollarHelper.isOwner(collar, commander.getUUID())) {
|
||||
if (!this.isGagged()) {
|
||||
com.tiedup.remake.dialogue.EntityDialogueManager.talkByDialogueId(
|
||||
this,
|
||||
@@ -644,8 +644,8 @@ public class EntityDamsel
|
||||
player instanceof net.minecraft.server.level.ServerPlayer sp
|
||||
) {
|
||||
sp.displayClientMessage(
|
||||
Component.literal(
|
||||
"This NPC needs a collar before you can feed them."
|
||||
Component.translatable(
|
||||
"entity.tiedup.damsel.needs_collar_to_feed"
|
||||
).withStyle(ChatFormatting.RED),
|
||||
true
|
||||
);
|
||||
@@ -653,15 +653,15 @@ public class EntityDamsel
|
||||
return net.minecraft.world.InteractionResult.FAIL;
|
||||
}
|
||||
ItemStack collar = this.getEquipment(BodyRegionV2.NECK);
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
if (!collarItem.isOwner(collar, player)) {
|
||||
if (CollarHelper.isCollar(collar)) {
|
||||
if (!CollarHelper.isOwner(collar, player)) {
|
||||
if (
|
||||
player instanceof
|
||||
net.minecraft.server.level.ServerPlayer sp
|
||||
) {
|
||||
sp.displayClientMessage(
|
||||
Component.literal(
|
||||
"You don't own this NPC's collar."
|
||||
Component.translatable(
|
||||
"entity.tiedup.damsel.not_collar_owner"
|
||||
).withStyle(ChatFormatting.RED),
|
||||
true
|
||||
);
|
||||
@@ -675,8 +675,8 @@ public class EntityDamsel
|
||||
player instanceof net.minecraft.server.level.ServerPlayer sp
|
||||
) {
|
||||
sp.displayClientMessage(
|
||||
Component.literal(
|
||||
"This NPC can't eat that right now."
|
||||
Component.translatable(
|
||||
"entity.tiedup.damsel.cant_eat_now"
|
||||
).withStyle(ChatFormatting.RED),
|
||||
true
|
||||
);
|
||||
@@ -693,9 +693,9 @@ public class EntityDamsel
|
||||
this.hasCollar()
|
||||
) {
|
||||
ItemStack collar = this.getEquipment(BodyRegionV2.NECK);
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
if (CollarHelper.isCollar(collar)) {
|
||||
if (
|
||||
collarItem.isOwner(collar, player) &&
|
||||
CollarHelper.isOwner(collar, player) &&
|
||||
player instanceof
|
||||
net.minecraft.server.level.ServerPlayer serverPlayer
|
||||
) {
|
||||
@@ -772,7 +772,7 @@ public class EntityDamsel
|
||||
state == com.tiedup.remake.prison.PrisonerState.IMPRISONED ||
|
||||
state == com.tiedup.remake.prison.PrisonerState.WORKING
|
||||
) {
|
||||
com.tiedup.remake.prison.service.PrisonerService.get().escape(
|
||||
com.tiedup.remake.prison.service.EscapeMonitorService.get().escape(
|
||||
serverLevel,
|
||||
uuid,
|
||||
"player_death"
|
||||
@@ -822,8 +822,8 @@ public class EntityDamsel
|
||||
public String getTargetRelation(Player player) {
|
||||
if (hasCollar()) {
|
||||
ItemStack collar = getEquipment(BodyRegionV2.NECK);
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
if (collarItem.isOwner(collar, player)) {
|
||||
if (CollarHelper.isCollar(collar)) {
|
||||
if (CollarHelper.isOwner(collar, player)) {
|
||||
return "master";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.tiedup.remake.entities;
|
||||
|
||||
import com.tiedup.remake.blocks.entity.KidnapBombBlockEntity;
|
||||
import com.tiedup.remake.core.ModConfig;
|
||||
import com.tiedup.remake.core.SettingsAccessor;
|
||||
import com.tiedup.remake.util.KidnapExplosion;
|
||||
import javax.annotation.Nullable;
|
||||
@@ -56,7 +55,7 @@ public class EntityKidnapBomb extends PrimedTnt {
|
||||
this.setPos(x, y, z);
|
||||
double d0 = level.random.nextDouble() * (Math.PI * 2);
|
||||
this.setDeltaMovement(-Math.sin(d0) * 0.02, 0.2, -Math.cos(d0) * 0.02);
|
||||
this.setFuse(ModConfig.SERVER.kidnapBombFuse.get());
|
||||
this.setFuse(SettingsAccessor.getKidnapBombFuse());
|
||||
|
||||
this.xo = x;
|
||||
this.yo = y;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.tiedup.remake.entities;
|
||||
|
||||
import com.tiedup.remake.cells.CellDataV2;
|
||||
import com.tiedup.remake.cells.CellRegistryV2;
|
||||
import com.tiedup.remake.core.SettingsAccessor;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.dialogue.IDialogueSpeaker;
|
||||
@@ -11,31 +10,23 @@ import com.tiedup.remake.entities.ai.kidnapper.*;
|
||||
import com.tiedup.remake.entities.kidnapper.components.KidnapperAggressionSystem;
|
||||
import com.tiedup.remake.entities.skins.Gender;
|
||||
import com.tiedup.remake.entities.skins.KidnapperSkinManager;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.items.base.ItemCollar;
|
||||
import com.tiedup.remake.v2.bondage.CollarHelper;
|
||||
import com.tiedup.remake.personality.PersonalityType;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
import com.tiedup.remake.state.ICaptor;
|
||||
import com.tiedup.remake.state.IRestrainable;
|
||||
import com.tiedup.remake.util.KidnappedHelper;
|
||||
import com.tiedup.remake.util.tasks.ItemTask;
|
||||
import com.tiedup.remake.util.tasks.SaleLoader;
|
||||
import com.tiedup.remake.util.teleport.Position;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import javax.annotation.Nullable;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.nbt.Tag;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.syncher.EntityDataAccessor;
|
||||
import net.minecraft.network.syncher.EntityDataSerializers;
|
||||
import net.minecraft.network.syncher.SynchedEntityData;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.*;
|
||||
import net.minecraft.world.entity.ai.attributes.AttributeSupplier;
|
||||
@@ -44,7 +35,6 @@ import net.minecraft.world.entity.ai.goal.*;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
/**
|
||||
* EntityKidnapper - Aggressive NPC that captures and enslaves players.
|
||||
@@ -87,48 +77,6 @@ public class EntityKidnapper
|
||||
implements ICaptor, IDialogueSpeaker
|
||||
{
|
||||
|
||||
// CAPTIVE PRIORITY (for prisoner replacement)
|
||||
|
||||
/**
|
||||
* Priority levels for captives when replacing prisoners in cells.
|
||||
* Higher priority captives will cause lower priority prisoners to be released.
|
||||
*/
|
||||
public enum CaptivePriority {
|
||||
DAMSEL(1),
|
||||
DAMSEL_SHINY(2),
|
||||
PLAYER(3);
|
||||
|
||||
private final int priority;
|
||||
|
||||
CaptivePriority(int priority) {
|
||||
this.priority = priority;
|
||||
}
|
||||
|
||||
public int getPriority() {
|
||||
return priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the priority for an entity.
|
||||
*
|
||||
* @param entity The entity to check
|
||||
* @return The captive priority
|
||||
*/
|
||||
public static CaptivePriority fromEntity(LivingEntity entity) {
|
||||
if (entity instanceof Player) return PLAYER;
|
||||
if (entity instanceof EntityDamselShiny) return DAMSEL_SHINY;
|
||||
if (entity instanceof EntityDamsel) return DAMSEL;
|
||||
return DAMSEL; // Default for unknown entities
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this priority is higher than another.
|
||||
*/
|
||||
public boolean isHigherThan(CaptivePriority other) {
|
||||
return this.priority > other.priority;
|
||||
}
|
||||
}
|
||||
|
||||
// DATA SYNC (Client-Server)
|
||||
|
||||
/**
|
||||
@@ -194,11 +142,11 @@ public class EntityKidnapper
|
||||
/** Whether this kidnapper is currently dogwalking a prisoner. */
|
||||
private boolean dogwalking = false;
|
||||
|
||||
/** Items stolen from players via KidnapperThiefGoal. Dropped at 100% on death. */
|
||||
private final List<ItemStack> stolenItems = new ArrayList<>();
|
||||
/** Loot manager for stolen items, collar keys, and death drops. */
|
||||
private final com.tiedup.remake.entities.kidnapper.components.KidnapperLootManager lootManager;
|
||||
|
||||
/** Collar keys generated when collaring captives. Dropped at 20% on death. */
|
||||
private final List<ItemStack> collarKeys = new ArrayList<>();
|
||||
/** Dialogue speaker implementation for IDialogueSpeaker delegation. */
|
||||
private final com.tiedup.remake.entities.kidnapper.components.KidnapperDialogue dialogue;
|
||||
|
||||
/** Job manager handles job assignment and tracking. */
|
||||
private final KidnapperJobManager jobManager = new KidnapperJobManager(
|
||||
@@ -275,6 +223,18 @@ public class EntityKidnapper
|
||||
DATA_THEME_COLOR
|
||||
);
|
||||
|
||||
// Initialize loot manager
|
||||
this.lootManager =
|
||||
new com.tiedup.remake.entities.kidnapper.components.KidnapperLootManager(
|
||||
new com.tiedup.remake.entities.kidnapper.hosts.LootHost(this)
|
||||
);
|
||||
|
||||
// Initialize dialogue component
|
||||
this.dialogue =
|
||||
new com.tiedup.remake.entities.kidnapper.components.KidnapperDialogue(
|
||||
new com.tiedup.remake.entities.kidnapper.hosts.DialogueHost(this)
|
||||
);
|
||||
|
||||
// Initialize state manager
|
||||
this.stateManager =
|
||||
new com.tiedup.remake.entities.kidnapper.components.KidnapperStateManager(
|
||||
@@ -585,40 +545,35 @@ public class EntityKidnapper
|
||||
// All real IBondageState instances are IRestrainable, so the cast is safe.
|
||||
// Log a warning if the invariant is ever broken (future-proofing).
|
||||
|
||||
private void withRestrainable(String method, IBondageState captive, java.util.function.Consumer<IRestrainable> action) {
|
||||
if (captive instanceof IRestrainable r) action.accept(r);
|
||||
else logBridgeWarning(method, captive);
|
||||
}
|
||||
|
||||
private boolean testRestrainable(String method, IBondageState captive, java.util.function.Predicate<IRestrainable> test) {
|
||||
if (captive instanceof IRestrainable r) return test.test(r);
|
||||
logBridgeWarning(method, captive);
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCaptive(IBondageState captive) {
|
||||
if (captive instanceof IRestrainable r) {
|
||||
captiveManager.addCaptive(r);
|
||||
} else {
|
||||
logBridgeWarning("addCaptive", captive);
|
||||
}
|
||||
withRestrainable("addCaptive", captive, captiveManager::addCaptive);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeCaptive(IBondageState captive, boolean transportState) {
|
||||
if (captive instanceof IRestrainable r) {
|
||||
captiveManager.removeCaptive(r, transportState);
|
||||
} else {
|
||||
logBridgeWarning("removeCaptive", captive);
|
||||
}
|
||||
withRestrainable("removeCaptive", captive, r -> captiveManager.removeCaptive(r, transportState));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canCapture(IBondageState captive) {
|
||||
if (
|
||||
captive instanceof IRestrainable r
|
||||
) return captiveManager.canCapture(r);
|
||||
logBridgeWarning("canCapture", captive);
|
||||
return false;
|
||||
return testRestrainable("canCapture", captive, captiveManager::canCapture);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canRelease(IBondageState captive) {
|
||||
if (
|
||||
captive instanceof IRestrainable r
|
||||
) return captiveManager.canRelease(r);
|
||||
logBridgeWarning("canRelease", captive);
|
||||
return false;
|
||||
return testRestrainable("canRelease", captive, captiveManager::canRelease);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -633,29 +588,17 @@ public class EntityKidnapper
|
||||
|
||||
@Override
|
||||
public void onCaptiveLogout(IBondageState captive) {
|
||||
if (captive instanceof IRestrainable r) {
|
||||
captiveManager.onCaptiveLogout(r);
|
||||
} else {
|
||||
logBridgeWarning("onCaptiveLogout", captive);
|
||||
}
|
||||
withRestrainable("onCaptiveLogout", captive, captiveManager::onCaptiveLogout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCaptiveReleased(IBondageState captive) {
|
||||
if (captive instanceof IRestrainable r) {
|
||||
captiveManager.onCaptiveReleased(r);
|
||||
} else {
|
||||
logBridgeWarning("onCaptiveReleased", captive);
|
||||
}
|
||||
withRestrainable("onCaptiveReleased", captive, captiveManager::onCaptiveReleased);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCaptiveStruggle(IBondageState captive) {
|
||||
if (captive instanceof IRestrainable r) {
|
||||
captiveManager.onCaptiveStruggle(r);
|
||||
} else {
|
||||
logBridgeWarning("onCaptiveStruggle", captive);
|
||||
}
|
||||
withRestrainable("onCaptiveStruggle", captive, captiveManager::onCaptiveStruggle);
|
||||
}
|
||||
|
||||
private void logBridgeWarning(String method, IBondageState captive) {
|
||||
@@ -936,130 +879,15 @@ public class EntityKidnapper
|
||||
super.die(damageSource);
|
||||
}
|
||||
|
||||
/** Token drop chance (5%) */
|
||||
private static final float TOKEN_DROP_CHANCE = 0.05f;
|
||||
|
||||
/**
|
||||
* Prevent taser from dropping when kidnapper dies.
|
||||
* Taser is unique to kidnappers and should not be obtainable by players.
|
||||
* Also handles token drop (5% chance).
|
||||
* Delegates loot logic to KidnapperLootManager.
|
||||
*/
|
||||
@Override
|
||||
protected void dropEquipment() {
|
||||
// Check main hand for taser - don't drop it
|
||||
ItemStack mainHand = this.getItemBySlot(
|
||||
net.minecraft.world.entity.EquipmentSlot.MAINHAND
|
||||
);
|
||||
if (
|
||||
!mainHand.isEmpty() &&
|
||||
mainHand.getItem() instanceof com.tiedup.remake.items.ItemTaser
|
||||
) {
|
||||
this.setItemSlot(
|
||||
net.minecraft.world.entity.EquipmentSlot.MAINHAND,
|
||||
ItemStack.EMPTY
|
||||
);
|
||||
}
|
||||
|
||||
// Check off hand too
|
||||
ItemStack offHand = this.getItemBySlot(
|
||||
net.minecraft.world.entity.EquipmentSlot.OFFHAND
|
||||
);
|
||||
if (
|
||||
!offHand.isEmpty() &&
|
||||
offHand.getItem() instanceof com.tiedup.remake.items.ItemTaser
|
||||
) {
|
||||
this.setItemSlot(
|
||||
net.minecraft.world.entity.EquipmentSlot.OFFHAND,
|
||||
ItemStack.EMPTY
|
||||
);
|
||||
}
|
||||
|
||||
lootManager.dropEquipment();
|
||||
super.dropEquipment();
|
||||
|
||||
// Token drop: 5% chance when killed
|
||||
if (
|
||||
!this.level().isClientSide &&
|
||||
this.getRandom().nextFloat() < TOKEN_DROP_CHANCE
|
||||
) {
|
||||
ItemStack token = new ItemStack(
|
||||
com.tiedup.remake.items.ModItems.TOKEN.get()
|
||||
);
|
||||
this.spawnAtLocation(token);
|
||||
TiedUpMod.LOGGER.info(
|
||||
"[EntityKidnapper] {} dropped a token on death!",
|
||||
this.getNpcName()
|
||||
);
|
||||
}
|
||||
|
||||
// Bind item drops from kidnapper inventory
|
||||
if (!this.level().isClientSide) {
|
||||
KidnapperItemSelector.SelectionResult selection =
|
||||
getItemSelection();
|
||||
if (selection != null) {
|
||||
float dropChance = 0.15f;
|
||||
if (
|
||||
!selection.bind.isEmpty() &&
|
||||
this.getRandom().nextFloat() < dropChance
|
||||
) {
|
||||
this.spawnAtLocation(selection.bind.copy());
|
||||
}
|
||||
if (
|
||||
selection.hasGag() &&
|
||||
this.getRandom().nextFloat() < dropChance
|
||||
) {
|
||||
this.spawnAtLocation(selection.gag.copy());
|
||||
}
|
||||
if (
|
||||
selection.hasMittens() &&
|
||||
this.getRandom().nextFloat() < dropChance
|
||||
) {
|
||||
this.spawnAtLocation(selection.mittens.copy());
|
||||
}
|
||||
if (
|
||||
selection.hasEarplugs() &&
|
||||
this.getRandom().nextFloat() < dropChance
|
||||
) {
|
||||
this.spawnAtLocation(selection.earplugs.copy());
|
||||
}
|
||||
if (
|
||||
selection.hasBlindfold() &&
|
||||
this.getRandom().nextFloat() < dropChance
|
||||
) {
|
||||
this.spawnAtLocation(selection.blindfold.copy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop stolen items at 100% rate (player's property)
|
||||
if (!this.level().isClientSide) {
|
||||
for (ItemStack stolen : this.stolenItems) {
|
||||
if (!stolen.isEmpty()) {
|
||||
this.spawnAtLocation(stolen);
|
||||
}
|
||||
}
|
||||
if (!this.stolenItems.isEmpty()) {
|
||||
TiedUpMod.LOGGER.info(
|
||||
"[EntityKidnapper] {} dropped {} stolen item(s) on death",
|
||||
this.getNpcName(),
|
||||
this.stolenItems.size()
|
||||
);
|
||||
}
|
||||
this.stolenItems.clear();
|
||||
}
|
||||
|
||||
// Drop collar keys at 20% rate
|
||||
if (!this.level().isClientSide) {
|
||||
for (ItemStack key : this.collarKeys) {
|
||||
if (!key.isEmpty() && this.getRandom().nextFloat() < 0.20f) {
|
||||
this.spawnAtLocation(key);
|
||||
TiedUpMod.LOGGER.info(
|
||||
"[EntityKidnapper] {} dropped a collar key on death",
|
||||
this.getNpcName()
|
||||
);
|
||||
}
|
||||
}
|
||||
this.collarKeys.clear();
|
||||
}
|
||||
lootManager.dropPostEquipment();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1255,27 +1083,8 @@ public class EntityKidnapper
|
||||
// Delegate to data serializer
|
||||
dataSerializer.saveToNBT(tag);
|
||||
|
||||
// Save stolen items
|
||||
if (!this.stolenItems.isEmpty()) {
|
||||
ListTag stolenTag = new ListTag();
|
||||
for (ItemStack stack : this.stolenItems) {
|
||||
if (!stack.isEmpty()) {
|
||||
stolenTag.add(stack.save(new CompoundTag()));
|
||||
}
|
||||
}
|
||||
tag.put("StolenItems", stolenTag);
|
||||
}
|
||||
|
||||
// Save collar keys
|
||||
if (!this.collarKeys.isEmpty()) {
|
||||
ListTag keysTag = new ListTag();
|
||||
for (ItemStack key : this.collarKeys) {
|
||||
if (!key.isEmpty()) {
|
||||
keysTag.add(key.save(new CompoundTag()));
|
||||
}
|
||||
}
|
||||
tag.put("CollarKeys", keysTag);
|
||||
}
|
||||
// Delegate stolen items and collar keys to loot manager
|
||||
lootManager.saveToNBT(tag);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1285,29 +1094,8 @@ public class EntityKidnapper
|
||||
// Delegate to data serializer
|
||||
dataSerializer.loadFromNBT(tag);
|
||||
|
||||
// Load stolen items
|
||||
this.stolenItems.clear();
|
||||
if (tag.contains("StolenItems", Tag.TAG_LIST)) {
|
||||
ListTag stolenTag = tag.getList("StolenItems", Tag.TAG_COMPOUND);
|
||||
for (int i = 0; i < stolenTag.size(); i++) {
|
||||
ItemStack stack = ItemStack.of(stolenTag.getCompound(i));
|
||||
if (!stack.isEmpty()) {
|
||||
this.stolenItems.add(stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load collar keys
|
||||
this.collarKeys.clear();
|
||||
if (tag.contains("CollarKeys", Tag.TAG_LIST)) {
|
||||
ListTag keysTag = tag.getList("CollarKeys", Tag.TAG_COMPOUND);
|
||||
for (int i = 0; i < keysTag.size(); i++) {
|
||||
ItemStack key = ItemStack.of(keysTag.getCompound(i));
|
||||
if (!key.isEmpty()) {
|
||||
this.collarKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Delegate stolen items and collar keys to loot manager
|
||||
lootManager.loadFromNBT(tag);
|
||||
}
|
||||
|
||||
// ESCAPE TRACKING METHODS
|
||||
@@ -1367,65 +1155,17 @@ public class EntityKidnapper
|
||||
if (!this.hasCollar()) return false;
|
||||
|
||||
ItemStack collar = this.getEquipment(BodyRegionV2.NECK);
|
||||
if (collar.getItem() instanceof ItemCollar collarItem) {
|
||||
return collarItem.isOwner(collar, player);
|
||||
return CollarHelper.isOwner(collar, player);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Damage reduction multiplier against monsters (50% damage taken) */
|
||||
private static final float MONSTER_DAMAGE_REDUCTION = 0.5f;
|
||||
|
||||
@Override
|
||||
public boolean hurt(
|
||||
net.minecraft.world.damagesource.DamageSource source,
|
||||
float amount
|
||||
) {
|
||||
float finalAmount = amount;
|
||||
|
||||
// Track the attacker for fight back system
|
||||
if (source.getEntity() instanceof LivingEntity attacker) {
|
||||
aggressionSystem.setLastAttacker(attacker);
|
||||
|
||||
// Punish prisoners who dare to attack us
|
||||
if (
|
||||
!this.level().isClientSide &&
|
||||
attacker instanceof ServerPlayer player
|
||||
) {
|
||||
punishAttackingPrisoner(player);
|
||||
|
||||
// Expire PROTECTED status if player attacks a kidnapper
|
||||
// Attacking a kidnapper voids your safe-exit window
|
||||
if (this.level() instanceof ServerLevel serverLevel) {
|
||||
com.tiedup.remake.prison.PrisonerManager pm =
|
||||
com.tiedup.remake.prison.PrisonerManager.get(
|
||||
serverLevel
|
||||
);
|
||||
if (
|
||||
pm.getState(player.getUUID()) ==
|
||||
com.tiedup.remake.prison.PrisonerState.PROTECTED
|
||||
) {
|
||||
pm.expireProtection(
|
||||
player.getUUID(),
|
||||
serverLevel.getGameTime()
|
||||
);
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[EntityKidnapper] Expired PROTECTED status for {} (attacked kidnapper)",
|
||||
player.getName().getString()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Camp kidnappers take reduced damage from monsters (trained fighters)
|
||||
if (
|
||||
this.getAssociatedStructure() != null &&
|
||||
attacker instanceof net.minecraft.world.entity.monster.Monster
|
||||
) {
|
||||
finalAmount = amount * MONSTER_DAMAGE_REDUCTION;
|
||||
}
|
||||
}
|
||||
return super.hurt(source, finalAmount);
|
||||
float modified = aggressionSystem.processIncomingDamage(source, amount);
|
||||
if (modified <= 0) return false;
|
||||
return super.hurt(source, modified);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1435,7 +1175,7 @@ public class EntityKidnapper
|
||||
* @param player The player who attacked
|
||||
* @return true if punishment was applied
|
||||
*/
|
||||
protected boolean punishAttackingPrisoner(ServerPlayer player) {
|
||||
public boolean punishAttackingPrisoner(ServerPlayer player) {
|
||||
return captiveManager.punishAttackingPrisoner(player);
|
||||
}
|
||||
|
||||
@@ -1915,76 +1655,31 @@ public class EntityKidnapper
|
||||
com.tiedup.remake.util.MessageDispatcher.actionTo(this, player, action);
|
||||
}
|
||||
|
||||
/** Dialogue cooldown timer (ticks remaining before next dialogue) */
|
||||
private int dialogueCooldown = 0;
|
||||
|
||||
@Override
|
||||
public String getDialogueName() {
|
||||
return this.getNpcName();
|
||||
return dialogue.getDialogueName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SpeakerType getSpeakerType() {
|
||||
return SpeakerType.KIDNAPPER;
|
||||
return dialogue.getSpeakerType();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public PersonalityType getSpeakerPersonality() {
|
||||
// Map kidnapper theme to a personality-like behavior
|
||||
KidnapperTheme theme = this.getTheme();
|
||||
if (theme == null) {
|
||||
return PersonalityType.CALM;
|
||||
}
|
||||
|
||||
return switch (theme) {
|
||||
case ROPE, SHIBARI -> PersonalityType.CALM; // Traditional, methodical
|
||||
case TAPE, LEATHER, CHAIN -> PersonalityType.FIERCE; // Rough, aggressive
|
||||
case MEDICAL, ASYLUM -> PersonalityType.PROUD; // Clinical, professional
|
||||
case LATEX, RIBBON -> PersonalityType.PLAYFUL; // Playful, teasing
|
||||
case BEAM, WRAP -> PersonalityType.CURIOUS; // Experimental, modern
|
||||
};
|
||||
return dialogue.getSpeakerPersonality();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSpeakerMood() {
|
||||
// Kidnappers mood is based on:
|
||||
// - Having a captive (+20)
|
||||
// - Current state (varies)
|
||||
int mood = 50;
|
||||
|
||||
if (this.hasCaptives()) {
|
||||
mood += 20;
|
||||
}
|
||||
|
||||
// State-based adjustment
|
||||
KidnapperState state = this.getCurrentState();
|
||||
if (state != null) {
|
||||
mood += switch (state) {
|
||||
case SELLING -> 10; // Excited about sale
|
||||
case JOB_WATCH -> 5;
|
||||
case GUARD -> 0;
|
||||
case CAPTURE -> 15; // Hunting excitement
|
||||
case PUNISH -> -10; // Stern
|
||||
case PATROL, IDLE, HUNT -> 0; // Neutral for patrolling/hunting
|
||||
case ALERT -> -5; // Concerned
|
||||
case TRANSPORT -> 5;
|
||||
};
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, mood));
|
||||
return dialogue.getSpeakerMood();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public String getTargetRelation(Player player) {
|
||||
// Check if this kidnapper is holding the player captive
|
||||
IRestrainable captive = this.getCaptive();
|
||||
if (captive != null && captive.asLivingEntity() == player) {
|
||||
return "captor";
|
||||
}
|
||||
|
||||
return null;
|
||||
return dialogue.getTargetRelation(player);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1994,12 +1689,12 @@ public class EntityKidnapper
|
||||
|
||||
@Override
|
||||
public int getDialogueCooldown() {
|
||||
return this.dialogueCooldown;
|
||||
return dialogue.getDialogueCooldown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setDialogueCooldown(int ticks) {
|
||||
this.dialogueCooldown = ticks;
|
||||
dialogue.setDialogueCooldown(ticks);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2007,9 +1702,7 @@ public class EntityKidnapper
|
||||
* Called from the main tick method.
|
||||
*/
|
||||
protected void tickDialogueCooldown() {
|
||||
if (this.dialogueCooldown > 0) {
|
||||
this.dialogueCooldown--;
|
||||
}
|
||||
dialogue.tickDialogueCooldown();
|
||||
}
|
||||
|
||||
// STOLEN ITEMS (Thief Goal)
|
||||
@@ -2019,9 +1712,7 @@ public class EntityKidnapper
|
||||
* Called by KidnapperThiefGoal when stealing from a player.
|
||||
*/
|
||||
public void addStolenItem(ItemStack stack) {
|
||||
if (!stack.isEmpty()) {
|
||||
this.stolenItems.add(stack.copy());
|
||||
}
|
||||
lootManager.addStolenItem(stack);
|
||||
}
|
||||
|
||||
// COLLAR KEYS (Capture Goal)
|
||||
@@ -2031,8 +1722,6 @@ public class EntityKidnapper
|
||||
* Called by KidnapperCaptureGoal when collaring a captive.
|
||||
*/
|
||||
public void addCollarKey(ItemStack keyStack) {
|
||||
if (!keyStack.isEmpty()) {
|
||||
this.collarKeys.add(keyStack.copy());
|
||||
}
|
||||
lootManager.addCollarKey(keyStack);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,9 +335,9 @@ public class EntityKidnapperArcher
|
||||
public int getBindChanceForTarget(UUID targetUUID) {
|
||||
int hitCount = targetHitCounts.getOrDefault(targetUUID, 0);
|
||||
int baseChance =
|
||||
com.tiedup.remake.core.ModConfig.SERVER.archerArrowBindChanceBase.get();
|
||||
SettingsAccessor.getArcherArrowBindChanceBase();
|
||||
int perHitChance =
|
||||
com.tiedup.remake.core.ModConfig.SERVER.archerArrowBindChancePerHit.get();
|
||||
SettingsAccessor.getArcherArrowBindChancePerHit();
|
||||
|
||||
int chance = baseChance + (hitCount * perHitChance);
|
||||
return Math.min(chance, ARCHER_MAX_BIND_CHANCE);
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.tiedup.remake.entities;
|
||||
|
||||
import static com.tiedup.remake.util.GameConstants.*;
|
||||
|
||||
import com.tiedup.remake.core.ModConfig;
|
||||
import com.tiedup.remake.core.SettingsAccessor;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.dialogue.SpeakerType;
|
||||
@@ -18,6 +17,7 @@ import com.tiedup.remake.state.IBondageState;
|
||||
import com.tiedup.remake.state.ICaptor;
|
||||
import com.tiedup.remake.util.MessageDispatcher;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
@@ -355,7 +355,7 @@ public class EntityKidnapperMerchant extends EntityKidnapperElite {
|
||||
private void transitionToHostile(LivingEntity attacker) {
|
||||
currentState = MerchantState.HOSTILE;
|
||||
attackerUUID = attacker.getUUID();
|
||||
hostileCooldownTicks = ModConfig.SERVER.merchantHostileDuration.get();
|
||||
hostileCooldownTicks = SettingsAccessor.getMerchantHostileDuration();
|
||||
entityData.set(DATA_MERCHANT_STATE, MerchantState.HOSTILE.getId());
|
||||
|
||||
// Equip kidnapper items
|
||||
@@ -535,8 +535,8 @@ public class EntityKidnapperMerchant extends EntityKidnapperElite {
|
||||
addGuaranteedUtilities();
|
||||
|
||||
// RANDOM TRADES
|
||||
int min = ModConfig.SERVER.merchantMinTrades.get();
|
||||
int max = ModConfig.SERVER.merchantMaxTrades.get();
|
||||
int min = SettingsAccessor.getMerchantMinTrades();
|
||||
int max = SettingsAccessor.getMerchantMaxTrades();
|
||||
int count = min + this.random.nextInt(Math.max(1, max - min + 1));
|
||||
|
||||
// Collect all mod items
|
||||
@@ -615,78 +615,10 @@ public class EntityKidnapperMerchant extends EntityKidnapperElite {
|
||||
private List<ItemStack> collectAllModItems() {
|
||||
List<ItemStack> items = new ArrayList<>();
|
||||
|
||||
// All binds (15)
|
||||
// Items with colors get multiple variants (one per color)
|
||||
for (BindVariant variant : BindVariant.values()) {
|
||||
if (variant.supportsColor()) {
|
||||
// Add one item per color (16 standard colors)
|
||||
for (ItemColor color : ItemColor.values()) {
|
||||
if (
|
||||
color != ItemColor.CAUTION && color != ItemColor.CLEAR
|
||||
) {
|
||||
ItemStack stack = new ItemStack(
|
||||
ModItems.getBind(variant)
|
||||
);
|
||||
KidnapperItemSelector.applyColor(stack, color);
|
||||
items.add(stack);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No color variants
|
||||
items.add(new ItemStack(ModItems.getBind(variant)));
|
||||
}
|
||||
}
|
||||
|
||||
// All gags (19)
|
||||
for (GagVariant variant : GagVariant.values()) {
|
||||
if (variant.supportsColor()) {
|
||||
// Add one item per color
|
||||
for (ItemColor color : ItemColor.values()) {
|
||||
// TAPE_GAG can use caution/clear, others only standard colors
|
||||
if (
|
||||
variant == GagVariant.TAPE_GAG ||
|
||||
(color != ItemColor.CAUTION && color != ItemColor.CLEAR)
|
||||
) {
|
||||
ItemStack stack = new ItemStack(
|
||||
ModItems.getGag(variant)
|
||||
);
|
||||
KidnapperItemSelector.applyColor(stack, color);
|
||||
items.add(stack);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items.add(new ItemStack(ModItems.getGag(variant)));
|
||||
}
|
||||
}
|
||||
|
||||
// All blindfolds (2) - BOTH support colors
|
||||
for (BlindfoldVariant variant : BlindfoldVariant.values()) {
|
||||
if (variant.supportsColor()) {
|
||||
// Add one item per color (16 standard colors)
|
||||
for (ItemColor color : ItemColor.values()) {
|
||||
if (
|
||||
color != ItemColor.CAUTION && color != ItemColor.CLEAR
|
||||
) {
|
||||
ItemStack stack = new ItemStack(
|
||||
ModItems.getBlindfold(variant)
|
||||
);
|
||||
KidnapperItemSelector.applyColor(stack, color);
|
||||
items.add(stack);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items.add(new ItemStack(ModItems.getBlindfold(variant)));
|
||||
}
|
||||
}
|
||||
|
||||
// Earplugs - no color support
|
||||
for (EarplugsVariant variant : EarplugsVariant.values()) {
|
||||
items.add(new ItemStack(ModItems.getEarplugs(variant)));
|
||||
}
|
||||
|
||||
// Mittens - no color support
|
||||
for (MittensVariant variant : MittensVariant.values()) {
|
||||
items.add(new ItemStack(ModItems.getMittens(variant)));
|
||||
// All data-driven bondage items (binds, gags, blindfolds, earplugs, mittens, collars, etc.)
|
||||
for (com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition def :
|
||||
com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry.getAll()) {
|
||||
items.add(DataDrivenBondageItem.createStack(def.id()));
|
||||
}
|
||||
|
||||
// Knives - no color support
|
||||
@@ -694,16 +626,11 @@ public class EntityKidnapperMerchant extends EntityKidnapperElite {
|
||||
items.add(new ItemStack(ModItems.getKnife(variant)));
|
||||
}
|
||||
|
||||
// Complex items
|
||||
items.add(new ItemStack(ModItems.CLASSIC_COLLAR.get()));
|
||||
items.add(new ItemStack(ModItems.SHOCK_COLLAR.get()));
|
||||
items.add(new ItemStack(ModItems.GPS_COLLAR.get()));
|
||||
// Tools
|
||||
items.add(new ItemStack(ModItems.WHIP.get()));
|
||||
// BLACKLIST: TASER (too powerful)
|
||||
// BLACKLIST: LOCKPICK (now in guaranteed utilities)
|
||||
// BLACKLIST: MASTER_KEY (too OP - unlocks everything)
|
||||
items.add(new ItemStack(ModItems.MEDICAL_GAG.get()));
|
||||
items.add(new ItemStack(ModItems.HOOD.get()));
|
||||
items.add(new ItemStack(ModItems.CLOTHES.get()));
|
||||
|
||||
return items;
|
||||
@@ -716,21 +643,21 @@ public class EntityKidnapperMerchant extends EntityKidnapperElite {
|
||||
int minPrice, maxPrice;
|
||||
switch (tier) {
|
||||
case 4:
|
||||
minPrice = ModConfig.SERVER.tier4PriceMin.get();
|
||||
maxPrice = ModConfig.SERVER.tier4PriceMax.get();
|
||||
minPrice = SettingsAccessor.getTier4PriceMin();
|
||||
maxPrice = SettingsAccessor.getTier4PriceMax();
|
||||
break;
|
||||
case 3:
|
||||
minPrice = ModConfig.SERVER.tier3PriceMin.get();
|
||||
maxPrice = ModConfig.SERVER.tier3PriceMax.get();
|
||||
minPrice = SettingsAccessor.getTier3PriceMin();
|
||||
maxPrice = SettingsAccessor.getTier3PriceMax();
|
||||
break;
|
||||
case 2:
|
||||
minPrice = ModConfig.SERVER.tier2PriceMin.get();
|
||||
maxPrice = ModConfig.SERVER.tier2PriceMax.get();
|
||||
minPrice = SettingsAccessor.getTier2PriceMin();
|
||||
maxPrice = SettingsAccessor.getTier2PriceMax();
|
||||
break;
|
||||
case 1:
|
||||
default:
|
||||
minPrice = ModConfig.SERVER.tier1PriceMin.get();
|
||||
maxPrice = ModConfig.SERVER.tier1PriceMax.get();
|
||||
minPrice = SettingsAccessor.getTier1PriceMin();
|
||||
maxPrice = SettingsAccessor.getTier1PriceMax();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -749,13 +676,13 @@ public class EntityKidnapperMerchant extends EntityKidnapperElite {
|
||||
Item i = item.getItem();
|
||||
|
||||
// Tier 4: GPS collar
|
||||
if (i == ModItems.GPS_COLLAR.get()) {
|
||||
if (com.tiedup.remake.v2.bondage.CollarHelper.hasGPS(item)) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
// Tier 3: Shock collar, taser, master key
|
||||
if (
|
||||
i == ModItems.SHOCK_COLLAR.get() ||
|
||||
com.tiedup.remake.v2.bondage.CollarHelper.canShock(item) ||
|
||||
i == ModItems.TASER.get() ||
|
||||
i == ModItems.MASTER_KEY.get()
|
||||
) {
|
||||
@@ -764,11 +691,9 @@ public class EntityKidnapperMerchant extends EntityKidnapperElite {
|
||||
|
||||
// Tier 2: Collars, whip, tools, complex items, clothes
|
||||
if (
|
||||
i == ModItems.CLASSIC_COLLAR.get() ||
|
||||
com.tiedup.remake.v2.bondage.CollarHelper.isCollar(item) ||
|
||||
i == ModItems.WHIP.get() ||
|
||||
i == ModItems.LOCKPICK.get() ||
|
||||
i == ModItems.MEDICAL_GAG.get() ||
|
||||
i == ModItems.HOOD.get() ||
|
||||
i instanceof GenericClothes
|
||||
) {
|
||||
return 2;
|
||||
@@ -963,6 +888,10 @@ public class EntityKidnapperMerchant extends EntityKidnapperElite {
|
||||
// Clear trading players to prevent dangling references
|
||||
if (!this.level().isClientSide) {
|
||||
int count = tradingPlayers.size();
|
||||
// Clean up reverse-lookup map BEFORE clearing to prevent memory leak
|
||||
for (UUID playerUuid : tradingPlayers) {
|
||||
playerToMerchant.remove(playerUuid);
|
||||
}
|
||||
this.tradingPlayers.clear();
|
||||
if (count > 0) {
|
||||
TiedUpMod.LOGGER.debug(
|
||||
|
||||
@@ -12,7 +12,7 @@ import com.tiedup.remake.entities.skins.LaborGuardSkinManager;
|
||||
import com.tiedup.remake.labor.LaborTask;
|
||||
import com.tiedup.remake.prison.LaborRecord;
|
||||
import com.tiedup.remake.prison.PrisonerManager;
|
||||
import com.tiedup.remake.prison.service.PrisonerService;
|
||||
import com.tiedup.remake.prison.service.EscapeMonitorService;
|
||||
import com.tiedup.remake.state.IRestrainable;
|
||||
import com.tiedup.remake.util.KidnappedHelper;
|
||||
import com.tiedup.remake.util.MessageDispatcher;
|
||||
@@ -341,7 +341,7 @@ public class EntityLaborGuard extends EntityDamsel {
|
||||
}
|
||||
|
||||
prisoner.sendSystemMessage(
|
||||
Component.literal("Attacking your guard is punished!").withStyle(
|
||||
Component.translatable("entity.tiedup.guard.attack_punished").withStyle(
|
||||
ChatFormatting.DARK_RED
|
||||
)
|
||||
);
|
||||
@@ -520,7 +520,7 @@ public class EntityLaborGuard extends EntityDamsel {
|
||||
labor.setGuardId(null);
|
||||
|
||||
// Trigger escape
|
||||
PrisonerService.get().escape(level, prisonerUUID, reason);
|
||||
EscapeMonitorService.get().escape(level, prisonerUUID, reason);
|
||||
|
||||
// Notify prisoner
|
||||
ServerPlayer prisoner = level
|
||||
@@ -529,8 +529,8 @@ public class EntityLaborGuard extends EntityDamsel {
|
||||
.getPlayer(prisonerUUID);
|
||||
if (prisoner != null) {
|
||||
prisoner.sendSystemMessage(
|
||||
Component.literal(
|
||||
"Your guard has been eliminated! You are free!"
|
||||
Component.translatable(
|
||||
"entity.tiedup.guard.eliminated_free"
|
||||
).withStyle(ChatFormatting.GREEN, ChatFormatting.BOLD)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -447,8 +447,8 @@ public class EntityMaid extends EntityKidnapperElite {
|
||||
player.position().distanceTo(this.position()) <= 50
|
||||
) {
|
||||
player.sendSystemMessage(
|
||||
net.minecraft.network.chat.Component.literal(
|
||||
"The maid has died. Work is paused. A replacement will arrive in 5 minutes."
|
||||
net.minecraft.network.chat.Component.translatable(
|
||||
"entity.tiedup.maid.died_work_paused"
|
||||
).withStyle(net.minecraft.ChatFormatting.GOLD)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -606,8 +606,8 @@ public class EntityMaster extends EntityKidnapperElite {
|
||||
|
||||
// Send warning message to pet
|
||||
pet.sendSystemMessage(
|
||||
Component.literal(
|
||||
this.getNpcName() + " caught you trying to escape!"
|
||||
Component.translatable(
|
||||
"entity.tiedup.master.caught_escaping", this.getNpcName()
|
||||
).withStyle(Style.EMPTY.withColor(MASTER_NAME_COLOR))
|
||||
);
|
||||
}
|
||||
@@ -1072,7 +1072,7 @@ public class EntityMaster extends EntityKidnapperElite {
|
||||
if (
|
||||
tag != null &&
|
||||
tag.getBoolean(
|
||||
com.tiedup.remake.state.HumanChairHelper.NBT_KEY
|
||||
com.tiedup.remake.util.HumanChairHelper.NBT_KEY
|
||||
)
|
||||
) {
|
||||
bindState.unequip(BodyRegionV2.ARMS);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.tiedup.remake.entities;
|
||||
|
||||
import com.tiedup.remake.core.ModConfig;
|
||||
import com.tiedup.remake.core.SettingsAccessor;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
@@ -87,17 +87,15 @@ public class EntityRopeArrow extends AbstractArrow {
|
||||
bindChance = archer.getBindChanceForTarget(target.getUUID());
|
||||
} else {
|
||||
// Other shooters: default 50% chance
|
||||
bindChance = ModConfig.SERVER.ropeArrowBindChance.get();
|
||||
bindChance = SettingsAccessor.getRopeArrowBindChance();
|
||||
}
|
||||
|
||||
// Roll for bind chance
|
||||
int roll = this.random.nextInt(100) + 1;
|
||||
if (roll <= bindChance) {
|
||||
// Success! Bind the target
|
||||
ItemStack ropeItem = new ItemStack(
|
||||
ModItems.getBind(
|
||||
com.tiedup.remake.items.base.BindVariant.ROPES
|
||||
)
|
||||
ItemStack ropeItem = com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem.createStack(
|
||||
net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("tiedup", "ropes")
|
||||
);
|
||||
targetState.equip(BodyRegionV2.ARMS, ropeItem);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user