Polish V2 subsystem: lockpick kinds, package boundaries, client extractions
Architectural debt cleanup on top of the earlier V2 hardening pass.
Minigame:
- LockpickMiniGameState splits the overloaded targetSlot int into a
LockpickTargetKind enum + targetData int. Body-vs-furniture
dispatch is now a simple enum check; the NBT-tag nonce it
previously depended on is gone, along with the AIOOBE risk at
BodyRegionV2.values()[targetSlot].
- PacketLockpickAttempt.handleFurnitureLockpickSuccess takes the
entity and seat id as explicit parameters. Caller pre-validates
both before any side effect, so a corrupted ctx tag can no longer
produce a "Lock picked!" UI with a used lockpick and nothing
unlocked.
Package boundaries:
- client.gltf no longer imports v2.bondage. Render-layer attachment,
DataDrivenItemReloadListener, and GlbValidationReloadListener all
live in v2.client.V2ClientSetup.
- GlbValidationReloadListener moved to v2.bondage.client.diagnostic.
- Reload-listener ordering is preserved via EventPriority (HIGH for
the generic GLB cache clear in GltfClientSetup, LOW for bondage
consumers in V2ClientSetup).
- Removed the unused validateAgainstDefinition stub on GlbValidator.
Extractions from EntityFurniture:
- FurnitureSeatSyncCodec (pipe/semicolon serialization for the
SEAT_ASSIGNMENTS_SYNC entity data field), with 8 unit tests.
- FurnitureClientAnimator (client-only seat-pose kickoff, moved out
of the dual-side entity class).
- EntityFurniture drops ~100 lines with no behavior change.
Interface docs:
- ISeatProvider Javadoc narrowed to reflect that EntityFurniture is
the only implementation; callers that need animation state or
definition reference still downcast.
- FurnitureAuthPredicate.findOccupant uses the interface only.
- AnimationIdBuilder flagged as legacy JSON-era utility (NPC
fallback + MCA mixin).
Artist guide: corrected the "Monster Seat System (Planned)" section
to match the ISeatProvider single-impl reality.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package com.tiedup.remake.v2.bondage.client.diagnostic;
|
||||
|
||||
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 com.tiedup.remake.client.gltf.diagnostic.GlbValidator;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.components.toasts.SystemToast;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.packs.resources.Resource;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
|
||||
import net.minecraft.util.profiling.ProfilerFiller;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Reload listener that validates all data-driven item GLB models on resource
|
||||
* reload (F3+T or startup).
|
||||
*
|
||||
* <p>Must be registered AFTER {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemReloadListener}
|
||||
* so that the item registry is populated before validation runs.</p>
|
||||
*
|
||||
* <p>On each reload:
|
||||
* <ol>
|
||||
* <li>Clears the {@link GlbDiagnosticRegistry}</li>
|
||||
* <li>Iterates all {@link DataDrivenItemDefinition}s</li>
|
||||
* <li>For each definition with a model location, attempts to open the GLB
|
||||
* resource and runs {@link GlbValidator#validate} on it</li>
|
||||
* <li>Records results into the diagnostic registry and logs a summary</li>
|
||||
* </ol>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class GlbValidationReloadListener
|
||||
extends SimplePreparableReloadListener<Void>
|
||||
{
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfValidation");
|
||||
|
||||
@Override
|
||||
protected Void prepare(
|
||||
ResourceManager resourceManager,
|
||||
ProfilerFiller profiler
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void apply(
|
||||
Void nothing,
|
||||
ResourceManager resourceManager,
|
||||
ProfilerFiller profiler
|
||||
) {
|
||||
GlbDiagnosticRegistry.clear();
|
||||
|
||||
Collection<DataDrivenItemDefinition> definitions =
|
||||
DataDrivenItemRegistry.getAll();
|
||||
|
||||
if (definitions.isEmpty()) {
|
||||
LOGGER.warn(
|
||||
"[GltfValidation] No data-driven item definitions found — skipping GLB validation"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
int passed = 0;
|
||||
int withWarnings = 0;
|
||||
int withErrors = 0;
|
||||
|
||||
for (DataDrivenItemDefinition def : definitions) {
|
||||
ResourceLocation modelLoc = def.modelLocation();
|
||||
if (modelLoc == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Optional<Resource> resourceOpt =
|
||||
resourceManager.getResource(modelLoc);
|
||||
|
||||
if (resourceOpt.isEmpty()) {
|
||||
// GLB file not found in any resource pack
|
||||
GlbValidationResult missingResult = GlbValidationResult.of(
|
||||
modelLoc,
|
||||
List.of(new GlbDiagnostic(
|
||||
modelLoc,
|
||||
def.id(),
|
||||
GlbDiagnostic.Severity.ERROR,
|
||||
"MISSING_GLB",
|
||||
"GLB file not found: " + modelLoc
|
||||
+ " (referenced by item " + def.id() + ")"
|
||||
))
|
||||
);
|
||||
GlbDiagnosticRegistry.addResult(missingResult);
|
||||
withErrors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try (InputStream stream = resourceOpt.get().open()) {
|
||||
GlbValidationResult result =
|
||||
GlbValidator.validate(stream, modelLoc);
|
||||
GlbDiagnosticRegistry.addResult(result);
|
||||
|
||||
if (!result.passed()) {
|
||||
withErrors++;
|
||||
} else if (hasWarnings(result)) {
|
||||
withWarnings++;
|
||||
} else {
|
||||
passed++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
GlbValidationResult errorResult = GlbValidationResult.of(
|
||||
modelLoc,
|
||||
List.of(new GlbDiagnostic(
|
||||
modelLoc,
|
||||
def.id(),
|
||||
GlbDiagnostic.Severity.ERROR,
|
||||
"GLB_READ_ERROR",
|
||||
"Failed to read GLB file: " + e.getMessage()
|
||||
))
|
||||
);
|
||||
GlbDiagnosticRegistry.addResult(errorResult);
|
||||
withErrors++;
|
||||
}
|
||||
}
|
||||
|
||||
int total = passed + withWarnings + withErrors;
|
||||
LOGGER.info(
|
||||
"[GltfValidation] Validated {} GLBs: {} passed, {} with warnings, {} with errors",
|
||||
total, passed, withWarnings, withErrors
|
||||
);
|
||||
|
||||
// Show toast notification for errors so artists don't have to check logs
|
||||
if (withErrors > 0) {
|
||||
int errorCount = withErrors;
|
||||
Minecraft.getInstance().tell(() ->
|
||||
Minecraft.getInstance().getToasts().addToast(
|
||||
SystemToast.multiline(
|
||||
Minecraft.getInstance(),
|
||||
SystemToast.SystemToastIds.PACK_LOAD_FAILURE,
|
||||
Component.literal("TiedUp! GLB Validation"),
|
||||
Component.literal(errorCount + " model(s) have errors. Run /tiedup validate")
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasWarnings(GlbValidationResult result) {
|
||||
return result.diagnostics().stream()
|
||||
.anyMatch(d -> d.severity() == GlbDiagnostic.Severity.WARNING);
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,25 @@ import com.tiedup.remake.client.model.CellCoreBakedModel;
|
||||
import com.tiedup.remake.client.renderer.CellCoreRenderer;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.v2.V2BlockEntities;
|
||||
import com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer;
|
||||
import com.tiedup.remake.v2.bondage.client.diagnostic.GlbValidationReloadListener;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemReloadListener;
|
||||
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
|
||||
import com.tiedup.remake.v2.furniture.FurnitureRegistry;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.client.renderer.entity.player.PlayerRenderer;
|
||||
import net.minecraft.client.resources.model.BakedModel;
|
||||
import net.minecraft.client.resources.model.ModelResourceLocation;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.client.event.EntityRenderersEvent;
|
||||
import net.minecraftforge.client.event.ModelEvent;
|
||||
import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
|
||||
import net.minecraftforge.eventbus.api.EventPriority;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import net.minecraftforge.registries.ForgeRegistries;
|
||||
@@ -72,6 +78,49 @@ public class V2ClientSetup {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the V2 bondage item render layer to both player model variants.
|
||||
* The layer queries the equipment capability on every player render, so
|
||||
* registering it once covers all players (local + remote).
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
@SubscribeEvent
|
||||
public static void onAddLayers(EntityRenderersEvent.AddLayers event) {
|
||||
var defaultRenderer = event.getSkin("default");
|
||||
if (defaultRenderer instanceof PlayerRenderer playerRenderer) {
|
||||
playerRenderer.addLayer(
|
||||
new V2BondageRenderLayer<>(playerRenderer)
|
||||
);
|
||||
}
|
||||
var slimRenderer = event.getSkin("slim");
|
||||
if (slimRenderer instanceof PlayerRenderer playerRenderer) {
|
||||
playerRenderer.addLayer(
|
||||
new V2BondageRenderLayer<>(playerRenderer)
|
||||
);
|
||||
}
|
||||
TiedUpMod.LOGGER.info(
|
||||
"[V2ClientSetup] V2 bondage render layer attached to player renderers"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register bondage-specific reload listeners. Uses {@code LOW} priority so
|
||||
* it fires after {@link com.tiedup.remake.client.gltf.GltfClientSetup}
|
||||
* registers the generic GLB cache clear — item definitions must not load
|
||||
* against a stale GLB cache, and the validator must run against a warm
|
||||
* item registry.
|
||||
*/
|
||||
@SubscribeEvent(priority = EventPriority.LOW)
|
||||
public static void onRegisterReloadListeners(
|
||||
RegisterClientReloadListenersEvent event
|
||||
) {
|
||||
event.registerReloadListener(new DataDrivenItemReloadListener());
|
||||
event.registerReloadListener(new GlbValidationReloadListener());
|
||||
TiedUpMod.LOGGER.info(
|
||||
"[V2ClientSetup] Data-driven item + GLB validation reload listeners registered"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register custom icon models for baking.
|
||||
*
|
||||
|
||||
@@ -232,42 +232,19 @@ public class EntityFurniture
|
||||
syncSeatAssignmentsIfServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize {@link #seatAssignments} into {@link #SEAT_ASSIGNMENTS_SYNC}
|
||||
* so tracking clients see the authoritative mapping. No-op on client.
|
||||
*/
|
||||
/** Push seatAssignments into SEAT_ASSIGNMENTS_SYNC. No-op on client. */
|
||||
private void syncSeatAssignmentsIfServer() {
|
||||
if (this.level().isClientSide) return;
|
||||
StringBuilder sb = new StringBuilder(seatAssignments.size() * 40);
|
||||
boolean first = true;
|
||||
for (Map.Entry<UUID, String> entry : seatAssignments.entrySet()) {
|
||||
if (!first) sb.append('|');
|
||||
sb.append(entry.getKey()).append(';').append(entry.getValue());
|
||||
first = false;
|
||||
}
|
||||
this.entityData.set(SEAT_ASSIGNMENTS_SYNC, sb.toString());
|
||||
this.entityData.set(
|
||||
SEAT_ASSIGNMENTS_SYNC,
|
||||
FurnitureSeatSyncCodec.encode(seatAssignments)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse {@link #SEAT_ASSIGNMENTS_SYNC} back into {@link #seatAssignments}.
|
||||
* Called on the client when the server's broadcast arrives. Malformed
|
||||
* entries (bad UUID, empty seat id) are skipped silently; we don't want
|
||||
* to throw on a packet from a future protocol version.
|
||||
*/
|
||||
/** Rebuild seatAssignments from the server's broadcast. */
|
||||
private void applySyncedSeatAssignments(String serialized) {
|
||||
seatAssignments.clear();
|
||||
if (serialized.isEmpty()) return;
|
||||
for (String entry : serialized.split("\\|")) {
|
||||
int sep = entry.indexOf(';');
|
||||
if (sep <= 0 || sep == entry.length() - 1) continue;
|
||||
try {
|
||||
UUID uuid = UUID.fromString(entry.substring(0, sep));
|
||||
String seatId = entry.substring(sep + 1);
|
||||
seatAssignments.put(uuid, seatId);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Corrupt UUID — skip this entry, preserve the rest.
|
||||
}
|
||||
}
|
||||
seatAssignments.putAll(FurnitureSeatSyncCodec.decode(serialized));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -474,98 +451,18 @@ public class EntityFurniture
|
||||
this.transitionTargetState = STATE_OCCUPIED;
|
||||
}
|
||||
|
||||
// Note: the previous client-side startFurnitureAnimationClient call is
|
||||
// no longer needed here — the animation is kicked off by
|
||||
// onSyncedDataUpdated when SEAT_ASSIGNMENTS_SYNC arrives (~1 tick
|
||||
// after mount). The cold-cache retry in AnimationTickHandler still
|
||||
// covers the case where the GLB hasn't parsed yet.
|
||||
// Seat-pose animation is kicked off from onSyncedDataUpdated once
|
||||
// SEAT_ASSIGNMENTS_SYNC arrives (~1 tick after mount). The cold-
|
||||
// cache retry in AnimationTickHandler covers the case where the
|
||||
// GLB hasn't parsed yet.
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-only: (re)start the seat pose animation for a player mounting this
|
||||
* furniture. Safe to call repeatedly — {@link BondageAnimationManager#playFurniture}
|
||||
* replaces any active animation. No-op if the GLB isn't loaded (cold cache),
|
||||
* the seat has no authored clip, or the animation context rejects the setup.
|
||||
*
|
||||
* <p>Called from three sites:</p>
|
||||
* <ul>
|
||||
* <li>{@link #addPassenger} on mount</li>
|
||||
* <li>{@link #onSyncedDataUpdated} when server-side {@code ANIM_STATE} changes</li>
|
||||
* <li>The per-tick retry in {@code AnimationTickHandler} (for cold-cache recovery)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Clip selection mirrors {@link com.tiedup.remake.v2.furniture.client.FurnitureEntityRenderer#resolveActiveAnimation}
|
||||
* for mesh/pose coherence. Fallback chain: state-specific clip → {@code "Occupied"}
|
||||
* → first available clip. This lets artists author optional state-specific poses
|
||||
* ({@code Entering}, {@code Exiting}, {@code Shake}) without requiring all of them.</p>
|
||||
*/
|
||||
public static void startFurnitureAnimationClient(
|
||||
EntityFurniture furniture,
|
||||
Player player
|
||||
) {
|
||||
if (!furniture.level().isClientSide) return;
|
||||
|
||||
SeatDefinition seat = furniture.getSeatForPassenger(player);
|
||||
if (seat == null) return;
|
||||
|
||||
FurnitureDefinition def = furniture.getDefinition();
|
||||
if (def == null) return;
|
||||
|
||||
com.tiedup.remake.v2.furniture.client.FurnitureGltfData gltfData =
|
||||
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.get(
|
||||
def.modelLocation()
|
||||
);
|
||||
if (gltfData == null) return;
|
||||
|
||||
Map<
|
||||
String,
|
||||
com.tiedup.remake.client.gltf.GltfData.AnimationClip
|
||||
> seatClips = gltfData.seatAnimations().get(seat.id());
|
||||
if (seatClips == null || seatClips.isEmpty()) return;
|
||||
|
||||
com.tiedup.remake.client.gltf.GltfData seatSkeleton =
|
||||
gltfData.seatSkeletons().get(seat.id());
|
||||
|
||||
// State-driven clip selection for the player seat armature. Names match
|
||||
// the ARTIST_GUIDE.md "Player Seat Animations" section so artists can
|
||||
// author matching clips. The fallback chain handles missing clips
|
||||
// (state-specific → "Occupied" → first available), so artists only need
|
||||
// to author what they want to customize.
|
||||
String stateClipName = switch (furniture.getAnimState()) {
|
||||
case STATE_OCCUPIED -> "Occupied";
|
||||
case STATE_STRUGGLE -> "Struggle";
|
||||
case STATE_ENTERING -> "Enter";
|
||||
case STATE_EXITING -> "Exit";
|
||||
case STATE_LOCKING -> "LockClose";
|
||||
case STATE_UNLOCKING -> "LockOpen";
|
||||
default -> "Idle";
|
||||
};
|
||||
|
||||
com.tiedup.remake.client.gltf.GltfData.AnimationClip clip =
|
||||
seatClips.get(stateClipName);
|
||||
if (clip == null) clip = seatClips.get("Occupied");
|
||||
if (clip == null) clip = seatClips.values().iterator().next();
|
||||
if (clip == null) return;
|
||||
|
||||
dev.kosmx.playerAnim.core.data.KeyframeAnimation anim =
|
||||
com.tiedup.remake.v2.furniture.client.FurnitureAnimationContext.create(
|
||||
clip,
|
||||
seatSkeleton,
|
||||
seat.blockedRegions()
|
||||
);
|
||||
if (anim != null) {
|
||||
com.tiedup.remake.client.animation.BondageAnimationManager.playFurniture(
|
||||
player,
|
||||
anim
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side: when the synched {@code ANIM_STATE} changes, re-play the seat
|
||||
* pose for each seated player so the authored state-specific clip kicks in.
|
||||
* Without this, a server-side transition (mount entering → occupied, lock
|
||||
* close, struggle start) never propagates to the player's pose.
|
||||
* Client-side: when the synched {@code ANIM_STATE} or seat assignments
|
||||
* change, re-play the seat pose for each seated player so the authored
|
||||
* state-specific clip kicks in. Without this, a server-side transition
|
||||
* (mount entering → occupied, lock close, struggle start) never
|
||||
* propagates to the player's pose.
|
||||
*/
|
||||
@Override
|
||||
public void onSyncedDataUpdated(
|
||||
@@ -574,22 +471,21 @@ public class EntityFurniture
|
||||
super.onSyncedDataUpdated(key);
|
||||
if (!this.level().isClientSide) return;
|
||||
if (ANIM_STATE.equals(key)) {
|
||||
for (Entity passenger : this.getPassengers()) {
|
||||
if (passenger instanceof Player player) {
|
||||
startFurnitureAnimationClient(this, player);
|
||||
}
|
||||
}
|
||||
restartAllSeatAnimations();
|
||||
} else if (SEAT_ASSIGNMENTS_SYNC.equals(key)) {
|
||||
applySyncedSeatAssignments(
|
||||
this.entityData.get(SEAT_ASSIGNMENTS_SYNC)
|
||||
);
|
||||
// Re-play animations for passengers whose seat id just changed.
|
||||
// Without this the client could keep rendering a passenger with
|
||||
// the previous seat's blockedRegions until some other trigger.
|
||||
for (Entity passenger : this.getPassengers()) {
|
||||
if (passenger instanceof Player player) {
|
||||
startFurnitureAnimationClient(this, player);
|
||||
}
|
||||
restartAllSeatAnimations();
|
||||
}
|
||||
}
|
||||
|
||||
/** Client-only — caller guards via level().isClientSide. */
|
||||
private void restartAllSeatAnimations() {
|
||||
for (Entity passenger : this.getPassengers()) {
|
||||
if (passenger instanceof Player player) {
|
||||
com.tiedup.remake.v2.furniture.client.FurnitureClientAnimator
|
||||
.start(this, player);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,18 +242,9 @@ public final class FurnitureAuthPredicate {
|
||||
*/
|
||||
@Nullable
|
||||
private static LivingEntity findOccupant(Entity furniture, String seatId) {
|
||||
if (!(furniture instanceof EntityFurniture ef)) return null;
|
||||
for (Entity passenger : ef.getPassengers()) {
|
||||
SeatDefinition assigned = ef.getSeatForPassenger(passenger);
|
||||
if (
|
||||
assigned != null &&
|
||||
assigned.id().equals(seatId) &&
|
||||
passenger instanceof LivingEntity living
|
||||
) {
|
||||
return living;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
if (!(furniture instanceof ISeatProvider provider)) return null;
|
||||
Entity passenger = provider.findPassengerInSeat(seatId);
|
||||
return passenger instanceof LivingEntity living ? living : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.tiedup.remake.v2.furniture;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Wire-format codec for the {@code seatAssignments} map synced to clients
|
||||
* via the SEAT_ASSIGNMENTS_SYNC entity data accessor.
|
||||
*
|
||||
* <p>Format: {@code uuid;seatId|uuid;seatId|…} (empty string = no
|
||||
* assignments). Seat ids cannot contain {@code |} or {@code ;} — enforced
|
||||
* at parse time in {@link FurnitureParser}.</p>
|
||||
*/
|
||||
public final class FurnitureSeatSyncCodec {
|
||||
|
||||
private FurnitureSeatSyncCodec() {}
|
||||
|
||||
/** Serialize an assignments map to its wire form. */
|
||||
public static String encode(Map<UUID, String> assignments) {
|
||||
if (assignments.isEmpty()) return "";
|
||||
StringBuilder sb = new StringBuilder(assignments.size() * 40);
|
||||
boolean first = true;
|
||||
for (Map.Entry<UUID, String> entry : assignments.entrySet()) {
|
||||
if (!first) sb.append('|');
|
||||
sb.append(entry.getKey()).append(';').append(entry.getValue());
|
||||
first = false;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a wire-form string back into an assignments map. Malformed
|
||||
* entries (invalid UUID, missing separator, empty seat id) are skipped
|
||||
* silently so a packet from a future protocol version can still be
|
||||
* consumed at worst-effort without throwing.
|
||||
*/
|
||||
public static Map<UUID, String> decode(String serialized) {
|
||||
Map<UUID, String> result = new LinkedHashMap<>();
|
||||
if (serialized == null || serialized.isEmpty()) return result;
|
||||
for (String entry : serialized.split("\\|")) {
|
||||
int sep = entry.indexOf(';');
|
||||
if (sep <= 0 || sep == entry.length() - 1) continue;
|
||||
try {
|
||||
UUID uuid = UUID.fromString(entry.substring(0, sep));
|
||||
String seatId = entry.substring(sep + 1);
|
||||
result.put(uuid, seatId);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Corrupt UUID — skip this entry, preserve the rest.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,15 @@ import net.minecraft.world.entity.Entity;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Universal interface for entities that hold players in constrained poses.
|
||||
* Contract for entities that hold passengers in named seats.
|
||||
*
|
||||
* <p>Implemented by EntityFurniture (static) and optionally by monsters/NPCs.
|
||||
* All downstream systems (packets, animation, rendering) check ISeatProvider,
|
||||
* never EntityFurniture directly.</p>
|
||||
* <p>{@link EntityFurniture} is currently the only implementation. The
|
||||
* interface covers the seat-lookup / lock / region surface but NOT
|
||||
* animation state or definition reference — callers that need those
|
||||
* still downcast to {@code EntityFurniture}. Adding a second
|
||||
* implementation (NPC carrying a captive, a block acting as a chair,
|
||||
* …) would require extending this interface first; the existing
|
||||
* consumers aren't polymorphic over those methods today.</p>
|
||||
*/
|
||||
public interface ISeatProvider {
|
||||
/** All seat definitions for this entity. */
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.tiedup.remake.v2.furniture.client;
|
||||
|
||||
import com.tiedup.remake.client.animation.BondageAnimationManager;
|
||||
import com.tiedup.remake.client.gltf.GltfData;
|
||||
import com.tiedup.remake.v2.furniture.EntityFurniture;
|
||||
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
|
||||
import com.tiedup.remake.v2.furniture.SeatDefinition;
|
||||
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||
import java.util.Map;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Client-only: start the seat-pose animation for a player on a furniture
|
||||
* entity. Selects the authored clip for the current animation state, falls
|
||||
* back through {@code "Occupied"} → first available, and dispatches the
|
||||
* result to {@link BondageAnimationManager#playFurniture}.
|
||||
*
|
||||
* <p>Safe to call repeatedly — {@code playFurniture} replaces any active
|
||||
* animation. No-op if the GLB isn't loaded yet (cold cache) or the seat
|
||||
* has no authored clip.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class FurnitureClientAnimator {
|
||||
|
||||
private FurnitureClientAnimator() {}
|
||||
|
||||
/**
|
||||
* Kick off (or restart) the seat-pose animation. Callers:
|
||||
* <ul>
|
||||
* <li>{@code EntityFurniture.addPassenger} — on mount</li>
|
||||
* <li>{@code EntityFurniture.onSyncedDataUpdated} — on
|
||||
* {@code ANIM_STATE} / {@code SEAT_ASSIGNMENTS_SYNC} change</li>
|
||||
* <li>{@code AnimationTickHandler} cold-cache retry</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Clip selection matches {@code FurnitureEntityRenderer.resolveActiveAnimation}
|
||||
* to keep mesh and pose in lockstep.</p>
|
||||
*/
|
||||
public static void start(EntityFurniture furniture, Player player) {
|
||||
if (!furniture.level().isClientSide) return;
|
||||
|
||||
SeatDefinition seat = furniture.getSeatForPassenger(player);
|
||||
if (seat == null) return;
|
||||
|
||||
FurnitureDefinition def = furniture.getDefinition();
|
||||
if (def == null) return;
|
||||
|
||||
FurnitureGltfData gltfData = FurnitureGltfCache.get(def.modelLocation());
|
||||
if (gltfData == null) return;
|
||||
|
||||
Map<String, GltfData.AnimationClip> seatClips =
|
||||
gltfData.seatAnimations().get(seat.id());
|
||||
if (seatClips == null || seatClips.isEmpty()) return;
|
||||
|
||||
GltfData seatSkeleton = gltfData.seatSkeletons().get(seat.id());
|
||||
|
||||
String stateClipName = switch (furniture.getAnimState()) {
|
||||
case EntityFurniture.STATE_OCCUPIED -> "Occupied";
|
||||
case EntityFurniture.STATE_STRUGGLE -> "Struggle";
|
||||
case EntityFurniture.STATE_ENTERING -> "Enter";
|
||||
case EntityFurniture.STATE_EXITING -> "Exit";
|
||||
case EntityFurniture.STATE_LOCKING -> "LockClose";
|
||||
case EntityFurniture.STATE_UNLOCKING -> "LockOpen";
|
||||
default -> "Idle";
|
||||
};
|
||||
|
||||
GltfData.AnimationClip clip = seatClips.get(stateClipName);
|
||||
if (clip == null) clip = seatClips.get("Occupied");
|
||||
if (clip == null) clip = seatClips.values().iterator().next();
|
||||
if (clip == null) return;
|
||||
|
||||
KeyframeAnimation anim = FurnitureAnimationContext.create(
|
||||
clip,
|
||||
seatSkeleton,
|
||||
seat.blockedRegions()
|
||||
);
|
||||
if (anim != null) {
|
||||
BondageAnimationManager.playFurniture(player, anim);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,18 +363,14 @@ public class PacketFurnitureEscape {
|
||||
0.15f - (totalDifficulty / (float) MAX_DIFFICULTY) * 0.12f
|
||||
);
|
||||
|
||||
// Start lockpick session via LockpickSessionManager.
|
||||
// The existing lockpick session uses a targetSlot (BodyRegionV2 ordinal) for
|
||||
// bondage items. For furniture, we repurpose targetSlot as the furniture entity ID
|
||||
// and store the seat ID in a context tag so the completion callback can find it.
|
||||
// For now, we use the simplified approach: start the session and let the existing
|
||||
// PacketLockpickAttempt handler manage the sweet-spot interaction. On success,
|
||||
// the furniture-specific completion is handled by a post-session check.
|
||||
// Start a FURNITURE_SEAT-kind session; PacketLockpickAttempt branches
|
||||
// on the kind to run the furniture-specific completion.
|
||||
LockpickSessionManager lockpickManager =
|
||||
LockpickSessionManager.getInstance();
|
||||
LockpickMiniGameState session = lockpickManager.startLockpickSession(
|
||||
sender,
|
||||
furnitureEntity.getId(), // repurpose targetSlot as entity ID
|
||||
com.tiedup.remake.minigame.LockpickTargetKind.FURNITURE_SEAT,
|
||||
furnitureEntity.getId(),
|
||||
sweetSpotWidth
|
||||
);
|
||||
|
||||
@@ -388,16 +384,12 @@ public class PacketFurnitureEscape {
|
||||
|
||||
session.setRemainingUses(remainingUses);
|
||||
|
||||
// Store furniture context in the sender's persistent data so the
|
||||
// lockpick attempt handler can resolve the furniture on success.
|
||||
// This is cleaned up when the session ends.
|
||||
// Store seat_id in persistent data so the completion callback can
|
||||
// find it (the session carries furniture id via targetData, but
|
||||
// seat id has nowhere else to live yet).
|
||||
net.minecraft.nbt.CompoundTag ctx = new net.minecraft.nbt.CompoundTag();
|
||||
ctx.putInt("furniture_id", furnitureEntity.getId());
|
||||
ctx.putString("seat_id", targetSeat.id());
|
||||
// Nonce: the handler accepts this ctx only when session_id matches
|
||||
// the active session. Prevents stale ctx from mis-routing a later
|
||||
// body-item lockpick.
|
||||
ctx.putUUID("session_id", session.getSessionId());
|
||||
sender.getPersistentData().put("tiedup_furniture_lockpick_ctx", ctx);
|
||||
|
||||
// Send initial lockpick state to open the minigame GUI on the client
|
||||
|
||||
Reference in New Issue
Block a user