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:
1041
src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java
Normal file
1041
src/main/java/com/tiedup/remake/v2/furniture/EntityFurniture.java
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user