Clean repo for open source release

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
package com.tiedup.remake.v2.furniture;
import java.util.List;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.Nullable;
/**
* Immutable definition for a data-driven furniture piece.
*
* <p>Loaded from JSON files in {@code data/<namespace>/tiedup_furniture/}.
* Synced to clients via {@code PacketSyncFurnitureDefinitions}.
* Each definition describes placement rules, visual properties,
* seat layout, and interaction feedback for a furniture type.</p>
*
* <p>All rendering and gameplay properties are read from this record at runtime
* via the furniture entity and renderer.</p>
*/
public record FurnitureDefinition(
/** Unique identifier (e.g., "tiedup:wooden_stocks"). */
ResourceLocation id,
/** Human-readable display name (fallback if no translation key). */
String displayName,
/** Optional translation key for localized display name. */
@Nullable String translationKey,
/** Resource location of the GLB model file. */
ResourceLocation modelLocation,
/** Tint channel defaults: channel name to ARGB color (e.g., "tintable_0" -> 0x8B4513). */
Map<String, Integer> tintChannels,
/** Whether this furniture supports player-applied color customization. */
boolean supportsColor,
/** Collision box width in blocks (X/Z axis). */
float hitboxWidth,
/** Collision box height in blocks (Y axis). */
float hitboxHeight,
/** Whether this furniture snaps to adjacent walls on placement. */
boolean snapToWall,
/** Whether this furniture can only be placed on solid ground. */
boolean floorOnly,
/** Whether this furniture can be locked with a key item. */
boolean lockable,
/** Resistance to breaking (higher = harder to destroy). */
float breakResistance,
/** Whether the furniture drops as an item when broken. */
boolean dropOnBreak,
/** Ordered list of seat definitions. Index is used for bitmask operations. */
List<SeatDefinition> seats,
/** Optional sound overrides for interactions. */
FurnitureFeedback feedback,
/** Grouping category for creative menu / UI filtering (e.g., "restraint", "decoration"). */
String category,
/**
* Optional inventory icon model location (e.g., "tiedup:item/wooden_stocks").
*
* <p>Points to a standard {@code item/generated} model JSON that will be used
* as the inventory sprite for this furniture variant when held as a placer item.
* When null, the default {@code tiedup:item/furniture_placer} model is used.</p>
*/
@Nullable ResourceLocation icon
) {
/**
* Find a seat definition by its unique ID.
*
* @param seatId the seat identifier to search for
* @return the matching {@link SeatDefinition}, or null if not found
*/
@Nullable
public SeatDefinition getSeat(String seatId) {
for (SeatDefinition seat : seats) {
if (seat.id().equals(seatId)) return seat;
}
return null;
}
/**
* Get the positional index of a seat (for bitmask operations on lock state, occupancy, etc.).
*
* @param seatId the seat identifier to search for
* @return the zero-based index, or -1 if not found
*/
public int getSeatIndex(String seatId) {
for (int i = 0; i < seats.size(); i++) {
if (seats.get(i).id().equals(seatId)) return i;
}
return -1;
}
}

View File

@@ -0,0 +1,36 @@
package com.tiedup.remake.v2.furniture;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.Nullable;
/**
* Sound events for furniture interactions. All fields are optional
* -- null means "use the default sound" at runtime.
*
* <p>Loaded from the {@code "feedback"} block of a furniture JSON definition.
* When the entire block is absent, {@link #EMPTY} is used.</p>
*/
public record FurnitureFeedback(
/** Sound played when a player mounts the furniture. */
@Nullable ResourceLocation mountSound,
/** Sound played when a seat is locked. */
@Nullable ResourceLocation lockSound,
/** Sound played when a seat is unlocked. */
@Nullable ResourceLocation unlockSound,
/** Looping sound played while a player struggles in a locked seat. */
@Nullable ResourceLocation struggleLoopSound,
/** Sound played on successful escape. */
@Nullable ResourceLocation escapeSound,
/** Sound played when an action is denied (e.g., locked seat interaction). */
@Nullable ResourceLocation deniedSound
) {
/** Empty feedback -- all sounds null (use defaults). */
public static final FurnitureFeedback EMPTY = new FurnitureFeedback(
null, null, null, null, null, null
);
}

View File

@@ -0,0 +1,412 @@
package com.tiedup.remake.v2.furniture;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.tiedup.remake.v2.BodyRegionV2;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import net.minecraft.resources.ResourceLocation;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.Nullable;
/**
* Parses JSON files into {@link FurnitureDefinition} instances.
*
* <p>Uses manual field extraction (not Gson deserialization) for strict
* validation control. Invalid required fields cause the entire definition
* to be rejected; optional fields use safe defaults.</p>
*
* <p>Expected JSON files in {@code data/<namespace>/tiedup_furniture/}.</p>
*/
public final class FurnitureParser {
private static final Logger LOGGER = LogManager.getLogger("TiedUpFurniture");
private static final String TAG = "[FurnitureParser]";
/** Strict hex color pattern: # followed by exactly 6 hex digits. */
private static final Pattern HEX_COLOR = Pattern.compile("^#[0-9A-Fa-f]{6}$");
/** Maximum number of seats per furniture (bitmask limit: 8 bits). */
private static final int MAX_SEATS = 8;
private FurnitureParser() {}
/**
* Parse a JSON input stream into a FurnitureDefinition.
*
* @param input the JSON input stream
* @param fileId the resource location of the source file (for error messages)
* @return the parsed definition, or null if the file is invalid
*/
@Nullable
public static FurnitureDefinition parse(InputStream input, ResourceLocation fileId) {
try {
JsonObject root = JsonParser.parseReader(
new InputStreamReader(input, StandardCharsets.UTF_8)
).getAsJsonObject();
return parseObject(root, fileId);
} catch (Exception e) {
LOGGER.error("{} Failed to parse JSON {}: {}", TAG, fileId, e.getMessage());
return null;
}
}
/**
* Parse a JsonObject into a FurnitureDefinition.
*
* @param root the parsed JSON object
* @param fileId the resource location of the source file (for error messages)
* @return the parsed definition, or null if validation fails
*/
@Nullable
public static FurnitureDefinition parseObject(JsonObject root, ResourceLocation fileId) {
// --- Required: id ---
String idStr = getStringOrNull(root, "id");
if (idStr == null || idStr.isEmpty()) {
LOGGER.error("{} Skipping {}: missing 'id'", TAG, fileId);
return null;
}
ResourceLocation id = ResourceLocation.tryParse(idStr);
if (id == null) {
LOGGER.error("{} Skipping {}: invalid id ResourceLocation '{}'", TAG, fileId, idStr);
return null;
}
// --- Required: display_name ---
String displayName = getStringOrNull(root, "display_name");
if (displayName == null || displayName.isEmpty()) {
LOGGER.error("{} Skipping {}: missing 'display_name'", TAG, fileId);
return null;
}
// --- Optional: translation_key ---
String translationKey = getStringOrNull(root, "translation_key");
// --- Required: model ---
String modelStr = getStringOrNull(root, "model");
if (modelStr == null || modelStr.isEmpty()) {
LOGGER.error("{} Skipping {}: missing 'model'", TAG, fileId);
return null;
}
ResourceLocation modelLocation = ResourceLocation.tryParse(modelStr);
if (modelLocation == null) {
LOGGER.error("{} Skipping {}: invalid model ResourceLocation '{}'", TAG, fileId, modelStr);
return null;
}
// --- Optional: tint_channels (strict hex validation) ---
Map<String, Integer> tintChannels = parseTintChannels(root, fileId);
if (tintChannels == null) {
// parseTintChannels returns null on invalid hex -> reject entire furniture
return null;
}
// --- Optional: supports_color (default false) ---
boolean supportsColor = getBooleanOrDefault(root, "supports_color", false);
// --- Optional: hitbox (defaults: 1.0 x 1.0, clamped [0.1, 5.0]) ---
float hitboxWidth = 1.0f;
float hitboxHeight = 1.0f;
if (root.has("hitbox") && root.get("hitbox").isJsonObject()) {
JsonObject hitbox = root.getAsJsonObject("hitbox");
hitboxWidth = clamp(getFloatOrDefault(hitbox, "width", 1.0f), 0.1f, 5.0f);
hitboxHeight = clamp(getFloatOrDefault(hitbox, "height", 1.0f), 0.1f, 5.0f);
}
// --- Optional: placement ---
boolean snapToWall = false;
boolean floorOnly = true;
if (root.has("placement") && root.get("placement").isJsonObject()) {
JsonObject placement = root.getAsJsonObject("placement");
snapToWall = getBooleanOrDefault(placement, "snap_to_wall", false);
floorOnly = getBooleanOrDefault(placement, "floor_only", true);
}
// --- Optional: lockable (default false) ---
boolean lockable = getBooleanOrDefault(root, "lockable", false);
// --- Optional: break_resistance (default 100, clamped [1, 10000]) ---
float breakResistance = clamp(getFloatOrDefault(root, "break_resistance", 100.0f), 1.0f, 10000.0f);
// --- Optional: drop_on_break (default true) ---
boolean dropOnBreak = getBooleanOrDefault(root, "drop_on_break", true);
// --- Required: seats (non-empty array, size [1, 8]) ---
if (!root.has("seats") || !root.get("seats").isJsonArray()) {
LOGGER.error("{} Skipping {}: missing or invalid 'seats' array", TAG, fileId);
return null;
}
JsonArray seatsArray = root.getAsJsonArray("seats");
if (seatsArray.isEmpty()) {
LOGGER.error("{} Skipping {}: 'seats' array is empty", TAG, fileId);
return null;
}
if (seatsArray.size() > MAX_SEATS) {
LOGGER.error("{} Skipping {}: 'seats' array has {} entries (max {})",
TAG, fileId, seatsArray.size(), MAX_SEATS);
return null;
}
List<SeatDefinition> seats = new ArrayList<>(seatsArray.size());
for (int i = 0; i < seatsArray.size(); i++) {
if (!seatsArray.get(i).isJsonObject()) {
LOGGER.error("{} Skipping {}: seats[{}] is not a JSON object", TAG, fileId, i);
return null;
}
SeatDefinition seat = parseSeat(seatsArray.get(i).getAsJsonObject(), i, lockable, fileId);
if (seat == null) {
// parseSeat already logged the error
return null;
}
seats.add(seat);
}
// --- Optional: feedback ---
FurnitureFeedback feedback = FurnitureFeedback.EMPTY;
if (root.has("feedback") && root.get("feedback").isJsonObject()) {
feedback = parseFeedback(root.getAsJsonObject("feedback"), fileId);
}
// --- Optional: category (default "furniture") ---
String category = getStringOrDefault(root, "category", "furniture");
// --- Optional: icon (item model ResourceLocation for inventory sprite) ---
ResourceLocation icon = parseOptionalResourceLocation(root, "icon", fileId);
return new FurnitureDefinition(
id, displayName, translationKey, modelLocation,
tintChannels, supportsColor,
hitboxWidth, hitboxHeight,
snapToWall, floorOnly,
lockable, breakResistance, dropOnBreak,
seats, feedback, category, icon
);
}
// ===== Seat Parsing =====
/**
* Parse a single seat JSON object.
*
* @param obj the seat JSON object
* @param index the seat index (for error messages)
* @param parentLockable the top-level lockable value (used as default)
* @param fileId the source file (for error messages)
* @return the parsed seat, or null on validation failure
*/
@Nullable
private static SeatDefinition parseSeat(JsonObject obj, int index,
boolean parentLockable,
ResourceLocation fileId) {
// Required: id (must not contain ':')
String seatId = getStringOrNull(obj, "id");
if (seatId == null || seatId.isEmpty()) {
LOGGER.error("{} Skipping {}: seats[{}] missing 'id'", TAG, fileId, index);
return null;
}
if (seatId.contains(":")) {
LOGGER.error("{} Skipping {}: seats[{}] id '{}' must not contain ':'",
TAG, fileId, index, seatId);
return null;
}
// Required: armature
String armature = getStringOrNull(obj, "armature");
if (armature == null || armature.isEmpty()) {
LOGGER.error("{} Skipping {}: seats[{}] missing 'armature'", TAG, fileId, index);
return null;
}
// Optional: blocked_regions (unknown region = fatal for entire furniture)
Set<BodyRegionV2> blockedRegions = parseBlockedRegions(obj, index, fileId);
if (blockedRegions == null) {
// parseBlockedRegions returns null ONLY on unknown region name (fatal)
return null;
}
// Optional: lockable (inherits from top-level)
boolean seatLockable = getBooleanOrDefault(obj, "lockable", parentLockable);
// Optional: locked_difficulty (clamped [1, 10000], default 1)
int lockedDifficulty = clampInt(getIntOrDefault(obj, "locked_difficulty", 1), 1, 10000);
// Optional: item_difficulty_bonus (default false)
boolean itemDifficultyBonus = getBooleanOrDefault(obj, "item_difficulty_bonus", false);
return new SeatDefinition(
seatId, armature, blockedRegions,
seatLockable, lockedDifficulty, itemDifficultyBonus
);
}
/**
* Parse blocked_regions for a seat. Returns empty set if field is absent.
* Returns null (fatal) if any region name is unknown.
*/
@Nullable
private static Set<BodyRegionV2> parseBlockedRegions(JsonObject obj, int seatIndex,
ResourceLocation fileId) {
if (!obj.has("blocked_regions") || !obj.get("blocked_regions").isJsonArray()) {
return Collections.unmodifiableSet(EnumSet.noneOf(BodyRegionV2.class));
}
JsonArray arr = obj.getAsJsonArray("blocked_regions");
if (arr.isEmpty()) {
return Collections.unmodifiableSet(EnumSet.noneOf(BodyRegionV2.class));
}
EnumSet<BodyRegionV2> regions = EnumSet.noneOf(BodyRegionV2.class);
for (JsonElement elem : arr) {
String name;
try {
name = elem.getAsString().toUpperCase();
} catch (Exception e) {
LOGGER.error("{} Skipping {}: seats[{}] invalid element in 'blocked_regions': {}",
TAG, fileId, seatIndex, e.getMessage());
return null;
}
BodyRegionV2 region = BodyRegionV2.fromName(name);
if (region == null) {
LOGGER.error("{} Skipping {}: seats[{}] unknown body region '{}'",
TAG, fileId, seatIndex, name);
return null;
}
regions.add(region);
}
return Collections.unmodifiableSet(regions);
}
// ===== Feedback Parsing =====
private static FurnitureFeedback parseFeedback(JsonObject obj, ResourceLocation fileId) {
return new FurnitureFeedback(
parseOptionalResourceLocation(obj, "mount_sound", fileId),
parseOptionalResourceLocation(obj, "lock_sound", fileId),
parseOptionalResourceLocation(obj, "unlock_sound", fileId),
parseOptionalResourceLocation(obj, "struggle_loop_sound", fileId),
parseOptionalResourceLocation(obj, "escape_sound", fileId),
parseOptionalResourceLocation(obj, "denied_sound", fileId)
);
}
// ===== Tint Channel Parsing =====
/**
* Parse tint_channels with strict hex validation.
* Returns empty map if field is absent. Returns null if any value is invalid hex.
*/
@Nullable
private static Map<String, Integer> parseTintChannels(JsonObject root, ResourceLocation fileId) {
if (!root.has("tint_channels") || !root.get("tint_channels").isJsonObject()) {
return Map.of();
}
JsonObject channels = root.getAsJsonObject("tint_channels");
Map<String, Integer> result = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : channels.entrySet()) {
String hex;
try {
hex = entry.getValue().getAsString();
} catch (Exception e) {
LOGGER.error("{} Skipping {}: tint_channels '{}' value is not a string",
TAG, fileId, entry.getKey());
return null;
}
if (!HEX_COLOR.matcher(hex).matches()) {
LOGGER.error("{} Skipping {}: tint_channels '{}' has invalid hex color '{}' "
+ "(expected '#' followed by 6 hex digits)",
TAG, fileId, entry.getKey(), hex);
return null;
}
int color = Integer.parseInt(hex.substring(1), 16);
result.put(entry.getKey(), color);
}
return Collections.unmodifiableMap(result);
}
// ===== Primitive Helpers =====
@Nullable
private static String getStringOrNull(JsonObject obj, String key) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
try {
return obj.get(key).getAsString();
} catch (Exception e) {
return null;
}
}
private static String getStringOrDefault(JsonObject obj, String key, String defaultValue) {
String value = getStringOrNull(obj, key);
return (value != null && !value.isEmpty()) ? value : defaultValue;
}
private static int getIntOrDefault(JsonObject obj, String key, int defaultValue) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
try {
return obj.get(key).getAsInt();
} catch (Exception e) {
return defaultValue;
}
}
private static float getFloatOrDefault(JsonObject obj, String key, float defaultValue) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
try {
return obj.get(key).getAsFloat();
} catch (Exception e) {
return defaultValue;
}
}
private static boolean getBooleanOrDefault(JsonObject obj, String key, boolean defaultValue) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
try {
return obj.get(key).getAsBoolean();
} catch (Exception e) {
return defaultValue;
}
}
@Nullable
private static ResourceLocation parseOptionalResourceLocation(
JsonObject obj, String key, ResourceLocation fileId
) {
String value = getStringOrNull(obj, key);
if (value == null || value.isEmpty()) return null;
ResourceLocation loc = ResourceLocation.tryParse(value);
if (loc == null) {
LOGGER.warn("{} In {}: invalid ResourceLocation for '{}': '{}'", TAG, fileId, key, value);
}
return loc;
}
// ===== Clamping Helpers =====
private static float clamp(float value, float min, float max) {
return Math.max(min, Math.min(max, value));
}
private static int clampInt(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
}

View File

@@ -0,0 +1,180 @@
package com.tiedup.remake.v2.furniture;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.ModEntities;
import com.tiedup.remake.v2.bondage.V2BondageItems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import org.jetbrains.annotations.Nullable;
/**
* Singleton item that spawns {@link EntityFurniture} on right-click.
*
* <p>Each ItemStack carries a {@link FurnitureRegistry#NBT_FURNITURE_ID} NBT tag
* that determines which furniture definition to use. This follows the same pattern
* as {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem} where
* a single registered Item serves all data-driven variants.</p>
*
* <p>On use, the item reads the furniture ID from NBT, validates that a
* {@link FurnitureDefinition} exists in the registry, spawns an
* {@link EntityFurniture} at the clicked position, and consumes the item
* (unless in creative mode).</p>
*/
public class FurniturePlacerItem extends Item {
public FurniturePlacerItem() {
super(new Properties().stacksTo(1));
}
// ===== PLACEMENT =====
@Override
public InteractionResult useOn(UseOnContext context) {
Level level = context.getLevel();
if (level.isClientSide) {
return InteractionResult.SUCCESS;
}
ItemStack stack = context.getItemInHand();
String furnitureIdStr = getFurnitureIdFromStack(stack);
if (furnitureIdStr == null) {
return InteractionResult.FAIL;
}
// Validate definition exists
FurnitureDefinition def = FurnitureRegistry.get(furnitureIdStr);
if (def == null) {
TiedUpMod.LOGGER.warn(
"[FurniturePlacerItem] Unknown furniture ID '{}', cannot place",
furnitureIdStr
);
return InteractionResult.FAIL;
}
// Calculate placement position based on clicked face
BlockPos clickedPos = context.getClickedPos();
Direction face = context.getClickedFace();
Vec3 spawnPos;
if (face == Direction.UP) {
// Clicked top of a block: place on top of it
spawnPos = Vec3.atBottomCenterOf(clickedPos.above());
} else if (face == Direction.DOWN) {
// Clicked bottom of a block: place below it
spawnPos = Vec3.atBottomCenterOf(clickedPos.below());
} else {
// Clicked a side: place adjacent to the clicked face
spawnPos = Vec3.atBottomCenterOf(clickedPos.relative(face));
}
// Check floor_only placement restriction: must have solid ground below
BlockPos spawnBlockPos = BlockPos.containing(spawnPos);
if (def.floorOnly()) {
BlockPos below = spawnBlockPos.below();
if (!level.getBlockState(below).isSolidRender(level, below)) {
return InteractionResult.FAIL;
}
}
// Spawn the furniture entity
EntityFurniture furniture = new EntityFurniture(
ModEntities.FURNITURE.get(), level
);
furniture.setFurnitureId(furnitureIdStr);
furniture.moveTo(spawnPos.x, spawnPos.y, spawnPos.z);
// Face the same direction as the player (rounded to nearest 90 degrees)
float yaw = 0f;
if (context.getPlayer() != null) {
float playerYaw = context.getPlayer().getYRot();
yaw = Math.round(playerYaw / 90.0f) * 90.0f;
}
// Snap to wall: if enabled, check 4 cardinal directions for an adjacent wall
// and rotate the furniture to face it (back against wall), overriding player yaw.
if (def.snapToWall()) {
Direction[] directions = {Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST};
for (Direction dir : directions) {
BlockPos wallPos = spawnBlockPos.relative(dir);
if (level.getBlockState(wallPos).isFaceSturdy(level, wallPos, dir.getOpposite())) {
yaw = dir.toYRot();
break;
}
}
}
furniture.setYRot(yaw);
level.addFreshEntity(furniture);
// Consume the item (unless creative)
if (context.getPlayer() != null && !context.getPlayer().isCreative()) {
stack.shrink(1);
}
return InteractionResult.CONSUME;
}
// ===== DISPLAY NAME =====
@Override
public Component getName(ItemStack stack) {
String furnitureIdStr = getFurnitureIdFromStack(stack);
if (furnitureIdStr == null) {
return super.getName(stack);
}
FurnitureDefinition def = FurnitureRegistry.get(furnitureIdStr);
if (def == null) {
return super.getName(stack);
}
if (def.translationKey() != null) {
return Component.translatable(def.translationKey());
}
return Component.literal(def.displayName());
}
// ===== FACTORY =====
/**
* Create an ItemStack for a specific furniture type.
*
* @param furnitureId the definition ID (must exist in {@link FurnitureRegistry})
* @return a new ItemStack with the {@link FurnitureRegistry#NBT_FURNITURE_ID} NBT tag set,
* or {@link ItemStack#EMPTY} if the placer item is not yet registered
*/
public static ItemStack createStack(ResourceLocation furnitureId) {
if (V2BondageItems.FURNITURE_PLACER == null) return ItemStack.EMPTY;
ItemStack stack = new ItemStack(V2BondageItems.FURNITURE_PLACER.get());
stack.getOrCreateTag().putString(
FurnitureRegistry.NBT_FURNITURE_ID, furnitureId.toString()
);
return stack;
}
// ===== HELPERS =====
/**
* Read the furniture definition ID string from an ItemStack's NBT.
*
* @param stack the item stack to read from
* @return the furniture ID string, or null if the tag is missing or empty
*/
@Nullable
public static String getFurnitureIdFromStack(ItemStack stack) {
if (stack.isEmpty() || !stack.hasTag()) return null;
// noinspection DataFlowIssue -- hasTag() guarantees non-null
if (!stack.getTag().contains(FurnitureRegistry.NBT_FURNITURE_ID, 8)) return null;
String id = stack.getTag().getString(FurnitureRegistry.NBT_FURNITURE_ID);
return id.isEmpty() ? null : id;
}
}

View File

@@ -0,0 +1,99 @@
package com.tiedup.remake.v2.furniture;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.Nullable;
/**
* Thread-safe registry for data-driven furniture definitions.
*
* <p>Server-authoritative: definitions are loaded from {@code data/<namespace>/tiedup_furniture/}
* JSON files by the server reload listener, then synced to clients via
* {@code PacketSyncFurnitureDefinitions}. Unlike {@link
* com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry}, there is no {@code mergeAll()}
* because furniture definitions have a single source of truth (server data pack).</p>
*
* <p>Uses volatile atomic swap to ensure the render thread and network threads
* always see a consistent snapshot of definitions.</p>
*
* @see FurnitureDefinition
*/
public final class FurnitureRegistry {
/** NBT key storing the furniture definition ID on furniture entities. */
public static final String NBT_FURNITURE_ID = "tiedup_furniture_id";
/**
* Volatile reference to an unmodifiable map. {@link #reload} builds a new map
* and swaps atomically; consumer threads always see a consistent snapshot.
*/
private static volatile Map<ResourceLocation, FurnitureDefinition> DEFINITIONS = Map.of();
private FurnitureRegistry() {}
/**
* Atomically replace all definitions with a new set.
* Called by the reload listener after parsing all JSON files,
* and by the client sync packet handler after receiving server definitions.
*
* @param newDefs the new definitions map (will be defensively copied)
*/
public static void reload(Map<ResourceLocation, FurnitureDefinition> newDefs) {
DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs));
}
/**
* Get a definition by its unique ID.
*
* @param id the definition ID (e.g., "tiedup:wooden_stocks")
* @return the definition, or null if not found
*/
@Nullable
public static FurnitureDefinition get(ResourceLocation id) {
return DEFINITIONS.get(id);
}
/**
* Lookup a definition by string ID (from SyncedEntityData or NBT).
*
* @param furnitureIdStr the string form of the ResourceLocation, or null/empty
* @return the definition, or null if the string is blank, unparseable, or unknown
*/
@Nullable
public static FurnitureDefinition get(String furnitureIdStr) {
if (furnitureIdStr == null || furnitureIdStr.isEmpty()) return null;
ResourceLocation id = ResourceLocation.tryParse(furnitureIdStr);
return id != null ? DEFINITIONS.get(id) : null;
}
/**
* Get all registered definitions.
*
* @return unmodifiable collection of all definitions
*/
public static Collection<FurnitureDefinition> getAll() {
return DEFINITIONS.values();
}
/**
* Full map snapshot for packet serialization.
*
* <p>The returned map is the same unmodifiable reference held internally,
* so it is safe to iterate during packet encoding without copying.</p>
*
* @return unmodifiable map of all definitions keyed by ID
*/
public static Map<ResourceLocation, FurnitureDefinition> getAllMap() {
return DEFINITIONS;
}
/**
* Clear all definitions. Called on world unload or for testing.
*/
public static void clear() {
DEFINITIONS = Map.of();
}
}

View File

@@ -0,0 +1,88 @@
package com.tiedup.remake.v2.furniture;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
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 org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Server-side resource reload listener that scans {@code data/<namespace>/tiedup_furniture/}
* for JSON files and populates the {@link FurnitureRegistry}.
*
* <p>Unlike the data-driven item system (which has both client and server listeners),
* furniture definitions are server-authoritative only. The registry is atomically
* replaced via {@link FurnitureRegistry#reload(Map)} on each reload.</p>
*
* <p>Registered via {@link net.minecraftforge.event.AddReloadListenerEvent} in
* {@link com.tiedup.remake.core.TiedUpMod.ForgeEvents}.</p>
*/
public class FurnitureServerReloadListener extends SimplePreparableReloadListener<Void> {
private static final Logger LOGGER = LogManager.getLogger("TiedUpFurniture");
/** Resource directory containing furniture definition JSON files (under data/). */
private static final String DIRECTORY = "tiedup_furniture";
@Override
protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) {
// No preparation needed -- parsing happens in apply phase
return null;
}
@Override
protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) {
Map<ResourceLocation, FurnitureDefinition> newDefs = new HashMap<>();
Map<ResourceLocation, Resource> resources = resourceManager.listResources(
DIRECTORY, loc -> loc.getPath().endsWith(".json")
);
int skipped = 0;
for (Map.Entry<ResourceLocation, Resource> entry : resources.entrySet()) {
ResourceLocation fileId = entry.getKey();
Resource resource = entry.getValue();
try (InputStream input = resource.open()) {
FurnitureDefinition def = FurnitureParser.parse(input, fileId);
if (def != null) {
// Check for duplicate IDs
if (newDefs.containsKey(def.id())) {
LOGGER.warn("[TiedUpFurniture] Server: Duplicate furniture ID '{}' from file '{}' -- overwriting previous definition",
def.id(), fileId);
}
newDefs.put(def.id(), def);
LOGGER.debug("[TiedUpFurniture] Server loaded: {} -> '{}'", def.id(), def.displayName());
} else {
skipped++;
}
} catch (Exception e) {
LOGGER.error("[TiedUpFurniture] Server: Failed to read resource {}: {}", fileId, e.getMessage());
skipped++;
}
}
// Atomically replace all definitions in the registry
FurnitureRegistry.reload(newDefs);
// Broadcast updated definitions to all connected players
net.minecraft.server.MinecraftServer server =
net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer();
if (server != null) {
for (net.minecraft.server.level.ServerPlayer p : server.getPlayerList().getPlayers()) {
com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureDefinitions.sendToPlayer(p);
}
}
LOGGER.info("[TiedUpFurniture] Server loaded {} furniture definitions ({} skipped) from {} JSON files",
newDefs.size(), skipped, resources.size());
}
}

View File

@@ -0,0 +1,50 @@
package com.tiedup.remake.v2.furniture;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.List;
import java.util.Set;
import net.minecraft.world.entity.Entity;
import org.jetbrains.annotations.Nullable;
/**
* Universal interface for entities that hold players in constrained poses.
*
* <p>Implemented by EntityFurniture (static) and optionally by monsters/NPCs.
* All downstream systems (packets, animation, rendering) check ISeatProvider,
* never EntityFurniture directly.</p>
*/
public interface ISeatProvider {
/** All seat definitions for this entity. */
List<SeatDefinition> getSeats();
/** Which seat is this passenger in? Null if not seated. */
@Nullable
SeatDefinition getSeatForPassenger(Entity passenger);
/** Assign a passenger to a specific seat. */
void assignSeat(Entity passenger, String seatId);
/** Release a passenger's seat assignment. */
void releaseSeat(Entity passenger);
/** Is this specific seat locked? */
boolean isSeatLocked(String seatId);
/** Lock/unlock a specific seat. */
void setSeatLocked(String seatId, boolean locked);
/** The locked difficulty for this seat. */
int getLockedDifficulty(String seatId);
/** Blocked body regions for a specific seat. */
Set<BodyRegionV2> getBlockedRegions(String seatId);
/** Whether items on non-blocked regions add to escape difficulty. */
boolean hasItemDifficultyBonus(String seatId);
/** Convenience: get the entity this interface is attached to. */
default Entity asEntity() {
return (Entity) this;
}
}

View File

@@ -0,0 +1,31 @@
package com.tiedup.remake.v2.furniture;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.Set;
/**
* Immutable definition for a single seat on a furniture piece or monster.
*
* <p>Loaded from JSON (furniture) or defined programmatically (monsters).
* Each seat describes the physical constraints imposed on a seated player,
* including which body regions are blocked and escape difficulty.</p>
*/
public record SeatDefinition(
/** Unique seat identifier within this furniture (e.g., "main", "left"). */
String id,
/** GLB armature name (e.g., "Player_main"). */
String armatureName,
/** Body regions physically controlled by this seat. */
Set<BodyRegionV2> blockedRegions,
/** Whether this seat can be locked with a key. */
boolean lockable,
/** Struggle difficulty when locked (raw resistance, range 1-10000). */
int lockedDifficulty,
/** Whether items on non-blocked regions add to escape difficulty. */
boolean itemDifficultyBonus
) {}

View File

@@ -0,0 +1,146 @@
package com.tiedup.remake.v2.furniture.client;
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
import com.tiedup.remake.client.gltf.GltfData;
import com.tiedup.remake.client.gltf.GltfPoseConverter;
import com.tiedup.remake.v2.BodyRegionV2;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import java.util.HashSet;
import java.util.Set;
import org.jetbrains.annotations.Nullable;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Builds a {@link KeyframeAnimation} for a player seated on furniture.
*
* <p>The furniture animation layer sits at priority 43 (above the item layer at 42),
* so it wins on shared bones. To allow bondage items on non-blocked regions to still
* animate (e.g., a gag on the head while the chair blocks arms+legs), this factory
* enables ONLY the bones corresponding to the seat's blocked regions and disables
* all others. Disabled parts pass through to the lower-priority item layer.</p>
*
* <p>Conversion flow:
* <ol>
* <li>Convert the raw glTF animation clip to a PlayerAnimator {@link KeyframeAnimation}
* using {@link GltfPoseConverter#convertWithSkeleton}</li>
* <li>Create a mutable copy of the animation</li>
* <li>Map blocked {@link BodyRegionV2}s to PlayerAnimator bone names via
* {@link RegionBoneMapper#getPartsForRegion}</li>
* <li>Disable all bones NOT in the blocked set</li>
* <li>Build and return the immutable result</li>
* </ol>
*
* <p>In V1 (seat skeleton not yet parsed), returns null. Furniture animation
* requires V2 skeleton parsing to provide the rest pose data needed by
* {@link GltfPoseConverter}.</p>
*
* @see com.tiedup.remake.client.animation.BondageAnimationManager#playFurniture
* @see RegionBoneMapper
*/
@OnlyIn(Dist.CLIENT)
public final class FurnitureAnimationContext {
private static final Logger LOGGER = LogManager.getLogger("FurnitureAnimation");
private FurnitureAnimationContext() {}
/**
* Create a KeyframeAnimation for a player seated on furniture.
* Enables ONLY bones in blocked regions, disables all others.
*
* @param seatClip the seat animation clip (from
* {@link FurnitureGltfData#seatAnimations()})
* @param seatSkeleton the seat skeleton data providing rest pose and joint names
* (from {@link FurnitureGltfData#seatSkeletons()}); null in V1
* @param blockedRegions the body regions the furniture controls for this seat
* @return a KeyframeAnimation with only blocked-region bones enabled, or null if
* the skeleton is unavailable (V1) or conversion fails
*/
@Nullable
public static KeyframeAnimation create(
GltfData.AnimationClip seatClip,
@Nullable GltfData seatSkeleton,
Set<BodyRegionV2> blockedRegions) {
if (seatClip == null) {
LOGGER.warn("[FurnitureAnim] Cannot create animation: seatClip is null");
return null;
}
if (seatSkeleton == null) {
// V1: skeleton parsing not yet implemented. Furniture animation requires
// rest pose data for glTF-to-PlayerAnimator conversion.
LOGGER.debug("[FurnitureAnim] Seat skeleton unavailable (V1), skipping animation");
return null;
}
if (blockedRegions == null || blockedRegions.isEmpty()) {
LOGGER.debug("[FurnitureAnim] No blocked regions, skipping animation");
return null;
}
// Step 1: Convert the raw clip to a full KeyframeAnimation using skeleton data.
// convertWithSkeleton returns an animation with ALL parts enabled.
KeyframeAnimation fullAnim;
try {
fullAnim = GltfPoseConverter.convertWithSkeleton(
seatSkeleton, seatClip, "furniture_seat");
} catch (Exception e) {
LOGGER.error("[FurnitureAnim] Failed to convert seat animation clip", e);
return null;
}
// Step 2: Compute which PlayerAnimator parts correspond to blocked regions.
Set<String> blockedParts = new HashSet<>();
for (BodyRegionV2 region : blockedRegions) {
blockedParts.addAll(RegionBoneMapper.getPartsForRegion(region));
}
if (blockedParts.isEmpty()) {
// Blocked regions don't map to any animation bones (e.g., only NECK/FINGERS/TAIL/WINGS)
LOGGER.debug("[FurnitureAnim] Blocked regions {} map to zero bones, skipping", blockedRegions);
return null;
}
// Step 3: Compute disabled parts = ALL parts MINUS blocked parts.
Set<String> disabledParts = new HashSet<>(RegionBoneMapper.ALL_PARTS);
disabledParts.removeAll(blockedParts);
if (disabledParts.isEmpty()) {
// All parts are blocked by the furniture -- return the full animation as-is.
return fullAnim;
}
// Step 4: Create a mutable copy and disable non-blocked bones.
KeyframeAnimation.AnimationBuilder builder = fullAnim.mutableCopy();
disableParts(builder, disabledParts);
KeyframeAnimation result = builder.build();
LOGGER.debug("[FurnitureAnim] Created animation: blocked={}, enabled={}, disabled={}",
blockedRegions, blockedParts, disabledParts);
return result;
}
/**
* Disable all animation axes on the specified parts.
*
* <p>Uses the same pattern as
* {@link com.tiedup.remake.client.animation.context.ContextAnimationFactory}.
* Unknown part names are silently ignored.</p>
*
* @param builder the mutable animation builder
* @param disabledParts set of PlayerAnimator part names to disable
*/
private static void disableParts(
KeyframeAnimation.AnimationBuilder builder, Set<String> disabledParts) {
for (String partName : disabledParts) {
KeyframeAnimation.StateCollection part = builder.getPart(partName);
if (part != null) {
part.setEnabled(false);
}
}
}
}

View File

@@ -0,0 +1,177 @@
package com.tiedup.remake.v2.furniture.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import com.tiedup.remake.client.gltf.GltfData;
import com.tiedup.remake.client.gltf.GltfMeshRenderer;
import com.tiedup.remake.client.gltf.GltfSkinningEngine;
import com.tiedup.remake.v2.furniture.EntityFurniture;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import java.util.Map;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.joml.Matrix4f;
/**
* EntityRenderer for data-driven furniture entities.
*
* <p>Renders the furniture mesh from GLB data loaded via {@link FurnitureGltfCache},
* using the existing {@link GltfMeshRenderer} pipeline for CPU-skinned GLTF rendering.
* Supports both static rest-pose and animated rendering based on the entity's
* synched animation state ({@link EntityFurniture#getAnimState()}).</p>
*
* <p>Tint channels from the {@link FurnitureDefinition} are applied via
* {@link GltfMeshRenderer#renderSkinnedTinted} when the definition specifies
* non-empty tint channel defaults and the mesh has multiple primitives.</p>
*
* <p>This is a non-living entity renderer (extends {@code EntityRenderer}, not
* {@code LivingEntityRenderer}), so there is no hurt overlay or death animation.
* Overlay coords use {@link OverlayTexture#NO_OVERLAY} instead of the
* living-entity overlay that reads entity health (which would NPE).</p>
*
* @see EntityFurniture
* @see FurnitureGltfCache
* @see GltfMeshRenderer
*/
@OnlyIn(Dist.CLIENT)
public class FurnitureEntityRenderer extends EntityRenderer<EntityFurniture> {
public FurnitureEntityRenderer(EntityRendererProvider.Context ctx) {
super(ctx);
}
@Override
public void render(
EntityFurniture entity,
float yaw,
float partialTick,
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight
) {
FurnitureDefinition def = entity.getDefinition();
if (def == null) return;
FurnitureGltfData data = FurnitureGltfCache.get(def.modelLocation());
if (data == null || data.furnitureMesh() == null) return;
GltfData meshData = data.furnitureMesh();
// Compute joint matrices: animated if there is an active clip, static otherwise
GltfData.AnimationClip activeClip = resolveActiveAnimation(entity, meshData);
Matrix4f[] joints;
if (activeClip != null) {
float time = computeAnimationTime(entity, activeClip, partialTick);
joints = GltfSkinningEngine.computeJointMatricesAnimated(meshData, activeClip, time);
} else {
joints = GltfSkinningEngine.computeJointMatrices(meshData);
}
poseStack.pushPose();
// Apply entity yaw rotation around the Y axis.
// The entity's yaw is set during placement and synced via IEntityAdditionalSpawnData.
poseStack.mulPose(Axis.YP.rotationDegrees(-yaw));
// Non-living entities use NO_OVERLAY (no red hurt flash, no death tint).
// LivingEntityRenderer.getOverlayCoords(null, ...) would NPE because it
// accesses entity health.
int packedOverlay = OverlayTexture.NO_OVERLAY;
// Render with tint support if the definition has tint channels and the mesh
// has multiple primitives (tintable and non-tintable parts).
Map<String, Integer> tintColors = def.tintChannels();
if (!tintColors.isEmpty() && meshData.primitives().size() > 1) {
RenderType renderType = GltfMeshRenderer.getRenderTypeForDefaultTexture();
GltfMeshRenderer.renderSkinnedTinted(
meshData, joints, poseStack, buffer,
packedLight, packedOverlay, renderType, tintColors
);
} else {
GltfMeshRenderer.renderSkinned(
meshData, joints, poseStack, buffer,
packedLight, packedOverlay
);
}
poseStack.popPose();
// super.render() handles debug hitbox rendering and name tag display
super.render(entity, yaw, partialTick, poseStack, buffer, packedLight);
}
/**
* Map the entity's synched animation state to a named animation clip from the GLB.
*
* <p>Falls back to "Idle" if the specific state animation is not found in the mesh.
* Returns null if the mesh has no animations at all (static furniture).</p>
*
* @param entity the furniture entity
* @param meshData the parsed GLB mesh data
* @return the resolved animation clip, or null for static rendering
*/
private GltfData.AnimationClip resolveActiveAnimation(
EntityFurniture entity, GltfData meshData
) {
String animName = switch (entity.getAnimState()) {
case EntityFurniture.STATE_OCCUPIED -> "Occupied";
case EntityFurniture.STATE_LOCKING -> "LockClose";
case EntityFurniture.STATE_STRUGGLE -> "Shake";
case EntityFurniture.STATE_UNLOCKING -> "LockOpen";
case EntityFurniture.STATE_ENTERING -> "Occupied"; // furniture plays Occupied during player enter transition
case EntityFurniture.STATE_EXITING -> "Idle"; // furniture transitions to Idle during player exit
default -> "Idle";
};
GltfData.AnimationClip clip = meshData.getAnimation(animName);
if (clip == null && entity.getAnimState() != EntityFurniture.STATE_IDLE) {
// Specific state animation missing: fall back to Idle
clip = meshData.getAnimation("Idle");
}
// Returns null if there are no animations at all (static mesh)
return clip;
}
/**
* Compute the animation time in frame-space for the skinning engine.
*
* <p>Uses the entity's tick count plus partial tick for smooth interpolation.
* The time is looped via modulo against the clip's frame count. A playback speed
* of 1.0 means one frame per tick (20 FPS, matching Minecraft's tick rate).</p>
*
* @param entity the furniture entity (provides tick count)
* @param clip the active animation clip (provides frame count)
* @param partialTick fractional tick for interpolation (0.0 to 1.0)
* @return time in frame-space, suitable for {@link GltfSkinningEngine#computeJointMatricesAnimated}
*/
private float computeAnimationTime(
EntityFurniture entity, GltfData.AnimationClip clip, float partialTick
) {
int frameCount = clip.frameCount();
if (frameCount <= 1) return 0f;
// 1 frame per tick = 20 FPS playback, matching Minecraft tick rate.
// partialTick smooths between ticks for frame-rate-independent display.
float time = (entity.tickCount + partialTick);
// Loop within the valid frame range [0, frameCount - 1]
return time % frameCount;
}
/**
* GLB pipeline does not use the vanilla texture atlas system.
* Textures are baked into the GLB file and applied via the custom RenderType
* in {@link GltfMeshRenderer}.
*
* @return null because the GLB pipeline manages its own textures
*/
@Override
public ResourceLocation getTextureLocation(EntityFurniture entity) {
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
package com.tiedup.remake.v2.furniture.client;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Lazy-loading cache for parsed multi-armature furniture GLB data.
*
* <p>Loads .glb files via Minecraft's ResourceManager on first access and parses them
* with {@link FurnitureGlbParser}. Thread-safe via {@link ConcurrentHashMap}.
*
* <p>Call {@link #clear()} on resource reload (e.g., F3+T) to invalidate stale entries.
*
* <p>This class is client-only and must never be referenced from server code.
*/
@OnlyIn(Dist.CLIENT)
public final class FurnitureGltfCache {
private static final Logger LOGGER = LogManager.getLogger("FurnitureGltf");
/**
* Sentinel value stored in the cache when loading fails, to avoid retrying
* broken resources on every frame.
*/
private static final FurnitureGltfData FAILED_SENTINEL = new FurnitureGltfData(
null,
Map.of(),
Map.of(),
Map.of()
);
private static final Map<ResourceLocation, FurnitureGltfData> CACHE = new ConcurrentHashMap<>();
private FurnitureGltfCache() {}
/**
* Get parsed furniture GLB data for a resource, loading and parsing on first access.
*
* @param modelLocation resource location of the .glb file
* (e.g., {@code tiedup:models/furniture/wooden_stocks.glb})
* @return parsed {@link FurnitureGltfData}, or {@code null} if loading/parsing failed
*/
@Nullable
public static FurnitureGltfData get(ResourceLocation modelLocation) {
FurnitureGltfData cached = CACHE.computeIfAbsent(modelLocation, FurnitureGltfCache::load);
return cached == FAILED_SENTINEL ? null : cached;
}
/**
* Load and parse a furniture GLB from the resource manager.
*
* @return parsed data, or the {@link #FAILED_SENTINEL} on failure
*/
private static FurnitureGltfData load(ResourceLocation loc) {
try {
Resource resource = Minecraft.getInstance()
.getResourceManager()
.getResource(loc)
.orElse(null);
if (resource == null) {
LOGGER.error("[FurnitureGltf] Resource not found: {}", loc);
return FAILED_SENTINEL;
}
try (InputStream is = resource.open()) {
FurnitureGltfData data = FurnitureGlbParser.parse(is, loc.toString());
LOGGER.debug("[FurnitureGltf] Cached: {}", loc);
return data;
}
} catch (Exception e) {
LOGGER.error("[FurnitureGltf] Failed to load furniture GLB: {}", loc, e);
return FAILED_SENTINEL;
}
}
/**
* Clear all cached data. Call on resource reload (F3+T) or dimension change.
*/
public static void clear() {
int size = CACHE.size();
CACHE.clear();
if (size > 0) {
LOGGER.info("[FurnitureGltf] Cache cleared ({} entries)", size);
}
}
}

View File

@@ -0,0 +1,56 @@
package com.tiedup.remake.v2.furniture.client;
import com.tiedup.remake.client.gltf.GltfData;
import java.util.Map;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Parsed multi-armature GLB data for a furniture piece.
*
* <p>A furniture GLB may contain:
* <ul>
* <li>A <b>furniture armature</b> with mesh/skeleton for the furniture itself</li>
* <li>Zero or more <b>Player_*</b> armatures defining seat positions and player animations</li>
* </ul>
*
* <p>The furniture mesh is parsed into a standard {@link GltfData} for rendering via the
* existing {@code GltfMeshRenderer}. Seat transforms and animations are extracted from
* Player_* armatures and keyed by seat ID (derived from armature name, e.g.
* {@code "Player_main"} becomes seat ID {@code "main"}).
*/
@OnlyIn(Dist.CLIENT)
public record FurnitureGltfData(
/** Furniture mesh data (renderable via GltfMeshRenderer). */
GltfData furnitureMesh,
/** Per-seat root transforms from Player_* armatures: seatId to transform. */
Map<String, SeatTransform> seatTransforms,
/** Per-seat player animations: seatId to (animName to clip). */
Map<String, Map<String, GltfData.AnimationClip>> seatAnimations,
/**
* Per-seat player skeleton data (for GltfPoseConverter).
* Joints are filtered through {@code GltfBoneMapper.isKnownBone()},
* rest poses are converted to Minecraft space, and animations are
* remapped to match the filtered joint indices.
*/
Map<String, GltfData> seatSkeletons
) {
/**
* Root transform of a Player_* armature, defining where a seated player is
* positioned and oriented relative to the furniture origin.
*
* @param seatId seat identifier (e.g., "main", "left")
* @param position translation offset in glTF space (meters, Y-up)
* @param rotation orientation quaternion in glTF space
*/
public record SeatTransform(
String seatId,
Vector3f position,
Quaternionf rotation
) {}
}

View File

@@ -0,0 +1,73 @@
package com.tiedup.remake.v2.furniture.client;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.jetbrains.annotations.Nullable;
import org.joml.Vector3f;
/**
* Client-only helper to resolve seat world positions from parsed GLB data.
*
* <p>This class exists to isolate the {@link FurnitureGltfCache} dependency
* from {@link com.tiedup.remake.v2.furniture.EntityFurniture EntityFurniture},
* which is loaded on both client and dedicated server. The dedicated server
* never touches this class, preventing classloader errors from the
* {@code @OnlyIn(Dist.CLIENT)} cache/parser classes.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class FurnitureSeatPositionHelper {
private FurnitureSeatPositionHelper() {}
/**
* Look up the seat transform from the parsed GLB data and compute
* the world position for a passenger in that seat.
*
* @param def the furniture definition (provides modelLocation)
* @param seatId the seat identifier to look up
* @param furnitureX furniture entity X position
* @param furnitureY furniture entity Y position
* @param furnitureZ furniture entity Z position
* @param furnitureYRot furniture entity Y rotation in degrees
* @return the world position [x, y, z] for the passenger, or null if
* the GLB data or seat transform is unavailable
*/
@Nullable
public static double[] getSeatWorldPosition(
FurnitureDefinition def,
String seatId,
double furnitureX, double furnitureY, double furnitureZ,
float furnitureYRot
) {
ResourceLocation modelLoc = def.modelLocation();
if (modelLoc == null) return null;
FurnitureGltfData gltfData = FurnitureGltfCache.get(modelLoc);
if (gltfData == null) return null;
FurnitureGltfData.SeatTransform transform = gltfData.seatTransforms().get(seatId);
if (transform == null) return null;
// The seat transform position is in Minecraft model space (post-conversion):
// X and Y are negated from glTF. We need to rotate by the entity's yaw.
Vector3f pos = transform.position();
float yawRad = (float) Math.toRadians(-furnitureYRot);
float cos = (float) Math.cos(yawRad);
float sin = (float) Math.sin(yawRad);
// Rotate the local seat offset by the furniture's yaw around the Y axis
float sx = pos.x();
float sy = pos.y();
float sz = pos.z();
float rx = sx * cos - sz * sin;
float rz = sx * sin + sz * cos;
return new double[] {
furnitureX + rx,
furnitureY + sy,
furnitureZ + rz
};
}
}

View File

@@ -0,0 +1,536 @@
package com.tiedup.remake.v2.furniture.network;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.ItemLockpick;
import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState;
import com.tiedup.remake.minigame.LockpickMiniGameState;
import com.tiedup.remake.minigame.LockpickSessionManager;
import com.tiedup.remake.minigame.StruggleSessionManager;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.PacketRateLimiter;
import com.tiedup.remake.network.minigame.PacketContinuousStruggleState;
import com.tiedup.remake.network.minigame.PacketLockpickMiniGameState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.furniture.EntityFurniture;
import com.tiedup.remake.v2.furniture.ISeatProvider;
import com.tiedup.remake.v2.furniture.SeatDefinition;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.network.NetworkEvent;
/**
* Client-to-server packet: seated player initiates a struggle escape, or a
* third party standing nearby initiates a lockpick escape.
*
* <p>Escape methods:
* <ul>
* <li><b>STRUGGLE (0)</b>: Sender must BE the seated passenger. Always
* allowed regardless of blocked regions.</li>
* <li><b>LOCKPICK (1)</b>: Sender must NOT be the seated passenger (third
* party standing nearby). Must have a lockpick in their inventory.</li>
* </ul>
*
* <p>Difficulty is computed as: {@code total = min(seat.lockedDifficulty + itemBonus, 600)},
* where {@code itemBonus} is the sum of {@link IV2BondageItem#getEscapeDifficulty(ItemStack)}
* for all equipped items on NON-blocked body regions.</p>
*
* <p>Wire format: int furnitureEntityId (4) + byte escapeMethod (1)</p>
*
* <p>Direction: Client to Server (C2S)</p>
*/
public class PacketFurnitureEscape {
/** Escape method: the seated player struggles to break free. */
public static final byte METHOD_STRUGGLE = 0;
/** Escape method: a third party lockpicks the seat lock. */
public static final byte METHOD_LOCKPICK = 1;
/** Maximum total escape difficulty (cap). */
private static final int MAX_DIFFICULTY = 600;
private final int furnitureEntityId;
private final byte escapeMethod;
public PacketFurnitureEscape(int furnitureEntityId, byte escapeMethod) {
this.furnitureEntityId = furnitureEntityId;
this.escapeMethod = escapeMethod;
}
// ==================== Codec ====================
public static void encode(PacketFurnitureEscape msg, FriendlyByteBuf buf) {
buf.writeInt(msg.furnitureEntityId);
buf.writeByte(msg.escapeMethod);
}
public static PacketFurnitureEscape decode(FriendlyByteBuf buf) {
return new PacketFurnitureEscape(buf.readInt(), buf.readByte());
}
// ==================== Handler ====================
public static void handle(
PacketFurnitureEscape msg,
Supplier<NetworkEvent.Context> ctxSupplier
) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> handleOnServer(msg, ctx));
ctx.setPacketHandled(true);
}
private static void handleOnServer(PacketFurnitureEscape msg, NetworkEvent.Context ctx) {
ServerPlayer sender = ctx.getSender();
if (sender == null) return;
// Rate limit: prevent escape spam
if (!PacketRateLimiter.allowPacket(sender, "struggle")) return;
// Resolve the furniture entity
Entity entity = sender.level().getEntity(msg.furnitureEntityId);
if (entity == null) return;
if (!(entity instanceof ISeatProvider provider)) return;
if (sender.distanceTo(entity) > 5.0) return;
if (!entity.isAlive() || entity.isRemoved()) return;
// Validate escape method
if (msg.escapeMethod != METHOD_STRUGGLE && msg.escapeMethod != METHOD_LOCKPICK) {
TiedUpMod.LOGGER.warn(
"[PacketFurnitureEscape] Invalid escape method {} from {}",
msg.escapeMethod, sender.getName().getString()
);
return;
}
if (msg.escapeMethod == METHOD_STRUGGLE) {
handleStruggle(sender, entity, provider);
} else {
handleLockpick(sender, entity, provider);
}
}
// ==================== Struggle ====================
/**
* Sender must BE the seated passenger. Always allowed regardless of blocked
* regions.
*/
private static void handleStruggle(
ServerPlayer sender,
Entity furnitureEntity,
ISeatProvider provider
) {
// Sender must be riding this furniture
if (!furnitureEntity.hasPassenger(sender)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Struggle: {} is not a passenger of furniture {}",
sender.getName().getString(), furnitureEntity.getId()
);
return;
}
// Find sender's seat
SeatDefinition seat = provider.getSeatForPassenger(sender);
if (seat == null) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Struggle: {} has no assigned seat",
sender.getName().getString()
);
return;
}
// Seat must be locked (no point struggling if unlocked — just dismount)
if (!provider.isSeatLocked(seat.id())) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Struggle: seat '{}' is not locked, no struggle needed",
seat.id()
);
return;
}
// Compute difficulty
int baseDifficulty = provider.getLockedDifficulty(seat.id());
int itemBonus = computeItemDifficultyBonus(sender, provider, seat);
int totalDifficulty = Math.min(baseDifficulty + itemBonus, MAX_DIFFICULTY);
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Struggle: {} on seat '{}' — difficulty {} (base {} + items {})",
sender.getName().getString(), seat.id(),
totalDifficulty, baseDifficulty, itemBonus
);
// Difficulty 0: immediate escape (no minigame needed)
if (totalDifficulty == 0) {
provider.setSeatLocked(seat.id(), false);
sender.getPersistentData().remove("tiedup_locked_furniture");
sender.stopRiding();
// Broadcast updated state
if (furnitureEntity instanceof EntityFurniture furniture) {
PacketSyncFurnitureState.sendToTracking(furniture);
}
TiedUpMod.LOGGER.info(
"[PacketFurnitureEscape] {} escaped furniture {} (difficulty was 0)",
sender.getName().getString(), furnitureEntity.getId()
);
return;
}
// Respect server config: if struggle minigame is disabled, skip
if (!com.tiedup.remake.core.ModConfig.SERVER.struggleMiniGameEnabled.get()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Struggle minigame disabled by server config"
);
return;
}
// Launch struggle minigame session via StruggleSessionManager
StruggleSessionManager manager = StruggleSessionManager.getInstance();
ContinuousStruggleMiniGameState session = manager.startFurnitureStruggleSession(
sender, furnitureEntity.getId(), seat.id(), totalDifficulty
);
if (session != null) {
// Send START packet to open the struggle GUI on the client
ModNetwork.sendToPlayer(
new PacketContinuousStruggleState(
session.getSessionId(),
ContinuousStruggleMiniGameState.UpdateType.START,
session.getCurrentDirection().getIndex(),
session.getCurrentResistance(),
session.getMaxResistance(),
true // locked context
),
sender
);
// Set furniture animation state to STRUGGLE
if (furnitureEntity instanceof EntityFurniture furniture) {
furniture.setAnimState(EntityFurniture.STATE_STRUGGLE);
PacketSyncFurnitureState.sendToTracking(furniture);
}
}
}
// ==================== Lockpick ====================
/**
* Sender must NOT be the seated passenger (third party standing nearby).
* Must have a lockpick item in inventory. Targets the locked occupied seat
* closest to the sender's look direction.
*/
private static void handleLockpick(
ServerPlayer sender,
Entity furnitureEntity,
ISeatProvider provider
) {
// Sender must NOT be riding this furniture (third party assistance)
if (furnitureEntity.hasPassenger(sender)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Lockpick: {} cannot lockpick while seated",
sender.getName().getString()
);
return;
}
// Sender must have a lockpick in their inventory
if (!hasLockpickInInventory(sender)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Lockpick: {} has no lockpick",
sender.getName().getString()
);
return;
}
// Use look-direction-based seat targeting (same vector math as
// EntityFurniture.findNearestOccupiedLockableSeat) instead of
// blindly picking the first locked seat.
SeatDefinition targetSeat = findNearestLockedOccupiedSeat(sender, provider, furnitureEntity);
if (targetSeat == null) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Lockpick: no locked occupied seat found"
);
return;
}
// Find the passenger in this seat (needed for item bonus computation)
Entity passenger = findPassengerInSeat(provider, furnitureEntity, targetSeat.id());
// Compute difficulty
int baseDifficulty = provider.getLockedDifficulty(targetSeat.id());
int itemBonus = 0;
if (passenger instanceof LivingEntity livingPassenger) {
itemBonus = computeItemDifficultyBonus(livingPassenger, provider, targetSeat);
}
int totalDifficulty = Math.min(baseDifficulty + itemBonus, MAX_DIFFICULTY);
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Lockpick: {} on seat '{}' -- difficulty {} (base {} + items {})",
sender.getName().getString(), targetSeat.id(),
totalDifficulty, baseDifficulty, itemBonus
);
// Difficulty 0: immediate success — unlock + dismount + consume lockpick
if (totalDifficulty == 0) {
completeLockpickSuccess(sender, furnitureEntity, provider, targetSeat, passenger);
return;
}
// Respect server config: if lockpick minigame is disabled, skip
if (!com.tiedup.remake.core.ModConfig.SERVER.lockpickMiniGameEnabled.get()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Lockpick minigame disabled by server config"
);
return;
}
// Find the lockpick to determine remaining uses and sweet spot width
ItemStack lockpickStack = ItemLockpick.findLockpickInInventory(sender);
if (lockpickStack.isEmpty()) return; // double-check; hasLockpickInInventory passed above
int remainingUses = lockpickStack.getMaxDamage() - lockpickStack.getDamageValue();
// Sweet spot width scales inversely with difficulty: harder locks = narrower sweet spot.
// Base width 0.15 at difficulty 1, down to 0.03 at MAX_DIFFICULTY.
float sweetSpotWidth = Math.max(0.03f, 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.
LockpickSessionManager lockpickManager = LockpickSessionManager.getInstance();
LockpickMiniGameState session = lockpickManager.startLockpickSession(
sender,
furnitureEntity.getId(), // repurpose targetSlot as entity ID
sweetSpotWidth
);
if (session == null) {
TiedUpMod.LOGGER.warn(
"[PacketFurnitureEscape] Failed to create lockpick session for {}",
sender.getName().getString()
);
return;
}
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.
net.minecraft.nbt.CompoundTag ctx = new net.minecraft.nbt.CompoundTag();
ctx.putInt("furniture_id", furnitureEntity.getId());
ctx.putString("seat_id", targetSeat.id());
sender.getPersistentData().put("tiedup_furniture_lockpick_ctx", ctx);
// Send initial lockpick state to open the minigame GUI on the client
ModNetwork.sendToPlayer(
new PacketLockpickMiniGameState(
session.getSessionId(),
session.getSweetSpotCenter(),
session.getSweetSpotWidth(),
session.getCurrentPosition(),
session.getRemainingUses()
),
sender
);
TiedUpMod.LOGGER.info(
"[PacketFurnitureEscape] {} started lockpick on seat '{}' of furniture {} (difficulty {}, sweet spot width {})",
sender.getName().getString(), targetSeat.id(),
furnitureEntity.getId(), totalDifficulty, sweetSpotWidth
);
}
/**
* Complete a successful lockpick: unlock the seat, clear reconnection tag,
* dismount the passenger, consume/damage the lockpick, and broadcast state.
*/
private static void completeLockpickSuccess(
ServerPlayer sender,
Entity furnitureEntity,
ISeatProvider provider,
SeatDefinition targetSeat,
Entity passenger
) {
provider.setSeatLocked(targetSeat.id(), false);
if (passenger instanceof ServerPlayer passengerPlayer) {
passengerPlayer.getPersistentData().remove("tiedup_locked_furniture");
}
if (passenger != null) {
passenger.stopRiding();
}
// Damage the lockpick (1 durability per successful pick)
ItemStack lockpickStack = ItemLockpick.findLockpickInInventory(sender);
if (!lockpickStack.isEmpty()) {
lockpickStack.setDamageValue(lockpickStack.getDamageValue() + 1);
if (lockpickStack.getDamageValue() >= lockpickStack.getMaxDamage()) {
lockpickStack.shrink(1);
}
}
// Broadcast updated state
if (furnitureEntity instanceof EntityFurniture furniture) {
PacketSyncFurnitureState.sendToTracking(furniture);
}
TiedUpMod.LOGGER.info(
"[PacketFurnitureEscape] {} lockpicked seat '{}' on furniture {} (difficulty was 0)",
sender.getName().getString(), targetSeat.id(), furnitureEntity.getId()
);
}
// ==================== Helpers ====================
/**
* Compute the item difficulty bonus: sum of {@code getEscapeDifficulty(stack)}
* for all V2 bondage items equipped on NON-blocked body regions.
*
* <p>Only applies if the seat's {@code itemDifficultyBonus} flag is true.</p>
*/
private static int computeItemDifficultyBonus(
LivingEntity passenger,
ISeatProvider provider,
SeatDefinition seat
) {
if (!provider.hasItemDifficultyBonus(seat.id())) {
return 0;
}
Set<BodyRegionV2> blockedRegions = provider.getBlockedRegions(seat.id());
Map<BodyRegionV2, ItemStack> equipped = V2EquipmentHelper.getAllEquipped(passenger);
int bonus = 0;
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
// Skip items on blocked regions (those are "held" by the furniture)
if (blockedRegions.contains(entry.getKey())) continue;
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem bondageItem) {
bonus += bondageItem.getEscapeDifficulty(stack);
}
}
return bonus;
}
/**
* Check if the sender has a lockpick item anywhere in their inventory.
*/
private static boolean hasLockpickInInventory(ServerPlayer player) {
for (ItemStack stack : player.getInventory().items) {
if (stack.getItem() instanceof ItemLockpick) return true;
}
// Also check offhand
if (player.getOffhandItem().getItem() instanceof ItemLockpick) return true;
return false;
}
/**
* Find the locked and occupied seat whose approximate world position has the
* smallest angle to the player's look direction.
*
* <p>Uses the same vector math as
* {@link EntityFurniture#findNearestOccupiedLockableSeat} to ensure
* consistent targeting between key interactions and lockpick attempts.</p>
*
* @param player the player performing the lockpick (provides look direction)
* @param provider the seat provider (furniture entity)
* @param furnitureEntity the furniture entity (provides position and yaw)
* @return the best matching seat, or null if no locked occupied seats exist
*/
private static SeatDefinition findNearestLockedOccupiedSeat(
ServerPlayer player,
ISeatProvider provider,
Entity furnitureEntity
) {
List<SeatDefinition> seats = provider.getSeats();
if (seats.isEmpty()) return null;
Vec3 playerPos = player.getEyePosition();
Vec3 lookDir = player.getLookAngle();
float yawRad = (float) Math.toRadians(furnitureEntity.getYRot());
// Entity-local right axis (perpendicular to facing in the XZ plane)
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
SeatDefinition best = null;
double bestScore = Double.MAX_VALUE;
int seatCount = seats.size();
for (int i = 0; i < seatCount; i++) {
SeatDefinition seat = seats.get(i);
// Only consider locked seats that have a passenger
if (!provider.isSeatLocked(seat.id())) continue;
boolean hasPassenger = false;
for (Entity p : furnitureEntity.getPassengers()) {
SeatDefinition ps = provider.getSeatForPassenger(p);
if (ps != null && ps.id().equals(seat.id())) {
hasPassenger = true;
break;
}
}
if (!hasPassenger) continue;
// Approximate seat world position along the right axis
double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
Vec3 seatWorldPos = new Vec3(
furnitureEntity.getX() + rightX * offset,
furnitureEntity.getY() + 0.5,
furnitureEntity.getZ() + rightZ * offset
);
Vec3 toSeat = seatWorldPos.subtract(playerPos);
double distSq = toSeat.lengthSqr();
if (distSq < 1e-6) {
best = seat;
bestScore = -1.0;
continue;
}
Vec3 toSeatNorm = toSeat.normalize();
double dot = lookDir.dot(toSeatNorm);
double score = -dot; // lower = better (looking more directly at seat)
if (score < bestScore) {
bestScore = score;
best = seat;
}
}
return best;
}
/**
* Find the passenger entity sitting in a specific seat.
*/
private static Entity findPassengerInSeat(
ISeatProvider provider,
Entity furnitureEntity,
String seatId
) {
for (Entity passenger : furnitureEntity.getPassengers()) {
SeatDefinition passengerSeat = provider.getSeatForPassenger(passenger);
if (passengerSeat != null && passengerSeat.id().equals(seatId)) {
return passenger;
}
}
return null;
}
}

View File

@@ -0,0 +1,241 @@
package com.tiedup.remake.v2.furniture.network;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.network.PacketRateLimiter;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.v2.furniture.EntityFurniture;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import com.tiedup.remake.v2.furniture.FurnitureFeedback;
import com.tiedup.remake.v2.furniture.ISeatProvider;
import com.tiedup.remake.v2.furniture.SeatDefinition;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.AABB;
import net.minecraftforge.network.NetworkEvent;
/**
* Client-to-server packet: master forces a captive onto a furniture seat.
*
* <p>The sender must own the captive's collar (verified via
* {@link ItemCollar#isOwner(ItemStack, net.minecraft.world.entity.player.Player)}),
* the captive must be alive and within 5 blocks of both sender and furniture,
* and the furniture must have an available seat.</p>
*
* <p>Wire format: int furnitureEntityId (4) + UUID captiveUUID (16)</p>
*
* <p>Direction: Client to Server (C2S)</p>
*/
public class PacketFurnitureForcemount {
private final int furnitureEntityId;
private final UUID captiveUUID;
public PacketFurnitureForcemount(int furnitureEntityId, UUID captiveUUID) {
this.furnitureEntityId = furnitureEntityId;
this.captiveUUID = captiveUUID;
}
// ==================== Codec ====================
public static void encode(PacketFurnitureForcemount msg, FriendlyByteBuf buf) {
buf.writeInt(msg.furnitureEntityId);
buf.writeUUID(msg.captiveUUID);
}
public static PacketFurnitureForcemount decode(FriendlyByteBuf buf) {
return new PacketFurnitureForcemount(buf.readInt(), buf.readUUID());
}
// ==================== Handler ====================
public static void handle(
PacketFurnitureForcemount msg,
Supplier<NetworkEvent.Context> ctxSupplier
) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> handleOnServer(msg, ctx));
ctx.setPacketHandled(true);
}
private static void handleOnServer(PacketFurnitureForcemount msg, NetworkEvent.Context ctx) {
ServerPlayer sender = ctx.getSender();
if (sender == null) return;
// Rate limit: prevent force-mount spam
if (!PacketRateLimiter.allowPacket(sender, "action")) return;
// Resolve the furniture entity
Entity entity = sender.level().getEntity(msg.furnitureEntityId);
if (entity == null) return;
if (!(entity instanceof ISeatProvider provider)) return;
if (sender.distanceTo(entity) > 5.0) return;
if (!entity.isAlive() || entity.isRemoved()) return;
// Look up captive by UUID in the sender's level
LivingEntity captive = findCaptiveByUUID(sender, msg.captiveUUID);
if (captive == null) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive not found: {}",
msg.captiveUUID
);
return;
}
// Captive must be alive
if (!captive.isAlive() || captive.isRemoved()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive is not alive: {}",
captive.getName().getString()
);
return;
}
// Captive must be within 5 blocks of both sender and furniture
if (sender.distanceTo(captive) > 5.0) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive too far from sender"
);
return;
}
if (captive.distanceTo(entity) > 5.0) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive too far from furniture"
);
return;
}
// Verify collar ownership: captive must have a collar owned by sender
IBondageState captiveState = KidnappedHelper.getKidnappedState(captive);
if (captiveState == null || !captiveState.hasCollar()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive has no collar: {}",
captive.getName().getString()
);
return;
}
ItemStack collarStack = captiveState.getEquipment(BodyRegionV2.NECK);
if (collarStack.isEmpty()
|| !(collarStack.getItem() instanceof ItemCollar collar)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Invalid collar item on captive"
);
return;
}
// Collar must be owned by sender (or sender has admin permission)
if (!collar.isOwner(collarStack, sender) && !sender.hasPermissions(2)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] {} is not the collar owner of {}",
sender.getName().getString(),
captive.getName().getString()
);
return;
}
// Find the first available (unoccupied) seat
String availableSeatId = findFirstAvailableSeat(provider, entity);
if (availableSeatId == null) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] No available seat on furniture entity {}",
msg.furnitureEntityId
);
return;
}
// Force-mount: startRiding triggers EntityFurniture.addPassenger which
// assigns the first available seat automatically. We use force=true to
// bypass any canRide checks on the captive.
boolean success = captive.startRiding(entity, true);
if (success) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] {} force-mounted {} onto furniture {}",
sender.getName().getString(),
captive.getName().getString(),
msg.furnitureEntityId
);
// Play mount sound from FurnitureFeedback
if (entity instanceof EntityFurniture furniture) {
FurnitureDefinition def = furniture.getDefinition();
if (def != null) {
FurnitureFeedback feedback = def.feedback();
ResourceLocation mountSoundRL = feedback.mountSound();
if (mountSoundRL != null) {
SoundEvent sound = SoundEvent.createVariableRangeEvent(mountSoundRL);
entity.level().playSound(
null,
entity.getX(), entity.getY(), entity.getZ(),
sound, SoundSource.BLOCKS,
1.0f, 1.0f
);
}
}
// Broadcast updated state to tracking clients
PacketSyncFurnitureState.sendToTracking(furniture);
}
} else {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] startRiding failed for {} on furniture {}",
captive.getName().getString(),
msg.furnitureEntityId
);
}
}
// ==================== Helpers ====================
/**
* Find a living entity by UUID within a reasonable range of the sender.
* Checks players first (O(1) lookup), then falls back to entity search.
*/
private static LivingEntity findCaptiveByUUID(ServerPlayer sender, UUID uuid) {
// Try player lookup first (fast)
net.minecraft.world.entity.player.Player player =
sender.level().getPlayerByUUID(uuid);
if (player != null) return player;
// Search nearby entities (64 block radius)
AABB searchBox = sender.getBoundingBox().inflate(64);
for (LivingEntity nearby : sender.level()
.getEntitiesOfClass(LivingEntity.class, searchBox)) {
if (nearby.getUUID().equals(uuid)) {
return nearby;
}
}
return null;
}
/**
* Find the first available (unoccupied) seat on the furniture.
*
* @return the seat ID, or null if all seats are occupied
*/
private static String findFirstAvailableSeat(ISeatProvider provider, Entity furniture) {
for (SeatDefinition seat : provider.getSeats()) {
boolean occupied = false;
for (Entity passenger : furniture.getPassengers()) {
SeatDefinition passengerSeat = provider.getSeatForPassenger(passenger);
if (passengerSeat != null && passengerSeat.id().equals(seat.id())) {
occupied = true;
break;
}
}
if (!occupied) return seat.id();
}
return null;
}
}

View File

@@ -0,0 +1,188 @@
package com.tiedup.remake.v2.furniture.network;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.ItemMasterKey;
import com.tiedup.remake.network.PacketRateLimiter;
import com.tiedup.remake.v2.furniture.EntityFurniture;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import com.tiedup.remake.v2.furniture.FurnitureFeedback;
import com.tiedup.remake.v2.furniture.ISeatProvider;
import com.tiedup.remake.v2.furniture.SeatDefinition;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.Entity;
import net.minecraftforge.network.NetworkEvent;
/**
* Client-to-server packet: toggle lock/unlock on a specific furniture seat.
*
* <p>The sender must hold a key item (ItemMasterKey) in their main hand,
* the seat must be lockable and occupied (someone sitting in it), and the
* sender must be within 5 blocks of the furniture entity.</p>
*
* <p>On success, toggles the lock state and broadcasts a
* {@link PacketSyncFurnitureState} to all tracking clients.</p>
*
* <p>Wire format: int entityId (4) + utf seatId (variable)</p>
*
* <p>Direction: Client to Server (C2S)</p>
*/
public class PacketFurnitureLock {
private final int entityId;
private final String seatId;
public PacketFurnitureLock(int entityId, String seatId) {
this.entityId = entityId;
this.seatId = seatId;
}
// ==================== Codec ====================
public static void encode(PacketFurnitureLock msg, FriendlyByteBuf buf) {
buf.writeInt(msg.entityId);
buf.writeUtf(msg.seatId);
}
public static PacketFurnitureLock decode(FriendlyByteBuf buf) {
return new PacketFurnitureLock(buf.readInt(), buf.readUtf());
}
// ==================== Handler ====================
public static void handle(
PacketFurnitureLock msg,
Supplier<NetworkEvent.Context> ctxSupplier
) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> handleOnServer(msg, ctx));
ctx.setPacketHandled(true);
}
private static void handleOnServer(PacketFurnitureLock msg, NetworkEvent.Context ctx) {
ServerPlayer sender = ctx.getSender();
if (sender == null) return;
// Rate limit: prevent lock toggle spam
if (!PacketRateLimiter.allowPacket(sender, "action")) return;
// Resolve the target entity
Entity entity = sender.level().getEntity(msg.entityId);
if (entity == null) return;
if (!(entity instanceof ISeatProvider provider)) return;
if (sender.distanceTo(entity) > 5.0) return;
if (!entity.isAlive() || entity.isRemoved()) return;
// Sender must hold a key item in either hand
boolean hasKey = (sender.getMainHandItem().getItem() instanceof ItemMasterKey)
|| (sender.getOffhandItem().getItem() instanceof ItemMasterKey);
if (!hasKey) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] {} does not hold a key item in either hand",
sender.getName().getString()
);
return;
}
// Validate the seat exists and is lockable
SeatDefinition seat = findSeatById(provider, msg.seatId);
if (seat == null) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] Seat '{}' not found on entity {}",
msg.seatId, msg.entityId
);
return;
}
if (!seat.lockable()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] Seat '{}' is not lockable",
msg.seatId
);
return;
}
// Seat must be occupied (someone sitting in it)
if (!isSeatOccupied(provider, entity, msg.seatId)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] Seat '{}' is not occupied",
msg.seatId
);
return;
}
// Toggle the lock state
boolean wasLocked = provider.isSeatLocked(msg.seatId);
provider.setSeatLocked(msg.seatId, !wasLocked);
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] {} {} seat '{}' on furniture entity {}",
sender.getName().getString(),
wasLocked ? "unlocked" : "locked",
msg.seatId,
msg.entityId
);
// Play lock/unlock sound and set animation state
if (entity instanceof EntityFurniture furniture) {
FurnitureDefinition def = furniture.getDefinition();
if (def != null) {
FurnitureFeedback feedback = def.feedback();
ResourceLocation soundRL = wasLocked
? feedback.unlockSound()
: feedback.lockSound();
if (soundRL != null) {
SoundEvent sound = SoundEvent.createVariableRangeEvent(soundRL);
entity.level().playSound(
null, // null = play for all nearby players
entity.getX(), entity.getY(), entity.getZ(),
sound, SoundSource.BLOCKS,
1.0f, 1.0f
);
}
}
// Set lock/unlock animation state. The next updateAnimState() call
// (from tick or passenger change) will reset it to OCCUPIED/IDLE.
boolean nowLocked = !wasLocked;
furniture.setAnimState(nowLocked
? EntityFurniture.STATE_LOCKING
: EntityFurniture.STATE_UNLOCKING);
// Broadcast updated state to all tracking clients
PacketSyncFurnitureState.sendToTracking(furniture);
}
}
// ==================== Helpers ====================
/**
* Find a SeatDefinition by ID from the provider's seat list.
*/
private static SeatDefinition findSeatById(ISeatProvider provider, String seatId) {
for (SeatDefinition seat : provider.getSeats()) {
if (seat.id().equals(seatId)) return seat;
}
return null;
}
/**
* Check if a seat is occupied by any passenger.
*/
private static boolean isSeatOccupied(
ISeatProvider provider,
Entity furnitureEntity,
String seatId
) {
for (Entity passenger : furnitureEntity.getPassengers()) {
SeatDefinition passengerSeat = provider.getSeatForPassenger(passenger);
if (passengerSeat != null && passengerSeat.id().equals(seatId)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,308 @@
package com.tiedup.remake.v2.furniture.network;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import com.tiedup.remake.v2.furniture.FurnitureFeedback;
import com.tiedup.remake.v2.furniture.FurnitureRegistry;
import com.tiedup.remake.v2.furniture.SeatDefinition;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.fml.loading.FMLEnvironment;
import net.minecraftforge.network.NetworkEvent;
import net.minecraftforge.network.PacketDistributor;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Server-to-client packet that syncs ALL furniture definitions from the
* {@link FurnitureRegistry} to a client.
*
* <p>Sent on player login and after {@code /reload}. The client handler
* calls {@link FurnitureRegistry#reload(Map)} with the deserialized
* definitions, replacing its entire local cache.</p>
*
* <p>Wire format: varint definition count, then for each definition
* the full set of fields including nested {@link SeatDefinition} list
* and {@link FurnitureFeedback} optional sounds.</p>
*
* <p>Direction: Server to Client (S2C)</p>
*/
public class PacketSyncFurnitureDefinitions {
private static final Logger LOGGER = LogManager.getLogger("PacketSyncFurnitureDefinitions");
/**
* Safety cap on the number of definitions to prevent memory exhaustion
* from malformed or malicious packets.
*/
private static final int MAX_DEFINITIONS = 10_000;
/**
* Safety cap on the number of seats per furniture definition.
*/
private static final int MAX_SEATS = 64;
/**
* Safety cap on the number of tint channels per definition.
*/
private static final int MAX_TINT_CHANNELS = 32;
/**
* Safety cap on the number of blocked regions per seat.
*/
private static final int MAX_BLOCKED_REGIONS = BodyRegionV2.values().length;
private final Map<ResourceLocation, FurnitureDefinition> definitions;
public PacketSyncFurnitureDefinitions(Map<ResourceLocation, FurnitureDefinition> definitions) {
this.definitions = definitions;
}
// ==================== Codec ====================
public static void encode(PacketSyncFurnitureDefinitions msg, FriendlyByteBuf buf) {
buf.writeVarInt(msg.definitions.size());
for (FurnitureDefinition def : msg.definitions.values()) {
// Identity
buf.writeResourceLocation(def.id());
buf.writeUtf(def.displayName());
// Optional translation key
boolean hasTranslationKey = def.translationKey() != null;
buf.writeBoolean(hasTranslationKey);
if (hasTranslationKey) {
buf.writeUtf(def.translationKey());
}
// Model
buf.writeResourceLocation(def.modelLocation());
// Tint channels
buf.writeVarInt(def.tintChannels().size());
for (Map.Entry<String, Integer> entry : def.tintChannels().entrySet()) {
buf.writeUtf(entry.getKey());
buf.writeInt(entry.getValue());
}
// Booleans and floats for placement/physics
buf.writeBoolean(def.supportsColor());
buf.writeFloat(def.hitboxWidth());
buf.writeFloat(def.hitboxHeight());
buf.writeBoolean(def.snapToWall());
buf.writeBoolean(def.floorOnly());
buf.writeBoolean(def.lockable());
buf.writeFloat(def.breakResistance());
buf.writeBoolean(def.dropOnBreak());
// Seats
buf.writeVarInt(def.seats().size());
for (SeatDefinition seat : def.seats()) {
encodeSeat(seat, buf);
}
// Feedback (6 optional ResourceLocations)
encodeFeedback(def.feedback(), buf);
// Category
buf.writeUtf(def.category());
// Icon (optional model ResourceLocation)
writeOptionalRL(buf, def.icon());
}
}
private static void encodeSeat(SeatDefinition seat, FriendlyByteBuf buf) {
buf.writeUtf(seat.id());
buf.writeUtf(seat.armatureName());
// Blocked regions as string names
buf.writeVarInt(seat.blockedRegions().size());
for (BodyRegionV2 region : seat.blockedRegions()) {
buf.writeUtf(region.name());
}
buf.writeBoolean(seat.lockable());
buf.writeVarInt(seat.lockedDifficulty());
buf.writeBoolean(seat.itemDifficultyBonus());
}
private static void encodeFeedback(FurnitureFeedback feedback, FriendlyByteBuf buf) {
writeOptionalRL(buf, feedback.mountSound());
writeOptionalRL(buf, feedback.lockSound());
writeOptionalRL(buf, feedback.unlockSound());
writeOptionalRL(buf, feedback.struggleLoopSound());
writeOptionalRL(buf, feedback.escapeSound());
writeOptionalRL(buf, feedback.deniedSound());
}
private static void writeOptionalRL(FriendlyByteBuf buf, ResourceLocation rl) {
buf.writeBoolean(rl != null);
if (rl != null) {
buf.writeResourceLocation(rl);
}
}
public static PacketSyncFurnitureDefinitions decode(FriendlyByteBuf buf) {
int count = Math.min(buf.readVarInt(), MAX_DEFINITIONS);
Map<ResourceLocation, FurnitureDefinition> defs = new HashMap<>(count);
for (int i = 0; i < count; i++) {
// Identity
ResourceLocation id = buf.readResourceLocation();
String displayName = buf.readUtf();
// Optional translation key
String translationKey = buf.readBoolean() ? buf.readUtf() : null;
// Model
ResourceLocation modelLocation = buf.readResourceLocation();
// Tint channels
int tintCount = Math.min(buf.readVarInt(), MAX_TINT_CHANNELS);
Map<String, Integer> tintChannels = new HashMap<>(tintCount);
for (int t = 0; t < tintCount; t++) {
tintChannels.put(buf.readUtf(), buf.readInt());
}
// Booleans and floats
boolean supportsColor = buf.readBoolean();
float hitboxWidth = buf.readFloat();
float hitboxHeight = buf.readFloat();
boolean snapToWall = buf.readBoolean();
boolean floorOnly = buf.readBoolean();
boolean lockable = buf.readBoolean();
float breakResistance = buf.readFloat();
boolean dropOnBreak = buf.readBoolean();
// Seats
int seatCount = Math.min(buf.readVarInt(), MAX_SEATS);
List<SeatDefinition> seats = new ArrayList<>(seatCount);
for (int s = 0; s < seatCount; s++) {
seats.add(decodeSeat(buf));
}
// Feedback
FurnitureFeedback feedback = decodeFeedback(buf);
// Category
String category = buf.readUtf();
// Icon (optional model ResourceLocation)
ResourceLocation icon = readOptionalRL(buf);
FurnitureDefinition def = new FurnitureDefinition(
id, displayName, translationKey, modelLocation,
Map.copyOf(tintChannels), supportsColor,
hitboxWidth, hitboxHeight, snapToWall, floorOnly,
lockable, breakResistance, dropOnBreak,
List.copyOf(seats), feedback, category, icon
);
defs.put(id, def);
}
return new PacketSyncFurnitureDefinitions(defs);
}
private static SeatDefinition decodeSeat(FriendlyByteBuf buf) {
String id = buf.readUtf();
String armatureName = buf.readUtf();
int regionCount = Math.min(buf.readVarInt(), MAX_BLOCKED_REGIONS);
Set<BodyRegionV2> blockedRegions = new HashSet<>(regionCount);
for (int r = 0; r < regionCount; r++) {
BodyRegionV2 region = BodyRegionV2.fromName(buf.readUtf());
if (region != null) {
blockedRegions.add(region);
}
// Silently skip unknown region names for forward compatibility
}
boolean lockable = buf.readBoolean();
int lockedDifficulty = buf.readVarInt();
boolean itemDifficultyBonus = buf.readBoolean();
return new SeatDefinition(
id, armatureName, Set.copyOf(blockedRegions),
lockable, lockedDifficulty, itemDifficultyBonus
);
}
private static FurnitureFeedback decodeFeedback(FriendlyByteBuf buf) {
ResourceLocation mountSound = readOptionalRL(buf);
ResourceLocation lockSound = readOptionalRL(buf);
ResourceLocation unlockSound = readOptionalRL(buf);
ResourceLocation struggleLoopSound = readOptionalRL(buf);
ResourceLocation escapeSound = readOptionalRL(buf);
ResourceLocation deniedSound = readOptionalRL(buf);
return new FurnitureFeedback(
mountSound, lockSound, unlockSound,
struggleLoopSound, escapeSound, deniedSound
);
}
private static ResourceLocation readOptionalRL(FriendlyByteBuf buf) {
return buf.readBoolean() ? buf.readResourceLocation() : null;
}
// ==================== Handler ====================
public static void handle(
PacketSyncFurnitureDefinitions msg,
Supplier<NetworkEvent.Context> ctxSupplier
) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> {
if (FMLEnvironment.dist == Dist.CLIENT) {
handleOnClient(msg);
}
});
ctx.setPacketHandled(true);
}
@OnlyIn(Dist.CLIENT)
private static void handleOnClient(PacketSyncFurnitureDefinitions msg) {
FurnitureRegistry.reload(msg.definitions);
LOGGER.debug("Client received {} furniture definitions from server",
msg.definitions.size());
}
// ==================== Server-side Helpers ====================
/**
* Send all current furniture definitions to a single player.
* Call this on player login (PlayerLoggedInEvent) and after /reload.
*
* @param player the player to send definitions to
*/
public static void sendToPlayer(ServerPlayer player) {
ModNetwork.CHANNEL.send(
PacketDistributor.PLAYER.with(() -> player),
new PacketSyncFurnitureDefinitions(FurnitureRegistry.getAllMap())
);
}
/**
* Send all current furniture definitions to every connected player.
* Call this after /reload completes.
*/
public static void sendToAll() {
ModNetwork.CHANNEL.send(
PacketDistributor.ALL.noArg(),
new PacketSyncFurnitureDefinitions(FurnitureRegistry.getAllMap())
);
}
}

View File

@@ -0,0 +1,104 @@
package com.tiedup.remake.v2.furniture.network;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.v2.furniture.EntityFurniture;
import java.util.function.Supplier;
import net.minecraft.client.Minecraft;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.fml.loading.FMLEnvironment;
import net.minecraftforge.network.NetworkEvent;
/**
* Server-to-client packet that syncs a furniture entity's lock bitmask and
* animation state.
*
* <p>While {@link EntityFurniture} uses {@code SynchedEntityData} for these
* fields (which handles baseline sync), this packet provides an immediate,
* explicit update when the server modifies lock state or animation state
* (e.g., after a lock toggle interaction or a struggle minigame state change).
* SynchedEntityData batches dirty entries once per tick, so this packet
* ensures clients see the change within the same network flush.</p>
*
* <p>Wire format (6 bytes): int entityId (4) + byte lockBits (1) + byte animState (1)</p>
*
* <p>Direction: Server to Client (S2C)</p>
*/
public class PacketSyncFurnitureState {
private final int entityId;
private final byte lockBits;
private final byte animState;
public PacketSyncFurnitureState(int entityId, byte lockBits, byte animState) {
this.entityId = entityId;
this.lockBits = lockBits;
this.animState = animState;
}
// ==================== Codec ====================
public static void encode(PacketSyncFurnitureState msg, FriendlyByteBuf buf) {
buf.writeInt(msg.entityId);
buf.writeByte(msg.lockBits);
buf.writeByte(msg.animState);
}
public static PacketSyncFurnitureState decode(FriendlyByteBuf buf) {
return new PacketSyncFurnitureState(
buf.readInt(),
buf.readByte(),
buf.readByte()
);
}
// ==================== Handler ====================
public static void handle(
PacketSyncFurnitureState msg,
Supplier<NetworkEvent.Context> ctxSupplier
) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> {
if (FMLEnvironment.dist == Dist.CLIENT) {
handleOnClient(msg);
}
});
ctx.setPacketHandled(true);
}
@OnlyIn(Dist.CLIENT)
private static void handleOnClient(PacketSyncFurnitureState msg) {
Level level = Minecraft.getInstance().level;
if (level == null) return;
Entity entity = level.getEntity(msg.entityId);
if (entity instanceof EntityFurniture furniture) {
furniture.setSeatLockBitsRaw(msg.lockBits);
furniture.setAnimState(msg.animState);
}
}
// ==================== Server-side Helper ====================
/**
* Send the current lock + anim state of the given furniture entity to all
* players tracking it. Call this from the server after modifying lock bits
* or animation state.
*
* @param furniture the furniture entity whose state changed (must be server-side)
*/
public static void sendToTracking(EntityFurniture furniture) {
ModNetwork.sendToTracking(
new PacketSyncFurnitureState(
furniture.getId(),
furniture.getSeatLockBits(),
furniture.getAnimState()
),
furniture
);
}
}