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:
@@ -0,0 +1,155 @@
|
||||
package com.tiedup.remake.client;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.GenericBind;
|
||||
import com.tiedup.remake.items.base.BindVariant;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.model.PlayerModel;
|
||||
import net.minecraft.client.model.geom.ModelPart;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.client.renderer.entity.player.PlayerRenderer;
|
||||
import net.minecraft.client.renderer.texture.OverlayTexture;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.HumanoidArm;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderArmEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Renders mittens on the player's arms in first-person view.
|
||||
*
|
||||
* Uses RenderArmEvent which fires specifically when a player's arm
|
||||
* is being rendered in first person. This is more targeted than RenderHandEvent.
|
||||
*
|
||||
* @see <a href="https://nekoyue.github.io/ForgeJavaDocs-NG/javadoc/1.18.2/net/minecraftforge/client/event/RenderArmEvent.html">RenderArmEvent Documentation</a>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class FirstPersonMittensRenderer {
|
||||
|
||||
private static final ResourceLocation MITTENS_TEXTURE =
|
||||
ResourceLocation.fromNamespaceAndPath(
|
||||
TiedUpMod.MOD_ID,
|
||||
"textures/models/bondage/mittens/mittens.png"
|
||||
);
|
||||
|
||||
/**
|
||||
* Render mittens overlay on the player's arm in first-person view.
|
||||
*
|
||||
* This event fires after the arm is set up for rendering but we can add
|
||||
* our own rendering on top of it.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onRenderArm(RenderArmEvent event) {
|
||||
AbstractClientPlayer player = event.getPlayer();
|
||||
|
||||
// Get player's bind state
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) return;
|
||||
|
||||
// If tied up, arms are hidden by FirstPersonHandHideHandler - don't render mittens
|
||||
if (state.isTiedUp()) return;
|
||||
|
||||
// Check if player has mittens
|
||||
if (!state.hasMittens()) return;
|
||||
|
||||
// Hide mittens when player is in a wrap or latex sack (hands are covered)
|
||||
if (isBindHidingMittens(player)) return;
|
||||
|
||||
// Render mittens on this arm
|
||||
renderMittensOnArm(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the mittens overlay on the arm.
|
||||
*/
|
||||
private static void renderMittensOnArm(RenderArmEvent event) {
|
||||
PoseStack poseStack = event.getPoseStack();
|
||||
MultiBufferSource buffer = event.getMultiBufferSource();
|
||||
int packedLight = event.getPackedLight();
|
||||
AbstractClientPlayer player = event.getPlayer();
|
||||
HumanoidArm arm = event.getArm();
|
||||
|
||||
// Get the player's model to access the arm ModelPart
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
var renderer = mc.getEntityRenderDispatcher().getRenderer(player);
|
||||
if (!(renderer instanceof PlayerRenderer playerRenderer)) return;
|
||||
|
||||
PlayerModel<AbstractClientPlayer> playerModel =
|
||||
playerRenderer.getModel();
|
||||
|
||||
poseStack.pushPose();
|
||||
|
||||
// Get the appropriate arm from the player model
|
||||
ModelPart armPart = (arm == HumanoidArm.RIGHT)
|
||||
? playerModel.rightArm
|
||||
: playerModel.leftArm;
|
||||
ModelPart sleevePart = (arm == HumanoidArm.RIGHT)
|
||||
? playerModel.rightSleeve
|
||||
: playerModel.leftSleeve;
|
||||
|
||||
// The arm is already positioned by the game's first-person renderer
|
||||
// We just need to render our mittens texture on top
|
||||
|
||||
// Use a slightly scaled version to appear on top (avoid z-fighting)
|
||||
poseStack.scale(1.001F, 1.001F, 1.001F);
|
||||
|
||||
// Render the arm with mittens texture
|
||||
VertexConsumer vertexConsumer = buffer.getBuffer(
|
||||
RenderType.entitySolid(MITTENS_TEXTURE)
|
||||
);
|
||||
|
||||
// Render the arm part with mittens texture
|
||||
armPart.render(
|
||||
poseStack,
|
||||
vertexConsumer,
|
||||
packedLight,
|
||||
OverlayTexture.NO_OVERLAY
|
||||
);
|
||||
|
||||
// Also render the sleeve part if visible
|
||||
if (sleevePart.visible) {
|
||||
sleevePart.render(
|
||||
poseStack,
|
||||
vertexConsumer,
|
||||
packedLight,
|
||||
OverlayTexture.NO_OVERLAY
|
||||
);
|
||||
}
|
||||
|
||||
poseStack.popPose();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the player's current bind variant hides mittens.
|
||||
* WRAP and LATEX_SACK cover the entire body including hands.
|
||||
*/
|
||||
private static boolean isBindHidingMittens(AbstractClientPlayer player) {
|
||||
net.minecraft.world.item.ItemStack bindStack =
|
||||
V2EquipmentHelper.getInRegion(
|
||||
player,
|
||||
BodyRegionV2.ARMS
|
||||
);
|
||||
if (bindStack.isEmpty()) return false;
|
||||
if (bindStack.getItem() instanceof GenericBind bind) {
|
||||
BindVariant variant = bind.getVariant();
|
||||
return (
|
||||
variant == BindVariant.WRAP || variant == BindVariant.LATEX_SACK
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
446
src/main/java/com/tiedup/remake/client/ModKeybindings.java
Normal file
446
src/main/java/com/tiedup/remake/client/ModKeybindings.java
Normal file
@@ -0,0 +1,446 @@
|
||||
package com.tiedup.remake.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import com.tiedup.remake.client.gui.screens.AdjustmentScreen;
|
||||
import com.tiedup.remake.client.gui.screens.UnifiedBondageScreen;
|
||||
import com.tiedup.remake.items.base.ItemCollar;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import com.tiedup.remake.core.ModConfig;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.base.ILockable;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.action.PacketForceSeatModifier;
|
||||
import com.tiedup.remake.network.action.PacketStruggle;
|
||||
import com.tiedup.remake.network.action.PacketTighten;
|
||||
import com.tiedup.remake.network.bounty.PacketRequestBounties;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.client.KeyMapping;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
|
||||
import net.minecraftforge.event.TickEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Phase 7: Client-side keybindings for TiedUp mod.
|
||||
*
|
||||
* Manages key mappings and sends packets to server when keys are pressed.
|
||||
*
|
||||
* Based on original KeyBindings from 1.12.2
|
||||
*/
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class ModKeybindings {
|
||||
|
||||
/**
|
||||
* Key category for TiedUp keybindings
|
||||
*/
|
||||
private static final String CATEGORY = "key.categories.tiedup";
|
||||
|
||||
/**
|
||||
* Struggle keybinding - Press to struggle against binds
|
||||
* Default: R key
|
||||
*/
|
||||
public static final KeyMapping STRUGGLE_KEY = new KeyMapping(
|
||||
"key.tiedup.struggle", // Translation key
|
||||
InputConstants.Type.KEYSYM,
|
||||
InputConstants.KEY_R, // Default key: R
|
||||
CATEGORY
|
||||
);
|
||||
|
||||
/**
|
||||
* Adjustment screen keybinding - Open item adjustment screen
|
||||
* Default: K key
|
||||
*/
|
||||
public static final KeyMapping ADJUSTMENT_KEY = new KeyMapping(
|
||||
"key.tiedup.adjustment_screen",
|
||||
InputConstants.Type.KEYSYM,
|
||||
InputConstants.KEY_K, // Default key: K
|
||||
CATEGORY
|
||||
);
|
||||
|
||||
/**
|
||||
* Bondage inventory keybinding - Open bondage inventory screen
|
||||
* Default: J key
|
||||
*/
|
||||
public static final KeyMapping INVENTORY_KEY = new KeyMapping(
|
||||
"key.tiedup.bondage_inventory",
|
||||
InputConstants.Type.KEYSYM,
|
||||
InputConstants.KEY_J, // Default key: J
|
||||
CATEGORY
|
||||
);
|
||||
|
||||
/**
|
||||
* Slave management keybinding - Open slave management dashboard
|
||||
* Default: L key
|
||||
*/
|
||||
public static final KeyMapping SLAVE_MANAGEMENT_KEY = new KeyMapping(
|
||||
"key.tiedup.slave_management",
|
||||
InputConstants.Type.KEYSYM,
|
||||
InputConstants.KEY_L, // Default key: L
|
||||
CATEGORY
|
||||
);
|
||||
|
||||
/**
|
||||
* Bounty list keybinding - Open bounty list screen
|
||||
* Default: B key
|
||||
*/
|
||||
public static final KeyMapping BOUNTY_KEY = new KeyMapping(
|
||||
"key.tiedup.bounties",
|
||||
InputConstants.Type.KEYSYM,
|
||||
InputConstants.KEY_B, // Default key: B
|
||||
CATEGORY
|
||||
);
|
||||
|
||||
/**
|
||||
* Force seat keybinding - Hold to force captive on/off vehicles
|
||||
* Default: Left ALT key
|
||||
*/
|
||||
public static final KeyMapping FORCE_SEAT_KEY = new KeyMapping(
|
||||
"key.tiedup.force_seat",
|
||||
InputConstants.Type.KEYSYM,
|
||||
InputConstants.KEY_LALT, // Default key: Left ALT
|
||||
CATEGORY
|
||||
);
|
||||
|
||||
/**
|
||||
* Tighten bind keybinding - Tighten binds on looked-at target
|
||||
* Default: T key
|
||||
*/
|
||||
public static final KeyMapping TIGHTEN_KEY = new KeyMapping(
|
||||
"key.tiedup.tighten",
|
||||
InputConstants.Type.KEYSYM,
|
||||
InputConstants.KEY_T, // Default key: T
|
||||
CATEGORY
|
||||
);
|
||||
|
||||
/** Track last sent state to avoid spamming packets */
|
||||
private static boolean lastForceSeatState = false;
|
||||
|
||||
/**
|
||||
* Check if Force Seat key is currently pressed.
|
||||
*/
|
||||
public static boolean isForceSeatPressed() {
|
||||
return FORCE_SEAT_KEY.isDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register keybindings.
|
||||
* Called during mod initialization (MOD bus).
|
||||
*
|
||||
* @param event The registration event
|
||||
*/
|
||||
public static void register(RegisterKeyMappingsEvent event) {
|
||||
event.register(STRUGGLE_KEY);
|
||||
event.register(ADJUSTMENT_KEY);
|
||||
event.register(INVENTORY_KEY);
|
||||
event.register(SLAVE_MANAGEMENT_KEY);
|
||||
event.register(BOUNTY_KEY);
|
||||
event.register(FORCE_SEAT_KEY);
|
||||
event.register(TIGHTEN_KEY);
|
||||
TiedUpMod.LOGGER.info("Registered {} keybindings", 7);
|
||||
}
|
||||
|
||||
// ==================== STRUGGLE MINI-GAME (uses vanilla movement keys) ====================
|
||||
|
||||
/**
|
||||
* Get the vanilla movement keybind for a given direction index.
|
||||
* Uses Minecraft's movement keys so AZERTY/QWERTY is already configured.
|
||||
* @param index 0=FORWARD, 1=LEFT, 2=BACK, 3=RIGHT
|
||||
* @return The keybind or null if invalid index
|
||||
*/
|
||||
public static KeyMapping getStruggleDirectionKey(int index) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.options == null) return null;
|
||||
return switch (index) {
|
||||
case 0 -> mc.options.keyUp; // Forward (W/Z)
|
||||
case 1 -> mc.options.keyLeft; // Strafe Left (A/Q)
|
||||
case 2 -> mc.options.keyDown; // Back (S)
|
||||
case 3 -> mc.options.keyRight; // Strafe Right (D)
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keycode matches any vanilla movement keybind.
|
||||
* @param keyCode The GLFW key code
|
||||
* @return The direction index (0-3) or -1 if not a movement key
|
||||
*/
|
||||
public static int getStruggleDirectionFromKeyCode(int keyCode) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.options == null) return -1;
|
||||
if (mc.options.keyUp.matches(keyCode, 0)) return 0;
|
||||
if (mc.options.keyLeft.matches(keyCode, 0)) return 1;
|
||||
if (mc.options.keyDown.matches(keyCode, 0)) return 2;
|
||||
if (mc.options.keyRight.matches(keyCode, 0)) return 3;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name of a vanilla movement key.
|
||||
* Shows the actual bound key (W for QWERTY, Z for AZERTY, etc.)
|
||||
* @param index 0=FORWARD, 1=LEFT, 2=BACK, 3=RIGHT
|
||||
* @return The key's display name
|
||||
*/
|
||||
public static String getStruggleDirectionKeyName(int index) {
|
||||
KeyMapping key = getStruggleDirectionKey(index);
|
||||
if (key == null) return "?";
|
||||
return key.getTranslatedKeyMessage().getString().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle key presses on client tick.
|
||||
* Called every client tick (FORGE bus).
|
||||
*
|
||||
* @param event The tick event
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||
// Only run at end of tick
|
||||
if (event.phase != TickEvent.Phase.END) {
|
||||
return;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.player == null || mc.level == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync Force Seat keybind state to server (only send on change)
|
||||
boolean currentForceSeatState = isForceSeatPressed();
|
||||
if (currentForceSeatState != lastForceSeatState) {
|
||||
lastForceSeatState = currentForceSeatState;
|
||||
ModNetwork.sendToServer(
|
||||
new PacketForceSeatModifier(currentForceSeatState)
|
||||
);
|
||||
}
|
||||
|
||||
// Check struggle key - Phase 21: Flow based on bind/accessories
|
||||
while (STRUGGLE_KEY.consumeClick()) {
|
||||
handleStruggleKey();
|
||||
}
|
||||
|
||||
// Check adjustment screen key
|
||||
while (ADJUSTMENT_KEY.consumeClick()) {
|
||||
// Only open if not already in a screen and player has adjustable items
|
||||
if (mc.screen == null && AdjustmentScreen.canOpen()) {
|
||||
mc.setScreen(new AdjustmentScreen());
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[CLIENT] Adjustment key pressed - opening screen"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check bondage inventory key - opens UnifiedBondageScreen in SELF or MASTER mode
|
||||
while (INVENTORY_KEY.consumeClick()) {
|
||||
if (mc.screen == null) {
|
||||
LivingEntity masterTarget = findOwnedCollarTarget(mc.player);
|
||||
if (masterTarget != null) {
|
||||
mc.setScreen(new UnifiedBondageScreen(masterTarget));
|
||||
} else {
|
||||
mc.setScreen(new UnifiedBondageScreen());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SLAVE_MANAGEMENT_KEY: now handled by [J] with master mode detection (see above)
|
||||
while (SLAVE_MANAGEMENT_KEY.consumeClick()) {
|
||||
// consumed but no-op — kept registered to avoid key conflict during transition
|
||||
}
|
||||
|
||||
// Check bounty list key
|
||||
while (BOUNTY_KEY.consumeClick()) {
|
||||
// Request bounty list from server (server will open the screen)
|
||||
if (mc.screen == null) {
|
||||
ModNetwork.sendToServer(new PacketRequestBounties());
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[CLIENT] Bounty key pressed - requesting bounty list"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check tighten key
|
||||
while (TIGHTEN_KEY.consumeClick()) {
|
||||
// Send tighten packet to server (server finds target)
|
||||
if (mc.screen == null) {
|
||||
ModNetwork.sendToServer(new PacketTighten());
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[CLIENT] Tighten key pressed - sending tighten request"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 21: Handle struggle key press with new flow.
|
||||
*
|
||||
* Flow:
|
||||
* 1. If bind equipped: Send PacketStruggle to server (struggle against bind)
|
||||
* 2. If no bind: Check for locked accessories
|
||||
* - If locked accessories exist: Open StruggleChoiceScreen
|
||||
* - If no locked accessories: Show "Nothing to struggle" message
|
||||
*/
|
||||
private static void handleStruggleKey() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
Player player = mc.player;
|
||||
if (player == null || mc.screen != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// V2 path: check if player has V2 equipment to struggle against
|
||||
if (com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.hasAnyEquipment(player)) {
|
||||
handleV2Struggle(player);
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if player has bind equipped
|
||||
if (state.isTiedUp()) {
|
||||
// Has bind - struggle against it
|
||||
// Phase 2.5: Check if mini-game is enabled
|
||||
if (ModConfig.SERVER.struggleMiniGameEnabled.get()) {
|
||||
// New: Start struggle mini-game
|
||||
ModNetwork.sendToServer(new PacketV2StruggleStart(BodyRegionV2.ARMS));
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[CLIENT] Struggle key pressed - starting V2 struggle mini-game"
|
||||
);
|
||||
} else {
|
||||
// Legacy: Probability-based struggle
|
||||
ModNetwork.sendToServer(new PacketStruggle());
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[CLIENT] Struggle key pressed - legacy struggle against bind"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No bind - check for locked accessories
|
||||
boolean hasLockedAccessories = hasAnyLockedAccessory(player);
|
||||
|
||||
if (hasLockedAccessories) {
|
||||
// Open UnifiedBondageScreen in self mode
|
||||
mc.setScreen(new UnifiedBondageScreen());
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[CLIENT] Struggle key pressed - opening unified bondage screen"
|
||||
);
|
||||
} else {
|
||||
// No locked accessories - show message
|
||||
player.displayClientMessage(
|
||||
Component.translatable("tiedup.struggle.nothing").withStyle(
|
||||
ChatFormatting.GRAY
|
||||
),
|
||||
true
|
||||
);
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[CLIENT] Struggle key pressed - nothing to struggle"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle struggle key for V2 equipment.
|
||||
* Auto-targets the highest posePriority item.
|
||||
*/
|
||||
private static void handleV2Struggle(Player player) {
|
||||
java.util.Map<com.tiedup.remake.v2.BodyRegionV2, ItemStack> equipped =
|
||||
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getAllEquipped(player);
|
||||
|
||||
if (equipped.isEmpty()) return;
|
||||
|
||||
// Auto-target: find highest posePriority item
|
||||
com.tiedup.remake.v2.BodyRegionV2 bestRegion = null;
|
||||
int bestPriority = Integer.MIN_VALUE;
|
||||
|
||||
for (java.util.Map.Entry<com.tiedup.remake.v2.BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (stack.getItem() instanceof com.tiedup.remake.v2.bondage.IV2BondageItem item) {
|
||||
if (item.getPosePriority(stack) > bestPriority) {
|
||||
bestPriority = item.getPosePriority(stack);
|
||||
bestRegion = entry.getKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestRegion != null) {
|
||||
ModNetwork.sendToServer(
|
||||
new com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart(bestRegion)
|
||||
);
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[CLIENT] V2 Struggle key pressed - targeting region {}",
|
||||
bestRegion.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the crosshair entity: if it is a LivingEntity wearing a collar owned by the player,
|
||||
* return it as the MASTER mode target. Returns null if no valid target.
|
||||
*/
|
||||
@Nullable
|
||||
private static LivingEntity findOwnedCollarTarget(Player player) {
|
||||
if (player == null) return null;
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
net.minecraft.world.entity.Entity crosshair = mc.crosshairPickEntity;
|
||||
if (crosshair instanceof LivingEntity living) {
|
||||
return checkCollarOwnership(living, player) ? living : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given entity has a collar in the NECK region that lists the player as an owner.
|
||||
*/
|
||||
private static boolean checkCollarOwnership(LivingEntity target, Player player) {
|
||||
ItemStack collarStack = com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion(
|
||||
target, BodyRegionV2.NECK
|
||||
);
|
||||
if (!collarStack.isEmpty() && collarStack.getItem() instanceof ItemCollar collar) {
|
||||
return collar.isOwner(collarStack, player);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if player has any locked accessories.
|
||||
*/
|
||||
private static boolean hasAnyLockedAccessory(Player player) {
|
||||
BodyRegionV2[] accessoryRegions = {
|
||||
BodyRegionV2.MOUTH,
|
||||
BodyRegionV2.EYES,
|
||||
BodyRegionV2.EARS,
|
||||
BodyRegionV2.NECK,
|
||||
BodyRegionV2.TORSO,
|
||||
BodyRegionV2.HANDS,
|
||||
};
|
||||
|
||||
for (BodyRegionV2 region : accessoryRegions) {
|
||||
ItemStack stack = V2EquipmentHelper.getInRegion(player, region);
|
||||
if (
|
||||
!stack.isEmpty() &&
|
||||
stack.getItem() instanceof ILockable lockable
|
||||
) {
|
||||
if (lockable.isLocked(stack)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
118
src/main/java/com/tiedup/remake/client/MuffledSoundInstance.java
Normal file
118
src/main/java/com/tiedup/remake/client/MuffledSoundInstance.java
Normal file
@@ -0,0 +1,118 @@
|
||||
package com.tiedup.remake.client;
|
||||
|
||||
import net.minecraft.client.resources.sounds.Sound;
|
||||
import net.minecraft.client.resources.sounds.SoundInstance;
|
||||
import net.minecraft.client.resources.sounds.TickableSoundInstance;
|
||||
import net.minecraft.client.sounds.SoundManager;
|
||||
import net.minecraft.client.sounds.WeighedSoundEvents;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Wrapper around a SoundInstance that applies volume and pitch modifiers.
|
||||
* Used for the earplugs muffling effect.
|
||||
*
|
||||
* This delegates all methods to the wrapped sound, but overrides
|
||||
* getVolume() and getPitch() to apply modifiers.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class MuffledSoundInstance implements SoundInstance {
|
||||
|
||||
private final SoundInstance wrapped;
|
||||
private final float volumeMultiplier;
|
||||
private final float pitchMultiplier;
|
||||
|
||||
public MuffledSoundInstance(
|
||||
SoundInstance wrapped,
|
||||
float volumeMultiplier,
|
||||
float pitchMultiplier
|
||||
) {
|
||||
this.wrapped = wrapped;
|
||||
this.volumeMultiplier = volumeMultiplier;
|
||||
this.pitchMultiplier = pitchMultiplier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResourceLocation getLocation() {
|
||||
return wrapped.getLocation();
|
||||
}
|
||||
|
||||
@Override
|
||||
public WeighedSoundEvents resolve(SoundManager soundManager) {
|
||||
return wrapped.resolve(soundManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Sound getSound() {
|
||||
return wrapped.getSound();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SoundSource getSource() {
|
||||
return wrapped.getSource();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLooping() {
|
||||
return wrapped.isLooping();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRelative() {
|
||||
return wrapped.isRelative();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDelay() {
|
||||
return wrapped.getDelay();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getVolume() {
|
||||
// Apply muffling to volume
|
||||
return wrapped.getVolume() * volumeMultiplier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getPitch() {
|
||||
// Apply muffling to pitch
|
||||
return wrapped.getPitch() * pitchMultiplier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getX() {
|
||||
return wrapped.getX();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getY() {
|
||||
return wrapped.getY();
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getZ() {
|
||||
return wrapped.getZ();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Attenuation getAttenuation() {
|
||||
return wrapped.getAttenuation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is wrapping a tickable sound.
|
||||
* Used to handle special cases.
|
||||
*/
|
||||
public boolean isTickable() {
|
||||
return wrapped instanceof TickableSoundInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the wrapped sound instance.
|
||||
*/
|
||||
public SoundInstance getWrapped() {
|
||||
return wrapped;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.tiedup.remake.client.animation;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Central registry for player animation state tracking.
|
||||
*
|
||||
* <p>Holds per-player state maps that were previously scattered across
|
||||
* AnimationTickHandler. Provides a single clearAll() entry point for
|
||||
* world unload cleanup.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class AnimationStateRegistry {
|
||||
|
||||
/** Track last tied state per player */
|
||||
static final Map<UUID, Boolean> lastTiedState = new ConcurrentHashMap<>();
|
||||
|
||||
/** Track last animation ID per player to avoid redundant updates */
|
||||
static final Map<UUID, String> lastAnimId = new ConcurrentHashMap<>();
|
||||
|
||||
private AnimationStateRegistry() {}
|
||||
|
||||
public static Map<UUID, Boolean> getLastTiedState() {
|
||||
return lastTiedState;
|
||||
}
|
||||
|
||||
public static Map<UUID, String> getLastAnimId() {
|
||||
return lastAnimId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all animation-related state in one call.
|
||||
* Called on world unload to prevent memory leaks and stale data.
|
||||
*/
|
||||
public static void clearAll() {
|
||||
// Animation state tracking
|
||||
lastTiedState.clear();
|
||||
lastAnimId.clear();
|
||||
|
||||
// Animation managers
|
||||
BondageAnimationManager.clearAll();
|
||||
PendingAnimationManager.clearAll();
|
||||
|
||||
// V2 animation context system (clearAll chains to ContextAnimationFactory.clearCache)
|
||||
com.tiedup.remake.client.gltf.GltfAnimationApplier.clearAll();
|
||||
|
||||
// Render state
|
||||
com.tiedup.remake.client.animation.render.DogPoseRenderHandler.clearState();
|
||||
|
||||
// NPC animation state
|
||||
com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler.clearAll();
|
||||
|
||||
// MCA animation cache
|
||||
com.tiedup.remake.client.animation.tick.MCAAnimationTickCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
package com.tiedup.remake.client.animation;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import com.tiedup.remake.v2.furniture.ISeatProvider;
|
||||
import dev.kosmx.playerAnim.api.layered.IAnimation;
|
||||
import dev.kosmx.playerAnim.api.layered.KeyframeAnimationPlayer;
|
||||
import dev.kosmx.playerAnim.api.layered.ModifierLayer;
|
||||
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||
import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
|
||||
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationAccess;
|
||||
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationFactory;
|
||||
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Unified animation manager for bondage animations.
|
||||
*
|
||||
* <p>Handles both players and NPCs (any entity implementing IAnimatedPlayer).
|
||||
* Uses PlayerAnimator library for smooth keyframe animations with bendy-lib support.
|
||||
*
|
||||
* <p>This replaces the previous split system:
|
||||
* <ul>
|
||||
* <li>PlayerAnimatorBridge (for players)</li>
|
||||
* <li>DamselAnimationManager (for NPCs)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class BondageAnimationManager {
|
||||
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
|
||||
/** Cache of ModifierLayers for NPC entities (players use PlayerAnimationAccess) */
|
||||
private static final Map<UUID, ModifierLayer<IAnimation>> npcLayers =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** Cache of context ModifierLayers for NPC entities */
|
||||
private static final Map<UUID, ModifierLayer<IAnimation>> npcContextLayers =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** Cache of furniture ModifierLayers for NPC entities */
|
||||
private static final Map<UUID, ModifierLayer<IAnimation>> npcFurnitureLayers =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** Factory ID for PlayerAnimator item layer (players only) */
|
||||
private static final ResourceLocation FACTORY_ID =
|
||||
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage");
|
||||
|
||||
/** Factory ID for PlayerAnimator context layer (players only) */
|
||||
private static final ResourceLocation CONTEXT_FACTORY_ID =
|
||||
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_context");
|
||||
|
||||
/** Factory ID for PlayerAnimator furniture layer (players only) */
|
||||
private static final ResourceLocation FURNITURE_FACTORY_ID =
|
||||
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_furniture");
|
||||
|
||||
/** Priority for context animation layer (lower = overridable by item layer) */
|
||||
private static final int CONTEXT_LAYER_PRIORITY = 40;
|
||||
/** Priority for item animation layer (higher = overrides context layer) */
|
||||
private static final int ITEM_LAYER_PRIORITY = 42;
|
||||
/**
|
||||
* Priority for furniture animation layer (highest = overrides item layer on blocked bones).
|
||||
* Non-blocked bones are disabled so items can still animate them via the item layer.
|
||||
*/
|
||||
private static final int FURNITURE_LAYER_PRIORITY = 43;
|
||||
|
||||
/** Number of ticks to wait before removing a stale furniture animation. */
|
||||
private static final int FURNITURE_GRACE_TICKS = 3;
|
||||
|
||||
/**
|
||||
* Tracks ticks since a player with an active furniture animation stopped riding
|
||||
* an ISeatProvider. After {@link #FURNITURE_GRACE_TICKS}, the animation is removed
|
||||
* to prevent stuck poses from entity death or network issues.
|
||||
*
|
||||
* <p>Uses ConcurrentHashMap for safe access from both client tick and render thread.</p>
|
||||
*/
|
||||
private static final Map<UUID, Integer> furnitureGraceTicks = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Initialize the animation system.
|
||||
* Must be called during client setup to register the player animation factory.
|
||||
*/
|
||||
public static void init() {
|
||||
LOGGER.info("BondageAnimationManager initializing...");
|
||||
|
||||
// Context layer: lower priority = evaluated first, overridable by item layer.
|
||||
// In AnimationStack, layers are sorted ascending by priority and evaluated in order.
|
||||
// Higher priority layers override lower ones.
|
||||
PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
|
||||
CONTEXT_FACTORY_ID,
|
||||
CONTEXT_LAYER_PRIORITY,
|
||||
player -> new ModifierLayer<>()
|
||||
);
|
||||
|
||||
// Item layer: higher priority = evaluated last, overrides context layer
|
||||
PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
|
||||
FACTORY_ID,
|
||||
ITEM_LAYER_PRIORITY,
|
||||
player -> new ModifierLayer<>()
|
||||
);
|
||||
|
||||
// Furniture layer: highest priority = overrides item layer on blocked bones.
|
||||
// Non-blocked bones are disabled via FurnitureAnimationContext so items
|
||||
// can still animate free regions (gag, blindfold, etc.).
|
||||
PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
|
||||
FURNITURE_FACTORY_ID,
|
||||
FURNITURE_LAYER_PRIORITY,
|
||||
player -> new ModifierLayer<>()
|
||||
);
|
||||
|
||||
LOGGER.info(
|
||||
"BondageAnimationManager: Factories registered — context (pri {}), item (pri {}), furniture (pri {})",
|
||||
CONTEXT_LAYER_PRIORITY, ITEM_LAYER_PRIORITY, FURNITURE_LAYER_PRIORITY
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// PLAY ANIMATION
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Play an animation on any entity (player or NPC).
|
||||
*
|
||||
* @param entity The entity to animate
|
||||
* @param animId Animation ID string (will be prefixed with "tiedup:" namespace)
|
||||
* @return true if animation started successfully, false if layer not available
|
||||
*/
|
||||
public static boolean playAnimation(LivingEntity entity, String animId) {
|
||||
ResourceLocation location = ResourceLocation.fromNamespaceAndPath(
|
||||
"tiedup",
|
||||
animId
|
||||
);
|
||||
return playAnimation(entity, location);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play an animation on any entity (player or NPC).
|
||||
*
|
||||
* <p>If the animation layer is not available (e.g., remote player not fully
|
||||
* initialized), the animation will be queued for retry via PendingAnimationManager.
|
||||
*
|
||||
* @param entity The entity to animate
|
||||
* @param animId Full ResourceLocation of the animation
|
||||
* @return true if animation started successfully, false if layer not available
|
||||
*/
|
||||
public static boolean playAnimation(
|
||||
LivingEntity entity,
|
||||
ResourceLocation animId
|
||||
) {
|
||||
if (entity == null || !entity.level().isClientSide()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
KeyframeAnimation anim = PlayerAnimationRegistry.getAnimation(animId);
|
||||
if (anim == null) {
|
||||
// Try fallback: remove _sneak_ suffix if present
|
||||
ResourceLocation fallbackId = tryFallbackAnimation(animId);
|
||||
if (fallbackId != null) {
|
||||
anim = PlayerAnimationRegistry.getAnimation(fallbackId);
|
||||
if (anim != null) {
|
||||
LOGGER.debug(
|
||||
"Using fallback animation '{}' for missing '{}'",
|
||||
fallbackId,
|
||||
animId
|
||||
);
|
||||
}
|
||||
}
|
||||
if (anim == null) {
|
||||
LOGGER.warn("Animation not found in registry: {}", animId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
ModifierLayer<IAnimation> layer = getOrCreateLayer(entity);
|
||||
if (layer != null) {
|
||||
// Check if same animation is already playing
|
||||
// Use reference comparison (==) instead of equals() because:
|
||||
// 1. PlayerAnimationRegistry caches animations by ID
|
||||
// 2. Same ID = same cached object reference
|
||||
// 3. This avoids issues with KeyframeAnimation.equals() implementation
|
||||
IAnimation current = layer.getAnimation();
|
||||
if (current instanceof KeyframeAnimationPlayer player) {
|
||||
if (player.getData() == anim) {
|
||||
// Same animation already playing, don't reset
|
||||
return true; // Still counts as success
|
||||
}
|
||||
}
|
||||
layer.setAnimation(new KeyframeAnimationPlayer(anim));
|
||||
|
||||
// Remove from pending queue if it was waiting
|
||||
PendingAnimationManager.remove(entity.getUUID());
|
||||
|
||||
LOGGER.debug(
|
||||
"Playing animation '{}' on entity: {}",
|
||||
animId,
|
||||
entity.getUUID()
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
// Layer not available - queue for retry if it's a player
|
||||
if (entity instanceof AbstractClientPlayer) {
|
||||
PendingAnimationManager.queueForRetry(
|
||||
entity.getUUID(),
|
||||
animId.getPath()
|
||||
);
|
||||
LOGGER.debug(
|
||||
"Animation layer not ready for {}, queued for retry",
|
||||
entity.getName().getString()
|
||||
);
|
||||
} else {
|
||||
LOGGER.warn(
|
||||
"Animation layer is NULL for NPC: {} (type: {})",
|
||||
entity.getName().getString(),
|
||||
entity.getClass().getSimpleName()
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a pre-converted KeyframeAnimation directly on an entity, bypassing the registry.
|
||||
* Used by GltfAnimationApplier for GLB-converted poses.
|
||||
*
|
||||
* @param entity The entity to animate
|
||||
* @param anim The KeyframeAnimation to play
|
||||
* @return true if animation started successfully
|
||||
*/
|
||||
public static boolean playDirect(LivingEntity entity, KeyframeAnimation anim) {
|
||||
if (entity == null || anim == null || !entity.level().isClientSide()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ModifierLayer<IAnimation> layer = getOrCreateLayer(entity);
|
||||
if (layer != null) {
|
||||
IAnimation current = layer.getAnimation();
|
||||
if (current instanceof KeyframeAnimationPlayer player) {
|
||||
if (player.getData() == anim) {
|
||||
return true; // Same animation already playing
|
||||
}
|
||||
}
|
||||
layer.setAnimation(new KeyframeAnimationPlayer(anim));
|
||||
PendingAnimationManager.remove(entity.getUUID());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// STOP ANIMATION
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Stop any currently playing animation on an entity.
|
||||
*
|
||||
* @param entity The entity
|
||||
*/
|
||||
public static void stopAnimation(LivingEntity entity) {
|
||||
if (entity == null || !entity.level().isClientSide()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ModifierLayer<IAnimation> layer = getLayer(entity);
|
||||
if (layer != null) {
|
||||
layer.setAnimation(null);
|
||||
LOGGER.debug("Stopped animation on entity: {}", entity.getUUID());
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// LAYER MANAGEMENT
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get the ModifierLayer for an entity (without creating).
|
||||
*/
|
||||
private static ModifierLayer<IAnimation> getLayer(LivingEntity entity) {
|
||||
// Players: try PlayerAnimationAccess first, then cache
|
||||
if (entity instanceof AbstractClientPlayer player) {
|
||||
ModifierLayer<IAnimation> factoryLayer = getPlayerLayer(player);
|
||||
if (factoryLayer != null) {
|
||||
return factoryLayer;
|
||||
}
|
||||
// Check cache (for remote players using fallback)
|
||||
return npcLayers.get(entity.getUUID());
|
||||
}
|
||||
|
||||
// NPCs: use cache
|
||||
return npcLayers.get(entity.getUUID());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the ModifierLayer for an entity.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static ModifierLayer<IAnimation> getOrCreateLayer(
|
||||
LivingEntity entity
|
||||
) {
|
||||
UUID uuid = entity.getUUID();
|
||||
|
||||
// Players: try factory-based access first, fallback to direct stack access
|
||||
if (entity instanceof AbstractClientPlayer player) {
|
||||
// Try the registered factory first (works for local player)
|
||||
ModifierLayer<IAnimation> factoryLayer = getPlayerLayer(player);
|
||||
if (factoryLayer != null) {
|
||||
return factoryLayer;
|
||||
}
|
||||
|
||||
// Fallback for remote players: use direct stack access like NPCs
|
||||
// This handles cases where the factory data isn't available
|
||||
if (player instanceof IAnimatedPlayer animated) {
|
||||
return npcLayers.computeIfAbsent(uuid, k -> {
|
||||
ModifierLayer<IAnimation> newLayer = new ModifierLayer<>();
|
||||
animated
|
||||
.getAnimationStack()
|
||||
.addAnimLayer(ITEM_LAYER_PRIORITY, newLayer);
|
||||
LOGGER.info(
|
||||
"Created animation layer for remote player via stack: {}",
|
||||
player.getName().getString()
|
||||
);
|
||||
return newLayer;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// NPCs implementing IAnimatedPlayer: create/cache layer
|
||||
if (entity instanceof IAnimatedPlayer animated) {
|
||||
return npcLayers.computeIfAbsent(uuid, k -> {
|
||||
ModifierLayer<IAnimation> newLayer = new ModifierLayer<>();
|
||||
animated
|
||||
.getAnimationStack()
|
||||
.addAnimLayer(ITEM_LAYER_PRIORITY, newLayer);
|
||||
LOGGER.debug("Created animation layer for NPC: {}", uuid);
|
||||
return newLayer;
|
||||
});
|
||||
}
|
||||
|
||||
LOGGER.warn(
|
||||
"Entity {} does not support animations (not a player or IAnimatedPlayer)",
|
||||
uuid
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the animation layer for a player from PlayerAnimationAccess.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static ModifierLayer<IAnimation> getPlayerLayer(
|
||||
AbstractClientPlayer player
|
||||
) {
|
||||
try {
|
||||
return (ModifierLayer<
|
||||
IAnimation
|
||||
>) PlayerAnimationAccess.getPlayerAssociatedData(player).get(
|
||||
FACTORY_ID
|
||||
);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error(
|
||||
"Failed to get animation layer for player: {}",
|
||||
player.getName().getString(),
|
||||
e
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get the animation layer for a player.
|
||||
* Returns null if the layer is not yet initialized.
|
||||
*
|
||||
* <p>Public method for PendingAnimationManager to access.
|
||||
* Checks both the factory-based layer and the NPC cache fallback.
|
||||
*
|
||||
* @param player The player
|
||||
* @return The animation layer, or null if not available
|
||||
*/
|
||||
@javax.annotation.Nullable
|
||||
public static ModifierLayer<IAnimation> getPlayerLayerSafe(
|
||||
AbstractClientPlayer player
|
||||
) {
|
||||
// Try factory first
|
||||
ModifierLayer<IAnimation> factoryLayer = getPlayerLayer(player);
|
||||
if (factoryLayer != null) {
|
||||
return factoryLayer;
|
||||
}
|
||||
|
||||
// Check NPC cache (for remote players using fallback path)
|
||||
return npcLayers.get(player.getUUID());
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CONTEXT LAYER (lower priority, for sit/kneel/sneak)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get the context animation layer for a player from PlayerAnimationAccess.
|
||||
* Returns null if the layer is not yet initialized.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
@javax.annotation.Nullable
|
||||
private static ModifierLayer<IAnimation> getPlayerContextLayer(
|
||||
AbstractClientPlayer player
|
||||
) {
|
||||
try {
|
||||
return (ModifierLayer<
|
||||
IAnimation
|
||||
>) PlayerAnimationAccess.getPlayerAssociatedData(player).get(
|
||||
CONTEXT_FACTORY_ID
|
||||
);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the context animation layer for an NPC entity.
|
||||
* Uses CONTEXT_LAYER_PRIORITY, below the item layer at ITEM_LAYER_PRIORITY.
|
||||
*/
|
||||
@javax.annotation.Nullable
|
||||
private static ModifierLayer<IAnimation> getOrCreateNpcContextLayer(
|
||||
LivingEntity entity
|
||||
) {
|
||||
if (entity instanceof IAnimatedPlayer animated) {
|
||||
return npcContextLayers.computeIfAbsent(
|
||||
entity.getUUID(),
|
||||
k -> {
|
||||
ModifierLayer<IAnimation> layer = new ModifierLayer<>();
|
||||
animated.getAnimationStack().addAnimLayer(CONTEXT_LAYER_PRIORITY, layer);
|
||||
return layer;
|
||||
}
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a context animation on the context layer (lower priority).
|
||||
* Context animations (sit, kneel, sneak) can be overridden by item animations
|
||||
* on the main layer which has higher priority.
|
||||
*
|
||||
* @param entity The entity to animate
|
||||
* @param anim The KeyframeAnimation to play on the context layer
|
||||
* @return true if animation started successfully
|
||||
*/
|
||||
public static boolean playContext(
|
||||
LivingEntity entity,
|
||||
KeyframeAnimation anim
|
||||
) {
|
||||
if (entity == null || anim == null || !entity.level().isClientSide()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ModifierLayer<IAnimation> layer;
|
||||
if (entity instanceof AbstractClientPlayer player) {
|
||||
layer = getPlayerContextLayer(player);
|
||||
} else {
|
||||
layer = getOrCreateNpcContextLayer(entity);
|
||||
}
|
||||
|
||||
if (layer != null) {
|
||||
layer.setAnimation(new KeyframeAnimationPlayer(anim));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the context layer animation.
|
||||
*
|
||||
* @param entity The entity whose context animation should stop
|
||||
*/
|
||||
public static void stopContext(LivingEntity entity) {
|
||||
if (entity == null || !entity.level().isClientSide()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ModifierLayer<IAnimation> layer;
|
||||
if (entity instanceof AbstractClientPlayer player) {
|
||||
layer = getPlayerContextLayer(player);
|
||||
} else {
|
||||
layer = npcContextLayers.get(entity.getUUID());
|
||||
}
|
||||
|
||||
if (layer != null) {
|
||||
layer.setAnimation(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// FURNITURE LAYER (highest priority, for seat poses)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Play a furniture animation on the furniture layer (highest priority).
|
||||
*
|
||||
* <p>The furniture layer sits above the item layer so it controls blocked-region
|
||||
* bones. Non-blocked bones should already be disabled in the provided animation
|
||||
* (via {@link com.tiedup.remake.v2.furniture.client.FurnitureAnimationContext#create}).
|
||||
* This allows bondage items on free regions to still animate via the item layer.</p>
|
||||
*
|
||||
* @param player the player to animate
|
||||
* @param animation the KeyframeAnimation from FurnitureAnimationContext
|
||||
* @return true if animation started successfully
|
||||
*/
|
||||
public static boolean playFurniture(Player player, KeyframeAnimation animation) {
|
||||
if (player == null || animation == null || !player.level().isClientSide()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
|
||||
if (layer != null) {
|
||||
layer.setAnimation(new KeyframeAnimationPlayer(animation));
|
||||
// Reset grace ticks since we just started/refreshed the animation
|
||||
furnitureGraceTicks.remove(player.getUUID());
|
||||
LOGGER.debug("Playing furniture animation on player: {}", player.getName().getString());
|
||||
return true;
|
||||
}
|
||||
|
||||
LOGGER.warn("Furniture layer not available for player: {}", player.getName().getString());
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the furniture layer animation for a player.
|
||||
*
|
||||
* @param player the player whose furniture animation should stop
|
||||
*/
|
||||
public static void stopFurniture(Player player) {
|
||||
if (player == null || !player.level().isClientSide()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
|
||||
if (layer != null) {
|
||||
layer.setAnimation(null);
|
||||
}
|
||||
furnitureGraceTicks.remove(player.getUUID());
|
||||
LOGGER.debug("Stopped furniture animation on player: {}", player.getName().getString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a player currently has an active furniture animation.
|
||||
*
|
||||
* @param player the player to check
|
||||
* @return true if the furniture layer has an active animation
|
||||
*/
|
||||
public static boolean hasFurnitureAnimation(Player player) {
|
||||
if (player == null || !player.level().isClientSide()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
|
||||
return layer != null && layer.getAnimation() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the furniture ModifierLayer for a player.
|
||||
* Uses PlayerAnimationAccess for local/factory-registered players,
|
||||
* falls back to NPC cache for remote players.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
@javax.annotation.Nullable
|
||||
private static ModifierLayer<IAnimation> getFurnitureLayer(Player player) {
|
||||
if (player instanceof AbstractClientPlayer clientPlayer) {
|
||||
try {
|
||||
ModifierLayer<IAnimation> layer = (ModifierLayer<IAnimation>)
|
||||
PlayerAnimationAccess.getPlayerAssociatedData(clientPlayer)
|
||||
.get(FURNITURE_FACTORY_ID);
|
||||
if (layer != null) {
|
||||
return layer;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Fall through to NPC cache
|
||||
}
|
||||
|
||||
// Fallback for remote players: check NPC furniture cache
|
||||
return npcFurnitureLayers.get(player.getUUID());
|
||||
}
|
||||
|
||||
// Non-player entities: use NPC cache
|
||||
return npcFurnitureLayers.get(player.getUUID());
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety tick for furniture animations. Call once per client tick per player.
|
||||
*
|
||||
* <p>If a player has an active furniture animation but is NOT riding an
|
||||
* {@link ISeatProvider}, increment a grace counter. After
|
||||
* {@link #FURNITURE_GRACE_TICKS} consecutive ticks without a seat, the
|
||||
* animation is removed to prevent stuck poses from entity death, network
|
||||
* desync, or teleportation.</p>
|
||||
*
|
||||
* <p>If the player IS riding an ISeatProvider, the counter is reset.</p>
|
||||
*
|
||||
* @param player the player to check
|
||||
*/
|
||||
public static void tickFurnitureSafety(Player player) {
|
||||
if (player == null || !player.level().isClientSide()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasFurnitureAnimation(player)) {
|
||||
// No furniture animation active, nothing to guard
|
||||
furnitureGraceTicks.remove(player.getUUID());
|
||||
return;
|
||||
}
|
||||
|
||||
UUID uuid = player.getUUID();
|
||||
|
||||
// Check if the player is riding an ISeatProvider
|
||||
Entity vehicle = player.getVehicle();
|
||||
boolean ridingSeat = vehicle instanceof ISeatProvider;
|
||||
|
||||
if (ridingSeat) {
|
||||
// Player is properly seated, reset grace counter
|
||||
furnitureGraceTicks.remove(uuid);
|
||||
} else {
|
||||
// Player has furniture anim but no seat -- increment grace
|
||||
int ticks = furnitureGraceTicks.merge(uuid, 1, Integer::sum);
|
||||
if (ticks >= FURNITURE_GRACE_TICKS) {
|
||||
LOGGER.info("Removing stale furniture animation for player {} "
|
||||
+ "(not riding ISeatProvider for {} ticks)",
|
||||
player.getName().getString(), ticks);
|
||||
stopFurniture(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// FALLBACK ANIMATION HANDLING
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Try to find a fallback animation ID when the requested one doesn't exist.
|
||||
*
|
||||
* <p>Fallback chain:
|
||||
* <ol>
|
||||
* <li>Remove _sneak_ suffix (sneak variants often missing)</li>
|
||||
* <li>For sit_dog/kneel_dog variants, fall back to basic standing DOG</li>
|
||||
* <li>For _arms_ variants, try FULL variant</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param originalId The original animation ID that wasn't found
|
||||
* @return A fallback ResourceLocation to try, or null if no fallback
|
||||
*/
|
||||
@javax.annotation.Nullable
|
||||
private static ResourceLocation tryFallbackAnimation(
|
||||
ResourceLocation originalId
|
||||
) {
|
||||
String path = originalId.getPath();
|
||||
String namespace = originalId.getNamespace();
|
||||
|
||||
// 1. Remove _sneak_ suffix
|
||||
if (path.contains("_sneak_")) {
|
||||
String fallback = path.replace("_sneak_", "_");
|
||||
return ResourceLocation.fromNamespaceAndPath(namespace, fallback);
|
||||
}
|
||||
|
||||
// 2. sit_dog_* / kneel_dog_* -> tied_up_dog_*
|
||||
if (path.startsWith("sit_dog_") || path.startsWith("kneel_dog_")) {
|
||||
String suffix = path.substring(path.lastIndexOf("_")); // _idle or _struggle
|
||||
return ResourceLocation.fromNamespaceAndPath(
|
||||
namespace,
|
||||
"tied_up_dog" + suffix
|
||||
);
|
||||
}
|
||||
|
||||
// 3. _arms_ variants -> try FULL variant (remove _arms)
|
||||
if (path.contains("_arms_")) {
|
||||
String fallback = path.replace("_arms_", "_");
|
||||
return ResourceLocation.fromNamespaceAndPath(namespace, fallback);
|
||||
}
|
||||
|
||||
// 4. Struggle variants for free/legs -> idle variant
|
||||
if (
|
||||
(path.startsWith("sit_free_") ||
|
||||
path.startsWith("kneel_free_") ||
|
||||
path.startsWith("sit_legs_") ||
|
||||
path.startsWith("kneel_legs_")) &&
|
||||
path.endsWith("_struggle")
|
||||
) {
|
||||
String fallback = path.replace("_struggle", "_idle");
|
||||
return ResourceLocation.fromNamespaceAndPath(namespace, fallback);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CLEANUP
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Clean up animation layer for an NPC when it's removed.
|
||||
*
|
||||
* @param entityId UUID of the removed entity
|
||||
*/
|
||||
/** All NPC layer caches, for bulk cleanup operations. */
|
||||
private static final Map<UUID, ModifierLayer<IAnimation>>[] ALL_NPC_CACHES = new Map[] {
|
||||
npcLayers, npcContextLayers, npcFurnitureLayers
|
||||
};
|
||||
|
||||
public static void cleanup(UUID entityId) {
|
||||
for (Map<UUID, ModifierLayer<IAnimation>> cache : ALL_NPC_CACHES) {
|
||||
ModifierLayer<IAnimation> layer = cache.remove(entityId);
|
||||
if (layer != null) {
|
||||
layer.setAnimation(null);
|
||||
}
|
||||
}
|
||||
furnitureGraceTicks.remove(entityId);
|
||||
LOGGER.debug("Cleaned up animation layers for entity: {}", entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all NPC animation layers.
|
||||
* Should be called on world unload.
|
||||
*/
|
||||
public static void clearAll() {
|
||||
for (Map<UUID, ModifierLayer<IAnimation>> cache : ALL_NPC_CACHES) {
|
||||
cache.values().forEach(layer -> layer.setAnimation(null));
|
||||
cache.clear();
|
||||
}
|
||||
furnitureGraceTicks.clear();
|
||||
LOGGER.info("Cleared all NPC animation layers");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.tiedup.remake.client.animation;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import dev.kosmx.playerAnim.api.layered.IAnimation;
|
||||
import dev.kosmx.playerAnim.api.layered.KeyframeAnimationPlayer;
|
||||
import dev.kosmx.playerAnim.api.layered.ModifierLayer;
|
||||
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Manages pending animations for remote players whose animation layers
|
||||
* may not be immediately available due to timing issues.
|
||||
*
|
||||
* <p>When a player is tied, the sync packet may arrive before the remote player's
|
||||
* animation layer is initialized by PlayerAnimator. This class queues failed
|
||||
* animation attempts and retries them each tick until success or timeout.
|
||||
*
|
||||
* <p>This follows the same pattern as SyncManager's pending queue for inventory sync.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class PendingAnimationManager {
|
||||
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
|
||||
/** Pending animations waiting for layer initialization */
|
||||
private static final Map<UUID, PendingEntry> pending =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** Maximum retry attempts before giving up (~2 seconds at 20 ticks/sec) */
|
||||
private static final int MAX_RETRIES = 40;
|
||||
|
||||
/**
|
||||
* Queue a player's animation for retry.
|
||||
* Called when playAnimation fails due to null layer.
|
||||
*
|
||||
* @param uuid The player's UUID
|
||||
* @param animId The animation ID (without namespace)
|
||||
*/
|
||||
public static void queueForRetry(UUID uuid, String animId) {
|
||||
pending.compute(uuid, (k, existing) -> {
|
||||
if (existing == null) {
|
||||
LOGGER.debug(
|
||||
"Queued animation '{}' for retry on player {}",
|
||||
animId,
|
||||
uuid
|
||||
);
|
||||
return new PendingEntry(animId, 0);
|
||||
}
|
||||
// Update animation ID but preserve retry count
|
||||
return new PendingEntry(animId, existing.retries);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a player from the pending queue.
|
||||
* Called when animation succeeds or player disconnects.
|
||||
*
|
||||
* @param uuid The player's UUID
|
||||
*/
|
||||
public static void remove(UUID uuid) {
|
||||
pending.remove(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a player has a pending animation.
|
||||
*
|
||||
* @param uuid The player's UUID
|
||||
* @return true if pending
|
||||
*/
|
||||
public static boolean hasPending(UUID uuid) {
|
||||
return pending.containsKey(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending animations. Called every tick from AnimationTickHandler.
|
||||
* Attempts to play queued animations and removes successful or expired entries.
|
||||
*
|
||||
* @param level The client level
|
||||
*/
|
||||
public static void processPending(ClientLevel level) {
|
||||
if (pending.isEmpty()) return;
|
||||
|
||||
Iterator<Map.Entry<UUID, PendingEntry>> it = pending
|
||||
.entrySet()
|
||||
.iterator();
|
||||
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<UUID, PendingEntry> entry = it.next();
|
||||
UUID uuid = entry.getKey();
|
||||
PendingEntry pe = entry.getValue();
|
||||
|
||||
// Check expiration
|
||||
if (pe.retries >= MAX_RETRIES) {
|
||||
LOGGER.warn("Animation retry exhausted for player {}", uuid);
|
||||
it.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to find player and play animation
|
||||
Player player = level.getPlayerByUUID(uuid);
|
||||
if (player instanceof AbstractClientPlayer clientPlayer) {
|
||||
ModifierLayer<IAnimation> layer =
|
||||
BondageAnimationManager.getPlayerLayerSafe(clientPlayer);
|
||||
|
||||
if (layer != null) {
|
||||
ResourceLocation loc =
|
||||
ResourceLocation.fromNamespaceAndPath(
|
||||
"tiedup",
|
||||
pe.animId
|
||||
);
|
||||
KeyframeAnimation anim =
|
||||
PlayerAnimationRegistry.getAnimation(loc);
|
||||
|
||||
if (anim != null) {
|
||||
layer.setAnimation(new KeyframeAnimationPlayer(anim));
|
||||
LOGGER.info(
|
||||
"Animation retry succeeded for {} after {} attempts",
|
||||
clientPlayer.getName().getString(),
|
||||
pe.retries
|
||||
);
|
||||
it.remove();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Increment retry count
|
||||
pending.put(uuid, new PendingEntry(pe.animId, pe.retries + 1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending animations.
|
||||
* Called on world unload.
|
||||
*/
|
||||
public static void clearAll() {
|
||||
pending.clear();
|
||||
LOGGER.debug("Cleared all pending animations");
|
||||
}
|
||||
|
||||
/**
|
||||
* Record to store pending animation data.
|
||||
*/
|
||||
private record PendingEntry(String animId, int retries) {}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.tiedup.remake.client.animation;
|
||||
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import net.minecraft.client.model.HumanoidModel;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Applies static bondage poses directly to HumanoidModel.
|
||||
*
|
||||
* <p>Used for entities that don't support PlayerAnimator (e.g., MCA villagers).
|
||||
* Directly modifies arm/leg rotations on the model.
|
||||
*
|
||||
* <p>Extracted from BondageAnimationManager to separate concerns:
|
||||
* BondageAnimationManager handles PlayerAnimator layers,
|
||||
* StaticPoseApplier handles raw model manipulation.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class StaticPoseApplier {
|
||||
|
||||
/**
|
||||
* Apply a static bondage pose directly to a HumanoidModel.
|
||||
*
|
||||
* @param model The humanoid model to modify
|
||||
* @param poseType The pose type (STANDARD, STRAITJACKET, WRAP, LATEX_SACK)
|
||||
* @param armsBound whether ARMS region is occupied
|
||||
* @param legsBound whether LEGS region is occupied
|
||||
*/
|
||||
public static void applyStaticPose(
|
||||
HumanoidModel<?> model,
|
||||
PoseType poseType,
|
||||
boolean armsBound,
|
||||
boolean legsBound
|
||||
) {
|
||||
if (model == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyBodyPose(model, poseType);
|
||||
|
||||
if (armsBound) {
|
||||
applyArmPose(model, poseType);
|
||||
}
|
||||
|
||||
if (legsBound) {
|
||||
applyLegPose(model, poseType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply arm pose based on pose type.
|
||||
* Values converted from animation JSON (degrees to radians).
|
||||
*/
|
||||
private static void applyArmPose(
|
||||
HumanoidModel<?> model,
|
||||
PoseType poseType
|
||||
) {
|
||||
switch (poseType) {
|
||||
case STANDARD -> {
|
||||
model.rightArm.xRot = 0.899f;
|
||||
model.rightArm.yRot = 1.0f;
|
||||
model.rightArm.zRot = 0f;
|
||||
model.leftArm.xRot = 0.899f;
|
||||
model.leftArm.yRot = -1.0f;
|
||||
model.leftArm.zRot = 0f;
|
||||
}
|
||||
case STRAITJACKET -> {
|
||||
model.rightArm.xRot = 0.764f;
|
||||
model.rightArm.yRot = -0.84f;
|
||||
model.rightArm.zRot = 0f;
|
||||
model.leftArm.xRot = 0.764f;
|
||||
model.leftArm.yRot = 0.84f;
|
||||
model.leftArm.zRot = 0f;
|
||||
}
|
||||
case WRAP, LATEX_SACK -> {
|
||||
model.rightArm.xRot = 0f;
|
||||
model.rightArm.yRot = 0f;
|
||||
model.rightArm.zRot = -0.087f;
|
||||
model.leftArm.xRot = 0f;
|
||||
model.leftArm.yRot = 0f;
|
||||
model.leftArm.zRot = 0.087f;
|
||||
}
|
||||
case DOG -> {
|
||||
model.rightArm.xRot = -2.094f;
|
||||
model.rightArm.yRot = 0.175f;
|
||||
model.rightArm.zRot = 0f;
|
||||
model.leftArm.xRot = -2.094f;
|
||||
model.leftArm.yRot = -0.175f;
|
||||
model.leftArm.zRot = 0f;
|
||||
}
|
||||
case HUMAN_CHAIR -> {
|
||||
model.rightArm.xRot = -2.094f;
|
||||
model.rightArm.yRot = 0.175f;
|
||||
model.rightArm.zRot = 0f;
|
||||
model.leftArm.xRot = -2.094f;
|
||||
model.leftArm.yRot = -0.175f;
|
||||
model.leftArm.zRot = 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply leg pose based on pose type.
|
||||
*/
|
||||
private static void applyLegPose(
|
||||
HumanoidModel<?> model,
|
||||
PoseType poseType
|
||||
) {
|
||||
if (poseType == PoseType.DOG || poseType == PoseType.HUMAN_CHAIR) {
|
||||
model.rightLeg.xRot = -1.047f;
|
||||
model.rightLeg.yRot = 0.349f;
|
||||
model.rightLeg.zRot = 0f;
|
||||
model.leftLeg.xRot = -1.047f;
|
||||
model.leftLeg.yRot = -0.349f;
|
||||
model.leftLeg.zRot = 0f;
|
||||
} else {
|
||||
model.rightLeg.xRot = 0f;
|
||||
model.rightLeg.yRot = 0f;
|
||||
model.rightLeg.zRot = -0.1f;
|
||||
model.leftLeg.xRot = 0f;
|
||||
model.leftLeg.yRot = 0f;
|
||||
model.leftLeg.zRot = 0.1f;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply body pose for DOG/HUMAN_CHAIR pose.
|
||||
*/
|
||||
public static void applyBodyPose(
|
||||
HumanoidModel<?> model,
|
||||
PoseType poseType
|
||||
) {
|
||||
if (poseType == PoseType.DOG || poseType == PoseType.HUMAN_CHAIR) {
|
||||
model.body.xRot = -1.571f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.tiedup.remake.client.animation.context;
|
||||
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Represents the current player/NPC posture and action state for animation selection.
|
||||
* Determines which base body posture animation to play.
|
||||
*
|
||||
* <p>Each context maps to a GLB animation name via a prefix + variant scheme:
|
||||
* <ul>
|
||||
* <li>Prefix: "Sit", "Kneel", "Sneak", "Walk", or "" (standing)</li>
|
||||
* <li>Variant: "Idle" or "Struggle"</li>
|
||||
* </ul>
|
||||
* The {@link GlbAnimationResolver} uses these to build a fallback chain
|
||||
* (e.g., SitStruggle -> Struggle -> SitIdle -> Idle).</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public enum AnimationContext {
|
||||
|
||||
STAND_IDLE("stand_idle", false),
|
||||
STAND_WALK("stand_walk", false),
|
||||
STAND_SNEAK("stand_sneak", false),
|
||||
STAND_STRUGGLE("stand_struggle", true),
|
||||
SIT_IDLE("sit_idle", false),
|
||||
SIT_STRUGGLE("sit_struggle", true),
|
||||
KNEEL_IDLE("kneel_idle", false),
|
||||
KNEEL_STRUGGLE("kneel_struggle", true),
|
||||
|
||||
// Movement style contexts
|
||||
SHUFFLE_IDLE("shuffle_idle", false),
|
||||
SHUFFLE_WALK("shuffle_walk", false),
|
||||
HOP_IDLE("hop_idle", false),
|
||||
HOP_WALK("hop_walk", false),
|
||||
WADDLE_IDLE("waddle_idle", false),
|
||||
WADDLE_WALK("waddle_walk", false),
|
||||
CRAWL_IDLE("crawl_idle", false),
|
||||
CRAWL_MOVE("crawl_move", false);
|
||||
|
||||
private final String animationSuffix;
|
||||
private final boolean struggling;
|
||||
|
||||
AnimationContext(String animationSuffix, boolean struggling) {
|
||||
this.animationSuffix = animationSuffix;
|
||||
this.struggling = struggling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suffix used as key for context animation JSON files (e.g., "stand_idle").
|
||||
*/
|
||||
public String getAnimationSuffix() {
|
||||
return animationSuffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this context represents an active struggle state.
|
||||
*/
|
||||
public boolean isStruggling() {
|
||||
return struggling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GLB animation name prefix for this context's posture.
|
||||
* Used by the fallback chain in {@link GlbAnimationResolver}.
|
||||
*
|
||||
* @return "Sit", "Kneel", "Sneak", "Walk", or "" for standing
|
||||
*/
|
||||
public String getGlbContextPrefix() {
|
||||
return switch (this) {
|
||||
case SIT_IDLE, SIT_STRUGGLE -> "Sit";
|
||||
case KNEEL_IDLE, KNEEL_STRUGGLE -> "Kneel";
|
||||
case STAND_SNEAK -> "Sneak";
|
||||
case STAND_WALK -> "Walk";
|
||||
case STAND_IDLE, STAND_STRUGGLE -> "";
|
||||
case SHUFFLE_IDLE, SHUFFLE_WALK -> "Shuffle";
|
||||
case HOP_IDLE, HOP_WALK -> "Hop";
|
||||
case WADDLE_IDLE, WADDLE_WALK -> "Waddle";
|
||||
case CRAWL_IDLE, CRAWL_MOVE -> "Crawl";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GLB animation variant name: "Struggle" or "Idle".
|
||||
*/
|
||||
public String getGlbVariant() {
|
||||
return switch (this) {
|
||||
case STAND_STRUGGLE, SIT_STRUGGLE, KNEEL_STRUGGLE -> "Struggle";
|
||||
case STAND_WALK, SHUFFLE_WALK, HOP_WALK, WADDLE_WALK -> "Walk";
|
||||
case CRAWL_MOVE -> "Move";
|
||||
default -> "Idle";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.tiedup.remake.client.animation.context;
|
||||
|
||||
import com.tiedup.remake.client.state.PetBedClientState;
|
||||
import com.tiedup.remake.entities.AbstractTiedUpNpc;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Resolves the current {@link AnimationContext} for players and NPCs based on their state.
|
||||
*
|
||||
* <p>This is a pure function with no side effects -- it reads entity state and returns
|
||||
* the appropriate animation context. The resolution priority is:
|
||||
* <ol>
|
||||
* <li><b>Sitting</b> (pet bed for players, pose for NPCs) -- highest priority posture</li>
|
||||
* <li><b>Kneeling</b> (NPCs only)</li>
|
||||
* <li><b>Struggling</b> (standing struggle if not sitting/kneeling)</li>
|
||||
* <li><b>Sneaking</b> (players only)</li>
|
||||
* <li><b>Walking</b> (horizontal movement detected)</li>
|
||||
* <li><b>Standing idle</b> (fallback)</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>For players, the "sitting" state is determined by the client-side pet bed cache
|
||||
* ({@link PetBedClientState}) rather than entity data, since pet bed state is not
|
||||
* synced via entity data accessors.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class AnimationContextResolver {
|
||||
|
||||
private AnimationContextResolver() {}
|
||||
|
||||
/**
|
||||
* Resolve the animation context for a player based on their bind state and movement.
|
||||
*
|
||||
* <p>Priority chain:
|
||||
* <ol>
|
||||
* <li>Sitting (pet bed/furniture) -- highest priority posture</li>
|
||||
* <li>Struggling -- standing struggle if not sitting</li>
|
||||
* <li>Movement style -- style-specific idle/walk based on movement</li>
|
||||
* <li>Sneaking</li>
|
||||
* <li>Walking</li>
|
||||
* <li>Standing idle -- fallback</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param player the player entity (must not be null)
|
||||
* @param state the player's bind state, or null if not bound
|
||||
* @param activeStyle the active movement style from client state, or null
|
||||
* @return the resolved animation context, never null
|
||||
*/
|
||||
public static AnimationContext resolve(Player player, @Nullable PlayerBindState state,
|
||||
@Nullable MovementStyle activeStyle) {
|
||||
boolean sitting = PetBedClientState.get(player.getUUID()) != 0;
|
||||
boolean struggling = state != null && state.isStruggling();
|
||||
boolean sneaking = player.isCrouching();
|
||||
boolean moving = player.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
|
||||
|
||||
if (sitting) {
|
||||
return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE;
|
||||
}
|
||||
if (struggling) {
|
||||
return AnimationContext.STAND_STRUGGLE;
|
||||
}
|
||||
if (activeStyle != null) {
|
||||
return resolveStyleContext(activeStyle, moving);
|
||||
}
|
||||
if (sneaking) {
|
||||
return AnimationContext.STAND_SNEAK;
|
||||
}
|
||||
if (moving) {
|
||||
return AnimationContext.STAND_WALK;
|
||||
}
|
||||
return AnimationContext.STAND_IDLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a movement style + moving flag to the appropriate AnimationContext.
|
||||
*/
|
||||
private static AnimationContext resolveStyleContext(MovementStyle style, boolean moving) {
|
||||
return switch (style) {
|
||||
case SHUFFLE -> moving ? AnimationContext.SHUFFLE_WALK : AnimationContext.SHUFFLE_IDLE;
|
||||
case HOP -> moving ? AnimationContext.HOP_WALK : AnimationContext.HOP_IDLE;
|
||||
case WADDLE -> moving ? AnimationContext.WADDLE_WALK : AnimationContext.WADDLE_IDLE;
|
||||
case CRAWL -> moving ? AnimationContext.CRAWL_MOVE : AnimationContext.CRAWL_IDLE;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the animation context for a Damsel NPC based on pose and movement.
|
||||
*
|
||||
* <p>Unlike players, NPCs support kneeling as a distinct posture and do not sneak.</p>
|
||||
*
|
||||
* @param entity the damsel entity (must not be null)
|
||||
* @return the resolved animation context, never null
|
||||
*/
|
||||
public static AnimationContext resolveNpc(AbstractTiedUpNpc entity) {
|
||||
boolean sitting = entity.isSitting();
|
||||
boolean kneeling = entity.isKneeling();
|
||||
boolean struggling = entity.isStruggling();
|
||||
boolean moving = entity.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
|
||||
|
||||
if (sitting) {
|
||||
return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE;
|
||||
}
|
||||
if (kneeling) {
|
||||
return struggling ? AnimationContext.KNEEL_STRUGGLE : AnimationContext.KNEEL_IDLE;
|
||||
}
|
||||
if (struggling) {
|
||||
return AnimationContext.STAND_STRUGGLE;
|
||||
}
|
||||
if (moving) {
|
||||
return AnimationContext.STAND_WALK;
|
||||
}
|
||||
return AnimationContext.STAND_IDLE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.tiedup.remake.client.animation.context;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Builds context {@link KeyframeAnimation}s with item-owned body parts disabled.
|
||||
*
|
||||
* <p>Context animations (loaded from {@code context_*.json} files in the PlayerAnimator
|
||||
* registry) control the base body posture -- standing, sitting, walking, etc.
|
||||
* When a V2 bondage item "owns" certain body parts (e.g., handcuffs own rightArm + leftArm),
|
||||
* those parts must NOT be driven by the context animation because the item's own
|
||||
* GLB animation controls them instead.</p>
|
||||
*
|
||||
* <p>This factory loads the base context animation, creates a mutable copy, disables
|
||||
* the owned parts, and builds an immutable result. Results are cached by
|
||||
* {@code contextSuffix|ownedPartsHash} to avoid repeated copies.</p>
|
||||
*
|
||||
* <p>Thread safety: the cache uses {@link ConcurrentHashMap}. All methods are
|
||||
* called from the render thread, but the concurrent map avoids issues if
|
||||
* resource reload triggers on a different thread.</p>
|
||||
*
|
||||
* @see AnimationContext
|
||||
* @see RegionBoneMapper#computeOwnedParts
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class ContextAnimationFactory {
|
||||
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static final String NAMESPACE = "tiedup";
|
||||
|
||||
/**
|
||||
* Cache keyed by "contextSuffix|ownedPartsHashCode".
|
||||
* Null values are stored as sentinels for missing animations to avoid repeated lookups.
|
||||
*/
|
||||
private static final Map<String, KeyframeAnimation> CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Sentinel set used to track cache keys where the base animation was not found,
|
||||
* so we don't log the same warning repeatedly.
|
||||
*/
|
||||
private static final Set<String> MISSING_WARNED = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private ContextAnimationFactory() {}
|
||||
|
||||
/**
|
||||
* Create (or retrieve from cache) a context animation with the given parts disabled.
|
||||
*
|
||||
* <p>If no parts need disabling, the base animation is returned as-is (no copy needed).
|
||||
* If the base animation is not found in the PlayerAnimator registry, returns null.</p>
|
||||
*
|
||||
* @param context the current animation context (determines which context_*.json to load)
|
||||
* @param disabledParts set of PlayerAnimator part names to disable on the context layer
|
||||
* (e.g., {"rightArm", "leftArm"}), typically from
|
||||
* {@link RegionBoneMapper.BoneOwnership#disabledOnContext()}
|
||||
* @return the context animation with disabled parts suppressed, or null if not found
|
||||
*/
|
||||
@Nullable
|
||||
public static KeyframeAnimation create(AnimationContext context, Set<String> disabledParts) {
|
||||
String cacheKey = context.getAnimationSuffix() + "|" + String.join(",", new java.util.TreeSet<>(disabledParts));
|
||||
// computeIfAbsent cannot store null values, so we handle the missing case
|
||||
// by checking the MISSING_WARNED set to avoid redundant work.
|
||||
KeyframeAnimation cached = CACHE.get(cacheKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
if (MISSING_WARNED.contains(cacheKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
KeyframeAnimation result = buildContextAnimation(context, disabledParts);
|
||||
if (result != null) {
|
||||
CACHE.put(cacheKey, result);
|
||||
} else {
|
||||
MISSING_WARNED.add(cacheKey);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a context animation with the specified parts disabled.
|
||||
*
|
||||
* <p>Flow:
|
||||
* <ol>
|
||||
* <li>Check {@link ContextGlbRegistry} for a GLB-based context animation (takes priority)</li>
|
||||
* <li>Fall back to {@code tiedup:context_<suffix>} in PlayerAnimationRegistry (JSON-based)</li>
|
||||
* <li>If no parts need disabling, return the base animation directly (immutable, shared)</li>
|
||||
* <li>Otherwise, create a mutable copy via {@link KeyframeAnimation#mutableCopy()}</li>
|
||||
* <li>Disable each part via {@link KeyframeAnimation.StateCollection#setEnabled(boolean)}</li>
|
||||
* <li>Build and return the new immutable animation</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Nullable
|
||||
private static KeyframeAnimation buildContextAnimation(AnimationContext context,
|
||||
Set<String> disabledParts) {
|
||||
String suffix = context.getAnimationSuffix();
|
||||
|
||||
// Priority 1: GLB-based context animation from ContextGlbRegistry
|
||||
KeyframeAnimation baseAnim = ContextGlbRegistry.get(suffix);
|
||||
|
||||
// Priority 2: JSON-based context animation from PlayerAnimationRegistry
|
||||
if (baseAnim == null) {
|
||||
ResourceLocation animId = ResourceLocation.fromNamespaceAndPath(
|
||||
NAMESPACE, "context_" + suffix
|
||||
);
|
||||
baseAnim = PlayerAnimationRegistry.getAnimation(animId);
|
||||
}
|
||||
|
||||
if (baseAnim == null) {
|
||||
LOGGER.warn("[V2Animation] Context animation not found for suffix: {}", suffix);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (disabledParts.isEmpty()) {
|
||||
return baseAnim;
|
||||
}
|
||||
|
||||
// Create mutable copy so we can disable parts without affecting the registry/cache original
|
||||
KeyframeAnimation.AnimationBuilder builder = baseAnim.mutableCopy();
|
||||
disableParts(builder, disabledParts);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all animation axes on the specified parts.
|
||||
*
|
||||
* <p>Uses {@link KeyframeAnimation.AnimationBuilder#getPart(String)} to look up parts
|
||||
* by name, then {@link KeyframeAnimation.StateCollection#setEnabled(boolean)} to disable
|
||||
* all axes (x, y, z, pitch, yaw, roll, and bend/bendDirection if applicable).</p>
|
||||
*
|
||||
* <p>Unknown part names are silently ignored -- this can happen if the disabled parts set
|
||||
* includes future bone names not present in the current context animation.</p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached animations. Call this on resource reload or when equipped items change
|
||||
* in a way that might invalidate cached part ownership.
|
||||
*/
|
||||
public static void clearCache() {
|
||||
CACHE.clear();
|
||||
MISSING_WARNED.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.tiedup.remake.client.animation.context;
|
||||
|
||||
import com.tiedup.remake.client.gltf.GlbParser;
|
||||
import com.tiedup.remake.client.gltf.GltfData;
|
||||
import com.tiedup.remake.client.gltf.GltfPoseConverter;
|
||||
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.packs.resources.Resource;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Registry for context animations loaded from GLB files.
|
||||
*
|
||||
* <p>Scans the {@code tiedup_contexts/} resource directory for {@code .glb} files,
|
||||
* parses each one via {@link GlbParser}, converts to a {@link KeyframeAnimation}
|
||||
* via {@link GltfPoseConverter#convert(GltfData)}, and stores the result keyed by
|
||||
* the file name suffix (e.g., {@code "stand_walk"} from {@code tiedup_contexts/stand_walk.glb}).</p>
|
||||
*
|
||||
* <p>GLB context animations take priority over JSON-based PlayerAnimator context
|
||||
* animations. This allows artists to author posture animations directly in Blender
|
||||
* instead of hand-editing JSON keyframes.</p>
|
||||
*
|
||||
* <p>Reloaded on resource pack reload (F3+T) via the listener registered in
|
||||
* {@link com.tiedup.remake.client.gltf.GltfClientSetup}.</p>
|
||||
*
|
||||
* <p>Thread safety: the registry field is a volatile reference to an unmodifiable map.
|
||||
* {@link #reload} builds a new map on the reload thread then atomically swaps the
|
||||
* reference, so the render thread never sees a partially populated registry.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class ContextGlbRegistry {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
|
||||
/** Resource directory containing context GLB files. */
|
||||
private static final String DIRECTORY = "tiedup_contexts";
|
||||
|
||||
/**
|
||||
* Registry keyed by context suffix (e.g., "stand_walk", "sit_idle").
|
||||
* Values are fully converted KeyframeAnimations with all parts enabled.
|
||||
*
|
||||
* <p>Volatile reference to an unmodifiable map. Reload builds a new map
|
||||
* and swaps atomically; the render thread always sees a consistent snapshot.</p>
|
||||
*/
|
||||
private static volatile Map<String, KeyframeAnimation> REGISTRY = Map.of();
|
||||
|
||||
private ContextGlbRegistry() {}
|
||||
|
||||
/**
|
||||
* Reload all context GLB files from the resource manager.
|
||||
*
|
||||
* <p>Scans {@code assets/<namespace>/tiedup_contexts/} for {@code .glb} files.
|
||||
* Each file is parsed and converted to a full-body KeyframeAnimation.
|
||||
* The context suffix is extracted from the file path:
|
||||
* {@code tiedup_contexts/stand_walk.glb} becomes key {@code "stand_walk"}.</p>
|
||||
*
|
||||
* <p>GLB files without animation data or with parse errors are logged and skipped.</p>
|
||||
*
|
||||
* @param resourceManager the current resource manager (from reload listener)
|
||||
*/
|
||||
public static void reload(ResourceManager resourceManager) {
|
||||
Map<String, KeyframeAnimation> newRegistry = new HashMap<>();
|
||||
|
||||
Map<ResourceLocation, Resource> resources = resourceManager.listResources(
|
||||
DIRECTORY, loc -> loc.getPath().endsWith(".glb"));
|
||||
|
||||
for (Map.Entry<ResourceLocation, Resource> entry : resources.entrySet()) {
|
||||
ResourceLocation loc = entry.getKey();
|
||||
Resource resource = entry.getValue();
|
||||
|
||||
// Extract suffix from path: "tiedup_contexts/stand_walk.glb" -> "stand_walk"
|
||||
String path = loc.getPath();
|
||||
String fileName = path.substring(path.lastIndexOf('/') + 1);
|
||||
String suffix = fileName.substring(0, fileName.length() - 4); // strip ".glb"
|
||||
|
||||
try (InputStream is = resource.open()) {
|
||||
GltfData data = GlbParser.parse(is, loc.toString());
|
||||
|
||||
// Convert to a full-body KeyframeAnimation (all parts enabled)
|
||||
KeyframeAnimation anim = GltfPoseConverter.convert(data);
|
||||
newRegistry.put(suffix, anim);
|
||||
|
||||
LOGGER.info("[GltfPipeline] Loaded context GLB: '{}' -> suffix '{}'", loc, suffix);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[GltfPipeline] Failed to load context GLB: {}", loc, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic swap: render thread never sees a partially populated registry
|
||||
REGISTRY = Collections.unmodifiableMap(newRegistry);
|
||||
LOGGER.info("[ContextGlb] Loaded {} context GLB animations", newRegistry.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a context animation by suffix.
|
||||
*
|
||||
* @param contextSuffix the context suffix (e.g., "stand_walk", "sit_idle")
|
||||
* @return the KeyframeAnimation, or null if no GLB was found for this suffix
|
||||
*/
|
||||
@Nullable
|
||||
public static KeyframeAnimation get(String contextSuffix) {
|
||||
return REGISTRY.get(contextSuffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached context animations.
|
||||
* Called on resource reload and world unload.
|
||||
*/
|
||||
public static void clear() {
|
||||
REGISTRY = Map.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.tiedup.remake.client.animation.context;
|
||||
|
||||
import com.tiedup.remake.client.gltf.GltfCache;
|
||||
import com.tiedup.remake.client.gltf.GltfData;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Resolves which named animation to play from a GLB file based on the current
|
||||
* {@link AnimationContext}. Implements three features:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>Context-based resolution with fallback chain</b> — tries progressively
|
||||
* less specific animation names until one is found:
|
||||
* <pre>SitStruggle -> Struggle -> SitIdle -> Sit -> Idle -> null</pre></li>
|
||||
* <li><b>Animation variants</b> — if {@code Struggle.1}, {@code Struggle.2},
|
||||
* {@code Struggle.3} exist in the GLB, one is picked at random each time</li>
|
||||
* <li><b>Shared animation templates</b> — animations can come from a separate GLB
|
||||
* file (passed as {@code animationSource} to {@link #resolveAnimationData})</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>This class is stateless and thread-safe. All methods are static.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class GlbAnimationResolver {
|
||||
|
||||
private GlbAnimationResolver() {}
|
||||
|
||||
/**
|
||||
* Resolve the animation data source.
|
||||
* If {@code animationSource} is non-null, load that GLB for animations
|
||||
* (shared template). Otherwise use the item's own model GLB.
|
||||
*
|
||||
* @param itemModelLoc the item's GLB model resource location
|
||||
* @param animationSource optional separate GLB containing shared animations
|
||||
* @return parsed GLB data, or null if loading failed
|
||||
*/
|
||||
@Nullable
|
||||
public static GltfData resolveAnimationData(ResourceLocation itemModelLoc,
|
||||
@Nullable ResourceLocation animationSource) {
|
||||
ResourceLocation source = animationSource != null ? animationSource : itemModelLoc;
|
||||
return GltfCache.get(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the best animation name from a GLB for the given context.
|
||||
* Supports variant selection ({@code Struggle.1}, {@code Struggle.2} -> random pick)
|
||||
* and full-body animations ({@code FullWalk}, {@code FullStruggle}).
|
||||
*
|
||||
* <p>Fallback chain (Full variants checked first at each step):</p>
|
||||
* <pre>
|
||||
* FullSitStruggle -> SitStruggle -> FullStruggle -> Struggle
|
||||
* -> FullSitIdle -> SitIdle -> FullSit -> Sit
|
||||
* -> FullIdle -> Idle -> null
|
||||
* </pre>
|
||||
*
|
||||
* @param data the parsed GLB data containing named animations
|
||||
* @param context the current animation context (posture + action)
|
||||
* @return the animation name to use, or null to use the default (first) clip
|
||||
*/
|
||||
@Nullable
|
||||
public static String resolve(GltfData data, AnimationContext context) {
|
||||
String prefix = context.getGlbContextPrefix(); // "Sit", "Kneel", "Sneak", "Walk", ""
|
||||
String variant = context.getGlbVariant(); // "Idle" or "Struggle"
|
||||
|
||||
// 1. Exact match: "FullSitIdle" then "SitIdle" (with variants)
|
||||
String exact = prefix + variant;
|
||||
if (!exact.isEmpty()) {
|
||||
String picked = pickWithVariants(data, "Full" + exact);
|
||||
if (picked != null) return picked;
|
||||
picked = pickWithVariants(data, exact);
|
||||
if (picked != null) return picked;
|
||||
}
|
||||
|
||||
// 2. For struggles: try "FullStruggle" then "Struggle" (with variants)
|
||||
if (context.isStruggling()) {
|
||||
String picked = pickWithVariants(data, "FullStruggle");
|
||||
if (picked != null) return picked;
|
||||
picked = pickWithVariants(data, "Struggle");
|
||||
if (picked != null) return picked;
|
||||
}
|
||||
|
||||
// 3. Context-only: "FullSit" then "Sit" (with variants)
|
||||
if (!prefix.isEmpty()) {
|
||||
String picked = pickWithVariants(data, "Full" + prefix);
|
||||
if (picked != null) return picked;
|
||||
picked = pickWithVariants(data, prefix);
|
||||
if (picked != null) return picked;
|
||||
}
|
||||
|
||||
// 4. Variant-only: "FullIdle" then "Idle" (with variants)
|
||||
{
|
||||
String picked = pickWithVariants(data, "Full" + variant);
|
||||
if (picked != null) return picked;
|
||||
picked = pickWithVariants(data, variant);
|
||||
if (picked != null) return picked;
|
||||
}
|
||||
|
||||
// 5. Default: return null = use first animation clip in GLB
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for an animation by base name, including numbered variants.
|
||||
* <ul>
|
||||
* <li>If "Struggle" exists alone, return "Struggle"</li>
|
||||
* <li>If "Struggle.1" and "Struggle.2" exist, pick one randomly</li>
|
||||
* <li>If both "Struggle" and "Struggle.1" exist, include all in the random pool</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Variant numbering starts at 1 and tolerates a missing {@code .1}
|
||||
* (continues to check {@code .2}). Gaps after index 1 stop the scan.
|
||||
* For example, {@code Struggle.1, Struggle.3} would only find
|
||||
* {@code Struggle.1} because the gap at index 2 stops iteration.
|
||||
* However, if only {@code Struggle.2} exists (no {@code .1}), it will
|
||||
* still be found because the scan skips the first gap.</p>
|
||||
*
|
||||
* @param data the parsed GLB data
|
||||
* @param baseName the base animation name (e.g., "Struggle", "SitIdle")
|
||||
* @return the selected animation name, or null if no match found
|
||||
*/
|
||||
@Nullable
|
||||
private static String pickWithVariants(GltfData data, String baseName) {
|
||||
Map<String, GltfData.AnimationClip> anims = data.namedAnimations();
|
||||
List<String> candidates = new ArrayList<>();
|
||||
|
||||
if (anims.containsKey(baseName)) {
|
||||
candidates.add(baseName);
|
||||
}
|
||||
|
||||
// Check numbered variants: baseName.1, baseName.2, ...
|
||||
for (int i = 1; i <= 99; i++) {
|
||||
String variantName = baseName + "." + i;
|
||||
if (anims.containsKey(variantName)) {
|
||||
candidates.add(variantName);
|
||||
} else if (i > 1) {
|
||||
break; // Stop at first gap after .1
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.isEmpty()) return null;
|
||||
if (candidates.size() == 1) return candidates.get(0);
|
||||
return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package com.tiedup.remake.client.animation.context;
|
||||
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageItem;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
||||
import java.util.*;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Maps V2 body regions to PlayerAnimator part names.
|
||||
* Bridge between gameplay regions and animation bones.
|
||||
*
|
||||
* <p>PlayerAnimator uses 6 named parts: head, body, rightArm, leftArm, rightLeg, leftLeg.
|
||||
* This mapper translates the 14 {@link BodyRegionV2} gameplay regions into those bone names,
|
||||
* enabling the animation system to know which bones are "owned" by equipped bondage items.</p>
|
||||
*
|
||||
* <p>Regions without a direct bone mapping (NECK, FINGERS, TAIL, WINGS) return empty sets.
|
||||
* These regions still affect gameplay (blocking, escape difficulty) but don't directly
|
||||
* constrain animation bones.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class RegionBoneMapper {
|
||||
|
||||
/** All PlayerAnimator part names for the player model. */
|
||||
public static final Set<String> ALL_PARTS = Set.of(
|
||||
"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"
|
||||
);
|
||||
|
||||
/**
|
||||
* Describes bone ownership for a specific item in the context of all equipped items.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code thisParts} — parts owned exclusively by the winning item</li>
|
||||
* <li>{@code otherParts} — parts owned by other equipped items</li>
|
||||
* <li>{@link #freeParts()} — parts not owned by any item (available for animation)</li>
|
||||
* <li>{@link #enabledParts()} — parts the winning item may animate (owned + free)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>When both the winning item and another item claim the same bone,
|
||||
* the other item takes precedence (the bone goes to {@code otherParts}).</p>
|
||||
*/
|
||||
public record BoneOwnership(Set<String> thisParts, Set<String> otherParts) {
|
||||
|
||||
/**
|
||||
* Parts not owned by any item. These are "free" and can be animated
|
||||
* by the winning item IF the GLB contains keyframes for them.
|
||||
*/
|
||||
public Set<String> freeParts() {
|
||||
Set<String> free = new HashSet<>(ALL_PARTS);
|
||||
free.removeAll(thisParts);
|
||||
free.removeAll(otherParts);
|
||||
return Collections.unmodifiableSet(free);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts the winning item is allowed to animate: its own parts + free parts.
|
||||
* Free parts are only actually enabled if the GLB has keyframes for them.
|
||||
*/
|
||||
public Set<String> enabledParts() {
|
||||
Set<String> enabled = new HashSet<>(thisParts);
|
||||
enabled.addAll(freeParts());
|
||||
return Collections.unmodifiableSet(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts that must be disabled on the context layer: parts owned by this item
|
||||
* (handled by item layer) + parts owned by other items (handled by their layer).
|
||||
* This equals ALL_PARTS minus freeParts.
|
||||
*/
|
||||
public Set<String> disabledOnContext() {
|
||||
Set<String> disabled = new HashSet<>(thisParts);
|
||||
disabled.addAll(otherParts);
|
||||
return Collections.unmodifiableSet(disabled);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Map<BodyRegionV2, Set<String>> REGION_TO_PARTS;
|
||||
|
||||
static {
|
||||
Map<BodyRegionV2, Set<String>> map = new EnumMap<>(BodyRegionV2.class);
|
||||
map.put(BodyRegionV2.HEAD, Set.of("head"));
|
||||
map.put(BodyRegionV2.EYES, Set.of("head"));
|
||||
map.put(BodyRegionV2.EARS, Set.of("head"));
|
||||
map.put(BodyRegionV2.MOUTH, Set.of("head"));
|
||||
map.put(BodyRegionV2.NECK, Set.of());
|
||||
map.put(BodyRegionV2.TORSO, Set.of("body"));
|
||||
map.put(BodyRegionV2.ARMS, Set.of("rightArm", "leftArm"));
|
||||
map.put(BodyRegionV2.HANDS, Set.of("rightArm", "leftArm"));
|
||||
map.put(BodyRegionV2.FINGERS, Set.of());
|
||||
map.put(BodyRegionV2.WAIST, Set.of("body"));
|
||||
map.put(BodyRegionV2.LEGS, Set.of("rightLeg", "leftLeg"));
|
||||
map.put(BodyRegionV2.FEET, Set.of("rightLeg", "leftLeg"));
|
||||
map.put(BodyRegionV2.TAIL, Set.of());
|
||||
map.put(BodyRegionV2.WINGS, Set.of());
|
||||
REGION_TO_PARTS = Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
private RegionBoneMapper() {}
|
||||
|
||||
/**
|
||||
* Get the PlayerAnimator part names affected by a single body region.
|
||||
*
|
||||
* @param region the V2 body region
|
||||
* @return unmodifiable set of part name strings, never null (may be empty)
|
||||
*/
|
||||
public static Set<String> getPartsForRegion(BodyRegionV2 region) {
|
||||
return REGION_TO_PARTS.getOrDefault(region, Set.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the union of all PlayerAnimator parts "owned" by equipped bondage items.
|
||||
*
|
||||
* <p>Iterates over the equipped map (as returned by
|
||||
* {@link com.tiedup.remake.v2.bondage.IV2BondageEquipment#getAllEquipped()})
|
||||
* and collects every bone affected by each item's occupied regions.</p>
|
||||
*
|
||||
* @param equipped map from representative region to equipped ItemStack
|
||||
* @return unmodifiable set of owned part name strings
|
||||
*/
|
||||
public static Set<String> computeOwnedParts(Map<BodyRegionV2, ItemStack> equipped) {
|
||||
Set<String> owned = new HashSet<>();
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (stack.getItem() instanceof IV2BondageItem v2Item) {
|
||||
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
|
||||
owned.addAll(getPartsForRegion(region));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableSet(owned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute per-item bone ownership for a specific "winning" item.
|
||||
*
|
||||
* <p>Iterates over all equipped items. Parts owned by the winning item
|
||||
* go to {@code thisParts}; parts owned by other items go to {@code otherParts}.
|
||||
* If both the winning item and another item claim the same bone, the other
|
||||
* item takes precedence (conflict resolution: other wins).</p>
|
||||
*
|
||||
* <p>Uses ItemStack reference equality ({@code ==}) to identify the winning item
|
||||
* because the same ItemStack instance is used in the equipped map.</p>
|
||||
*
|
||||
* @param equipped map from representative region to equipped ItemStack
|
||||
* @param winningItemStack the ItemStack of the highest-priority V2 item with a GLB model
|
||||
* @return BoneOwnership describing this item's parts vs other items' parts
|
||||
*/
|
||||
public static BoneOwnership computePerItemParts(Map<BodyRegionV2, ItemStack> equipped,
|
||||
ItemStack winningItemStack) {
|
||||
Set<String> thisParts = new HashSet<>();
|
||||
Set<String> otherParts = new HashSet<>();
|
||||
|
||||
// Track which ItemStacks we've already processed to avoid duplicate work
|
||||
// (multiple regions can map to the same ItemStack)
|
||||
Set<ItemStack> processed = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (processed.contains(stack)) continue;
|
||||
processed.add(stack);
|
||||
|
||||
if (stack.getItem() instanceof IV2BondageItem v2Item) {
|
||||
Set<String> itemParts = new HashSet<>();
|
||||
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
|
||||
itemParts.addAll(getPartsForRegion(region));
|
||||
}
|
||||
|
||||
if (stack == winningItemStack) {
|
||||
thisParts.addAll(itemParts);
|
||||
} else {
|
||||
otherParts.addAll(itemParts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conflict resolution: if both this item and another claim the same bone,
|
||||
// the other item takes precedence
|
||||
thisParts.removeAll(otherParts);
|
||||
|
||||
return new BoneOwnership(
|
||||
Collections.unmodifiableSet(thisParts),
|
||||
Collections.unmodifiableSet(otherParts)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of resolving the highest-priority V2 item with a GLB model.
|
||||
* Combines the model location, optional animation source, and the winning ItemStack
|
||||
* into a single object so callers don't need two separate iteration passes.
|
||||
*
|
||||
* @param modelLoc the GLB model ResourceLocation of the winning item
|
||||
* @param animSource separate GLB for animations (shared template), or null to use modelLoc
|
||||
* @param winningItem the actual ItemStack reference (for identity comparison in
|
||||
* {@link #computePerItemParts})
|
||||
*/
|
||||
public record GlbModelResult(ResourceLocation modelLoc, @Nullable ResourceLocation animSource,
|
||||
ItemStack winningItem) {}
|
||||
|
||||
/**
|
||||
* Animation info for a single equipped V2 item.
|
||||
* Used by the multi-item animation pipeline to process each item independently.
|
||||
*
|
||||
* @param modelLoc GLB model location (for rendering + default animation source)
|
||||
* @param animSource separate animation GLB, or null to use modelLoc
|
||||
* @param ownedParts parts this item exclusively owns (after conflict resolution)
|
||||
* @param posePriority the item's pose priority (for free-bone assignment)
|
||||
* @param animationBones per-animation bone whitelist from the data-driven definition.
|
||||
* Empty map for hardcoded items (no filtering applied).
|
||||
*/
|
||||
public record V2ItemAnimInfo(ResourceLocation modelLoc, @Nullable ResourceLocation animSource,
|
||||
Set<String> ownedParts, int posePriority,
|
||||
Map<String, Set<String>> animationBones) {}
|
||||
|
||||
/**
|
||||
* Find the highest-priority V2 item with a GLB model in the equipped map.
|
||||
*
|
||||
* <p>Single pass over all equipped items, comparing their
|
||||
* {@link IV2BondageItem#getPosePriority()} to select the dominant model.
|
||||
* Returns both the model location and the winning ItemStack reference so
|
||||
* callers can pass the ItemStack to {@link #computePerItemParts} without
|
||||
* a second iteration.</p>
|
||||
*
|
||||
* @param equipped map of equipped V2 items by body region (may be empty, never null)
|
||||
* @return the winning item's model info, or null if no V2 item has a GLB model (V1 fallback)
|
||||
*/
|
||||
@Nullable
|
||||
public static GlbModelResult resolveWinningItem(Map<BodyRegionV2, ItemStack> equipped) {
|
||||
ItemStack bestStack = null;
|
||||
ResourceLocation bestModel = null;
|
||||
int bestPriority = Integer.MIN_VALUE;
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (stack.getItem() instanceof IV2BondageItem v2Item) {
|
||||
ResourceLocation model = v2Item.getModelLocation(stack);
|
||||
if (model != null && v2Item.getPosePriority(stack) > bestPriority) {
|
||||
bestPriority = v2Item.getPosePriority(stack);
|
||||
bestModel = model;
|
||||
bestStack = stack;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestStack == null || bestModel == null) return null;
|
||||
|
||||
// Extract animation source from data-driven item definitions.
|
||||
// For hardcoded IV2BondageItem implementations, animSource stays null
|
||||
// (the model's own animations are used).
|
||||
ResourceLocation animSource = null;
|
||||
if (bestStack.getItem() instanceof DataDrivenBondageItem) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(bestStack);
|
||||
if (def != null) {
|
||||
animSource = def.animationSource();
|
||||
}
|
||||
}
|
||||
|
||||
return new GlbModelResult(bestModel, animSource, bestStack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ALL equipped V2 items with GLB models, with per-item bone ownership.
|
||||
*
|
||||
* <p>Each item gets ownership of its declared regions' bones. When two items claim
|
||||
* the same bone, the higher-priority item wins. The highest-priority item is also
|
||||
* designated as the "free bone donor" — it can animate free bones if its GLB has
|
||||
* keyframes for them.</p>
|
||||
*
|
||||
* @param equipped map from representative region to equipped ItemStack
|
||||
* @return list of V2ItemAnimInfo, sorted by priority descending. Empty if no V2 items.
|
||||
* The first element (if any) is the free-bone donor.
|
||||
*/
|
||||
public static List<V2ItemAnimInfo> resolveAllV2Items(Map<BodyRegionV2, ItemStack> equipped) {
|
||||
record ItemEntry(ItemStack stack, IV2BondageItem v2Item, ResourceLocation model,
|
||||
@Nullable ResourceLocation animSource, Set<String> rawParts, int priority,
|
||||
Map<String, Set<String>> animationBones) {}
|
||||
|
||||
List<ItemEntry> entries = new ArrayList<>();
|
||||
Set<ItemStack> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (seen.contains(stack)) continue;
|
||||
seen.add(stack);
|
||||
|
||||
if (stack.getItem() instanceof IV2BondageItem v2Item) {
|
||||
ResourceLocation model = v2Item.getModelLocation(stack);
|
||||
if (model == null) continue;
|
||||
|
||||
Set<String> rawParts = new HashSet<>();
|
||||
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
|
||||
rawParts.addAll(getPartsForRegion(region));
|
||||
}
|
||||
if (rawParts.isEmpty()) continue;
|
||||
|
||||
ResourceLocation animSource = null;
|
||||
Map<String, Set<String>> animBones = Map.of();
|
||||
if (stack.getItem() instanceof DataDrivenBondageItem) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
if (def != null) {
|
||||
animSource = def.animationSource();
|
||||
animBones = def.animationBones();
|
||||
}
|
||||
}
|
||||
|
||||
entries.add(new ItemEntry(stack, v2Item, model, animSource, rawParts,
|
||||
v2Item.getPosePriority(stack), animBones));
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.isEmpty()) return List.of();
|
||||
|
||||
entries.sort((a, b) -> Integer.compare(b.priority(), a.priority()));
|
||||
|
||||
Set<String> claimed = new HashSet<>();
|
||||
List<V2ItemAnimInfo> result = new ArrayList<>();
|
||||
|
||||
for (ItemEntry e : entries) {
|
||||
Set<String> ownedParts = new HashSet<>(e.rawParts());
|
||||
ownedParts.removeAll(claimed);
|
||||
if (ownedParts.isEmpty()) continue;
|
||||
claimed.addAll(ownedParts);
|
||||
result.add(new V2ItemAnimInfo(e.model(), e.animSource(),
|
||||
Collections.unmodifiableSet(ownedParts), e.priority(), e.animationBones()));
|
||||
}
|
||||
|
||||
return Collections.unmodifiableList(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the set of all bone parts owned by any item in the resolved list.
|
||||
* Used to disable owned parts on the context layer.
|
||||
*/
|
||||
public static Set<String> computeAllOwnedParts(List<V2ItemAnimInfo> items) {
|
||||
Set<String> allOwned = new HashSet<>();
|
||||
for (V2ItemAnimInfo item : items) {
|
||||
allOwned.addAll(item.ownedParts());
|
||||
}
|
||||
return Collections.unmodifiableSet(allOwned);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.state.HumanChairHelper;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.EventPriority;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Handles DOG and HUMAN_CHAIR pose rendering adjustments.
|
||||
*
|
||||
* <p>Applies vertical offset and smooth body rotation for DOG/HUMAN_CHAIR poses.
|
||||
* Runs at HIGH priority to ensure transforms are applied before other Pre handlers.
|
||||
*
|
||||
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class DogPoseRenderHandler {
|
||||
|
||||
/**
|
||||
* DOG pose state tracking per player.
|
||||
* Stores: [0: smoothedTarget, 1: currentRot, 2: appliedDelta, 3: isMoving (0/1)]
|
||||
*/
|
||||
private static final Int2ObjectMap<float[]> dogPoseState =
|
||||
new Int2ObjectOpenHashMap<>();
|
||||
|
||||
// Array indices for dogPoseState
|
||||
private static final int IDX_TARGET = 0;
|
||||
private static final int IDX_CURRENT = 1;
|
||||
private static final int IDX_DELTA = 2;
|
||||
private static final int IDX_MOVING = 3;
|
||||
|
||||
/**
|
||||
* Get the rotation delta applied to a player's render for DOG pose.
|
||||
* Used by MixinPlayerModel to compensate head rotation.
|
||||
*/
|
||||
public static float getAppliedRotationDelta(int playerId) {
|
||||
float[] state = dogPoseState.get(playerId);
|
||||
return state != null ? state[IDX_DELTA] : 0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a player is currently moving in DOG pose.
|
||||
*/
|
||||
public static boolean isDogPoseMoving(int playerId) {
|
||||
float[] state = dogPoseState.get(playerId);
|
||||
return state != null && state[IDX_MOVING] > 0.5f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all DOG pose state data.
|
||||
* Called on world unload to prevent memory leaks.
|
||||
*/
|
||||
public static void clearState() {
|
||||
dogPoseState.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Before player render: Apply vertical offset and rotation for DOG/HUMAN_CHAIR poses.
|
||||
* HIGH priority ensures this runs before arm/item hiding handlers.
|
||||
*/
|
||||
@SubscribeEvent(priority = EventPriority.HIGH)
|
||||
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.isRemoved() || !player.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ItemStack bindForPose = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (
|
||||
bindForPose.isEmpty() ||
|
||||
!(bindForPose.getItem() instanceof ItemBind itemBind)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
PoseType bindPoseType = itemBind.getPoseType();
|
||||
// Check for humanChairMode NBT override
|
||||
bindPoseType = HumanChairHelper.resolveEffectivePose(
|
||||
bindPoseType,
|
||||
bindForPose
|
||||
);
|
||||
|
||||
if (
|
||||
bindPoseType != PoseType.DOG && bindPoseType != PoseType.HUMAN_CHAIR
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Lower player by 6 model units (6/16 = 0.375 blocks)
|
||||
event
|
||||
.getPoseStack()
|
||||
.translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0);
|
||||
|
||||
int playerId = player.getId();
|
||||
net.minecraft.world.phys.Vec3 movement = player.getDeltaMovement();
|
||||
boolean isMoving = movement.horizontalDistanceSqr() > 0.0001;
|
||||
|
||||
// Get or create state - initialize to current body rotation
|
||||
float[] s = dogPoseState.get(playerId);
|
||||
if (s == null) {
|
||||
s = new float[] { player.yBodyRot, player.yBodyRot, 0f, 0f };
|
||||
dogPoseState.put(playerId, s);
|
||||
}
|
||||
|
||||
// Human chair: lock rotation state — body must not turn
|
||||
if (bindPoseType == PoseType.HUMAN_CHAIR) {
|
||||
s[IDX_CURRENT] = player.yBodyRot;
|
||||
s[IDX_TARGET] = player.yBodyRot;
|
||||
s[IDX_DELTA] = 0f;
|
||||
s[IDX_MOVING] = 0f;
|
||||
} else {
|
||||
// Determine target rotation
|
||||
float rawTarget;
|
||||
if (isMoving) {
|
||||
// Moving: face movement direction
|
||||
rawTarget = (float) Math.toDegrees(
|
||||
Math.atan2(-movement.x, movement.z)
|
||||
);
|
||||
} else {
|
||||
// Stationary: face where head is looking
|
||||
rawTarget = player.yHeadRot;
|
||||
}
|
||||
|
||||
// Check if head would be clamped (body lagging behind head)
|
||||
float predictedHeadYaw = net.minecraft.util.Mth.wrapDegrees(
|
||||
player.yHeadRot - s[IDX_CURRENT]
|
||||
);
|
||||
float maxYaw = isMoving
|
||||
? RenderConstants.HEAD_MAX_YAW_MOVING
|
||||
: RenderConstants.HEAD_MAX_YAW_STATIONARY;
|
||||
boolean headAtLimit =
|
||||
Math.abs(predictedHeadYaw) >
|
||||
maxYaw * RenderConstants.HEAD_AT_LIMIT_RATIO;
|
||||
|
||||
if (headAtLimit && !isMoving) {
|
||||
// Head at limit while stationary: snap body to release head
|
||||
float sign = predictedHeadYaw > 0 ? 1f : -1f;
|
||||
s[IDX_CURRENT] =
|
||||
player.yHeadRot -
|
||||
sign * maxYaw * RenderConstants.HEAD_SNAP_RELEASE_RATIO;
|
||||
s[IDX_TARGET] = s[IDX_CURRENT];
|
||||
} else {
|
||||
// Normal smoothing
|
||||
float targetDelta = net.minecraft.util.Mth.wrapDegrees(
|
||||
rawTarget - s[IDX_TARGET]
|
||||
);
|
||||
float targetSpeed = isMoving
|
||||
? RenderConstants.DOG_TARGET_SPEED_MOVING
|
||||
: RenderConstants.DOG_TARGET_SPEED_STATIONARY;
|
||||
s[IDX_TARGET] += targetDelta * targetSpeed;
|
||||
|
||||
float rotDelta = net.minecraft.util.Mth.wrapDegrees(
|
||||
s[IDX_TARGET] - s[IDX_CURRENT]
|
||||
);
|
||||
float speed = isMoving
|
||||
? RenderConstants.DOG_ROT_SPEED_MOVING
|
||||
: RenderConstants.DOG_ROT_SPEED_STATIONARY;
|
||||
s[IDX_CURRENT] += rotDelta * speed;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate and store the delta we apply to poseStack
|
||||
s[IDX_DELTA] = player.yBodyRot - s[IDX_CURRENT];
|
||||
s[IDX_MOVING] = isMoving ? 1f : 0f;
|
||||
|
||||
// Apply rotation to make body face our custom direction
|
||||
event
|
||||
.getPoseStack()
|
||||
.mulPose(com.mojang.math.Axis.YP.rotationDegrees(s[IDX_DELTA]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderHandEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Hide first-person hand/item rendering based on bondage state.
|
||||
*
|
||||
* Behavior:
|
||||
* - Tied up: Hide hands completely (hands are behind back)
|
||||
* - Mittens: Hide hands + items (Forge limitation - can't separate them)
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class FirstPersonHandHideHandler {
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onRenderHand(RenderHandEvent event) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null) {
|
||||
return;
|
||||
}
|
||||
LocalPlayer player = mc.player;
|
||||
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tied or Mittens: hide hands completely
|
||||
// (Forge limitation: RenderHandEvent controls hand + item together)
|
||||
if (state.isTiedUp() || state.hasMittens()) {
|
||||
event.setCanceled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Hides held items when player has arms bound or is wearing mittens.
|
||||
*
|
||||
* <p>Uses Pre/Post pattern to temporarily replace held items with empty
|
||||
* stacks for rendering, then restore them after.
|
||||
*
|
||||
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class HeldItemHideHandler {
|
||||
|
||||
/**
|
||||
* Stored items to restore after rendering.
|
||||
* Key: Player entity ID (int), Value: [mainHand, offHand]
|
||||
*/
|
||||
private static final Int2ObjectMap<ItemStack[]> storedItems =
|
||||
new Int2ObjectOpenHashMap<>();
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.isRemoved() || !player.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean hasArmsBound = state.hasArmsBound();
|
||||
boolean hasMittens = state.hasMittens();
|
||||
|
||||
if (hasArmsBound || hasMittens) {
|
||||
ItemStack mainHand = player.getItemInHand(
|
||||
InteractionHand.MAIN_HAND
|
||||
);
|
||||
ItemStack offHand = player.getItemInHand(InteractionHand.OFF_HAND);
|
||||
|
||||
if (!mainHand.isEmpty() || !offHand.isEmpty()) {
|
||||
storedItems.put(
|
||||
player.getId(),
|
||||
new ItemStack[] { mainHand.copy(), offHand.copy() }
|
||||
);
|
||||
|
||||
player.setItemInHand(
|
||||
InteractionHand.MAIN_HAND,
|
||||
ItemStack.EMPTY
|
||||
);
|
||||
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ItemStack[] items = storedItems.remove(player.getId());
|
||||
if (items != null) {
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, items[0]);
|
||||
player.setItemInHand(InteractionHand.OFF_HAND, items[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.client.state.PetBedClientState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.state.HumanChairHelper;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.EventPriority;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Handles pet bed render adjustments (SIT and SLEEP modes).
|
||||
*
|
||||
* <p>Applies vertical offset and forced standing pose for pet bed states.
|
||||
* Runs at HIGH priority alongside DogPoseRenderHandler.
|
||||
*
|
||||
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class PetBedRenderHandler {
|
||||
|
||||
/**
|
||||
* Before player render: Apply vertical offset and forced pose for pet bed.
|
||||
*/
|
||||
@SubscribeEvent(priority = EventPriority.HIGH)
|
||||
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.isRemoved() || !player.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
java.util.UUID petBedUuid = player.getUUID();
|
||||
byte petBedMode = PetBedClientState.get(petBedUuid);
|
||||
|
||||
if (petBedMode == 1 || petBedMode == 2) {
|
||||
// Skip Y-offset if DogPoseRenderHandler already applies it
|
||||
// (DOG/HUMAN_CHAIR pose uses the same offset amount)
|
||||
if (!isDogOrChairPose(player)) {
|
||||
event
|
||||
.getPoseStack()
|
||||
.translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0);
|
||||
}
|
||||
}
|
||||
if (petBedMode == 2) {
|
||||
// SLEEP: force STANDING pose to prevent vanilla sleeping rotation
|
||||
player.setForcedPose(net.minecraft.world.entity.Pose.STANDING);
|
||||
|
||||
// Compensate for vanilla sleeping Y offset
|
||||
player
|
||||
.getSleepingPos()
|
||||
.ifPresent(pos -> {
|
||||
double yOffset = player.getY() - pos.getY();
|
||||
if (yOffset > 0.01) {
|
||||
event.getPoseStack().translate(0, -yOffset, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the player is in DOG or HUMAN_CHAIR pose.
|
||||
* Used to avoid double Y-offset with DogPoseRenderHandler.
|
||||
*/
|
||||
private static boolean isDogOrChairPose(Player player) {
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) return false;
|
||||
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (
|
||||
bind.isEmpty() || !(bind.getItem() instanceof ItemBind itemBind)
|
||||
) return false;
|
||||
PoseType pose = HumanChairHelper.resolveEffectivePose(
|
||||
itemBind.getPoseType(),
|
||||
bind
|
||||
);
|
||||
return pose == PoseType.DOG || pose == PoseType.HUMAN_CHAIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* After player render: Restore forced pose for pet bed SLEEP mode.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
byte petBedMode = PetBedClientState.get(player.getUUID());
|
||||
if (petBedMode == 2) {
|
||||
player.setForcedPose(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.client.renderer.layers.ClothesRenderHelper;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.items.clothes.ClothesProperties;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||
import net.minecraft.client.model.PlayerModel;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Hide player arms and outer layers based on bondage/clothes state.
|
||||
*
|
||||
* <p>Responsibilities (after extraction of dog pose, pet bed, and held items):
|
||||
* <ul>
|
||||
* <li>Hide arms for wrap/latex_sack poses</li>
|
||||
* <li>Hide outer layers (hat, jacket, sleeves, pants) based on clothes settings</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Uses Pre/Post pattern to temporarily modify and restore state.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class PlayerArmHideEventHandler {
|
||||
|
||||
/**
|
||||
* Stored layer visibility to restore after rendering.
|
||||
* Key: Player entity ID (int), Value: [hat, jacket, leftSleeve, rightSleeve, leftPants, rightPants]
|
||||
*/
|
||||
private static final Int2ObjectMap<boolean[]> storedLayers =
|
||||
new Int2ObjectOpenHashMap<>();
|
||||
|
||||
/**
|
||||
* Before player render:
|
||||
* - Hide arms for wrap/latex_sack poses
|
||||
* - Hide outer layers based on clothes settings (Phase 19)
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer clientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.isRemoved() || !player.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerModel<?> model = event.getRenderer().getModel();
|
||||
|
||||
// === HIDE ARMS (wrap/latex_sack poses) ===
|
||||
if (state.hasArmsBound()) {
|
||||
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (
|
||||
!bind.isEmpty() && bind.getItem() instanceof ItemBind itemBind
|
||||
) {
|
||||
PoseType poseType = itemBind.getPoseType();
|
||||
|
||||
// Only hide arms for wrap/sack poses (arms are covered by the item)
|
||||
if (
|
||||
poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK
|
||||
) {
|
||||
model.leftArm.visible = false;
|
||||
model.rightArm.visible = false;
|
||||
model.leftSleeve.visible = false;
|
||||
model.rightSleeve.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === HIDE WEARER LAYERS (clothes settings) - Phase 19 ===
|
||||
ItemStack clothes = state.getEquipment(BodyRegionV2.TORSO);
|
||||
if (!clothes.isEmpty()) {
|
||||
ClothesProperties props =
|
||||
ClothesRenderHelper.getPropsForLayerHiding(
|
||||
clothes,
|
||||
clientPlayer
|
||||
);
|
||||
if (props != null) {
|
||||
boolean[] savedLayers = ClothesRenderHelper.hideWearerLayers(
|
||||
model,
|
||||
props
|
||||
);
|
||||
if (savedLayers != null) {
|
||||
storedLayers.put(player.getId(), savedLayers);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After player render: Restore arm visibility and layer visibility.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerModel<?> model = event.getRenderer().getModel();
|
||||
|
||||
// === RESTORE ARM VISIBILITY ===
|
||||
model.leftArm.visible = true;
|
||||
model.rightArm.visible = true;
|
||||
model.leftSleeve.visible = true;
|
||||
model.rightSleeve.visible = true;
|
||||
|
||||
// === RESTORE WEARER LAYERS - Phase 19 ===
|
||||
boolean[] savedLayers = storedLayers.remove(player.getId());
|
||||
if (savedLayers != null) {
|
||||
ClothesRenderHelper.restoreWearerLayers(model, savedLayers);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Centralizes magic numbers used across render handlers.
|
||||
*
|
||||
* <p>DOG pose rotation smoothing, head clamp limits, and vertical offsets
|
||||
* that were previously scattered as unnamed literals.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class RenderConstants {
|
||||
|
||||
private RenderConstants() {}
|
||||
|
||||
// === DOG pose rotation smoothing speeds ===
|
||||
|
||||
/** Speed for smoothing body rotation toward target while moving */
|
||||
public static final float DOG_ROT_SPEED_MOVING = 0.15f;
|
||||
|
||||
/** Speed for smoothing body rotation toward target while stationary */
|
||||
public static final float DOG_ROT_SPEED_STATIONARY = 0.12f;
|
||||
|
||||
/** Speed for smoothing target rotation while moving */
|
||||
public static final float DOG_TARGET_SPEED_MOVING = 0.2f;
|
||||
|
||||
/** Speed for smoothing target rotation while stationary */
|
||||
public static final float DOG_TARGET_SPEED_STATIONARY = 0.3f;
|
||||
|
||||
// === Head clamp limits ===
|
||||
|
||||
/** Maximum head yaw relative to body while moving (degrees) */
|
||||
public static final float HEAD_MAX_YAW_MOVING = 60f;
|
||||
|
||||
/** Maximum head yaw relative to body while stationary (degrees) */
|
||||
public static final float HEAD_MAX_YAW_STATIONARY = 90f;
|
||||
|
||||
/** Threshold ratio for detecting head-at-limit (triggers body snap) */
|
||||
public static final float HEAD_AT_LIMIT_RATIO = 0.85f;
|
||||
|
||||
/** Ratio of max yaw to snap body to when releasing head */
|
||||
public static final float HEAD_SNAP_RELEASE_RATIO = 0.7f;
|
||||
|
||||
// === Vertical offsets (model units, 16 = 1 block) ===
|
||||
|
||||
/** Y offset for DOG and PET BED poses (6/16 = 0.375 blocks) */
|
||||
public static final double DOG_AND_PETBED_Y_OFFSET = -6.0 / 16.0;
|
||||
|
||||
/** Y offset for Damsel sitting pose (model units) */
|
||||
public static final float DAMSEL_SIT_OFFSET = -10.0f;
|
||||
|
||||
/** Y offset for Damsel kneeling pose (model units) */
|
||||
public static final float DAMSEL_KNEEL_OFFSET = -5.0f;
|
||||
|
||||
/** Y offset for Damsel dog pose (model units) */
|
||||
public static final float DAMSEL_DOG_OFFSET = -7.0f;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.tiedup.remake.client.animation.tick;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import com.tiedup.remake.client.animation.AnimationStateRegistry;
|
||||
import com.tiedup.remake.client.animation.BondageAnimationManager;
|
||||
import com.tiedup.remake.client.animation.PendingAnimationManager;
|
||||
import com.tiedup.remake.client.animation.util.AnimationIdBuilder;
|
||||
import com.tiedup.remake.client.events.CellHighlightHandler;
|
||||
import com.tiedup.remake.client.events.LeashProxyClientHandler;
|
||||
import com.tiedup.remake.client.gltf.GltfAnimationApplier;
|
||||
import com.tiedup.remake.client.state.ClothesClientCache;
|
||||
import com.tiedup.remake.client.state.MovementStyleClientState;
|
||||
import com.tiedup.remake.client.state.PetBedClientState;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
|
||||
import com.tiedup.remake.client.animation.context.AnimationContext;
|
||||
import com.tiedup.remake.client.animation.context.AnimationContextResolver;
|
||||
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
|
||||
import com.tiedup.remake.state.HumanChairHelper;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.event.TickEvent;
|
||||
import net.minecraftforge.event.entity.player.PlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Event handler for player animation tick updates.
|
||||
*
|
||||
* <p>Simplified handler that:
|
||||
* <ul>
|
||||
* <li>Tracks tied/struggling/sneaking state for players</li>
|
||||
* <li>Plays animations via BondageAnimationManager when state changes</li>
|
||||
* <li>Handles cleanup on logout/world unload</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Registered on the FORGE event bus (not MOD bus).
|
||||
*/
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = "tiedup",
|
||||
value = Dist.CLIENT,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE
|
||||
)
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class AnimationTickHandler {
|
||||
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
|
||||
/** Tick counter for periodic cleanup tasks */
|
||||
private static int cleanupTickCounter = 0;
|
||||
|
||||
/**
|
||||
* Client tick event - called every tick on the client.
|
||||
* Updates animations for all players when their bondage state changes.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||
if (event.phase != TickEvent.Phase.END) {
|
||||
return;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.level == null || mc.isPaused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process pending animations first (retry failed animations for remote players)
|
||||
PendingAnimationManager.processPending(mc.level);
|
||||
|
||||
// Periodic cleanup of stale cache entries (every 60 seconds = 1200 ticks)
|
||||
if (++cleanupTickCounter >= 1200) {
|
||||
cleanupTickCounter = 0;
|
||||
ClothesClientCache.cleanupStale();
|
||||
}
|
||||
|
||||
// Then update all player animations
|
||||
for (Player player : mc.level.players()) {
|
||||
if (player instanceof AbstractClientPlayer clientPlayer) {
|
||||
updatePlayerAnimation(clientPlayer);
|
||||
}
|
||||
// Safety: remove stale furniture animations for players no longer on seats
|
||||
BondageAnimationManager.tickFurnitureSafety(player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update animation for a single player.
|
||||
*/
|
||||
private static void updatePlayerAnimation(AbstractClientPlayer player) {
|
||||
// Safety check: skip for removed/dead players
|
||||
if (player.isRemoved() || !player.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
UUID uuid = player.getUUID();
|
||||
|
||||
// Check if player has ANY V2 bondage item equipped (not just ARMS).
|
||||
// isTiedUp() only checks ARMS, but items on LEGS, HEAD, etc. also need animation.
|
||||
boolean isTied = state != null && (state.isTiedUp()
|
||||
|| V2EquipmentHelper.hasAnyEquipment(player));
|
||||
boolean wasTied =
|
||||
AnimationStateRegistry.getLastTiedState().getOrDefault(uuid, false);
|
||||
|
||||
// Pet bed animations take priority over bondage animations
|
||||
if (PetBedClientState.get(uuid) != 0) {
|
||||
// Lock body rotation to bed facing (prevents camera from rotating the model)
|
||||
float lockedRot = PetBedClientState.getFacing(uuid);
|
||||
player.yBodyRot = lockedRot;
|
||||
player.yBodyRotO = lockedRot;
|
||||
|
||||
// Clamp head rotation to ±50° from body (like vehicle)
|
||||
float headRot = player.getYHeadRot();
|
||||
float clamped =
|
||||
lockedRot +
|
||||
net.minecraft.util.Mth.clamp(
|
||||
net.minecraft.util.Mth.wrapDegrees(headRot - lockedRot),
|
||||
-50f,
|
||||
50f
|
||||
);
|
||||
player.setYHeadRot(clamped);
|
||||
player.yHeadRotO = clamped;
|
||||
|
||||
AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
|
||||
return;
|
||||
}
|
||||
|
||||
// Human chair: clamp 1st-person camera only (body lock handled by MixinLivingEntityBodyRot)
|
||||
// NO return — animation HUMAN_CHAIR must continue playing below
|
||||
if (isTied && state != null) {
|
||||
ItemStack chairBind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (HumanChairHelper.isActive(chairBind)) {
|
||||
// 1st person only: clamp yRot so player can't look behind
|
||||
// 3rd person: yRot untouched → camera orbits freely 360°
|
||||
if (
|
||||
player == Minecraft.getInstance().player &&
|
||||
Minecraft.getInstance().options.getCameraType() ==
|
||||
net.minecraft.client.CameraType.FIRST_PERSON
|
||||
) {
|
||||
float lockedRot = HumanChairHelper.getFacing(chairBind);
|
||||
float camClamped =
|
||||
lockedRot +
|
||||
net.minecraft.util.Mth.clamp(
|
||||
net.minecraft.util.Mth.wrapDegrees(
|
||||
player.getYRot() - lockedRot
|
||||
),
|
||||
-90f,
|
||||
90f
|
||||
);
|
||||
player.setYRot(camClamped);
|
||||
player.yRotO =
|
||||
lockedRot +
|
||||
net.minecraft.util.Mth.clamp(
|
||||
net.minecraft.util.Mth.wrapDegrees(
|
||||
player.yRotO - lockedRot
|
||||
),
|
||||
-90f,
|
||||
90f
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTied) {
|
||||
// Resolve V2 equipped items
|
||||
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(player);
|
||||
Map<BodyRegionV2, ItemStack> equipped = equipment != null
|
||||
? equipment.getAllEquipped() : Map.of();
|
||||
|
||||
// Resolve ALL V2 items with GLB models and per-item bone ownership
|
||||
java.util.List<RegionBoneMapper.V2ItemAnimInfo> v2Items =
|
||||
RegionBoneMapper.resolveAllV2Items(equipped);
|
||||
|
||||
if (!v2Items.isEmpty()) {
|
||||
// V2 path: multi-item composite animation
|
||||
java.util.Set<String> allOwnedParts = RegionBoneMapper.computeAllOwnedParts(v2Items);
|
||||
MovementStyle activeStyle = MovementStyleClientState.get(player.getUUID());
|
||||
AnimationContext context = AnimationContextResolver.resolve(player, state, activeStyle);
|
||||
GltfAnimationApplier.applyMultiItemV2Animation(player, v2Items, context, allOwnedParts);
|
||||
// Clear V1 tracking so transition back works
|
||||
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||
} else {
|
||||
// V1 fallback
|
||||
if (GltfAnimationApplier.hasActiveState(player)) {
|
||||
GltfAnimationApplier.clearV2Animation(player);
|
||||
}
|
||||
String animId = buildAnimationId(player, state);
|
||||
String lastId = AnimationStateRegistry.getLastAnimId().get(uuid);
|
||||
if (!animId.equals(lastId)) {
|
||||
boolean success = BondageAnimationManager.playAnimation(player, animId);
|
||||
if (success) {
|
||||
AnimationStateRegistry.getLastAnimId().put(uuid, animId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (wasTied) {
|
||||
// Was tied, now free - stop all animations
|
||||
if (GltfAnimationApplier.hasActiveState(player)) {
|
||||
GltfAnimationApplier.clearV2Animation(player);
|
||||
} else {
|
||||
BondageAnimationManager.stopAnimation(player);
|
||||
}
|
||||
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||
}
|
||||
|
||||
AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build animation ID from player's current state (V1 path).
|
||||
*/
|
||||
private static String buildAnimationId(
|
||||
Player player,
|
||||
PlayerBindState state
|
||||
) {
|
||||
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
PoseType poseType = PoseType.STANDARD;
|
||||
|
||||
if (bind.getItem() instanceof ItemBind itemBind) {
|
||||
poseType = itemBind.getPoseType();
|
||||
|
||||
// Human chair mode: override DOG pose to HUMAN_CHAIR (straight limbs)
|
||||
poseType = HumanChairHelper.resolveEffectivePose(poseType, bind);
|
||||
}
|
||||
|
||||
// Derive bound state from V2 regions (works client-side, synced via capability)
|
||||
boolean armsBound = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS);
|
||||
boolean legsBound = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.LEGS);
|
||||
|
||||
// V1 fallback: if no V2 regions are set but player is tied, derive from ItemBind NBT
|
||||
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) {
|
||||
armsBound = ItemBind.hasArmsBound(bind);
|
||||
legsBound = ItemBind.hasLegsBound(bind);
|
||||
}
|
||||
|
||||
boolean isStruggling = state.isStruggling();
|
||||
boolean isSneaking = player.isCrouching();
|
||||
boolean isMoving =
|
||||
player.getDeltaMovement().horizontalDistanceSqr() > 1e-6;
|
||||
|
||||
// Build animation ID with sneak and movement support
|
||||
return AnimationIdBuilder.build(
|
||||
poseType,
|
||||
armsBound,
|
||||
legsBound,
|
||||
null,
|
||||
isStruggling,
|
||||
true,
|
||||
isSneaking,
|
||||
isMoving
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Player logout event - cleanup animation data.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||
if (event.getEntity().level().isClientSide()) {
|
||||
UUID uuid = event.getEntity().getUUID();
|
||||
AnimationStateRegistry.getLastTiedState().remove(uuid);
|
||||
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||
BondageAnimationManager.cleanup(uuid);
|
||||
GltfAnimationApplier.removeTracking(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* World unload event - clear all animation and cache data.
|
||||
* FIX: Now also clears client-side caches to prevent memory leaks and stale data.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onWorldUnload(
|
||||
net.minecraftforge.event.level.LevelEvent.Unload event
|
||||
) {
|
||||
if (event.getLevel().isClientSide()) {
|
||||
// Animation state (includes BondageAnimationManager, PendingAnimationManager,
|
||||
// DogPoseRenderHandler, MCAAnimationTickCache)
|
||||
// AnimationStateRegistry.clearAll() handles GltfAnimationApplier.clearAll() transitively
|
||||
AnimationStateRegistry.clearAll();
|
||||
|
||||
// Non-animation client-side caches
|
||||
PetBedClientState.clearAll();
|
||||
MovementStyleClientState.clearAll();
|
||||
com.tiedup.remake.client.state.CollarRegistryClient.clear();
|
||||
CellHighlightHandler.clearCache();
|
||||
LeashProxyClientHandler.clearAll();
|
||||
com.tiedup.remake.client.state.ClientLaborState.clearTask();
|
||||
com.tiedup.remake.client.state.ClothesClientCache.clearAll();
|
||||
com.tiedup.remake.client.texture.DynamicTextureManager.getInstance().clearAll();
|
||||
|
||||
// C1: Player bind state client instances (prevents stale Player references)
|
||||
PlayerBindState.clearClientInstances();
|
||||
|
||||
// C2: Armor stand bondage data (entity IDs are not stable across worlds)
|
||||
com.tiedup.remake.entities.armorstand.ArmorStandBondageClientCache.clear();
|
||||
|
||||
// C3: Furniture GLB model cache (resource-backed, also cleared on F3+T reload)
|
||||
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear();
|
||||
|
||||
LOGGER.debug(
|
||||
"Cleared all animation and cache data due to world unload"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.tiedup.remake.client.animation.tick;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Cache for MCA villager animation tick tracking.
|
||||
* Used by MixinVillagerEntityBaseModelMCA to prevent animations from ticking
|
||||
* multiple times per game tick.
|
||||
*
|
||||
* <p>This is extracted from the mixin so it can be cleared on world unload
|
||||
* to prevent memory leaks.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class MCAAnimationTickCache {
|
||||
|
||||
private static final Map<UUID, Integer> lastTickMap = new HashMap<>();
|
||||
|
||||
private MCAAnimationTickCache() {
|
||||
// Utility class
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last tick value for an entity.
|
||||
* @param uuid Entity UUID
|
||||
* @return Last tick value, or -1 if not cached
|
||||
*/
|
||||
public static int getLastTick(UUID uuid) {
|
||||
return lastTickMap.getOrDefault(uuid, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the last tick value for an entity.
|
||||
* @param uuid Entity UUID
|
||||
* @param tick Current tick value
|
||||
*/
|
||||
public static void setLastTick(UUID uuid, int tick) {
|
||||
lastTickMap.put(uuid, tick);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data.
|
||||
* Called on world unload to prevent memory leaks.
|
||||
*/
|
||||
public static void clear() {
|
||||
lastTickMap.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.tiedup.remake.client.animation.tick;
|
||||
|
||||
import com.tiedup.remake.client.animation.BondageAnimationManager;
|
||||
import com.tiedup.remake.client.animation.context.AnimationContext;
|
||||
import com.tiedup.remake.client.animation.context.AnimationContextResolver;
|
||||
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
|
||||
import com.tiedup.remake.client.animation.util.AnimationIdBuilder;
|
||||
import com.tiedup.remake.client.gltf.GltfAnimationApplier;
|
||||
import com.tiedup.remake.entities.AbstractTiedUpNpc;
|
||||
import com.tiedup.remake.entities.EntityMaster;
|
||||
import com.tiedup.remake.entities.ai.master.MasterState;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.event.TickEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Tick handler for NPC (AbstractTiedUpNpc) bondage animations.
|
||||
*
|
||||
* <p>Same pattern as AnimationTickHandler for players, but for loaded
|
||||
* AbstractTiedUpNpc instances. Tracks last animation ID per NPC UUID and
|
||||
* triggers BondageAnimationManager.playAnimation() on state changes.
|
||||
*
|
||||
* <p>Extracted from DamselModel.setupAnim() to decouple animation
|
||||
* triggering from model rendering.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = "tiedup",
|
||||
value = Dist.CLIENT,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE
|
||||
)
|
||||
public class NpcAnimationTickHandler {
|
||||
|
||||
/** Track last animation ID per NPC to avoid redundant updates */
|
||||
private static final Map<UUID, String> lastNpcAnimId = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Client tick: update animations for all loaded AbstractTiedUpNpc instances.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||
if (event.phase != TickEvent.Phase.END) {
|
||||
return;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.level == null || mc.isPaused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Entity entity : mc.level.entitiesForRendering()) {
|
||||
if (
|
||||
entity instanceof AbstractTiedUpNpc damsel &&
|
||||
entity.isAlive() &&
|
||||
!entity.isRemoved()
|
||||
) {
|
||||
updateNpcAnimation(damsel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update animation for a single NPC.
|
||||
*
|
||||
* <p>Dual-layer V2 path: if the highest-priority equipped V2 item has a GLB model,
|
||||
* uses {@link GltfAnimationApplier#applyV2Animation} which plays a context layer
|
||||
* (base posture) and an item layer (GLB-driven bones). Sitting and kneeling are
|
||||
* handled by the context resolver, so the V2 path now covers all postures.
|
||||
*
|
||||
* <p>V1 fallback: if no V2 GLB model is found, falls back to JSON-based
|
||||
* PlayerAnimator animations via {@link BondageAnimationManager}.
|
||||
*/
|
||||
private static void updateNpcAnimation(AbstractTiedUpNpc entity) {
|
||||
boolean inPose =
|
||||
entity.isTiedUp() || entity.isSitting() || entity.isKneeling();
|
||||
|
||||
UUID uuid = entity.getUUID();
|
||||
|
||||
if (inPose) {
|
||||
// Resolve V2 equipment map
|
||||
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(entity);
|
||||
Map<BodyRegionV2, net.minecraft.world.item.ItemStack> equipped = equipment != null
|
||||
? equipment.getAllEquipped() : Map.of();
|
||||
RegionBoneMapper.GlbModelResult glbResult = RegionBoneMapper.resolveWinningItem(equipped);
|
||||
|
||||
if (glbResult != null) {
|
||||
// V2 path: dual-layer animation with per-item bone ownership
|
||||
RegionBoneMapper.BoneOwnership ownership =
|
||||
RegionBoneMapper.computePerItemParts(equipped, glbResult.winningItem());
|
||||
AnimationContext context = AnimationContextResolver.resolveNpc(entity);
|
||||
GltfAnimationApplier.applyV2Animation(entity, glbResult.modelLoc(),
|
||||
glbResult.animSource(), context, ownership);
|
||||
lastNpcAnimId.remove(uuid);
|
||||
} else {
|
||||
// V1 fallback: JSON-based PlayerAnimator animations
|
||||
if (GltfAnimationApplier.hasActiveState(entity)) {
|
||||
GltfAnimationApplier.clearV2Animation(entity);
|
||||
}
|
||||
|
||||
String animId = buildNpcAnimationId(entity);
|
||||
String lastId = lastNpcAnimId.get(uuid);
|
||||
if (!animId.equals(lastId)) {
|
||||
BondageAnimationManager.playAnimation(entity, animId);
|
||||
lastNpcAnimId.put(uuid, animId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (lastNpcAnimId.containsKey(uuid) || GltfAnimationApplier.hasActiveState(entity)) {
|
||||
if (GltfAnimationApplier.hasActiveState(entity)) {
|
||||
GltfAnimationApplier.clearV2Animation(entity);
|
||||
} else {
|
||||
BondageAnimationManager.stopAnimation(entity);
|
||||
}
|
||||
lastNpcAnimId.remove(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build animation ID for an NPC from its current state (V1 path).
|
||||
*/
|
||||
private static String buildNpcAnimationId(AbstractTiedUpNpc entity) {
|
||||
// Determine position prefix for SIT/KNEEL poses
|
||||
String positionPrefix = null;
|
||||
if (entity.isSitting()) {
|
||||
positionPrefix = "sit";
|
||||
} else if (entity.isKneeling()) {
|
||||
positionPrefix = "kneel";
|
||||
}
|
||||
|
||||
net.minecraft.world.item.ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS);
|
||||
PoseType poseType = PoseType.STANDARD;
|
||||
boolean hasBind = false;
|
||||
|
||||
if (bind.getItem() instanceof ItemBind itemBind) {
|
||||
poseType = itemBind.getPoseType();
|
||||
hasBind = true;
|
||||
}
|
||||
|
||||
// Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder)
|
||||
boolean armsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.ARMS);
|
||||
boolean legsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.LEGS);
|
||||
|
||||
// V1 fallback: if no V2 regions set but NPC has a bind, derive from ItemBind NBT
|
||||
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) {
|
||||
armsBound = ItemBind.hasArmsBound(bind);
|
||||
legsBound = ItemBind.hasLegsBound(bind);
|
||||
}
|
||||
|
||||
boolean isStruggling = entity.isStruggling();
|
||||
boolean isSneaking = entity.isCrouching();
|
||||
boolean isMoving =
|
||||
entity.getDeltaMovement().horizontalDistanceSqr() > 1e-6;
|
||||
|
||||
String animId = AnimationIdBuilder.build(
|
||||
poseType,
|
||||
armsBound,
|
||||
legsBound,
|
||||
positionPrefix,
|
||||
isStruggling,
|
||||
hasBind,
|
||||
isSneaking,
|
||||
isMoving
|
||||
);
|
||||
|
||||
// Master NPC sitting on human chair: use dedicated sitting animation
|
||||
if (
|
||||
entity instanceof EntityMaster masterEntity &&
|
||||
masterEntity.getMasterState() == MasterState.HUMAN_CHAIR &&
|
||||
masterEntity.isSitting()
|
||||
) {
|
||||
animId = "master_chair_sit_idle";
|
||||
}
|
||||
|
||||
return animId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all NPC animation state.
|
||||
* Called on world unload to prevent memory leaks.
|
||||
*/
|
||||
public static void clearAll() {
|
||||
lastNpcAnimId.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package com.tiedup.remake.client.animation.util;
|
||||
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Utility class for building animation ResourceLocation IDs.
|
||||
*
|
||||
* <p>Centralizes the logic for constructing animation file names.
|
||||
* Used by BondageAnimationManager, NpcAnimationTickHandler, and AnimationTickHandler.
|
||||
*
|
||||
* <p>Animation naming convention:
|
||||
* <pre>
|
||||
* {poseType}_{bindMode}_{variant}.json
|
||||
*
|
||||
* poseType: tied_up_basic | straitjacket | wrap | latex_sack
|
||||
* bindMode: (empty for FULL) | _arms | _legs
|
||||
* variant: _idle | _struggle | (empty for static)
|
||||
* </pre>
|
||||
*
|
||||
* <p>Examples:
|
||||
* <ul>
|
||||
* <li>tiedup:tied_up_basic_idle - STANDARD + FULL + idle</li>
|
||||
* <li>tiedup:straitjacket_arms_struggle - STRAITJACKET + ARMS + struggle</li>
|
||||
* <li>tiedup:wrap_idle - WRAP + FULL + idle</li>
|
||||
* </ul>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class AnimationIdBuilder {
|
||||
|
||||
private static final String NAMESPACE = "tiedup";
|
||||
|
||||
// Bind mode suffixes
|
||||
private static final String SUFFIX_ARMS = "_arms";
|
||||
private static final String SUFFIX_LEGS = "_legs";
|
||||
|
||||
// Variant suffixes
|
||||
private static final String SUFFIX_IDLE = "_idle";
|
||||
private static final String SUFFIX_WALK = "_walk";
|
||||
private static final String SUFFIX_STRUGGLE = "_struggle";
|
||||
private static final String SUFFIX_SNEAK = "_sneak";
|
||||
|
||||
private AnimationIdBuilder() {
|
||||
// Utility class - no instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base animation name from pose type.
|
||||
* Delegates to {@link PoseType#getAnimationId()}.
|
||||
*
|
||||
* @param poseType Pose type
|
||||
* @return Base name string
|
||||
*/
|
||||
public static String getBaseName(PoseType poseType) {
|
||||
return poseType.getAnimationId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suffix for bind mode derived from region flags.
|
||||
*
|
||||
* @param armsBound whether ARMS region is occupied
|
||||
* @param legsBound whether LEGS region is occupied
|
||||
* @return Suffix string: "" for FULL (both), "_arms" for arms-only, "_legs" for legs-only
|
||||
*/
|
||||
public static String getModeSuffix(boolean armsBound, boolean legsBound) {
|
||||
if (armsBound && legsBound) return ""; // FULL has no suffix
|
||||
if (armsBound) return SUFFIX_ARMS;
|
||||
if (legsBound) return SUFFIX_LEGS;
|
||||
return ""; // neither bound = no suffix (shouldn't happen in practice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bind type name for SIT/KNEEL animations.
|
||||
* Delegates to {@link PoseType#getBindTypeName()}.
|
||||
*
|
||||
* @param poseType Pose type
|
||||
* @return Bind type name ("basic", "straitjacket", "wrap", "latex_sack")
|
||||
*/
|
||||
public static String getBindTypeName(PoseType poseType) {
|
||||
return poseType.getBindTypeName();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unified Build Method
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Build animation ID string for entities.
|
||||
*
|
||||
* <p>This method handles all cases:
|
||||
* <ul>
|
||||
* <li>Standing poses: tied_up_basic_idle, straitjacket_struggle, etc.</li>
|
||||
* <li>Sitting poses: sit_basic_idle, sit_free_idle, etc.</li>
|
||||
* <li>Kneeling poses: kneel_basic_idle, kneel_wrap_struggle, etc.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.)
|
||||
* @param armsBound whether ARMS region is occupied
|
||||
* @param legsBound whether LEGS region is occupied
|
||||
* @param positionPrefix Position prefix ("sit", "kneel") or null for standing
|
||||
* @param isStruggling Whether entity is struggling
|
||||
* @param hasBind Whether entity has a bind equipped
|
||||
* @return Animation ID string (without namespace)
|
||||
*/
|
||||
public static String build(
|
||||
PoseType poseType,
|
||||
boolean armsBound,
|
||||
boolean legsBound,
|
||||
String positionPrefix,
|
||||
boolean isStruggling,
|
||||
boolean hasBind
|
||||
) {
|
||||
return build(
|
||||
poseType,
|
||||
armsBound,
|
||||
legsBound,
|
||||
positionPrefix,
|
||||
isStruggling,
|
||||
hasBind,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build animation ID string for entities with sneak support.
|
||||
*
|
||||
* @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.)
|
||||
* @param armsBound whether ARMS region is occupied
|
||||
* @param legsBound whether LEGS region is occupied
|
||||
* @param positionPrefix Position prefix ("sit", "kneel") or null for standing
|
||||
* @param isStruggling Whether entity is struggling
|
||||
* @param hasBind Whether entity has a bind equipped
|
||||
* @param isSneaking Whether entity is sneaking
|
||||
* @return Animation ID string (without namespace)
|
||||
*/
|
||||
public static String build(
|
||||
PoseType poseType,
|
||||
boolean armsBound,
|
||||
boolean legsBound,
|
||||
String positionPrefix,
|
||||
boolean isStruggling,
|
||||
boolean hasBind,
|
||||
boolean isSneaking
|
||||
) {
|
||||
return build(
|
||||
poseType,
|
||||
armsBound,
|
||||
legsBound,
|
||||
positionPrefix,
|
||||
isStruggling,
|
||||
hasBind,
|
||||
isSneaking,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build animation ID string for entities with sneak and movement support.
|
||||
*
|
||||
* @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.)
|
||||
* @param armsBound whether ARMS region is occupied
|
||||
* @param legsBound whether LEGS region is occupied
|
||||
* @param positionPrefix Position prefix ("sit", "kneel") or null for standing
|
||||
* @param isStruggling Whether entity is struggling
|
||||
* @param hasBind Whether entity has a bind equipped
|
||||
* @param isSneaking Whether entity is sneaking
|
||||
* @param isMoving Whether entity is moving
|
||||
* @return Animation ID string (without namespace)
|
||||
*/
|
||||
public static String build(
|
||||
PoseType poseType,
|
||||
boolean armsBound,
|
||||
boolean legsBound,
|
||||
String positionPrefix,
|
||||
boolean isStruggling,
|
||||
boolean hasBind,
|
||||
boolean isSneaking,
|
||||
boolean isMoving
|
||||
) {
|
||||
String sneakSuffix = isSneaking ? SUFFIX_SNEAK : "";
|
||||
|
||||
// Determine variant suffix based on state priority: struggle > walk > idle
|
||||
String variantSuffix;
|
||||
if (isStruggling) {
|
||||
variantSuffix = SUFFIX_STRUGGLE;
|
||||
} else if (isMoving && poseType == PoseType.DOG) {
|
||||
// DOG pose has a walking animation (tied_up_dog_walk.json)
|
||||
variantSuffix = SUFFIX_WALK;
|
||||
} else {
|
||||
variantSuffix = SUFFIX_IDLE;
|
||||
}
|
||||
|
||||
// SIT or KNEEL pose
|
||||
if (positionPrefix != null) {
|
||||
if (!hasBind) {
|
||||
// No bind: free pose (arms natural)
|
||||
return positionPrefix + "_free" + sneakSuffix + variantSuffix;
|
||||
}
|
||||
|
||||
// Has bind
|
||||
String bindTypeName;
|
||||
if (legsBound && !armsBound) {
|
||||
// LEGS-only mode = arms free
|
||||
bindTypeName = "legs";
|
||||
} else {
|
||||
// FULL or ARMS mode
|
||||
bindTypeName = getBindTypeName(poseType);
|
||||
}
|
||||
return (
|
||||
positionPrefix +
|
||||
"_" +
|
||||
bindTypeName +
|
||||
sneakSuffix +
|
||||
variantSuffix
|
||||
);
|
||||
}
|
||||
|
||||
// Standing pose (no position prefix)
|
||||
String baseName = getBaseName(poseType);
|
||||
String modeSuffix = getModeSuffix(armsBound, legsBound);
|
||||
|
||||
// LEGS-only mode: only lock legs, arms are free - no idle/struggle variants needed
|
||||
if (legsBound && !armsBound) {
|
||||
return baseName + modeSuffix;
|
||||
}
|
||||
|
||||
return baseName + modeSuffix + sneakSuffix + variantSuffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build animation ResourceLocation for SIT or KNEEL pose.
|
||||
*
|
||||
* @param posePrefix "sit" or "kneel"
|
||||
* @param poseType Bind pose type
|
||||
* @param armsBound whether ARMS region is occupied
|
||||
* @param legsBound whether LEGS region is occupied
|
||||
* @param isStruggling Whether entity is struggling
|
||||
* @return Animation ResourceLocation
|
||||
*/
|
||||
public static ResourceLocation buildPositionAnimation(
|
||||
String posePrefix,
|
||||
PoseType poseType,
|
||||
boolean armsBound,
|
||||
boolean legsBound,
|
||||
boolean isStruggling
|
||||
) {
|
||||
String bindTypeName;
|
||||
if (legsBound && !armsBound) {
|
||||
bindTypeName = "legs";
|
||||
} else {
|
||||
bindTypeName = getBindTypeName(poseType);
|
||||
}
|
||||
|
||||
String variantSuffix = isStruggling ? SUFFIX_STRUGGLE : SUFFIX_IDLE;
|
||||
String animationName = posePrefix + "_" + bindTypeName + variantSuffix;
|
||||
return ResourceLocation.fromNamespaceAndPath(NAMESPACE, animationName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build animation ResourceLocation for SIT or KNEEL pose when NOT bound.
|
||||
*
|
||||
* @param posePrefix "sit" or "kneel"
|
||||
* @return Animation ResourceLocation for free pose
|
||||
*/
|
||||
public static ResourceLocation buildFreePositionAnimation(
|
||||
String posePrefix
|
||||
) {
|
||||
String animationName = posePrefix + "_free" + SUFFIX_IDLE;
|
||||
return ResourceLocation.fromNamespaceAndPath(NAMESPACE, animationName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.tiedup.remake.client.animation.util;
|
||||
|
||||
import net.minecraft.client.model.geom.ModelPart;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Utility class for DOG pose head compensation.
|
||||
*
|
||||
* <h2>Problem</h2>
|
||||
* <p>When in DOG pose, the body is rotated -90° pitch (horizontal, face down).
|
||||
* This makes the head point at the ground. We need to compensate:
|
||||
* <ul>
|
||||
* <li>Head pitch: add -90° offset so head looks forward</li>
|
||||
* <li>Head yaw: convert to zRot (roll) since yRot axis is sideways</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Architecture: Players vs NPCs</h2>
|
||||
* <pre>
|
||||
* ┌─────────────────────────────────────────────────────────────────┐
|
||||
* │ PLAYERS │
|
||||
* ├─────────────────────────────────────────────────────────────────┤
|
||||
* │ 1. PlayerArmHideEventHandler.onRenderPlayerPre() │
|
||||
* │ - Offset vertical (-6 model units) │
|
||||
* │ - Rotation Y lissée (dogPoseState tracking) │
|
||||
* │ │
|
||||
* │ 2. Animation (PlayerAnimator) │
|
||||
* │ - body.pitch = -90° → appliqué au PoseStack automatiquement │
|
||||
* │ │
|
||||
* │ 3. MixinPlayerModel.setupAnim() @TAIL │
|
||||
* │ - Uses DogPoseHelper.applyHeadCompensationClamped() │
|
||||
* └─────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────────┐
|
||||
* │ NPCs │
|
||||
* ├─────────────────────────────────────────────────────────────────┤
|
||||
* │ 1. EntityDamsel.tick() │
|
||||
* │ - Uses RotationSmoother for Y rotation (10% per tick) │
|
||||
* │ │
|
||||
* │ 2. DamselRenderer.setupRotations() │
|
||||
* │ - super.setupRotations() (applique rotation Y) │
|
||||
* │ - Rotation X -90° au PoseStack (APRÈS Y = espace local) │
|
||||
* │ - Offset vertical (-7 model units) │
|
||||
* │ │
|
||||
* │ 3. DamselModel.setupAnim() │
|
||||
* │ - body.xRot = 0 (évite double rotation) │
|
||||
* │ - Uses DogPoseHelper.applyHeadCompensation() │
|
||||
* └─────────────────────────────────────────────────────────────────┘
|
||||
* </pre>
|
||||
*
|
||||
* <h2>Key Differences</h2>
|
||||
* <table>
|
||||
* <tr><th>Aspect</th><th>Players</th><th>NPCs</th></tr>
|
||||
* <tr><td>Rotation X application</td><td>Auto by PlayerAnimator</td><td>Manual in setupRotations()</td></tr>
|
||||
* <tr><td>Rotation Y smoothing</td><td>PlayerArmHideEventHandler</td><td>EntityDamsel.tick() via RotationSmoother</td></tr>
|
||||
* <tr><td>Head compensation</td><td>MixinPlayerModel</td><td>DamselModel.setupAnim()</td></tr>
|
||||
* <tr><td>Reset body.xRot</td><td>Not needed</td><td>Yes (prevents double rotation)</td></tr>
|
||||
* <tr><td>Vertical offset</td><td>-6 model units</td><td>-7 model units</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <h2>Usage</h2>
|
||||
* <p>Used by:
|
||||
* <ul>
|
||||
* <li>MixinPlayerModel - for player head compensation</li>
|
||||
* <li>DamselModel - for NPC head compensation</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see RotationSmoother for Y rotation smoothing
|
||||
* @see com.tiedup.remake.mixin.client.MixinPlayerModel
|
||||
* @see com.tiedup.remake.client.model.DamselModel
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class DogPoseHelper {
|
||||
|
||||
private static final float DEG_TO_RAD = (float) Math.PI / 180F;
|
||||
private static final float HEAD_PITCH_OFFSET = (float) Math.toRadians(-90);
|
||||
|
||||
private DogPoseHelper() {
|
||||
// Utility class - no instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply head compensation for DOG pose (horizontal body).
|
||||
*
|
||||
* <p>When body is horizontal (-90° pitch), the head needs compensation:
|
||||
* <ul>
|
||||
* <li>xRot: -90° offset + player's up/down look (headPitch)</li>
|
||||
* <li>yRot: 0 (this axis points sideways when body is horizontal)</li>
|
||||
* <li>zRot: -headYaw (left/right look, replaces yaw)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param head The head ModelPart to modify
|
||||
* @param hat The hat ModelPart to sync (can be null)
|
||||
* @param headPitch Player's up/down look angle in degrees
|
||||
* @param headYaw Head yaw relative to body in degrees (netHeadYaw for NPCs,
|
||||
* netHeadYaw + rotationDelta for players)
|
||||
*/
|
||||
public static void applyHeadCompensation(
|
||||
ModelPart head,
|
||||
ModelPart hat,
|
||||
float headPitch,
|
||||
float headYaw
|
||||
) {
|
||||
float pitchRad = headPitch * DEG_TO_RAD;
|
||||
float yawRad = headYaw * DEG_TO_RAD;
|
||||
|
||||
// xRot: base offset (-90° to look forward) + player's up/down look
|
||||
head.xRot = HEAD_PITCH_OFFSET + pitchRad;
|
||||
|
||||
// yRot: stays at 0 (this axis points sideways when body is horizontal)
|
||||
head.yRot = 0;
|
||||
|
||||
// zRot: used for left/right look (replaces yaw since body is horizontal)
|
||||
head.zRot = -yawRad;
|
||||
|
||||
// Sync hat layer if provided
|
||||
if (hat != null) {
|
||||
hat.copyFrom(head);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply head compensation with yaw clamping.
|
||||
*
|
||||
* <p>Same as {@link #applyHeadCompensation} but clamps yaw to a maximum angle.
|
||||
* Used for players where yaw range depends on movement state.
|
||||
*
|
||||
* @param head The head ModelPart to modify
|
||||
* @param hat The hat ModelPart to sync (can be null)
|
||||
* @param headPitch Player's up/down look angle in degrees
|
||||
* @param headYaw Head yaw relative to body in degrees
|
||||
* @param maxYaw Maximum allowed yaw angle in degrees
|
||||
*/
|
||||
public static void applyHeadCompensationClamped(
|
||||
ModelPart head,
|
||||
ModelPart hat,
|
||||
float headPitch,
|
||||
float headYaw,
|
||||
float maxYaw
|
||||
) {
|
||||
// Wrap first so 350° becomes -10° before clamping (fixes full-rotation accumulation)
|
||||
float clampedYaw = net.minecraft.util.Mth.clamp(
|
||||
net.minecraft.util.Mth.wrapDegrees(headYaw),
|
||||
-maxYaw,
|
||||
maxYaw
|
||||
);
|
||||
applyHeadCompensation(head, hat, headPitch, clampedYaw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.tiedup.remake.client.events;
|
||||
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import com.tiedup.remake.core.ModConfig;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.entity.HumanoidArm;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
|
||||
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Phase 5: Blindfold Rendering
|
||||
*
|
||||
* Based on the original TiedUp! mod (1.12.2) by Yuti & Marl Velius.
|
||||
*
|
||||
* The original approach:
|
||||
* 1. Render blindfold texture over the entire screen (covers everything)
|
||||
* 2. Manually redraw the hotbar on top of the blindfold
|
||||
*
|
||||
* This ensures the hotbar remains visible while the game world is obscured.
|
||||
*/
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class BlindfoldRenderEventHandler {
|
||||
|
||||
private static final ResourceLocation BLINDFOLD_TEXTURE =
|
||||
ResourceLocation.fromNamespaceAndPath(
|
||||
TiedUpMod.MOD_ID,
|
||||
"textures/misc/blindfolded.png"
|
||||
);
|
||||
|
||||
// Vanilla widgets texture (contains hotbar graphics)
|
||||
private static final ResourceLocation WIDGETS_TEXTURE =
|
||||
ResourceLocation.fromNamespaceAndPath(
|
||||
"minecraft",
|
||||
"textures/gui/widgets.png"
|
||||
);
|
||||
|
||||
private static boolean wasBlindfolded = false;
|
||||
|
||||
/**
|
||||
* Render the blindfold overlay AFTER the hotbar is rendered.
|
||||
* Then redraw the hotbar on top of the blindfold (original mod approach).
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onRenderGuiPost(RenderGuiOverlayEvent.Post event) {
|
||||
// Render after HOTBAR (same as original mod)
|
||||
if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
LocalPlayer player = mc.player;
|
||||
|
||||
// Safety checks
|
||||
if (player == null || mc.options.hideGui) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-hardcore mode: hide blindfold when a GUI screen is open
|
||||
boolean hardcore = ModConfig.CLIENT.hardcoreBlindfold.get();
|
||||
if (!hardcore && mc.screen != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get player state
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean isBlindfolded = state.isBlindfolded();
|
||||
|
||||
// Log state changes only
|
||||
if (isBlindfolded != wasBlindfolded) {
|
||||
if (isBlindfolded) {
|
||||
TiedUpMod.LOGGER.info(
|
||||
"[BLINDFOLD] Player is now blindfolded - rendering overlay"
|
||||
);
|
||||
} else {
|
||||
TiedUpMod.LOGGER.info(
|
||||
"[BLINDFOLD] Player is no longer blindfolded - stopping overlay"
|
||||
);
|
||||
}
|
||||
wasBlindfolded = isBlindfolded;
|
||||
}
|
||||
|
||||
// Only render if blindfolded
|
||||
if (!isBlindfolded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
int screenWidth = mc.getWindow().getGuiScaledWidth();
|
||||
int screenHeight = mc.getWindow().getGuiScaledHeight();
|
||||
|
||||
// Set opacity: hardcore forces full opacity, otherwise use config
|
||||
float opacity = hardcore
|
||||
? 1.0F
|
||||
: ModConfig.CLIENT.blindfoldOverlayOpacity
|
||||
.get()
|
||||
.floatValue();
|
||||
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, opacity);
|
||||
RenderSystem.enableBlend();
|
||||
RenderSystem.defaultBlendFunc();
|
||||
|
||||
// Step 1: Render the blindfold texture over the entire screen
|
||||
event
|
||||
.getGuiGraphics()
|
||||
.blit(
|
||||
BLINDFOLD_TEXTURE,
|
||||
0,
|
||||
0,
|
||||
0.0F,
|
||||
0.0F,
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
screenWidth,
|
||||
screenHeight
|
||||
);
|
||||
|
||||
// Reset shader color for hotbar
|
||||
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F);
|
||||
|
||||
// Step 2: Redraw the hotbar on top of the blindfold (original mod approach)
|
||||
redrawHotbar(
|
||||
mc,
|
||||
event.getGuiGraphics(),
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
player
|
||||
);
|
||||
} catch (RuntimeException e) {
|
||||
TiedUpMod.LOGGER.error("[BLINDFOLD] Error rendering overlay", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually redraw the hotbar on top of the blindfold texture.
|
||||
* Based on the original mod's redrawHotBar() function.
|
||||
*
|
||||
* This draws:
|
||||
* - Hotbar background (182x22 pixels)
|
||||
* - Selected slot highlight
|
||||
* - Offhand slot (if item present)
|
||||
*/
|
||||
private static void redrawHotbar(
|
||||
Minecraft mc,
|
||||
GuiGraphics guiGraphics,
|
||||
int screenWidth,
|
||||
int screenHeight,
|
||||
LocalPlayer player
|
||||
) {
|
||||
// Reset render state
|
||||
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F);
|
||||
RenderSystem.enableBlend();
|
||||
RenderSystem.defaultBlendFunc();
|
||||
|
||||
// Center of screen (hotbar is centered)
|
||||
int centerX = screenWidth / 2;
|
||||
int hotbarY = screenHeight - 22; // Hotbar is 22 pixels from bottom
|
||||
|
||||
// Draw hotbar background (182 pixels wide, 22 pixels tall)
|
||||
// Original: this.drawTexturedModalRect(i - 91, sr.getScaledHeight() - 22, 0, 0, 182, 22);
|
||||
guiGraphics.blit(
|
||||
WIDGETS_TEXTURE,
|
||||
centerX - 91,
|
||||
hotbarY, // Position
|
||||
0,
|
||||
0, // Texture UV start
|
||||
182,
|
||||
22 // Size
|
||||
);
|
||||
|
||||
// Draw selected slot highlight (24x22 pixels)
|
||||
// Original: this.drawTexturedModalRect(i - 91 - 1 + entityplayer.inventory.currentItem * 20, ...);
|
||||
int selectedSlot = player.getInventory().selected;
|
||||
guiGraphics.blit(
|
||||
WIDGETS_TEXTURE,
|
||||
centerX - 91 - 1 + selectedSlot * 20,
|
||||
hotbarY - 1, // Position (offset by selected slot)
|
||||
0,
|
||||
22, // Texture UV (highlight texture)
|
||||
24,
|
||||
22 // Size
|
||||
);
|
||||
|
||||
// Draw offhand slot if player has an item in offhand
|
||||
ItemStack offhandItem = player.getItemInHand(InteractionHand.OFF_HAND);
|
||||
if (!offhandItem.isEmpty()) {
|
||||
HumanoidArm offhandSide = player.getMainArm().getOpposite();
|
||||
|
||||
if (offhandSide == HumanoidArm.LEFT) {
|
||||
// Offhand on left side
|
||||
// Original: this.drawTexturedModalRect(i - 91 - 29, sr.getScaledHeight() - 23, 24, 22, 29, 24);
|
||||
guiGraphics.blit(
|
||||
WIDGETS_TEXTURE,
|
||||
centerX - 91 - 29,
|
||||
hotbarY - 1, // Position
|
||||
24,
|
||||
22, // Texture UV
|
||||
29,
|
||||
24 // Size
|
||||
);
|
||||
} else {
|
||||
// Offhand on right side
|
||||
// Original: this.drawTexturedModalRect(i + 91, sr.getScaledHeight() - 23, 53, 22, 29, 24);
|
||||
guiGraphics.blit(
|
||||
WIDGETS_TEXTURE,
|
||||
centerX + 91,
|
||||
hotbarY - 1, // Position
|
||||
53,
|
||||
22, // Texture UV
|
||||
29,
|
||||
24 // Size
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RenderSystem.disableBlend();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package com.tiedup.remake.client.events;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.tiedup.remake.blocks.BlockMarker;
|
||||
import com.tiedup.remake.blocks.entity.MarkerBlockEntity;
|
||||
import com.tiedup.remake.cells.CellDataV2;
|
||||
import com.tiedup.remake.cells.CellRegistryV2;
|
||||
import com.tiedup.remake.cells.MarkerType;
|
||||
import com.tiedup.remake.client.renderer.CellOutlineRenderer;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.ItemAdminWand;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.client.Camera;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.client.event.RenderLevelStageEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Event handler for rendering cell outlines when holding an admin wand.
|
||||
*
|
||||
* Phase: Kidnapper Revamp - Cell System
|
||||
*
|
||||
* Renders colored outlines around cell positions when:
|
||||
* - Player is holding an Admin Wand
|
||||
* - A cell is currently selected in the wand
|
||||
*
|
||||
* The outlines help builders visualize which blocks are part of the cell.
|
||||
*
|
||||
* Network sync: On dedicated servers, cell data is synced via PacketSyncCellData.
|
||||
* On integrated servers, direct access to server data is used as a fallback.
|
||||
*/
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class CellHighlightHandler {
|
||||
|
||||
// Client-side cache of cell data (synced from server via PacketSyncCellData)
|
||||
private static final java.util.Map<UUID, CellDataV2> syncedCells =
|
||||
new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
// Legacy single-cell cache for backward compatibility
|
||||
private static CellDataV2 cachedCellData = null;
|
||||
private static UUID cachedCellId = null;
|
||||
|
||||
/**
|
||||
* Render cell outlines after translucent blocks.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onRenderLevelStage(RenderLevelStageEvent event) {
|
||||
// Only render after translucent stage (so outlines appear on top)
|
||||
if (
|
||||
event.getStage() !=
|
||||
RenderLevelStageEvent.Stage.AFTER_TRANSLUCENT_BLOCKS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
LocalPlayer player = mc.player;
|
||||
|
||||
if (player == null) return;
|
||||
|
||||
// Check if player is holding an Admin Wand
|
||||
ItemStack mainHand = player.getMainHandItem();
|
||||
ItemStack offHand = player.getOffhandItem();
|
||||
|
||||
boolean holdingAdminWand =
|
||||
mainHand.getItem() instanceof ItemAdminWand ||
|
||||
offHand.getItem() instanceof ItemAdminWand;
|
||||
|
||||
if (!holdingAdminWand) {
|
||||
cachedCellData = null;
|
||||
cachedCellId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
PoseStack poseStack = event.getPoseStack();
|
||||
Camera camera = event.getCamera();
|
||||
|
||||
// If holding Admin Wand, render nearby structure markers and preview
|
||||
renderNearbyStructureMarkers(poseStack, camera, player);
|
||||
renderAdminWandPreview(poseStack, camera, player, mainHand, offHand);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a preview outline showing where the Admin Wand will place a marker.
|
||||
*/
|
||||
private static void renderAdminWandPreview(
|
||||
PoseStack poseStack,
|
||||
Camera camera,
|
||||
LocalPlayer player,
|
||||
ItemStack mainHand,
|
||||
ItemStack offHand
|
||||
) {
|
||||
// Get the block the player is looking at
|
||||
net.minecraft.world.phys.HitResult hitResult =
|
||||
Minecraft.getInstance().hitResult;
|
||||
if (
|
||||
hitResult == null ||
|
||||
hitResult.getType() != net.minecraft.world.phys.HitResult.Type.BLOCK
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
net.minecraft.world.phys.BlockHitResult blockHit =
|
||||
(net.minecraft.world.phys.BlockHitResult) hitResult;
|
||||
BlockPos targetPos = blockHit.getBlockPos().above(); // Marker goes above the clicked block
|
||||
|
||||
// Get the current marker type from the wand
|
||||
MarkerType type;
|
||||
if (mainHand.getItem() instanceof ItemAdminWand) {
|
||||
type = ItemAdminWand.getCurrentType(mainHand);
|
||||
} else {
|
||||
type = ItemAdminWand.getCurrentType(offHand);
|
||||
}
|
||||
|
||||
Vec3 cameraPos = camera.getPosition();
|
||||
float[] color = CellOutlineRenderer.getColorForType(type);
|
||||
|
||||
// Make preview semi-transparent and pulsing
|
||||
float alpha =
|
||||
0.5f + 0.3f * (float) Math.sin(System.currentTimeMillis() / 200.0);
|
||||
float[] previewColor = { color[0], color[1], color[2], alpha };
|
||||
|
||||
// Setup rendering (depth test off so preview shows through blocks)
|
||||
com.mojang.blaze3d.systems.RenderSystem.enableBlend();
|
||||
com.mojang.blaze3d.systems.RenderSystem.defaultBlendFunc();
|
||||
com.mojang.blaze3d.systems.RenderSystem.disableDepthTest();
|
||||
com.mojang.blaze3d.systems.RenderSystem.depthMask(false);
|
||||
com.mojang.blaze3d.systems.RenderSystem.setShader(
|
||||
net.minecraft.client.renderer.GameRenderer::getPositionColorShader
|
||||
);
|
||||
|
||||
CellOutlineRenderer.renderFilledBlock(
|
||||
poseStack,
|
||||
targetPos,
|
||||
cameraPos,
|
||||
previewColor
|
||||
);
|
||||
|
||||
com.mojang.blaze3d.systems.RenderSystem.depthMask(true);
|
||||
com.mojang.blaze3d.systems.RenderSystem.enableDepthTest();
|
||||
com.mojang.blaze3d.systems.RenderSystem.disableBlend();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render outlines for nearby structure markers (markers without cell IDs).
|
||||
*/
|
||||
private static void renderNearbyStructureMarkers(
|
||||
PoseStack poseStack,
|
||||
Camera camera,
|
||||
LocalPlayer player
|
||||
) {
|
||||
Level level = player.level();
|
||||
BlockPos playerPos = player.blockPosition();
|
||||
Vec3 cameraPos = camera.getPosition();
|
||||
|
||||
// Collect markers first to check if we need to render anything
|
||||
java.util.List<
|
||||
java.util.Map.Entry<BlockPos, MarkerType>
|
||||
> markersToRender = new java.util.ArrayList<>();
|
||||
|
||||
// Scan in a 32-block radius for structure markers
|
||||
int radius = 32;
|
||||
|
||||
for (int x = -radius; x <= radius; x++) {
|
||||
for (int y = -radius / 2; y <= radius / 2; y++) {
|
||||
for (int z = -radius; z <= radius; z++) {
|
||||
BlockPos pos = playerPos.offset(x, y, z);
|
||||
|
||||
if (
|
||||
level.getBlockState(pos).getBlock() instanceof
|
||||
BlockMarker
|
||||
) {
|
||||
BlockEntity be = level.getBlockEntity(pos);
|
||||
if (be instanceof MarkerBlockEntity marker) {
|
||||
// Only render structure markers (no cell ID)
|
||||
if (marker.getCellId() == null) {
|
||||
markersToRender.add(
|
||||
java.util.Map.entry(
|
||||
pos,
|
||||
marker.getMarkerType()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only setup rendering if we have markers to render
|
||||
if (!markersToRender.isEmpty()) {
|
||||
// Setup rendering state (depth test off so markers show through blocks)
|
||||
com.mojang.blaze3d.systems.RenderSystem.enableBlend();
|
||||
com.mojang.blaze3d.systems.RenderSystem.defaultBlendFunc();
|
||||
com.mojang.blaze3d.systems.RenderSystem.disableDepthTest();
|
||||
com.mojang.blaze3d.systems.RenderSystem.depthMask(false);
|
||||
com.mojang.blaze3d.systems.RenderSystem.setShader(
|
||||
net.minecraft.client.renderer
|
||||
.GameRenderer::getPositionColorShader
|
||||
);
|
||||
|
||||
for (var entry : markersToRender) {
|
||||
BlockPos pos = entry.getKey();
|
||||
MarkerType type = entry.getValue();
|
||||
float[] baseColor = CellOutlineRenderer.getColorForType(type);
|
||||
// Semi-transparent fill
|
||||
float[] fillColor = {
|
||||
baseColor[0],
|
||||
baseColor[1],
|
||||
baseColor[2],
|
||||
0.4f,
|
||||
};
|
||||
CellOutlineRenderer.renderFilledBlock(
|
||||
poseStack,
|
||||
pos,
|
||||
cameraPos,
|
||||
fillColor
|
||||
);
|
||||
}
|
||||
|
||||
// Restore rendering state
|
||||
com.mojang.blaze3d.systems.RenderSystem.depthMask(true);
|
||||
com.mojang.blaze3d.systems.RenderSystem.enableDepthTest();
|
||||
com.mojang.blaze3d.systems.RenderSystem.disableBlend();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cell data on client side.
|
||||
* First checks the network-synced cache, then falls back to integrated server access.
|
||||
*
|
||||
* @param cellId The cell UUID to look up
|
||||
* @return CellDataV2 if found, null otherwise
|
||||
*/
|
||||
private static CellDataV2 getCellDataClient(UUID cellId) {
|
||||
if (cellId == null) return null;
|
||||
|
||||
// Priority 1: Check network-synced cache (works on dedicated servers)
|
||||
CellDataV2 synced = syncedCells.get(cellId);
|
||||
if (synced != null) {
|
||||
return synced;
|
||||
}
|
||||
|
||||
// Priority 2: Check legacy single-cell cache
|
||||
if (cellId.equals(cachedCellId) && cachedCellData != null) {
|
||||
return cachedCellData;
|
||||
}
|
||||
|
||||
// Priority 3: On integrated server, access server level directly (fallback)
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.getSingleplayerServer() != null) {
|
||||
ServerLevel serverLevel = mc.getSingleplayerServer().overworld();
|
||||
if (serverLevel != null) {
|
||||
CellRegistryV2 registry = CellRegistryV2.get(serverLevel);
|
||||
CellDataV2 cell = registry.getCell(cellId);
|
||||
if (cell != null) {
|
||||
// Cache for future use
|
||||
cachedCellId = cellId;
|
||||
cachedCellData = cell;
|
||||
return cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not found - on dedicated server, packet hasn't arrived yet
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cached cell data (called from network sync - PacketSyncCellData).
|
||||
* Stores in both the synced map and legacy cache for compatibility.
|
||||
*
|
||||
* @param cell The cell data received from server
|
||||
*/
|
||||
public static void updateCachedCell(CellDataV2 cell) {
|
||||
if (cell != null) {
|
||||
// Store in synced map
|
||||
syncedCells.put(cell.getId(), cell);
|
||||
|
||||
// Also update legacy cache
|
||||
cachedCellId = cell.getId();
|
||||
cachedCellData = cell;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a cell from the cache (e.g., when cell is deleted).
|
||||
*
|
||||
* @param cellId The cell UUID to remove
|
||||
*/
|
||||
public static void removeCachedCell(UUID cellId) {
|
||||
if (cellId != null) {
|
||||
syncedCells.remove(cellId);
|
||||
if (cellId.equals(cachedCellId)) {
|
||||
cachedCellId = null;
|
||||
cachedCellData = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached cell data.
|
||||
* Called when disconnecting from server or on dimension change.
|
||||
*/
|
||||
public static void clearCache() {
|
||||
syncedCells.clear();
|
||||
cachedCellId = null;
|
||||
cachedCellData = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.tiedup.remake.client.events;
|
||||
|
||||
import com.tiedup.remake.client.MuffledSoundInstance;
|
||||
import com.tiedup.remake.core.ModConfig;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.client.resources.sounds.SoundInstance;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.sound.PlaySoundEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Client-side sound handler for earplugs effect.
|
||||
*
|
||||
* When the player has earplugs equipped, all sounds are muffled
|
||||
* (volume reduced to simulate hearing impairment).
|
||||
*
|
||||
* Based on Forge's PlaySoundEvent to intercept and modify sounds.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class EarplugSoundHandler {
|
||||
|
||||
/** Pitch modifier to make sounds more muffled (much lower for "underwater" effect) */
|
||||
private static final float MUFFLED_PITCH_MODIFIER = 0.6f;
|
||||
|
||||
/** Categories to always let through at normal volume (important UI feedback) */
|
||||
private static final SoundSource[] UNAFFECTED_CATEGORIES = {
|
||||
SoundSource.MASTER, // Master volume controls
|
||||
SoundSource.MUSIC, // Music is internal, not muffled by earplugs
|
||||
};
|
||||
|
||||
/**
|
||||
* Intercept sound events and muffle them if player has earplugs.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onPlaySound(PlaySoundEvent event) {
|
||||
// Get the sound being played
|
||||
SoundInstance sound = event.getSound();
|
||||
if (sound == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if player has earplugs
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null) {
|
||||
return;
|
||||
}
|
||||
LocalPlayer player = mc.player;
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null || !state.hasEarplugs()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this sound category should be affected
|
||||
SoundSource source = sound.getSource();
|
||||
for (SoundSource unaffected : UNAFFECTED_CATEGORIES) {
|
||||
if (source == unaffected) {
|
||||
return; // Don't muffle this category
|
||||
}
|
||||
}
|
||||
|
||||
// Don't wrap already-wrapped sounds (prevent infinite recursion)
|
||||
if (sound instanceof MuffledSoundInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap the sound with our muffling wrapper
|
||||
// The wrapper delegates to the original but modifies getVolume()/getPitch()
|
||||
SoundInstance muffledSound = new MuffledSoundInstance(
|
||||
sound,
|
||||
ModConfig.CLIENT.earplugVolumeMultiplier.get().floatValue(),
|
||||
MUFFLED_PITCH_MODIFIER
|
||||
);
|
||||
|
||||
event.setSound(muffledSound);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.tiedup.remake.client.events;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import com.tiedup.remake.client.animation.BondageAnimationManager;
|
||||
import com.tiedup.remake.client.animation.PendingAnimationManager;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.event.entity.EntityLeaveLevelEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Automatic cleanup handler for entity-related resources.
|
||||
*
|
||||
* <p>This handler automatically cleans up animation layers and pending animations
|
||||
* when entities leave the world, preventing memory leaks from stale cache entries.
|
||||
*
|
||||
* <p>Phase: Performance & Memory Management
|
||||
*
|
||||
* <p>Previously, cleanup had to be called manually via {@link BondageAnimationManager#cleanup(java.util.UUID)},
|
||||
* which was error-prone and could lead to memory leaks if forgotten.
|
||||
* This handler ensures cleanup happens automatically on entity removal.
|
||||
*/
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class EntityCleanupHandler {
|
||||
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
|
||||
/**
|
||||
* Automatically clean up animation resources when an entity leaves the world.
|
||||
*
|
||||
* <p>This event fires when:
|
||||
* <ul>
|
||||
* <li>An entity is removed from the world (killed, despawned, unloaded)</li>
|
||||
* <li>A player logs out</li>
|
||||
* <li>A chunk is unloaded and its entities are removed</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Cleanup includes:
|
||||
* <ul>
|
||||
* <li>Removing animation layers from {@link BondageAnimationManager}</li>
|
||||
* <li>Removing pending animations from {@link PendingAnimationManager}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param event The entity leave level event
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onEntityLeaveLevel(EntityLeaveLevelEvent event) {
|
||||
// Only process on client side
|
||||
if (!event.getLevel().isClientSide()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up animation layers
|
||||
BondageAnimationManager.cleanup(event.getEntity().getUUID());
|
||||
|
||||
// Clean up pending animation queue
|
||||
PendingAnimationManager.remove(event.getEntity().getUUID());
|
||||
|
||||
LOGGER.debug(
|
||||
"Auto-cleaned animation resources for entity: {} (type: {})",
|
||||
event.getEntity().getUUID(),
|
||||
event.getEntity().getClass().getSimpleName()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.tiedup.remake.client.events;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.items.base.BindVariant;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
import com.tiedup.remake.util.KidnappedHelper;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.event.TickEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Client-side handler for smooth leash proxy positioning.
|
||||
*
|
||||
* FIX: Changed from RenderLevelStageEvent.AFTER_ENTITIES to ClientTickEvent.
|
||||
* AFTER_ENTITIES positioned the proxy AFTER rendering, causing 1-frame lag.
|
||||
* ClientTickEvent positions BEFORE rendering for smooth leash display.
|
||||
*
|
||||
* Instead of waiting for server position updates (which causes lag),
|
||||
* this handler repositions the proxy entity locally each tick based
|
||||
* on the player's current position.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
value = Dist.CLIENT,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE
|
||||
)
|
||||
public class LeashProxyClientHandler {
|
||||
|
||||
/**
|
||||
* Map of player UUID -> proxy entity ID.
|
||||
* Uses UUID for player (persistent) and entity ID for proxy (runtime).
|
||||
*/
|
||||
private static final Map<UUID, Integer> playerToProxy =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** Default Y offset for normal standing pose (neck height) */
|
||||
private static final double DEFAULT_Y_OFFSET = 1.3;
|
||||
|
||||
/** Y offset for dogwalk pose (back/hip level) */
|
||||
private static final double DOGWALK_Y_OFFSET = 0.35;
|
||||
|
||||
/**
|
||||
* Handle sync packet from server.
|
||||
* Called when a player gets leashed or unleashed.
|
||||
*/
|
||||
public static void handleSyncPacket(
|
||||
UUID targetPlayerUUID,
|
||||
int proxyId,
|
||||
boolean attach
|
||||
) {
|
||||
if (attach) {
|
||||
playerToProxy.put(targetPlayerUUID, proxyId);
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[LeashProxyClient] Registered proxy {} for player {}",
|
||||
proxyId,
|
||||
targetPlayerUUID
|
||||
);
|
||||
} else {
|
||||
playerToProxy.remove(targetPlayerUUID);
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[LeashProxyClient] Removed proxy for player {}",
|
||||
targetPlayerUUID
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FIX: Use ClientTickEvent instead of RenderLevelStageEvent.AFTER_ENTITIES.
|
||||
* This positions proxies BEFORE rendering, eliminating the 1-frame lag
|
||||
* that caused jittery leash rendering.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||
// Only run at end of tick (after player position is updated)
|
||||
if (event.phase != TickEvent.Phase.END) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playerToProxy.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null || mc.level == null || mc.isPaused()) {
|
||||
return;
|
||||
}
|
||||
Level level = mc.level;
|
||||
|
||||
// Reposition each tracked proxy
|
||||
for (Map.Entry<UUID, Integer> entry : playerToProxy.entrySet()) {
|
||||
UUID playerUUID = entry.getKey();
|
||||
int proxyId = entry.getValue();
|
||||
|
||||
Player playerEntity = level.getPlayerByUUID(playerUUID);
|
||||
Entity proxyEntity = level.getEntity(proxyId);
|
||||
|
||||
if (playerEntity != null && proxyEntity != null) {
|
||||
// FIX: Calculate Y offset based on bind type (dogwalk vs normal)
|
||||
double yOffset = calculateYOffset(playerEntity);
|
||||
|
||||
// Use current position (interpolation will be handled by renderer)
|
||||
double x = playerEntity.getX();
|
||||
double y = playerEntity.getY() + yOffset;
|
||||
double z = playerEntity.getZ() - 0.15;
|
||||
|
||||
// Set proxy position
|
||||
proxyEntity.setPos(x, y, z);
|
||||
|
||||
// Update old positions for smooth interpolation
|
||||
proxyEntity.xOld = proxyEntity.xo = x;
|
||||
proxyEntity.yOld = proxyEntity.yo = y;
|
||||
proxyEntity.zOld = proxyEntity.zo = z;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Y offset based on player's bind type.
|
||||
* Dogwalk (DOGBINDER) uses lower offset for 4-legged pose.
|
||||
*/
|
||||
private static double calculateYOffset(Player player) {
|
||||
IBondageState state = KidnappedHelper.getKidnappedState(player);
|
||||
if (state != null && state.isTiedUp()) {
|
||||
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (
|
||||
!bind.isEmpty() &&
|
||||
bind.getItem() == ModItems.getBind(BindVariant.DOGBINDER)
|
||||
) {
|
||||
return DOGWALK_Y_OFFSET;
|
||||
}
|
||||
}
|
||||
return DEFAULT_Y_OFFSET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tracked proxies.
|
||||
* Called when disconnecting from server.
|
||||
*/
|
||||
public static void clearAll() {
|
||||
playerToProxy.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.tiedup.remake.client.events;
|
||||
|
||||
import com.tiedup.remake.items.base.*;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageItem;
|
||||
import com.tiedup.remake.network.selfbondage.PacketSelfBondage;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.event.TickEvent;
|
||||
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Client-side event handler for self-bondage input.
|
||||
*
|
||||
* Intercepts left-click when holding bondage items and
|
||||
* sends packets continuously to the server to perform self-bondage.
|
||||
*
|
||||
* Self-bondage items:
|
||||
* - Binds (rope, chain, etc.) - Self-tie (requires holding left-click)
|
||||
* - Gags - Self-gag (if already tied, instant)
|
||||
* - Blindfolds - Self-blindfold (if already tied, instant)
|
||||
* - Mittens - Self-mitten (if already tied, instant)
|
||||
* - Earplugs - Self-earplug (if already tied, instant)
|
||||
* - Collar - NOT ALLOWED (cannot self-collar)
|
||||
*/
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = "tiedup",
|
||||
value = Dist.CLIENT,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE
|
||||
)
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class SelfBondageInputHandler {
|
||||
|
||||
/** Track if we're currently in self-bondage mode */
|
||||
private static boolean isSelfBondageActive = false;
|
||||
|
||||
/** The hand we're using for self-bondage */
|
||||
private static InteractionHand activeHand = null;
|
||||
|
||||
/** Tick counter for packet sending interval */
|
||||
private static int tickCounter = 0;
|
||||
|
||||
/** Send packet every 4 ticks (5 times per second) for smooth progress */
|
||||
private static final int PACKET_INTERVAL = 4;
|
||||
|
||||
/**
|
||||
* Handle left-click in empty air - START self-bondage.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onLeftClickEmpty(
|
||||
PlayerInteractEvent.LeftClickEmpty event
|
||||
) {
|
||||
startSelfBondage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle left-click on block - START self-bondage (cancel block breaking).
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onLeftClickBlock(
|
||||
PlayerInteractEvent.LeftClickBlock event
|
||||
) {
|
||||
if (!event.getLevel().isClientSide()) return;
|
||||
|
||||
ItemStack stack = event.getItemStack();
|
||||
if (isSelfBondageItem(stack.getItem())) {
|
||||
event.setCanceled(true);
|
||||
startSelfBondage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start self-bondage mode if holding a bondage item.
|
||||
*/
|
||||
private static void startSelfBondage() {
|
||||
LocalPlayer player = Minecraft.getInstance().player;
|
||||
if (player == null) return;
|
||||
|
||||
// Check main hand first, then off hand
|
||||
InteractionHand hand = InteractionHand.MAIN_HAND;
|
||||
ItemStack stack = player.getMainHandItem();
|
||||
|
||||
if (!isSelfBondageItem(stack.getItem())) {
|
||||
stack = player.getOffhandItem();
|
||||
hand = InteractionHand.OFF_HAND;
|
||||
|
||||
if (!isSelfBondageItem(stack.getItem())) {
|
||||
return; // No bondage item in either hand
|
||||
}
|
||||
}
|
||||
|
||||
// Start self-bondage mode
|
||||
isSelfBondageActive = true;
|
||||
activeHand = hand;
|
||||
tickCounter = 0;
|
||||
|
||||
// Send initial packet immediately
|
||||
ModNetwork.sendToServer(new PacketSelfBondage(hand));
|
||||
}
|
||||
|
||||
/**
|
||||
* Client tick - continuously send packets while attack button is held.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||
if (event.phase != TickEvent.Phase.END) return;
|
||||
if (!isSelfBondageActive) return;
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
LocalPlayer player = mc.player;
|
||||
|
||||
// Stop if conditions are no longer valid
|
||||
if (player == null || mc.screen != null) {
|
||||
stopSelfBondage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if attack button is still held
|
||||
if (!mc.options.keyAttack.isDown()) {
|
||||
stopSelfBondage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if still holding bondage item in the active hand
|
||||
ItemStack stack = player.getItemInHand(activeHand);
|
||||
if (!isSelfBondageItem(stack.getItem())) {
|
||||
stopSelfBondage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send packet at interval for continuous progress
|
||||
tickCounter++;
|
||||
if (tickCounter >= PACKET_INTERVAL) {
|
||||
tickCounter = 0;
|
||||
ModNetwork.sendToServer(new PacketSelfBondage(activeHand));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop self-bondage mode.
|
||||
*/
|
||||
private static void stopSelfBondage() {
|
||||
isSelfBondageActive = false;
|
||||
activeHand = null;
|
||||
tickCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item supports self-bondage.
|
||||
* Collar is explicitly excluded.
|
||||
*/
|
||||
private static boolean isSelfBondageItem(Item item) {
|
||||
// Collar cannot be self-equipped (V1 collar guard)
|
||||
if (item instanceof ItemCollar) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// V2 bondage items support self-bondage (left-click hold with tying duration)
|
||||
if (item instanceof IV2BondageItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// V1 bondage items (legacy)
|
||||
return (
|
||||
item instanceof ItemBind ||
|
||||
item instanceof ItemGag ||
|
||||
item instanceof ItemBlindfold ||
|
||||
item instanceof ItemMittens ||
|
||||
item instanceof ItemEarplugs
|
||||
);
|
||||
}
|
||||
}
|
||||
628
src/main/java/com/tiedup/remake/client/gltf/GlbParser.java
Normal file
628
src/main/java/com/tiedup/remake/client/gltf/GlbParser.java
Normal file
@@ -0,0 +1,628 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Parser for binary .glb (glTF 2.0) files.
|
||||
* Extracts mesh geometry, skinning data, bone hierarchy, and animations.
|
||||
* Filters out meshes named "Player".
|
||||
*/
|
||||
public final class GlbParser {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
|
||||
private static final int GLB_MAGIC = 0x46546C67; // "glTF"
|
||||
private static final int GLB_VERSION = 2;
|
||||
private static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
|
||||
private static final int CHUNK_BIN = 0x004E4942; // "BIN\0"
|
||||
|
||||
private GlbParser() {}
|
||||
|
||||
/**
|
||||
* Parse a .glb file from an InputStream.
|
||||
*
|
||||
* @param input the input stream (will be fully read)
|
||||
* @param debugName name for log messages
|
||||
* @return parsed GltfData
|
||||
* @throws IOException if the file is malformed or I/O fails
|
||||
*/
|
||||
public static GltfData parse(InputStream input, String debugName) throws IOException {
|
||||
byte[] allBytes = input.readAllBytes();
|
||||
ByteBuffer buf = ByteBuffer.wrap(allBytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
|
||||
// -- Header --
|
||||
int magic = buf.getInt();
|
||||
if (magic != GLB_MAGIC) {
|
||||
throw new IOException("Not a GLB file: " + debugName);
|
||||
}
|
||||
int version = buf.getInt();
|
||||
if (version != GLB_VERSION) {
|
||||
throw new IOException("Unsupported GLB version " + version + " in " + debugName);
|
||||
}
|
||||
int totalLength = buf.getInt();
|
||||
|
||||
// -- JSON chunk --
|
||||
int jsonChunkLength = buf.getInt();
|
||||
int jsonChunkType = buf.getInt();
|
||||
if (jsonChunkType != CHUNK_JSON) {
|
||||
throw new IOException("Expected JSON chunk in " + debugName);
|
||||
}
|
||||
byte[] jsonBytes = new byte[jsonChunkLength];
|
||||
buf.get(jsonBytes);
|
||||
String jsonStr = new String(jsonBytes, StandardCharsets.UTF_8);
|
||||
JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
|
||||
|
||||
// -- BIN chunk --
|
||||
ByteBuffer binData = null;
|
||||
if (buf.hasRemaining()) {
|
||||
int binChunkLength = buf.getInt();
|
||||
int binChunkType = buf.getInt();
|
||||
if (binChunkType != CHUNK_BIN) {
|
||||
throw new IOException("Expected BIN chunk in " + debugName);
|
||||
}
|
||||
byte[] binBytes = new byte[binChunkLength];
|
||||
buf.get(binBytes);
|
||||
binData = ByteBuffer.wrap(binBytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
}
|
||||
if (binData == null) {
|
||||
throw new IOException("No BIN chunk in " + debugName);
|
||||
}
|
||||
|
||||
JsonArray accessors = root.getAsJsonArray("accessors");
|
||||
JsonArray bufferViews = root.getAsJsonArray("bufferViews");
|
||||
JsonArray nodes = root.getAsJsonArray("nodes");
|
||||
JsonArray meshes = root.getAsJsonArray("meshes");
|
||||
|
||||
// -- Find skin --
|
||||
JsonArray skins = root.getAsJsonArray("skins");
|
||||
if (skins == null || skins.size() == 0) {
|
||||
throw new IOException("No skins found in " + debugName);
|
||||
}
|
||||
JsonObject skin = skins.get(0).getAsJsonObject();
|
||||
JsonArray skinJoints = skin.getAsJsonArray("joints");
|
||||
|
||||
// Filter skin joints to only include known deforming bones
|
||||
List<Integer> filteredJointNodes = new ArrayList<>();
|
||||
int[] skinJointRemap = new int[skinJoints.size()]; // old skin index -> new filtered index
|
||||
java.util.Arrays.fill(skinJointRemap, -1);
|
||||
for (int j = 0; j < skinJoints.size(); j++) {
|
||||
int nodeIdx = skinJoints.get(j).getAsInt();
|
||||
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
|
||||
String name = node.has("name") ? node.get("name").getAsString() : "joint_" + j;
|
||||
if (GltfBoneMapper.isKnownBone(name)) {
|
||||
skinJointRemap[j] = filteredJointNodes.size();
|
||||
filteredJointNodes.add(nodeIdx);
|
||||
} else {
|
||||
LOGGER.debug("[GltfPipeline] Skipping non-deforming bone: '{}' (node {})", name, nodeIdx);
|
||||
}
|
||||
}
|
||||
|
||||
int jointCount = filteredJointNodes.size();
|
||||
String[] jointNames = new String[jointCount];
|
||||
int[] parentJointIndices = new int[jointCount];
|
||||
Quaternionf[] restRotations = new Quaternionf[jointCount];
|
||||
Vector3f[] restTranslations = new Vector3f[jointCount];
|
||||
|
||||
// Map node index -> joint index (filtered)
|
||||
int[] nodeToJoint = new int[nodes.size()];
|
||||
java.util.Arrays.fill(nodeToJoint, -1);
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
int nodeIdx = filteredJointNodes.get(j);
|
||||
nodeToJoint[nodeIdx] = j;
|
||||
}
|
||||
|
||||
// Read joint names, rest pose, and build parent mapping
|
||||
java.util.Arrays.fill(parentJointIndices, -1);
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
int nodeIdx = filteredJointNodes.get(j);
|
||||
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
|
||||
|
||||
jointNames[j] = node.has("name") ? node.get("name").getAsString() : "joint_" + j;
|
||||
|
||||
// Rest rotation
|
||||
if (node.has("rotation")) {
|
||||
JsonArray r = node.getAsJsonArray("rotation");
|
||||
restRotations[j] = new Quaternionf(
|
||||
r.get(0).getAsFloat(), r.get(1).getAsFloat(),
|
||||
r.get(2).getAsFloat(), r.get(3).getAsFloat()
|
||||
);
|
||||
} else {
|
||||
restRotations[j] = new Quaternionf(); // identity
|
||||
}
|
||||
|
||||
// Rest translation
|
||||
if (node.has("translation")) {
|
||||
JsonArray t = node.getAsJsonArray("translation");
|
||||
restTranslations[j] = new Vector3f(
|
||||
t.get(0).getAsFloat(), t.get(1).getAsFloat(), t.get(2).getAsFloat()
|
||||
);
|
||||
} else {
|
||||
restTranslations[j] = new Vector3f();
|
||||
}
|
||||
}
|
||||
|
||||
// Build parent indices by traversing node children
|
||||
for (int ni = 0; ni < nodes.size(); ni++) {
|
||||
JsonObject node = nodes.get(ni).getAsJsonObject();
|
||||
if (node.has("children")) {
|
||||
int parentJoint = nodeToJoint[ni];
|
||||
JsonArray children = node.getAsJsonArray("children");
|
||||
for (JsonElement child : children) {
|
||||
int childNodeIdx = child.getAsInt();
|
||||
int childJoint = nodeToJoint[childNodeIdx];
|
||||
if (childJoint >= 0 && parentJoint >= 0) {
|
||||
parentJointIndices[childJoint] = parentJoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Inverse Bind Matrices --
|
||||
// IBM accessor is indexed by original skin joint order, so we pick the filtered entries
|
||||
Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount];
|
||||
if (skin.has("inverseBindMatrices")) {
|
||||
int ibmAccessor = skin.get("inverseBindMatrices").getAsInt();
|
||||
float[] ibmData = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, ibmAccessor);
|
||||
for (int origJ = 0; origJ < skinJoints.size(); origJ++) {
|
||||
int newJ = skinJointRemap[origJ];
|
||||
if (newJ >= 0) {
|
||||
inverseBindMatrices[newJ] = new Matrix4f();
|
||||
inverseBindMatrices[newJ].set(ibmData, origJ * 16);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
inverseBindMatrices[j] = new Matrix4f(); // identity
|
||||
}
|
||||
}
|
||||
|
||||
// -- Find mesh (ignore "Player" mesh, take LAST non-Player) --
|
||||
// WORKAROUND: Takes the LAST non-Player mesh because modelers may leave prototype meshes
|
||||
// in the .glb. Revert to first non-Player mesh once modeler workflow is standardized.
|
||||
int targetMeshIdx = -1;
|
||||
if (meshes != null) {
|
||||
for (int mi = 0; mi < meshes.size(); mi++) {
|
||||
JsonObject mesh = meshes.get(mi).getAsJsonObject();
|
||||
String meshName = mesh.has("name") ? mesh.get("name").getAsString() : "";
|
||||
if (!"Player".equals(meshName)) {
|
||||
targetMeshIdx = mi;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Parse root material names (for tint channel detection) --
|
||||
String[] materialNames = GlbParserUtils.parseMaterialNames(root);
|
||||
|
||||
// Mesh data: empty arrays if no mesh found (animation-only GLB)
|
||||
float[] positions;
|
||||
float[] normals;
|
||||
float[] texCoords;
|
||||
int[] indices;
|
||||
int vertexCount;
|
||||
int[] meshJoints;
|
||||
float[] weights;
|
||||
List<GltfData.Primitive> parsedPrimitives = new ArrayList<>();
|
||||
|
||||
if (targetMeshIdx >= 0) {
|
||||
JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
|
||||
JsonArray primitives = mesh.getAsJsonArray("primitives");
|
||||
|
||||
// -- Accumulate vertex data from ALL primitives --
|
||||
List<float[]> allPositions = new ArrayList<>();
|
||||
List<float[]> allNormals = new ArrayList<>();
|
||||
List<float[]> allTexCoords = new ArrayList<>();
|
||||
List<int[]> allJoints = new ArrayList<>();
|
||||
List<float[]> allWeights = new ArrayList<>();
|
||||
int cumulativeVertexCount = 0;
|
||||
|
||||
for (int pi = 0; pi < primitives.size(); pi++) {
|
||||
JsonObject primitive = primitives.get(pi).getAsJsonObject();
|
||||
JsonObject attributes = primitive.getAsJsonObject("attributes");
|
||||
|
||||
// -- Read this primitive's vertex data --
|
||||
float[] primPositions = GlbParserUtils.readFloatAccessor(
|
||||
accessors, bufferViews, binData,
|
||||
attributes.get("POSITION").getAsInt()
|
||||
);
|
||||
float[] primNormals = attributes.has("NORMAL")
|
||||
? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("NORMAL").getAsInt())
|
||||
: new float[primPositions.length];
|
||||
float[] primTexCoords = attributes.has("TEXCOORD_0")
|
||||
? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("TEXCOORD_0").getAsInt())
|
||||
: new float[primPositions.length / 3 * 2];
|
||||
|
||||
int primVertexCount = primPositions.length / 3;
|
||||
|
||||
// -- Read this primitive's indices (offset by cumulative vertex count) --
|
||||
int[] primIndices;
|
||||
if (primitive.has("indices")) {
|
||||
primIndices = GlbParserUtils.readIntAccessor(
|
||||
accessors, bufferViews, binData,
|
||||
primitive.get("indices").getAsInt()
|
||||
);
|
||||
} else {
|
||||
// Non-indexed: generate sequential indices
|
||||
primIndices = new int[primVertexCount];
|
||||
for (int i = 0; i < primVertexCount; i++) primIndices[i] = i;
|
||||
}
|
||||
|
||||
// Offset indices by cumulative vertex count from prior primitives
|
||||
if (cumulativeVertexCount > 0) {
|
||||
for (int i = 0; i < primIndices.length; i++) {
|
||||
primIndices[i] += cumulativeVertexCount;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Read skinning attributes for this primitive --
|
||||
int[] primJoints = new int[primVertexCount * 4];
|
||||
float[] primWeights = new float[primVertexCount * 4];
|
||||
|
||||
if (attributes.has("JOINTS_0")) {
|
||||
primJoints = GlbParserUtils.readIntAccessor(
|
||||
accessors, bufferViews, binData,
|
||||
attributes.get("JOINTS_0").getAsInt()
|
||||
);
|
||||
// Remap vertex joint indices from original skin order to filtered order
|
||||
for (int i = 0; i < primJoints.length; i++) {
|
||||
int origIdx = primJoints[i];
|
||||
if (origIdx >= 0 && origIdx < skinJointRemap.length) {
|
||||
primJoints[i] = skinJointRemap[origIdx] >= 0 ? skinJointRemap[origIdx] : 0;
|
||||
} else {
|
||||
primJoints[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (attributes.has("WEIGHTS_0")) {
|
||||
primWeights = GlbParserUtils.readFloatAccessor(
|
||||
accessors, bufferViews, binData,
|
||||
attributes.get("WEIGHTS_0").getAsInt()
|
||||
);
|
||||
}
|
||||
|
||||
// -- Resolve material name and tint channel --
|
||||
String matName = null;
|
||||
if (primitive.has("material")) {
|
||||
int matIdx = primitive.get("material").getAsInt();
|
||||
if (matIdx >= 0 && matIdx < materialNames.length) {
|
||||
matName = materialNames[matIdx];
|
||||
}
|
||||
}
|
||||
boolean isTintable = matName != null && matName.startsWith("tintable_");
|
||||
String tintChannel = isTintable ? matName : null;
|
||||
|
||||
parsedPrimitives.add(new GltfData.Primitive(primIndices, matName, isTintable, tintChannel));
|
||||
|
||||
allPositions.add(primPositions);
|
||||
allNormals.add(primNormals);
|
||||
allTexCoords.add(primTexCoords);
|
||||
allJoints.add(primJoints);
|
||||
allWeights.add(primWeights);
|
||||
cumulativeVertexCount += primVertexCount;
|
||||
}
|
||||
|
||||
// -- Flatten accumulated data into single arrays --
|
||||
vertexCount = cumulativeVertexCount;
|
||||
positions = GlbParserUtils.flattenFloats(allPositions);
|
||||
normals = GlbParserUtils.flattenFloats(allNormals);
|
||||
texCoords = GlbParserUtils.flattenFloats(allTexCoords);
|
||||
meshJoints = GlbParserUtils.flattenInts(allJoints);
|
||||
weights = GlbParserUtils.flattenFloats(allWeights);
|
||||
|
||||
// Build union of all primitive indices (for backward-compat indices() accessor)
|
||||
int totalIndices = 0;
|
||||
for (GltfData.Primitive p : parsedPrimitives) totalIndices += p.indices().length;
|
||||
indices = new int[totalIndices];
|
||||
int offset = 0;
|
||||
for (GltfData.Primitive p : parsedPrimitives) {
|
||||
System.arraycopy(p.indices(), 0, indices, offset, p.indices().length);
|
||||
offset += p.indices().length;
|
||||
}
|
||||
} else {
|
||||
// Animation-only GLB: no mesh data
|
||||
LOGGER.info("[GltfPipeline] No mesh found in '{}' (animation-only GLB)", debugName);
|
||||
positions = new float[0];
|
||||
normals = new float[0];
|
||||
texCoords = new float[0];
|
||||
indices = new int[0];
|
||||
vertexCount = 0;
|
||||
meshJoints = new int[0];
|
||||
weights = new float[0];
|
||||
}
|
||||
|
||||
// -- Read ALL animations --
|
||||
Map<String, GltfData.AnimationClip> allClips = new LinkedHashMap<>();
|
||||
JsonArray animations = root.getAsJsonArray("animations");
|
||||
if (animations != null) {
|
||||
for (int ai = 0; ai < animations.size(); ai++) {
|
||||
JsonObject anim = animations.get(ai).getAsJsonObject();
|
||||
String animName = anim.has("name") ? anim.get("name").getAsString() : "animation_" + ai;
|
||||
// Strip the "ArmatureName|" prefix if present (Blender convention)
|
||||
if (animName.contains("|")) {
|
||||
animName = animName.substring(animName.lastIndexOf('|') + 1);
|
||||
}
|
||||
GltfData.AnimationClip clip = parseAnimation(anim, accessors, bufferViews, binData, nodeToJoint, jointCount);
|
||||
if (clip != null) {
|
||||
allClips.put(animName, clip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default animation = first clip (for backward compat)
|
||||
GltfData.AnimationClip animClip = allClips.isEmpty() ? null : allClips.values().iterator().next();
|
||||
|
||||
LOGGER.info("[GltfPipeline] Parsed '{}': vertices={}, indices={}, joints={}, animations={}",
|
||||
debugName, vertexCount, indices.length, jointCount, allClips.size());
|
||||
for (String name : allClips.keySet()) {
|
||||
LOGGER.debug("[GltfPipeline] animation: '{}'", name);
|
||||
}
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
Quaternionf rq = restRotations[j];
|
||||
Vector3f rt = restTranslations[j];
|
||||
LOGGER.debug(String.format("[GltfPipeline] joint[%d] = '%s', parent=%d, restQ=(%.3f,%.3f,%.3f,%.3f) restT=(%.3f,%.3f,%.3f)",
|
||||
j, jointNames[j], parentJointIndices[j],
|
||||
rq.x, rq.y, rq.z, rq.w, rt.x, rt.y, rt.z));
|
||||
}
|
||||
|
||||
// Log animation translation channels for default clip (BEFORE MC conversion)
|
||||
if (animClip != null && animClip.translations() != null) {
|
||||
Vector3f[][] animTrans = animClip.translations();
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
if (j < animTrans.length && animTrans[j] != null) {
|
||||
Vector3f at = animTrans[j][0]; // first frame
|
||||
Vector3f rt = restTranslations[j];
|
||||
LOGGER.debug(String.format(
|
||||
"[GltfPipeline] joint[%d] '%s' has ANIM TRANSLATION: (%.4f,%.4f,%.4f) vs rest (%.4f,%.4f,%.4f) delta=(%.4f,%.4f,%.4f)",
|
||||
j, jointNames[j],
|
||||
at.x, at.y, at.z,
|
||||
rt.x, rt.y, rt.z,
|
||||
at.x - rt.x, at.y - rt.y, at.z - rt.z));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LOGGER.debug("[GltfPipeline] Default animation has NO translation channels");
|
||||
}
|
||||
|
||||
// Save raw glTF rotations BEFORE coordinate conversion (for pose converter)
|
||||
// MC model space faces +Z just like glTF, so delta quaternions for ModelPart
|
||||
// rotation should be computed from raw glTF data, not from the converted data.
|
||||
Quaternionf[] rawRestRotations = new Quaternionf[jointCount];
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
rawRestRotations[j] = new Quaternionf(restRotations[j]);
|
||||
}
|
||||
|
||||
// Build raw copies of ALL animation clips (before MC conversion)
|
||||
Map<String, GltfData.AnimationClip> rawAllClips = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, GltfData.AnimationClip> entry : allClips.entrySet()) {
|
||||
rawAllClips.put(entry.getKey(), GlbParserUtils.deepCopyClip(entry.getValue()));
|
||||
}
|
||||
GltfData.AnimationClip rawAnimClip = rawAllClips.isEmpty() ? null : rawAllClips.values().iterator().next();
|
||||
|
||||
// Convert from glTF coordinate system (Y-up, faces +Z) to MC (Y-up, faces -Z)
|
||||
// This is a 180° rotation around Y: negate X and Z for all spatial data
|
||||
// Convert ALL animation clips to MC space
|
||||
for (GltfData.AnimationClip clip : allClips.values()) {
|
||||
GlbParserUtils.convertAnimationToMinecraftSpace(clip, jointCount);
|
||||
}
|
||||
convertToMinecraftSpace(positions, normals, restTranslations, restRotations,
|
||||
inverseBindMatrices, null, jointCount); // pass null — clips already converted above
|
||||
LOGGER.debug("[GltfPipeline] Converted all data to Minecraft coordinate space");
|
||||
|
||||
return new GltfData(
|
||||
positions, normals, texCoords,
|
||||
indices, meshJoints, weights,
|
||||
jointNames, parentJointIndices,
|
||||
inverseBindMatrices,
|
||||
restRotations, restTranslations,
|
||||
rawRestRotations,
|
||||
rawAnimClip,
|
||||
animClip,
|
||||
allClips, rawAllClips,
|
||||
parsedPrimitives,
|
||||
vertexCount, jointCount
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Animation parsing ----
|
||||
|
||||
private static GltfData.AnimationClip parseAnimation(
|
||||
JsonObject animation,
|
||||
JsonArray accessors, JsonArray bufferViews,
|
||||
ByteBuffer binData,
|
||||
int[] nodeToJoint, int jointCount
|
||||
) {
|
||||
JsonArray channels = animation.getAsJsonArray("channels");
|
||||
JsonArray samplers = animation.getAsJsonArray("samplers");
|
||||
|
||||
// Collect rotation and translation channels
|
||||
List<Integer> rotJoints = new ArrayList<>();
|
||||
List<float[]> rotTimestamps = new ArrayList<>();
|
||||
List<Quaternionf[]> rotValues = new ArrayList<>();
|
||||
|
||||
List<Integer> transJoints = new ArrayList<>();
|
||||
List<float[]> transTimestamps = new ArrayList<>();
|
||||
List<Vector3f[]> transValues = new ArrayList<>();
|
||||
|
||||
for (JsonElement chElem : channels) {
|
||||
JsonObject channel = chElem.getAsJsonObject();
|
||||
JsonObject target = channel.getAsJsonObject("target");
|
||||
String path = target.get("path").getAsString();
|
||||
|
||||
int nodeIdx = target.get("node").getAsInt();
|
||||
if (nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0) continue;
|
||||
int jointIdx = nodeToJoint[nodeIdx];
|
||||
|
||||
int samplerIdx = channel.get("sampler").getAsInt();
|
||||
JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject();
|
||||
|
||||
float[] times = GlbParserUtils.readFloatAccessor(
|
||||
accessors, bufferViews, binData,
|
||||
sampler.get("input").getAsInt()
|
||||
);
|
||||
|
||||
if ("rotation".equals(path)) {
|
||||
float[] quats = GlbParserUtils.readFloatAccessor(
|
||||
accessors, bufferViews, binData,
|
||||
sampler.get("output").getAsInt()
|
||||
);
|
||||
Quaternionf[] qArr = new Quaternionf[times.length];
|
||||
for (int i = 0; i < times.length; i++) {
|
||||
qArr[i] = new Quaternionf(
|
||||
quats[i * 4], quats[i * 4 + 1],
|
||||
quats[i * 4 + 2], quats[i * 4 + 3]
|
||||
);
|
||||
}
|
||||
rotJoints.add(jointIdx);
|
||||
rotTimestamps.add(times);
|
||||
rotValues.add(qArr);
|
||||
} else if ("translation".equals(path)) {
|
||||
float[] vecs = GlbParserUtils.readFloatAccessor(
|
||||
accessors, bufferViews, binData,
|
||||
sampler.get("output").getAsInt()
|
||||
);
|
||||
Vector3f[] tArr = new Vector3f[times.length];
|
||||
for (int i = 0; i < times.length; i++) {
|
||||
tArr[i] = new Vector3f(
|
||||
vecs[i * 3], vecs[i * 3 + 1], vecs[i * 3 + 2]
|
||||
);
|
||||
}
|
||||
transJoints.add(jointIdx);
|
||||
transTimestamps.add(times);
|
||||
transValues.add(tArr);
|
||||
}
|
||||
}
|
||||
|
||||
if (rotJoints.isEmpty() && transJoints.isEmpty()) return null;
|
||||
|
||||
// Use the first available channel's timestamps as reference
|
||||
float[] timestamps = !rotTimestamps.isEmpty()
|
||||
? rotTimestamps.get(0)
|
||||
: transTimestamps.get(0);
|
||||
int frameCount = timestamps.length;
|
||||
|
||||
// Build per-joint rotation arrays (null if no animation for that joint)
|
||||
Quaternionf[][] rotations = new Quaternionf[jointCount][];
|
||||
for (int i = 0; i < rotJoints.size(); i++) {
|
||||
int jIdx = rotJoints.get(i);
|
||||
Quaternionf[] vals = rotValues.get(i);
|
||||
rotations[jIdx] = new Quaternionf[frameCount];
|
||||
for (int f = 0; f < frameCount; f++) {
|
||||
rotations[jIdx][f] = f < vals.length ? vals[f] : vals[vals.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Build per-joint translation arrays (null if no animation for that joint)
|
||||
Vector3f[][] translations = new Vector3f[jointCount][];
|
||||
for (int i = 0; i < transJoints.size(); i++) {
|
||||
int jIdx = transJoints.get(i);
|
||||
Vector3f[] vals = transValues.get(i);
|
||||
translations[jIdx] = new Vector3f[frameCount];
|
||||
for (int f = 0; f < frameCount; f++) {
|
||||
translations[jIdx][f] = f < vals.length
|
||||
? new Vector3f(vals[f])
|
||||
: new Vector3f(vals[vals.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Log translation channels found
|
||||
if (!transJoints.isEmpty()) {
|
||||
LOGGER.debug("[GltfPipeline] Animation has {} translation channel(s)",
|
||||
transJoints.size());
|
||||
}
|
||||
|
||||
return new GltfData.AnimationClip(timestamps, rotations, translations, frameCount);
|
||||
}
|
||||
|
||||
// ---- Coordinate system conversion ----
|
||||
|
||||
/**
|
||||
* Convert all spatial data from glTF space to MC model-def space.
|
||||
* The Blender-exported character faces -Z in glTF, same as MC model-def.
|
||||
* Only X (right→left) and Y (up→down) differ between the two spaces.
|
||||
* Equivalent to a 180° rotation around Z: negate X and Y components.
|
||||
*
|
||||
* For positions/normals/translations: (x,y,z) → (-x, -y, z)
|
||||
* For quaternions: (x,y,z,w) → (-x, -y, z, w) (conjugation by 180° Z)
|
||||
* For matrices: M → C * M * C where C = diag(-1, -1, 1, 1)
|
||||
*/
|
||||
private static void convertToMinecraftSpace(
|
||||
float[] positions, float[] normals,
|
||||
Vector3f[] restTranslations, Quaternionf[] restRotations,
|
||||
Matrix4f[] inverseBindMatrices,
|
||||
GltfData.AnimationClip animClip, int jointCount
|
||||
) {
|
||||
// Vertex positions: negate X and Y
|
||||
for (int i = 0; i < positions.length; i += 3) {
|
||||
positions[i] = -positions[i]; // X
|
||||
positions[i + 1] = -positions[i + 1]; // Y
|
||||
}
|
||||
|
||||
// Vertex normals: negate X and Y
|
||||
for (int i = 0; i < normals.length; i += 3) {
|
||||
normals[i] = -normals[i];
|
||||
normals[i + 1] = -normals[i + 1];
|
||||
}
|
||||
|
||||
// Rest translations: negate X and Y
|
||||
for (Vector3f t : restTranslations) {
|
||||
t.x = -t.x;
|
||||
t.y = -t.y;
|
||||
}
|
||||
|
||||
// Rest rotations: conjugate by 180° Z = negate qx and qy
|
||||
for (Quaternionf q : restRotations) {
|
||||
q.x = -q.x;
|
||||
q.y = -q.y;
|
||||
}
|
||||
|
||||
// Inverse bind matrices: C * M * C where C = diag(-1, -1, 1)
|
||||
Matrix4f C = new Matrix4f().scaling(-1, -1, 1);
|
||||
Matrix4f temp = new Matrix4f();
|
||||
for (Matrix4f ibm : inverseBindMatrices) {
|
||||
temp.set(C).mul(ibm).mul(C);
|
||||
ibm.set(temp);
|
||||
}
|
||||
|
||||
// Animation quaternions: same conjugation
|
||||
if (animClip != null) {
|
||||
Quaternionf[][] rotations = animClip.rotations();
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
if (j < rotations.length && rotations[j] != null) {
|
||||
for (Quaternionf q : rotations[j]) {
|
||||
q.x = -q.x;
|
||||
q.y = -q.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animation translations: negate X and Y (same as rest translations)
|
||||
Vector3f[][] translations = animClip.translations();
|
||||
if (translations != null) {
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
if (j < translations.length && translations[j] != null) {
|
||||
for (Vector3f t : translations[j]) {
|
||||
t.x = -t.x;
|
||||
t.y = -t.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
253
src/main/java/com/tiedup/remake/client/gltf/GlbParserUtils.java
Normal file
253
src/main/java/com/tiedup/remake/client/gltf/GlbParserUtils.java
Normal file
@@ -0,0 +1,253 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.List;
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Shared stateless utilities for parsing binary glTF (.glb) files.
|
||||
*
|
||||
* <p>These methods are used by both {@link GlbParser} (single-armature bondage meshes)
|
||||
* and {@link com.tiedup.remake.v2.furniture.client.FurnitureGlbParser FurnitureGlbParser}
|
||||
* (multi-armature furniture meshes). Extracted to eliminate ~160 lines of verbatim
|
||||
* duplication between the two parsers.</p>
|
||||
*
|
||||
* <p>All methods are pure functions (no state, no side effects).</p>
|
||||
*/
|
||||
public final class GlbParserUtils {
|
||||
|
||||
// glTF component type constants
|
||||
public static final int BYTE = 5120;
|
||||
public static final int UNSIGNED_BYTE = 5121;
|
||||
public static final int SHORT = 5122;
|
||||
public static final int UNSIGNED_SHORT = 5123;
|
||||
public static final int UNSIGNED_INT = 5125;
|
||||
public static final int FLOAT = 5126;
|
||||
|
||||
private GlbParserUtils() {}
|
||||
|
||||
// ---- Material name parsing ----
|
||||
|
||||
/**
|
||||
* Parse the root "materials" array and extract each material's "name" field.
|
||||
* Returns an empty array if no materials are present.
|
||||
*/
|
||||
public static String[] parseMaterialNames(JsonObject root) {
|
||||
if (!root.has("materials") || !root.get("materials").isJsonArray()) {
|
||||
return new String[0];
|
||||
}
|
||||
JsonArray materials = root.getAsJsonArray("materials");
|
||||
String[] names = new String[materials.size()];
|
||||
for (int i = 0; i < materials.size(); i++) {
|
||||
JsonObject mat = materials.get(i).getAsJsonObject();
|
||||
names[i] = mat.has("name") ? mat.get("name").getAsString() : null;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// ---- Array flattening utilities ----
|
||||
|
||||
public static float[] flattenFloats(List<float[]> arrays) {
|
||||
int total = 0;
|
||||
for (float[] a : arrays) total += a.length;
|
||||
float[] result = new float[total];
|
||||
int offset = 0;
|
||||
for (float[] a : arrays) {
|
||||
System.arraycopy(a, 0, result, offset, a.length);
|
||||
offset += a.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static int[] flattenInts(List<int[]> arrays) {
|
||||
int total = 0;
|
||||
for (int[] a : arrays) total += a.length;
|
||||
int[] result = new int[total];
|
||||
int offset = 0;
|
||||
for (int[] a : arrays) {
|
||||
System.arraycopy(a, 0, result, offset, a.length);
|
||||
offset += a.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Accessor reading utilities ----
|
||||
|
||||
public static float[] readFloatAccessor(
|
||||
JsonArray accessors, JsonArray bufferViews,
|
||||
ByteBuffer binData, int accessorIdx
|
||||
) {
|
||||
JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject();
|
||||
int count = accessor.get("count").getAsInt();
|
||||
int componentType = accessor.get("componentType").getAsInt();
|
||||
String type = accessor.get("type").getAsString();
|
||||
int components = typeComponents(type);
|
||||
|
||||
int bvIdx = accessor.get("bufferView").getAsInt();
|
||||
JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject();
|
||||
int byteOffset = (bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0)
|
||||
+ (accessor.has("byteOffset") ? accessor.get("byteOffset").getAsInt() : 0);
|
||||
int byteStride = bv.has("byteStride") ? bv.get("byteStride").getAsInt() : 0;
|
||||
|
||||
int totalElements = count * components;
|
||||
float[] result = new float[totalElements];
|
||||
|
||||
int componentSize = componentByteSize(componentType);
|
||||
int stride = byteStride > 0 ? byteStride : components * componentSize;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
int pos = byteOffset + i * stride;
|
||||
for (int c = 0; c < components; c++) {
|
||||
binData.position(pos + c * componentSize);
|
||||
result[i * components + c] = readComponentAsFloat(binData, componentType);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static int[] readIntAccessor(
|
||||
JsonArray accessors, JsonArray bufferViews,
|
||||
ByteBuffer binData, int accessorIdx
|
||||
) {
|
||||
JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject();
|
||||
int count = accessor.get("count").getAsInt();
|
||||
int componentType = accessor.get("componentType").getAsInt();
|
||||
String type = accessor.get("type").getAsString();
|
||||
int components = typeComponents(type);
|
||||
|
||||
int bvIdx = accessor.get("bufferView").getAsInt();
|
||||
JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject();
|
||||
int byteOffset = (bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0)
|
||||
+ (accessor.has("byteOffset") ? accessor.get("byteOffset").getAsInt() : 0);
|
||||
int byteStride = bv.has("byteStride") ? bv.get("byteStride").getAsInt() : 0;
|
||||
|
||||
int totalElements = count * components;
|
||||
int[] result = new int[totalElements];
|
||||
|
||||
int componentSize = componentByteSize(componentType);
|
||||
int stride = byteStride > 0 ? byteStride : components * componentSize;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
int pos = byteOffset + i * stride;
|
||||
for (int c = 0; c < components; c++) {
|
||||
binData.position(pos + c * componentSize);
|
||||
result[i * components + c] = readComponentAsInt(binData, componentType);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static float readComponentAsFloat(ByteBuffer buf, int componentType) {
|
||||
return switch (componentType) {
|
||||
case FLOAT -> buf.getFloat();
|
||||
case BYTE -> buf.get() / 127.0f;
|
||||
case UNSIGNED_BYTE -> (buf.get() & 0xFF) / 255.0f;
|
||||
case SHORT -> buf.getShort() / 32767.0f;
|
||||
case UNSIGNED_SHORT -> (buf.getShort() & 0xFFFF) / 65535.0f;
|
||||
case UNSIGNED_INT -> (buf.getInt() & 0xFFFFFFFFL) / (float) 0xFFFFFFFFL;
|
||||
default -> throw new IllegalArgumentException("Unknown component type: " + componentType);
|
||||
};
|
||||
}
|
||||
|
||||
public static int readComponentAsInt(ByteBuffer buf, int componentType) {
|
||||
return switch (componentType) {
|
||||
case BYTE -> buf.get();
|
||||
case UNSIGNED_BYTE -> buf.get() & 0xFF;
|
||||
case SHORT -> buf.getShort();
|
||||
case UNSIGNED_SHORT -> buf.getShort() & 0xFFFF;
|
||||
case UNSIGNED_INT -> buf.getInt();
|
||||
case FLOAT -> (int) buf.getFloat();
|
||||
default -> throw new IllegalArgumentException("Unknown component type: " + componentType);
|
||||
};
|
||||
}
|
||||
|
||||
public static int typeComponents(String type) {
|
||||
return switch (type) {
|
||||
case "SCALAR" -> 1;
|
||||
case "VEC2" -> 2;
|
||||
case "VEC3" -> 3;
|
||||
case "VEC4" -> 4;
|
||||
case "MAT4" -> 16;
|
||||
default -> throw new IllegalArgumentException("Unknown accessor type: " + type);
|
||||
};
|
||||
}
|
||||
|
||||
public static int componentByteSize(int componentType) {
|
||||
return switch (componentType) {
|
||||
case BYTE, UNSIGNED_BYTE -> 1;
|
||||
case SHORT, UNSIGNED_SHORT -> 2;
|
||||
case UNSIGNED_INT, FLOAT -> 4;
|
||||
default -> throw new IllegalArgumentException("Unknown component type: " + componentType);
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Deep-copy utility ----
|
||||
|
||||
/**
|
||||
* Deep-copy an AnimationClip (preserves original data before MC conversion).
|
||||
*/
|
||||
public static GltfData.AnimationClip deepCopyClip(GltfData.AnimationClip clip) {
|
||||
Quaternionf[][] rawRotations = new Quaternionf[clip.rotations().length][];
|
||||
for (int j = 0; j < clip.rotations().length; j++) {
|
||||
if (clip.rotations()[j] != null) {
|
||||
rawRotations[j] = new Quaternionf[clip.rotations()[j].length];
|
||||
for (int f = 0; f < clip.rotations()[j].length; f++) {
|
||||
rawRotations[j][f] = new Quaternionf(clip.rotations()[j][f]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vector3f[][] rawTranslations = null;
|
||||
if (clip.translations() != null) {
|
||||
rawTranslations = new Vector3f[clip.translations().length][];
|
||||
for (int j = 0; j < clip.translations().length; j++) {
|
||||
if (clip.translations()[j] != null) {
|
||||
rawTranslations[j] = new Vector3f[clip.translations()[j].length];
|
||||
for (int f = 0; f < clip.translations()[j].length; f++) {
|
||||
rawTranslations[j][f] = new Vector3f(clip.translations()[j][f]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new GltfData.AnimationClip(
|
||||
clip.timestamps().clone(), rawRotations, rawTranslations,
|
||||
clip.frameCount()
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Coordinate system conversion ----
|
||||
|
||||
/**
|
||||
* Convert an animation clip's rotations and translations to MC space.
|
||||
* Negate qx/qy for rotations and negate tx/ty for translations.
|
||||
*/
|
||||
public static void convertAnimationToMinecraftSpace(GltfData.AnimationClip clip, int jointCount) {
|
||||
if (clip == null) return;
|
||||
|
||||
Quaternionf[][] rotations = clip.rotations();
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
if (j < rotations.length && rotations[j] != null) {
|
||||
for (Quaternionf q : rotations[j]) {
|
||||
q.x = -q.x;
|
||||
q.y = -q.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vector3f[][] translations = clip.translations();
|
||||
if (translations != null) {
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
if (j < translations.length && translations[j] != null) {
|
||||
for (Vector3f t : translations[j]) {
|
||||
t.x = -t.x;
|
||||
t.y = -t.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import com.tiedup.remake.client.animation.BondageAnimationManager;
|
||||
import com.tiedup.remake.client.animation.context.AnimationContext;
|
||||
import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
|
||||
import com.tiedup.remake.client.animation.context.GlbAnimationResolver;
|
||||
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
|
||||
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* V2 Animation Applier -- manages dual-layer animation for V2 bondage items.
|
||||
*
|
||||
* <p>Orchestrates two PlayerAnimator layers simultaneously:
|
||||
* <ul>
|
||||
* <li><b>Context layer</b> (priority 40): base body posture (stand/sit/kneel/sneak/walk)
|
||||
* with item-owned parts disabled, via {@link ContextAnimationFactory}</li>
|
||||
* <li><b>Item layer</b> (priority 42): per-item GLB animation with only owned bones enabled,
|
||||
* via {@link GltfPoseConverter#convertSelective}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Each equipped V2 item controls ONLY the bones matching its occupied body regions.
|
||||
* Bones not owned by any item pass through from the context layer, which provides the
|
||||
* appropriate base posture animation.
|
||||
*
|
||||
* <p>State tracking avoids redundant animation replays: a composite key of
|
||||
* {@code animSource|context|ownedParts} is compared per-entity to skip no-op updates.
|
||||
*
|
||||
* <p>Item animations are cached by {@code animSource#context#ownedParts} since the same
|
||||
* GLB + context + owned parts always produces the same KeyframeAnimation.
|
||||
*
|
||||
* @see ContextAnimationFactory
|
||||
* @see GlbAnimationResolver
|
||||
* @see GltfPoseConverter#convertSelective
|
||||
* @see BondageAnimationManager
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class GltfAnimationApplier {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
|
||||
/**
|
||||
* Cache of converted item-layer KeyframeAnimations.
|
||||
* Keyed by "animSource#context#ownedPartsHash".
|
||||
* Same GLB + same context + same owned parts = same KeyframeAnimation.
|
||||
*/
|
||||
private static final Map<String, KeyframeAnimation> itemAnimCache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Track which composite state is currently active per entity, to avoid redundant replays.
|
||||
* Keyed by entity UUID, value is "animSource|context|sortedParts".
|
||||
*/
|
||||
private static final Map<UUID, String> activeStateKeys = new ConcurrentHashMap<>();
|
||||
|
||||
/** Track cache keys where GLB loading failed, to avoid per-tick retries. */
|
||||
private static final Set<String> failedLoadKeys = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private GltfAnimationApplier() {}
|
||||
|
||||
// ========================================
|
||||
// INIT (legacy)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Legacy init method -- called by GltfClientSetup.
|
||||
* No-op: layer registration is handled by {@link BondageAnimationManager#init()}.
|
||||
*/
|
||||
public static void init() {
|
||||
// No-op: animation layers are managed by BondageAnimationManager
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// V2 DUAL-LAYER API
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Apply the full V2 animation state: context layer + item layer.
|
||||
*
|
||||
* <p>Flow:
|
||||
* <ol>
|
||||
* <li>Build a composite state key and skip if unchanged</li>
|
||||
* <li>Create/retrieve a context animation with disabledOnContext parts disabled,
|
||||
* play on context layer via {@link BondageAnimationManager#playContext}</li>
|
||||
* <li>Load the GLB (from {@code animationSource} or {@code modelLoc}),
|
||||
* resolve the named animation via {@link GlbAnimationResolver#resolve},
|
||||
* convert with selective parts via {@link GltfPoseConverter#convertSelective},
|
||||
* play on item layer via {@link BondageAnimationManager#playDirect}</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>The ownership model enables "free bone" animation: if a bone is not claimed
|
||||
* by any item, the winning item can animate it IF its GLB has keyframes for that bone.
|
||||
* This allows a straitjacket (ARMS+TORSO) to also animate free legs.</p>
|
||||
*
|
||||
* @param entity the entity to animate
|
||||
* @param modelLoc the item's GLB model (for mesh rendering, and default animation source)
|
||||
* @param animationSource separate GLB for animations (shared template), or null to use modelLoc
|
||||
* @param context current animation context (STAND_IDLE, SIT_IDLE, etc.)
|
||||
* @param ownership bone ownership: which parts this item owns vs other items
|
||||
* @return true if the item layer animation was applied successfully
|
||||
*/
|
||||
public static boolean applyV2Animation(LivingEntity entity, ResourceLocation modelLoc,
|
||||
@Nullable ResourceLocation animationSource,
|
||||
AnimationContext context, RegionBoneMapper.BoneOwnership ownership) {
|
||||
if (entity == null || modelLoc == null) return false;
|
||||
|
||||
ResourceLocation animSource = animationSource != null ? animationSource : modelLoc;
|
||||
// Cache key includes both owned and enabled parts for full disambiguation
|
||||
String ownedKey = canonicalPartsKey(ownership.thisParts());
|
||||
String enabledKey = canonicalPartsKey(ownership.enabledParts());
|
||||
String partsKey = ownedKey + ";" + enabledKey;
|
||||
|
||||
// Build composite state key to avoid redundant updates
|
||||
String stateKey = animSource + "|" + context.name() + "|" + partsKey;
|
||||
String currentKey = activeStateKeys.get(entity.getUUID());
|
||||
if (stateKey.equals(currentKey)) {
|
||||
return true; // Already active, no-op
|
||||
}
|
||||
|
||||
// === Layer 1: Context animation (base body posture) ===
|
||||
// Parts owned by ANY item (this or others) are disabled on the context layer.
|
||||
// Only free parts remain enabled on context.
|
||||
KeyframeAnimation contextAnim = ContextAnimationFactory.create(
|
||||
context, ownership.disabledOnContext());
|
||||
if (contextAnim != null) {
|
||||
BondageAnimationManager.playContext(entity, contextAnim);
|
||||
}
|
||||
|
||||
// === Layer 2: Item animation (GLB pose with selective bones) ===
|
||||
String itemCacheKey = buildItemCacheKey(animSource, context, partsKey);
|
||||
|
||||
// Skip if this GLB already failed to load
|
||||
if (failedLoadKeys.contains(itemCacheKey)) {
|
||||
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
KeyframeAnimation itemAnim = itemAnimCache.get(itemCacheKey);
|
||||
if (itemAnim == null) {
|
||||
GltfData animData = GlbAnimationResolver.resolveAnimationData(modelLoc, animationSource);
|
||||
if (animData == null) {
|
||||
LOGGER.warn("[GltfPipeline] Failed to load animation GLB: {}", animSource);
|
||||
failedLoadKeys.add(itemCacheKey);
|
||||
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||
return false;
|
||||
}
|
||||
// Resolve which named animation to use (with fallback chain + variant selection)
|
||||
String glbAnimName = GlbAnimationResolver.resolve(animData, context);
|
||||
// Pass both owned parts and enabled parts (owned + free) for selective enabling
|
||||
itemAnim = GltfPoseConverter.convertSelective(
|
||||
animData, glbAnimName, ownership.thisParts(), ownership.enabledParts());
|
||||
itemAnimCache.put(itemCacheKey, itemAnim);
|
||||
}
|
||||
|
||||
BondageAnimationManager.playDirect(entity, itemAnim);
|
||||
|
||||
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply V2 animation from ALL equipped items simultaneously.
|
||||
*
|
||||
* <p>Each item contributes keyframes for only its owned bones into a shared
|
||||
* {@link KeyframeAnimation.AnimationBuilder}. The first item in the list (highest priority)
|
||||
* can additionally animate free bones if its GLB has keyframes for them.</p>
|
||||
*
|
||||
* @param entity the entity to animate
|
||||
* @param items resolved V2 items with per-item ownership, sorted by priority desc
|
||||
* @param context current animation context
|
||||
* @param allOwnedParts union of all owned parts across all items
|
||||
* @return true if the composite animation was applied
|
||||
*/
|
||||
public static boolean applyMultiItemV2Animation(LivingEntity entity,
|
||||
List<RegionBoneMapper.V2ItemAnimInfo> items,
|
||||
AnimationContext context, Set<String> allOwnedParts) {
|
||||
if (entity == null || items.isEmpty()) return false;
|
||||
|
||||
// Build composite state key
|
||||
StringBuilder keyBuilder = new StringBuilder();
|
||||
for (RegionBoneMapper.V2ItemAnimInfo item : items) {
|
||||
ResourceLocation src = item.animSource() != null ? item.animSource() : item.modelLoc();
|
||||
keyBuilder.append(src).append(':').append(canonicalPartsKey(item.ownedParts())).append(';');
|
||||
}
|
||||
keyBuilder.append(context.name());
|
||||
String stateKey = keyBuilder.toString();
|
||||
|
||||
String currentKey = activeStateKeys.get(entity.getUUID());
|
||||
if (stateKey.equals(currentKey)) {
|
||||
return true; // Already active
|
||||
}
|
||||
|
||||
// === Layer 1: Context animation ===
|
||||
KeyframeAnimation contextAnim = ContextAnimationFactory.create(context, allOwnedParts);
|
||||
if (contextAnim != null) {
|
||||
BondageAnimationManager.playContext(entity, contextAnim);
|
||||
}
|
||||
|
||||
// === Layer 2: Composite item animation ===
|
||||
String compositeCacheKey = "multi#" + stateKey;
|
||||
|
||||
if (failedLoadKeys.contains(compositeCacheKey)) {
|
||||
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
KeyframeAnimation compositeAnim = itemAnimCache.get(compositeCacheKey);
|
||||
if (compositeAnim == null) {
|
||||
KeyframeAnimation.AnimationBuilder builder =
|
||||
new KeyframeAnimation.AnimationBuilder(
|
||||
dev.kosmx.playerAnim.core.data.AnimationFormat.JSON_EMOTECRAFT);
|
||||
builder.beginTick = 0;
|
||||
builder.endTick = 1;
|
||||
builder.stopTick = 1;
|
||||
builder.isLooped = true;
|
||||
builder.returnTick = 0;
|
||||
builder.name = "gltf_composite";
|
||||
|
||||
boolean anyLoaded = false;
|
||||
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
RegionBoneMapper.V2ItemAnimInfo item = items.get(i);
|
||||
ResourceLocation animSource = item.animSource() != null ? item.animSource() : item.modelLoc();
|
||||
|
||||
GltfData animData = GlbAnimationResolver.resolveAnimationData(item.modelLoc(), item.animSource());
|
||||
if (animData == null) {
|
||||
LOGGER.warn("[GltfPipeline] Failed to load GLB for multi-item: {}", animSource);
|
||||
continue;
|
||||
}
|
||||
|
||||
String glbAnimName = GlbAnimationResolver.resolve(animData, context);
|
||||
GltfData.AnimationClip rawClip;
|
||||
if (glbAnimName != null) {
|
||||
rawClip = animData.getRawAnimation(glbAnimName);
|
||||
} else {
|
||||
rawClip = null;
|
||||
}
|
||||
if (rawClip == null) {
|
||||
rawClip = animData.rawGltfAnimation();
|
||||
}
|
||||
|
||||
// Compute effective parts: intersect animation_bones whitelist with ownedParts
|
||||
// if the item declares per-animation bone filtering.
|
||||
Set<String> effectiveParts = item.ownedParts();
|
||||
if (glbAnimName != null && !item.animationBones().isEmpty()) {
|
||||
Set<String> override = item.animationBones().get(glbAnimName);
|
||||
if (override != null) {
|
||||
Set<String> filtered = new HashSet<>(override);
|
||||
filtered.retainAll(item.ownedParts());
|
||||
if (!filtered.isEmpty()) {
|
||||
effectiveParts = filtered;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GltfPoseConverter.addBonesToBuilder(
|
||||
builder, animData, rawClip, effectiveParts);
|
||||
anyLoaded = true;
|
||||
|
||||
LOGGER.debug("[GltfPipeline] Multi-item: {} -> owned={}, effective={}, anim={}",
|
||||
animSource, item.ownedParts(), effectiveParts, glbAnimName);
|
||||
}
|
||||
|
||||
if (!anyLoaded) {
|
||||
failedLoadKeys.add(compositeCacheKey);
|
||||
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enable only owned parts on the item layer.
|
||||
// Free parts (head, body, etc. not owned by any item) are disabled here
|
||||
// so they pass through to the context layer / vanilla animation.
|
||||
String[] allPartNames = {"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"};
|
||||
for (String partName : allPartNames) {
|
||||
KeyframeAnimation.StateCollection part = getPartByName(builder, partName);
|
||||
if (part != null) {
|
||||
if (allOwnedParts.contains(partName)) {
|
||||
part.fullyEnablePart(false);
|
||||
} else {
|
||||
part.setEnabled(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compositeAnim = builder.build();
|
||||
itemAnimCache.put(compositeCacheKey, compositeAnim);
|
||||
}
|
||||
|
||||
BondageAnimationManager.playDirect(entity, compositeAnim);
|
||||
activeStateKeys.put(entity.getUUID(), stateKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CLEAR / QUERY
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Clear all V2 animation layers from an entity and remove tracking.
|
||||
* Stops both the context layer and the item layer.
|
||||
*
|
||||
* @param entity the entity to clear animations from
|
||||
*/
|
||||
public static void clearV2Animation(LivingEntity entity) {
|
||||
if (entity == null) return;
|
||||
activeStateKeys.remove(entity.getUUID());
|
||||
BondageAnimationManager.stopContext(entity);
|
||||
BondageAnimationManager.stopAnimation(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entity has active V2 animation state.
|
||||
*
|
||||
* @param entity the entity to check
|
||||
* @return true if the entity has an active V2 animation state key
|
||||
*/
|
||||
public static boolean hasActiveState(LivingEntity entity) {
|
||||
return entity != null && activeStateKeys.containsKey(entity.getUUID());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tracking for an entity (e.g., on logout/unload).
|
||||
* Does NOT stop any currently playing animation -- use {@link #clearV2Animation} for that.
|
||||
*
|
||||
* @param entityId UUID of the entity to stop tracking
|
||||
*/
|
||||
public static void removeTracking(UUID entityId) {
|
||||
activeStateKeys.remove(entityId);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CACHE MANAGEMENT
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Invalidate all cached item animations and tracking state.
|
||||
* Call this on resource reload (F3+T) to pick up changed GLB/JSON files.
|
||||
*
|
||||
* <p>Does NOT clear ContextAnimationFactory or ContextGlbRegistry here.
|
||||
* Those are cleared in the reload listener AFTER ContextGlbRegistry.reload()
|
||||
* to prevent the render thread from caching stale JSON fallbacks during
|
||||
* the window between clear and repopulate.</p>
|
||||
*/
|
||||
public static void invalidateCache() {
|
||||
itemAnimCache.clear();
|
||||
activeStateKeys.clear();
|
||||
failedLoadKeys.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all state (cache + tracking). Called on world unload.
|
||||
* Clears everything including context caches (no concurrent reload during unload).
|
||||
*/
|
||||
public static void clearAll() {
|
||||
itemAnimCache.clear();
|
||||
activeStateKeys.clear();
|
||||
failedLoadKeys.clear();
|
||||
com.tiedup.remake.client.animation.context.ContextGlbRegistry.clear();
|
||||
ContextAnimationFactory.clearCache();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// LEGACY F9 DEBUG TOGGLE
|
||||
// ========================================
|
||||
|
||||
private static boolean debugEnabled = false;
|
||||
|
||||
/**
|
||||
* Toggle debug mode via F9 key.
|
||||
* When enabled, applies handcuffs V2 animation (rightArm + leftArm) to the local player
|
||||
* using STAND_IDLE context. When disabled, clears all V2 animation.
|
||||
*/
|
||||
public static void toggle() {
|
||||
debugEnabled = !debugEnabled;
|
||||
LOGGER.info("[GltfPipeline] Debug toggle: {}", debugEnabled ? "ON" : "OFF");
|
||||
|
||||
AbstractClientPlayer player = Minecraft.getInstance().player;
|
||||
if (player == null) return;
|
||||
|
||||
if (debugEnabled) {
|
||||
ResourceLocation modelLoc = ResourceLocation.fromNamespaceAndPath(
|
||||
"tiedup", "models/gltf/v2/handcuffs/cuffs_prototype.glb"
|
||||
);
|
||||
Set<String> armParts = Set.of("rightArm", "leftArm");
|
||||
RegionBoneMapper.BoneOwnership debugOwnership =
|
||||
new RegionBoneMapper.BoneOwnership(armParts, Set.of());
|
||||
applyV2Animation(player, modelLoc, null, AnimationContext.STAND_IDLE, debugOwnership);
|
||||
} else {
|
||||
clearV2Animation(player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether F9 debug mode is currently enabled.
|
||||
*/
|
||||
public static boolean isEnabled() {
|
||||
return debugEnabled;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// INTERNAL
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Build cache key for item-layer animations.
|
||||
* Format: "animSource#contextName#sortedParts"
|
||||
*/
|
||||
private static String buildItemCacheKey(ResourceLocation animSource,
|
||||
AnimationContext context, String partsKey) {
|
||||
return animSource + "#" + context.name() + "#" + partsKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a canonical, deterministic string from the owned parts set.
|
||||
* Sorted alphabetically and joined by comma — guarantees no hash collisions.
|
||||
*/
|
||||
private static String canonicalPartsKey(Set<String> ownedParts) {
|
||||
return String.join(",", new TreeSet<>(ownedParts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an {@link KeyframeAnimation.StateCollection} by part name on a builder.
|
||||
*/
|
||||
private static KeyframeAnimation.StateCollection getPartByName(
|
||||
KeyframeAnimation.AnimationBuilder builder, String name) {
|
||||
return switch (name) {
|
||||
case "head" -> builder.head;
|
||||
case "body" -> builder.body;
|
||||
case "rightArm" -> builder.rightArm;
|
||||
case "leftArm" -> builder.leftArm;
|
||||
case "rightLeg" -> builder.rightLeg;
|
||||
case "leftLeg" -> builder.leftLeg;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
104
src/main/java/com/tiedup/remake/client/gltf/GltfBoneMapper.java
Normal file
104
src/main/java/com/tiedup/remake/client/gltf/GltfBoneMapper.java
Normal file
@@ -0,0 +1,104 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.client.model.HumanoidModel;
|
||||
import net.minecraft.client.model.geom.ModelPart;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Maps glTF bone names to Minecraft HumanoidModel parts.
|
||||
* Handles upper bones (full rotation) and lower bones (bend only).
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class GltfBoneMapper {
|
||||
|
||||
/** Maps glTF bone name -> MC model part field name */
|
||||
private static final Map<String, String> BONE_TO_PART = new HashMap<>();
|
||||
|
||||
/** Lower bones that represent bend (elbow/knee) */
|
||||
private static final Set<String> LOWER_BONES = Set.of(
|
||||
"leftLowerArm", "rightLowerArm",
|
||||
"leftLowerLeg", "rightLowerLeg"
|
||||
);
|
||||
|
||||
/** Maps lower bone name -> corresponding upper bone name */
|
||||
private static final Map<String, String> LOWER_TO_UPPER = Map.of(
|
||||
"leftLowerArm", "leftUpperArm",
|
||||
"rightLowerArm", "rightUpperArm",
|
||||
"leftLowerLeg", "leftUpperLeg",
|
||||
"rightLowerLeg", "rightUpperLeg"
|
||||
);
|
||||
|
||||
static {
|
||||
BONE_TO_PART.put("body", "body");
|
||||
BONE_TO_PART.put("torso", "body");
|
||||
BONE_TO_PART.put("head", "head");
|
||||
BONE_TO_PART.put("leftUpperArm", "leftArm");
|
||||
BONE_TO_PART.put("leftLowerArm", "leftArm");
|
||||
BONE_TO_PART.put("rightUpperArm", "rightArm");
|
||||
BONE_TO_PART.put("rightLowerArm", "rightArm");
|
||||
BONE_TO_PART.put("leftUpperLeg", "leftLeg");
|
||||
BONE_TO_PART.put("leftLowerLeg", "leftLeg");
|
||||
BONE_TO_PART.put("rightUpperLeg", "rightLeg");
|
||||
BONE_TO_PART.put("rightLowerLeg", "rightLeg");
|
||||
}
|
||||
|
||||
private GltfBoneMapper() {}
|
||||
|
||||
/**
|
||||
* Get the ModelPart corresponding to a glTF bone name.
|
||||
*
|
||||
* @param model the HumanoidModel
|
||||
* @param boneName glTF bone name
|
||||
* @return the ModelPart, or null if not mapped
|
||||
*/
|
||||
public static ModelPart getModelPart(HumanoidModel<?> model, String boneName) {
|
||||
String partName = BONE_TO_PART.get(boneName);
|
||||
if (partName == null) return null;
|
||||
|
||||
return switch (partName) {
|
||||
case "body" -> model.body;
|
||||
case "head" -> model.head;
|
||||
case "leftArm" -> model.leftArm;
|
||||
case "rightArm" -> model.rightArm;
|
||||
case "leftLeg" -> model.leftLeg;
|
||||
case "rightLeg" -> model.rightLeg;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this bone represents a lower segment (bend: elbow/knee).
|
||||
*/
|
||||
public static boolean isLowerBone(String boneName) {
|
||||
return LOWER_BONES.contains(boneName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the upper bone name for a given lower bone.
|
||||
* Returns null if not a lower bone.
|
||||
*/
|
||||
public static String getUpperBoneFor(String lowerBoneName) {
|
||||
return LOWER_TO_UPPER.get(lowerBoneName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PlayerAnimator part name for a glTF bone.
|
||||
* Both glTF and PlayerAnimator use "body" for the torso part.
|
||||
*/
|
||||
public static String getAnimPartName(String boneName) {
|
||||
String partName = BONE_TO_PART.get(boneName);
|
||||
if (partName == null) return null;
|
||||
return partName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a bone name is known/mapped.
|
||||
*/
|
||||
public static boolean isKnownBone(String boneName) {
|
||||
return BONE_TO_PART.containsKey(boneName);
|
||||
}
|
||||
}
|
||||
67
src/main/java/com/tiedup/remake/client/gltf/GltfCache.java
Normal file
67
src/main/java/com/tiedup/remake/client/gltf/GltfCache.java
Normal file
@@ -0,0 +1,67 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
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 glTF data.
|
||||
* Loads .glb files via Minecraft's ResourceManager on first access.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class GltfCache {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
private static final Map<ResourceLocation, GltfData> CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
private GltfCache() {}
|
||||
|
||||
/**
|
||||
* Get parsed glTF data for a resource, loading it on first access.
|
||||
*
|
||||
* @param location resource location of the .glb file (e.g. "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb")
|
||||
* @return parsed GltfData, or null if loading failed
|
||||
*/
|
||||
public static GltfData get(ResourceLocation location) {
|
||||
GltfData cached = CACHE.get(location);
|
||||
if (cached != null) return cached;
|
||||
|
||||
try {
|
||||
Resource resource = Minecraft.getInstance()
|
||||
.getResourceManager()
|
||||
.getResource(location)
|
||||
.orElse(null);
|
||||
if (resource == null) {
|
||||
LOGGER.error("[GltfPipeline] Resource not found: {}", location);
|
||||
return null;
|
||||
}
|
||||
|
||||
try (InputStream is = resource.open()) {
|
||||
GltfData data = GlbParser.parse(is, location.toString());
|
||||
CACHE.put(location, data);
|
||||
return data;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[GltfPipeline] Failed to load GLB: {}", location, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all cached data (call on resource reload). */
|
||||
public static void clearCache() {
|
||||
CACHE.clear();
|
||||
LOGGER.info("[GltfPipeline] Cache cleared");
|
||||
}
|
||||
|
||||
/** Initialize the cache (called during FMLClientSetupEvent). */
|
||||
public static void init() {
|
||||
LOGGER.info("[GltfPipeline] GltfCache initialized");
|
||||
}
|
||||
}
|
||||
140
src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
Normal file
140
src/main/java/com/tiedup/remake/client/gltf/GltfClientSetup.java
Normal file
@@ -0,0 +1,140 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
|
||||
import com.tiedup.remake.client.animation.context.ContextGlbRegistry;
|
||||
import com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemReloadListener;
|
||||
import net.minecraft.client.KeyMapping;
|
||||
import net.minecraft.client.renderer.entity.player.PlayerRenderer;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import net.minecraft.util.profiling.ProfilerFiller;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.client.event.EntityRenderersEvent;
|
||||
import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
|
||||
import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
|
||||
import net.minecraftforge.event.TickEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
|
||||
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Forge event registration for the glTF pipeline.
|
||||
* Registers keybind (F9), render layers, and animation factory.
|
||||
*/
|
||||
public final class GltfClientSetup {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
|
||||
private static final String KEY_CATEGORY = "key.categories.tiedup";
|
||||
static final KeyMapping TOGGLE_KEY = new KeyMapping(
|
||||
"key.tiedup.gltf_toggle",
|
||||
InputConstants.Type.KEYSYM,
|
||||
InputConstants.KEY_F9,
|
||||
KEY_CATEGORY
|
||||
);
|
||||
|
||||
private GltfClientSetup() {}
|
||||
|
||||
/**
|
||||
* MOD bus event subscribers (FMLClientSetupEvent, RegisterKeyMappings, AddLayers).
|
||||
*/
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = "tiedup",
|
||||
bus = Mod.EventBusSubscriber.Bus.MOD,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public static class ModBusEvents {
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onClientSetup(FMLClientSetupEvent event) {
|
||||
event.enqueueWork(() -> {
|
||||
GltfCache.init();
|
||||
GltfAnimationApplier.init();
|
||||
LOGGER.info("[GltfPipeline] Client setup complete");
|
||||
});
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onRegisterKeybindings(RegisterKeyMappingsEvent event) {
|
||||
event.register(TOGGLE_KEY);
|
||||
LOGGER.info("[GltfPipeline] Keybind registered: F9");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@SubscribeEvent
|
||||
public static void onAddLayers(EntityRenderersEvent.AddLayers event) {
|
||||
// Add GltfRenderLayer (prototype/debug with F9 toggle) to player renderers
|
||||
var defaultRenderer = event.getSkin("default");
|
||||
if (defaultRenderer instanceof PlayerRenderer playerRenderer) {
|
||||
playerRenderer.addLayer(new GltfRenderLayer(playerRenderer));
|
||||
playerRenderer.addLayer(new V2BondageRenderLayer<>(playerRenderer));
|
||||
LOGGER.info("[GltfPipeline] Render layers added to 'default' player renderer");
|
||||
}
|
||||
|
||||
// Add both layers to slim player renderer (Alex)
|
||||
var slimRenderer = event.getSkin("slim");
|
||||
if (slimRenderer instanceof PlayerRenderer playerRenderer) {
|
||||
playerRenderer.addLayer(new GltfRenderLayer(playerRenderer));
|
||||
playerRenderer.addLayer(new V2BondageRenderLayer<>(playerRenderer));
|
||||
LOGGER.info("[GltfPipeline] Render layers added to 'slim' player renderer");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register resource reload listener to clear GLB caches on resource pack reload.
|
||||
* This ensures re-exported GLB models are picked up without restarting the game.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onRegisterReloadListeners(RegisterClientReloadListenersEvent event) {
|
||||
event.registerReloadListener(new SimplePreparableReloadListener<Void>() {
|
||||
@Override
|
||||
protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) {
|
||||
GltfCache.clearCache();
|
||||
GltfAnimationApplier.invalidateCache();
|
||||
GltfMeshRenderer.clearRenderTypeCache();
|
||||
// Reload context GLB animations from resource packs FIRST,
|
||||
// then clear the factory cache so it rebuilds against the
|
||||
// new GLB registry (prevents stale JSON fallback caching).
|
||||
ContextGlbRegistry.reload(resourceManager);
|
||||
ContextAnimationFactory.clearCache();
|
||||
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear();
|
||||
LOGGER.info("[GltfPipeline] Caches cleared on resource reload");
|
||||
}
|
||||
});
|
||||
LOGGER.info("[GltfPipeline] Resource reload listener registered");
|
||||
|
||||
// Data-driven bondage item definitions (tiedup_items/*.json)
|
||||
event.registerReloadListener(new DataDrivenItemReloadListener());
|
||||
LOGGER.info("[GltfPipeline] Data-driven item reload listener registered");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FORGE bus event subscribers (ClientTickEvent for keybind toggle).
|
||||
*/
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = "tiedup",
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public static class ForgeBusEvents {
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||
if (event.phase != TickEvent.Phase.END) return;
|
||||
|
||||
while (TOGGLE_KEY.consumeClick()) {
|
||||
GltfAnimationApplier.toggle();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
src/main/java/com/tiedup/remake/client/gltf/GltfData.java
Normal file
194
src/main/java/com/tiedup/remake/client/gltf/GltfData.java
Normal file
@@ -0,0 +1,194 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Immutable container for parsed glTF/GLB data.
|
||||
* Holds mesh geometry, skinning data, bone hierarchy, and optional animations.
|
||||
* <p>
|
||||
* Supports multiple named animations per GLB file. The "default" animation
|
||||
* (first clip) is accessible via {@link #animation()} and {@link #rawGltfAnimation()}
|
||||
* for backward compatibility. All animations are available via
|
||||
* {@link #namedAnimations()}.
|
||||
*/
|
||||
public final class GltfData {
|
||||
|
||||
// -- Mesh geometry (flattened arrays) --
|
||||
private final float[] positions; // VEC3, length = vertexCount * 3
|
||||
private final float[] normals; // VEC3, length = vertexCount * 3
|
||||
private final float[] texCoords; // VEC2, length = vertexCount * 2
|
||||
private final int[] indices; // triangle indices
|
||||
|
||||
// -- Skinning data (per-vertex, 4 influences) --
|
||||
private final int[] joints; // 4 joint indices per vertex, length = vertexCount * 4
|
||||
private final float[] weights; // 4 weights per vertex, length = vertexCount * 4
|
||||
|
||||
// -- Bone hierarchy (MC-converted for skinning) --
|
||||
private final String[] jointNames;
|
||||
private final int[] parentJointIndices; // -1 for root
|
||||
private final Matrix4f[] inverseBindMatrices;
|
||||
private final Quaternionf[] restRotations;
|
||||
private final Vector3f[] restTranslations;
|
||||
|
||||
// -- Raw glTF rotations (unconverted, for pose conversion) --
|
||||
private final Quaternionf[] rawGltfRestRotations;
|
||||
@Nullable
|
||||
private final AnimationClip rawGltfAnimation;
|
||||
|
||||
// -- Optional animation clip (MC-converted for skinning) --
|
||||
@Nullable
|
||||
private final AnimationClip animation;
|
||||
|
||||
// -- Multiple named animations --
|
||||
private final Map<String, AnimationClip> namedAnimations; // MC-converted
|
||||
private final Map<String, AnimationClip> rawNamedAnimations; // raw glTF space
|
||||
|
||||
// -- Per-primitive material/tint info --
|
||||
private final List<Primitive> primitives;
|
||||
|
||||
// -- Counts --
|
||||
private final int vertexCount;
|
||||
private final int jointCount;
|
||||
|
||||
/**
|
||||
* Full constructor with multiple named animations and per-primitive data.
|
||||
*/
|
||||
public GltfData(
|
||||
float[] positions, float[] normals, float[] texCoords,
|
||||
int[] indices, int[] joints, float[] weights,
|
||||
String[] jointNames, int[] parentJointIndices,
|
||||
Matrix4f[] inverseBindMatrices,
|
||||
Quaternionf[] restRotations, Vector3f[] restTranslations,
|
||||
Quaternionf[] rawGltfRestRotations,
|
||||
@Nullable AnimationClip rawGltfAnimation,
|
||||
@Nullable AnimationClip animation,
|
||||
Map<String, AnimationClip> namedAnimations,
|
||||
Map<String, AnimationClip> rawNamedAnimations,
|
||||
List<Primitive> primitives,
|
||||
int vertexCount, int jointCount
|
||||
) {
|
||||
this.positions = positions;
|
||||
this.normals = normals;
|
||||
this.texCoords = texCoords;
|
||||
this.indices = indices;
|
||||
this.joints = joints;
|
||||
this.weights = weights;
|
||||
this.jointNames = jointNames;
|
||||
this.parentJointIndices = parentJointIndices;
|
||||
this.inverseBindMatrices = inverseBindMatrices;
|
||||
this.restRotations = restRotations;
|
||||
this.restTranslations = restTranslations;
|
||||
this.rawGltfRestRotations = rawGltfRestRotations;
|
||||
this.rawGltfAnimation = rawGltfAnimation;
|
||||
this.animation = animation;
|
||||
this.namedAnimations = Collections.unmodifiableMap(new LinkedHashMap<>(namedAnimations));
|
||||
this.rawNamedAnimations = Collections.unmodifiableMap(new LinkedHashMap<>(rawNamedAnimations));
|
||||
this.primitives = List.copyOf(primitives);
|
||||
this.vertexCount = vertexCount;
|
||||
this.jointCount = jointCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy constructor for backward compatibility (single animation only).
|
||||
*/
|
||||
public GltfData(
|
||||
float[] positions, float[] normals, float[] texCoords,
|
||||
int[] indices, int[] joints, float[] weights,
|
||||
String[] jointNames, int[] parentJointIndices,
|
||||
Matrix4f[] inverseBindMatrices,
|
||||
Quaternionf[] restRotations, Vector3f[] restTranslations,
|
||||
Quaternionf[] rawGltfRestRotations,
|
||||
@Nullable AnimationClip rawGltfAnimation,
|
||||
@Nullable AnimationClip animation,
|
||||
int vertexCount, int jointCount
|
||||
) {
|
||||
this(positions, normals, texCoords, indices, joints, weights,
|
||||
jointNames, parentJointIndices, inverseBindMatrices,
|
||||
restRotations, restTranslations, rawGltfRestRotations,
|
||||
rawGltfAnimation, animation,
|
||||
new LinkedHashMap<>(), new LinkedHashMap<>(),
|
||||
List.of(new Primitive(indices, null, false, null)),
|
||||
vertexCount, jointCount);
|
||||
}
|
||||
|
||||
public float[] positions() { return positions; }
|
||||
public float[] normals() { return normals; }
|
||||
public float[] texCoords() { return texCoords; }
|
||||
public int[] indices() { return indices; }
|
||||
public int[] joints() { return joints; }
|
||||
public float[] weights() { return weights; }
|
||||
public String[] jointNames() { return jointNames; }
|
||||
public int[] parentJointIndices() { return parentJointIndices; }
|
||||
public Matrix4f[] inverseBindMatrices() { return inverseBindMatrices; }
|
||||
public Quaternionf[] restRotations() { return restRotations; }
|
||||
public Vector3f[] restTranslations() { return restTranslations; }
|
||||
public Quaternionf[] rawGltfRestRotations() { return rawGltfRestRotations; }
|
||||
@Nullable
|
||||
public AnimationClip rawGltfAnimation() { return rawGltfAnimation; }
|
||||
@Nullable
|
||||
public AnimationClip animation() { return animation; }
|
||||
public int vertexCount() { return vertexCount; }
|
||||
public int jointCount() { return jointCount; }
|
||||
|
||||
/** Per-primitive material and tint metadata. One entry per glTF primitive in the mesh. */
|
||||
public List<Primitive> primitives() { return primitives; }
|
||||
|
||||
/** All named animations in MC-converted space. Keys are animation names (e.g. "BasicPose", "Struggle"). */
|
||||
public Map<String, AnimationClip> namedAnimations() { return namedAnimations; }
|
||||
|
||||
/** Get a specific named animation in MC-converted space, or null if not found. */
|
||||
@Nullable
|
||||
public AnimationClip getAnimation(String name) { return namedAnimations.get(name); }
|
||||
|
||||
/** Get a specific named animation in raw glTF space, or null if not found. */
|
||||
@Nullable
|
||||
public AnimationClip getRawAnimation(String name) { return rawNamedAnimations.get(name); }
|
||||
|
||||
/**
|
||||
* Animation clip: per-bone timestamps, quaternion rotations, and optional translations.
|
||||
*/
|
||||
public static final class AnimationClip {
|
||||
private final float[] timestamps; // shared timestamps
|
||||
private final Quaternionf[][] rotations; // [jointIndex][frameIndex], null if no anim
|
||||
@Nullable
|
||||
private final Vector3f[][] translations; // [jointIndex][frameIndex], null if no anim
|
||||
private final int frameCount;
|
||||
|
||||
public AnimationClip(float[] timestamps, Quaternionf[][] rotations,
|
||||
@Nullable Vector3f[][] translations, int frameCount) {
|
||||
this.timestamps = timestamps;
|
||||
this.rotations = rotations;
|
||||
this.translations = translations;
|
||||
this.frameCount = frameCount;
|
||||
}
|
||||
|
||||
public float[] timestamps() { return timestamps; }
|
||||
public Quaternionf[][] rotations() { return rotations; }
|
||||
@Nullable
|
||||
public Vector3f[][] translations() { return translations; }
|
||||
public int frameCount() { return frameCount; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-primitive metadata parsed from the glTF mesh.
|
||||
* Each primitive corresponds to a material assignment in Blender.
|
||||
*
|
||||
* @param indices triangle indices for this primitive (already offset to the unified vertex buffer)
|
||||
* @param materialName the glTF material name, or null if unassigned
|
||||
* @param tintable true if the material name starts with "tintable_"
|
||||
* @param tintChannel the tint channel key (e.g. "tintable_0"), or null if not tintable
|
||||
*/
|
||||
public record Primitive(
|
||||
int[] indices,
|
||||
@Nullable String materialName,
|
||||
boolean tintable,
|
||||
@Nullable String tintChannel
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import dev.kosmx.playerAnim.core.util.Pair;
|
||||
import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
|
||||
import dev.kosmx.playerAnim.impl.animation.AnimationApplier;
|
||||
import net.minecraft.client.model.HumanoidModel;
|
||||
import net.minecraft.client.model.geom.ModelPart;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Reads the LIVE skeleton state from HumanoidModel (after PlayerAnimator + bendy-lib
|
||||
* have applied all rotations for the current frame) and produces joint matrices
|
||||
* compatible with {@link GltfSkinningEngine#skinVertex}.
|
||||
* <p>
|
||||
* KEY INSIGHT: The ModelPart xRot/yRot/zRot values set by PlayerAnimator represent
|
||||
* DELTA rotations (difference from rest pose) expressed in the MC model-def frame.
|
||||
* GltfPoseConverter computed them as parent-frame deltas, decomposed to Euler ZYX.
|
||||
* <p>
|
||||
* To reconstruct the correct LOCAL rotation for the glTF hierarchy:
|
||||
* <pre>
|
||||
* delta = rotationZYX(zRot, yRot, xRot) // MC-frame delta from ModelPart
|
||||
* localRot = delta * restQ_mc // delta applied on top of local rest
|
||||
* </pre>
|
||||
* No de-parenting is needed because both delta and restQ_mc are already in the
|
||||
* parent's local frame. The MC-to-glTF conjugation (negate qx,qy) is a homomorphism,
|
||||
* so frame relationships are preserved through the conversion.
|
||||
* <p>
|
||||
* For bones WITHOUT a MC ModelPart (root, torso), use the MC-converted rest rotation
|
||||
* directly from GltfData.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class GltfLiveBoneReader {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
|
||||
private GltfLiveBoneReader() {}
|
||||
|
||||
/**
|
||||
* Compute joint matrices by reading live skeleton state from the HumanoidModel.
|
||||
* <p>
|
||||
* For upper bones: reconstructs the MC-frame delta from ModelPart euler angles,
|
||||
* then composes with the MC-converted rest rotation to get the local rotation.
|
||||
* For lower bones: reads bend values from the entity's AnimationApplier and
|
||||
* composes the bend delta with the local rest rotation.
|
||||
* For non-animated bones: uses rest rotation from GltfData directly.
|
||||
* <p>
|
||||
* The resulting joint matrices should match {@link GltfSkinningEngine#computeJointMatrices}
|
||||
* when the player is in the rest pose (no animation active).
|
||||
*
|
||||
* @param model the HumanoidModel after PlayerAnimator has applied rotations
|
||||
* @param data parsed glTF data (MC-converted)
|
||||
* @param entity the living entity being rendered
|
||||
* @return array of joint matrices ready for skinning, or null on failure
|
||||
*/
|
||||
public static Matrix4f[] computeJointMatricesFromModel(
|
||||
HumanoidModel<?> model, GltfData data, LivingEntity entity
|
||||
) {
|
||||
if (model == null || data == null || entity == null) return null;
|
||||
|
||||
int jointCount = data.jointCount();
|
||||
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
|
||||
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
|
||||
|
||||
int[] parents = data.parentJointIndices();
|
||||
String[] jointNames = data.jointNames();
|
||||
Quaternionf[] restRotations = data.restRotations();
|
||||
Vector3f[] restTranslations = data.restTranslations();
|
||||
|
||||
// Get the AnimationApplier for bend values (may be null)
|
||||
AnimationApplier emote = getAnimationApplier(entity);
|
||||
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
String boneName = jointNames[j];
|
||||
Quaternionf localRot;
|
||||
|
||||
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||
// --- Lower bone: reconstruct from bend values ---
|
||||
localRot = computeLowerBoneLocalRotation(
|
||||
boneName, j, restRotations, emote
|
||||
);
|
||||
} else if (hasUniqueModelPart(boneName)) {
|
||||
// --- Upper bone with a unique ModelPart ---
|
||||
ModelPart part = GltfBoneMapper.getModelPart(model, boneName);
|
||||
if (part != null) {
|
||||
localRot = computeUpperBoneLocalRotation(
|
||||
part, j, restRotations
|
||||
);
|
||||
} else {
|
||||
// Fallback: use rest rotation
|
||||
localRot = new Quaternionf(restRotations[j]);
|
||||
}
|
||||
} else {
|
||||
// --- Non-animated bone (root, torso, etc.): use rest rotation ---
|
||||
localRot = new Quaternionf(restRotations[j]);
|
||||
}
|
||||
|
||||
// Build local transform: translate(restTranslation) * rotate(localRot)
|
||||
Matrix4f local = new Matrix4f();
|
||||
local.translate(restTranslations[j]);
|
||||
local.rotate(localRot);
|
||||
|
||||
// Compose with parent to get world transform
|
||||
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
|
||||
worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local);
|
||||
} else {
|
||||
worldTransforms[j] = new Matrix4f(local);
|
||||
}
|
||||
|
||||
// Final joint matrix = worldTransform * inverseBindMatrix
|
||||
jointMatrices[j] = new Matrix4f(worldTransforms[j])
|
||||
.mul(data.inverseBindMatrices()[j]);
|
||||
}
|
||||
|
||||
return jointMatrices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute local rotation for an upper bone that has a unique ModelPart.
|
||||
* <p>
|
||||
* ModelPart xRot/yRot/zRot are DELTA rotations (set by PlayerAnimator) expressed
|
||||
* as ZYX Euler angles in the MC model-def frame. These deltas were originally
|
||||
* computed by GltfPoseConverter as parent-frame quantities.
|
||||
* <p>
|
||||
* The local rotation for the glTF hierarchy is simply:
|
||||
* <pre>
|
||||
* delta = rotationZYX(zRot, yRot, xRot)
|
||||
* localRot = delta * restQ_mc
|
||||
* </pre>
|
||||
* No de-parenting is needed: both delta and restQ_mc are already in the parent's
|
||||
* frame. The MC-to-glTF negate-xy conjugation is a group homomorphism, preserving
|
||||
* the frame relationship.
|
||||
*/
|
||||
private static Quaternionf computeUpperBoneLocalRotation(
|
||||
ModelPart part, int jointIndex,
|
||||
Quaternionf[] restRotations
|
||||
) {
|
||||
// Reconstruct the MC-frame delta from ModelPart euler angles.
|
||||
Quaternionf delta = new Quaternionf().rotationZYX(part.zRot, part.yRot, part.xRot);
|
||||
// Local rotation = delta applied on top of the local rest rotation.
|
||||
return new Quaternionf(delta).mul(restRotations[jointIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute local rotation for a lower bone (elbow/knee) from bend values.
|
||||
* <p>
|
||||
* Bend values are read from the entity's AnimationApplier. The bend delta is
|
||||
* reconstructed as a quaternion rotation around the bend axis, then composed
|
||||
* with the local rest rotation:
|
||||
* <pre>
|
||||
* bendQuat = axisAngle(cos(bendAxis)*s, 0, sin(bendAxis)*s, cos(halfAngle))
|
||||
* localRot = bendQuat * restQ_mc
|
||||
* </pre>
|
||||
* No de-parenting needed — same reasoning as upper bones.
|
||||
*/
|
||||
private static Quaternionf computeLowerBoneLocalRotation(
|
||||
String boneName, int jointIndex,
|
||||
Quaternionf[] restRotations,
|
||||
AnimationApplier emote
|
||||
) {
|
||||
if (emote != null) {
|
||||
// Get the MC part name for the upper bone of this lower bone
|
||||
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||
String animPartName = (upperBone != null)
|
||||
? GltfBoneMapper.getAnimPartName(upperBone)
|
||||
: null;
|
||||
|
||||
if (animPartName != null) {
|
||||
Pair<Float, Float> bend = emote.getBend(animPartName);
|
||||
if (bend != null) {
|
||||
float bendAxis = bend.getLeft();
|
||||
float bendValue = bend.getRight();
|
||||
|
||||
// Reconstruct bend as quaternion (this is the delta)
|
||||
float ax = (float) Math.cos(bendAxis);
|
||||
float az = (float) Math.sin(bendAxis);
|
||||
float halfAngle = bendValue * 0.5f;
|
||||
float s = (float) Math.sin(halfAngle);
|
||||
Quaternionf bendQuat = new Quaternionf(
|
||||
ax * s, 0, az * s, (float) Math.cos(halfAngle)
|
||||
);
|
||||
|
||||
// Local rotation = bend delta applied on top of local rest rotation
|
||||
return new Quaternionf(bendQuat).mul(restRotations[jointIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No bend data or no AnimationApplier — use rest rotation (identity delta)
|
||||
return new Quaternionf(restRotations[jointIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a bone name corresponds to a bone that has its OWN unique ModelPart
|
||||
* (not just a mapping — it must be the PRIMARY bone for that ModelPart).
|
||||
* <p>
|
||||
* "torso" maps to model.body but "body" is the primary bone for it.
|
||||
* Lower bones share a ModelPart with their upper bone.
|
||||
* Unknown bones (e.g., "PlayerArmature") have no ModelPart at all.
|
||||
*/
|
||||
private static boolean hasUniqueModelPart(String boneName) {
|
||||
// Bones that should read their rotation from the live HumanoidModel.
|
||||
//
|
||||
// NOTE: "body" is deliberately EXCLUDED. MC's HumanoidModel is FLAT —
|
||||
// body, arms, legs, head are all siblings with ABSOLUTE rotations.
|
||||
// But the GLB skeleton is HIERARCHICAL (body → torso → arms).
|
||||
// If we read body's live rotation (e.g., attack swing yRot), it propagates
|
||||
// to arms/head through the hierarchy, but MC's flat model does NOT do this.
|
||||
// Result: cuffs mesh rotates with body during attack while arms stay put.
|
||||
//
|
||||
// Body rotation effects that matter (sneak lean, sitting) are handled by
|
||||
// LivingEntityRenderer's PoseStack transform, which applies to the entire
|
||||
// mesh uniformly. No need to read body rotation into joint matrices.
|
||||
return switch (boneName) {
|
||||
case "head" -> true;
|
||||
case "leftUpperArm" -> true;
|
||||
case "rightUpperArm"-> true;
|
||||
case "leftUpperLeg" -> true;
|
||||
case "rightUpperLeg"-> true;
|
||||
default -> false; // body, torso, lower bones, unknown
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AnimationApplier from an entity, if available.
|
||||
* Works for both players (via mixin) and NPCs implementing IAnimatedPlayer.
|
||||
*/
|
||||
private static AnimationApplier getAnimationApplier(LivingEntity entity) {
|
||||
if (entity instanceof IAnimatedPlayer animated) {
|
||||
try {
|
||||
return animated.playerAnimator_getAnimation();
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug("[GltfPipeline] Could not get AnimationApplier for {}: {}",
|
||||
entity.getClass().getSimpleName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import com.mojang.blaze3d.vertex.VertexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.RenderStateShard;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.joml.Matrix3f;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Vector4f;
|
||||
|
||||
/**
|
||||
* Submits CPU-skinned glTF mesh vertices to Minecraft's rendering pipeline.
|
||||
* Uses TRIANGLES mode RenderType (same pattern as ObjModelRenderer).
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class GltfMeshRenderer extends RenderStateShard {
|
||||
|
||||
private static final ResourceLocation WHITE_TEXTURE =
|
||||
ResourceLocation.fromNamespaceAndPath("tiedup", "models/obj/shared/white.png");
|
||||
|
||||
/** Cached default RenderType (white texture). Created once, reused every frame. */
|
||||
private static RenderType cachedDefaultRenderType;
|
||||
|
||||
/** Cache for texture-specific RenderTypes, keyed by ResourceLocation. */
|
||||
private static final Map<ResourceLocation, RenderType> RENDER_TYPE_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
private GltfMeshRenderer() {
|
||||
super("tiedup_gltf_renderer", () -> {}, () -> {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default TRIANGLES-mode RenderType (white texture), creating it once if needed.
|
||||
*/
|
||||
private static RenderType getDefaultRenderType() {
|
||||
if (cachedDefaultRenderType == null) {
|
||||
cachedDefaultRenderType = createTriangleRenderType(WHITE_TEXTURE);
|
||||
}
|
||||
return cachedDefaultRenderType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public accessor for the default RenderType (white texture).
|
||||
* Used by external renderers that need the same RenderType for tinted rendering.
|
||||
*/
|
||||
public static RenderType getRenderTypeForDefaultTexture() {
|
||||
return getDefaultRenderType();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a RenderType for a specific texture, caching it for reuse.
|
||||
*
|
||||
* @param texture the texture ResourceLocation
|
||||
* @return the cached or newly created RenderType
|
||||
*/
|
||||
private static RenderType getRenderTypeForTexture(ResourceLocation texture) {
|
||||
return RENDER_TYPE_CACHE.computeIfAbsent(texture,
|
||||
GltfMeshRenderer::createTriangleRenderType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TRIANGLES-mode RenderType for glTF mesh rendering with the given texture.
|
||||
*/
|
||||
private static RenderType createTriangleRenderType(ResourceLocation texture) {
|
||||
RenderType.CompositeState state = RenderType.CompositeState.builder()
|
||||
.setShaderState(RENDERTYPE_ENTITY_CUTOUT_NO_CULL_SHADER)
|
||||
.setTextureState(
|
||||
new RenderStateShard.TextureStateShard(texture, false, false)
|
||||
)
|
||||
.setTransparencyState(NO_TRANSPARENCY)
|
||||
.setCullState(NO_CULL)
|
||||
.setLightmapState(LIGHTMAP)
|
||||
.setOverlayState(OVERLAY)
|
||||
.createCompositeState(true);
|
||||
|
||||
return RenderType.create(
|
||||
"tiedup_gltf_triangles",
|
||||
DefaultVertexFormat.NEW_ENTITY,
|
||||
VertexFormat.Mode.TRIANGLES,
|
||||
256 * 1024,
|
||||
true,
|
||||
false,
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached RenderTypes. Call on resource reload so that re-exported
|
||||
* textures are picked up without restarting the game.
|
||||
*/
|
||||
public static void clearRenderTypeCache() {
|
||||
cachedDefaultRenderType = null;
|
||||
RENDER_TYPE_CACHE.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a skinned glTF mesh using the default white texture.
|
||||
*
|
||||
* @param data parsed glTF data
|
||||
* @param jointMatrices computed joint matrices from skinning engine
|
||||
* @param poseStack current pose stack
|
||||
* @param buffer multi-buffer source
|
||||
* @param packedLight packed light value
|
||||
* @param packedOverlay packed overlay value
|
||||
*/
|
||||
public static void renderSkinned(
|
||||
GltfData data, Matrix4f[] jointMatrices,
|
||||
PoseStack poseStack, MultiBufferSource buffer,
|
||||
int packedLight, int packedOverlay
|
||||
) {
|
||||
renderSkinnedInternal(data, jointMatrices, poseStack, buffer,
|
||||
packedLight, packedOverlay, getDefaultRenderType());
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a skinned glTF mesh using a custom texture.
|
||||
*
|
||||
* @param data parsed glTF data
|
||||
* @param jointMatrices computed joint matrices from skinning engine
|
||||
* @param poseStack current pose stack
|
||||
* @param buffer multi-buffer source
|
||||
* @param packedLight packed light value
|
||||
* @param packedOverlay packed overlay value
|
||||
* @param texture the texture to use for rendering
|
||||
*/
|
||||
public static void renderSkinned(
|
||||
GltfData data, Matrix4f[] jointMatrices,
|
||||
PoseStack poseStack, MultiBufferSource buffer,
|
||||
int packedLight, int packedOverlay,
|
||||
ResourceLocation texture
|
||||
) {
|
||||
renderSkinnedInternal(data, jointMatrices, poseStack, buffer,
|
||||
packedLight, packedOverlay, getRenderTypeForTexture(texture));
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal rendering implementation shared by both overloads.
|
||||
*/
|
||||
private static void renderSkinnedInternal(
|
||||
GltfData data, Matrix4f[] jointMatrices,
|
||||
PoseStack poseStack, MultiBufferSource buffer,
|
||||
int packedLight, int packedOverlay,
|
||||
RenderType renderType
|
||||
) {
|
||||
Matrix4f pose = poseStack.last().pose();
|
||||
Matrix3f normalMat = poseStack.last().normal();
|
||||
|
||||
VertexConsumer vc = buffer.getBuffer(renderType);
|
||||
|
||||
int[] indices = data.indices();
|
||||
float[] texCoords = data.texCoords();
|
||||
|
||||
float[] outPos = new float[3];
|
||||
float[] outNormal = new float[3];
|
||||
|
||||
// Pre-allocate scratch vectors outside the loop to avoid per-vertex allocations
|
||||
Vector4f tmpPos = new Vector4f();
|
||||
Vector4f tmpNorm = new Vector4f();
|
||||
|
||||
for (int idx : indices) {
|
||||
// Skin this vertex
|
||||
GltfSkinningEngine.skinVertex(data, idx, jointMatrices, outPos, outNormal, tmpPos, tmpNorm);
|
||||
|
||||
// UV coordinates
|
||||
float u = texCoords[idx * 2];
|
||||
float v = texCoords[idx * 2 + 1];
|
||||
|
||||
vc.vertex(pose, outPos[0], outPos[1], outPos[2])
|
||||
.color(255, 255, 255, 255)
|
||||
.uv(u, 1.0f - v)
|
||||
.overlayCoords(packedOverlay)
|
||||
.uv2(packedLight)
|
||||
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2])
|
||||
.endVertex();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a skinned glTF mesh with per-primitive tint colors.
|
||||
*
|
||||
* <p>Each primitive in the mesh is checked against the tintColors map.
|
||||
* If a primitive is tintable and its channel is present in the map,
|
||||
* the corresponding RGB color is applied as vertex color (multiplied
|
||||
* against the texture by the {@code rendertype_entity_cutout_no_cull} shader).
|
||||
* Non-tintable primitives render with white (no tint).</p>
|
||||
*
|
||||
* <p>This is a single VertexConsumer stream — all primitives share the
|
||||
* same RenderType and draw call, only the vertex color differs per range.</p>
|
||||
*
|
||||
* @param data parsed glTF data (must have primitives)
|
||||
* @param jointMatrices computed joint matrices from skinning engine
|
||||
* @param poseStack current pose stack
|
||||
* @param buffer multi-buffer source
|
||||
* @param packedLight packed light value
|
||||
* @param packedOverlay packed overlay value
|
||||
* @param renderType the RenderType to use
|
||||
* @param tintColors channel name to RGB int (0xRRGGBB); empty map = white everywhere
|
||||
*/
|
||||
public static void renderSkinnedTinted(
|
||||
GltfData data, Matrix4f[] jointMatrices,
|
||||
PoseStack poseStack, MultiBufferSource buffer,
|
||||
int packedLight, int packedOverlay,
|
||||
RenderType renderType,
|
||||
Map<String, Integer> tintColors
|
||||
) {
|
||||
Matrix4f pose = poseStack.last().pose();
|
||||
Matrix3f normalMat = poseStack.last().normal();
|
||||
|
||||
VertexConsumer vc = buffer.getBuffer(renderType);
|
||||
float[] texCoords = data.texCoords();
|
||||
|
||||
float[] outPos = new float[3];
|
||||
float[] outNormal = new float[3];
|
||||
Vector4f tmpPos = new Vector4f();
|
||||
Vector4f tmpNorm = new Vector4f();
|
||||
|
||||
List<GltfData.Primitive> primitives = data.primitives();
|
||||
|
||||
for (GltfData.Primitive prim : primitives) {
|
||||
// Determine color for this primitive
|
||||
int r = 255, g = 255, b = 255;
|
||||
if (prim.tintable() && prim.tintChannel() != null) {
|
||||
Integer colorInt = tintColors.get(prim.tintChannel());
|
||||
if (colorInt != null) {
|
||||
r = (colorInt >> 16) & 0xFF;
|
||||
g = (colorInt >> 8) & 0xFF;
|
||||
b = colorInt & 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
for (int idx : prim.indices()) {
|
||||
GltfSkinningEngine.skinVertex(data, idx, jointMatrices, outPos, outNormal, tmpPos, tmpNorm);
|
||||
|
||||
float u = texCoords[idx * 2];
|
||||
float v = texCoords[idx * 2 + 1];
|
||||
|
||||
vc.vertex(pose, outPos[0], outPos[1], outPos[2])
|
||||
.color(r, g, b, 255)
|
||||
.uv(u, 1.0f - v)
|
||||
.overlayCoords(packedOverlay)
|
||||
.uv2(packedLight)
|
||||
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2])
|
||||
.endVertex();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import dev.kosmx.playerAnim.core.data.AnimationFormat;
|
||||
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||
import dev.kosmx.playerAnim.core.util.Ease;
|
||||
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;
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Converts glTF rest pose + animation quaternions into a PlayerAnimator KeyframeAnimation.
|
||||
* <p>
|
||||
* Data is expected to be already in MC coordinate space (converted by GlbParser).
|
||||
* For upper bones: computes delta quaternion, decomposes to Euler ZYX (pitch/yaw/roll).
|
||||
* For lower bones: extracts bend angle from delta quaternion.
|
||||
* <p>
|
||||
* The GLB model's arm pivots are expected to match MC's exactly (world Y=1.376),
|
||||
* so no angle scaling is needed. If the pivots don't match, fix the Blender model.
|
||||
* <p>
|
||||
* Produces a static looping pose (beginTick=0, endTick=1, looped).
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class GltfPoseConverter {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
|
||||
private GltfPoseConverter() {}
|
||||
|
||||
/**
|
||||
* Convert a GltfData's rest pose (or first animation frame) to a KeyframeAnimation.
|
||||
* Uses the default (first) animation clip.
|
||||
* GltfData must already be in MC coordinate space.
|
||||
*
|
||||
* @param data parsed glTF data (in MC space)
|
||||
* @return a static looping KeyframeAnimation suitable for PlayerAnimator
|
||||
*/
|
||||
public static KeyframeAnimation convert(GltfData data) {
|
||||
return convertClip(data, data.rawGltfAnimation(), "gltf_pose");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a specific named animation from GltfData to a KeyframeAnimation.
|
||||
* Falls back to the default animation if the name is not found.
|
||||
*
|
||||
* @param data parsed glTF data (in MC space)
|
||||
* @param animationName the name of the animation to convert (e.g. "Struggle", "Idle")
|
||||
* @return a static looping KeyframeAnimation suitable for PlayerAnimator
|
||||
*/
|
||||
public static KeyframeAnimation convert(GltfData data, String animationName) {
|
||||
GltfData.AnimationClip rawClip = data.getRawAnimation(animationName);
|
||||
if (rawClip == null) {
|
||||
LOGGER.warn("[GltfPipeline] Animation '{}' not found, falling back to default", animationName);
|
||||
return convert(data);
|
||||
}
|
||||
return convertClip(data, rawClip, "gltf_" + animationName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a GLB animation with selective part enabling and free-bone support.
|
||||
*
|
||||
* <p>Owned parts are always enabled in the output animation. Free parts (in
|
||||
* {@code enabledParts} but not in {@code ownedParts}) are only enabled if the
|
||||
* GLB contains actual keyframe data for them. Parts not in {@code enabledParts}
|
||||
* at all are always disabled (pass through to lower layers).</p>
|
||||
*
|
||||
* @param data parsed glTF data (in MC space)
|
||||
* @param animationName animation name in GLB, or null for default
|
||||
* @param ownedParts parts the item explicitly owns (always enabled)
|
||||
* @param enabledParts parts the item may animate (owned + free); free parts
|
||||
* are only enabled if the GLB has keyframes for them
|
||||
* @return KeyframeAnimation with selective parts active
|
||||
*/
|
||||
public static KeyframeAnimation convertSelective(GltfData data, @Nullable String animationName,
|
||||
Set<String> ownedParts, Set<String> enabledParts) {
|
||||
GltfData.AnimationClip rawClip;
|
||||
String animName;
|
||||
if (animationName != null) {
|
||||
rawClip = data.getRawAnimation(animationName);
|
||||
animName = "gltf_" + animationName;
|
||||
} else {
|
||||
rawClip = null;
|
||||
animName = "gltf_pose";
|
||||
}
|
||||
if (rawClip == null) {
|
||||
rawClip = data.rawGltfAnimation();
|
||||
}
|
||||
return convertClipSelective(data, rawClip, animName, ownedParts, enabledParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: convert a specific raw animation clip with selective part enabling
|
||||
* and free-bone support.
|
||||
*
|
||||
* <p>Tracks which PlayerAnimator parts received actual keyframe data from the GLB.
|
||||
* A bone has keyframes if {@code rawClip.rotations()[jointIndex] != null}.
|
||||
* This information is used by {@link #enableSelectiveParts} to decide whether
|
||||
* free parts should be enabled.</p>
|
||||
*
|
||||
* @param ownedParts parts the item explicitly owns (always enabled)
|
||||
* @param enabledParts parts the item may animate (owned + free)
|
||||
*/
|
||||
private static KeyframeAnimation convertClipSelective(GltfData data, GltfData.AnimationClip rawClip,
|
||||
String animName, Set<String> ownedParts, Set<String> enabledParts) {
|
||||
KeyframeAnimation.AnimationBuilder builder =
|
||||
new KeyframeAnimation.AnimationBuilder(AnimationFormat.JSON_EMOTECRAFT);
|
||||
|
||||
builder.beginTick = 0;
|
||||
builder.endTick = 1;
|
||||
builder.stopTick = 1;
|
||||
builder.isLooped = true;
|
||||
builder.returnTick = 0;
|
||||
builder.name = animName;
|
||||
|
||||
String[] jointNames = data.jointNames();
|
||||
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
|
||||
|
||||
// Track which PlayerAnimator part names received actual animation data
|
||||
Set<String> partsWithKeyframes = new HashSet<>();
|
||||
|
||||
for (int j = 0; j < data.jointCount(); j++) {
|
||||
String boneName = jointNames[j];
|
||||
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
|
||||
|
||||
// Check if this joint has explicit animation data (not just rest pose fallback).
|
||||
// A bone counts as explicitly animated if it has rotation OR translation keyframes.
|
||||
boolean hasExplicitAnim = rawClip != null && (
|
||||
(j < rawClip.rotations().length && rawClip.rotations()[j] != null)
|
||||
|| (rawClip.translations() != null
|
||||
&& j < rawClip.translations().length
|
||||
&& rawClip.translations()[j] != null)
|
||||
);
|
||||
|
||||
Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j);
|
||||
Quaternionf restQ = rawRestRotations[j];
|
||||
|
||||
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
|
||||
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
|
||||
|
||||
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
|
||||
Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal)
|
||||
.mul(new Quaternionf(restQ).invert());
|
||||
|
||||
// Convert from glTF parent frame to MC model-def frame.
|
||||
// 180deg rotation around Z (X and Y differ): negate qx and qy.
|
||||
Quaternionf deltaQ = new Quaternionf(deltaParent);
|
||||
deltaQ.x = -deltaQ.x;
|
||||
deltaQ.y = -deltaQ.y;
|
||||
|
||||
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||
convertLowerBone(builder, boneName, deltaQ);
|
||||
} else {
|
||||
convertUpperBone(builder, boneName, deltaQ);
|
||||
}
|
||||
|
||||
// Record which PlayerAnimator part received data
|
||||
if (hasExplicitAnim) {
|
||||
String animPart = GltfBoneMapper.getAnimPartName(boneName);
|
||||
if (animPart != null) {
|
||||
partsWithKeyframes.add(animPart);
|
||||
}
|
||||
// For lower bones, the keyframe data goes to the upper bone's part
|
||||
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||
if (upperBone != null) {
|
||||
String upperPart = GltfBoneMapper.getAnimPartName(upperBone);
|
||||
if (upperPart != null) {
|
||||
partsWithKeyframes.add(upperPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selective: enable owned parts always, free parts only if they have keyframes
|
||||
enableSelectiveParts(builder, ownedParts, enabledParts, partsWithKeyframes);
|
||||
|
||||
KeyframeAnimation anim = builder.build();
|
||||
LOGGER.debug("[GltfPipeline] Converted selective animation '{}' (owned: {}, enabled: {}, withKeyframes: {})",
|
||||
animName, ownedParts, enabledParts, partsWithKeyframes);
|
||||
return anim;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add keyframes for specific owned parts from a GLB animation clip to an existing builder.
|
||||
*
|
||||
* <p>Only writes keyframes for bones that map to a part in {@code ownedParts}.
|
||||
* Other bones are skipped entirely. This allows multiple items to contribute
|
||||
* to the same animation builder without overwriting each other's keyframes.</p>
|
||||
*
|
||||
* @param builder the shared animation builder to add keyframes to
|
||||
* @param data parsed glTF data
|
||||
* @param rawClip the raw animation clip, or null for rest pose
|
||||
* @param ownedParts parts this item exclusively owns (only these get keyframes)
|
||||
* @return set of part names that received actual keyframe data from the GLB
|
||||
*/
|
||||
public static Set<String> addBonesToBuilder(
|
||||
KeyframeAnimation.AnimationBuilder builder,
|
||||
GltfData data, @Nullable GltfData.AnimationClip rawClip,
|
||||
Set<String> ownedParts) {
|
||||
|
||||
String[] jointNames = data.jointNames();
|
||||
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
|
||||
Set<String> partsWithKeyframes = new HashSet<>();
|
||||
|
||||
for (int j = 0; j < data.jointCount(); j++) {
|
||||
String boneName = jointNames[j];
|
||||
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
|
||||
|
||||
// Only process bones that belong to this item's owned parts
|
||||
String animPart = GltfBoneMapper.getAnimPartName(boneName);
|
||||
if (animPart == null || !ownedParts.contains(animPart)) continue;
|
||||
|
||||
// For lower bones, check if the UPPER bone's part is owned
|
||||
// (lower bone keyframes go to the upper bone's StateCollection)
|
||||
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||
if (upperBone != null) {
|
||||
String upperPart = GltfBoneMapper.getAnimPartName(upperBone);
|
||||
if (upperPart == null || !ownedParts.contains(upperPart)) continue;
|
||||
}
|
||||
}
|
||||
|
||||
boolean hasExplicitAnim = rawClip != null && (
|
||||
(j < rawClip.rotations().length && rawClip.rotations()[j] != null)
|
||||
|| (rawClip.translations() != null
|
||||
&& j < rawClip.translations().length
|
||||
&& rawClip.translations()[j] != null)
|
||||
);
|
||||
|
||||
Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j);
|
||||
Quaternionf restQ = rawRestRotations[j];
|
||||
|
||||
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
|
||||
Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal)
|
||||
.mul(new Quaternionf(restQ).invert());
|
||||
|
||||
Quaternionf deltaQ = new Quaternionf(deltaParent);
|
||||
deltaQ.x = -deltaQ.x;
|
||||
deltaQ.y = -deltaQ.y;
|
||||
|
||||
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||
convertLowerBone(builder, boneName, deltaQ);
|
||||
} else {
|
||||
convertUpperBone(builder, boneName, deltaQ);
|
||||
}
|
||||
|
||||
if (hasExplicitAnim) {
|
||||
partsWithKeyframes.add(animPart);
|
||||
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||
if (upperBone != null) {
|
||||
String upperPart = GltfBoneMapper.getAnimPartName(upperBone);
|
||||
if (upperPart != null) partsWithKeyframes.add(upperPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return partsWithKeyframes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an animation clip using skeleton data from a separate source.
|
||||
*
|
||||
* <p>This is useful when the animation clip is stored separately from the
|
||||
* skeleton (e.g., furniture seat animations where the Player_* armature's
|
||||
* clips are parsed into a separate map from the skeleton GltfData).</p>
|
||||
*
|
||||
* <p>The resulting animation has all parts fully enabled. Callers should
|
||||
* create a mutable copy and selectively disable parts as needed.</p>
|
||||
*
|
||||
* @param skeleton the GltfData providing rest pose, joint names, and joint count
|
||||
* @param clip the raw animation clip (in glTF space) to convert
|
||||
* @param animName debug name for the resulting animation
|
||||
* @return a static looping KeyframeAnimation with all parts enabled
|
||||
*/
|
||||
public static KeyframeAnimation convertWithSkeleton(
|
||||
GltfData skeleton, GltfData.AnimationClip clip, String animName) {
|
||||
return convertClip(skeleton, clip, animName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: convert a specific raw animation clip to a KeyframeAnimation.
|
||||
*/
|
||||
private static KeyframeAnimation convertClip(GltfData data, GltfData.AnimationClip rawClip, String animName) {
|
||||
KeyframeAnimation.AnimationBuilder builder =
|
||||
new KeyframeAnimation.AnimationBuilder(AnimationFormat.JSON_EMOTECRAFT);
|
||||
|
||||
builder.beginTick = 0;
|
||||
builder.endTick = 1;
|
||||
builder.stopTick = 1;
|
||||
builder.isLooped = true;
|
||||
builder.returnTick = 0;
|
||||
builder.name = animName;
|
||||
|
||||
String[] jointNames = data.jointNames();
|
||||
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
|
||||
|
||||
for (int j = 0; j < data.jointCount(); j++) {
|
||||
String boneName = jointNames[j];
|
||||
|
||||
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
|
||||
|
||||
Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j);
|
||||
Quaternionf restQ = rawRestRotations[j];
|
||||
|
||||
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
|
||||
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
|
||||
|
||||
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
|
||||
// Simplifies algebraically to: animQ * inv(restQ)
|
||||
Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal)
|
||||
.mul(new Quaternionf(restQ).invert());
|
||||
|
||||
// Convert from glTF parent frame to MC model-def frame.
|
||||
// 180° rotation around Z (X and Y differ): negate qx and qy.
|
||||
Quaternionf deltaQ = new Quaternionf(deltaParent);
|
||||
deltaQ.x = -deltaQ.x;
|
||||
deltaQ.y = -deltaQ.y;
|
||||
|
||||
LOGGER.debug(String.format(
|
||||
"[GltfPipeline] Bone '%s': restQ=(%.3f,%.3f,%.3f,%.3f) animQ=(%.3f,%.3f,%.3f,%.3f) deltaQ=(%.3f,%.3f,%.3f,%.3f)",
|
||||
boneName,
|
||||
restQ.x, restQ.y, restQ.z, restQ.w,
|
||||
animQ.x, animQ.y, animQ.z, animQ.w,
|
||||
deltaQ.x, deltaQ.y, deltaQ.z, deltaQ.w));
|
||||
|
||||
if (GltfBoneMapper.isLowerBone(boneName)) {
|
||||
convertLowerBone(builder, boneName, deltaQ);
|
||||
} else {
|
||||
convertUpperBone(builder, boneName, deltaQ);
|
||||
}
|
||||
}
|
||||
|
||||
builder.fullyEnableParts();
|
||||
|
||||
KeyframeAnimation anim = builder.build();
|
||||
LOGGER.debug("[GltfPipeline] Converted glTF animation '{}' to KeyframeAnimation", animName);
|
||||
return anim;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw animation quaternion for a joint from a specific clip.
|
||||
* Falls back to rest rotation if the clip is null or has no data for this joint.
|
||||
*/
|
||||
private static Quaternionf getRawAnimQuaternion(
|
||||
GltfData.AnimationClip rawClip, Quaternionf[] rawRestRotations, int jointIndex
|
||||
) {
|
||||
if (rawClip != null && jointIndex < rawClip.rotations().length
|
||||
&& rawClip.rotations()[jointIndex] != null) {
|
||||
return rawClip.rotations()[jointIndex][0]; // first frame
|
||||
}
|
||||
return rawRestRotations[jointIndex]; // fallback to rest
|
||||
}
|
||||
|
||||
private static void convertUpperBone(
|
||||
KeyframeAnimation.AnimationBuilder builder,
|
||||
String boneName, Quaternionf deltaQ
|
||||
) {
|
||||
// Decompose delta quaternion to Euler ZYX
|
||||
// JOML's getEulerAnglesZYX stores: euler.x = X rotation, euler.y = Y rotation, euler.z = Z rotation
|
||||
// (the "ZYX" refers to rotation ORDER, not storage order)
|
||||
Vector3f euler = new Vector3f();
|
||||
deltaQ.getEulerAnglesZYX(euler);
|
||||
float pitch = euler.x; // X rotation (pitch)
|
||||
float yaw = euler.y; // Y rotation (yaw)
|
||||
float roll = euler.z; // Z rotation (roll)
|
||||
|
||||
LOGGER.debug(String.format(
|
||||
"[GltfPipeline] Upper bone '%s': pitch=%.1f° yaw=%.1f° roll=%.1f°",
|
||||
boneName,
|
||||
Math.toDegrees(pitch),
|
||||
Math.toDegrees(yaw),
|
||||
Math.toDegrees(roll)));
|
||||
|
||||
// Get the StateCollection for this body part
|
||||
String animPart = GltfBoneMapper.getAnimPartName(boneName);
|
||||
if (animPart == null) return;
|
||||
|
||||
KeyframeAnimation.StateCollection part = getPartByName(builder, animPart);
|
||||
if (part == null) return;
|
||||
|
||||
part.pitch.addKeyFrame(0, pitch, Ease.CONSTANT);
|
||||
part.yaw.addKeyFrame(0, yaw, Ease.CONSTANT);
|
||||
part.roll.addKeyFrame(0, roll, Ease.CONSTANT);
|
||||
}
|
||||
|
||||
private static void convertLowerBone(
|
||||
KeyframeAnimation.AnimationBuilder builder,
|
||||
String boneName, Quaternionf deltaQ
|
||||
) {
|
||||
// Extract bend angle and axis from the delta quaternion
|
||||
float angle = 2.0f * (float) Math.acos(
|
||||
Math.min(1.0, Math.abs(deltaQ.w))
|
||||
);
|
||||
|
||||
// Determine bend direction from axis
|
||||
float bendDirection = 0.0f;
|
||||
if (deltaQ.x * deltaQ.x + deltaQ.z * deltaQ.z > 0.001f) {
|
||||
bendDirection = (float) Math.atan2(deltaQ.z, deltaQ.x);
|
||||
}
|
||||
|
||||
// Sign: if w is negative, the angle wraps
|
||||
if (deltaQ.w < 0) {
|
||||
angle = -angle;
|
||||
}
|
||||
|
||||
LOGGER.debug(String.format(
|
||||
"[GltfPipeline] Lower bone '%s': bendAngle=%.1f° bendDir=%.1f°",
|
||||
boneName,
|
||||
Math.toDegrees(angle),
|
||||
Math.toDegrees(bendDirection)));
|
||||
|
||||
// Apply bend to the upper bone's StateCollection
|
||||
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
|
||||
if (upperBone == null) return;
|
||||
|
||||
String animPart = GltfBoneMapper.getAnimPartName(upperBone);
|
||||
if (animPart == null) return;
|
||||
|
||||
KeyframeAnimation.StateCollection part = getPartByName(builder, animPart);
|
||||
if (part == null || !part.isBendable) return;
|
||||
|
||||
part.bend.addKeyFrame(0, angle, Ease.CONSTANT);
|
||||
part.bendDirection.addKeyFrame(0, bendDirection, Ease.CONSTANT);
|
||||
}
|
||||
|
||||
private static KeyframeAnimation.StateCollection getPartByName(
|
||||
KeyframeAnimation.AnimationBuilder builder, String name
|
||||
) {
|
||||
return switch (name) {
|
||||
case "head" -> builder.head;
|
||||
case "body" -> builder.body;
|
||||
case "rightArm" -> builder.rightArm;
|
||||
case "leftArm" -> builder.leftArm;
|
||||
case "rightLeg" -> builder.rightLeg;
|
||||
case "leftLeg" -> builder.leftLeg;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable parts selectively based on ownership and keyframe presence.
|
||||
*
|
||||
* <ul>
|
||||
* <li>Owned parts: always enabled (the item controls these bones)</li>
|
||||
* <li>Free parts WITH keyframes: enabled (the GLB has animation data for them)</li>
|
||||
* <li>Free parts WITHOUT keyframes: disabled (no data to animate, pass through to context)</li>
|
||||
* <li>Other items' parts: disabled (pass through to their own layer)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param builder the animation builder with keyframes already added
|
||||
* @param ownedParts parts the item explicitly owns (always enabled)
|
||||
* @param enabledParts parts the item may animate (owned + free)
|
||||
* @param partsWithKeyframes parts that received actual animation data from the GLB
|
||||
*/
|
||||
private static void enableSelectiveParts(
|
||||
KeyframeAnimation.AnimationBuilder builder,
|
||||
Set<String> ownedParts, Set<String> enabledParts,
|
||||
Set<String> partsWithKeyframes) {
|
||||
String[] allParts = {"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"};
|
||||
for (String partName : allParts) {
|
||||
KeyframeAnimation.StateCollection part = getPartByName(builder, partName);
|
||||
if (part != null) {
|
||||
if (ownedParts.contains(partName)) {
|
||||
// Always enable owned parts — the item controls these bones
|
||||
part.fullyEnablePart(false);
|
||||
} else if (enabledParts.contains(partName) && partsWithKeyframes.contains(partName)) {
|
||||
// Free part WITH keyframes: enable so the GLB animation drives it
|
||||
part.fullyEnablePart(false);
|
||||
} else {
|
||||
// Other item's part, or free part without keyframes: disable.
|
||||
// Disabled parts pass through to the lower-priority context layer.
|
||||
part.setEnabled(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.model.PlayerModel;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.entity.RenderLayerParent;
|
||||
import net.minecraft.client.renderer.entity.layers.RenderLayer;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.joml.Matrix4f;
|
||||
|
||||
/**
|
||||
* RenderLayer that renders the glTF mesh (handcuffs) on the player.
|
||||
* Only active when enabled and only renders on the local player.
|
||||
* <p>
|
||||
* Uses the live skinning path: reads live skeleton from HumanoidModel
|
||||
* via {@link GltfLiveBoneReader}, following PlayerAnimator + bendy-lib rotations.
|
||||
* Falls back to GLB-internal skinning via {@link GltfSkinningEngine} if live reading fails.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class GltfRenderLayer
|
||||
extends RenderLayer<AbstractClientPlayer, PlayerModel<AbstractClientPlayer>> {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
|
||||
private static final ResourceLocation CUFFS_MODEL =
|
||||
ResourceLocation.fromNamespaceAndPath(
|
||||
"tiedup", "models/gltf/v2/handcuffs/cuffs_prototype.glb"
|
||||
);
|
||||
|
||||
public GltfRenderLayer(
|
||||
RenderLayerParent<AbstractClientPlayer, PlayerModel<AbstractClientPlayer>> renderer
|
||||
) {
|
||||
super(renderer);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Y translate offset to place the glTF mesh in the MC PoseStack.
|
||||
* <p>
|
||||
* After LivingEntityRenderer's scale(-1,-1,1) + translate(0,-1.501,0),
|
||||
* the PoseStack origin is at the model top (1.501 blocks above feet), Y-down.
|
||||
* The glTF mesh (MC-converted) has feet at Y=0 and head at Y≈-1.5.
|
||||
* Translating by 1.501 maps glTF feet to PoseStack feet and head to top.
|
||||
*/
|
||||
private static final float ALIGNMENT_Y = 1.501f;
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource buffer,
|
||||
int packedLight,
|
||||
AbstractClientPlayer entity,
|
||||
float limbSwing,
|
||||
float limbSwingAmount,
|
||||
float partialTick,
|
||||
float ageInTicks,
|
||||
float netHeadYaw,
|
||||
float headPitch
|
||||
) {
|
||||
if (!GltfAnimationApplier.isEnabled()) return;
|
||||
if (entity != Minecraft.getInstance().player) return;
|
||||
|
||||
GltfData data = GltfCache.get(CUFFS_MODEL);
|
||||
if (data == null) return;
|
||||
|
||||
// Live path: read skeleton from HumanoidModel (after PlayerAnimator)
|
||||
PlayerModel<AbstractClientPlayer> parentModel = this.getParentModel();
|
||||
Matrix4f[] joints = GltfLiveBoneReader.computeJointMatricesFromModel(
|
||||
parentModel, data, entity
|
||||
);
|
||||
if (joints == null) {
|
||||
// Fallback to GLB-internal path if live reading fails
|
||||
joints = GltfSkinningEngine.computeJointMatrices(data);
|
||||
}
|
||||
|
||||
poseStack.pushPose();
|
||||
|
||||
// Align glTF mesh with MC model (feet-to-feet alignment)
|
||||
poseStack.translate(0, ALIGNMENT_Y, 0);
|
||||
|
||||
GltfMeshRenderer.renderSkinned(
|
||||
data, joints, poseStack, buffer,
|
||||
packedLight,
|
||||
net.minecraft.client.renderer.entity.LivingEntityRenderer
|
||||
.getOverlayCoords(entity, 0.0f)
|
||||
);
|
||||
poseStack.popPose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.tiedup.remake.client.gltf;
|
||||
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.joml.Matrix4f;
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
import org.joml.Vector4f;
|
||||
|
||||
/**
|
||||
* CPU-based Linear Blend Skinning (LBS) engine.
|
||||
* Computes joint matrices purely from glTF data (rest translations + animation rotations).
|
||||
* All data is in MC-converted space (consistent with IBMs and vertex positions).
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class GltfSkinningEngine {
|
||||
|
||||
private GltfSkinningEngine() {}
|
||||
|
||||
/**
|
||||
* Compute joint matrices from glTF animation/rest data (default animation).
|
||||
* Each joint matrix = worldTransform * inverseBindMatrix.
|
||||
* Uses MC-converted glTF data throughout for consistency.
|
||||
*
|
||||
* @param data parsed glTF data (MC-converted)
|
||||
* @return array of joint matrices ready for skinning
|
||||
*/
|
||||
public static Matrix4f[] computeJointMatrices(GltfData data) {
|
||||
return computeJointMatricesFromClip(data, data.animation());
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute joint matrices with frame interpolation for animated entities.
|
||||
* Uses SLERP for rotations and LERP for translations between adjacent keyframes.
|
||||
*
|
||||
* <p>The {@code time} parameter is in frame-space: 0.0 corresponds to the first
|
||||
* keyframe and {@code frameCount - 1} to the last. Values between integer frames
|
||||
* are interpolated. Out-of-range values are clamped.</p>
|
||||
*
|
||||
* @param data the parsed glTF data (MC-converted)
|
||||
* @param clip the animation clip to sample (null = rest pose for all joints)
|
||||
* @param time time in frame-space (0.0 = first frame, N-1 = last frame)
|
||||
* @return interpolated joint matrices ready for skinning
|
||||
*/
|
||||
public static Matrix4f[] computeJointMatricesAnimated(
|
||||
GltfData data, GltfData.AnimationClip clip, float time
|
||||
) {
|
||||
int jointCount = data.jointCount();
|
||||
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
|
||||
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
|
||||
|
||||
int[] parents = data.parentJointIndices();
|
||||
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
// Build local transform: translate(interpT) * rotate(interpQ)
|
||||
Matrix4f local = new Matrix4f();
|
||||
local.translate(getInterpolatedTranslation(data, clip, j, time));
|
||||
local.rotate(getInterpolatedRotation(data, clip, j, time));
|
||||
|
||||
// Compose with parent
|
||||
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
|
||||
worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local);
|
||||
} else {
|
||||
worldTransforms[j] = new Matrix4f(local);
|
||||
}
|
||||
|
||||
// Final joint matrix = worldTransform * inverseBindMatrix
|
||||
jointMatrices[j] = new Matrix4f(worldTransforms[j])
|
||||
.mul(data.inverseBindMatrices()[j]);
|
||||
}
|
||||
|
||||
return jointMatrices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: compute joint matrices from a specific animation clip.
|
||||
*/
|
||||
private static Matrix4f[] computeJointMatricesFromClip(GltfData data, GltfData.AnimationClip clip) {
|
||||
int jointCount = data.jointCount();
|
||||
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
|
||||
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
|
||||
|
||||
int[] parents = data.parentJointIndices();
|
||||
|
||||
for (int j = 0; j < jointCount; j++) {
|
||||
// Build local transform: translate(animT or restT) * rotate(animQ or restQ)
|
||||
Matrix4f local = new Matrix4f();
|
||||
local.translate(getAnimTranslation(data, clip, j));
|
||||
local.rotate(getAnimRotation(data, clip, j));
|
||||
|
||||
// Compose with parent
|
||||
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
|
||||
worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local);
|
||||
} else {
|
||||
worldTransforms[j] = new Matrix4f(local);
|
||||
}
|
||||
|
||||
// Final joint matrix = worldTransform * inverseBindMatrix
|
||||
jointMatrices[j] = new Matrix4f(worldTransforms[j])
|
||||
.mul(data.inverseBindMatrices()[j]);
|
||||
}
|
||||
|
||||
return jointMatrices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the animation rotation for a joint (MC-converted).
|
||||
* Falls back to rest rotation if no animation.
|
||||
*/
|
||||
private static Quaternionf getAnimRotation(GltfData data, GltfData.AnimationClip clip, int jointIndex) {
|
||||
if (clip != null && jointIndex < clip.rotations().length
|
||||
&& clip.rotations()[jointIndex] != null) {
|
||||
return clip.rotations()[jointIndex][0]; // first frame
|
||||
}
|
||||
return data.restRotations()[jointIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the animation translation for a joint (MC-converted).
|
||||
* Falls back to rest translation if no animation translation exists.
|
||||
*/
|
||||
private static Vector3f getAnimTranslation(GltfData data, GltfData.AnimationClip clip, int jointIndex) {
|
||||
if (clip != null && clip.translations() != null
|
||||
&& jointIndex < clip.translations().length
|
||||
&& clip.translations()[jointIndex] != null) {
|
||||
return clip.translations()[jointIndex][0]; // first frame
|
||||
}
|
||||
return data.restTranslations()[jointIndex];
|
||||
}
|
||||
|
||||
// ---- Interpolated accessors (for computeJointMatricesAnimated) ----
|
||||
|
||||
/**
|
||||
* Get an interpolated rotation for a joint at a fractional frame time.
|
||||
* Uses SLERP between the two bounding keyframes.
|
||||
*
|
||||
* <p>Falls back to rest rotation when the clip is null or has no rotation
|
||||
* data for the given joint. A single-frame channel returns that frame directly.</p>
|
||||
*
|
||||
* @param data parsed glTF data
|
||||
* @param clip animation clip (may be null)
|
||||
* @param jointIndex joint to query
|
||||
* @param time frame-space time (clamped internally)
|
||||
* @return new Quaternionf with the interpolated rotation (never mutates source data)
|
||||
*/
|
||||
private static Quaternionf getInterpolatedRotation(
|
||||
GltfData data, GltfData.AnimationClip clip, int jointIndex, float time
|
||||
) {
|
||||
if (clip == null || jointIndex >= clip.rotations().length
|
||||
|| clip.rotations()[jointIndex] == null) {
|
||||
// No animation data for this joint -- use rest pose (copy to avoid mutation)
|
||||
Quaternionf rest = data.restRotations()[jointIndex];
|
||||
return new Quaternionf(rest);
|
||||
}
|
||||
|
||||
Quaternionf[] frames = clip.rotations()[jointIndex];
|
||||
if (frames.length == 1) {
|
||||
return new Quaternionf(frames[0]);
|
||||
}
|
||||
|
||||
// Clamp time to valid range [0, frameCount-1]
|
||||
float clamped = Math.max(0.0f, Math.min(time, frames.length - 1));
|
||||
int f0 = (int) Math.floor(clamped);
|
||||
int f1 = Math.min(f0 + 1, frames.length - 1);
|
||||
float alpha = clamped - f0;
|
||||
|
||||
if (alpha < 1e-6f || f0 == f1) {
|
||||
return new Quaternionf(frames[f0]);
|
||||
}
|
||||
|
||||
// SLERP: create a copy of frame0 and slerp toward frame1
|
||||
return new Quaternionf(frames[f0]).slerp(frames[f1], alpha);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an interpolated translation for a joint at a fractional frame time.
|
||||
* Uses LERP between the two bounding keyframes.
|
||||
*
|
||||
* <p>Falls back to rest translation when the clip is null, the clip has no
|
||||
* translation data at all, or has no translation data for the given joint.
|
||||
* A single-frame channel returns that frame directly.</p>
|
||||
*
|
||||
* @param data parsed glTF data
|
||||
* @param clip animation clip (may be null)
|
||||
* @param jointIndex joint to query
|
||||
* @param time frame-space time (clamped internally)
|
||||
* @return new Vector3f with the interpolated translation (never mutates source data)
|
||||
*/
|
||||
private static Vector3f getInterpolatedTranslation(
|
||||
GltfData data, GltfData.AnimationClip clip, int jointIndex, float time
|
||||
) {
|
||||
if (clip == null || clip.translations() == null
|
||||
|| jointIndex >= clip.translations().length
|
||||
|| clip.translations()[jointIndex] == null) {
|
||||
// No animation data for this joint -- use rest pose (copy to avoid mutation)
|
||||
Vector3f rest = data.restTranslations()[jointIndex];
|
||||
return new Vector3f(rest);
|
||||
}
|
||||
|
||||
Vector3f[] frames = clip.translations()[jointIndex];
|
||||
if (frames.length == 1) {
|
||||
return new Vector3f(frames[0]);
|
||||
}
|
||||
|
||||
// Clamp time to valid range [0, frameCount-1]
|
||||
float clamped = Math.max(0.0f, Math.min(time, frames.length - 1));
|
||||
int f0 = (int) Math.floor(clamped);
|
||||
int f1 = Math.min(f0 + 1, frames.length - 1);
|
||||
float alpha = clamped - f0;
|
||||
|
||||
if (alpha < 1e-6f || f0 == f1) {
|
||||
return new Vector3f(frames[f0]);
|
||||
}
|
||||
|
||||
// LERP: create a copy of frame0 and lerp toward frame1
|
||||
return new Vector3f(frames[f0]).lerp(frames[f1], alpha);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skin a single vertex using Linear Blend Skinning.
|
||||
*
|
||||
* <p>Callers should pre-allocate {@code tmpPos} and {@code tmpNorm} and reuse
|
||||
* them across all vertices in a mesh to avoid per-vertex allocations (12k+
|
||||
* allocations per frame for a typical mesh).</p>
|
||||
*
|
||||
* @param data parsed glTF data
|
||||
* @param vertexIdx index into the vertex arrays
|
||||
* @param jointMatrices joint matrices from computeJointMatrices
|
||||
* @param outPos output skinned position (3 floats)
|
||||
* @param outNormal output skinned normal (3 floats)
|
||||
* @param tmpPos pre-allocated scratch Vector4f for position transforms
|
||||
* @param tmpNorm pre-allocated scratch Vector4f for normal transforms
|
||||
*/
|
||||
public static void skinVertex(
|
||||
GltfData data, int vertexIdx, Matrix4f[] jointMatrices,
|
||||
float[] outPos, float[] outNormal,
|
||||
Vector4f tmpPos, Vector4f tmpNorm
|
||||
) {
|
||||
float[] positions = data.positions();
|
||||
float[] normals = data.normals();
|
||||
int[] joints = data.joints();
|
||||
float[] weights = data.weights();
|
||||
|
||||
// Rest position
|
||||
float vx = positions[vertexIdx * 3];
|
||||
float vy = positions[vertexIdx * 3 + 1];
|
||||
float vz = positions[vertexIdx * 3 + 2];
|
||||
|
||||
// Rest normal
|
||||
float nx = normals[vertexIdx * 3];
|
||||
float ny = normals[vertexIdx * 3 + 1];
|
||||
float nz = normals[vertexIdx * 3 + 2];
|
||||
|
||||
// LBS: v_skinned = Σ(w[i] * jointMatrix[j[i]] * v_rest)
|
||||
float sx = 0, sy = 0, sz = 0;
|
||||
float snx = 0, sny = 0, snz = 0;
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int ji = joints[vertexIdx * 4 + i];
|
||||
float w = weights[vertexIdx * 4 + i];
|
||||
if (w <= 0.0f || ji >= jointMatrices.length) continue;
|
||||
|
||||
Matrix4f jm = jointMatrices[ji];
|
||||
|
||||
// Transform position
|
||||
tmpPos.set(vx, vy, vz, 1.0f);
|
||||
jm.transform(tmpPos);
|
||||
sx += w * tmpPos.x;
|
||||
sy += w * tmpPos.y;
|
||||
sz += w * tmpPos.z;
|
||||
|
||||
// Transform normal (ignore translation)
|
||||
tmpNorm.set(nx, ny, nz, 0.0f);
|
||||
jm.transform(tmpNorm);
|
||||
snx += w * tmpNorm.x;
|
||||
sny += w * tmpNorm.y;
|
||||
snz += w * tmpNorm.z;
|
||||
}
|
||||
|
||||
outPos[0] = sx;
|
||||
outPos[1] = sy;
|
||||
outPos[2] = sz;
|
||||
|
||||
// Normalize the normal
|
||||
float len = (float) Math.sqrt(snx * snx + sny * sny + snz * snz);
|
||||
if (len > 0.0001f) {
|
||||
outNormal[0] = snx / len;
|
||||
outNormal[1] = sny / len;
|
||||
outNormal[2] = snz / len;
|
||||
} else {
|
||||
outNormal[0] = 0;
|
||||
outNormal[1] = 1;
|
||||
outNormal[2] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.tiedup.remake.client.gui.overlays;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||
import com.tiedup.remake.client.state.ClientLaborState;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
|
||||
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Overlay that shows labor task progress bar.
|
||||
* Displayed in the top-right corner when a labor task is active.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
|
||||
public class LaborProgressOverlay {
|
||||
|
||||
// Bar dimensions
|
||||
private static final int BAR_WIDTH = 150;
|
||||
private static final int BAR_HEIGHT = 10;
|
||||
private static final int PADDING = 6;
|
||||
private static final int MARGIN = 10;
|
||||
|
||||
// Animation
|
||||
private static float smoothProgress = 0.0f;
|
||||
private static final float SMOOTH_SPEED = 0.1f;
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
|
||||
// Render after hotbar
|
||||
if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have an active task
|
||||
if (!ClientLaborState.hasActiveTask()) {
|
||||
// Fade out smoothly
|
||||
smoothProgress = Math.max(0, smoothProgress - SMOOTH_SPEED);
|
||||
if (smoothProgress <= 0.01f) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Smooth interpolation towards target
|
||||
float target = ClientLaborState.getProgressFraction();
|
||||
smoothProgress += (target - smoothProgress) * SMOOTH_SPEED;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
GuiGraphics graphics = event.getGuiGraphics();
|
||||
int screenWidth = mc.getWindow().getGuiScaledWidth();
|
||||
|
||||
// Position in top-right corner
|
||||
int x = screenWidth - BAR_WIDTH - PADDING * 2 - MARGIN;
|
||||
int y = MARGIN;
|
||||
|
||||
// Calculate panel dimensions
|
||||
int panelWidth = BAR_WIDTH + PADDING * 2;
|
||||
int panelHeight = BAR_HEIGHT + PADDING * 2 + mc.font.lineHeight * 2 + 6;
|
||||
|
||||
// Background panel
|
||||
graphics.fill(
|
||||
x,
|
||||
y,
|
||||
x + panelWidth,
|
||||
y + panelHeight,
|
||||
GuiColors.withAlpha(GuiColors.BG_DARK, 220)
|
||||
);
|
||||
|
||||
// Border
|
||||
GuiRenderUtil.drawBorder(
|
||||
graphics,
|
||||
x,
|
||||
y,
|
||||
panelWidth,
|
||||
panelHeight,
|
||||
GuiColors.ACCENT_TAN
|
||||
);
|
||||
|
||||
// Task description
|
||||
String description = ClientLaborState.getTaskDescription();
|
||||
graphics.drawString(
|
||||
mc.font,
|
||||
description,
|
||||
x + PADDING,
|
||||
y + PADDING,
|
||||
GuiColors.TEXT_WHITE,
|
||||
false
|
||||
);
|
||||
|
||||
// Progress bar position
|
||||
int barX = x + PADDING;
|
||||
int barY = y + PADDING + mc.font.lineHeight + 4;
|
||||
|
||||
// Progress bar background
|
||||
graphics.fill(
|
||||
barX,
|
||||
barY,
|
||||
barX + BAR_WIDTH,
|
||||
barY + BAR_HEIGHT,
|
||||
GuiColors.BG_LIGHT
|
||||
);
|
||||
|
||||
// Progress bar fill
|
||||
int fillWidth = (int) (BAR_WIDTH * smoothProgress);
|
||||
int fillColor =
|
||||
smoothProgress >= 1.0f ? GuiColors.SUCCESS : GuiColors.ACCENT_TAN;
|
||||
graphics.fill(
|
||||
barX,
|
||||
barY,
|
||||
barX + fillWidth,
|
||||
barY + BAR_HEIGHT,
|
||||
fillColor
|
||||
);
|
||||
|
||||
// Progress bar border
|
||||
GuiRenderUtil.drawBorder(
|
||||
graphics,
|
||||
barX,
|
||||
barY,
|
||||
BAR_WIDTH,
|
||||
BAR_HEIGHT,
|
||||
GuiColors.BORDER_LIGHT
|
||||
);
|
||||
|
||||
// Progress text inside bar
|
||||
String progressStr = ClientLaborState.getProgressString();
|
||||
int textWidth = mc.font.width(progressStr);
|
||||
graphics.drawString(
|
||||
mc.font,
|
||||
progressStr,
|
||||
barX + (BAR_WIDTH - textWidth) / 2,
|
||||
barY + (BAR_HEIGHT - mc.font.lineHeight) / 2 + 1,
|
||||
GuiColors.TEXT_WHITE,
|
||||
false
|
||||
);
|
||||
|
||||
// Value text below bar
|
||||
String valueStr = ClientLaborState.getValueEmeralds() + " emeralds";
|
||||
graphics.drawString(
|
||||
mc.font,
|
||||
valueStr,
|
||||
x + PADDING,
|
||||
barY + BAR_HEIGHT + 2,
|
||||
GuiColors.TEXT_GRAY,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.tiedup.remake.client.gui.overlays;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.tasks.PlayerStateTask;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
|
||||
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Overlay that shows a progress bar for tying/untying/struggling actions.
|
||||
* Displayed above the hotbar when an action is in progress.
|
||||
*
|
||||
* Phase 16: GUI Revamp - Progress bar overlay
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
|
||||
public class ProgressOverlay {
|
||||
|
||||
// Bar dimensions
|
||||
private static final int BAR_WIDTH = 200;
|
||||
private static final int BAR_HEIGHT = 12;
|
||||
private static final int PADDING = 4;
|
||||
|
||||
// Animation
|
||||
private static float smoothProgress = 0.0f;
|
||||
private static final float SMOOTH_SPEED = 0.15f;
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
|
||||
// Render after crosshair
|
||||
if (event.getOverlay() != VanillaGuiOverlay.CROSSHAIR.type()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
Player player = mc.player;
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for active actions
|
||||
ProgressInfo info = getActiveProgress(state);
|
||||
if (info == null) {
|
||||
// Fade out smoothly
|
||||
smoothProgress = Math.max(0, smoothProgress - SMOOTH_SPEED);
|
||||
if (smoothProgress <= 0.01f) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Smooth interpolation
|
||||
smoothProgress += (info.progress - smoothProgress) * SMOOTH_SPEED;
|
||||
}
|
||||
|
||||
if (info == null && smoothProgress <= 0.01f) {
|
||||
return;
|
||||
}
|
||||
|
||||
GuiGraphics graphics = event.getGuiGraphics();
|
||||
int screenWidth = mc.getWindow().getGuiScaledWidth();
|
||||
int screenHeight = mc.getWindow().getGuiScaledHeight();
|
||||
|
||||
// Center horizontally, above hotbar
|
||||
int x = (screenWidth - BAR_WIDTH) / 2;
|
||||
int y = screenHeight - 60;
|
||||
|
||||
// Background panel
|
||||
int panelHeight = BAR_HEIGHT + PADDING * 2 + 12; // +12 for text
|
||||
graphics.fill(
|
||||
x - PADDING,
|
||||
y - PADDING,
|
||||
x + BAR_WIDTH + PADDING,
|
||||
y + panelHeight,
|
||||
GuiColors.withAlpha(GuiColors.BG_DARK, 200)
|
||||
);
|
||||
|
||||
// Progress bar background
|
||||
graphics.fill(x, y, x + BAR_WIDTH, y + BAR_HEIGHT, GuiColors.BG_LIGHT);
|
||||
|
||||
// Progress bar fill
|
||||
int fillWidth = (int) (BAR_WIDTH * smoothProgress);
|
||||
int fillColor = info != null ? info.color : GuiColors.ACCENT_TAN;
|
||||
graphics.fill(x, y, x + fillWidth, y + BAR_HEIGHT, fillColor);
|
||||
|
||||
// Border
|
||||
GuiRenderUtil.drawBorder(
|
||||
graphics,
|
||||
x,
|
||||
y,
|
||||
BAR_WIDTH,
|
||||
BAR_HEIGHT,
|
||||
GuiColors.BORDER_LIGHT
|
||||
);
|
||||
|
||||
// Percentage text
|
||||
String percent = String.format("%.0f%%", smoothProgress * 100);
|
||||
int textWidth = mc.font.width(percent);
|
||||
graphics.drawString(
|
||||
mc.font,
|
||||
percent,
|
||||
x + (BAR_WIDTH - textWidth) / 2,
|
||||
y + (BAR_HEIGHT - mc.font.lineHeight) / 2 + 1,
|
||||
GuiColors.TEXT_WHITE,
|
||||
false
|
||||
);
|
||||
|
||||
// Description text
|
||||
if (info != null) {
|
||||
graphics.drawCenteredString(
|
||||
mc.font,
|
||||
info.description,
|
||||
x + BAR_WIDTH / 2,
|
||||
y + BAR_HEIGHT + PADDING,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active progress information.
|
||||
*/
|
||||
private static ProgressInfo getActiveProgress(PlayerBindState state) {
|
||||
// Check client tying task
|
||||
PlayerStateTask tyingTask = state.getClientTyingTask();
|
||||
if (tyingTask != null && !tyingTask.isOutdated()) {
|
||||
float progress = tyingTask.getProgress();
|
||||
Component text = getTyingText(tyingTask);
|
||||
return new ProgressInfo(progress, GuiColors.WARNING, text);
|
||||
}
|
||||
|
||||
// Check client untying task
|
||||
PlayerStateTask untyingTask = state.getClientUntyingTask();
|
||||
if (untyingTask != null && !untyingTask.isOutdated()) {
|
||||
float progress = untyingTask.getProgress();
|
||||
Component text = getUntyingText(untyingTask);
|
||||
return new ProgressInfo(progress, GuiColors.SUCCESS, text);
|
||||
}
|
||||
|
||||
// Check client feeding task
|
||||
PlayerStateTask feedingTask = state.getClientFeedingTask();
|
||||
if (feedingTask != null && !feedingTask.isOutdated()) {
|
||||
float progress = feedingTask.getProgress();
|
||||
Component text = getFeedingText(feedingTask);
|
||||
return new ProgressInfo(progress, GuiColors.ACCENT_TAN, text);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate text for tying progress based on role.
|
||||
*/
|
||||
private static Component getTyingText(PlayerStateTask task) {
|
||||
String otherName = task.getOtherEntityName();
|
||||
if (otherName == null || otherName.isEmpty()) {
|
||||
otherName = "???";
|
||||
}
|
||||
|
||||
if (task.isKidnapper()) {
|
||||
// Kidnapper sees: "Tying [target]..."
|
||||
return Component.translatable(
|
||||
"gui.tiedup.action.tying_target",
|
||||
otherName
|
||||
);
|
||||
} else {
|
||||
// Victim sees: "[kidnapper] is tying you up!"
|
||||
return Component.translatable(
|
||||
"gui.tiedup.action.being_tied_by",
|
||||
otherName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate text for untying progress based on role.
|
||||
*/
|
||||
private static Component getUntyingText(PlayerStateTask task) {
|
||||
String otherName = task.getOtherEntityName();
|
||||
if (otherName == null || otherName.isEmpty()) {
|
||||
otherName = "???";
|
||||
}
|
||||
|
||||
if (task.isKidnapper()) {
|
||||
// Helper sees: "Untying [target]..."
|
||||
return Component.translatable(
|
||||
"gui.tiedup.action.untying_target",
|
||||
otherName
|
||||
);
|
||||
} else {
|
||||
// Victim sees: "[helper] is untying you!"
|
||||
return Component.translatable(
|
||||
"gui.tiedup.action.being_untied_by",
|
||||
otherName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate text for feeding progress based on role.
|
||||
*/
|
||||
private static Component getFeedingText(PlayerStateTask task) {
|
||||
String otherName = task.getOtherEntityName();
|
||||
if (otherName == null || otherName.isEmpty()) {
|
||||
otherName = "???";
|
||||
}
|
||||
|
||||
if (task.isKidnapper()) {
|
||||
// Feeder sees: "Feeding [target]..."
|
||||
return Component.translatable(
|
||||
"gui.tiedup.action.feeding_target",
|
||||
otherName
|
||||
);
|
||||
} else {
|
||||
// Target sees: "[feeder] is feeding you!"
|
||||
return Component.translatable(
|
||||
"gui.tiedup.action.being_fed_by",
|
||||
otherName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress information container.
|
||||
*/
|
||||
private static class ProgressInfo {
|
||||
|
||||
final float progress;
|
||||
final int color;
|
||||
final Component description;
|
||||
|
||||
ProgressInfo(float progress, int color, Component description) {
|
||||
this.progress = progress;
|
||||
this.color = color;
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.tiedup.remake.client.gui.overlays;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
|
||||
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Overlay that shows status icons when player is restrained.
|
||||
* Icons appear in top-left corner showing current bondage state.
|
||||
*
|
||||
* Phase 16: GUI Revamp - Status indicator overlay
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
|
||||
public class StatusOverlay {
|
||||
|
||||
// Icon size and spacing
|
||||
private static final int ICON_SIZE = 16;
|
||||
private static final int PADDING = 2;
|
||||
private static final int MARGIN = 5;
|
||||
|
||||
// Visibility toggle
|
||||
private static boolean visible = true;
|
||||
|
||||
// Icon colors (use centralized GuiColors)
|
||||
private static final int COLOR_BOUND = GuiColors.TYPE_BIND;
|
||||
private static final int COLOR_GAGGED = GuiColors.TYPE_GAG;
|
||||
private static final int COLOR_BLIND = GuiColors.TYPE_BLINDFOLD;
|
||||
private static final int COLOR_DEAF = GuiColors.TYPE_EARPLUGS;
|
||||
private static final int COLOR_COLLAR = GuiColors.TYPE_COLLAR;
|
||||
private static final int COLOR_MITTENS = GuiColors.TYPE_MITTENS;
|
||||
|
||||
/**
|
||||
* Toggle overlay visibility.
|
||||
*/
|
||||
public static void toggleVisibility() {
|
||||
visible = !visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set overlay visibility.
|
||||
*/
|
||||
public static void setVisible(boolean vis) {
|
||||
visible = vis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if overlay is visible.
|
||||
*/
|
||||
public static boolean isVisible() {
|
||||
return visible;
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
|
||||
// Only render after hotbar
|
||||
if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
Player player = mc.player;
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show if not restrained at all
|
||||
if (
|
||||
!state.isTiedUp() &&
|
||||
!state.isGagged() &&
|
||||
!state.isBlindfolded() &&
|
||||
!state.hasEarplugs() &&
|
||||
!state.hasCollar() &&
|
||||
!state.hasMittens()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
GuiGraphics graphics = event.getGuiGraphics();
|
||||
|
||||
// Position: top-left corner
|
||||
int x = MARGIN;
|
||||
int y = MARGIN;
|
||||
|
||||
// Background panel
|
||||
int iconCount = countActiveIcons(state);
|
||||
if (iconCount > 0) {
|
||||
int cols = Math.min(iconCount, 3);
|
||||
int rows = (iconCount + 2) / 3;
|
||||
int panelWidth = cols * (ICON_SIZE + PADDING) + PADDING;
|
||||
int panelHeight = rows * (ICON_SIZE + PADDING) + PADDING;
|
||||
|
||||
// Semi-transparent background
|
||||
graphics.fill(
|
||||
x - PADDING,
|
||||
y - PADDING,
|
||||
x + panelWidth,
|
||||
y + panelHeight,
|
||||
GuiColors.withAlpha(GuiColors.BG_DARK, 180)
|
||||
);
|
||||
}
|
||||
|
||||
// Render icons in grid (max 3 per row)
|
||||
int iconIndex = 0;
|
||||
|
||||
if (state.isTiedUp()) {
|
||||
renderIcon(graphics, x, y, iconIndex++, COLOR_BOUND, "B");
|
||||
}
|
||||
if (state.isGagged()) {
|
||||
renderIcon(graphics, x, y, iconIndex++, COLOR_GAGGED, "G");
|
||||
}
|
||||
if (state.isBlindfolded()) {
|
||||
renderIcon(graphics, x, y, iconIndex++, COLOR_BLIND, "X");
|
||||
}
|
||||
if (state.hasEarplugs()) {
|
||||
renderIcon(graphics, x, y, iconIndex++, COLOR_DEAF, "D");
|
||||
}
|
||||
if (state.hasCollar()) {
|
||||
renderIcon(graphics, x, y, iconIndex++, COLOR_COLLAR, "C");
|
||||
}
|
||||
if (state.hasMittens()) {
|
||||
renderIcon(graphics, x, y, iconIndex++, COLOR_MITTENS, "M");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count active status icons.
|
||||
*/
|
||||
private static int countActiveIcons(PlayerBindState state) {
|
||||
int count = 0;
|
||||
if (state.isTiedUp()) count++;
|
||||
if (state.isGagged()) count++;
|
||||
if (state.isBlindfolded()) count++;
|
||||
if (state.hasEarplugs()) count++;
|
||||
if (state.hasCollar()) count++;
|
||||
if (state.hasMittens()) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single status icon.
|
||||
* Uses colored squares with letters as placeholders until proper textures are made.
|
||||
*/
|
||||
private static void renderIcon(
|
||||
GuiGraphics graphics,
|
||||
int baseX,
|
||||
int baseY,
|
||||
int index,
|
||||
int color,
|
||||
String letter
|
||||
) {
|
||||
int col = index % 3;
|
||||
int row = index / 3;
|
||||
|
||||
int x = baseX + col * (ICON_SIZE + PADDING);
|
||||
int y = baseY + row * (ICON_SIZE + PADDING);
|
||||
|
||||
// Icon background
|
||||
graphics.fill(x, y, x + ICON_SIZE, y + ICON_SIZE, color);
|
||||
|
||||
// Border
|
||||
GuiRenderUtil.drawBorder(
|
||||
graphics,
|
||||
x,
|
||||
y,
|
||||
ICON_SIZE,
|
||||
ICON_SIZE,
|
||||
GuiColors.darken(color, 0.3f)
|
||||
);
|
||||
|
||||
// Letter (centered)
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
int textWidth = mc.font.width(letter);
|
||||
graphics.drawString(
|
||||
mc.font,
|
||||
letter,
|
||||
x + (ICON_SIZE - textWidth) / 2,
|
||||
y + (ICON_SIZE - mc.font.lineHeight) / 2 + 1,
|
||||
GuiColors.TEXT_WHITE,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.tiedup.remake.client.gui.overlays;
|
||||
|
||||
import com.tiedup.remake.client.ModKeybindings;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
import com.tiedup.remake.util.KidnappedHelper;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.phys.EntityHitResult;
|
||||
import net.minecraft.world.phys.HitResult;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
|
||||
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Overlay that shows a tooltip when looking at a tied player/NPC.
|
||||
* Displayed below the crosshair when aiming at a restrainable entity.
|
||||
*
|
||||
* Shows contextual hints like:
|
||||
* - "[Right-click] Untie" when looking at tied entity
|
||||
* - "[Right-click] Remove gag" when looking at gagged entity
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
|
||||
public class UntieTooltipOverlay {
|
||||
|
||||
/** Offset below the crosshair */
|
||||
private static final int Y_OFFSET = 12;
|
||||
|
||||
/** Text color (white with slight transparency) */
|
||||
private static final int TEXT_COLOR = 0xFFFFFFFF;
|
||||
|
||||
/** Shadow color */
|
||||
private static final int SHADOW_COLOR = 0x80000000;
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
|
||||
// Render after crosshair
|
||||
if (event.getOverlay() != VanillaGuiOverlay.CROSSHAIR.type()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check what we're looking at
|
||||
HitResult hitResult = mc.hitResult;
|
||||
if (!(hitResult instanceof EntityHitResult entityHit)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Entity target = entityHit.getEntity();
|
||||
if (!(target instanceof LivingEntity livingTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the target's kidnapped state
|
||||
IBondageState state = KidnappedHelper.getKidnappedState(livingTarget);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine what action is available
|
||||
String tooltip = getTooltipText(state);
|
||||
if (tooltip == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Render the tooltip below the crosshair
|
||||
GuiGraphics graphics = event.getGuiGraphics();
|
||||
int screenWidth = mc.getWindow().getGuiScaledWidth();
|
||||
int screenHeight = mc.getWindow().getGuiScaledHeight();
|
||||
|
||||
int centerX = screenWidth / 2;
|
||||
int centerY = screenHeight / 2;
|
||||
|
||||
// Draw text centered below crosshair
|
||||
int textWidth = mc.font.width(tooltip);
|
||||
int textX = centerX - textWidth / 2;
|
||||
int textY = centerY + Y_OFFSET;
|
||||
|
||||
// Draw shadow first for better readability
|
||||
graphics.drawString(
|
||||
mc.font,
|
||||
tooltip,
|
||||
textX + 1,
|
||||
textY + 1,
|
||||
SHADOW_COLOR,
|
||||
false
|
||||
);
|
||||
graphics.drawString(mc.font, tooltip, textX, textY, TEXT_COLOR, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate tooltip text based on the target's state.
|
||||
* Respects lock status - locked items show "Help struggle" instead.
|
||||
*
|
||||
* @param state The target's kidnapped state
|
||||
* @return The tooltip text, or null if no action is available
|
||||
*/
|
||||
private static String getTooltipText(IBondageState state) {
|
||||
// Priority order: untie > ungag > unblindfold > uncollar
|
||||
// If item is locked, can only help struggle (not remove directly)
|
||||
String struggleKey =
|
||||
"[" +
|
||||
ModKeybindings.STRUGGLE_KEY.getTranslatedKeyMessage().getString() +
|
||||
"]";
|
||||
|
||||
if (state.isTiedUp()) {
|
||||
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (state.isLocked(bind, false)) {
|
||||
return struggleKey + " Help struggle";
|
||||
}
|
||||
return "[Right-click] Untie";
|
||||
}
|
||||
|
||||
if (state.isGagged()) {
|
||||
// Check if player is holding food → force feeding prompt
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (
|
||||
mc.player != null &&
|
||||
mc.player.getMainHandItem().getItem().isEdible()
|
||||
) {
|
||||
return "[Right-click] Feed";
|
||||
}
|
||||
ItemStack gag = state.getEquipment(BodyRegionV2.MOUTH);
|
||||
if (state.isLocked(gag, false)) {
|
||||
return struggleKey + " Help struggle";
|
||||
}
|
||||
return "[Right-click] Remove gag";
|
||||
}
|
||||
|
||||
if (state.isBlindfolded()) {
|
||||
ItemStack blindfold = state.getEquipment(BodyRegionV2.EYES);
|
||||
if (state.isLocked(blindfold, false)) {
|
||||
return struggleKey + " Help struggle";
|
||||
}
|
||||
return "[Right-click] Remove blindfold";
|
||||
}
|
||||
|
||||
if (state.hasCollar()) {
|
||||
ItemStack collar = state.getEquipment(BodyRegionV2.NECK);
|
||||
if (state.isLocked(collar, false)) {
|
||||
return struggleKey + " Help struggle";
|
||||
}
|
||||
return "[Right-click] Remove collar";
|
||||
}
|
||||
|
||||
if (state.hasEarplugs()) {
|
||||
ItemStack earplugs = state.getEquipment(BodyRegionV2.EARS);
|
||||
if (state.isLocked(earplugs, false)) {
|
||||
return struggleKey + " Help struggle";
|
||||
}
|
||||
return "[Right-click] Remove earplugs";
|
||||
}
|
||||
|
||||
if (state.hasMittens()) {
|
||||
ItemStack mittens = state.getEquipment(BodyRegionV2.HANDS);
|
||||
if (state.isLocked(mittens, false)) {
|
||||
return struggleKey + " Help struggle";
|
||||
}
|
||||
return "[Right-click] Remove mittens";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.item.PacketAdjustItem;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Screen for adjusting Y position of the player's own gags and blindfolds.
|
||||
* Shows 3D preview of player with real-time adjustment.
|
||||
*
|
||||
* Phase 16b: GUI Refactoring - Simplified using BaseAdjustmentScreen
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class AdjustmentScreen extends BaseAdjustmentScreen {
|
||||
|
||||
public AdjustmentScreen() {
|
||||
super(Component.translatable("gui.tiedup.adjust_position"));
|
||||
}
|
||||
|
||||
// ==================== ABSTRACT IMPLEMENTATIONS ====================
|
||||
|
||||
@Override
|
||||
protected LivingEntity getTargetEntity() {
|
||||
return this.minecraft.player;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemStack getGag() {
|
||||
Player player = this.minecraft.player;
|
||||
if (player == null) return ItemStack.EMPTY;
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) return ItemStack.EMPTY;
|
||||
|
||||
return state.getEquipment(BodyRegionV2.MOUTH);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemStack getBlindfold() {
|
||||
Player player = this.minecraft.player;
|
||||
if (player == null) return ItemStack.EMPTY;
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) return ItemStack.EMPTY;
|
||||
|
||||
return state.getEquipment(BodyRegionV2.EYES);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void sendAdjustment(Mode mode, float value, float scale) {
|
||||
BodyRegionV2 region = switch (mode) {
|
||||
case GAG -> BodyRegionV2.MOUTH;
|
||||
case BLINDFOLD -> BodyRegionV2.EYES;
|
||||
case BOTH -> null; // Handled separately in applyAdjustment
|
||||
};
|
||||
|
||||
if (region != null) {
|
||||
ModNetwork.sendToServer(new PacketAdjustItem(region, value, scale));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== STATIC HELPERS ====================
|
||||
|
||||
/**
|
||||
* Check if this screen should be openable (player has adjustable items).
|
||||
*/
|
||||
public static boolean canOpen() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.player == null) return false;
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(mc.player);
|
||||
if (state == null) return false;
|
||||
|
||||
return (
|
||||
!state.getEquipment(BodyRegionV2.MOUTH).isEmpty() ||
|
||||
!state.getEquipment(BodyRegionV2.EYES).isEmpty()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import static com.tiedup.remake.client.gui.util.GuiLayoutConstants.*;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.widgets.AdjustmentSlider;
|
||||
import com.tiedup.remake.client.gui.widgets.EntityPreviewWidget;
|
||||
import com.tiedup.remake.items.base.AdjustmentHelper;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Base class for adjustment screens (player self and remote slave adjustment).
|
||||
* Refactored to use new BaseScreen architecture.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public abstract class BaseAdjustmentScreen extends BaseScreen {
|
||||
|
||||
/**
|
||||
* Adjustment mode - which item(s) to adjust.
|
||||
*/
|
||||
protected enum Mode {
|
||||
GAG,
|
||||
BLINDFOLD,
|
||||
BOTH,
|
||||
}
|
||||
|
||||
// Current state
|
||||
protected Mode currentMode = Mode.GAG;
|
||||
protected float currentValue = 0.0f;
|
||||
protected float currentScaleValue = AdjustmentHelper.DEFAULT_SCALE;
|
||||
|
||||
// Widgets
|
||||
protected EntityPreviewWidget preview;
|
||||
protected AdjustmentSlider slider;
|
||||
protected Button gagButton;
|
||||
protected Button blindfoldButton;
|
||||
protected Button bothButton;
|
||||
protected Button resetButton;
|
||||
protected Button decrementButton;
|
||||
protected Button incrementButton;
|
||||
protected Button scaleDecrementButton;
|
||||
protected Button scaleIncrementButton;
|
||||
protected Button scaleResetButton;
|
||||
protected Button doneButton;
|
||||
|
||||
// Selected mode indicator colors
|
||||
private static final int TAB_SELECTED = GuiColors.ACCENT_BROWN;
|
||||
|
||||
protected BaseAdjustmentScreen(Component title) {
|
||||
super(title);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredWidth() {
|
||||
// Target: ~320px
|
||||
return 320;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredHeight() {
|
||||
// Target: ~290px (extra row for scale controls)
|
||||
return 290;
|
||||
}
|
||||
|
||||
// ==================== ABSTRACT METHODS ====================
|
||||
|
||||
protected abstract LivingEntity getTargetEntity();
|
||||
|
||||
protected abstract ItemStack getGag();
|
||||
|
||||
protected abstract ItemStack getBlindfold();
|
||||
|
||||
protected abstract void sendAdjustment(Mode mode, float value, float scale);
|
||||
|
||||
protected String getExtraInfo() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== INITIALIZATION ====================
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
|
||||
// Determine initial mode based on what's equipped
|
||||
if (getGag().isEmpty() && !getBlindfold().isEmpty()) {
|
||||
currentMode = Mode.BLINDFOLD;
|
||||
}
|
||||
|
||||
// Load current adjustment value
|
||||
loadCurrentValue();
|
||||
|
||||
setupPreview();
|
||||
setupSlider();
|
||||
setupModeTabs();
|
||||
setupActionButtons();
|
||||
setupScaleButtons();
|
||||
setupDoneButton();
|
||||
|
||||
updateButtonStates();
|
||||
}
|
||||
|
||||
private void setupPreview() {
|
||||
// Preview on left side of panel
|
||||
int previewX = this.leftPos + MARGIN_L;
|
||||
int previewY = this.topPos + TITLE_HEIGHT + MARGIN_M;
|
||||
int previewWidth = 120; // Fixed width for preview area
|
||||
int previewHeight = 140;
|
||||
|
||||
LivingEntity entity = getTargetEntity();
|
||||
if (entity != null) {
|
||||
preview = new EntityPreviewWidget(
|
||||
previewX,
|
||||
previewY,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
entity
|
||||
);
|
||||
preview.setAutoRotate(false);
|
||||
this.addRenderableWidget(preview);
|
||||
}
|
||||
}
|
||||
|
||||
private void setupSlider() {
|
||||
// Slider on right side of panel
|
||||
int sliderWidth = 40;
|
||||
int sliderX =
|
||||
this.leftPos + this.imageWidth - sliderWidth - MARGIN_L - 10;
|
||||
int sliderY = this.topPos + TITLE_HEIGHT + MARGIN_M;
|
||||
int sliderHeight = AdjustmentSlider.getRecommendedHeight(120);
|
||||
|
||||
slider = new AdjustmentSlider(
|
||||
sliderX,
|
||||
sliderY,
|
||||
sliderWidth,
|
||||
sliderHeight,
|
||||
-4.0f,
|
||||
4.0f,
|
||||
currentValue,
|
||||
this::onSliderChanged
|
||||
);
|
||||
this.addRenderableWidget(slider);
|
||||
}
|
||||
|
||||
private void setupModeTabs() {
|
||||
// Mode tabs below preview and slider area
|
||||
int tabY = this.topPos + TITLE_HEIGHT + 140 + 10; // Below preview
|
||||
int tabWidth = 60;
|
||||
int totalTabsWidth = tabWidth * 3 + MARGIN_S * 2;
|
||||
int tabStartX = this.leftPos + (this.imageWidth - totalTabsWidth) / 2;
|
||||
|
||||
gagButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.tab.gag"),
|
||||
b -> setMode(Mode.GAG)
|
||||
)
|
||||
.bounds(tabStartX, tabY, tabWidth, BUTTON_HEIGHT)
|
||||
.build();
|
||||
this.addRenderableWidget(gagButton);
|
||||
|
||||
blindfoldButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.tab.blindfold"),
|
||||
b -> setMode(Mode.BLINDFOLD)
|
||||
)
|
||||
.bounds(
|
||||
tabStartX + tabWidth + MARGIN_S,
|
||||
tabY,
|
||||
tabWidth,
|
||||
BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(blindfoldButton);
|
||||
|
||||
bothButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.tab.both"),
|
||||
b -> setMode(Mode.BOTH)
|
||||
)
|
||||
.bounds(
|
||||
tabStartX + (tabWidth + MARGIN_S) * 2,
|
||||
tabY,
|
||||
tabWidth,
|
||||
BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(bothButton);
|
||||
}
|
||||
|
||||
private void setupActionButtons() {
|
||||
// Action buttons below tabs
|
||||
int actionY = gagButton.getY() + BUTTON_HEIGHT + MARGIN_S;
|
||||
int buttonWidth = 45;
|
||||
int totalWidth = buttonWidth * 3 + MARGIN_S * 2;
|
||||
int actionStartX = this.leftPos + (this.imageWidth - totalWidth) / 2;
|
||||
|
||||
resetButton = Button.builder(Component.literal("0"), b -> resetValue())
|
||||
.bounds(actionStartX, actionY, buttonWidth, BUTTON_HEIGHT)
|
||||
.build();
|
||||
this.addRenderableWidget(resetButton);
|
||||
|
||||
decrementButton = Button.builder(Component.literal("-0.25"), b ->
|
||||
slider.decrement()
|
||||
)
|
||||
.bounds(
|
||||
actionStartX + buttonWidth + MARGIN_S,
|
||||
actionY,
|
||||
buttonWidth,
|
||||
BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(decrementButton);
|
||||
|
||||
incrementButton = Button.builder(Component.literal("+0.25"), b ->
|
||||
slider.increment()
|
||||
)
|
||||
.bounds(
|
||||
actionStartX + (buttonWidth + MARGIN_S) * 2,
|
||||
actionY,
|
||||
buttonWidth,
|
||||
BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(incrementButton);
|
||||
}
|
||||
|
||||
private void setupScaleButtons() {
|
||||
// Scale controls below position action buttons
|
||||
int scaleY = incrementButton.getY() + BUTTON_HEIGHT + MARGIN_S;
|
||||
int buttonWidth = 45;
|
||||
int totalWidth = buttonWidth * 3 + MARGIN_S * 2;
|
||||
int scaleStartX = this.leftPos + (this.imageWidth - totalWidth) / 2;
|
||||
|
||||
scaleResetButton = Button.builder(Component.literal("1x"), b ->
|
||||
applyScale(AdjustmentHelper.DEFAULT_SCALE)
|
||||
)
|
||||
.bounds(scaleStartX, scaleY, buttonWidth, BUTTON_HEIGHT)
|
||||
.build();
|
||||
this.addRenderableWidget(scaleResetButton);
|
||||
|
||||
scaleDecrementButton = Button.builder(Component.literal("-0.1"), b ->
|
||||
applyScale(currentScaleValue - AdjustmentHelper.SCALE_STEP)
|
||||
)
|
||||
.bounds(
|
||||
scaleStartX + buttonWidth + MARGIN_S,
|
||||
scaleY,
|
||||
buttonWidth,
|
||||
BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(scaleDecrementButton);
|
||||
|
||||
scaleIncrementButton = Button.builder(Component.literal("+0.1"), b ->
|
||||
applyScale(currentScaleValue + AdjustmentHelper.SCALE_STEP)
|
||||
)
|
||||
.bounds(
|
||||
scaleStartX + (buttonWidth + MARGIN_S) * 2,
|
||||
scaleY,
|
||||
buttonWidth,
|
||||
BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(scaleIncrementButton);
|
||||
}
|
||||
|
||||
private void setupDoneButton() {
|
||||
// Done button at bottom
|
||||
int doneX = this.leftPos + (this.imageWidth - BUTTON_WIDTH_L) / 2;
|
||||
int doneY = this.topPos + this.imageHeight - BUTTON_HEIGHT - MARGIN_M;
|
||||
|
||||
doneButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.done"),
|
||||
b -> onClose()
|
||||
)
|
||||
.bounds(doneX, doneY, BUTTON_WIDTH_L, BUTTON_HEIGHT)
|
||||
.build();
|
||||
this.addRenderableWidget(doneButton);
|
||||
}
|
||||
|
||||
// ==================== LOGIC ====================
|
||||
|
||||
protected void loadCurrentValue() {
|
||||
ItemStack stack = switch (currentMode) {
|
||||
case GAG -> getGag();
|
||||
case BLINDFOLD -> getBlindfold();
|
||||
case BOTH -> {
|
||||
ItemStack gag = getGag();
|
||||
yield gag.isEmpty() ? getBlindfold() : gag;
|
||||
}
|
||||
};
|
||||
|
||||
if (!stack.isEmpty()) {
|
||||
currentValue = AdjustmentHelper.getAdjustment(stack);
|
||||
currentScaleValue = AdjustmentHelper.getScale(stack);
|
||||
} else {
|
||||
currentValue = 0.0f;
|
||||
currentScaleValue = AdjustmentHelper.DEFAULT_SCALE;
|
||||
}
|
||||
if (slider != null) {
|
||||
slider.setValue(currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void onSliderChanged(float newValue) {
|
||||
this.currentValue = newValue;
|
||||
applyAdjustment(newValue);
|
||||
if (preview != null) {
|
||||
preview.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
protected void applyAdjustment(float value) {
|
||||
switch (currentMode) {
|
||||
case GAG -> {
|
||||
ItemStack gag = getGag();
|
||||
if (!gag.isEmpty()) {
|
||||
AdjustmentHelper.setAdjustment(gag, value);
|
||||
AdjustmentHelper.setScale(gag, currentScaleValue);
|
||||
sendAdjustment(Mode.GAG, value, currentScaleValue);
|
||||
}
|
||||
}
|
||||
case BLINDFOLD -> {
|
||||
ItemStack blind = getBlindfold();
|
||||
if (!blind.isEmpty()) {
|
||||
AdjustmentHelper.setAdjustment(blind, value);
|
||||
AdjustmentHelper.setScale(blind, currentScaleValue);
|
||||
sendAdjustment(Mode.BLINDFOLD, value, currentScaleValue);
|
||||
}
|
||||
}
|
||||
case BOTH -> {
|
||||
ItemStack gag = getGag();
|
||||
ItemStack blind = getBlindfold();
|
||||
if (!gag.isEmpty()) {
|
||||
AdjustmentHelper.setAdjustment(gag, value);
|
||||
AdjustmentHelper.setScale(gag, currentScaleValue);
|
||||
sendAdjustment(Mode.GAG, value, currentScaleValue);
|
||||
}
|
||||
if (!blind.isEmpty()) {
|
||||
AdjustmentHelper.setAdjustment(blind, value);
|
||||
AdjustmentHelper.setScale(blind, currentScaleValue);
|
||||
sendAdjustment(Mode.BLINDFOLD, value, currentScaleValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void applyScale(float scale) {
|
||||
this.currentScaleValue = Mth.clamp(
|
||||
scale,
|
||||
AdjustmentHelper.MIN_SCALE,
|
||||
AdjustmentHelper.MAX_SCALE
|
||||
);
|
||||
// Re-apply both values together (scale changed, position stays)
|
||||
applyAdjustment(currentValue);
|
||||
if (preview != null) {
|
||||
preview.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
protected void setMode(Mode mode) {
|
||||
this.currentMode = mode;
|
||||
loadCurrentValue();
|
||||
updateButtonStates();
|
||||
}
|
||||
|
||||
protected void resetValue() {
|
||||
if (slider != null) {
|
||||
slider.setValue(0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
protected void updateButtonStates() {
|
||||
boolean hasGag = !getGag().isEmpty();
|
||||
boolean hasBlind = !getBlindfold().isEmpty();
|
||||
|
||||
if (gagButton != null) gagButton.active = hasGag;
|
||||
if (blindfoldButton != null) blindfoldButton.active = hasBlind;
|
||||
if (bothButton != null) bothButton.active = hasGag || hasBlind;
|
||||
}
|
||||
|
||||
// ==================== RENDERING ====================
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
|
||||
// Draw selected mode indicator
|
||||
renderModeIndicator(graphics);
|
||||
|
||||
// Extra info (e.g., slave name)
|
||||
String extraInfo = getExtraInfo();
|
||||
if (extraInfo != null) {
|
||||
graphics.drawCenteredString(
|
||||
this.font,
|
||||
extraInfo,
|
||||
this.leftPos + this.imageWidth / 2,
|
||||
this.topPos + TITLE_HEIGHT - 2,
|
||||
GuiColors.ACCENT_TAN
|
||||
);
|
||||
}
|
||||
|
||||
// Scale label above scale buttons
|
||||
if (scaleResetButton != null) {
|
||||
String scaleLabel =
|
||||
Component.translatable(
|
||||
"gui.tiedup.adjustment.scale"
|
||||
).getString() +
|
||||
": " +
|
||||
String.format("%.1fx", currentScaleValue);
|
||||
graphics.drawCenteredString(
|
||||
this.font,
|
||||
scaleLabel,
|
||||
this.leftPos + this.imageWidth / 2,
|
||||
scaleResetButton.getY() - 10,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
}
|
||||
|
||||
// Current item info
|
||||
String itemInfo = getCurrentItemInfo();
|
||||
graphics.drawCenteredString(
|
||||
this.font,
|
||||
itemInfo,
|
||||
this.leftPos + this.imageWidth / 2,
|
||||
this.topPos + this.imageHeight - BUTTON_HEIGHT - MARGIN_L - 10,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
}
|
||||
|
||||
private void renderModeIndicator(GuiGraphics graphics) {
|
||||
Button selectedButton = switch (currentMode) {
|
||||
case GAG -> gagButton;
|
||||
case BLINDFOLD -> blindfoldButton;
|
||||
case BOTH -> bothButton;
|
||||
};
|
||||
|
||||
if (selectedButton != null) {
|
||||
int indicatorY = selectedButton.getY() + selectedButton.getHeight();
|
||||
graphics.fill(
|
||||
selectedButton.getX() + 2,
|
||||
indicatorY,
|
||||
selectedButton.getX() + selectedButton.getWidth() - 2,
|
||||
indicatorY + 2,
|
||||
TAB_SELECTED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected String getCurrentItemInfo() {
|
||||
return switch (currentMode) {
|
||||
case GAG -> {
|
||||
ItemStack gag = getGag();
|
||||
yield gag.isEmpty()
|
||||
? Component.translatable(
|
||||
"gui.tiedup.adjustment.no_gag"
|
||||
).getString()
|
||||
: gag.getHoverName().getString();
|
||||
}
|
||||
case BLINDFOLD -> {
|
||||
ItemStack blind = getBlindfold();
|
||||
yield blind.isEmpty()
|
||||
? Component.translatable(
|
||||
"gui.tiedup.adjustment.no_blindfold"
|
||||
).getString()
|
||||
: blind.getHoverName().getString();
|
||||
}
|
||||
case BOTH -> Component.translatable(
|
||||
"gui.tiedup.adjustment.both"
|
||||
).getString();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiTextureHelper;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Base class for interaction screens (Command Wand, Dialogue, Conversation, Pet Request).
|
||||
* Provides common layout functionality and reduces code duplication.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public abstract class BaseInteractionScreen extends Screen {
|
||||
|
||||
protected int leftPos;
|
||||
protected int topPos;
|
||||
protected final int panelWidth;
|
||||
protected final int panelHeight;
|
||||
|
||||
protected BaseInteractionScreen(
|
||||
Component title,
|
||||
int panelWidth,
|
||||
int panelHeight
|
||||
) {
|
||||
super(title);
|
||||
this.panelWidth = panelWidth;
|
||||
this.panelHeight = panelHeight;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
// Center the panel on screen
|
||||
this.leftPos = (this.width - panelWidth) / 2;
|
||||
this.topPos = (this.height - panelHeight) / 2;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
// Dark background
|
||||
this.renderBackground(graphics);
|
||||
|
||||
// Main panel with vanilla-style bevel
|
||||
GuiTextureHelper.renderBeveledPanel(
|
||||
graphics,
|
||||
leftPos,
|
||||
topPos,
|
||||
panelWidth,
|
||||
panelHeight
|
||||
);
|
||||
|
||||
// Let subclasses render their content
|
||||
renderContent(graphics, mouseX, mouseY, partialTick);
|
||||
|
||||
// Render widgets (buttons) on top
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the screen's content. Called after the panel background is drawn.
|
||||
*
|
||||
* @param graphics Graphics context
|
||||
* @param mouseX Mouse X position
|
||||
* @param mouseY Mouse Y position
|
||||
* @param partialTick Partial tick for interpolation
|
||||
*/
|
||||
protected abstract void renderContent(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the X position for content with the given margin.
|
||||
*
|
||||
* @param margin Margin from the left edge
|
||||
* @return Content X position
|
||||
*/
|
||||
protected int getContentX(int margin) {
|
||||
return leftPos + margin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the width available for content with the given margin.
|
||||
*
|
||||
* @param margin Margin on both sides
|
||||
* @return Content width
|
||||
*/
|
||||
protected int getContentWidth(int margin) {
|
||||
return panelWidth - margin * 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a centered title at the given Y position.
|
||||
*
|
||||
* @param graphics Graphics context
|
||||
* @param title Title component
|
||||
* @param y Y position
|
||||
* @param color Text color
|
||||
*/
|
||||
protected void renderTitle(
|
||||
GuiGraphics graphics,
|
||||
Component title,
|
||||
int y,
|
||||
int color
|
||||
) {
|
||||
graphics.drawCenteredString(
|
||||
this.font,
|
||||
title,
|
||||
leftPos + panelWidth / 2,
|
||||
y,
|
||||
color
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Base abstract class for TiedUp! GUI screens.
|
||||
* Handles responsive layout, background rendering, and common utilities.
|
||||
*
|
||||
* Refactored for cleaner architecture.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public abstract class BaseScreen extends Screen {
|
||||
|
||||
// Content area bounds
|
||||
protected int leftPos;
|
||||
protected int topPos;
|
||||
protected int imageWidth;
|
||||
protected int imageHeight;
|
||||
|
||||
protected BaseScreen(Component title) {
|
||||
super(title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the preferred percentage width of the screen (0.0 to 1.0)
|
||||
* or a fixed pixel width if context requires.
|
||||
* Default implementation uses a responsive approach.
|
||||
*/
|
||||
protected int getPreferredWidth() {
|
||||
// Default: 60% of screen, min 300px, max 450px
|
||||
return GuiLayoutConstants.getResponsiveWidth(
|
||||
this.width,
|
||||
0.6f,
|
||||
300,
|
||||
450
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the preferred percentage height of the screen.
|
||||
*/
|
||||
protected int getPreferredHeight() {
|
||||
// Default: 70% of screen, min 220px, max 400px
|
||||
return GuiLayoutConstants.getResponsiveHeight(
|
||||
this.height,
|
||||
0.7f,
|
||||
220,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
|
||||
// Calculate dimensions
|
||||
this.imageWidth = getPreferredWidth();
|
||||
this.imageHeight = getPreferredHeight();
|
||||
|
||||
// Center the panel
|
||||
this.leftPos = (this.width - this.imageWidth) / 2;
|
||||
this.topPos = (this.height - this.imageHeight) / 2;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
this.renderBackground(graphics);
|
||||
|
||||
// Draw Main Panel Background
|
||||
graphics.fill(
|
||||
leftPos,
|
||||
topPos,
|
||||
leftPos + imageWidth,
|
||||
topPos + imageHeight,
|
||||
GuiColors.BG_MEDIUM
|
||||
);
|
||||
|
||||
// Draw Borders
|
||||
GuiRenderUtil.drawBorder(
|
||||
graphics,
|
||||
leftPos,
|
||||
topPos,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
GuiColors.BORDER_LIGHT
|
||||
);
|
||||
|
||||
// Title
|
||||
graphics.drawCenteredString(
|
||||
this.font,
|
||||
this.title,
|
||||
this.width / 2,
|
||||
this.topPos + GuiLayoutConstants.MARGIN_M,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
|
||||
// Template method: subclasses render custom content between background and widgets
|
||||
renderContent(graphics, mouseX, mouseY, partialTick);
|
||||
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
}
|
||||
|
||||
/**
|
||||
* Template method for subclasses to render custom content between the
|
||||
* background/title and the widgets. Called after background, panel fill,
|
||||
* border, and title are drawn, but before {@code super.render()} draws
|
||||
* widgets and tooltips.
|
||||
*
|
||||
* <p>Default implementation is a no-op so existing subclasses that
|
||||
* override {@code render()} directly are unaffected.</p>
|
||||
*
|
||||
* <p><b>Important:</b> Subclasses should use EITHER this method OR a
|
||||
* {@code render()} override, not both — combining them would render
|
||||
* custom content twice.</p>
|
||||
*/
|
||||
protected void renderContent(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
// No-op by default — subclasses override to render custom content
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis if too long.
|
||||
*/
|
||||
protected String truncateText(String text, int maxWidth) {
|
||||
if (this.font.width(text) <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
String ellipsis = "...";
|
||||
int ellipsisWidth = this.font.width(ellipsis);
|
||||
int availableWidth = maxWidth - ellipsisWidth;
|
||||
if (availableWidth <= 0) {
|
||||
return ellipsis;
|
||||
}
|
||||
// Basic truncation
|
||||
return this.font.plainSubstrByWidth(text, availableWidth) + ellipsis;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.bounty.Bounty;
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
import com.tiedup.remake.client.gui.widgets.BountyEntryWidget;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.bounty.PacketDeleteBounty;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.ContainerObjectSelectionList;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Screen displaying all active bounties.
|
||||
* Refactored to use standard ContainerObjectSelectionList.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class BountyListScreen extends BaseScreen {
|
||||
|
||||
private final List<Bounty> bounties;
|
||||
private final boolean isAdmin;
|
||||
|
||||
private BountyList bountyList;
|
||||
private Button closeButton;
|
||||
private Button deleteButton;
|
||||
|
||||
public BountyListScreen(List<Bounty> bounties, boolean isAdmin) {
|
||||
super(Component.translatable("gui.tiedup.bounties.title"));
|
||||
this.bounties = bounties;
|
||||
this.isAdmin = isAdmin;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredWidth() {
|
||||
return GuiLayoutConstants.getResponsiveWidth(
|
||||
this.width,
|
||||
0.7f,
|
||||
350,
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredHeight() {
|
||||
return GuiLayoutConstants.getResponsiveHeight(
|
||||
this.height,
|
||||
0.8f,
|
||||
250,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
|
||||
int listTop = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 20;
|
||||
int listBottom =
|
||||
this.topPos +
|
||||
this.imageHeight -
|
||||
GuiLayoutConstants.BUTTON_HEIGHT * 2 -
|
||||
20;
|
||||
int listHeight = listBottom - listTop;
|
||||
|
||||
// Initialize List
|
||||
bountyList = new BountyList(
|
||||
minecraft,
|
||||
this.imageWidth - 20,
|
||||
listHeight,
|
||||
listTop,
|
||||
listBottom
|
||||
);
|
||||
bountyList.setLeftPos(this.leftPos + 10);
|
||||
|
||||
// Populate List
|
||||
for (Bounty bounty : bounties) {
|
||||
bountyList.addEntryPublic(
|
||||
new BountyEntryWidget(bounty, this::onEntrySelected)
|
||||
);
|
||||
}
|
||||
this.addRenderableWidget(bountyList);
|
||||
|
||||
// Buttons
|
||||
int btnWidth = GuiLayoutConstants.BUTTON_WIDTH_XL;
|
||||
int btnX = this.leftPos + (this.imageWidth - btnWidth) / 2;
|
||||
int btnY = listBottom + 10;
|
||||
|
||||
deleteButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.bounties.delete"),
|
||||
b -> onDeleteClicked()
|
||||
)
|
||||
.bounds(btnX, btnY, btnWidth, GuiLayoutConstants.BUTTON_HEIGHT)
|
||||
.build();
|
||||
deleteButton.active = false;
|
||||
this.addRenderableWidget(deleteButton);
|
||||
|
||||
closeButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.close"),
|
||||
b -> onClose()
|
||||
)
|
||||
.bounds(
|
||||
btnX,
|
||||
btnY + GuiLayoutConstants.BUTTON_HEIGHT + 4,
|
||||
btnWidth,
|
||||
GuiLayoutConstants.BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(closeButton);
|
||||
}
|
||||
|
||||
private void onEntrySelected(BountyEntryWidget entry) {
|
||||
bountyList.setSelected(entry);
|
||||
updateDeleteButton();
|
||||
}
|
||||
|
||||
private void updateDeleteButton() {
|
||||
BountyEntryWidget selected = bountyList.getSelected();
|
||||
if (selected == null) {
|
||||
deleteButton.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
Bounty bounty = selected.getBounty();
|
||||
if (minecraft.player == null) {
|
||||
deleteButton.active = false;
|
||||
return;
|
||||
}
|
||||
UUID playerId = minecraft.player.getUUID();
|
||||
|
||||
// Can delete if admin or bounty client
|
||||
deleteButton.active = isAdmin || bounty.isClient(playerId);
|
||||
|
||||
// Update visual selection state in all widgets
|
||||
for (BountyEntryWidget w : bountyList.children()) {
|
||||
w.setSelected(w == selected);
|
||||
}
|
||||
}
|
||||
|
||||
private void onDeleteClicked() {
|
||||
BountyEntryWidget selected = bountyList.getSelected();
|
||||
if (selected == null) return;
|
||||
|
||||
Bounty bounty = selected.getBounty();
|
||||
ModNetwork.sendToServer(new PacketDeleteBounty(bounty.getId()));
|
||||
|
||||
// Remove from list
|
||||
bounties.remove(bounty);
|
||||
bountyList.removeEntryPublic(selected);
|
||||
|
||||
// Reset selection
|
||||
bountyList.setSelected(null);
|
||||
updateDeleteButton();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
|
||||
// Bounty count
|
||||
String countText =
|
||||
bounties.size() + " bounti" + (bounties.size() != 1 ? "es" : "y");
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
countText,
|
||||
this.leftPos + GuiLayoutConstants.MARGIN_M,
|
||||
this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 2,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
|
||||
// Empty state
|
||||
if (bounties.isEmpty()) {
|
||||
graphics.drawCenteredString(
|
||||
this.font,
|
||||
Component.translatable("gui.tiedup.bounties.noEntries"),
|
||||
this.leftPos + this.imageWidth / 2,
|
||||
this.topPos + this.imageHeight / 2,
|
||||
GuiColors.TEXT_DISABLED
|
||||
);
|
||||
}
|
||||
|
||||
// Render tooltip for hovered entry
|
||||
BountyEntryWidget hovered = bountyList.getEntryAtPositionPublic(
|
||||
mouseX,
|
||||
mouseY
|
||||
);
|
||||
if (hovered != null) {
|
||||
graphics.renderTooltip(
|
||||
this.font,
|
||||
hovered.getBounty().getReward(),
|
||||
mouseX,
|
||||
mouseY
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== INNER CLASS: LIST ====================
|
||||
|
||||
class BountyList extends ContainerObjectSelectionList<BountyEntryWidget> {
|
||||
|
||||
public BountyList(
|
||||
Minecraft mc,
|
||||
int width,
|
||||
int height,
|
||||
int top,
|
||||
int bottom
|
||||
) {
|
||||
super(mc, width, height, top, bottom, 55); // 55 = item height
|
||||
this.centerListVertically = false;
|
||||
this.setRenderBackground(false);
|
||||
this.setRenderTopAndBottom(false);
|
||||
}
|
||||
|
||||
public void addEntryPublic(BountyEntryWidget entry) {
|
||||
this.addEntry(entry);
|
||||
}
|
||||
|
||||
public void removeEntryPublic(BountyEntryWidget entry) {
|
||||
this.removeEntry(entry);
|
||||
}
|
||||
|
||||
public BountyEntryWidget getEntryAtPositionPublic(double x, double y) {
|
||||
return super.getEntryAtPosition(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRowWidth() {
|
||||
return this.width - 20;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getScrollbarPosition() {
|
||||
return this.getLeft() + this.width - 6;
|
||||
}
|
||||
|
||||
public void setLeftPos(int left) {
|
||||
this.x0 = left;
|
||||
this.x1 = left + this.width;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRowLeft() {
|
||||
return BountyListScreen.this.leftPos + 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.cell.PacketCoreMenuAction;
|
||||
import com.tiedup.remake.network.cell.PacketRenameCell;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Compact Cell Core menu GUI.
|
||||
* Fixed-size panel (~180x220px) with action buttons and cell info.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class CellCoreScreen extends Screen {
|
||||
|
||||
private static final int PANEL_WIDTH = 180;
|
||||
private static final int PANEL_HEIGHT = 243;
|
||||
private static final int BUTTON_WIDTH = 160;
|
||||
private static final int BUTTON_HEIGHT = 20;
|
||||
private static final int MARGIN = 8;
|
||||
|
||||
private final BlockPos corePos;
|
||||
private final UUID cellId;
|
||||
private final String cellName;
|
||||
private final String stateName;
|
||||
private final int interiorVolume;
|
||||
private final int wallCount;
|
||||
private final int breachCount;
|
||||
private final int prisonerCount;
|
||||
private final int bedCount;
|
||||
private final int doorCount;
|
||||
private final int anchorCount;
|
||||
private final boolean hasSpawn;
|
||||
private final boolean hasDelivery;
|
||||
private final boolean hasDisguise;
|
||||
|
||||
private boolean showInfo = false;
|
||||
private boolean renameMode = false;
|
||||
private EditBox renameBox;
|
||||
private int leftPos;
|
||||
private int topPos;
|
||||
|
||||
public CellCoreScreen(
|
||||
BlockPos corePos,
|
||||
UUID cellId,
|
||||
String cellName,
|
||||
String stateName,
|
||||
int interiorVolume,
|
||||
int wallCount,
|
||||
int breachCount,
|
||||
int prisonerCount,
|
||||
int bedCount,
|
||||
int doorCount,
|
||||
int anchorCount,
|
||||
boolean hasSpawn,
|
||||
boolean hasDelivery,
|
||||
boolean hasDisguise
|
||||
) {
|
||||
super(Component.translatable("gui.tiedup.cell_core"));
|
||||
this.corePos = corePos;
|
||||
this.cellId = cellId;
|
||||
this.cellName =
|
||||
cellName != null && !cellName.isEmpty() ? cellName : "Cell Core";
|
||||
this.stateName = stateName;
|
||||
this.interiorVolume = interiorVolume;
|
||||
this.wallCount = wallCount;
|
||||
this.breachCount = breachCount;
|
||||
this.prisonerCount = prisonerCount;
|
||||
this.bedCount = bedCount;
|
||||
this.doorCount = doorCount;
|
||||
this.anchorCount = anchorCount;
|
||||
this.hasSpawn = hasSpawn;
|
||||
this.hasDelivery = hasDelivery;
|
||||
this.hasDisguise = hasDisguise;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
|
||||
int panelHeight = showInfo ? PANEL_HEIGHT + 90 : PANEL_HEIGHT;
|
||||
this.leftPos = (this.width - PANEL_WIDTH) / 2;
|
||||
this.topPos = (this.height - panelHeight) / 2;
|
||||
|
||||
int btnX = leftPos + (PANEL_WIDTH - BUTTON_WIDTH) / 2;
|
||||
int currentY = topPos + 24;
|
||||
|
||||
// Set Spawn
|
||||
addRenderableWidget(
|
||||
Button.builder(
|
||||
Component.translatable("gui.tiedup.cell_core.set_spawn"),
|
||||
b -> {
|
||||
ModNetwork.sendToServer(
|
||||
new PacketCoreMenuAction(
|
||||
corePos,
|
||||
PacketCoreMenuAction.Action.SET_SPAWN
|
||||
)
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
)
|
||||
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
.build()
|
||||
);
|
||||
currentY += BUTTON_HEIGHT + 3;
|
||||
|
||||
// Set Delivery
|
||||
addRenderableWidget(
|
||||
Button.builder(
|
||||
Component.translatable("gui.tiedup.cell_core.set_delivery"),
|
||||
b -> {
|
||||
ModNetwork.sendToServer(
|
||||
new PacketCoreMenuAction(
|
||||
corePos,
|
||||
PacketCoreMenuAction.Action.SET_DELIVERY
|
||||
)
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
)
|
||||
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
.build()
|
||||
);
|
||||
currentY += BUTTON_HEIGHT + 3;
|
||||
|
||||
// Set Disguise
|
||||
addRenderableWidget(
|
||||
Button.builder(
|
||||
Component.translatable("gui.tiedup.cell_core.set_disguise"),
|
||||
b -> {
|
||||
ModNetwork.sendToServer(
|
||||
new PacketCoreMenuAction(
|
||||
corePos,
|
||||
PacketCoreMenuAction.Action.SET_DISGUISE
|
||||
)
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
)
|
||||
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
.build()
|
||||
);
|
||||
currentY += BUTTON_HEIGHT + 3;
|
||||
|
||||
// Rename
|
||||
if (renameMode) {
|
||||
renameBox = new EditBox(
|
||||
this.font,
|
||||
btnX,
|
||||
currentY,
|
||||
BUTTON_WIDTH - 50,
|
||||
BUTTON_HEIGHT,
|
||||
Component.translatable("gui.tiedup.cell_core.cell_name")
|
||||
);
|
||||
renameBox.setMaxLength(32);
|
||||
String currentName = cellName.equals("Cell Core") ? "" : cellName;
|
||||
renameBox.setValue(currentName);
|
||||
addRenderableWidget(renameBox);
|
||||
renameBox.setFocused(true);
|
||||
setFocused(renameBox);
|
||||
|
||||
addRenderableWidget(
|
||||
Button.builder(
|
||||
Component.translatable("gui.tiedup.cell_core.button.ok"),
|
||||
b -> {
|
||||
String newName = renameBox.getValue().trim();
|
||||
ModNetwork.sendToServer(
|
||||
new PacketRenameCell(cellId, newName)
|
||||
);
|
||||
renameMode = false;
|
||||
onClose();
|
||||
}
|
||||
)
|
||||
.bounds(
|
||||
btnX + BUTTON_WIDTH - 46,
|
||||
currentY,
|
||||
46,
|
||||
BUTTON_HEIGHT
|
||||
)
|
||||
.build()
|
||||
);
|
||||
currentY += BUTTON_HEIGHT + 3;
|
||||
} else {
|
||||
addRenderableWidget(
|
||||
Button.builder(
|
||||
Component.translatable("gui.tiedup.cell_core.rename"),
|
||||
b -> {
|
||||
renameMode = true;
|
||||
rebuildWidgets();
|
||||
}
|
||||
)
|
||||
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
.build()
|
||||
);
|
||||
currentY += BUTTON_HEIGHT + 3;
|
||||
}
|
||||
|
||||
// Re-scan
|
||||
addRenderableWidget(
|
||||
Button.builder(
|
||||
Component.translatable("gui.tiedup.cell_core.rescan"),
|
||||
b -> {
|
||||
ModNetwork.sendToServer(
|
||||
new PacketCoreMenuAction(
|
||||
corePos,
|
||||
PacketCoreMenuAction.Action.RESCAN
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
.build()
|
||||
);
|
||||
currentY += BUTTON_HEIGHT + 3;
|
||||
|
||||
// Info toggle
|
||||
addRenderableWidget(
|
||||
Button.builder(
|
||||
Component.translatable("gui.tiedup.cell_core.info"),
|
||||
b -> {
|
||||
showInfo = !showInfo;
|
||||
rebuildWidgets();
|
||||
}
|
||||
)
|
||||
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
.build()
|
||||
);
|
||||
currentY += BUTTON_HEIGHT + 6;
|
||||
|
||||
// Close
|
||||
addRenderableWidget(
|
||||
Button.builder(
|
||||
Component.translatable("gui.tiedup.cell_core.close"),
|
||||
b -> onClose()
|
||||
)
|
||||
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
this.renderBackground(graphics);
|
||||
|
||||
int panelHeight = showInfo ? PANEL_HEIGHT + 90 : PANEL_HEIGHT;
|
||||
int panelTopPos = (this.height - panelHeight) / 2;
|
||||
|
||||
// Panel background
|
||||
graphics.fill(
|
||||
leftPos,
|
||||
panelTopPos,
|
||||
leftPos + PANEL_WIDTH,
|
||||
panelTopPos + panelHeight,
|
||||
GuiColors.BG_MEDIUM
|
||||
);
|
||||
GuiRenderUtil.drawBorder(
|
||||
graphics,
|
||||
leftPos,
|
||||
panelTopPos,
|
||||
PANEL_WIDTH,
|
||||
panelHeight,
|
||||
GuiColors.BORDER_LIGHT
|
||||
);
|
||||
|
||||
// Title
|
||||
graphics.drawCenteredString(
|
||||
this.font,
|
||||
cellName,
|
||||
leftPos + PANEL_WIDTH / 2,
|
||||
panelTopPos + MARGIN,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
|
||||
// Info section (below buttons)
|
||||
if (showInfo) {
|
||||
int infoY = panelTopPos + PANEL_HEIGHT - 10;
|
||||
int infoX = leftPos + MARGIN;
|
||||
int lineH = 10;
|
||||
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_core.info.state",
|
||||
stateName
|
||||
).getString(),
|
||||
infoX,
|
||||
infoY,
|
||||
getStateColor()
|
||||
);
|
||||
infoY += lineH;
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_core.info.interior",
|
||||
interiorVolume
|
||||
).getString(),
|
||||
infoX,
|
||||
infoY,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
infoY += lineH;
|
||||
String wallStr =
|
||||
breachCount > 0
|
||||
? Component.translatable(
|
||||
"gui.tiedup.cell_core.info.walls_breached",
|
||||
wallCount,
|
||||
breachCount
|
||||
).getString()
|
||||
: Component.translatable(
|
||||
"gui.tiedup.cell_core.info.walls",
|
||||
wallCount
|
||||
).getString();
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
wallStr,
|
||||
infoX,
|
||||
infoY,
|
||||
breachCount > 0 ? GuiColors.WARNING : GuiColors.TEXT_GRAY
|
||||
);
|
||||
infoY += lineH;
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_core.info.prisoners",
|
||||
prisonerCount
|
||||
).getString(),
|
||||
infoX,
|
||||
infoY,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
infoY += lineH;
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_core.info.beds_doors_anchors",
|
||||
bedCount,
|
||||
doorCount,
|
||||
anchorCount
|
||||
).getString(),
|
||||
infoX,
|
||||
infoY,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
infoY += lineH;
|
||||
|
||||
String features = "";
|
||||
if (hasSpawn) features +=
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_core.info.feature_spawn"
|
||||
).getString() +
|
||||
" ";
|
||||
if (hasDelivery) features +=
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_core.info.feature_delivery"
|
||||
).getString() +
|
||||
" ";
|
||||
if (hasDisguise) features += Component.translatable(
|
||||
"gui.tiedup.cell_core.info.feature_disguise"
|
||||
).getString();
|
||||
if (features.isEmpty()) features = Component.translatable(
|
||||
"gui.tiedup.cell_core.info.none_set"
|
||||
).getString();
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_core.info.set",
|
||||
features.trim()
|
||||
).getString(),
|
||||
infoX,
|
||||
infoY,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
}
|
||||
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
}
|
||||
|
||||
private int getStateColor() {
|
||||
return switch (stateName) {
|
||||
case "intact" -> GuiColors.SUCCESS;
|
||||
case "breached" -> GuiColors.WARNING;
|
||||
case "compromised" -> GuiColors.ERROR;
|
||||
default -> GuiColors.TEXT_GRAY;
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
import com.tiedup.remake.client.gui.widgets.CellListRenderer;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.cell.PacketCellAction;
|
||||
import com.tiedup.remake.network.cell.PacketOpenCellManager.CellSyncData;
|
||||
import com.tiedup.remake.network.cell.PacketOpenCellManager.PrisonerInfo;
|
||||
import com.tiedup.remake.network.cell.PacketRenameCell;
|
||||
import java.util.List;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Screen for managing player-owned cells.
|
||||
* Shows list of cells with prisoners and management actions.
|
||||
*
|
||||
* Operators (OP) can see and manage ALL cells.
|
||||
* Non-operators can only manage their own cells.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class CellManagerScreen extends BaseScreen {
|
||||
|
||||
private final List<CellSyncData> cells;
|
||||
private final boolean isOperator;
|
||||
|
||||
// Selection state
|
||||
@Nullable
|
||||
private CellSyncData selectedCell;
|
||||
|
||||
@Nullable
|
||||
private PrisonerInfo selectedPrisoner;
|
||||
|
||||
// UI state
|
||||
private boolean renameMode = false;
|
||||
private EditBox renameBox;
|
||||
|
||||
// Layout
|
||||
private int listStartY;
|
||||
private int listHeight;
|
||||
private int cellEntryHeight = 24;
|
||||
private int prisonerEntryHeight = 18;
|
||||
private int scrollOffset = 0;
|
||||
|
||||
// Buttons
|
||||
private Button closeButton;
|
||||
private Button renameButton;
|
||||
private Button deleteButton;
|
||||
private Button releaseButton;
|
||||
private Button teleportButton;
|
||||
|
||||
/**
|
||||
* Create the cell manager screen.
|
||||
*
|
||||
* @param cells List of cells to display
|
||||
* @param isOperator True if the viewing player is an operator (can manage all cells)
|
||||
*/
|
||||
public CellManagerScreen(List<CellSyncData> cells, boolean isOperator) {
|
||||
super(Component.translatable("gui.tiedup.cell_manager"));
|
||||
this.cells = cells;
|
||||
this.isOperator = isOperator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy constructor (assumes not operator).
|
||||
*/
|
||||
public CellManagerScreen(List<CellSyncData> cells) {
|
||||
this(cells, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredWidth() {
|
||||
return GuiLayoutConstants.getResponsiveWidth(
|
||||
this.width,
|
||||
0.7f,
|
||||
400,
|
||||
550
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredHeight() {
|
||||
return GuiLayoutConstants.getResponsiveHeight(
|
||||
this.height,
|
||||
0.8f,
|
||||
350,
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
|
||||
int contentTop =
|
||||
this.topPos +
|
||||
GuiLayoutConstants.TITLE_HEIGHT +
|
||||
GuiLayoutConstants.MARGIN_M;
|
||||
listStartY = contentTop;
|
||||
|
||||
// Calculate list height (leave room for buttons at bottom)
|
||||
int buttonsAreaHeight =
|
||||
GuiLayoutConstants.BUTTON_HEIGHT * 2 +
|
||||
GuiLayoutConstants.MARGIN_L +
|
||||
GuiLayoutConstants.MARGIN_M;
|
||||
listHeight =
|
||||
this.imageHeight - (listStartY - this.topPos) - buttonsAreaHeight;
|
||||
|
||||
// === Action Buttons (bottom) ===
|
||||
int buttonY =
|
||||
this.topPos +
|
||||
this.imageHeight -
|
||||
GuiLayoutConstants.BUTTON_HEIGHT * 2 -
|
||||
GuiLayoutConstants.MARGIN_L;
|
||||
int buttonWidth = 80;
|
||||
int buttonSpacing = 6;
|
||||
int buttonsStartX = this.leftPos + GuiLayoutConstants.MARGIN_M;
|
||||
|
||||
// Row 1: Rename, Delete
|
||||
renameButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.cell_manager.button.rename"),
|
||||
b -> toggleRenameMode()
|
||||
)
|
||||
.bounds(
|
||||
buttonsStartX,
|
||||
buttonY,
|
||||
buttonWidth,
|
||||
GuiLayoutConstants.BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(renameButton);
|
||||
|
||||
deleteButton = Button.builder(
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_manager.button.delete"
|
||||
).withStyle(ChatFormatting.RED),
|
||||
b -> deleteSelectedCell()
|
||||
)
|
||||
.bounds(
|
||||
buttonsStartX + buttonWidth + buttonSpacing,
|
||||
buttonY,
|
||||
buttonWidth,
|
||||
GuiLayoutConstants.BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(deleteButton);
|
||||
|
||||
// Row 2: Release, Teleport
|
||||
int row2Y =
|
||||
buttonY +
|
||||
GuiLayoutConstants.BUTTON_HEIGHT +
|
||||
GuiLayoutConstants.MARGIN_S;
|
||||
|
||||
releaseButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.cell_manager.button.release"),
|
||||
b -> releaseSelectedPrisoner()
|
||||
)
|
||||
.bounds(
|
||||
buttonsStartX,
|
||||
row2Y,
|
||||
buttonWidth,
|
||||
GuiLayoutConstants.BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(releaseButton);
|
||||
|
||||
teleportButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.cell_manager.button.teleport"),
|
||||
b -> teleportSelectedPrisoner()
|
||||
)
|
||||
.bounds(
|
||||
buttonsStartX + buttonWidth + buttonSpacing,
|
||||
row2Y,
|
||||
buttonWidth,
|
||||
GuiLayoutConstants.BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(teleportButton);
|
||||
|
||||
// Close button (right side)
|
||||
closeButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.close"),
|
||||
b -> onClose()
|
||||
)
|
||||
.bounds(
|
||||
this.leftPos +
|
||||
this.imageWidth -
|
||||
GuiLayoutConstants.BUTTON_WIDTH_M -
|
||||
GuiLayoutConstants.MARGIN_M,
|
||||
row2Y,
|
||||
GuiLayoutConstants.BUTTON_WIDTH_M,
|
||||
GuiLayoutConstants.BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(closeButton);
|
||||
|
||||
// Rename edit box (hidden by default)
|
||||
renameBox = new EditBox(
|
||||
this.font,
|
||||
this.leftPos + GuiLayoutConstants.MARGIN_M,
|
||||
buttonY - 25,
|
||||
this.imageWidth - GuiLayoutConstants.MARGIN_M * 2 - 60,
|
||||
20,
|
||||
Component.translatable("gui.tiedup.cell_manager.cell_name")
|
||||
);
|
||||
renameBox.setMaxLength(32);
|
||||
renameBox.setVisible(false);
|
||||
this.addRenderableWidget(renameBox);
|
||||
|
||||
updateButtonStates();
|
||||
}
|
||||
|
||||
private void updateButtonStates() {
|
||||
boolean hasCellSelected = selectedCell != null;
|
||||
boolean hasPrisonerSelected = selectedPrisoner != null;
|
||||
|
||||
// Determine if player can manage the selected cell
|
||||
// OP can manage any cell, non-OP can only manage their owned cells
|
||||
boolean canManageCell =
|
||||
hasCellSelected && (isOperator || selectedCell.isOwned);
|
||||
|
||||
renameButton.active = canManageCell && !renameMode;
|
||||
deleteButton.active = canManageCell && !renameMode;
|
||||
releaseButton.active =
|
||||
canManageCell && hasPrisonerSelected && !renameMode;
|
||||
teleportButton.active =
|
||||
canManageCell && hasPrisonerSelected && !renameMode;
|
||||
}
|
||||
|
||||
private void toggleRenameMode() {
|
||||
if (selectedCell == null) return;
|
||||
|
||||
renameMode = !renameMode;
|
||||
renameBox.setVisible(renameMode);
|
||||
|
||||
if (renameMode) {
|
||||
String currentName =
|
||||
selectedCell.name != null ? selectedCell.name : "";
|
||||
renameBox.setValue(currentName);
|
||||
renameBox.setFocused(true);
|
||||
renameButton.setMessage(
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_manager.button.save"
|
||||
).withStyle(ChatFormatting.GREEN)
|
||||
);
|
||||
} else {
|
||||
// Save the name
|
||||
String newName = renameBox.getValue().trim();
|
||||
ModNetwork.sendToServer(
|
||||
new PacketRenameCell(selectedCell.cellId, newName)
|
||||
);
|
||||
renameButton.setMessage(
|
||||
Component.translatable("gui.tiedup.cell_manager.button.rename")
|
||||
);
|
||||
}
|
||||
|
||||
updateButtonStates();
|
||||
}
|
||||
|
||||
private void deleteSelectedCell() {
|
||||
if (selectedCell == null) return;
|
||||
|
||||
ModNetwork.sendToServer(
|
||||
new PacketCellAction(
|
||||
PacketCellAction.Action.DELETE_CELL,
|
||||
selectedCell.cellId,
|
||||
null,
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
// Remove from local list and deselect
|
||||
cells.remove(selectedCell);
|
||||
selectedCell = null;
|
||||
selectedPrisoner = null;
|
||||
updateButtonStates();
|
||||
}
|
||||
|
||||
private void releaseSelectedPrisoner() {
|
||||
if (selectedCell == null || selectedPrisoner == null) return;
|
||||
|
||||
ModNetwork.sendToServer(
|
||||
new PacketCellAction(
|
||||
PacketCellAction.Action.RELEASE,
|
||||
selectedCell.cellId,
|
||||
selectedPrisoner.prisonerId,
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
// Remove prisoner from local state
|
||||
selectedCell.prisoners.remove(selectedPrisoner);
|
||||
selectedPrisoner = null;
|
||||
updateButtonStates();
|
||||
}
|
||||
|
||||
private void teleportSelectedPrisoner() {
|
||||
if (selectedCell == null || selectedPrisoner == null) return;
|
||||
|
||||
ModNetwork.sendToServer(
|
||||
new PacketCellAction(
|
||||
PacketCellAction.Action.TELEPORT,
|
||||
selectedCell.cellId,
|
||||
selectedPrisoner.prisonerId,
|
||||
null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderContent(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
// Cell count subtitle (show OP mode indicator if operator)
|
||||
Component subtitle = Component.translatable(
|
||||
"gui.tiedup.cell_manager.label.cell_count",
|
||||
cells.size()
|
||||
);
|
||||
if (isOperator) {
|
||||
subtitle = subtitle
|
||||
.copy()
|
||||
.append(" ")
|
||||
.append(
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_manager.label.op_mode"
|
||||
)
|
||||
);
|
||||
}
|
||||
graphics.drawCenteredString(
|
||||
this.font,
|
||||
subtitle
|
||||
.copy()
|
||||
.withStyle(
|
||||
isOperator ? ChatFormatting.GOLD : ChatFormatting.GRAY
|
||||
),
|
||||
this.leftPos + this.imageWidth / 2,
|
||||
this.topPos + GuiLayoutConstants.MARGIN_M + 12,
|
||||
isOperator ? GuiColors.WARNING : GuiColors.TEXT_GRAY
|
||||
);
|
||||
|
||||
// Render cell list
|
||||
renderCellList(graphics, mouseX, mouseY);
|
||||
|
||||
// Render rename mode hint
|
||||
if (renameMode) {
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_manager.status.rename_hint"
|
||||
).getString(),
|
||||
this.leftPos + GuiLayoutConstants.MARGIN_M,
|
||||
renameBox.getY() - 12,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the total pixel height of all cell entries.
|
||||
*/
|
||||
private int getTotalContentHeight() {
|
||||
int total = 0;
|
||||
for (CellSyncData cell : cells) {
|
||||
total += cellEntryHeight;
|
||||
int prisoners = cell.prisoners.isEmpty()
|
||||
? 1
|
||||
: cell.prisoners.size();
|
||||
total += prisoners * prisonerEntryHeight;
|
||||
total += 4; // spacing
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private int getMaxScrollOffset() {
|
||||
int totalHeight = getTotalContentHeight();
|
||||
return Math.max(0, totalHeight - listHeight);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
|
||||
int maxScroll = getMaxScrollOffset();
|
||||
if (maxScroll > 0) {
|
||||
int scrollAmount = (int) (delta * 20);
|
||||
scrollOffset = Math.max(
|
||||
0,
|
||||
Math.min(maxScroll, scrollOffset - scrollAmount)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return super.mouseScrolled(mouseX, mouseY, delta);
|
||||
}
|
||||
|
||||
private void renderCellList(GuiGraphics graphics, int mouseX, int mouseY) {
|
||||
int listX = this.leftPos + GuiLayoutConstants.MARGIN_M;
|
||||
int listWidth = this.imageWidth - GuiLayoutConstants.MARGIN_M * 2;
|
||||
|
||||
// Apply scroll offset to starting Y
|
||||
int currentY = listStartY - scrollOffset;
|
||||
|
||||
for (CellSyncData cell : cells) {
|
||||
if (currentY > listStartY + listHeight) break;
|
||||
if (currentY + cellEntryHeight < listStartY) {
|
||||
currentY +=
|
||||
cellEntryHeight +
|
||||
cell.prisoners.size() * prisonerEntryHeight;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cell header background
|
||||
boolean cellSelected = cell == selectedCell;
|
||||
boolean cellHovered =
|
||||
mouseX >= listX &&
|
||||
mouseX < listX + listWidth &&
|
||||
mouseY >= currentY &&
|
||||
mouseY < currentY + cellEntryHeight;
|
||||
|
||||
int bgColor;
|
||||
if (cellSelected) {
|
||||
bgColor = GuiColors.SLOT_SELECTED;
|
||||
} else if (cellHovered) {
|
||||
bgColor = GuiColors.SLOT_HOVER;
|
||||
} else {
|
||||
bgColor = GuiColors.BG_LIGHT;
|
||||
}
|
||||
|
||||
graphics.fill(
|
||||
listX,
|
||||
currentY,
|
||||
listX + listWidth,
|
||||
currentY + cellEntryHeight,
|
||||
bgColor
|
||||
);
|
||||
|
||||
// Cell icon - different color for owned vs not-owned cells
|
||||
int iconColor = cell.isOwned
|
||||
? GuiColors.TYPE_COLLAR
|
||||
: GuiColors.TEXT_DISABLED;
|
||||
graphics.fill(
|
||||
listX + 4,
|
||||
currentY + 4,
|
||||
listX + 18,
|
||||
currentY + 18,
|
||||
iconColor
|
||||
);
|
||||
|
||||
// Lock icon overlay if not owned and not OP
|
||||
if (!cell.isOwned && !isOperator) {
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
"\uD83D\uDD12",
|
||||
listX + 5,
|
||||
currentY + 5,
|
||||
GuiColors.TEXT_DISABLED
|
||||
); // Lock emoji
|
||||
}
|
||||
|
||||
// Cell name
|
||||
String displayName = cell.getDisplayName();
|
||||
int nameColor = cell.isOwned
|
||||
? GuiColors.TEXT_WHITE
|
||||
: GuiColors.TEXT_GRAY;
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
displayName,
|
||||
listX + 24,
|
||||
currentY + 4,
|
||||
nameColor
|
||||
);
|
||||
|
||||
// Prisoner count badge
|
||||
CellListRenderer.renderCountBadge(
|
||||
graphics,
|
||||
this.font,
|
||||
cell.prisonerCount,
|
||||
cell.maxPrisoners,
|
||||
listX + listWidth,
|
||||
currentY + 4
|
||||
);
|
||||
|
||||
// Location + owner info for non-owned cells
|
||||
String locStr = "@ " + cell.spawnPoint.toShortString();
|
||||
if (!cell.isOwned && cell.ownerName != null) {
|
||||
locStr +=
|
||||
" (" +
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_manager.label.owner",
|
||||
cell.ownerName
|
||||
).getString() +
|
||||
")";
|
||||
}
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
locStr,
|
||||
listX + 24,
|
||||
currentY + 14,
|
||||
GuiColors.TEXT_DISABLED
|
||||
);
|
||||
|
||||
currentY += cellEntryHeight;
|
||||
|
||||
// Render prisoners under this cell
|
||||
for (PrisonerInfo prisoner : cell.prisoners) {
|
||||
if (currentY > listStartY + listHeight) break;
|
||||
|
||||
boolean prisonerSelected =
|
||||
prisoner == selectedPrisoner && cell == selectedCell;
|
||||
boolean prisonerHovered =
|
||||
mouseX >= listX + 20 &&
|
||||
mouseX < listX + listWidth &&
|
||||
mouseY >= currentY &&
|
||||
mouseY < currentY + prisonerEntryHeight;
|
||||
|
||||
int prisonerBg;
|
||||
if (prisonerSelected) {
|
||||
prisonerBg = GuiColors.lighten(
|
||||
GuiColors.SLOT_SELECTED,
|
||||
0.2f
|
||||
);
|
||||
} else if (prisonerHovered) {
|
||||
prisonerBg = GuiColors.SLOT_HOVER;
|
||||
} else {
|
||||
prisonerBg = GuiColors.BG_DARK;
|
||||
}
|
||||
|
||||
graphics.fill(
|
||||
listX + 20,
|
||||
currentY,
|
||||
listX + listWidth,
|
||||
currentY + prisonerEntryHeight,
|
||||
prisonerBg
|
||||
);
|
||||
|
||||
// Prisoner indicator
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
" \u2514\u2500 ",
|
||||
listX,
|
||||
currentY + 2,
|
||||
GuiColors.TEXT_DISABLED
|
||||
);
|
||||
|
||||
// Prisoner name
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
prisoner.prisonerName,
|
||||
listX + 40,
|
||||
currentY + 4,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
|
||||
currentY += prisonerEntryHeight;
|
||||
}
|
||||
|
||||
// If no prisoners, show "(empty)"
|
||||
if (cell.prisoners.isEmpty()) {
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
Component.literal(" \u2514\u2500 ")
|
||||
.append(
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_manager.label.empty"
|
||||
)
|
||||
)
|
||||
.withStyle(ChatFormatting.ITALIC),
|
||||
listX,
|
||||
currentY + 2,
|
||||
GuiColors.TEXT_DISABLED
|
||||
);
|
||||
currentY += prisonerEntryHeight;
|
||||
}
|
||||
|
||||
// Spacing between cells
|
||||
currentY += 4;
|
||||
}
|
||||
|
||||
// If no cells
|
||||
if (cells.isEmpty()) {
|
||||
CellListRenderer.renderEmptyState(
|
||||
graphics,
|
||||
this.font,
|
||||
this.leftPos + this.imageWidth / 2,
|
||||
listStartY,
|
||||
"gui.tiedup.cell_manager.status.no_cells",
|
||||
"gui.tiedup.cell_manager.status.use_cellwand"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (button == 0 && !renameMode) {
|
||||
// Only handle clicks within the visible list area
|
||||
if (mouseY < listStartY || mouseY > listStartY + listHeight) {
|
||||
return super.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
// Handle cell/prisoner selection
|
||||
int listX = this.leftPos + GuiLayoutConstants.MARGIN_M;
|
||||
int listWidth = this.imageWidth - GuiLayoutConstants.MARGIN_M * 2;
|
||||
int currentY = listStartY - scrollOffset;
|
||||
|
||||
for (CellSyncData cell : cells) {
|
||||
if (currentY > listStartY + listHeight) break;
|
||||
|
||||
// Check cell header click
|
||||
if (
|
||||
mouseX >= listX &&
|
||||
mouseX < listX + listWidth &&
|
||||
mouseY >= currentY &&
|
||||
mouseY < currentY + cellEntryHeight
|
||||
) {
|
||||
selectedCell = cell;
|
||||
selectedPrisoner = null;
|
||||
updateButtonStates();
|
||||
return true;
|
||||
}
|
||||
|
||||
currentY += cellEntryHeight;
|
||||
|
||||
// Check prisoner clicks
|
||||
for (PrisonerInfo prisoner : cell.prisoners) {
|
||||
if (currentY > listStartY + listHeight) break;
|
||||
|
||||
if (
|
||||
mouseX >= listX + 20 &&
|
||||
mouseX < listX + listWidth &&
|
||||
mouseY >= currentY &&
|
||||
mouseY < currentY + prisonerEntryHeight
|
||||
) {
|
||||
selectedCell = cell;
|
||||
selectedPrisoner = prisoner;
|
||||
updateButtonStates();
|
||||
return true;
|
||||
}
|
||||
|
||||
currentY += prisonerEntryHeight;
|
||||
}
|
||||
|
||||
// Empty cell placeholder height
|
||||
if (cell.prisoners.isEmpty()) {
|
||||
currentY += prisonerEntryHeight;
|
||||
}
|
||||
|
||||
currentY += 4; // Spacing
|
||||
}
|
||||
}
|
||||
|
||||
return super.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||
if (renameMode) {
|
||||
if (keyCode == 257) {
|
||||
// Enter
|
||||
toggleRenameMode(); // Save
|
||||
return true;
|
||||
} else if (keyCode == 256) {
|
||||
// Escape
|
||||
renameMode = false;
|
||||
renameBox.setVisible(false);
|
||||
renameButton.setMessage(
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_manager.button.rename"
|
||||
)
|
||||
);
|
||||
updateButtonStates();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return super.keyPressed(keyCode, scanCode, modifiers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
import com.tiedup.remake.client.gui.widgets.CellListRenderer;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.cell.PacketAssignCellToCollar;
|
||||
import com.tiedup.remake.network.cell.PacketOpenCellSelector.CellOption;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Modal screen for selecting a cell to assign to a collar.
|
||||
* Opens from SlaveItemManagementScreen when clicking "Cell" button.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class CellSelectorScreen extends BaseScreen {
|
||||
|
||||
private final UUID targetEntityUUID;
|
||||
private final List<CellOption> cells;
|
||||
|
||||
@Nullable
|
||||
private CellOption selectedCell;
|
||||
|
||||
// Layout
|
||||
private int listStartY;
|
||||
private int listHeight;
|
||||
private int entryHeight = 28;
|
||||
|
||||
// Buttons
|
||||
private Button confirmButton;
|
||||
private Button clearButton;
|
||||
private Button backButton;
|
||||
|
||||
public CellSelectorScreen(UUID targetEntityUUID, List<CellOption> cells) {
|
||||
super(Component.translatable("gui.tiedup.select_cell"));
|
||||
this.targetEntityUUID = targetEntityUUID;
|
||||
this.cells = cells;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredWidth() {
|
||||
return GuiLayoutConstants.getResponsiveWidth(
|
||||
this.width,
|
||||
0.5f,
|
||||
280,
|
||||
380
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredHeight() {
|
||||
return GuiLayoutConstants.getResponsiveHeight(
|
||||
this.height,
|
||||
0.6f,
|
||||
250,
|
||||
350
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
|
||||
int contentTop =
|
||||
this.topPos +
|
||||
GuiLayoutConstants.TITLE_HEIGHT +
|
||||
GuiLayoutConstants.MARGIN_M;
|
||||
listStartY = contentTop;
|
||||
|
||||
// Calculate list height
|
||||
int buttonsAreaHeight =
|
||||
GuiLayoutConstants.BUTTON_HEIGHT + GuiLayoutConstants.MARGIN_L;
|
||||
listHeight =
|
||||
this.imageHeight - (listStartY - this.topPos) - buttonsAreaHeight;
|
||||
|
||||
// === Buttons at bottom ===
|
||||
int buttonY =
|
||||
this.topPos +
|
||||
this.imageHeight -
|
||||
GuiLayoutConstants.BUTTON_HEIGHT -
|
||||
GuiLayoutConstants.MARGIN_M;
|
||||
int buttonWidth = 70;
|
||||
int buttonSpacing = 8;
|
||||
int totalButtonsWidth = buttonWidth * 3 + buttonSpacing * 2;
|
||||
int buttonsStartX =
|
||||
this.leftPos + (this.imageWidth - totalButtonsWidth) / 2;
|
||||
|
||||
confirmButton = Button.builder(
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_selector.button.confirm"
|
||||
).withStyle(ChatFormatting.GREEN),
|
||||
b -> confirmSelection()
|
||||
)
|
||||
.bounds(
|
||||
buttonsStartX,
|
||||
buttonY,
|
||||
buttonWidth,
|
||||
GuiLayoutConstants.BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(confirmButton);
|
||||
|
||||
clearButton = Button.builder(
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_selector.button.clear"
|
||||
).withStyle(ChatFormatting.YELLOW),
|
||||
b -> clearSelection()
|
||||
)
|
||||
.bounds(
|
||||
buttonsStartX + buttonWidth + buttonSpacing,
|
||||
buttonY,
|
||||
buttonWidth,
|
||||
GuiLayoutConstants.BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(clearButton);
|
||||
|
||||
backButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.cell_selector.button.back"),
|
||||
b -> onClose()
|
||||
)
|
||||
.bounds(
|
||||
buttonsStartX + (buttonWidth + buttonSpacing) * 2,
|
||||
buttonY,
|
||||
buttonWidth,
|
||||
GuiLayoutConstants.BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(backButton);
|
||||
|
||||
updateButtonStates();
|
||||
}
|
||||
|
||||
private void updateButtonStates() {
|
||||
confirmButton.active = selectedCell != null;
|
||||
}
|
||||
|
||||
private void confirmSelection() {
|
||||
if (selectedCell == null) return;
|
||||
|
||||
// Send packet to assign cell
|
||||
ModNetwork.sendToServer(
|
||||
new PacketAssignCellToCollar(targetEntityUUID, selectedCell.cellId)
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
|
||||
private void clearSelection() {
|
||||
// Send packet to clear cell (null cellId)
|
||||
ModNetwork.sendToServer(
|
||||
new PacketAssignCellToCollar(targetEntityUUID, null)
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderContent(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
// Render cell list
|
||||
renderCellList(graphics, mouseX, mouseY);
|
||||
}
|
||||
|
||||
private void renderCellList(GuiGraphics graphics, int mouseX, int mouseY) {
|
||||
int listX = this.leftPos + GuiLayoutConstants.MARGIN_M;
|
||||
int listWidth = this.imageWidth - GuiLayoutConstants.MARGIN_M * 2;
|
||||
|
||||
int currentY = listStartY;
|
||||
|
||||
for (int i = 0; i < cells.size(); i++) {
|
||||
CellOption cell = cells.get(i);
|
||||
|
||||
if (currentY + entryHeight > listStartY + listHeight) break;
|
||||
|
||||
boolean isSelected = cell == selectedCell;
|
||||
boolean isHovered =
|
||||
mouseX >= listX &&
|
||||
mouseX < listX + listWidth &&
|
||||
mouseY >= currentY &&
|
||||
mouseY < currentY + entryHeight;
|
||||
|
||||
// Background
|
||||
int bgColor;
|
||||
if (isSelected) {
|
||||
bgColor = GuiColors.SLOT_SELECTED;
|
||||
} else if (isHovered) {
|
||||
bgColor = GuiColors.SLOT_HOVER;
|
||||
} else {
|
||||
bgColor = (i % 2 == 0)
|
||||
? GuiColors.SLOT_EMPTY
|
||||
: GuiColors.BG_LIGHT;
|
||||
}
|
||||
|
||||
graphics.fill(
|
||||
listX,
|
||||
currentY,
|
||||
listX + listWidth,
|
||||
currentY + entryHeight - 2,
|
||||
bgColor
|
||||
);
|
||||
|
||||
// Radio button indicator
|
||||
int radioX = listX + 8;
|
||||
int radioY = currentY + entryHeight / 2 - 4;
|
||||
if (isSelected) {
|
||||
// Filled circle
|
||||
graphics.fill(
|
||||
radioX,
|
||||
radioY,
|
||||
radioX + 8,
|
||||
radioY + 8,
|
||||
GuiColors.SUCCESS
|
||||
);
|
||||
} else {
|
||||
// Empty circle (border only)
|
||||
graphics.fill(
|
||||
radioX,
|
||||
radioY,
|
||||
radioX + 8,
|
||||
radioY + 8,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
graphics.fill(
|
||||
radioX + 1,
|
||||
radioY + 1,
|
||||
radioX + 7,
|
||||
radioY + 7,
|
||||
bgColor
|
||||
);
|
||||
}
|
||||
|
||||
// Cell name
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
cell.displayName,
|
||||
listX + 24,
|
||||
currentY + 4,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
|
||||
// Prisoner count badge
|
||||
CellListRenderer.renderCountBadge(
|
||||
graphics,
|
||||
this.font,
|
||||
cell.prisonerCount,
|
||||
cell.maxPrisoners,
|
||||
listX + listWidth,
|
||||
currentY + 4
|
||||
);
|
||||
|
||||
// Full indicator
|
||||
if (cell.prisonerCount >= cell.maxPrisoners) {
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
Component.translatable(
|
||||
"gui.tiedup.cell_selector.status.full"
|
||||
).withStyle(ChatFormatting.RED),
|
||||
listX + 24,
|
||||
currentY + 16,
|
||||
GuiColors.ERROR
|
||||
);
|
||||
}
|
||||
|
||||
currentY += entryHeight;
|
||||
}
|
||||
|
||||
// If no cells
|
||||
if (cells.isEmpty()) {
|
||||
CellListRenderer.renderEmptyState(
|
||||
graphics,
|
||||
this.font,
|
||||
this.leftPos + this.imageWidth / 2,
|
||||
listStartY,
|
||||
"gui.tiedup.cell_selector.status.no_cells",
|
||||
"gui.tiedup.cell_selector.status.use_cellwand"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (button == 0) {
|
||||
int listX = this.leftPos + GuiLayoutConstants.MARGIN_M;
|
||||
int listWidth = this.imageWidth - GuiLayoutConstants.MARGIN_M * 2;
|
||||
int currentY = listStartY;
|
||||
|
||||
for (CellOption cell : cells) {
|
||||
if (currentY + entryHeight > listStartY + listHeight) break;
|
||||
|
||||
if (
|
||||
mouseX >= listX &&
|
||||
mouseX < listX + listWidth &&
|
||||
mouseY >= currentY &&
|
||||
mouseY < currentY + entryHeight
|
||||
) {
|
||||
// Don't allow selecting full cells
|
||||
if (cell.prisonerCount < cell.maxPrisoners) {
|
||||
selectedCell = cell;
|
||||
updateButtonStates();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
currentY += entryHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return super.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,544 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import com.tiedup.remake.client.ModKeybindings;
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.minigame.PacketContinuousStruggleHold;
|
||||
import com.tiedup.remake.network.minigame.PacketContinuousStruggleStop;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.client.KeyMapping;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
|
||||
/**
|
||||
* Client-side GUI for the Continuous Struggle mini-game.
|
||||
*
|
||||
* Layout:
|
||||
* ┌─────────────────────────────────────┐
|
||||
* │ STRUGGLING... │
|
||||
* │ │
|
||||
* │ ┌─────┐ │
|
||||
* │ │ ↑ │ ← Arrow │
|
||||
* │ │ W │ direction │
|
||||
* │ └─────┘ │
|
||||
* │ │
|
||||
* │ HOLD [W] to struggle! │
|
||||
* │ │
|
||||
* │ ████████████░░░░░░░░░ 45/100 │ ← Progress bar
|
||||
* │ │
|
||||
* │ Press ESC to stop │
|
||||
* └─────────────────────────────────────┘
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ContinuousStruggleMiniGameScreen extends BaseScreen {
|
||||
|
||||
private UUID sessionId;
|
||||
private int currentDirection;
|
||||
private int currentResistance;
|
||||
private int maxResistance;
|
||||
private boolean isLocked;
|
||||
|
||||
// Visual state
|
||||
private float animatedResistance;
|
||||
private int flashTicks;
|
||||
private boolean showDirectionChangeFlash;
|
||||
private boolean showShockEffect;
|
||||
private int shockEffectTicks;
|
||||
private boolean gameComplete;
|
||||
private boolean gameSuccess;
|
||||
private int completionDelayTicks;
|
||||
|
||||
// Input state
|
||||
private int heldDirection = -1;
|
||||
private int ticksSinceLastUpdate = 0;
|
||||
|
||||
// Colors
|
||||
private static final int ARROW_BG_NORMAL = 0xFF444444;
|
||||
private static final int ARROW_BG_HOLDING = 0xFF006600;
|
||||
private static final int ARROW_BG_WRONG = 0xFF664400;
|
||||
private static final int ARROW_BG_SHOCKED = 0xFF660000;
|
||||
private static final int ARROW_TEXT_COLOR = 0xFFFFFFFF;
|
||||
private static final int PROGRESS_BAR_BG = 0xFF442222;
|
||||
private static final int PROGRESS_BAR_FILL = 0xFF44AA44;
|
||||
private static final int PROGRESS_BAR_EMPTY = 0xFFAA4444;
|
||||
|
||||
// Update interval (every 5 ticks = 4 times per second)
|
||||
private static final int UPDATE_INTERVAL_TICKS = 5;
|
||||
|
||||
public ContinuousStruggleMiniGameScreen(
|
||||
UUID sessionId,
|
||||
int currentDirection,
|
||||
int currentResistance,
|
||||
int maxResistance,
|
||||
boolean isLocked
|
||||
) {
|
||||
super(Component.translatable("gui.tiedup.continuous_struggle"));
|
||||
this.sessionId = sessionId;
|
||||
this.currentDirection = currentDirection;
|
||||
this.currentResistance = currentResistance;
|
||||
this.maxResistance = maxResistance;
|
||||
this.isLocked = isLocked;
|
||||
this.animatedResistance = currentResistance;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredWidth() {
|
||||
return GuiLayoutConstants.getResponsiveWidth(
|
||||
this.width,
|
||||
0.5f,
|
||||
280,
|
||||
350
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredHeight() {
|
||||
return GuiLayoutConstants.getResponsiveHeight(
|
||||
this.height,
|
||||
0.45f,
|
||||
200,
|
||||
280
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
TiedUpMod.LOGGER.info(
|
||||
"[ContinuousStruggleMiniGameScreen] Screen initialized: dir={}, resistance={}/{}, locked={}",
|
||||
currentDirection,
|
||||
currentResistance,
|
||||
maxResistance,
|
||||
isLocked
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== STATE UPDATES FROM SERVER ====================
|
||||
|
||||
/**
|
||||
* Called when direction changes.
|
||||
*/
|
||||
public void onDirectionChange(int newDirection) {
|
||||
this.currentDirection = newDirection;
|
||||
this.showDirectionChangeFlash = true;
|
||||
this.flashTicks = 15;
|
||||
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[ContinuousStruggleMiniGameScreen] Direction changed to {}",
|
||||
getDirectionKeyName(newDirection)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when resistance updates.
|
||||
*/
|
||||
public void onResistanceUpdate(int newResistance) {
|
||||
this.currentResistance = newResistance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when shock collar triggers.
|
||||
*/
|
||||
public void onShock() {
|
||||
this.showShockEffect = true;
|
||||
this.shockEffectTicks = 20;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when player escapes successfully.
|
||||
*/
|
||||
public void onEscape() {
|
||||
this.gameComplete = true;
|
||||
this.gameSuccess = true;
|
||||
this.completionDelayTicks = 40; // 2 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when session ends.
|
||||
*/
|
||||
public void onEnd() {
|
||||
this.gameComplete = true;
|
||||
this.gameSuccess = false;
|
||||
this.onClose();
|
||||
}
|
||||
|
||||
// ==================== TICK AND RENDER ====================
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
super.tick();
|
||||
|
||||
// Animate resistance bar
|
||||
animatedResistance = lerp(animatedResistance, currentResistance, 0.15f);
|
||||
|
||||
// Flash timer
|
||||
if (flashTicks > 0) {
|
||||
flashTicks--;
|
||||
if (flashTicks == 0) {
|
||||
showDirectionChangeFlash = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Shock effect timer
|
||||
if (shockEffectTicks > 0) {
|
||||
shockEffectTicks--;
|
||||
if (shockEffectTicks == 0) {
|
||||
showShockEffect = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Completion delay
|
||||
if (gameComplete && completionDelayTicks > 0) {
|
||||
completionDelayTicks--;
|
||||
if (completionDelayTicks <= 0) {
|
||||
this.onClose();
|
||||
}
|
||||
return; // Don't send updates after game complete
|
||||
}
|
||||
|
||||
// Check held keys and send updates to server
|
||||
if (!gameComplete) {
|
||||
updateHeldDirection();
|
||||
|
||||
ticksSinceLastUpdate++;
|
||||
if (ticksSinceLastUpdate >= UPDATE_INTERVAL_TICKS) {
|
||||
ticksSinceLastUpdate = 0;
|
||||
sendHoldUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which direction key is currently held.
|
||||
* Uses InputConstants.isKeyDown() directly because keyMapping.isDown()
|
||||
* doesn't work properly when a Screen is open.
|
||||
*/
|
||||
private void updateHeldDirection() {
|
||||
if (this.minecraft == null) return;
|
||||
|
||||
long windowHandle = this.minecraft.getWindow().getWindow();
|
||||
int newHeldDirection = -1;
|
||||
|
||||
// Check each direction key using direct GLFW input
|
||||
for (int i = 0; i < 4; i++) {
|
||||
KeyMapping keyMapping = ModKeybindings.getStruggleDirectionKey(i);
|
||||
if (keyMapping != null) {
|
||||
// Get the key code from the KeyMapping
|
||||
InputConstants.Key key = keyMapping.getKey();
|
||||
if (key.getType() == InputConstants.Type.KEYSYM) {
|
||||
if (
|
||||
InputConstants.isKeyDown(windowHandle, key.getValue())
|
||||
) {
|
||||
newHeldDirection = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.heldDirection = newHeldDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send held direction update to server.
|
||||
*/
|
||||
private void sendHoldUpdate() {
|
||||
boolean isHolding = heldDirection >= 0;
|
||||
ModNetwork.sendToServer(
|
||||
new PacketContinuousStruggleHold(
|
||||
sessionId,
|
||||
heldDirection,
|
||||
isHolding
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
try {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
|
||||
int centerX = this.width / 2;
|
||||
int y = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 15;
|
||||
|
||||
// Direction arrow
|
||||
renderDirectionArrow(graphics, centerX, y);
|
||||
y += 80;
|
||||
|
||||
// Hold instruction
|
||||
renderHoldInstruction(graphics, centerX, y);
|
||||
y += 25;
|
||||
|
||||
// Progress bar
|
||||
renderProgressBar(graphics, y);
|
||||
y += 40;
|
||||
|
||||
// Lock indicator
|
||||
if (isLocked) {
|
||||
Component lockText = Component.translatable(
|
||||
"gui.tiedup.continuous_struggle.status.locked"
|
||||
).withStyle(ChatFormatting.RED, ChatFormatting.BOLD);
|
||||
int lockWidth = this.font.width(lockText);
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
lockText,
|
||||
centerX - lockWidth / 2,
|
||||
y,
|
||||
0xFFFFFFFF
|
||||
);
|
||||
y += 18;
|
||||
}
|
||||
|
||||
// Status / instructions
|
||||
renderStatus(graphics, centerX, y);
|
||||
|
||||
// Shock overlay
|
||||
if (showShockEffect) {
|
||||
renderShockOverlay(graphics);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
TiedUpMod.LOGGER.error(
|
||||
"[ContinuousStruggleMiniGameScreen] Render error: ",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderDirectionArrow(
|
||||
GuiGraphics graphics,
|
||||
int centerX,
|
||||
int y
|
||||
) {
|
||||
int arrowSize = 60;
|
||||
int arrowX = centerX - arrowSize / 2;
|
||||
|
||||
// Determine background color
|
||||
int bgColor;
|
||||
if (showShockEffect) {
|
||||
bgColor = ARROW_BG_SHOCKED;
|
||||
} else if (showDirectionChangeFlash) {
|
||||
bgColor = (flashTicks % 4 < 2) ? 0xFF666600 : ARROW_BG_NORMAL;
|
||||
} else if (heldDirection == currentDirection) {
|
||||
bgColor = ARROW_BG_HOLDING;
|
||||
} else if (heldDirection >= 0 && heldDirection != currentDirection) {
|
||||
bgColor = ARROW_BG_WRONG;
|
||||
} else {
|
||||
bgColor = ARROW_BG_NORMAL;
|
||||
}
|
||||
|
||||
// Draw arrow box
|
||||
graphics.fill(arrowX, y, arrowX + arrowSize, y + arrowSize, bgColor);
|
||||
graphics.renderOutline(arrowX, y, arrowSize, arrowSize, 0xFFAAAAAA);
|
||||
|
||||
// Draw arrow symbol
|
||||
String arrowSymbol = getDirectionArrow(currentDirection);
|
||||
int arrowWidth = this.font.width(arrowSymbol);
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
arrowSymbol,
|
||||
centerX - arrowWidth / 2,
|
||||
y + 12,
|
||||
ARROW_TEXT_COLOR
|
||||
);
|
||||
|
||||
// Draw key name
|
||||
String keyName = getDirectionKeyName(currentDirection);
|
||||
int keyWidth = this.font.width(keyName);
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
keyName,
|
||||
centerX - keyWidth / 2,
|
||||
y + arrowSize - 20,
|
||||
ARROW_TEXT_COLOR
|
||||
);
|
||||
}
|
||||
|
||||
private void renderHoldInstruction(
|
||||
GuiGraphics graphics,
|
||||
int centerX,
|
||||
int y
|
||||
) {
|
||||
String keyName = getDirectionKeyName(currentDirection);
|
||||
Component instruction;
|
||||
|
||||
if (showShockEffect) {
|
||||
instruction = Component.translatable(
|
||||
"gui.tiedup.continuous_struggle.status.shocked"
|
||||
).withStyle(ChatFormatting.RED, ChatFormatting.BOLD);
|
||||
} else if (heldDirection == currentDirection) {
|
||||
instruction = Component.translatable(
|
||||
"gui.tiedup.continuous_struggle.status.struggling"
|
||||
).withStyle(ChatFormatting.GREEN);
|
||||
} else {
|
||||
instruction = Component.translatable(
|
||||
"gui.tiedup.continuous_struggle.hold_key",
|
||||
keyName
|
||||
).withStyle(ChatFormatting.YELLOW);
|
||||
}
|
||||
|
||||
int instructionWidth = this.font.width(instruction);
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
instruction,
|
||||
centerX - instructionWidth / 2,
|
||||
y,
|
||||
0xFFFFFFFF
|
||||
);
|
||||
}
|
||||
|
||||
private void renderProgressBar(GuiGraphics graphics, int y) {
|
||||
int barWidth = 200;
|
||||
int barHeight = 18;
|
||||
int barX = (this.width - barWidth) / 2;
|
||||
|
||||
// Calculate progress (inverted: 0 resistance = full progress)
|
||||
float progress =
|
||||
maxResistance > 0
|
||||
? 1.0f - (animatedResistance / maxResistance)
|
||||
: 0.0f;
|
||||
progress = Math.max(0.0f, Math.min(1.0f, progress));
|
||||
|
||||
// Background (empty portion)
|
||||
graphics.fill(barX, y, barX + barWidth, y + barHeight, PROGRESS_BAR_BG);
|
||||
|
||||
// Fill (progress portion)
|
||||
int fillWidth = (int) (barWidth * progress);
|
||||
if (fillWidth > 0) {
|
||||
graphics.fill(
|
||||
barX,
|
||||
y,
|
||||
barX + fillWidth,
|
||||
y + barHeight,
|
||||
PROGRESS_BAR_FILL
|
||||
);
|
||||
}
|
||||
|
||||
// Border
|
||||
graphics.renderOutline(barX, y, barWidth, barHeight, 0xFF888888);
|
||||
|
||||
// Text showing resistance
|
||||
String progressText = String.format(
|
||||
"%d/%d",
|
||||
(int) animatedResistance,
|
||||
maxResistance
|
||||
);
|
||||
int textWidth = this.font.width(progressText);
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
progressText,
|
||||
barX + barWidth + 8,
|
||||
y + 5,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
|
||||
// Label above bar
|
||||
Component label = Component.translatable(
|
||||
"gui.tiedup.continuous_struggle.label.resistance"
|
||||
).withStyle(ChatFormatting.GRAY);
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
label,
|
||||
barX,
|
||||
y - 12,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
}
|
||||
|
||||
private void renderStatus(GuiGraphics graphics, int centerX, int y) {
|
||||
Component status;
|
||||
|
||||
if (gameComplete && gameSuccess) {
|
||||
status = Component.translatable(
|
||||
"gui.tiedup.continuous_struggle.status.escaped"
|
||||
).withStyle(ChatFormatting.GREEN, ChatFormatting.BOLD);
|
||||
} else if (gameComplete) {
|
||||
status = Component.translatable(
|
||||
"gui.tiedup.continuous_struggle.status.stopped"
|
||||
).withStyle(ChatFormatting.GRAY);
|
||||
} else {
|
||||
status = Component.translatable(
|
||||
"gui.tiedup.continuous_struggle.status.press_esc"
|
||||
).withStyle(ChatFormatting.DARK_GRAY);
|
||||
}
|
||||
|
||||
int statusWidth = this.font.width(status);
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
status,
|
||||
centerX - statusWidth / 2,
|
||||
y,
|
||||
0xFFFFFFFF
|
||||
);
|
||||
}
|
||||
|
||||
private void renderShockOverlay(GuiGraphics graphics) {
|
||||
// Red flash overlay
|
||||
int alpha = (int) (100 * ((float) shockEffectTicks / 20.0f));
|
||||
int overlayColor = (alpha << 24) | 0xFF0000;
|
||||
graphics.fill(0, 0, this.width, this.height, overlayColor);
|
||||
}
|
||||
|
||||
// ==================== INPUT HANDLING ====================
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||
// ESC to stop — onClose() handles sending the stop packet
|
||||
if (keyCode == GLFW.GLFW_KEY_ESCAPE) {
|
||||
this.onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.keyPressed(keyCode, scanCode, modifiers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
// Send stop packet if not already complete
|
||||
if (!gameComplete) {
|
||||
ModNetwork.sendToServer(
|
||||
new PacketContinuousStruggleStop(sessionId)
|
||||
);
|
||||
}
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== HELPERS ====================
|
||||
|
||||
private String getDirectionArrow(int direction) {
|
||||
return switch (direction) {
|
||||
case 0 -> "\u2191"; // ↑
|
||||
case 1 -> "\u2190"; // ←
|
||||
case 2 -> "\u2193"; // ↓
|
||||
case 3 -> "\u2192"; // →
|
||||
default -> "?";
|
||||
};
|
||||
}
|
||||
|
||||
private String getDirectionKeyName(int direction) {
|
||||
return ModKeybindings.getStruggleDirectionKeyName(direction);
|
||||
}
|
||||
|
||||
private float lerp(float current, float target, float speed) {
|
||||
if (Math.abs(current - target) < 0.5f) {
|
||||
return target;
|
||||
}
|
||||
return current + (target - current) * speed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.dialogue.conversation.ConversationTopic;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.conversation.PacketEndConversationC2S;
|
||||
import com.tiedup.remake.network.conversation.PacketSelectTopic;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* GUI screen for interactive conversations with NPCs.
|
||||
* Displays available conversation topics with effectiveness indicators.
|
||||
*
|
||||
* Phase 5: Enhanced Conversation System
|
||||
* Phase 2: Refactored to extend BaseInteractionScreen
|
||||
*
|
||||
* DISABLED: Conversation system not in use. Kept because PacketEndConversationS2C
|
||||
* references this class in an instanceof check.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ConversationScreen extends BaseInteractionScreen {
|
||||
|
||||
// Layout constants
|
||||
private static final int PANEL_WIDTH = 260;
|
||||
private static final int PANEL_HEIGHT = 220;
|
||||
private static final int MARGIN = 12;
|
||||
private static final int BUTTON_HEIGHT = 22;
|
||||
private static final int BUTTON_SPACING = 4;
|
||||
private static final int CATEGORY_SPACING = 8;
|
||||
|
||||
// Color constants
|
||||
private static final int TITLE_COLOR = 0xFFFFD700;
|
||||
private static final int CATEGORY_COLOR = 0xFF88AAFF;
|
||||
private static final int TEXT_WHITE = 0xFFFFFFFF;
|
||||
private static final int TEXT_DIM = 0xFFAAAAAA;
|
||||
|
||||
// Effectiveness colors (green -> yellow -> orange -> red)
|
||||
private static final int EFF_100 = 0xFF55FF55; // Green (100%)
|
||||
private static final int EFF_80 = 0xFFAAFF55; // Yellow-green (80%)
|
||||
private static final int EFF_60 = 0xFFFFFF55; // Yellow (60%)
|
||||
private static final int EFF_40 = 0xFFFFAA55; // Orange (40%)
|
||||
private static final int EFF_20 = 0xFFFF5555; // Red (20%)
|
||||
|
||||
// Data
|
||||
private final int entityId;
|
||||
private final String npcName;
|
||||
private final List<ConversationTopic> availableTopics;
|
||||
private final Map<
|
||||
ConversationTopic.Category,
|
||||
List<ConversationTopic>
|
||||
> topicsByCategory;
|
||||
|
||||
// Effectiveness tracking (sent from server or estimated client-side)
|
||||
private final Map<ConversationTopic, Float> topicEffectiveness;
|
||||
|
||||
// Rapport level (0-100 display)
|
||||
private int rapportLevel = 50;
|
||||
|
||||
public ConversationScreen(
|
||||
int entityId,
|
||||
String npcName,
|
||||
List<ConversationTopic> topics
|
||||
) {
|
||||
super(
|
||||
Component.translatable("gui.tiedup.conversation.title", npcName),
|
||||
PANEL_WIDTH,
|
||||
PANEL_HEIGHT
|
||||
);
|
||||
this.entityId = entityId;
|
||||
this.npcName = npcName;
|
||||
this.availableTopics = topics;
|
||||
this.topicEffectiveness = new LinkedHashMap<>();
|
||||
|
||||
// Initialize all topics as 100% effective (will be updated from server)
|
||||
for (ConversationTopic topic : topics) {
|
||||
topicEffectiveness.put(topic, 1.0f);
|
||||
}
|
||||
|
||||
// Organize topics by category
|
||||
this.topicsByCategory = new LinkedHashMap<>();
|
||||
for (ConversationTopic.Category category : ConversationTopic.Category.values()) {
|
||||
topicsByCategory.put(category, new ArrayList<>());
|
||||
}
|
||||
for (ConversationTopic topic : topics) {
|
||||
topicsByCategory.get(topic.getCategory()).add(topic);
|
||||
}
|
||||
// Remove empty categories
|
||||
topicsByCategory
|
||||
.entrySet()
|
||||
.removeIf(entry -> entry.getValue().isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update topic effectiveness (called from packet handler).
|
||||
*
|
||||
* @param topic The topic
|
||||
* @param effectiveness Effectiveness value (0.2 to 1.0)
|
||||
*/
|
||||
public void updateEffectiveness(
|
||||
ConversationTopic topic,
|
||||
float effectiveness
|
||||
) {
|
||||
topicEffectiveness.put(topic, effectiveness);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rapport level (called from packet handler).
|
||||
*
|
||||
* @param rapport Rapport value (-100 to 100)
|
||||
*/
|
||||
public void updateRapport(int rapport) {
|
||||
this.rapportLevel = rapport;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init(); // Centers the panel (sets leftPos and topPos)
|
||||
rebuildButtons();
|
||||
}
|
||||
|
||||
private void rebuildButtons() {
|
||||
this.clearWidgets();
|
||||
|
||||
int contentX = getContentX(MARGIN);
|
||||
int contentWidth = getContentWidth(MARGIN);
|
||||
int y = topPos + 40; // After title and WIP badge
|
||||
|
||||
// WIP placeholder buttons
|
||||
Button wipBtn1 = Button.builder(
|
||||
Component.translatable(
|
||||
"gui.tiedup.conversation.wip.small_talk"
|
||||
).withStyle(net.minecraft.ChatFormatting.GRAY),
|
||||
b -> {}
|
||||
)
|
||||
.bounds(contentX, y, contentWidth, BUTTON_HEIGHT)
|
||||
.build();
|
||||
wipBtn1.active = false;
|
||||
addRenderableWidget(wipBtn1);
|
||||
y += BUTTON_HEIGHT + BUTTON_SPACING;
|
||||
|
||||
Button wipBtn2 = Button.builder(
|
||||
Component.translatable(
|
||||
"gui.tiedup.conversation.wip.deep_topics"
|
||||
).withStyle(net.minecraft.ChatFormatting.GRAY),
|
||||
b -> {}
|
||||
)
|
||||
.bounds(contentX, y, contentWidth, BUTTON_HEIGHT)
|
||||
.build();
|
||||
wipBtn2.active = false;
|
||||
addRenderableWidget(wipBtn2);
|
||||
y += BUTTON_HEIGHT + BUTTON_SPACING;
|
||||
|
||||
Button wipBtn3 = Button.builder(
|
||||
Component.translatable(
|
||||
"gui.tiedup.conversation.wip.flirting"
|
||||
).withStyle(net.minecraft.ChatFormatting.GRAY),
|
||||
b -> {}
|
||||
)
|
||||
.bounds(contentX, y, contentWidth, BUTTON_HEIGHT)
|
||||
.build();
|
||||
wipBtn3.active = false;
|
||||
addRenderableWidget(wipBtn3);
|
||||
y += BUTTON_HEIGHT + BUTTON_SPACING;
|
||||
|
||||
Button wipBtn4 = Button.builder(
|
||||
Component.translatable(
|
||||
"gui.tiedup.conversation.wip.requests"
|
||||
).withStyle(net.minecraft.ChatFormatting.GRAY),
|
||||
b -> {}
|
||||
)
|
||||
.bounds(contentX, y, contentWidth, BUTTON_HEIGHT)
|
||||
.build();
|
||||
wipBtn4.active = false;
|
||||
addRenderableWidget(wipBtn4);
|
||||
|
||||
// Close button at bottom
|
||||
int closeBtnY = topPos + panelHeight - 28;
|
||||
addRenderableWidget(
|
||||
Button.builder(Component.translatable("gui.tiedup.close"), btn ->
|
||||
onClose()
|
||||
)
|
||||
.bounds(leftPos + panelWidth / 2 - 40, closeBtnY, 80, 20)
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
private int getEffectivenessColor(float effectiveness) {
|
||||
if (effectiveness >= 1.0f) return EFF_100;
|
||||
if (effectiveness >= 0.8f) return EFF_80;
|
||||
if (effectiveness >= 0.6f) return EFF_60;
|
||||
if (effectiveness >= 0.4f) return EFF_40;
|
||||
return EFF_20;
|
||||
}
|
||||
|
||||
private String getEffectivenessSymbol(float effectiveness) {
|
||||
return (int) (effectiveness * 100) + "%";
|
||||
}
|
||||
|
||||
private void selectTopic(ConversationTopic topic) {
|
||||
// Send packet to server
|
||||
ModNetwork.sendToServer(new PacketSelectTopic(entityId, topic));
|
||||
|
||||
// Decrease local effectiveness estimate (will be corrected by server)
|
||||
float current = topicEffectiveness.getOrDefault(topic, 1.0f);
|
||||
float next = Math.max(0.2f, current - 0.2f);
|
||||
topicEffectiveness.put(topic, next);
|
||||
|
||||
// Don't rebuild buttons immediately - wait for server response
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderContent(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
// Draw simple title with WIP badge
|
||||
renderTitle(
|
||||
graphics,
|
||||
Component.translatable("gui.tiedup.conversation.heading"),
|
||||
topPos + 8,
|
||||
TITLE_COLOR
|
||||
);
|
||||
|
||||
// WIP badge
|
||||
Component wipBadge = Component.translatable(
|
||||
"gui.tiedup.conversation.wip_badge"
|
||||
).withStyle(
|
||||
net.minecraft.ChatFormatting.YELLOW,
|
||||
net.minecraft.ChatFormatting.BOLD
|
||||
);
|
||||
graphics.drawCenteredString(
|
||||
font,
|
||||
wipBadge,
|
||||
leftPos + panelWidth / 2,
|
||||
topPos + 18,
|
||||
0xFFFFAA00
|
||||
);
|
||||
|
||||
// Info text
|
||||
Component infoText = Component.translatable(
|
||||
"gui.tiedup.conversation.status.coming_soon"
|
||||
).withStyle(
|
||||
net.minecraft.ChatFormatting.GRAY,
|
||||
net.minecraft.ChatFormatting.ITALIC
|
||||
);
|
||||
graphics.drawCenteredString(
|
||||
font,
|
||||
infoText,
|
||||
leftPos + panelWidth / 2,
|
||||
topPos + 30,
|
||||
TEXT_DIM
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
// Notify server that conversation ended
|
||||
ModNetwork.sendToServer(new PacketEndConversationC2S(entityId));
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
public int getEntityId() {
|
||||
return entityId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.minigame.PacketLockpickAttempt;
|
||||
import com.tiedup.remake.network.minigame.PacketLockpickMiniGameMove;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
|
||||
/**
|
||||
* Phase 2.5: Client-side GUI for Lockpick mini-game (Skyrim-style).
|
||||
*
|
||||
* Features:
|
||||
* - Sweet spot is HIDDEN (uniform gray bar)
|
||||
* - NO feedback during movement (A/D)
|
||||
* - Feedback ONLY when testing (SPACE): tension bar animation
|
||||
* - Tension bar fills up based on proximity, then bounces back if miss
|
||||
* - Success = tension bar fills to 100% and stays
|
||||
*
|
||||
* Visual layout:
|
||||
* ┌─────────────────────────────────────────┐
|
||||
* │ LOCKPICKING │
|
||||
* │ │
|
||||
* │ Position: │
|
||||
* │ ┌─────────────────────────────────┐ │
|
||||
* │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ ← Uniform gray (sweet spot hidden)
|
||||
* │ └─────────────────────────────────┘ │
|
||||
* │ ▲ │
|
||||
* │ │
|
||||
* │ Tension: │
|
||||
* │ ┌─────────────────────────────────┐ │
|
||||
* │ │████████████░░░░░░░░░░░░░░░░░░░░░│ │ ← Animates on test
|
||||
* │ └─────────────────────────────────┘ │
|
||||
* │ │
|
||||
* │ [A/D] Move | [SPACE] Test | ◉◉◉◎◎ │
|
||||
* └─────────────────────────────────────────┘
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class LockpickMiniGameScreen extends BaseScreen {
|
||||
|
||||
private UUID sessionId;
|
||||
private float sweetSpotCenter;
|
||||
private float sweetSpotWidth;
|
||||
private float currentPosition;
|
||||
private int remainingUses;
|
||||
private int maxUses;
|
||||
|
||||
// Visual state
|
||||
private boolean gameComplete;
|
||||
private boolean gameSuccess;
|
||||
private int completionDelayTicks;
|
||||
|
||||
// Animation
|
||||
private float animatedPosition;
|
||||
|
||||
// Movement
|
||||
private static final float MOVE_SPEED = 0.015f;
|
||||
private boolean movingLeft;
|
||||
private boolean movingRight;
|
||||
|
||||
// Sync throttle
|
||||
private long lastSyncTime;
|
||||
private static final long SYNC_INTERVAL_MS = 50; // 20 updates per second max
|
||||
|
||||
// ==================== TENSION BAR ANIMATION ====================
|
||||
|
||||
/** Whether a tension animation is currently playing */
|
||||
private boolean isTesting = false;
|
||||
|
||||
/** Target fill level for tension bar (0.0-1.0) */
|
||||
private float tensionFillTarget = 0f;
|
||||
|
||||
/** Current fill level of tension bar (0.0-1.0) */
|
||||
private float tensionCurrent = 0f;
|
||||
|
||||
/** Is the tension bar rising or falling (bouncing) */
|
||||
private boolean tensionRising = true;
|
||||
|
||||
/** Speed of tension bar fill */
|
||||
private static final float FILL_SPEED = 0.025f;
|
||||
|
||||
/** Speed of tension bar bounce-back (slower than fill) */
|
||||
private static final float BOUNCE_SPEED = 0.015f;
|
||||
|
||||
/** Color for tension bar based on fill target */
|
||||
private int tensionColor = 0xFFAA0000; // Red by default
|
||||
|
||||
public LockpickMiniGameScreen(
|
||||
UUID sessionId,
|
||||
float sweetSpotCenter,
|
||||
float sweetSpotWidth,
|
||||
float currentPosition,
|
||||
int remainingUses
|
||||
) {
|
||||
super(Component.translatable("gui.tiedup.lockpick_minigame"));
|
||||
this.sessionId = sessionId;
|
||||
this.sweetSpotCenter = sweetSpotCenter;
|
||||
this.sweetSpotWidth = sweetSpotWidth;
|
||||
this.currentPosition = currentPosition;
|
||||
this.animatedPosition = currentPosition;
|
||||
this.remainingUses = remainingUses;
|
||||
this.maxUses = remainingUses;
|
||||
|
||||
this.gameComplete = false;
|
||||
this.gameSuccess = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredWidth() {
|
||||
return GuiLayoutConstants.getResponsiveWidth(
|
||||
this.width,
|
||||
0.5f,
|
||||
300,
|
||||
380
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredHeight() {
|
||||
return GuiLayoutConstants.getResponsiveHeight(
|
||||
this.height,
|
||||
0.45f,
|
||||
200,
|
||||
260
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
super.tick();
|
||||
|
||||
// Handle continuous movement
|
||||
if (!gameComplete && !isTesting) {
|
||||
if (movingLeft && !movingRight) {
|
||||
movePosition(-MOVE_SPEED);
|
||||
} else if (movingRight && !movingLeft) {
|
||||
movePosition(MOVE_SPEED);
|
||||
}
|
||||
}
|
||||
|
||||
// Animate position indicator
|
||||
animatedPosition = lerp(animatedPosition, currentPosition, 0.3f);
|
||||
|
||||
// Animate tension bar
|
||||
if (isTesting) {
|
||||
if (tensionRising) {
|
||||
tensionCurrent += FILL_SPEED;
|
||||
if (tensionCurrent >= tensionFillTarget) {
|
||||
tensionCurrent = tensionFillTarget;
|
||||
if (tensionFillTarget < 1.0f) {
|
||||
// Not success - start bouncing back
|
||||
tensionRising = false;
|
||||
}
|
||||
// If 100%, keep it (success animation handled separately)
|
||||
}
|
||||
} else {
|
||||
// Bouncing back
|
||||
tensionCurrent -= BOUNCE_SPEED;
|
||||
if (tensionCurrent <= 0) {
|
||||
tensionCurrent = 0;
|
||||
isTesting = false; // Animation complete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Completion delay
|
||||
if (gameComplete && completionDelayTicks > 0) {
|
||||
completionDelayTicks--;
|
||||
if (completionDelayTicks <= 0) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void movePosition(float delta) {
|
||||
float newPos = Math.max(0.0f, Math.min(1.0f, currentPosition + delta));
|
||||
if (newPos != currentPosition) {
|
||||
currentPosition = newPos;
|
||||
syncPositionToServer();
|
||||
}
|
||||
}
|
||||
|
||||
private void syncPositionToServer() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastSyncTime >= SYNC_INTERVAL_MS) {
|
||||
ModNetwork.sendToServer(
|
||||
new PacketLockpickMiniGameMove(sessionId, currentPosition)
|
||||
);
|
||||
lastSyncTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the lock was successfully picked.
|
||||
* Triggers success animation (tension bar fills to 100% and stays).
|
||||
*/
|
||||
public void onSuccess() {
|
||||
this.gameComplete = true;
|
||||
this.gameSuccess = true;
|
||||
this.completionDelayTicks = 60; // Wait for animation
|
||||
|
||||
// Success animation: fill to 100%
|
||||
triggerSuccessAnimation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the player missed the sweet spot.
|
||||
* Triggers tension bar animation based on distance.
|
||||
*/
|
||||
public void onMissed(int newRemainingUses, float distance) {
|
||||
this.remainingUses = newRemainingUses;
|
||||
|
||||
// Trigger tension animation based on distance
|
||||
triggerTensionAnimation(distance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the player ran out of lockpicks.
|
||||
*/
|
||||
public void onOutOfPicks() {
|
||||
this.gameComplete = true;
|
||||
this.gameSuccess = false;
|
||||
this.remainingUses = 0;
|
||||
this.completionDelayTicks = 40;
|
||||
|
||||
// Final failed animation
|
||||
tensionFillTarget = 0.1f;
|
||||
tensionCurrent = 0f;
|
||||
tensionRising = true;
|
||||
tensionColor = 0xFFAA0000; // Red
|
||||
isTesting = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the session was cancelled.
|
||||
*/
|
||||
public void onCancelled() {
|
||||
this.onClose();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger tension bar animation for a MISS based on distance.
|
||||
* Closer = fills more (but still bounces back).
|
||||
*/
|
||||
private void triggerTensionAnimation(float distance) {
|
||||
isTesting = true;
|
||||
tensionRising = true;
|
||||
tensionCurrent = 0f;
|
||||
|
||||
// Calculate fill target based on distance (sweet spot = 3% = 0.015 radius)
|
||||
if (distance < 0.05f) {
|
||||
tensionFillTarget = 0.85f; // Very close - fills a lot
|
||||
tensionColor = 0xFF00AA00; // Green
|
||||
} else if (distance < 0.10f) {
|
||||
tensionFillTarget = 0.60f; // Close
|
||||
tensionColor = 0xFFAAAA00; // Yellow
|
||||
} else if (distance < 0.20f) {
|
||||
tensionFillTarget = 0.35f; // Medium
|
||||
tensionColor = 0xFFFF8800; // Orange
|
||||
} else {
|
||||
tensionFillTarget = 0.15f; // Far - fills little
|
||||
tensionColor = 0xFFAA0000; // Red
|
||||
}
|
||||
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"[LockpickMiniGameScreen] Tension animation: distance={}, fillTarget={}",
|
||||
distance,
|
||||
tensionFillTarget
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger success animation - fills to 100% and stays.
|
||||
*/
|
||||
private void triggerSuccessAnimation() {
|
||||
isTesting = true;
|
||||
tensionRising = true;
|
||||
tensionCurrent = 0f;
|
||||
tensionFillTarget = 1.0f; // Full!
|
||||
tensionColor = 0xFF00FF00; // Bright green
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
|
||||
int centerX = this.width / 2;
|
||||
int y = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 15;
|
||||
|
||||
// Position bar (uniform - sweet spot hidden)
|
||||
Component posLabel = Component.translatable(
|
||||
"gui.tiedup.lockpick_minigame.label.position"
|
||||
).withStyle(ChatFormatting.GRAY);
|
||||
graphics.drawString(this.font, posLabel, centerX - 120, y, 0xFFFFFFFF);
|
||||
y += 12;
|
||||
renderPositionBar(graphics, y);
|
||||
y += 55;
|
||||
|
||||
// Tension bar
|
||||
Component tensionLabel = Component.translatable(
|
||||
"gui.tiedup.lockpick_minigame.label.tension"
|
||||
).withStyle(ChatFormatting.GRAY);
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
tensionLabel,
|
||||
centerX - 120,
|
||||
y,
|
||||
0xFFFFFFFF
|
||||
);
|
||||
y += 12;
|
||||
renderTensionBar(graphics, y);
|
||||
y += 35;
|
||||
|
||||
// Uses indicator
|
||||
renderUsesIndicator(graphics, y);
|
||||
y += 25;
|
||||
|
||||
// Instructions or result
|
||||
if (gameComplete) {
|
||||
Component result = gameSuccess
|
||||
? Component.translatable(
|
||||
"gui.tiedup.lockpick_minigame.status.unlocked"
|
||||
).withStyle(ChatFormatting.GREEN, ChatFormatting.BOLD)
|
||||
: Component.translatable(
|
||||
"gui.tiedup.lockpick_minigame.status.out_of_picks"
|
||||
).withStyle(ChatFormatting.RED, ChatFormatting.BOLD);
|
||||
int resultWidth = this.font.width(result);
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
result,
|
||||
centerX - resultWidth / 2,
|
||||
y,
|
||||
0xFFFFFFFF
|
||||
);
|
||||
} else {
|
||||
Component hint = Component.translatable(
|
||||
"gui.tiedup.lockpick_minigame.hint"
|
||||
).withStyle(ChatFormatting.GRAY);
|
||||
int hintWidth = this.font.width(hint);
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
hint,
|
||||
centerX - hintWidth / 2,
|
||||
y,
|
||||
0xFFFFFFFF
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the position bar (uniform gray - sweet spot hidden).
|
||||
*/
|
||||
private void renderPositionBar(GuiGraphics graphics, int y) {
|
||||
int barWidth = 240;
|
||||
int barHeight = 30;
|
||||
int barX = (this.width - barWidth) / 2;
|
||||
|
||||
// Background - uniform dark gray (sweet spot is INVISIBLE)
|
||||
graphics.fill(barX, y, barX + barWidth, y + barHeight, 0xFF333333);
|
||||
|
||||
// Border
|
||||
graphics.renderOutline(barX, y, barWidth, barHeight, 0xFF666666);
|
||||
|
||||
// Lockpick indicator (position marker)
|
||||
int pickX = barX + (int) (animatedPosition * barWidth);
|
||||
int pickColor = 0xFFFFFF00; // Yellow
|
||||
|
||||
// Draw lockpick as a triangle pointing down
|
||||
int pickWidth = 10;
|
||||
int pickHeight = 14;
|
||||
graphics.fill(
|
||||
pickX - pickWidth / 2,
|
||||
y - pickHeight,
|
||||
pickX + pickWidth / 2,
|
||||
y - 2,
|
||||
pickColor
|
||||
);
|
||||
graphics.fill(pickX - 1, y - 2, pickX + 1, y + 4, pickColor);
|
||||
|
||||
// Draw pick position line
|
||||
graphics.fill(pickX - 1, y, pickX + 1, y + barHeight, 0xAAFFFFFF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the tension bar (animates during test).
|
||||
*/
|
||||
private void renderTensionBar(GuiGraphics graphics, int y) {
|
||||
int barWidth = 240;
|
||||
int barHeight = 20;
|
||||
int barX = (this.width - barWidth) / 2;
|
||||
|
||||
// Background
|
||||
graphics.fill(barX, y, barX + barWidth, y + barHeight, 0xFF222222);
|
||||
|
||||
// Fill based on current tension
|
||||
if (tensionCurrent > 0) {
|
||||
int fillWidth = (int) (barWidth * tensionCurrent);
|
||||
graphics.fill(
|
||||
barX,
|
||||
y,
|
||||
barX + fillWidth,
|
||||
y + barHeight,
|
||||
tensionColor
|
||||
);
|
||||
}
|
||||
|
||||
// Border
|
||||
graphics.renderOutline(barX, y, barWidth, barHeight, 0xFF555555);
|
||||
|
||||
// Target line (100% marker)
|
||||
int targetX = barX + barWidth - 2;
|
||||
graphics.fill(targetX, y, targetX + 2, y + barHeight, 0xFFFFFFFF);
|
||||
}
|
||||
|
||||
private void renderUsesIndicator(GuiGraphics graphics, int y) {
|
||||
int centerX = this.width / 2;
|
||||
|
||||
// Draw use pips centered
|
||||
int totalPipWidth = maxUses * 14;
|
||||
int pipX = centerX - totalPipWidth / 2;
|
||||
|
||||
for (int i = 0; i < maxUses; i++) {
|
||||
int pipColor = i < remainingUses ? 0xFF00AA00 : 0xFF333333;
|
||||
graphics.fill(
|
||||
pipX + i * 14,
|
||||
y,
|
||||
pipX + i * 14 + 10,
|
||||
y + 10,
|
||||
pipColor
|
||||
);
|
||||
graphics.renderOutline(pipX + i * 14, y, 10, 10, 0xFF666666);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||
// ESC to cancel
|
||||
if (keyCode == GLFW.GLFW_KEY_ESCAPE) {
|
||||
this.onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (gameComplete || isTesting) {
|
||||
return super.keyPressed(keyCode, scanCode, modifiers);
|
||||
}
|
||||
|
||||
// Movement keys
|
||||
if (keyCode == GLFW.GLFW_KEY_A || keyCode == GLFW.GLFW_KEY_LEFT) {
|
||||
movingLeft = true;
|
||||
return true;
|
||||
}
|
||||
if (keyCode == GLFW.GLFW_KEY_D || keyCode == GLFW.GLFW_KEY_RIGHT) {
|
||||
movingRight = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Test key
|
||||
if (keyCode == GLFW.GLFW_KEY_SPACE) {
|
||||
// Final sync before test
|
||||
ModNetwork.sendToServer(
|
||||
new PacketLockpickMiniGameMove(sessionId, currentPosition)
|
||||
);
|
||||
ModNetwork.sendToServer(new PacketLockpickAttempt(sessionId));
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.keyPressed(keyCode, scanCode, modifiers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyReleased(int keyCode, int scanCode, int modifiers) {
|
||||
if (keyCode == GLFW.GLFW_KEY_A || keyCode == GLFW.GLFW_KEY_LEFT) {
|
||||
movingLeft = false;
|
||||
return true;
|
||||
}
|
||||
if (keyCode == GLFW.GLFW_KEY_D || keyCode == GLFW.GLFW_KEY_RIGHT) {
|
||||
movingRight = false;
|
||||
return true;
|
||||
}
|
||||
return super.keyReleased(keyCode, scanCode, modifiers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
}
|
||||
|
||||
private float lerp(float current, float target, float speed) {
|
||||
if (Math.abs(current - target) < 0.001f) {
|
||||
return target;
|
||||
}
|
||||
return current + (target - current) * speed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
import com.tiedup.remake.entities.MerchantTrade;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.merchant.PacketPurchaseTrade;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Trading screen for EntityKidnapperMerchant.
|
||||
* Displays available trades and allows purchasing items for gold.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class MerchantTradingScreen extends BaseScreen {
|
||||
|
||||
private final UUID merchantUUID;
|
||||
private final List<MerchantTrade> trades;
|
||||
private int selectedTradeIndex = -1;
|
||||
|
||||
private Button buyButton;
|
||||
private Button cancelButton;
|
||||
|
||||
private static final int TRADE_BUTTON_HEIGHT = 30;
|
||||
private static final int TRADE_BUTTON_SPACING = 4;
|
||||
|
||||
// Scroll state
|
||||
private int scrollOffset = 0;
|
||||
private int maxScrollOffset = 0;
|
||||
private int tradeListStartY;
|
||||
private int tradeListHeight;
|
||||
|
||||
public MerchantTradingScreen(
|
||||
UUID merchantUUID,
|
||||
List<MerchantTrade> trades
|
||||
) {
|
||||
super(Component.translatable("gui.tiedup.merchant.title"));
|
||||
this.merchantUUID = merchantUUID;
|
||||
this.trades = trades;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredWidth() {
|
||||
// 60% width, min 320px, max 500px
|
||||
return GuiLayoutConstants.getResponsiveWidth(
|
||||
this.width,
|
||||
0.6f,
|
||||
320,
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredHeight() {
|
||||
// 70% height, min 300px, max 450px
|
||||
return GuiLayoutConstants.getResponsiveHeight(
|
||||
this.height,
|
||||
0.7f,
|
||||
300,
|
||||
450
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
|
||||
// Gold count area (just below title)
|
||||
int goldDisplayY = this.topPos + 25;
|
||||
|
||||
// Trade list area (minimal spacing - only 8px gap)
|
||||
this.tradeListStartY = goldDisplayY + 18;
|
||||
this.tradeListHeight = this.imageHeight - 80; // Maximum space for trades
|
||||
|
||||
// Calculate max scroll offset
|
||||
int totalTradeListHeight =
|
||||
trades.size() * (TRADE_BUTTON_HEIGHT + TRADE_BUTTON_SPACING);
|
||||
this.maxScrollOffset = Math.max(
|
||||
0,
|
||||
totalTradeListHeight - tradeListHeight
|
||||
);
|
||||
|
||||
// Create trade buttons
|
||||
int tradeButtonWidth = this.imageWidth - 40;
|
||||
int tradeButtonX = this.leftPos + 20;
|
||||
|
||||
for (int i = 0; i < trades.size(); i++) {
|
||||
final int tradeIndex = i;
|
||||
MerchantTrade trade = trades.get(i);
|
||||
|
||||
TradeButton button = new TradeButton(
|
||||
tradeButtonX,
|
||||
0,
|
||||
tradeButtonWidth,
|
||||
TRADE_BUTTON_HEIGHT,
|
||||
trade,
|
||||
tradeIndex,
|
||||
btn -> onTradeClicked(tradeIndex)
|
||||
);
|
||||
this.addRenderableWidget(button);
|
||||
}
|
||||
|
||||
// Bottom buttons
|
||||
int buttonY =
|
||||
this.topPos +
|
||||
this.imageHeight -
|
||||
GuiLayoutConstants.BUTTON_HEIGHT -
|
||||
10;
|
||||
int buttonSpacing = 10;
|
||||
int totalButtonWidth =
|
||||
GuiLayoutConstants.BUTTON_WIDTH_L * 2 + buttonSpacing;
|
||||
int buttonStartX =
|
||||
this.leftPos + (this.imageWidth - totalButtonWidth) / 2;
|
||||
|
||||
// Buy button (disabled by default)
|
||||
buyButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.merchant.buy"),
|
||||
b -> onBuyClicked()
|
||||
)
|
||||
.bounds(
|
||||
buttonStartX,
|
||||
buttonY,
|
||||
GuiLayoutConstants.BUTTON_WIDTH_L,
|
||||
GuiLayoutConstants.BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
buyButton.active = false;
|
||||
this.addRenderableWidget(buyButton);
|
||||
|
||||
// Cancel button
|
||||
cancelButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.cancel"),
|
||||
b -> onClose()
|
||||
)
|
||||
.bounds(
|
||||
buttonStartX +
|
||||
GuiLayoutConstants.BUTTON_WIDTH_L +
|
||||
buttonSpacing,
|
||||
buttonY,
|
||||
GuiLayoutConstants.BUTTON_WIDTH_L,
|
||||
GuiLayoutConstants.BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(cancelButton);
|
||||
}
|
||||
|
||||
private void onTradeClicked(int index) {
|
||||
selectedTradeIndex = index;
|
||||
buyButton.active = true;
|
||||
}
|
||||
|
||||
private void onBuyClicked() {
|
||||
if (selectedTradeIndex >= 0 && selectedTradeIndex < trades.size()) {
|
||||
ModNetwork.sendToServer(
|
||||
new PacketPurchaseTrade(merchantUUID, selectedTradeIndex)
|
||||
);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
// Notify server that we closed the trading screen
|
||||
ModNetwork.sendToServer(
|
||||
new com.tiedup.remake.network.merchant.PacketCloseMerchantScreen(
|
||||
merchantUUID
|
||||
)
|
||||
);
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
|
||||
if (maxScrollOffset > 0) {
|
||||
// Scroll by 20 pixels per tick
|
||||
int scrollAmount = (int) (delta * 20);
|
||||
scrollOffset = Math.max(
|
||||
0,
|
||||
Math.min(maxScrollOffset, scrollOffset - scrollAmount)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return super.mouseScrolled(mouseX, mouseY, delta);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
|
||||
// Title (blue color for merchant)
|
||||
graphics.drawCenteredString(
|
||||
this.font,
|
||||
this.title,
|
||||
this.leftPos + this.imageWidth / 2,
|
||||
this.topPos + GuiLayoutConstants.MARGIN_M,
|
||||
GuiColors.INFO
|
||||
);
|
||||
|
||||
// Gold display (matches goldDisplayY from init)
|
||||
Player player = Minecraft.getInstance().player;
|
||||
if (player != null) {
|
||||
int goldIngots = countItemInInventory(player, Items.GOLD_INGOT);
|
||||
int goldNuggets = countItemInInventory(player, Items.GOLD_NUGGET);
|
||||
|
||||
Component goldText = Component.literal("Your Gold: ")
|
||||
.append(
|
||||
Component.literal(goldIngots + "x ").withStyle(style ->
|
||||
style.withColor(0xFFFFD700)
|
||||
)
|
||||
)
|
||||
.append(
|
||||
Component.literal("⚜ ").withStyle(style ->
|
||||
style.withColor(0xFFFFD700)
|
||||
)
|
||||
)
|
||||
.append(
|
||||
Component.literal("+ " + goldNuggets + "x ").withStyle(
|
||||
style -> style.withColor(0xFFFFA500)
|
||||
)
|
||||
)
|
||||
.append(
|
||||
Component.literal("✦").withStyle(style ->
|
||||
style.withColor(0xFFFFA500)
|
||||
)
|
||||
);
|
||||
|
||||
graphics.drawCenteredString(
|
||||
this.font,
|
||||
goldText,
|
||||
this.leftPos + this.imageWidth / 2,
|
||||
this.topPos + 25,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
}
|
||||
|
||||
// Update trade button positions based on scroll
|
||||
updateTradeButtonPositions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update trade button positions based on scroll offset.
|
||||
*/
|
||||
private void updateTradeButtonPositions() {
|
||||
int tradeButtonWidth = this.imageWidth - 40;
|
||||
int tradeButtonX = this.leftPos + 20;
|
||||
|
||||
// Get all renderable widgets (includes our trade buttons)
|
||||
for (int i = 0; i < this.renderables.size(); i++) {
|
||||
if (this.renderables.get(i) instanceof TradeButton tradeButton) {
|
||||
int tradeIndex = tradeButton.getTradeIndex();
|
||||
|
||||
// Calculate Y position with scroll offset
|
||||
// tradeListStartY already includes topPos, don't add it twice!
|
||||
int y =
|
||||
this.tradeListStartY +
|
||||
tradeIndex * (TRADE_BUTTON_HEIGHT + TRADE_BUTTON_SPACING) -
|
||||
scrollOffset;
|
||||
|
||||
// Update button position
|
||||
tradeButton.setPosition(tradeButtonX, y);
|
||||
|
||||
// Check if button is visible in the scroll area
|
||||
boolean isVisible =
|
||||
y >= this.tradeListStartY &&
|
||||
y + TRADE_BUTTON_HEIGHT <=
|
||||
this.tradeListStartY + this.tradeListHeight;
|
||||
tradeButton.visible = isVisible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many of a specific item are in the player's inventory.
|
||||
*/
|
||||
private int countItemInInventory(
|
||||
Player player,
|
||||
net.minecraft.world.item.Item item
|
||||
) {
|
||||
int count = 0;
|
||||
for (net.minecraft.world.item.ItemStack stack : player.getInventory().items) {
|
||||
if (stack.is(item)) {
|
||||
count += stack.getCount();
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom button for displaying a trade.
|
||||
*/
|
||||
private class TradeButton extends Button {
|
||||
|
||||
private final MerchantTrade trade;
|
||||
private final int tradeIndex;
|
||||
|
||||
public TradeButton(
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
int height,
|
||||
MerchantTrade trade,
|
||||
int tradeIndex,
|
||||
OnPress onPress
|
||||
) {
|
||||
super(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
Component.empty(),
|
||||
onPress,
|
||||
DEFAULT_NARRATION
|
||||
);
|
||||
this.trade = trade;
|
||||
this.tradeIndex = tradeIndex;
|
||||
}
|
||||
|
||||
public int getTradeIndex() {
|
||||
return this.tradeIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderWidget(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
boolean selected = (tradeIndex == selectedTradeIndex);
|
||||
boolean hovered = this.isHovered();
|
||||
|
||||
// Background color
|
||||
int bgColor;
|
||||
if (selected) {
|
||||
bgColor = GuiColors.SLOT_SELECTED; // Brown for selected
|
||||
} else if (hovered) {
|
||||
bgColor = GuiColors.SLOT_HOVER;
|
||||
} else {
|
||||
bgColor = GuiColors.BG_LIGHT;
|
||||
}
|
||||
|
||||
graphics.fill(
|
||||
getX(),
|
||||
getY(),
|
||||
getX() + width,
|
||||
getY() + height,
|
||||
bgColor
|
||||
);
|
||||
|
||||
// Border
|
||||
int borderColor = selected
|
||||
? GuiColors.ACCENT_TAN
|
||||
: GuiColors.BORDER_LIGHT;
|
||||
graphics.fill(
|
||||
getX(),
|
||||
getY(),
|
||||
getX() + width,
|
||||
getY() + 1,
|
||||
borderColor
|
||||
);
|
||||
graphics.fill(
|
||||
getX(),
|
||||
getY() + height - 1,
|
||||
getX() + width,
|
||||
getY() + height,
|
||||
borderColor
|
||||
);
|
||||
graphics.fill(
|
||||
getX(),
|
||||
getY(),
|
||||
getX() + 1,
|
||||
getY() + height,
|
||||
borderColor
|
||||
);
|
||||
graphics.fill(
|
||||
getX() + width - 1,
|
||||
getY(),
|
||||
getX() + width,
|
||||
getY() + height,
|
||||
borderColor
|
||||
);
|
||||
|
||||
// Item preview (left side)
|
||||
net.minecraft.world.item.ItemStack itemStack = trade.getItem();
|
||||
graphics.renderItem(itemStack, getX() + 5, getY() + 7);
|
||||
|
||||
// Item name (after item preview)
|
||||
Component itemName = trade.getItemName();
|
||||
String itemText = font.plainSubstrByWidth(
|
||||
itemName.getString(),
|
||||
width - 140
|
||||
);
|
||||
graphics.drawString(
|
||||
font,
|
||||
itemText,
|
||||
getX() + 28,
|
||||
getY() + 5,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
|
||||
// Price (after item name, below item preview)
|
||||
Component priceText = trade.getPriceDisplay();
|
||||
graphics.drawString(
|
||||
font,
|
||||
priceText,
|
||||
getX() + 28,
|
||||
getY() + 17,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiTextureHelper;
|
||||
import com.tiedup.remake.client.gui.widgets.EntityPreviewWidget;
|
||||
import com.tiedup.remake.entities.EntityDamsel;
|
||||
import com.tiedup.remake.entities.NpcInventoryMenu;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Screen for viewing and managing NPC inventory.
|
||||
* Uses vanilla chest texture for consistent Minecraft look.
|
||||
*
|
||||
* Layout:
|
||||
* - Left: Vanilla chest-style container (176px wide)
|
||||
* - Right: Equipment panel (armor + main hand, 26px wide)
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class NpcInventoryScreen
|
||||
extends AbstractContainerScreen<NpcInventoryMenu>
|
||||
{
|
||||
|
||||
/** Equipment panel width (slot 18px + 4px padding each side) */
|
||||
private static final int EQUIP_PANEL_WIDTH = 28;
|
||||
|
||||
/** Gap between main container and equipment panel */
|
||||
private static final int EQUIP_PANEL_GAP = 4;
|
||||
|
||||
/** Equipment panel height (5 slots + header + padding) */
|
||||
private static final int EQUIP_PANEL_HEIGHT = 5 * 18 + 18; // 108px
|
||||
|
||||
/** Entity preview widget */
|
||||
@Nullable
|
||||
private EntityPreviewWidget preview;
|
||||
|
||||
/** Number of NPC inventory rows */
|
||||
private final int npcRows;
|
||||
|
||||
/**
|
||||
* Create NPC inventory screen.
|
||||
*
|
||||
* @param menu The container menu
|
||||
* @param playerInventory Player's inventory
|
||||
* @param title Screen title (will be replaced with NPC name)
|
||||
*/
|
||||
public NpcInventoryScreen(
|
||||
NpcInventoryMenu menu,
|
||||
Inventory playerInventory,
|
||||
Component title
|
||||
) {
|
||||
super(menu, playerInventory, Component.literal(menu.getNpcName()));
|
||||
// Calculate rows from NPC inventory size
|
||||
this.npcRows = (menu.getNpcSlotCount() + 8) / 9;
|
||||
|
||||
// Standard vanilla chest dimensions
|
||||
this.imageWidth = GuiTextureHelper.CHEST_WIDTH;
|
||||
this.imageHeight = GuiTextureHelper.getChestHeight(npcRows);
|
||||
|
||||
// Player inventory label position (relative to container)
|
||||
// In vanilla chest, player inv label is at y = header + rows*18 + 3
|
||||
this.inventoryLabelY =
|
||||
GuiTextureHelper.CHEST_HEADER_HEIGHT +
|
||||
npcRows * GuiTextureHelper.SLOT_SIZE +
|
||||
3;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
|
||||
// Add entity preview if NPC available and there's room
|
||||
EntityDamsel npc = this.menu.getNpc();
|
||||
if (npc != null) {
|
||||
int previewSize = 50;
|
||||
int previewX = this.leftPos - previewSize - 10;
|
||||
int previewY = this.topPos + 10;
|
||||
|
||||
if (previewX > 0) {
|
||||
preview = new EntityPreviewWidget(
|
||||
previewX,
|
||||
previewY,
|
||||
previewSize,
|
||||
previewSize,
|
||||
npc
|
||||
);
|
||||
preview.setAutoRotate(true);
|
||||
preview.setAutoRotateSpeed(0.5f);
|
||||
this.addRenderableWidget(preview);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
this.renderBackground(graphics);
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
this.renderTooltip(graphics, mouseX, mouseY);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderBg(
|
||||
GuiGraphics graphics,
|
||||
float partialTick,
|
||||
int mouseX,
|
||||
int mouseY
|
||||
) {
|
||||
// Render main container with vanilla chest texture
|
||||
GuiTextureHelper.renderChestBackground(
|
||||
graphics,
|
||||
this.leftPos,
|
||||
this.topPos,
|
||||
this.npcRows
|
||||
);
|
||||
|
||||
// Render equipment panel on the right
|
||||
renderEquipmentPanel(graphics);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the equipment panel (armor + main hand) on the right side.
|
||||
*/
|
||||
private void renderEquipmentPanel(GuiGraphics graphics) {
|
||||
int panelX =
|
||||
this.leftPos + GuiTextureHelper.CHEST_WIDTH + EQUIP_PANEL_GAP;
|
||||
int panelY = this.topPos + GuiTextureHelper.CHEST_HEADER_HEIGHT;
|
||||
|
||||
// Panel background (vanilla inventory style)
|
||||
graphics.fill(
|
||||
panelX - 1,
|
||||
panelY - 1,
|
||||
panelX + EQUIP_PANEL_WIDTH + 1,
|
||||
panelY + EQUIP_PANEL_HEIGHT + 1,
|
||||
0xFF000000
|
||||
);
|
||||
graphics.fill(
|
||||
panelX,
|
||||
panelY,
|
||||
panelX + EQUIP_PANEL_WIDTH,
|
||||
panelY + EQUIP_PANEL_HEIGHT,
|
||||
0xFFC6C6C6
|
||||
);
|
||||
|
||||
// Title centered
|
||||
String title = "Gear";
|
||||
int titleWidth = this.font.width(title);
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
title,
|
||||
panelX + (EQUIP_PANEL_WIDTH - titleWidth) / 2,
|
||||
panelY + 3,
|
||||
0x404040,
|
||||
false
|
||||
);
|
||||
|
||||
// Slot backgrounds (4 armor + 1 main hand)
|
||||
// Position slots to match menu: slotX = panelX + 5, first slot at y = panelY + 12
|
||||
int slotX = panelX + 5;
|
||||
int slotStartY = panelY + 12;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
renderSlot(
|
||||
graphics,
|
||||
slotX,
|
||||
slotStartY + i * GuiTextureHelper.SLOT_SIZE
|
||||
);
|
||||
}
|
||||
|
||||
// Main hand slot (with small gap after armor)
|
||||
int handY = slotStartY + 4 * GuiTextureHelper.SLOT_SIZE + 4;
|
||||
renderSlot(graphics, slotX, handY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single slot background in vanilla style.
|
||||
*/
|
||||
private void renderSlot(GuiGraphics graphics, int x, int y) {
|
||||
// Vanilla slot style: dark top-left border, light bottom-right
|
||||
graphics.fill(x, y, x + 18, y + 18, 0xFF8B8B8B); // Base gray
|
||||
graphics.fill(x, y, x + 17, y + 1, 0xFF373737); // Top dark
|
||||
graphics.fill(x, y, x + 1, y + 17, 0xFF373737); // Left dark
|
||||
graphics.fill(x + 1, y + 17, x + 18, y + 18, 0xFFFFFFFF); // Bottom light
|
||||
graphics.fill(x + 17, y + 1, x + 18, y + 18, 0xFFFFFFFF); // Right light
|
||||
graphics.fill(x + 1, y + 1, x + 17, y + 17, 0xFF8B8B8B); // Inner
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
|
||||
// NPC name in title area
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
this.title,
|
||||
this.titleLabelX,
|
||||
this.titleLabelY,
|
||||
0x404040,
|
||||
false
|
||||
);
|
||||
|
||||
// Player inventory label
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
this.playerInventoryTitle,
|
||||
this.inventoryLabelX,
|
||||
this.inventoryLabelY,
|
||||
0x404040,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.dialogue.conversation.PetRequest;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.master.PacketPetRequest;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.Tooltip;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Screen for pet players to make requests to their Master.
|
||||
*
|
||||
* Displays 7 request options:
|
||||
* - Ask for food
|
||||
* - Ask to rest
|
||||
* - Request walk (you lead)
|
||||
* - Request walk (Master leads)
|
||||
* - Ask to be tied
|
||||
* - Ask to be untied
|
||||
* - End conversation
|
||||
*
|
||||
* Phase 2: Refactored to extend BaseInteractionScreen
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class PetRequestScreen extends BaseInteractionScreen {
|
||||
|
||||
private final int entityId;
|
||||
private final String masterName;
|
||||
|
||||
// Layout constants
|
||||
private static final int PANEL_WIDTH = 200;
|
||||
private static final int PANEL_HEIGHT = 220;
|
||||
private static final int MARGIN = 10;
|
||||
private static final int BUTTON_HEIGHT = 20;
|
||||
private static final int BUTTON_SPACING = 4;
|
||||
|
||||
// Color constants
|
||||
private static final int COLOR_WHITE = 0xFFFFFF;
|
||||
private static final int COLOR_TITLE = 0x8B008B; // Dark purple (Master color)
|
||||
|
||||
public PetRequestScreen(int entityId, String masterName) {
|
||||
super(
|
||||
Component.translatable("gui.tiedup.pet_request.title", masterName),
|
||||
PANEL_WIDTH,
|
||||
PANEL_HEIGHT
|
||||
);
|
||||
this.entityId = entityId;
|
||||
this.masterName = masterName;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init(); // Center the panel (sets leftPos and topPos)
|
||||
|
||||
int contentX = getContentX(MARGIN);
|
||||
int contentWidth = getContentWidth(MARGIN);
|
||||
int btnY = topPos + 35;
|
||||
|
||||
// === Request Buttons ===
|
||||
|
||||
// Row 1: Ask for food
|
||||
addRequestButton(
|
||||
contentX,
|
||||
btnY,
|
||||
contentWidth,
|
||||
PetRequest.REQUEST_FOOD,
|
||||
"gui.tiedup.pet_request.food",
|
||||
"gui.tiedup.pet_request.food.tooltip"
|
||||
);
|
||||
btnY += BUTTON_HEIGHT + BUTTON_SPACING;
|
||||
|
||||
// Row 2: Ask to rest
|
||||
addRequestButton(
|
||||
contentX,
|
||||
btnY,
|
||||
contentWidth,
|
||||
PetRequest.REQUEST_SLEEP,
|
||||
"gui.tiedup.pet_request.sleep",
|
||||
"gui.tiedup.pet_request.sleep.tooltip"
|
||||
);
|
||||
btnY += BUTTON_HEIGHT + BUTTON_SPACING;
|
||||
|
||||
// Row 3: Walk options (side by side)
|
||||
int halfWidth = (contentWidth - BUTTON_SPACING) / 2;
|
||||
|
||||
// Walk (you lead)
|
||||
Button walkPassiveBtn = Button.builder(
|
||||
Component.translatable("gui.tiedup.pet_request.walk_passive"),
|
||||
b -> sendRequest(PetRequest.REQUEST_WALK_PASSIVE)
|
||||
)
|
||||
.bounds(contentX, btnY, halfWidth, BUTTON_HEIGHT)
|
||||
.tooltip(
|
||||
Tooltip.create(
|
||||
Component.translatable(
|
||||
"gui.tiedup.pet_request.walk_passive.tooltip"
|
||||
)
|
||||
)
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(walkPassiveBtn);
|
||||
|
||||
// Walk (Master leads)
|
||||
Button walkActiveBtn = Button.builder(
|
||||
Component.translatable("gui.tiedup.pet_request.walk_active"),
|
||||
b -> sendRequest(PetRequest.REQUEST_WALK_ACTIVE)
|
||||
)
|
||||
.bounds(
|
||||
contentX + halfWidth + BUTTON_SPACING,
|
||||
btnY,
|
||||
halfWidth,
|
||||
BUTTON_HEIGHT
|
||||
)
|
||||
.tooltip(
|
||||
Tooltip.create(
|
||||
Component.translatable(
|
||||
"gui.tiedup.pet_request.walk_active.tooltip"
|
||||
)
|
||||
)
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(walkActiveBtn);
|
||||
btnY += BUTTON_HEIGHT + BUTTON_SPACING;
|
||||
|
||||
// Row 4: Tie/Untie options (side by side)
|
||||
Button tieBtn = Button.builder(
|
||||
Component.translatable("gui.tiedup.pet_request.tie"),
|
||||
b -> sendRequest(PetRequest.REQUEST_TIE)
|
||||
)
|
||||
.bounds(contentX, btnY, halfWidth, BUTTON_HEIGHT)
|
||||
.tooltip(
|
||||
Tooltip.create(
|
||||
Component.translatable("gui.tiedup.pet_request.tie.tooltip")
|
||||
)
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(tieBtn);
|
||||
|
||||
Button untieBtn = Button.builder(
|
||||
Component.translatable("gui.tiedup.pet_request.untie"),
|
||||
b -> sendRequest(PetRequest.REQUEST_UNTIE)
|
||||
)
|
||||
.bounds(
|
||||
contentX + halfWidth + BUTTON_SPACING,
|
||||
btnY,
|
||||
halfWidth,
|
||||
BUTTON_HEIGHT
|
||||
)
|
||||
.tooltip(
|
||||
Tooltip.create(
|
||||
Component.translatable(
|
||||
"gui.tiedup.pet_request.untie.tooltip"
|
||||
)
|
||||
)
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(untieBtn);
|
||||
btnY += BUTTON_HEIGHT + BUTTON_SPACING + 10;
|
||||
|
||||
// Row 5: End conversation
|
||||
addRequestButton(
|
||||
contentX,
|
||||
btnY,
|
||||
contentWidth,
|
||||
PetRequest.END_CONVERSATION,
|
||||
"gui.tiedup.pet_request.end",
|
||||
"gui.tiedup.pet_request.end.tooltip"
|
||||
);
|
||||
btnY += BUTTON_HEIGHT + BUTTON_SPACING + 10;
|
||||
|
||||
// Row 6: Cancel button
|
||||
Button cancelBtn = Button.builder(
|
||||
Component.translatable("gui.tiedup.pet_request.cancel"),
|
||||
b -> onClose()
|
||||
)
|
||||
.bounds(contentX, btnY, contentWidth, BUTTON_HEIGHT)
|
||||
.build();
|
||||
this.addRenderableWidget(cancelBtn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a request button to the screen.
|
||||
*/
|
||||
private void addRequestButton(
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
PetRequest request,
|
||||
String translationKey,
|
||||
String tooltipKey
|
||||
) {
|
||||
Button btn = Button.builder(Component.translatable(translationKey), b ->
|
||||
sendRequest(request)
|
||||
)
|
||||
.bounds(x, y, width, BUTTON_HEIGHT)
|
||||
.tooltip(Tooltip.create(Component.translatable(tooltipKey)))
|
||||
.build();
|
||||
this.addRenderableWidget(btn);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderContent(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
// Title
|
||||
renderTitle(graphics, this.title, topPos + 10, COLOR_TITLE);
|
||||
|
||||
// Subtitle
|
||||
graphics.drawCenteredString(
|
||||
this.font,
|
||||
Component.translatable("gui.tiedup.pet_request.subtitle"),
|
||||
leftPos + panelWidth / 2,
|
||||
topPos + 22,
|
||||
COLOR_WHITE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the server.
|
||||
*/
|
||||
private void sendRequest(PetRequest request) {
|
||||
ModNetwork.sendToServer(new PacketPetRequest(entityId, request));
|
||||
|
||||
// Close screen after sending request
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.item.PacketAdjustRemote;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Screen for remotely adjusting Y position of a slave's gags and blindfolds.
|
||||
* Similar to AdjustmentScreen but operates on a slave entity.
|
||||
*
|
||||
* Phase 16b: GUI Refactoring - Simplified using BaseAdjustmentScreen
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class RemoteAdjustmentScreen extends BaseAdjustmentScreen {
|
||||
|
||||
// Target slave
|
||||
private final IBondageState slave;
|
||||
private final UUID slaveId;
|
||||
|
||||
public RemoteAdjustmentScreen(IBondageState slave, UUID slaveId) {
|
||||
super(Component.translatable("gui.tiedup.adjust_position"));
|
||||
this.slave = slave;
|
||||
this.slaveId = slaveId;
|
||||
}
|
||||
|
||||
// ==================== ABSTRACT IMPLEMENTATIONS ====================
|
||||
|
||||
@Override
|
||||
protected LivingEntity getTargetEntity() {
|
||||
return slave.asLivingEntity();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemStack getGag() {
|
||||
return slave.getEquipment(BodyRegionV2.MOUTH);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemStack getBlindfold() {
|
||||
return slave.getEquipment(BodyRegionV2.EYES);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void sendAdjustment(Mode mode, float value, float scale) {
|
||||
BodyRegionV2 region = switch (mode) {
|
||||
case GAG -> BodyRegionV2.MOUTH;
|
||||
case BLINDFOLD -> BodyRegionV2.EYES;
|
||||
case BOTH -> null; // Handled separately in applyAdjustment
|
||||
};
|
||||
|
||||
if (region != null) {
|
||||
ModNetwork.sendToServer(
|
||||
new PacketAdjustRemote(slaveId, region, value, scale)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getExtraInfo() {
|
||||
return "Adjusting: " + slave.getKidnappedName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose() {
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
import com.tiedup.remake.client.gui.widgets.SlaveEntryWidget;
|
||||
import com.tiedup.remake.items.base.ItemCollar;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.slave.PacketSlaveAction;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.state.PlayerCaptorManager;
|
||||
import com.tiedup.remake.util.KidnappedHelper;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.ContainerObjectSelectionList;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Dashboard screen for masters to manage all their slaves.
|
||||
* Refactored to use standard ContainerObjectSelectionList.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class SlaveManagementScreen extends BaseScreen {
|
||||
|
||||
private SlaveList slaveList;
|
||||
private Button closeButton;
|
||||
|
||||
public SlaveManagementScreen() {
|
||||
super(Component.translatable("gui.tiedup.slave_management"));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredWidth() {
|
||||
return GuiLayoutConstants.getResponsiveWidth(
|
||||
this.width,
|
||||
0.7f,
|
||||
350,
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredHeight() {
|
||||
return GuiLayoutConstants.getResponsiveHeight(
|
||||
this.height,
|
||||
0.8f,
|
||||
250,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
|
||||
int listLeft = this.leftPos + 10;
|
||||
int listWidth = this.imageWidth - 20;
|
||||
int listTop = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 20; // Title + count
|
||||
int listBottom =
|
||||
this.topPos +
|
||||
this.imageHeight -
|
||||
GuiLayoutConstants.BUTTON_HEIGHT -
|
||||
10;
|
||||
|
||||
// Initialize the list with proper bounds
|
||||
slaveList = new SlaveList(
|
||||
minecraft,
|
||||
listWidth,
|
||||
listTop,
|
||||
listBottom,
|
||||
listLeft
|
||||
);
|
||||
|
||||
refreshSlaveList();
|
||||
this.addRenderableWidget(slaveList);
|
||||
|
||||
// Close button
|
||||
int btnWidth = GuiLayoutConstants.BUTTON_WIDTH_XL;
|
||||
closeButton = Button.builder(
|
||||
Component.translatable("gui.tiedup.close"),
|
||||
b -> onClose()
|
||||
)
|
||||
.bounds(
|
||||
this.leftPos + (this.imageWidth - btnWidth) / 2,
|
||||
this.topPos +
|
||||
this.imageHeight -
|
||||
GuiLayoutConstants.BUTTON_HEIGHT -
|
||||
6,
|
||||
btnWidth,
|
||||
GuiLayoutConstants.BUTTON_HEIGHT
|
||||
)
|
||||
.build();
|
||||
this.addRenderableWidget(closeButton);
|
||||
}
|
||||
|
||||
public static boolean shouldShow() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
return mc.player != null;
|
||||
}
|
||||
|
||||
private void refreshSlaveList() {
|
||||
slaveList.clearEntriesPublic();
|
||||
|
||||
if (this.minecraft == null || this.minecraft.player == null) return;
|
||||
|
||||
Player player = this.minecraft.player;
|
||||
Set<UUID> addedUUIDs = new HashSet<>();
|
||||
|
||||
// 1. Add captives from PlayerCaptorManager
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state != null) {
|
||||
PlayerCaptorManager manager = state.getCaptorManager();
|
||||
if (manager != null) {
|
||||
for (IBondageState captive : manager.getCaptives()) {
|
||||
addSlaveEntry(captive);
|
||||
LivingEntity entity = captive.asLivingEntity();
|
||||
if (entity != null) {
|
||||
addedUUIDs.add(entity.getUUID());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Add nearby collar-linked entities (50-block radius matches server validation)
|
||||
AABB searchBox = player.getBoundingBox().inflate(50);
|
||||
for (LivingEntity entity : player
|
||||
.level()
|
||||
.getEntitiesOfClass(LivingEntity.class, searchBox)) {
|
||||
if (entity == player) continue;
|
||||
if (addedUUIDs.contains(entity.getUUID())) continue;
|
||||
|
||||
IBondageState kidnapped = KidnappedHelper.getKidnappedState(entity);
|
||||
if (kidnapped != null && kidnapped.hasCollar()) {
|
||||
ItemStack collarStack = kidnapped.getEquipment(BodyRegionV2.NECK);
|
||||
if (collarStack.getItem() instanceof ItemCollar collar) {
|
||||
if (collar.isOwner(collarStack, player)) {
|
||||
addSlaveEntry(kidnapped);
|
||||
addedUUIDs.add(entity.getUUID());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addSlaveEntry(IBondageState slave) {
|
||||
slaveList.addEntryPublic(
|
||||
new SlaveEntryWidget(
|
||||
slave,
|
||||
this::onAdjustClicked,
|
||||
this::onShockClicked,
|
||||
this::onLocateClicked,
|
||||
this::onFreeClicked
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== ACTIONS ====================
|
||||
|
||||
private void onAdjustClicked(IBondageState slave) {
|
||||
LivingEntity entity = slave.asLivingEntity();
|
||||
if (entity != null) {
|
||||
this.minecraft.setScreen(
|
||||
new RemoteAdjustmentScreen(slave, entity.getUUID())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void onShockClicked(IBondageState slave) {
|
||||
LivingEntity entity = slave.asLivingEntity();
|
||||
if (entity != null) {
|
||||
ModNetwork.sendToServer(
|
||||
new PacketSlaveAction(
|
||||
entity.getUUID(),
|
||||
PacketSlaveAction.Action.SHOCK
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void onLocateClicked(IBondageState slave) {
|
||||
LivingEntity entity = slave.asLivingEntity();
|
||||
if (entity != null) {
|
||||
ModNetwork.sendToServer(
|
||||
new PacketSlaveAction(
|
||||
entity.getUUID(),
|
||||
PacketSlaveAction.Action.LOCATE
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void onFreeClicked(IBondageState slave) {
|
||||
LivingEntity entity = slave.asLivingEntity();
|
||||
if (entity != null) {
|
||||
ModNetwork.sendToServer(
|
||||
new PacketSlaveAction(
|
||||
entity.getUUID(),
|
||||
PacketSlaveAction.Action.FREE
|
||||
)
|
||||
);
|
||||
// Refresh list after a short delay or immediately (server sync latency might require delay)
|
||||
// For now, immediate refresh to remove from UI
|
||||
refreshSlaveList();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RENDERING ====================
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
|
||||
// Slave count text
|
||||
String countText =
|
||||
slaveList.children().size() +
|
||||
" slave" +
|
||||
(slaveList.children().size() != 1 ? "s" : "");
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
countText,
|
||||
this.leftPos + GuiLayoutConstants.MARGIN_M,
|
||||
this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 2,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
|
||||
// Empty state
|
||||
if (slaveList.children().isEmpty()) {
|
||||
graphics.drawCenteredString(
|
||||
this.font,
|
||||
Component.translatable("gui.tiedup.slave_management.no_slaves"),
|
||||
this.leftPos + this.imageWidth / 2,
|
||||
this.topPos + this.imageHeight / 2,
|
||||
GuiColors.TEXT_DISABLED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== INNER CLASS: LIST ====================
|
||||
|
||||
class SlaveList extends ContainerObjectSelectionList<SlaveEntryWidget> {
|
||||
|
||||
private final int listLeft;
|
||||
private final int listWidth;
|
||||
|
||||
public SlaveList(
|
||||
Minecraft mc,
|
||||
int width,
|
||||
int top,
|
||||
int bottom,
|
||||
int left
|
||||
) {
|
||||
super(
|
||||
mc,
|
||||
width,
|
||||
bottom - top,
|
||||
top,
|
||||
bottom,
|
||||
GuiLayoutConstants.ENTRY_HEIGHT
|
||||
);
|
||||
this.listLeft = left;
|
||||
this.listWidth = width;
|
||||
this.centerListVertically = false;
|
||||
this.setRenderBackground(false);
|
||||
this.setRenderTopAndBottom(false);
|
||||
|
||||
// Set horizontal bounds directly (x0/x1 are protected in AbstractSelectionList)
|
||||
this.x0 = left;
|
||||
this.x1 = left + width;
|
||||
}
|
||||
|
||||
public void addEntryPublic(SlaveEntryWidget entry) {
|
||||
this.addEntry(entry);
|
||||
}
|
||||
|
||||
public void clearEntriesPublic() {
|
||||
this.clearEntries();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRowWidth() {
|
||||
return this.listWidth - 12; // Leave space for scrollbar
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getScrollbarPosition() {
|
||||
return this.listLeft + this.listWidth - 6;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRowLeft() {
|
||||
return this.listLeft;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean canOpen() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.player == null) return false;
|
||||
PlayerBindState state = PlayerBindState.getInstance(mc.player);
|
||||
return (
|
||||
state != null &&
|
||||
state.getCaptorManager() != null &&
|
||||
state.getCaptorManager().hasCaptives()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.trader.PacketBuyCaptive;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Trading screen for the SlaveTrader.
|
||||
*
|
||||
* Displays list of captives available for purchase and allows buying.
|
||||
*
|
||||
* Layout:
|
||||
* ┌─────────────────────────────────────────┐
|
||||
* │ SLAVE TRADER │
|
||||
* │ │
|
||||
* │ [Name] - [Price] [BUY] │
|
||||
* │ [Name] - [Price] [BUY] │
|
||||
* │ [Name] - [Price] [BUY] │
|
||||
* │ │
|
||||
* │ [Close] │
|
||||
* └─────────────────────────────────────────┘
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class SlaveTraderScreen extends BaseScreen {
|
||||
|
||||
private final int traderEntityId;
|
||||
private final String traderName;
|
||||
private final List<CaptiveOffer> offers;
|
||||
|
||||
private static final int ROW_HEIGHT = 28;
|
||||
private static final int BUY_BUTTON_WIDTH = 50;
|
||||
private static final int BUY_BUTTON_HEIGHT = 18;
|
||||
|
||||
/**
|
||||
* Data class representing a captive available for purchase.
|
||||
*/
|
||||
public static class CaptiveOffer {
|
||||
|
||||
public final UUID captiveId;
|
||||
public final String captiveName;
|
||||
public final String priceDescription;
|
||||
public final int priceAmount;
|
||||
public final String priceItemId;
|
||||
|
||||
public CaptiveOffer(
|
||||
UUID captiveId,
|
||||
String captiveName,
|
||||
String priceDescription,
|
||||
int priceAmount,
|
||||
String priceItemId
|
||||
) {
|
||||
this.captiveId = captiveId;
|
||||
this.captiveName = captiveName;
|
||||
this.priceDescription = priceDescription;
|
||||
this.priceAmount = priceAmount;
|
||||
this.priceItemId = priceItemId;
|
||||
}
|
||||
}
|
||||
|
||||
public SlaveTraderScreen(
|
||||
int traderEntityId,
|
||||
String traderName,
|
||||
List<CaptiveOffer> offers
|
||||
) {
|
||||
super(Component.translatable("gui.tiedup.slave_trader"));
|
||||
this.traderEntityId = traderEntityId;
|
||||
this.traderName = traderName;
|
||||
this.offers = offers != null ? offers : new ArrayList<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredWidth() {
|
||||
return GuiLayoutConstants.getResponsiveWidth(
|
||||
this.width,
|
||||
0.5f,
|
||||
320,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredHeight() {
|
||||
int offerHeight = Math.max(1, offers.size()) * ROW_HEIGHT;
|
||||
int baseHeight =
|
||||
GuiLayoutConstants.TITLE_HEIGHT +
|
||||
50 +
|
||||
GuiLayoutConstants.BUTTON_HEIGHT +
|
||||
30;
|
||||
return GuiLayoutConstants.getResponsiveHeight(
|
||||
this.height,
|
||||
0.8f,
|
||||
180,
|
||||
Math.min(400, baseHeight + offerHeight)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
|
||||
int contentTop = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 15;
|
||||
int centerX = this.leftPos + this.imageWidth / 2;
|
||||
|
||||
// Add buy button for each offer
|
||||
int y = contentTop;
|
||||
for (int i = 0; i < offers.size(); i++) {
|
||||
CaptiveOffer offer = offers.get(i);
|
||||
int buttonX =
|
||||
this.leftPos + this.imageWidth - BUY_BUTTON_WIDTH - 15;
|
||||
int buttonY = y + (ROW_HEIGHT - BUY_BUTTON_HEIGHT) / 2;
|
||||
|
||||
final int index = i;
|
||||
Button buyBtn = Button.builder(
|
||||
Component.translatable("gui.tiedup.buy"),
|
||||
b -> onBuy(index)
|
||||
)
|
||||
.bounds(buttonX, buttonY, BUY_BUTTON_WIDTH, BUY_BUTTON_HEIGHT)
|
||||
.build();
|
||||
this.addRenderableWidget(buyBtn);
|
||||
|
||||
y += ROW_HEIGHT;
|
||||
}
|
||||
|
||||
// Close button at bottom
|
||||
int closeY =
|
||||
this.topPos +
|
||||
this.imageHeight -
|
||||
GuiLayoutConstants.BUTTON_HEIGHT -
|
||||
10;
|
||||
Button closeBtn = Button.builder(
|
||||
Component.translatable("gui.tiedup.close"),
|
||||
b -> onClose()
|
||||
)
|
||||
.bounds(centerX - 40, closeY, 80, GuiLayoutConstants.BUTTON_HEIGHT)
|
||||
.build();
|
||||
this.addRenderableWidget(closeBtn);
|
||||
}
|
||||
|
||||
private void onBuy(int offerIndex) {
|
||||
if (offerIndex < 0 || offerIndex >= offers.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
CaptiveOffer offer = offers.get(offerIndex);
|
||||
|
||||
TiedUpMod.LOGGER.info(
|
||||
"[SlaveTraderScreen] Attempting to buy captive {} from trader {}",
|
||||
offer.captiveId.toString().substring(0, 8),
|
||||
traderEntityId
|
||||
);
|
||||
|
||||
// Send buy request to server
|
||||
ModNetwork.sendToServer(
|
||||
new PacketBuyCaptive(traderEntityId, offer.captiveId)
|
||||
);
|
||||
|
||||
// Close screen after purchase attempt
|
||||
onClose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
|
||||
int contentTop = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 15;
|
||||
int leftX = this.leftPos + 15;
|
||||
|
||||
// Render trader name
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
traderName,
|
||||
leftX,
|
||||
this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 2,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
|
||||
// Render offers
|
||||
if (offers.isEmpty()) {
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
Component.translatable(
|
||||
"gui.tiedup.no_captives_available"
|
||||
).withStyle(ChatFormatting.GRAY),
|
||||
leftX,
|
||||
contentTop + 10,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
} else {
|
||||
int y = contentTop;
|
||||
for (CaptiveOffer offer : offers) {
|
||||
renderOfferRow(graphics, offer, leftX, y);
|
||||
y += ROW_HEIGHT;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderOfferRow(
|
||||
GuiGraphics graphics,
|
||||
CaptiveOffer offer,
|
||||
int x,
|
||||
int y
|
||||
) {
|
||||
// Captive name
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
offer.captiveName,
|
||||
x,
|
||||
y + 2,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
|
||||
// Price
|
||||
graphics.drawString(
|
||||
this.font,
|
||||
offer.priceDescription,
|
||||
x,
|
||||
y + 14,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package com.tiedup.remake.client.gui.screens;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||
import com.tiedup.remake.client.gui.widgets.*;
|
||||
import com.tiedup.remake.items.ItemKey;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.cell.PacketRequestCellList;
|
||||
import com.tiedup.remake.network.slave.PacketMasterEquip;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import com.tiedup.remake.v2.bondage.network.PacketV2SelfEquip;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Unified bondage equipment screen replacing BondageInventoryScreen,
|
||||
* SlaveItemManagementScreen, and StruggleChoiceScreen.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class UnifiedBondageScreen extends BaseScreen {
|
||||
|
||||
// Visual theme colors (vanilla MC style)
|
||||
private static final int TITLE_COLOR = 0xFF404040;
|
||||
private static final int MODE_SELF_BG = 0xFF8B8B8B;
|
||||
private static final int MODE_MASTER_BG = 0xFF707070;
|
||||
|
||||
// Full-body view for all tabs (zoom per-tab was too finicky to get right)
|
||||
private static final float[] TAB_SCALES = { 1.0f, 1.0f, 1.0f, 1.0f, 1.0f };
|
||||
private static final float[] TAB_OFFSETS = { 0.0f, 0.0f, 0.0f, 0.0f, 0.0f };
|
||||
|
||||
private final ActionPanel.ScreenMode mode;
|
||||
private final LivingEntity targetEntity;
|
||||
private final UUID targetEntityUUID;
|
||||
|
||||
// Widgets
|
||||
private RegionTabBar tabBar;
|
||||
private EntityPreviewWidget preview;
|
||||
private RegionSlotWidget[] currentSlots;
|
||||
private ActionPanel actionPanel;
|
||||
private ItemPickerOverlay pickerOverlay;
|
||||
private StatusBarWidget statusBar;
|
||||
|
||||
// State
|
||||
private RegionSlotWidget selectedSlot;
|
||||
private int refreshCountdown = -1;
|
||||
|
||||
// Key info (for master mode)
|
||||
private UUID keyUUID;
|
||||
private boolean isMasterKey;
|
||||
|
||||
/**
|
||||
* Open in SELF mode.
|
||||
*/
|
||||
public UnifiedBondageScreen() {
|
||||
this(ActionPanel.ScreenMode.SELF, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open in MASTER mode targeting a specific entity.
|
||||
*/
|
||||
public UnifiedBondageScreen(LivingEntity target) {
|
||||
this(ActionPanel.ScreenMode.MASTER, target);
|
||||
}
|
||||
|
||||
private UnifiedBondageScreen(ActionPanel.ScreenMode mode, LivingEntity target) {
|
||||
super(Component.translatable("gui.tiedup.unified_bondage"));
|
||||
this.mode = mode;
|
||||
this.targetEntity = target;
|
||||
this.targetEntityUUID = (target != null) ? target.getUUID() : null;
|
||||
}
|
||||
|
||||
private LivingEntity getTarget() {
|
||||
return (mode == ActionPanel.ScreenMode.SELF) ? minecraft.player : targetEntity;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredWidth() {
|
||||
return GuiLayoutConstants.getResponsiveWidth(this.width, 0.65f, 420, 600);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getPreferredHeight() {
|
||||
return GuiLayoutConstants.getResponsiveHeight(this.height, 0.75f, 350, 500);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
super.init();
|
||||
|
||||
// Resolve key info for master mode by iterating player inventory
|
||||
if (mode == ActionPanel.ScreenMode.MASTER && minecraft.player != null) {
|
||||
ItemStack keyStack = findFirstKey(minecraft.player);
|
||||
if (!keyStack.isEmpty()) {
|
||||
if (keyStack.getItem() instanceof ItemKey key) {
|
||||
this.keyUUID = key.getKeyUUID(keyStack);
|
||||
this.isMasterKey = false;
|
||||
} else if (keyStack.is(ModItems.MASTER_KEY.get())) {
|
||||
this.keyUUID = null;
|
||||
this.isMasterKey = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int contentTop = topPos + GuiLayoutConstants.TITLE_HEIGHT + GuiLayoutConstants.MARGIN_M;
|
||||
|
||||
// === Tab Bar ===
|
||||
tabBar = new RegionTabBar(leftPos + 2, contentTop, imageWidth - 4);
|
||||
tabBar.setTargetEntity(getTarget());
|
||||
tabBar.setOnTabChanged(this::onTabChanged);
|
||||
addRenderableWidget(tabBar);
|
||||
|
||||
int belowTabs = contentTop + 30;
|
||||
int statusBarHeight = 46;
|
||||
int mainContentHeight = imageHeight - (belowTabs - topPos) - statusBarHeight - GuiLayoutConstants.MARGIN_S;
|
||||
|
||||
// === Preview (Left, 40%) ===
|
||||
int previewWidth = (int)((imageWidth - GuiLayoutConstants.MARGIN_M * 3) * 0.40f);
|
||||
LivingEntity target = getTarget();
|
||||
if (target != null) {
|
||||
preview = new EntityPreviewWidget(
|
||||
leftPos + GuiLayoutConstants.MARGIN_M, belowTabs,
|
||||
previewWidth, mainContentHeight, target
|
||||
);
|
||||
preview.setAutoRotate(true);
|
||||
preview.setAutoRotateSpeed(0.3f);
|
||||
preview.setZoomTarget(TAB_SCALES[0], TAB_OFFSETS[0]);
|
||||
addRenderableWidget(preview);
|
||||
}
|
||||
|
||||
// === Right panel area ===
|
||||
int rightX = leftPos + GuiLayoutConstants.MARGIN_M + previewWidth + GuiLayoutConstants.MARGIN_M;
|
||||
int rightWidth = imageWidth - (rightX - leftPos) - GuiLayoutConstants.MARGIN_M;
|
||||
|
||||
// Action panel height
|
||||
int actionPanelHeight = 84;
|
||||
int slotsHeight = mainContentHeight - actionPanelHeight - GuiLayoutConstants.MARGIN_S;
|
||||
|
||||
// === Region Slots ===
|
||||
buildSlots(rightX, belowTabs, rightWidth, slotsHeight);
|
||||
|
||||
// === Action Panel ===
|
||||
actionPanel = new ActionPanel(rightX, belowTabs + slotsHeight + GuiLayoutConstants.MARGIN_S,
|
||||
rightWidth, actionPanelHeight);
|
||||
actionPanel.setMode(mode);
|
||||
actionPanel.setTargetEntity(getTarget());
|
||||
actionPanel.setKeyInfo(keyUUID, isMasterKey);
|
||||
actionPanel.setOnAdjustRequested(region -> {
|
||||
if (AdjustmentScreen.canOpen()) minecraft.setScreen(new AdjustmentScreen());
|
||||
});
|
||||
actionPanel.setOnEquipRequested(this::openPicker);
|
||||
actionPanel.setOnCellAssignRequested(() -> {
|
||||
if (targetEntityUUID != null) ModNetwork.sendToServer(new PacketRequestCellList(targetEntityUUID));
|
||||
});
|
||||
actionPanel.setOnCloseRequested(this::onClose);
|
||||
actionPanel.clearContext();
|
||||
addRenderableWidget(actionPanel);
|
||||
|
||||
// === Status Bar ===
|
||||
int statusY = topPos + imageHeight - statusBarHeight;
|
||||
statusBar = new StatusBarWidget(leftPos, statusY, imageWidth, statusBarHeight);
|
||||
statusBar.setMode(mode);
|
||||
statusBar.setTargetEntity(getTarget());
|
||||
statusBar.setOnCloseClicked(this::onClose);
|
||||
addRenderableWidget(statusBar);
|
||||
|
||||
// === Picker Overlay (created but hidden) ===
|
||||
pickerOverlay = new ItemPickerOverlay();
|
||||
pickerOverlay.setOnItemSelected(this::onPickerItemSelected);
|
||||
pickerOverlay.setOnCancelled(() -> {}); // No-op, just close
|
||||
addRenderableWidget(pickerOverlay);
|
||||
|
||||
// Auto-select first occupied slot
|
||||
autoSelectFirstOccupied();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first key (ItemKey or ItemMasterKey) in the player's inventory.
|
||||
* Regular ItemKey is preferred; falls back to master key if none found.
|
||||
*/
|
||||
private static ItemStack findFirstKey(Player player) {
|
||||
ItemStack masterKeyStack = ItemStack.EMPTY;
|
||||
for (int i = 0; i < player.getInventory().getContainerSize(); i++) {
|
||||
ItemStack stack = player.getInventory().getItem(i);
|
||||
if (stack.isEmpty()) continue;
|
||||
if (stack.getItem() instanceof ItemKey) {
|
||||
return stack; // Regular key takes priority
|
||||
}
|
||||
if (masterKeyStack.isEmpty() && stack.is(ModItems.MASTER_KEY.get())) {
|
||||
masterKeyStack = stack; // Remember master key as fallback
|
||||
}
|
||||
}
|
||||
return masterKeyStack; // Empty or master key
|
||||
}
|
||||
|
||||
private void buildSlots(int x, int y, int width, int totalHeight) {
|
||||
RegionTabBar.BodyTab tab = tabBar.getActiveTab();
|
||||
BodyRegionV2[] regions = tab.getRegions().toArray(new BodyRegionV2[0]);
|
||||
currentSlots = new RegionSlotWidget[regions.length];
|
||||
|
||||
int slotHeight = Math.min(52, (totalHeight - 4) / regions.length);
|
||||
LivingEntity target = getTarget();
|
||||
|
||||
for (int i = 0; i < regions.length; i++) {
|
||||
BodyRegionV2 region = regions[i];
|
||||
int slotY = y + i * slotHeight;
|
||||
|
||||
RegionSlotWidget slot = new RegionSlotWidget(x, slotY, width, slotHeight - 2,
|
||||
region, () -> target != null ? V2EquipmentHelper.getInRegion(target, region) : ItemStack.EMPTY);
|
||||
slot.setOnClick(this::onSlotClicked);
|
||||
slot.setShowEquipButton(true);
|
||||
slot.setOnEquipClick(s -> openPicker(s.getRegion()));
|
||||
|
||||
// Adjust button for MOUTH/EYES
|
||||
if (region == BodyRegionV2.MOUTH || region == BodyRegionV2.EYES) {
|
||||
slot.setShowAdjustButton(true);
|
||||
}
|
||||
|
||||
currentSlots[i] = slot;
|
||||
addRenderableWidget(slot);
|
||||
}
|
||||
}
|
||||
|
||||
private void onTabChanged(RegionTabBar.BodyTab tab) {
|
||||
// Update preview zoom
|
||||
int tabIdx = tab.ordinal();
|
||||
if (preview != null) {
|
||||
preview.setZoomTarget(TAB_SCALES[tabIdx], TAB_OFFSETS[tabIdx]);
|
||||
}
|
||||
|
||||
// Rebuild slots — clear old ones and re-init
|
||||
if (currentSlots != null) {
|
||||
for (RegionSlotWidget slot : currentSlots) {
|
||||
removeWidget(slot);
|
||||
}
|
||||
}
|
||||
selectedSlot = null;
|
||||
actionPanel.clearContext();
|
||||
|
||||
// Recalculate layout for slots
|
||||
int contentTop = topPos + GuiLayoutConstants.TITLE_HEIGHT + GuiLayoutConstants.MARGIN_M;
|
||||
int belowTabs = contentTop + 30;
|
||||
int statusBarHeight = 46;
|
||||
int mainContentHeight = imageHeight - (belowTabs - topPos) - statusBarHeight - GuiLayoutConstants.MARGIN_S;
|
||||
int previewWidth = (int)((imageWidth - GuiLayoutConstants.MARGIN_M * 3) * 0.40f);
|
||||
int rightX = leftPos + GuiLayoutConstants.MARGIN_M + previewWidth + GuiLayoutConstants.MARGIN_M;
|
||||
int rightWidth = imageWidth - (rightX - leftPos) - GuiLayoutConstants.MARGIN_M;
|
||||
int actionPanelHeight = 84;
|
||||
int slotsHeight = mainContentHeight - actionPanelHeight - GuiLayoutConstants.MARGIN_S;
|
||||
|
||||
buildSlots(rightX, belowTabs, rightWidth, slotsHeight);
|
||||
autoSelectFirstOccupied();
|
||||
}
|
||||
|
||||
private void onSlotClicked(RegionSlotWidget slot) {
|
||||
// Deselect previous
|
||||
if (selectedSlot != null) selectedSlot.setSelected(false);
|
||||
// Select new
|
||||
selectedSlot = slot;
|
||||
slot.setSelected(true);
|
||||
// Update action panel
|
||||
actionPanel.setContext(slot.getRegion(), slot.getItem());
|
||||
}
|
||||
|
||||
private void autoSelectFirstOccupied() {
|
||||
if (currentSlots == null) return;
|
||||
for (RegionSlotWidget slot : currentSlots) {
|
||||
if (!slot.getItem().isEmpty()) {
|
||||
onSlotClicked(slot);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// No occupied slots — select first slot anyway for equip
|
||||
if (currentSlots.length > 0) {
|
||||
onSlotClicked(currentSlots[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private void openPicker(BodyRegionV2 region) {
|
||||
pickerOverlay.open(region, mode == ActionPanel.ScreenMode.SELF, this.width, this.height);
|
||||
}
|
||||
|
||||
private void onPickerItemSelected(BodyRegionV2 region, int inventorySlot) {
|
||||
if (mode == ActionPanel.ScreenMode.SELF) {
|
||||
ModNetwork.sendToServer(new PacketV2SelfEquip(region, inventorySlot));
|
||||
} else {
|
||||
ModNetwork.sendToServer(new PacketMasterEquip(targetEntityUUID, region, inventorySlot));
|
||||
}
|
||||
refreshCountdown = 10; // Refresh after server processes
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
super.tick();
|
||||
if (preview != null) preview.tickZoom();
|
||||
|
||||
if (refreshCountdown > 0) {
|
||||
refreshCountdown--;
|
||||
} else if (refreshCountdown == 0) {
|
||||
refreshCountdown = -1;
|
||||
rebuildCurrentTab();
|
||||
}
|
||||
}
|
||||
|
||||
private void rebuildCurrentTab() {
|
||||
onTabChanged(tabBar.getActiveTab());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
this.renderBackground(graphics);
|
||||
|
||||
// MC-style raised panel
|
||||
GuiRenderUtil.drawMCPanel(graphics, leftPos, topPos, imageWidth, imageHeight);
|
||||
|
||||
// Title (dark text, vanilla style)
|
||||
String titleText = this.title.getString();
|
||||
if (mode == ActionPanel.ScreenMode.MASTER && targetEntity != null) {
|
||||
titleText += " \u2014 " + targetEntity.getName().getString();
|
||||
}
|
||||
GuiRenderUtil.drawCenteredStringNoShadow(graphics, font, titleText,
|
||||
leftPos + imageWidth / 2, topPos + GuiLayoutConstants.MARGIN_M, TITLE_COLOR);
|
||||
|
||||
// Mode badge (top-right) — sober gray badge
|
||||
int badgeWidth = 90;
|
||||
int badgeX = leftPos + imageWidth - badgeWidth - GuiLayoutConstants.MARGIN_M;
|
||||
int badgeY = topPos + GuiLayoutConstants.MARGIN_S;
|
||||
int badgeBg = mode == ActionPanel.ScreenMode.MASTER ? MODE_MASTER_BG : MODE_SELF_BG;
|
||||
graphics.fill(badgeX, badgeY, badgeX + badgeWidth, badgeY + 16, badgeBg);
|
||||
String badgeText = mode == ActionPanel.ScreenMode.MASTER
|
||||
? Component.translatable("gui.tiedup.mode.master").getString()
|
||||
: Component.translatable("gui.tiedup.mode.self").getString();
|
||||
GuiRenderUtil.drawCenteredStringNoShadow(graphics, font, badgeText, badgeX + badgeWidth / 2, badgeY + 4, GuiRenderUtil.MC_TEXT_DARK);
|
||||
|
||||
// Render all widgets
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
|
||||
// Picker overlay renders on top of everything
|
||||
if (pickerOverlay.isOverlayVisible()) {
|
||||
pickerOverlay.render(graphics, mouseX, mouseY, partialTick);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
// Picker overlay captures all clicks when visible
|
||||
if (pickerOverlay.isOverlayVisible()) {
|
||||
return pickerOverlay.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
return super.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||
if (pickerOverlay.isOverlayVisible()) {
|
||||
return pickerOverlay.keyPressed(keyCode, scanCode, modifiers);
|
||||
}
|
||||
return super.keyPressed(keyCode, scanCode, modifiers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
|
||||
if (pickerOverlay.isOverlayVisible()) {
|
||||
return pickerOverlay.mouseScrolled(mouseX, mouseY, delta);
|
||||
}
|
||||
return super.mouseScrolled(mouseX, mouseY, delta);
|
||||
}
|
||||
}
|
||||
154
src/main/java/com/tiedup/remake/client/gui/util/GuiColors.java
Normal file
154
src/main/java/com/tiedup/remake/client/gui/util/GuiColors.java
Normal file
@@ -0,0 +1,154 @@
|
||||
package com.tiedup.remake.client.gui.util;
|
||||
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
|
||||
/**
|
||||
* Color constants for TiedUp! GUI elements.
|
||||
* All colors are in ARGB format (0xAARRGGBB).
|
||||
*
|
||||
* Phase 16: GUI Revamp
|
||||
*/
|
||||
public class GuiColors {
|
||||
|
||||
// === Backgrounds ===
|
||||
public static final int BG_DARK = 0xFF1A1A1A; // #1A1A1A
|
||||
public static final int BG_MEDIUM = 0xFF2D2D2D; // #2D2D2D
|
||||
public static final int BG_LIGHT = 0xFF3D3D3D; // #3D3D3D
|
||||
|
||||
// === Accents (leather/rope theme) ===
|
||||
public static final int ACCENT_BROWN = 0xFF8B4513; // #8B4513 SaddleBrown
|
||||
public static final int ACCENT_TAN = 0xFFCD853F; // #CD853F Peru
|
||||
public static final int ACCENT_ROPE = 0xFFD2691E; // #D2691E Chocolate
|
||||
|
||||
// === Text ===
|
||||
public static final int TEXT_WHITE = 0xFFFFFFFF;
|
||||
public static final int TEXT_GRAY = 0xFFAAAAAA;
|
||||
public static final int TEXT_DISABLED = 0xFF666666;
|
||||
|
||||
// === States ===
|
||||
public static final int SUCCESS = 0xFF4CAF50; // Green
|
||||
public static final int WARNING = 0xFFFF9800; // Orange
|
||||
public static final int ERROR = 0xFFF44336; // Red
|
||||
public static final int INFO = 0xFF2196F3; // Blue
|
||||
|
||||
// === Borders ===
|
||||
public static final int BORDER_DARK = 0xFF0A0A0A;
|
||||
public static final int BORDER_LIGHT = 0xFF4A4A4A;
|
||||
|
||||
// === Slot states ===
|
||||
public static final int SLOT_EMPTY = 0xFF555555;
|
||||
public static final int SLOT_FILLED = 0xFF666666;
|
||||
public static final int SLOT_HOVER = 0xFF777777;
|
||||
public static final int SLOT_SELECTED = 0xFF8B4513;
|
||||
|
||||
// === Bondage Type Colors ===
|
||||
public static final int TYPE_BIND = 0xFF8B4513; // Brown (rope)
|
||||
public static final int TYPE_GAG = 0xFFFF6B6B; // Red
|
||||
public static final int TYPE_BLINDFOLD = 0xFF333333; // Dark gray
|
||||
public static final int TYPE_EARPLUGS = 0xFFFFD93D; // Yellow
|
||||
public static final int TYPE_COLLAR = 0xFF6BCB77; // Green
|
||||
public static final int TYPE_CLOTHES = 0xFF4D96FF; // Blue
|
||||
public static final int TYPE_MITTENS = 0xFFFF9F43; // Orange
|
||||
|
||||
// === Action Hover Colors ===
|
||||
public static final int HOVER_REMOVE = 0xFF5D2020; // Dark red
|
||||
public static final int HOVER_LOCK = 0xFF5D4520; // Dark orange
|
||||
public static final int HOVER_UNLOCK = 0xFF205D20; // Dark green
|
||||
public static final int BUTTON_REMOVE = 0xFFCC4444;
|
||||
public static final int BUTTON_REMOVE_HOVER = 0xFFFF6B6B;
|
||||
|
||||
/**
|
||||
* Get the color for a V2 body region.
|
||||
*
|
||||
* @param region The body region
|
||||
* @return The corresponding color
|
||||
*/
|
||||
public static int getRegionColor(BodyRegionV2 region) {
|
||||
return switch (region) {
|
||||
case HEAD -> 0xFF9C27B0; // Purple
|
||||
case EYES -> TYPE_BLINDFOLD;
|
||||
case EARS -> TYPE_EARPLUGS;
|
||||
case MOUTH -> TYPE_GAG;
|
||||
case NECK -> TYPE_COLLAR;
|
||||
case TORSO -> TYPE_CLOTHES;
|
||||
case ARMS -> TYPE_BIND;
|
||||
case HANDS -> TYPE_MITTENS;
|
||||
case FINGERS -> 0xFFFFAB91; // Light orange
|
||||
case WAIST -> 0xFF795548; // Brown
|
||||
case LEGS -> 0xFF607D8B; // Blue-gray
|
||||
case FEET -> 0xFF78909C; // Light blue-gray
|
||||
case TAIL -> 0xFFCE93D8; // Light purple
|
||||
case WINGS -> 0xFF80DEEA; // Light cyan
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color for a bondage item type.
|
||||
*
|
||||
* @param type The bondage item type name (lowercase)
|
||||
* @return The corresponding color
|
||||
*/
|
||||
public static int getTypeColor(String type) {
|
||||
return switch (type.toLowerCase()) {
|
||||
case "bind" -> TYPE_BIND;
|
||||
case "gag" -> TYPE_GAG;
|
||||
case "blindfold" -> TYPE_BLINDFOLD;
|
||||
case "earplugs" -> TYPE_EARPLUGS;
|
||||
case "collar" -> TYPE_COLLAR;
|
||||
case "clothes" -> TYPE_CLOTHES;
|
||||
case "mittens" -> TYPE_MITTENS;
|
||||
default -> TEXT_WHITE;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a color with custom alpha.
|
||||
*
|
||||
* @param color Base color (ARGB)
|
||||
* @param alpha Alpha value (0-255)
|
||||
* @return Color with new alpha
|
||||
*/
|
||||
public static int withAlpha(int color, int alpha) {
|
||||
return (color & 0x00FFFFFF) | (alpha << 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighten a color by a factor.
|
||||
*
|
||||
* @param color Base color (ARGB)
|
||||
* @param factor Factor (0.0 = no change, 1.0 = white)
|
||||
* @return Lightened color
|
||||
*/
|
||||
public static int lighten(int color, float factor) {
|
||||
int a = (color >> 24) & 0xFF;
|
||||
int r = (color >> 16) & 0xFF;
|
||||
int g = (color >> 8) & 0xFF;
|
||||
int b = color & 0xFF;
|
||||
|
||||
r = (int) (r + (255 - r) * factor);
|
||||
g = (int) (g + (255 - g) * factor);
|
||||
b = (int) (b + (255 - b) * factor);
|
||||
|
||||
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken a color by a factor.
|
||||
*
|
||||
* @param color Base color (ARGB)
|
||||
* @param factor Factor (0.0 = no change, 1.0 = black)
|
||||
* @return Darkened color
|
||||
*/
|
||||
public static int darken(int color, float factor) {
|
||||
int a = (color >> 24) & 0xFF;
|
||||
int r = (color >> 16) & 0xFF;
|
||||
int g = (color >> 8) & 0xFF;
|
||||
int b = color & 0xFF;
|
||||
|
||||
r = (int) (r * (1 - factor));
|
||||
g = (int) (g * (1 - factor));
|
||||
b = (int) (b * (1 - factor));
|
||||
|
||||
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.tiedup.remake.client.gui.util;
|
||||
|
||||
import net.minecraft.util.Mth;
|
||||
|
||||
/**
|
||||
* Centralized layout constants for all TiedUp! GUI screens and widgets.
|
||||
* Provides standard spacing and responsive calculation utilities.
|
||||
*
|
||||
* Refactored for Responsive Design.
|
||||
*/
|
||||
public final class GuiLayoutConstants {
|
||||
|
||||
private GuiLayoutConstants() {} // No instantiation
|
||||
|
||||
// ==================== MARGINS & PADDING ====================
|
||||
|
||||
public static final int MARGIN_XS = 2;
|
||||
public static final int MARGIN_S = 4;
|
||||
public static final int MARGIN_M = 8;
|
||||
public static final int MARGIN_L = 16;
|
||||
public static final int SCREEN_EDGE_MARGIN = 10;
|
||||
|
||||
public static final int LINE_HEIGHT = 10;
|
||||
public static final int TITLE_HEIGHT = 20;
|
||||
|
||||
// ==================== WIDGET DIMENSIONS ====================
|
||||
|
||||
public static final int BUTTON_HEIGHT = 20;
|
||||
public static final int BUTTON_WIDTH_S = 40;
|
||||
public static final int BUTTON_WIDTH_M = 80;
|
||||
public static final int BUTTON_WIDTH_L = 120;
|
||||
public static final int BUTTON_WIDTH_XL = 160;
|
||||
|
||||
public static final int SLOT_SIZE = 24; // Standard square slot
|
||||
public static final int SLOT_HEIGHT = 24;
|
||||
public static final int SLOT_SPACING = 3;
|
||||
public static final int ICON_SIZE = 16;
|
||||
|
||||
public static final int SCROLLBAR_WIDTH = 6;
|
||||
|
||||
// ==================== STATUS ICONS ====================
|
||||
|
||||
public static final int STATUS_ICON_SIZE = 14;
|
||||
public static final int STATUS_ICON_SPACING = 4;
|
||||
|
||||
// ==================== ENTRY/LIST DIMENSIONS ====================
|
||||
|
||||
public static final int ENTRY_HEIGHT = 65;
|
||||
public static final int ENTRY_SPACING = 4;
|
||||
|
||||
// ==================== PREVIEW SIZES ====================
|
||||
|
||||
public static final int PREVIEW_WIDTH_S = 50;
|
||||
public static final int PREVIEW_WIDTH_M = 100;
|
||||
public static final int PREVIEW_WIDTH_L = 120;
|
||||
public static final int PREVIEW_HEIGHT = 160;
|
||||
|
||||
// Slider specific dimensions
|
||||
public static final int SLIDER_THUMB_WIDTH = 8;
|
||||
public static final int SLIDER_THUMB_HEIGHT = 20;
|
||||
public static final int SLIDER_TRACK_WIDTH = 4;
|
||||
|
||||
// ==================== LAYOUT HELPERS ====================
|
||||
|
||||
/**
|
||||
* Calculates a responsive width constrained by min/max values.
|
||||
* @param screenWidth The current screen width
|
||||
* @param percentTarget Target percentage of screen width (0.0 - 1.0)
|
||||
* @param minWidth Minimum pixel width
|
||||
* @param maxWidth Maximum pixel width
|
||||
* @return Calculated width
|
||||
*/
|
||||
public static int getResponsiveWidth(
|
||||
int screenWidth,
|
||||
float percentTarget,
|
||||
int minWidth,
|
||||
int maxWidth
|
||||
) {
|
||||
int target = (int) (screenWidth * percentTarget);
|
||||
int available = screenWidth - (SCREEN_EDGE_MARGIN * 2);
|
||||
return Mth.clamp(target, minWidth, Math.min(maxWidth, available));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a responsive height constrained by min/max values.
|
||||
* @param screenHeight The current screen height
|
||||
* @param percentTarget Target percentage of screen height (0.0 - 1.0)
|
||||
* @param minHeight Minimum pixel height
|
||||
* @param maxHeight Maximum pixel height
|
||||
* @return Calculated height
|
||||
*/
|
||||
public static int getResponsiveHeight(
|
||||
int screenHeight,
|
||||
float percentTarget,
|
||||
int minHeight,
|
||||
int maxHeight
|
||||
) {
|
||||
int target = (int) (screenHeight * percentTarget);
|
||||
int available = screenHeight - (SCREEN_EDGE_MARGIN * 2);
|
||||
return Mth.clamp(target, minHeight, Math.min(maxHeight, available));
|
||||
}
|
||||
|
||||
/**
|
||||
* Centers an element coordinate X.
|
||||
*/
|
||||
public static int centerX(int containerWidth, int elementWidth) {
|
||||
return (containerWidth - elementWidth) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centers an element coordinate Y.
|
||||
*/
|
||||
public static int centerY(int containerHeight, int elementHeight) {
|
||||
return (containerHeight - elementHeight) / 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.tiedup.remake.client.gui.util;
|
||||
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Utility methods for GUI rendering.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class GuiRenderUtil {
|
||||
|
||||
// Vanilla MC 3D border colors
|
||||
public static final int MC_PANEL_BG = 0xFFC6C6C6;
|
||||
public static final int MC_HIGHLIGHT_OUTER = 0xFFFFFFFF;
|
||||
public static final int MC_HIGHLIGHT_INNER = 0xFFDBDBDB;
|
||||
public static final int MC_SHADOW_INNER = 0xFF555555;
|
||||
public static final int MC_SHADOW_OUTER = 0xFF373737;
|
||||
public static final int MC_SLOT_BG = 0xFF8B8B8B;
|
||||
public static final int MC_TEXT_DARK = 0xFF404040;
|
||||
public static final int MC_TEXT_GRAY = 0xFF555555;
|
||||
|
||||
private GuiRenderUtil() {}
|
||||
|
||||
/**
|
||||
* Draw a vanilla MC-style raised panel (light gray with 3D beveled borders).
|
||||
*/
|
||||
public static void drawMCPanel(GuiGraphics graphics, int x, int y, int width, int height) {
|
||||
// Fill
|
||||
graphics.fill(x, y, x + width, y + height, MC_PANEL_BG);
|
||||
// Top highlight (outer white, inner light)
|
||||
graphics.fill(x, y, x + width, y + 1, MC_HIGHLIGHT_OUTER);
|
||||
graphics.fill(x + 1, y + 1, x + width - 1, y + 2, MC_HIGHLIGHT_INNER);
|
||||
// Left highlight
|
||||
graphics.fill(x, y, x + 1, y + height, MC_HIGHLIGHT_OUTER);
|
||||
graphics.fill(x + 1, y + 1, x + 2, y + height - 1, MC_HIGHLIGHT_INNER);
|
||||
// Bottom shadow
|
||||
graphics.fill(x, y + height - 1, x + width, y + height, MC_SHADOW_OUTER);
|
||||
graphics.fill(x + 1, y + height - 2, x + width - 1, y + height - 1, MC_SHADOW_INNER);
|
||||
// Right shadow
|
||||
graphics.fill(x + width - 1, y, x + width, y + height, MC_SHADOW_OUTER);
|
||||
graphics.fill(x + width - 2, y + 1, x + width - 1, y + height - 1, MC_SHADOW_INNER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a vanilla MC-style sunken panel (inverted 3D borders — dark outside, light inside).
|
||||
*/
|
||||
public static void drawMCSunkenPanel(GuiGraphics graphics, int x, int y, int width, int height) {
|
||||
graphics.fill(x, y, x + width, y + height, MC_SLOT_BG);
|
||||
// Top shadow (dark)
|
||||
graphics.fill(x, y, x + width, y + 1, MC_SHADOW_OUTER);
|
||||
graphics.fill(x + 1, y + 1, x + width - 1, y + 2, MC_SHADOW_INNER);
|
||||
// Left shadow (dark)
|
||||
graphics.fill(x, y, x + 1, y + height, MC_SHADOW_OUTER);
|
||||
graphics.fill(x + 1, y + 1, x + 2, y + height - 1, MC_SHADOW_INNER);
|
||||
// Bottom highlight (light)
|
||||
graphics.fill(x, y + height - 1, x + width, y + height, MC_HIGHLIGHT_OUTER);
|
||||
graphics.fill(x + 1, y + height - 2, x + width - 1, y + height - 1, MC_HIGHLIGHT_INNER);
|
||||
// Right highlight (light)
|
||||
graphics.fill(x + width - 1, y, x + width, y + height, MC_HIGHLIGHT_OUTER);
|
||||
graphics.fill(x + width - 2, y + 1, x + width - 1, y + height - 1, MC_HIGHLIGHT_INNER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a vanilla MC-style sunken slot.
|
||||
*/
|
||||
public static void drawMCSlot(GuiGraphics graphics, int x, int y, int width, int height) {
|
||||
graphics.fill(x, y, x + width, y + height, MC_SLOT_BG);
|
||||
// Top shadow
|
||||
graphics.fill(x, y, x + width, y + 1, MC_SHADOW_OUTER);
|
||||
// Left shadow
|
||||
graphics.fill(x, y, x + 1, y + height, MC_SHADOW_OUTER);
|
||||
// Bottom highlight
|
||||
graphics.fill(x, y + height - 1, x + width, y + height, MC_HIGHLIGHT_OUTER);
|
||||
// Right highlight
|
||||
graphics.fill(x + width - 1, y, x + width, y + height, MC_HIGHLIGHT_OUTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw vanilla-style slot hover overlay (white semi-transparent).
|
||||
*/
|
||||
public static void drawSlotHover(GuiGraphics graphics, int x, int y, int width, int height) {
|
||||
graphics.fill(x + 1, y + 1, x + width - 1, y + height - 1, 0x80FFFFFF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a vanilla MC-style button (raised 3D appearance).
|
||||
*/
|
||||
public static void drawMCButton(GuiGraphics graphics, int x, int y, int width, int height, boolean hovered, boolean enabled) {
|
||||
int bg = enabled ? (hovered ? 0xFFA0A0A0 : MC_SLOT_BG) : 0xFF606060;
|
||||
graphics.fill(x, y, x + width, y + height, bg);
|
||||
if (enabled) {
|
||||
// Top highlight
|
||||
graphics.fill(x, y, x + width, y + 1, MC_HIGHLIGHT_OUTER);
|
||||
// Left highlight
|
||||
graphics.fill(x, y, x + 1, y + height, MC_HIGHLIGHT_OUTER);
|
||||
// Bottom shadow
|
||||
graphics.fill(x, y + height - 1, x + width, y + height, MC_SHADOW_OUTER);
|
||||
// Right shadow
|
||||
graphics.fill(x + width - 1, y, x + width, y + height, MC_SHADOW_OUTER);
|
||||
} else {
|
||||
// Flat dark border for disabled
|
||||
graphics.fill(x, y, x + width, y + 1, 0xFF505050);
|
||||
graphics.fill(x, y + height - 1, x + width, y + height, 0xFF505050);
|
||||
graphics.fill(x, y, x + 1, y + height, 0xFF505050);
|
||||
graphics.fill(x + width - 1, y, x + width, y + height, 0xFF505050);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a selected slot highlight border (gold/yellow).
|
||||
*/
|
||||
public static void drawSelectedBorder(GuiGraphics graphics, int x, int y, int width, int height) {
|
||||
int gold = 0xFFFFD700;
|
||||
// Top
|
||||
graphics.fill(x, y, x + width, y + 1, gold);
|
||||
graphics.fill(x, y + 1, x + width, y + 2, gold);
|
||||
// Bottom
|
||||
graphics.fill(x, y + height - 2, x + width, y + height - 1, gold);
|
||||
graphics.fill(x, y + height - 1, x + width, y + height, gold);
|
||||
// Left
|
||||
graphics.fill(x, y, x + 1, y + height, gold);
|
||||
graphics.fill(x + 1, y, x + 2, y + height, gold);
|
||||
// Right
|
||||
graphics.fill(x + width - 2, y, x + width - 1, y + height, gold);
|
||||
graphics.fill(x + width - 1, y, x + width, y + height, gold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw centered text WITHOUT shadow (vanilla drawCenteredString always adds shadow).
|
||||
* Use this for dark text on light MC panels.
|
||||
*/
|
||||
public static void drawCenteredStringNoShadow(GuiGraphics graphics, net.minecraft.client.gui.Font font, String text, int centerX, int y, int color) {
|
||||
int textWidth = font.width(text);
|
||||
graphics.drawString(font, text, centerX - textWidth / 2, y, color, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a 1-pixel border around a rectangle.
|
||||
*
|
||||
* @param graphics The graphics context
|
||||
* @param x Left position
|
||||
* @param y Top position
|
||||
* @param width Rectangle width
|
||||
* @param height Rectangle height
|
||||
* @param color Border color (ARGB)
|
||||
*/
|
||||
public static void drawBorder(
|
||||
GuiGraphics graphics,
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
int height,
|
||||
int color
|
||||
) {
|
||||
// Top
|
||||
graphics.fill(x, y, x + width, y + 1, color);
|
||||
// Bottom
|
||||
graphics.fill(x, y + height - 1, x + width, y + height, color);
|
||||
// Left
|
||||
graphics.fill(x, y, x + 1, y + height, color);
|
||||
// Right
|
||||
graphics.fill(x + width - 1, y, x + width, y + height, color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a border with custom thickness around a rectangle.
|
||||
*
|
||||
* @param graphics The graphics context
|
||||
* @param x Left position
|
||||
* @param y Top position
|
||||
* @param width Rectangle width
|
||||
* @param height Rectangle height
|
||||
* @param thickness Border thickness in pixels
|
||||
* @param color Border color (ARGB)
|
||||
*/
|
||||
public static void drawBorder(
|
||||
GuiGraphics graphics,
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
int height,
|
||||
int thickness,
|
||||
int color
|
||||
) {
|
||||
// Top
|
||||
graphics.fill(x, y, x + width, y + thickness, color);
|
||||
// Bottom
|
||||
graphics.fill(x, y + height - thickness, x + width, y + height, color);
|
||||
// Left
|
||||
graphics.fill(x, y, x + thickness, y + height, color);
|
||||
// Right
|
||||
graphics.fill(x + width - thickness, y, x + width, y + height, color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.tiedup.remake.client.gui.util;
|
||||
|
||||
import net.minecraft.client.gui.Font;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Utility class for rendering stat bars and value-colored elements in GUIs.
|
||||
* Centralizes stat bar rendering logic to reduce code duplication.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class GuiStatsRenderer {
|
||||
|
||||
private GuiStatsRenderer() {}
|
||||
|
||||
// ARGB colors for stat values (green -> orange -> red)
|
||||
private static final int COLOR_GREEN = 0xFF4CAF50;
|
||||
private static final int COLOR_ORANGE = 0xFFFF9800;
|
||||
private static final int COLOR_RED = 0xFFF44336;
|
||||
|
||||
/**
|
||||
* Render a stat bar with label and value-based coloring.
|
||||
*
|
||||
* @param graphics Graphics context
|
||||
* @param font Font renderer
|
||||
* @param x Left position
|
||||
* @param y Top position
|
||||
* @param label Stat label (e.g., "Hunger", "Willpower")
|
||||
* @param value Stat value (0-100)
|
||||
* @param totalWidth Total width including label and bar
|
||||
*/
|
||||
public static void renderStatBar(
|
||||
GuiGraphics graphics,
|
||||
Font font,
|
||||
int x,
|
||||
int y,
|
||||
String label,
|
||||
float value,
|
||||
int totalWidth
|
||||
) {
|
||||
// Draw label
|
||||
int labelWidth = font.width(label) + 2;
|
||||
graphics.drawString(font, label, x, y, 0xFFAAAAAA, false);
|
||||
|
||||
// Calculate bar dimensions
|
||||
int barX = x + labelWidth;
|
||||
int barW = totalWidth - labelWidth;
|
||||
int barH = 6;
|
||||
|
||||
// Background (black)
|
||||
graphics.fill(barX, y + 1, barX + barW, y + 1 + barH, 0xFF000000);
|
||||
|
||||
// Fill based on value (0-100)
|
||||
int fillWidth = (int) (barW * (value / 100f));
|
||||
int fillColor = getValueColorArgb(value);
|
||||
if (fillWidth > 0) {
|
||||
graphics.fill(
|
||||
barX + 1,
|
||||
y + 2,
|
||||
barX + 1 + Math.min(fillWidth, barW - 2),
|
||||
y + barH,
|
||||
fillColor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get RGB color for a stat value (green -> orange -> red).
|
||||
* Used for text rendering.
|
||||
*
|
||||
* @param value Stat value (0-100)
|
||||
* @return RGB color value (0xRRGGBB)
|
||||
*/
|
||||
public static int getValueColor(float value) {
|
||||
if (value >= 70) return 0x4CAF50; // Green
|
||||
if (value >= 40) return 0xFF9800; // Orange
|
||||
return 0xF44336; // Red
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ARGB color for a stat value (green -> orange -> red).
|
||||
* Used for fill operations.
|
||||
*
|
||||
* @param value Stat value (0-100)
|
||||
* @return ARGB color value (0xAARRGGBB)
|
||||
*/
|
||||
public static int getValueColorArgb(float value) {
|
||||
if (value >= 70) return COLOR_GREEN;
|
||||
if (value >= 40) return COLOR_ORANGE;
|
||||
return COLOR_RED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.tiedup.remake.client.gui.util;
|
||||
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Utility class for rendering vanilla-style GUI elements.
|
||||
* Provides methods to draw backgrounds using Minecraft's textures.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class GuiTextureHelper {
|
||||
|
||||
private GuiTextureHelper() {}
|
||||
|
||||
// === Vanilla Textures ===
|
||||
public static final ResourceLocation CHEST_BACKGROUND =
|
||||
ResourceLocation.fromNamespaceAndPath(
|
||||
"minecraft",
|
||||
"textures/gui/container/generic_54.png"
|
||||
);
|
||||
|
||||
public static final ResourceLocation INVENTORY_BACKGROUND =
|
||||
ResourceLocation.fromNamespaceAndPath(
|
||||
"minecraft",
|
||||
"textures/gui/container/inventory.png"
|
||||
);
|
||||
|
||||
// === Chest Texture Dimensions ===
|
||||
public static final int CHEST_WIDTH = 176;
|
||||
public static final int CHEST_ROW_HEIGHT = 18;
|
||||
public static final int CHEST_HEADER_HEIGHT = 17;
|
||||
public static final int CHEST_PLAYER_INV_HEIGHT = 96;
|
||||
public static final int SLOT_SIZE = 18;
|
||||
|
||||
/**
|
||||
* Render a chest-style background with variable number of rows.
|
||||
* Uses the vanilla generic_54.png texture (large chest).
|
||||
*
|
||||
* @param graphics GuiGraphics context
|
||||
* @param x Left position
|
||||
* @param y Top position
|
||||
* @param rows Number of inventory rows (1-6)
|
||||
*/
|
||||
public static void renderChestBackground(
|
||||
GuiGraphics graphics,
|
||||
int x,
|
||||
int y,
|
||||
int rows
|
||||
) {
|
||||
rows = Math.min(6, Math.max(1, rows));
|
||||
|
||||
// Header section (title area) - top 17 pixels
|
||||
graphics.blit(
|
||||
CHEST_BACKGROUND,
|
||||
x,
|
||||
y,
|
||||
0,
|
||||
0,
|
||||
CHEST_WIDTH,
|
||||
CHEST_HEADER_HEIGHT
|
||||
);
|
||||
|
||||
// Inventory rows - 18 pixels each
|
||||
for (int i = 0; i < rows; i++) {
|
||||
graphics.blit(
|
||||
CHEST_BACKGROUND,
|
||||
x,
|
||||
y + CHEST_HEADER_HEIGHT + i * CHEST_ROW_HEIGHT,
|
||||
0,
|
||||
CHEST_HEADER_HEIGHT,
|
||||
CHEST_WIDTH,
|
||||
CHEST_ROW_HEIGHT
|
||||
);
|
||||
}
|
||||
|
||||
// Player inventory section (bottom part with separator + 3 rows + hotbar)
|
||||
// In generic_54.png, player section starts at y=125 and is 96 pixels tall
|
||||
graphics.blit(
|
||||
CHEST_BACKGROUND,
|
||||
x,
|
||||
y + CHEST_HEADER_HEIGHT + rows * CHEST_ROW_HEIGHT,
|
||||
0,
|
||||
125,
|
||||
CHEST_WIDTH,
|
||||
CHEST_PLAYER_INV_HEIGHT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total height for a chest-style container with N rows.
|
||||
*
|
||||
* @param rows Number of inventory rows
|
||||
* @return Total height in pixels
|
||||
*/
|
||||
public static int getChestHeight(int rows) {
|
||||
return (
|
||||
CHEST_HEADER_HEIGHT +
|
||||
rows * CHEST_ROW_HEIGHT +
|
||||
CHEST_PLAYER_INV_HEIGHT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a vanilla-style 3D beveled panel.
|
||||
* Creates a dark panel with highlight on top/left and shadow on bottom/right.
|
||||
*
|
||||
* @param graphics GuiGraphics context
|
||||
* @param x Left position
|
||||
* @param y Top position
|
||||
* @param width Panel width
|
||||
* @param height Panel height
|
||||
*/
|
||||
public static void renderBeveledPanel(
|
||||
GuiGraphics graphics,
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
int height
|
||||
) {
|
||||
// Main background (dark gray)
|
||||
graphics.fill(x, y, x + width, y + height, 0xC0101010);
|
||||
|
||||
// Top highlight (light gray)
|
||||
graphics.fill(x, y, x + width, y + 2, 0xFF373737);
|
||||
// Left highlight
|
||||
graphics.fill(x, y, x + 2, y + height, 0xFF373737);
|
||||
|
||||
// Bottom shadow (black)
|
||||
graphics.fill(x, y + height - 2, x + width, y + height, 0xFF000000);
|
||||
// Right shadow
|
||||
graphics.fill(x + width - 2, y, x + width, y + height, 0xFF000000);
|
||||
|
||||
// Inner fill (slightly lighter)
|
||||
graphics.fill(x + 2, y + 2, x + width - 2, y + height - 2, 0xFF2D2D2D);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a small equipment panel (for armor/weapon slots).
|
||||
*
|
||||
* @param graphics GuiGraphics context
|
||||
* @param x Left position
|
||||
* @param y Top position
|
||||
* @param slots Number of slots vertically
|
||||
*/
|
||||
public static void renderEquipmentPanel(
|
||||
GuiGraphics graphics,
|
||||
int x,
|
||||
int y,
|
||||
int slots
|
||||
) {
|
||||
int width = 26;
|
||||
int height = 8 + slots * SLOT_SIZE;
|
||||
|
||||
// Background
|
||||
graphics.fill(x, y, x + width, y + height, 0xC0101010);
|
||||
|
||||
// Border
|
||||
graphics.fill(x, y, x + width, y + 1, 0xFF373737);
|
||||
graphics.fill(x, y, x + 1, y + height, 0xFF373737);
|
||||
graphics.fill(x, y + height - 1, x + width, y + height, 0xFF000000);
|
||||
graphics.fill(x + width - 1, y, x + width, y + height, 0xFF000000);
|
||||
|
||||
// Inner
|
||||
graphics.fill(x + 1, y + 1, x + width - 1, y + height - 1, 0xFF2D2D2D);
|
||||
|
||||
// Slot backgrounds
|
||||
for (int i = 0; i < slots; i++) {
|
||||
int slotX = x + 4;
|
||||
int slotY = y + 4 + i * SLOT_SIZE;
|
||||
renderSlotBackground(graphics, slotX, slotY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single slot background (18x18 with inner shadow).
|
||||
*
|
||||
* @param graphics GuiGraphics context
|
||||
* @param x Left position
|
||||
* @param y Top position
|
||||
*/
|
||||
public static void renderSlotBackground(
|
||||
GuiGraphics graphics,
|
||||
int x,
|
||||
int y
|
||||
) {
|
||||
// Outer dark border
|
||||
graphics.fill(x, y, x + SLOT_SIZE, y + SLOT_SIZE, 0xFF373737);
|
||||
// Inner lighter area
|
||||
graphics.fill(
|
||||
x + 1,
|
||||
y + 1,
|
||||
x + SLOT_SIZE - 1,
|
||||
y + SLOT_SIZE - 1,
|
||||
0xFF8B8B8B
|
||||
);
|
||||
// Slot center (dark)
|
||||
graphics.fill(
|
||||
x + 1,
|
||||
y + 1,
|
||||
x + SLOT_SIZE - 1,
|
||||
y + SLOT_SIZE - 1,
|
||||
0xFF373737
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a vanilla-style XP progress bar.
|
||||
*
|
||||
* @param graphics GuiGraphics context
|
||||
* @param x Left position
|
||||
* @param y Top position
|
||||
* @param width Bar width
|
||||
* @param progress Progress value (0.0 to 1.0)
|
||||
* @param color Fill color (ARGB)
|
||||
*/
|
||||
public static void renderProgressBar(
|
||||
GuiGraphics graphics,
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
float progress,
|
||||
int color
|
||||
) {
|
||||
// Background (black with inner dark gray)
|
||||
graphics.fill(x, y, x + width, y + 5, 0xFF000000);
|
||||
graphics.fill(x + 1, y + 1, x + width - 1, y + 4, 0xFF2E2E2E);
|
||||
|
||||
// Fill
|
||||
int fillWidth = (int) ((width - 2) *
|
||||
Math.min(1.0f, Math.max(0.0f, progress)));
|
||||
if (fillWidth > 0) {
|
||||
graphics.fill(x + 1, y + 1, x + 1 + fillWidth, y + 4, color);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a vanilla-style XP bar with green color.
|
||||
*/
|
||||
public static void renderXpBar(
|
||||
GuiGraphics graphics,
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
float progress
|
||||
) {
|
||||
renderProgressBar(graphics, x, y, width, progress, 0xFF80FF20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a horizontal separator line.
|
||||
*
|
||||
* @param graphics GuiGraphics context
|
||||
* @param x Left position
|
||||
* @param y Y position
|
||||
* @param width Line width
|
||||
*/
|
||||
public static void renderSeparator(
|
||||
GuiGraphics graphics,
|
||||
int x,
|
||||
int y,
|
||||
int width
|
||||
) {
|
||||
graphics.fill(x, y, x + width, y + 1, 0xFF373737);
|
||||
graphics.fill(x, y + 1, x + width, y + 2, 0xFF000000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package com.tiedup.remake.client.gui.widgets;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||
import com.tiedup.remake.items.GenericKnife;
|
||||
import com.tiedup.remake.items.ItemKey;
|
||||
import com.tiedup.remake.items.ItemLockpick;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.items.base.IHasResistance;
|
||||
import com.tiedup.remake.items.base.ILockable;
|
||||
import com.tiedup.remake.items.base.ItemCollar;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.action.PacketSetKnifeCutTarget;
|
||||
import com.tiedup.remake.network.minigame.PacketLockpickMiniGameStart;
|
||||
import com.tiedup.remake.network.slave.PacketMasterEquip;
|
||||
import com.tiedup.remake.network.slave.PacketSlaveItemManage;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import com.tiedup.remake.v2.bondage.network.PacketV2SelfEquip;
|
||||
import com.tiedup.remake.v2.bondage.network.PacketV2SelfLock;
|
||||
import com.tiedup.remake.v2.bondage.network.PacketV2SelfRemove;
|
||||
import com.tiedup.remake.v2.bondage.network.PacketV2SelfUnlock;
|
||||
import com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.AbstractWidget;
|
||||
import net.minecraft.client.gui.narration.NarratedElementType;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Contextual action panel for the unified bondage screen.
|
||||
* Shows different actions based on mode (SELF/MASTER) and selected item state.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ActionPanel extends AbstractWidget {
|
||||
|
||||
public enum ScreenMode { SELF, MASTER }
|
||||
|
||||
private record ActionEntry(
|
||||
String labelKey,
|
||||
boolean enabled,
|
||||
String disabledReasonKey,
|
||||
Runnable onClick
|
||||
) {}
|
||||
|
||||
// Layout
|
||||
private static final int BUTTON_WIDTH = 80;
|
||||
private static final int BUTTON_HEIGHT = 22;
|
||||
private static final int BUTTON_SPACING = 6;
|
||||
private static final int COLUMNS = 3;
|
||||
private static final int PADDING = 8;
|
||||
|
||||
// Colors (vanilla MC style)
|
||||
private static final int TEXT_ENABLED = 0xFF404040;
|
||||
private static final int TEXT_DISABLED = 0xFF909090;
|
||||
private static final int TITLE_COLOR = 0xFF404040;
|
||||
|
||||
private ScreenMode mode = ScreenMode.SELF;
|
||||
private BodyRegionV2 selectedRegion;
|
||||
private ItemStack selectedItem = ItemStack.EMPTY;
|
||||
private LivingEntity targetEntity;
|
||||
private UUID targetEntityUUID;
|
||||
private UUID keyUUID;
|
||||
private boolean isMasterKey;
|
||||
private final List<ActionEntry> actions = new ArrayList<>();
|
||||
|
||||
// Callbacks for actions that open other screens
|
||||
private Consumer<BodyRegionV2> onAdjustRequested;
|
||||
private Consumer<BodyRegionV2> onEquipRequested;
|
||||
private Runnable onCellAssignRequested;
|
||||
private Runnable onCloseRequested;
|
||||
|
||||
// Hovered button index for tooltip
|
||||
private int hoveredIndex = -1;
|
||||
|
||||
public ActionPanel(int x, int y, int width, int height) {
|
||||
super(x, y, width, height, Component.literal("Actions"));
|
||||
}
|
||||
|
||||
public void setMode(ScreenMode mode) { this.mode = mode; }
|
||||
public void setTargetEntity(LivingEntity entity) {
|
||||
this.targetEntity = entity;
|
||||
this.targetEntityUUID = entity != null ? entity.getUUID() : null;
|
||||
}
|
||||
public void setKeyInfo(UUID keyUUID, boolean isMasterKey) {
|
||||
this.keyUUID = keyUUID;
|
||||
this.isMasterKey = isMasterKey;
|
||||
}
|
||||
public void setOnAdjustRequested(Consumer<BodyRegionV2> cb) { this.onAdjustRequested = cb; }
|
||||
public void setOnEquipRequested(Consumer<BodyRegionV2> cb) { this.onEquipRequested = cb; }
|
||||
public void setOnCellAssignRequested(Runnable cb) { this.onCellAssignRequested = cb; }
|
||||
public void setOnCloseRequested(Runnable cb) { this.onCloseRequested = cb; }
|
||||
|
||||
/**
|
||||
* Update the action panel context. Call when the selected slot changes.
|
||||
*/
|
||||
public void setContext(BodyRegionV2 region, ItemStack item) {
|
||||
this.selectedRegion = region;
|
||||
this.selectedItem = item != null ? item : ItemStack.EMPTY;
|
||||
rebuildActions();
|
||||
}
|
||||
|
||||
/** Clear the selection — no slot selected. */
|
||||
public void clearContext() {
|
||||
this.selectedRegion = null;
|
||||
this.selectedItem = ItemStack.EMPTY;
|
||||
actions.clear();
|
||||
}
|
||||
|
||||
private void rebuildActions() {
|
||||
actions.clear();
|
||||
if (selectedRegion == null) return;
|
||||
|
||||
Player localPlayer = Minecraft.getInstance().player;
|
||||
if (localPlayer == null) return;
|
||||
|
||||
if (mode == ScreenMode.SELF) {
|
||||
buildSelfActions(localPlayer);
|
||||
} else {
|
||||
buildMasterActions(localPlayer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a key item in the player's inventory.
|
||||
* ItemKey.findKeyInInventory does not exist, so we implement it inline.
|
||||
*/
|
||||
private static ItemStack findKeyInInventory(Player player) {
|
||||
ItemStack masterKeyStack = ItemStack.EMPTY;
|
||||
for (int i = 0; i < player.getInventory().getContainerSize(); i++) {
|
||||
ItemStack stack = player.getInventory().getItem(i);
|
||||
if (stack.isEmpty()) continue;
|
||||
if (stack.getItem() instanceof ItemKey) {
|
||||
return stack; // Regular key takes priority
|
||||
}
|
||||
if (masterKeyStack.isEmpty() && stack.is(ModItems.MASTER_KEY.get())) {
|
||||
masterKeyStack = stack; // Remember master key as fallback
|
||||
}
|
||||
}
|
||||
return masterKeyStack; // Empty or master key
|
||||
}
|
||||
|
||||
private void buildSelfActions(Player player) {
|
||||
boolean isEmpty = selectedItem.isEmpty();
|
||||
boolean armsOccupied = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS);
|
||||
boolean handsOccupied = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.HANDS);
|
||||
boolean armsFree = !armsOccupied;
|
||||
boolean handsFree = !handsOccupied;
|
||||
|
||||
boolean isLocked = false;
|
||||
boolean isLockable = false;
|
||||
boolean isJammed = false;
|
||||
boolean hasMittens = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.HANDS);
|
||||
if (!isEmpty && selectedItem.getItem() instanceof ILockable lockable) {
|
||||
isLocked = lockable.isLocked(selectedItem);
|
||||
isLockable = lockable.isLockable(selectedItem);
|
||||
isJammed = lockable.isJammed(selectedItem);
|
||||
}
|
||||
|
||||
boolean hasLockpick = !ItemLockpick.findLockpickInInventory(player).isEmpty();
|
||||
boolean hasKnife = !GenericKnife.findKnifeInInventory(player).isEmpty();
|
||||
ItemStack foundKey = findKeyInInventory(player);
|
||||
boolean hasKey = !foundKey.isEmpty();
|
||||
boolean foundKeyIsMaster = hasKey && foundKey.is(ModItems.MASTER_KEY.get());
|
||||
|
||||
if (isEmpty) {
|
||||
// Equip action for empty slot
|
||||
actions.add(new ActionEntry("gui.tiedup.action.equip", true, null,
|
||||
() -> { if (onEquipRequested != null) onEquipRequested.accept(selectedRegion); }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove
|
||||
boolean canRemove = armsFree && handsFree && !isLocked && selectedRegion != BodyRegionV2.ARMS;
|
||||
String removeReason = isLocked ? "gui.tiedup.reason.locked" :
|
||||
!armsFree ? "gui.tiedup.reason.arms_bound" :
|
||||
!handsFree ? "gui.tiedup.reason.hands_bound" :
|
||||
selectedRegion == BodyRegionV2.ARMS ? "gui.tiedup.reason.use_struggle" : null;
|
||||
actions.add(new ActionEntry("gui.tiedup.action.remove", canRemove, removeReason,
|
||||
() -> ModNetwork.sendToServer(new PacketV2SelfRemove(selectedRegion))));
|
||||
|
||||
// Struggle (locked items only)
|
||||
if (isLocked) {
|
||||
actions.add(new ActionEntry("gui.tiedup.action.struggle", true, null,
|
||||
() -> {
|
||||
ModNetwork.sendToServer(new PacketV2StruggleStart(selectedRegion));
|
||||
if (onCloseRequested != null) onCloseRequested.run();
|
||||
}));
|
||||
}
|
||||
|
||||
// Lockpick
|
||||
if (isLocked) {
|
||||
boolean canPick = hasLockpick && !hasMittens && !isJammed;
|
||||
String pickReason = !hasLockpick ? "gui.tiedup.reason.no_lockpick" :
|
||||
hasMittens ? "gui.tiedup.reason.mittens" :
|
||||
isJammed ? "gui.tiedup.reason.jammed" : null;
|
||||
actions.add(new ActionEntry("gui.tiedup.action.lockpick", canPick, pickReason,
|
||||
() -> {
|
||||
ModNetwork.sendToServer(new PacketLockpickMiniGameStart(selectedRegion));
|
||||
if (onCloseRequested != null) onCloseRequested.run();
|
||||
}));
|
||||
}
|
||||
|
||||
// Cut
|
||||
if (isLocked) {
|
||||
boolean canCut = hasKnife && !hasMittens;
|
||||
String cutReason = !hasKnife ? "gui.tiedup.reason.no_knife" :
|
||||
hasMittens ? "gui.tiedup.reason.mittens" : null;
|
||||
actions.add(new ActionEntry("gui.tiedup.action.cut", canCut, cutReason,
|
||||
() -> {
|
||||
ModNetwork.sendToServer(new PacketSetKnifeCutTarget(selectedRegion));
|
||||
if (onCloseRequested != null) onCloseRequested.run();
|
||||
}));
|
||||
}
|
||||
|
||||
// Adjust (MOUTH, EYES only)
|
||||
if (selectedRegion == BodyRegionV2.MOUTH || selectedRegion == BodyRegionV2.EYES) {
|
||||
actions.add(new ActionEntry("gui.tiedup.action.adjust", true, null,
|
||||
() -> { if (onAdjustRequested != null) onAdjustRequested.accept(selectedRegion); }));
|
||||
}
|
||||
|
||||
// Lock (self, with key, arms free)
|
||||
if (isLockable && !isLocked) {
|
||||
boolean canLock = hasKey && armsFree;
|
||||
String lockReason = !hasKey ? "gui.tiedup.reason.no_key" :
|
||||
!armsFree ? "gui.tiedup.reason.arms_bound" : null;
|
||||
actions.add(new ActionEntry("gui.tiedup.action.lock", canLock, lockReason,
|
||||
() -> ModNetwork.sendToServer(new PacketV2SelfLock(selectedRegion))));
|
||||
}
|
||||
|
||||
// Unlock (self, with matching key, arms free)
|
||||
if (isLocked) {
|
||||
boolean keyMatches = false;
|
||||
if (hasKey && selectedItem.getItem() instanceof ILockable lockable) {
|
||||
if (foundKeyIsMaster) {
|
||||
keyMatches = true; // Master key matches all locks
|
||||
} else if (foundKey.getItem() instanceof ItemKey itemKey) {
|
||||
UUID lockKeyUUID = lockable.getLockedByKeyUUID(selectedItem);
|
||||
UUID foundKeyUUID = itemKey.getKeyUUID(foundKey);
|
||||
keyMatches = lockKeyUUID == null || lockKeyUUID.equals(foundKeyUUID);
|
||||
}
|
||||
}
|
||||
boolean canUnlock = hasKey && armsFree && keyMatches;
|
||||
String unlockReason = !hasKey ? "gui.tiedup.reason.no_key" :
|
||||
!armsFree ? "gui.tiedup.reason.arms_bound" :
|
||||
!keyMatches ? "gui.tiedup.reason.wrong_key" : null;
|
||||
actions.add(new ActionEntry("gui.tiedup.action.unlock", canUnlock, unlockReason,
|
||||
() -> ModNetwork.sendToServer(new PacketV2SelfUnlock(selectedRegion))));
|
||||
}
|
||||
}
|
||||
|
||||
private void buildMasterActions(Player master) {
|
||||
boolean isEmpty = selectedItem.isEmpty();
|
||||
|
||||
boolean isLocked = false;
|
||||
boolean isLockable = false;
|
||||
if (!isEmpty && selectedItem.getItem() instanceof ILockable lockable) {
|
||||
isLocked = lockable.isLocked(selectedItem);
|
||||
isLockable = lockable.isLockable(selectedItem);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
actions.add(new ActionEntry("gui.tiedup.action.equip", true, null,
|
||||
() -> { if (onEquipRequested != null) onEquipRequested.accept(selectedRegion); }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove
|
||||
actions.add(new ActionEntry("gui.tiedup.action.remove", !isLocked,
|
||||
isLocked ? "gui.tiedup.reason.locked" : null,
|
||||
() -> ModNetwork.sendToServer(new PacketSlaveItemManage(
|
||||
targetEntityUUID, selectedRegion, PacketSlaveItemManage.Action.REMOVE, keyUUID, isMasterKey))));
|
||||
|
||||
// Lock
|
||||
if (isLockable && !isLocked) {
|
||||
actions.add(new ActionEntry("gui.tiedup.action.lock", true, null,
|
||||
() -> ModNetwork.sendToServer(new PacketSlaveItemManage(
|
||||
targetEntityUUID, selectedRegion, PacketSlaveItemManage.Action.LOCK, keyUUID, isMasterKey))));
|
||||
}
|
||||
|
||||
// Unlock
|
||||
if (isLocked) {
|
||||
actions.add(new ActionEntry("gui.tiedup.action.unlock", true, null,
|
||||
() -> ModNetwork.sendToServer(new PacketSlaveItemManage(
|
||||
targetEntityUUID, selectedRegion, PacketSlaveItemManage.Action.UNLOCK, keyUUID, isMasterKey))));
|
||||
}
|
||||
|
||||
// Adjust (MOUTH, EYES)
|
||||
if (selectedRegion == BodyRegionV2.MOUTH || selectedRegion == BodyRegionV2.EYES) {
|
||||
actions.add(new ActionEntry("gui.tiedup.action.adjust", true, null,
|
||||
() -> { if (onAdjustRequested != null) onAdjustRequested.accept(selectedRegion); }));
|
||||
}
|
||||
|
||||
// Bondage Service toggle (NECK collar only, prison configured)
|
||||
if (selectedRegion == BodyRegionV2.NECK && selectedItem.getItem() instanceof ItemCollar collar) {
|
||||
if (collar.hasCellAssigned(selectedItem)) {
|
||||
boolean svcEnabled = collar.isBondageServiceEnabled(selectedItem);
|
||||
String svcKey = svcEnabled ? "gui.tiedup.action.svc_off" : "gui.tiedup.action.svc_on";
|
||||
actions.add(new ActionEntry(svcKey, true, null,
|
||||
() -> ModNetwork.sendToServer(new PacketSlaveItemManage(
|
||||
targetEntityUUID, selectedRegion,
|
||||
PacketSlaveItemManage.Action.TOGGLE_BONDAGE_SERVICE, keyUUID, isMasterKey))));
|
||||
}
|
||||
// Cell assign
|
||||
actions.add(new ActionEntry("gui.tiedup.action.cell_assign", true, null,
|
||||
() -> { if (onCellAssignRequested != null) onCellAssignRequested.run(); }));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
// MC-style sunken panel background
|
||||
GuiRenderUtil.drawMCSunkenPanel(graphics, getX(), getY(), width, height);
|
||||
|
||||
// Title
|
||||
String title;
|
||||
if (selectedRegion == null) {
|
||||
title = Component.translatable("gui.tiedup.action.no_selection").getString();
|
||||
} else if (selectedItem.isEmpty()) {
|
||||
title = Component.translatable("gui.tiedup.action.title_empty",
|
||||
Component.translatable("gui.tiedup.region." + selectedRegion.name().toLowerCase())).getString();
|
||||
} else {
|
||||
title = (mode == ScreenMode.MASTER ? "\u265B " : "") + selectedItem.getHoverName().getString();
|
||||
}
|
||||
graphics.drawString(mc.font, title, getX() + PADDING, getY() + PADDING, TITLE_COLOR, false);
|
||||
|
||||
// Action buttons grid (MC-style buttons)
|
||||
hoveredIndex = -1;
|
||||
int startY = getY() + PADDING + mc.font.lineHeight + 6;
|
||||
int startX = getX() + PADDING;
|
||||
|
||||
for (int i = 0; i < actions.size(); i++) {
|
||||
ActionEntry action = actions.get(i);
|
||||
int col = i % COLUMNS;
|
||||
int row = i / COLUMNS;
|
||||
int btnX = startX + col * (BUTTON_WIDTH + BUTTON_SPACING);
|
||||
int btnY = startY + row * (BUTTON_HEIGHT + 4);
|
||||
|
||||
boolean hovered = mouseX >= btnX && mouseX < btnX + BUTTON_WIDTH
|
||||
&& mouseY >= btnY && mouseY < btnY + BUTTON_HEIGHT;
|
||||
if (hovered) hoveredIndex = i;
|
||||
|
||||
int textColor = action.enabled() ? TEXT_ENABLED : TEXT_DISABLED;
|
||||
GuiRenderUtil.drawMCButton(graphics, btnX, btnY, BUTTON_WIDTH, BUTTON_HEIGHT, hovered, action.enabled());
|
||||
|
||||
String label = Component.translatable(action.labelKey()).getString();
|
||||
int textX = btnX + (BUTTON_WIDTH - mc.font.width(label)) / 2;
|
||||
int textY = btnY + (BUTTON_HEIGHT - mc.font.lineHeight) / 2;
|
||||
graphics.drawString(mc.font, label, textX, textY, textColor, false);
|
||||
}
|
||||
|
||||
// Tooltip for disabled button
|
||||
if (hoveredIndex >= 0 && hoveredIndex < actions.size()) {
|
||||
ActionEntry hoverAction = actions.get(hoveredIndex);
|
||||
if (!hoverAction.enabled() && hoverAction.disabledReasonKey() != null) {
|
||||
graphics.renderTooltip(mc.font,
|
||||
Component.translatable(hoverAction.disabledReasonKey()),
|
||||
mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (!isMouseOver(mouseX, mouseY) || button != 0) return false;
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
int startY = getY() + PADDING + mc.font.lineHeight + 6;
|
||||
int startX = getX() + PADDING;
|
||||
|
||||
for (int i = 0; i < actions.size(); i++) {
|
||||
ActionEntry action = actions.get(i);
|
||||
int col = i % COLUMNS;
|
||||
int row = i / COLUMNS;
|
||||
int btnX = startX + col * (BUTTON_WIDTH + BUTTON_SPACING);
|
||||
int btnY = startY + row * (BUTTON_HEIGHT + 4);
|
||||
|
||||
if (mouseX >= btnX && mouseX < btnX + BUTTON_WIDTH
|
||||
&& mouseY >= btnY && mouseY < btnY + BUTTON_HEIGHT) {
|
||||
if (action.enabled() && action.onClick() != null) {
|
||||
action.onClick().run();
|
||||
playDownSound(mc.getSoundManager());
|
||||
return true;
|
||||
}
|
||||
return false; // Clicked disabled button
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateWidgetNarration(NarrationElementOutput output) {
|
||||
output.add(NarratedElementType.TITLE, Component.translatable("gui.tiedup.action_panel"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package com.tiedup.remake.client.gui.widgets;
|
||||
|
||||
import static com.tiedup.remake.client.gui.util.GuiLayoutConstants.*;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.AbstractWidget;
|
||||
import net.minecraft.client.gui.narration.NarratedElementType;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Vertical slider widget for adjusting Y position of items.
|
||||
* Displays current value and supports mouse drag and scroll wheel.
|
||||
*
|
||||
* Phase 16b: GUI Refactoring - Fixed alignment and layout
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class AdjustmentSlider extends AbstractWidget {
|
||||
|
||||
private float minValue;
|
||||
private float maxValue;
|
||||
private float value;
|
||||
private float step;
|
||||
private Consumer<Float> onChange;
|
||||
private boolean dragging = false;
|
||||
|
||||
// Visual settings
|
||||
private int trackColor = GuiColors.BG_LIGHT;
|
||||
private int thumbColor = GuiColors.ACCENT_BROWN;
|
||||
private int thumbHighlightColor = GuiColors.ACCENT_TAN;
|
||||
private int textColor = GuiColors.TEXT_WHITE;
|
||||
private int labelColor = GuiColors.TEXT_GRAY;
|
||||
|
||||
// Layout constants - use centralized values
|
||||
private static final int THUMB_WIDTH =
|
||||
GuiLayoutConstants.SLIDER_THUMB_WIDTH; // 8px
|
||||
private static final int THUMB_HEIGHT =
|
||||
GuiLayoutConstants.SLIDER_THUMB_HEIGHT; // 20px
|
||||
private static final int TRACK_WIDTH =
|
||||
GuiLayoutConstants.SLIDER_TRACK_WIDTH; // 4px
|
||||
|
||||
// Space reserved for labels (top and bottom)
|
||||
private static final int LABEL_SPACE = 14;
|
||||
|
||||
/**
|
||||
* Create a new adjustment slider.
|
||||
*
|
||||
* @param x X position
|
||||
* @param y Y position
|
||||
* @param width Widget width
|
||||
* @param height Widget height (includes label space at top and bottom)
|
||||
* @param min Minimum value
|
||||
* @param max Maximum value
|
||||
* @param initial Initial value
|
||||
* @param onChange Callback when value changes
|
||||
*/
|
||||
public AdjustmentSlider(
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
int height,
|
||||
float min,
|
||||
float max,
|
||||
float initial,
|
||||
Consumer<Float> onChange
|
||||
) {
|
||||
super(x, y, width, height, Component.empty());
|
||||
this.minValue = min;
|
||||
this.maxValue = max;
|
||||
this.value = Mth.clamp(initial, min, max);
|
||||
this.step = 0.25f;
|
||||
this.onChange = onChange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the step size for scroll wheel and button adjustments.
|
||||
*/
|
||||
public void setStep(float step) {
|
||||
this.step = step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current value.
|
||||
*/
|
||||
public float getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current value (clamped to range).
|
||||
*/
|
||||
public void setValue(float newValue) {
|
||||
float oldValue = this.value;
|
||||
this.value = Mth.clamp(newValue, minValue, maxValue);
|
||||
|
||||
if (this.value != oldValue && onChange != null) {
|
||||
onChange.accept(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increase value by step.
|
||||
*/
|
||||
public void increment() {
|
||||
setValue(value + step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrease value by step.
|
||||
*/
|
||||
public void decrement() {
|
||||
setValue(value - step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to center (0.0).
|
||||
*/
|
||||
public void reset() {
|
||||
setValue(0.0f);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderWidget(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
// Calculate center X for all elements (ensures perfect alignment)
|
||||
int centerX = getX() + width / 2;
|
||||
|
||||
// Track area (excluding label space at top and bottom)
|
||||
int trackY = getY() + LABEL_SPACE;
|
||||
int trackHeight =
|
||||
height - LABEL_SPACE * 2 - GuiLayoutConstants.LINE_HEIGHT; // Leave room for value text
|
||||
|
||||
// Draw track background (centered)
|
||||
int trackX = centerX - TRACK_WIDTH / 2;
|
||||
graphics.fill(
|
||||
trackX,
|
||||
trackY,
|
||||
trackX + TRACK_WIDTH,
|
||||
trackY + trackHeight,
|
||||
trackColor
|
||||
);
|
||||
|
||||
// Track border for visibility
|
||||
graphics.fill(
|
||||
trackX - 1,
|
||||
trackY,
|
||||
trackX,
|
||||
trackY + trackHeight,
|
||||
GuiColors.darken(trackColor, 0.3f)
|
||||
);
|
||||
graphics.fill(
|
||||
trackX + TRACK_WIDTH,
|
||||
trackY,
|
||||
trackX + TRACK_WIDTH + 1,
|
||||
trackY + trackHeight,
|
||||
GuiColors.darken(trackColor, 0.3f)
|
||||
);
|
||||
|
||||
// Draw center line marker (zero point)
|
||||
if (minValue < 0 && maxValue > 0) {
|
||||
float zeroNormalized = (0 - minValue) / (maxValue - minValue);
|
||||
int zeroY =
|
||||
trackY +
|
||||
(int) ((1 - zeroNormalized) * (trackHeight - THUMB_HEIGHT)) +
|
||||
THUMB_HEIGHT / 2;
|
||||
graphics.fill(
|
||||
centerX - THUMB_WIDTH / 2 - 2,
|
||||
zeroY - 1,
|
||||
centerX + THUMB_WIDTH / 2 + 2,
|
||||
zeroY + 1,
|
||||
GuiColors.BORDER_LIGHT
|
||||
);
|
||||
}
|
||||
|
||||
// Thumb position (inverted because Y+ is down in screen space)
|
||||
float normalized = (value - minValue) / (maxValue - minValue);
|
||||
int thumbY =
|
||||
trackY + (int) ((1 - normalized) * (trackHeight - THUMB_HEIGHT));
|
||||
int thumbX = centerX - THUMB_WIDTH / 2; // Centered on same axis as track
|
||||
|
||||
// Draw thumb
|
||||
boolean hovered = isMouseOver(mouseX, mouseY);
|
||||
int currentThumbColor = (dragging || hovered)
|
||||
? thumbHighlightColor
|
||||
: thumbColor;
|
||||
|
||||
// Thumb shadow
|
||||
graphics.fill(
|
||||
thumbX + 1,
|
||||
thumbY + 1,
|
||||
thumbX + THUMB_WIDTH + 1,
|
||||
thumbY + THUMB_HEIGHT + 1,
|
||||
GuiColors.darken(currentThumbColor, 0.5f)
|
||||
);
|
||||
|
||||
// Thumb main body
|
||||
graphics.fill(
|
||||
thumbX,
|
||||
thumbY,
|
||||
thumbX + THUMB_WIDTH,
|
||||
thumbY + THUMB_HEIGHT,
|
||||
currentThumbColor
|
||||
);
|
||||
|
||||
// Thumb highlight edge (top)
|
||||
graphics.fill(
|
||||
thumbX,
|
||||
thumbY,
|
||||
thumbX + THUMB_WIDTH,
|
||||
thumbY + 1,
|
||||
GuiColors.lighten(currentThumbColor, 0.3f)
|
||||
);
|
||||
|
||||
// Thumb center grip lines
|
||||
int gripY = thumbY + THUMB_HEIGHT / 2;
|
||||
graphics.fill(
|
||||
thumbX + 2,
|
||||
gripY - 2,
|
||||
thumbX + THUMB_WIDTH - 2,
|
||||
gripY - 1,
|
||||
GuiColors.darken(currentThumbColor, 0.2f)
|
||||
);
|
||||
graphics.fill(
|
||||
thumbX + 2,
|
||||
gripY + 1,
|
||||
thumbX + THUMB_WIDTH - 2,
|
||||
gripY + 2,
|
||||
GuiColors.darken(currentThumbColor, 0.2f)
|
||||
);
|
||||
|
||||
// Draw max label at top (within widget bounds)
|
||||
String maxStr = String.format("+%.1f", maxValue);
|
||||
int maxTextWidth = mc.font.width(maxStr);
|
||||
graphics.drawString(
|
||||
mc.font,
|
||||
maxStr,
|
||||
centerX - maxTextWidth / 2,
|
||||
getY() + 2,
|
||||
labelColor
|
||||
);
|
||||
|
||||
// Draw min label at bottom (within widget bounds)
|
||||
String minStr = String.format("%.1f", minValue);
|
||||
int minTextWidth = mc.font.width(minStr);
|
||||
graphics.drawString(
|
||||
mc.font,
|
||||
minStr,
|
||||
centerX - minTextWidth / 2,
|
||||
trackY + trackHeight + 2,
|
||||
labelColor
|
||||
);
|
||||
|
||||
// Draw current value text (below min label)
|
||||
String valueStr = String.format("%.2f", value);
|
||||
int valueTextWidth = mc.font.width(valueStr);
|
||||
int valueColor =
|
||||
Math.abs(value) < 0.01f ? GuiColors.TEXT_GRAY : textColor;
|
||||
graphics.drawString(
|
||||
mc.font,
|
||||
valueStr,
|
||||
centerX - valueTextWidth / 2,
|
||||
getY() + height - GuiLayoutConstants.LINE_HEIGHT,
|
||||
valueColor
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (isMouseOver(mouseX, mouseY) && button == 0) {
|
||||
dragging = true;
|
||||
updateValueFromMouse(mouseY);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
if (button == 0) {
|
||||
dragging = false;
|
||||
}
|
||||
return super.mouseReleased(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseDragged(
|
||||
double mouseX,
|
||||
double mouseY,
|
||||
int button,
|
||||
double dragX,
|
||||
double dragY
|
||||
) {
|
||||
if (dragging && button == 0) {
|
||||
updateValueFromMouse(mouseY);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
|
||||
if (isMouseOver(mouseX, mouseY)) {
|
||||
// Scroll up = increase value (positive delta)
|
||||
setValue(value + (float) delta * step);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update value based on mouse Y position.
|
||||
*/
|
||||
private void updateValueFromMouse(double mouseY) {
|
||||
// Calculate track area
|
||||
int trackY = getY() + LABEL_SPACE;
|
||||
int trackHeight =
|
||||
height - LABEL_SPACE * 2 - GuiLayoutConstants.LINE_HEIGHT;
|
||||
|
||||
// Account for thumb height
|
||||
float usableHeight = trackHeight - THUMB_HEIGHT;
|
||||
float relativeY = (float) (mouseY - trackY - THUMB_HEIGHT / 2.0);
|
||||
|
||||
// Invert because screen Y increases downward
|
||||
float normalized = 1 - Mth.clamp(relativeY / usableHeight, 0, 1);
|
||||
|
||||
float newValue = minValue + normalized * (maxValue - minValue);
|
||||
|
||||
// Optionally snap to step
|
||||
newValue = Math.round(newValue / step) * step;
|
||||
|
||||
setValue(newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recommended widget height including labels.
|
||||
* Use this when creating the slider to ensure proper sizing.
|
||||
*/
|
||||
public static int getRecommendedHeight(int trackHeight) {
|
||||
return trackHeight + LABEL_SPACE * 2 + GuiLayoutConstants.LINE_HEIGHT;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateWidgetNarration(NarrationElementOutput output) {
|
||||
output.add(
|
||||
NarratedElementType.TITLE,
|
||||
Component.translatable("gui.tiedup.adjustment_slider", value)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.tiedup.remake.client.gui.widgets;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.tiedup.remake.bounty.Bounty;
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.Font;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.ContainerObjectSelectionList;
|
||||
import net.minecraft.client.gui.components.events.GuiEventListener;
|
||||
import net.minecraft.client.gui.narration.NarratableEntry;
|
||||
import net.minecraft.client.gui.narration.NarratedElementType;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* List Entry for BountyListScreen.
|
||||
* Displays bounty details (target, reward, time).
|
||||
* Refactored to be a ContainerObjectSelectionList.Entry.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class BountyEntryWidget
|
||||
extends ContainerObjectSelectionList.Entry<BountyEntryWidget>
|
||||
{
|
||||
|
||||
private final Bounty bounty;
|
||||
private final Consumer<BountyEntryWidget> onSelect;
|
||||
private boolean selected = false;
|
||||
|
||||
public BountyEntryWidget(
|
||||
Bounty bounty,
|
||||
Consumer<BountyEntryWidget> onSelect
|
||||
) {
|
||||
this.bounty = bounty;
|
||||
this.onSelect = onSelect;
|
||||
}
|
||||
|
||||
public Bounty getBounty() {
|
||||
return bounty;
|
||||
}
|
||||
|
||||
public void setSelected(boolean selected) {
|
||||
this.selected = selected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int index,
|
||||
int top,
|
||||
int left,
|
||||
int width,
|
||||
int height,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
boolean hovering,
|
||||
float partialTick
|
||||
) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
Font font = mc.font;
|
||||
|
||||
// Background
|
||||
int bgColor = selected
|
||||
? GuiColors.ACCENT_BROWN
|
||||
: (hovering ? GuiColors.BG_LIGHT : GuiColors.BG_DARK);
|
||||
graphics.fill(left, top, left + width, top + height, bgColor);
|
||||
|
||||
// Border
|
||||
int borderColor = selected
|
||||
? GuiColors.ACCENT_TAN
|
||||
: GuiColors.BORDER_LIGHT;
|
||||
graphics.fill(
|
||||
left,
|
||||
top + height - 1,
|
||||
left + width,
|
||||
top + height,
|
||||
borderColor
|
||||
); // Bottom separator
|
||||
|
||||
// === LEFT: Reward item icon ===
|
||||
int iconX = left + GuiLayoutConstants.MARGIN_S;
|
||||
int iconY = top + (height - 16) / 2;
|
||||
ItemStack reward = bounty.getReward();
|
||||
if (!reward.isEmpty()) {
|
||||
graphics.renderItem(reward, iconX, iconY);
|
||||
graphics.renderItemDecorations(font, reward, iconX, iconY);
|
||||
}
|
||||
|
||||
// === CENTER: Bounty info ===
|
||||
int textX =
|
||||
left +
|
||||
GuiLayoutConstants.MARGIN_S +
|
||||
20 +
|
||||
GuiLayoutConstants.MARGIN_M;
|
||||
int textY = top + GuiLayoutConstants.MARGIN_S;
|
||||
|
||||
// Line 1: Client
|
||||
String clientLabel = Component.translatable(
|
||||
"gui.tiedup.bounties.client",
|
||||
bounty.getClientName()
|
||||
).getString();
|
||||
graphics.drawString(
|
||||
font,
|
||||
clientLabel,
|
||||
textX,
|
||||
textY,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
|
||||
// Line 2: Target (highlighted)
|
||||
textY += GuiLayoutConstants.LINE_HEIGHT + 2;
|
||||
String targetLabel = Component.translatable(
|
||||
"gui.tiedup.bounties.target",
|
||||
bounty.getTargetName()
|
||||
).getString();
|
||||
graphics.drawString(
|
||||
font,
|
||||
targetLabel,
|
||||
textX,
|
||||
textY,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
|
||||
// Line 3: Reward description
|
||||
textY += GuiLayoutConstants.LINE_HEIGHT + 2;
|
||||
String rewardLabel = Component.translatable(
|
||||
"gui.tiedup.bounties.reward",
|
||||
bounty.getRewardDescription()
|
||||
).getString();
|
||||
graphics.drawString(
|
||||
font,
|
||||
rewardLabel,
|
||||
textX,
|
||||
textY,
|
||||
GuiColors.ACCENT_TAN
|
||||
);
|
||||
|
||||
// Line 4: Time remaining
|
||||
textY += GuiLayoutConstants.LINE_HEIGHT + 2;
|
||||
int[] time = bounty.getRemainingTime();
|
||||
String timeLabel = Component.translatable(
|
||||
"gui.tiedup.bounties.time",
|
||||
String.valueOf(time[0]),
|
||||
String.valueOf(time[1])
|
||||
).getString();
|
||||
int timeColor = bounty.isExpired()
|
||||
? GuiColors.ERROR
|
||||
: (time[0] == 0 && time[1] < 30
|
||||
? GuiColors.WARNING
|
||||
: GuiColors.TEXT_GRAY);
|
||||
graphics.drawString(font, timeLabel, textX, textY, timeColor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (button == 0) {
|
||||
if (onSelect != null) {
|
||||
onSelect.accept(this);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<? extends GuiEventListener> children() {
|
||||
return ImmutableList.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<? extends NarratableEntry> narratables() {
|
||||
return ImmutableList.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.tiedup.remake.client.gui.widgets;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.client.gui.Font;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Shared rendering utilities for cell list screens (CellManagerScreen, CellSelectorScreen).
|
||||
* Extracts duplicated rendering logic for empty states and count badges.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class CellListRenderer {
|
||||
|
||||
private CellListRenderer() {
|
||||
// Utility class — no instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a two-line empty state message centered in the list area.
|
||||
*
|
||||
* @param graphics the graphics context
|
||||
* @param font the font renderer
|
||||
* @param centerX horizontal center of the list area
|
||||
* @param startY vertical start of the list area
|
||||
* @param noItemsKey translation key for the "no items" message (line 1)
|
||||
* @param hintKey translation key for the hint message (line 2, italic)
|
||||
*/
|
||||
public static void renderEmptyState(
|
||||
GuiGraphics graphics,
|
||||
Font font,
|
||||
int centerX,
|
||||
int startY,
|
||||
String noItemsKey,
|
||||
String hintKey
|
||||
) {
|
||||
graphics.drawCenteredString(
|
||||
font,
|
||||
Component.translatable(noItemsKey)
|
||||
.withStyle(ChatFormatting.GRAY),
|
||||
centerX,
|
||||
startY + 20,
|
||||
GuiColors.TEXT_DISABLED
|
||||
);
|
||||
graphics.drawCenteredString(
|
||||
font,
|
||||
Component.translatable(hintKey)
|
||||
.withStyle(ChatFormatting.ITALIC),
|
||||
centerX,
|
||||
startY + 35,
|
||||
GuiColors.TEXT_DISABLED
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a {@code [count/max]} badge right-aligned at the given position.
|
||||
* Uses {@link GuiColors#WARNING} color when count reaches max capacity.
|
||||
*
|
||||
* @param graphics the graphics context
|
||||
* @param font the font renderer
|
||||
* @param count current prisoner count
|
||||
* @param max maximum prisoner capacity
|
||||
* @param rightX right edge X coordinate (badge is right-aligned to this minus 8px)
|
||||
* @param y Y coordinate for the text
|
||||
*/
|
||||
public static void renderCountBadge(
|
||||
GuiGraphics graphics,
|
||||
Font font,
|
||||
int count,
|
||||
int max,
|
||||
int rightX,
|
||||
int y
|
||||
) {
|
||||
String countStr = "[" + count + "/" + max + "]";
|
||||
int color = count >= max ? GuiColors.WARNING : GuiColors.TEXT_GRAY;
|
||||
graphics.drawString(
|
||||
font,
|
||||
countStr,
|
||||
rightX - font.width(countStr) - 8,
|
||||
y,
|
||||
color
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package com.tiedup.remake.client.gui.widgets;
|
||||
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.AbstractWidget;
|
||||
import net.minecraft.client.gui.narration.NarratedElementType;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.joml.Quaternionf;
|
||||
|
||||
/**
|
||||
* Widget that displays a 3D preview of a LivingEntity.
|
||||
* Supports mouse drag rotation and optional auto-rotation.
|
||||
*
|
||||
* Phase 16: GUI Revamp - Reusable entity preview widget
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class EntityPreviewWidget extends AbstractWidget {
|
||||
|
||||
private LivingEntity entity;
|
||||
private float rotationY = 0;
|
||||
private float rotationX = 0;
|
||||
private boolean isDragging = false;
|
||||
private double lastMouseX, lastMouseY;
|
||||
|
||||
// Auto-rotation
|
||||
private boolean autoRotate = false;
|
||||
private float autoRotateSpeed = 0.5f;
|
||||
|
||||
// Zoom target (for per-tab camera zoom)
|
||||
private float targetScale = 1.0f;
|
||||
private float targetOffsetY = 0.0f;
|
||||
private float currentScale = 1.0f;
|
||||
private float currentOffsetY = 0.0f;
|
||||
private static final float LERP_SPEED = 0.35f; // ~300ms to settle
|
||||
|
||||
// Visual settings
|
||||
private boolean drawBackground = true;
|
||||
private int backgroundColor = GuiColors.BG_DARK;
|
||||
private boolean drawBorder = true;
|
||||
private int borderColor = GuiColors.BORDER_LIGHT;
|
||||
|
||||
/**
|
||||
* Create a new entity preview widget.
|
||||
*
|
||||
* @param x X position
|
||||
* @param y Y position
|
||||
* @param width Widget width
|
||||
* @param height Widget height
|
||||
* @param entity Entity to display
|
||||
*/
|
||||
public EntityPreviewWidget(
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
int height,
|
||||
LivingEntity entity
|
||||
) {
|
||||
super(x, y, width, height, Component.empty());
|
||||
this.entity = entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the entity to display.
|
||||
*/
|
||||
public void setEntity(LivingEntity entity) {
|
||||
this.entity = entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current entity.
|
||||
*/
|
||||
public LivingEntity getEntity() {
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable auto-rotation.
|
||||
*/
|
||||
public void setAutoRotate(boolean autoRotate) {
|
||||
this.autoRotate = autoRotate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auto-rotation speed (degrees per frame).
|
||||
*/
|
||||
public void setAutoRotateSpeed(float speed) {
|
||||
this.autoRotateSpeed = speed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable background drawing.
|
||||
*/
|
||||
public void setDrawBackground(boolean drawBackground) {
|
||||
this.drawBackground = drawBackground;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set background color.
|
||||
*/
|
||||
public void setBackgroundColor(int color) {
|
||||
this.backgroundColor = color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable border drawing.
|
||||
*/
|
||||
public void setDrawBorder(boolean drawBorder) {
|
||||
this.drawBorder = drawBorder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set rotation angles directly.
|
||||
*/
|
||||
public void setRotation(float yaw, float pitch) {
|
||||
this.rotationY = yaw;
|
||||
this.rotationX = Mth.clamp(pitch, -30, 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rotation to default (facing forward).
|
||||
*/
|
||||
public void resetRotation() {
|
||||
this.rotationY = 0;
|
||||
this.rotationX = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the preview (call after entity equipment changes).
|
||||
*/
|
||||
public void refresh() {
|
||||
// The entity's equipment is read directly during render,
|
||||
// so no special action needed here. This method exists
|
||||
// for API consistency.
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the zoom target for animated transition.
|
||||
* @param scale Scale multiplier (1.0 = full body, 1.8 = face zoom)
|
||||
* @param offsetY Vertical offset (-0.6 = look up at head, +0.4 = look down at legs)
|
||||
*/
|
||||
public void setZoomTarget(float scale, float offsetY) {
|
||||
this.targetScale = scale;
|
||||
this.targetOffsetY = offsetY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tick the zoom lerp animation. Call each frame from the parent screen.
|
||||
*/
|
||||
public void tickZoom() {
|
||||
currentScale += (targetScale - currentScale) * LERP_SPEED;
|
||||
currentOffsetY += (targetOffsetY - currentOffsetY) * LERP_SPEED;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderWidget(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
if (entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-rotation
|
||||
if (autoRotate && !isDragging) {
|
||||
rotationY += autoRotateSpeed;
|
||||
}
|
||||
|
||||
// Background
|
||||
if (drawBackground) {
|
||||
graphics.fill(
|
||||
getX(),
|
||||
getY(),
|
||||
getX() + width,
|
||||
getY() + height,
|
||||
backgroundColor
|
||||
);
|
||||
}
|
||||
|
||||
// Border
|
||||
if (drawBorder) {
|
||||
// Top
|
||||
graphics.fill(
|
||||
getX(),
|
||||
getY(),
|
||||
getX() + width,
|
||||
getY() + 1,
|
||||
borderColor
|
||||
);
|
||||
// Bottom
|
||||
graphics.fill(
|
||||
getX(),
|
||||
getY() + height - 1,
|
||||
getX() + width,
|
||||
getY() + height,
|
||||
borderColor
|
||||
);
|
||||
// Left
|
||||
graphics.fill(
|
||||
getX(),
|
||||
getY(),
|
||||
getX() + 1,
|
||||
getY() + height,
|
||||
borderColor
|
||||
);
|
||||
// Right
|
||||
graphics.fill(
|
||||
getX() + width - 1,
|
||||
getY(),
|
||||
getX() + width,
|
||||
getY() + height,
|
||||
borderColor
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate entity render position — feet at widget bottom, fill height
|
||||
int centerX = getX() + width / 2;
|
||||
int entityScale = (int) (height / 1.95f);
|
||||
int centerY = getY() + height - 4;
|
||||
|
||||
// Enable scissor to clip entity to widget bounds
|
||||
RenderSystem.enableScissor(
|
||||
(int) (getX() * minecraft.getWindow().getGuiScale()),
|
||||
(int) ((minecraft.getWindow().getGuiScaledHeight() -
|
||||
getY() -
|
||||
height) *
|
||||
minecraft.getWindow().getGuiScale()),
|
||||
(int) (width * minecraft.getWindow().getGuiScale()),
|
||||
(int) (height * minecraft.getWindow().getGuiScale())
|
||||
);
|
||||
|
||||
// Save original entity rotations
|
||||
float origBodyRot = entity.yBodyRot;
|
||||
float origHeadRot = entity.yHeadRot;
|
||||
float origYRot = entity.getYRot();
|
||||
float origXRot = entity.getXRot();
|
||||
float origYHeadRotO = entity.yHeadRotO;
|
||||
float origYBodyRotO = entity.yBodyRotO;
|
||||
float origXRotO = entity.xRotO;
|
||||
|
||||
// Set neutral pose (facing forward, not looking up/down)
|
||||
entity.yBodyRot = 180f + rotationY; // Face the camera + user rotation
|
||||
entity.yHeadRot = 180f + rotationY; // Head same as body
|
||||
entity.setYRot(180f + rotationY);
|
||||
entity.setXRot(rotationX); // User-controlled pitch
|
||||
entity.yHeadRotO = entity.yHeadRot;
|
||||
entity.yBodyRotO = entity.yBodyRot;
|
||||
entity.xRotO = entity.getXRot(); // Prevent pitch interpolation
|
||||
|
||||
// Render entity using renderEntityInInventory with Quaternionf
|
||||
// X rotation flips upright, Y rotation faces camera
|
||||
Quaternionf poseRotation = new Quaternionf()
|
||||
.rotationX((float) Math.PI) // Flip upright
|
||||
.rotateY((float) Math.PI); // Face camera
|
||||
Quaternionf cameraRotation = new Quaternionf();
|
||||
|
||||
InventoryScreen.renderEntityInInventory(
|
||||
graphics,
|
||||
centerX,
|
||||
centerY,
|
||||
entityScale,
|
||||
poseRotation,
|
||||
cameraRotation,
|
||||
entity
|
||||
);
|
||||
|
||||
// Restore original rotations
|
||||
entity.yBodyRot = origBodyRot;
|
||||
entity.yHeadRot = origHeadRot;
|
||||
entity.setYRot(origYRot);
|
||||
entity.setXRot(origXRot);
|
||||
entity.yHeadRotO = origYHeadRotO;
|
||||
entity.yBodyRotO = origYBodyRotO;
|
||||
entity.xRotO = origXRotO;
|
||||
|
||||
RenderSystem.disableScissor();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (isMouseOver(mouseX, mouseY) && button == 0) {
|
||||
isDragging = true;
|
||||
lastMouseX = mouseX;
|
||||
lastMouseY = mouseY;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
if (button == 0) {
|
||||
isDragging = false;
|
||||
}
|
||||
return super.mouseReleased(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseDragged(
|
||||
double mouseX,
|
||||
double mouseY,
|
||||
int button,
|
||||
double dragX,
|
||||
double dragY
|
||||
) {
|
||||
if (isDragging && button == 0) {
|
||||
rotationY += (float) (mouseX - lastMouseX) * 0.8f;
|
||||
rotationX = Mth.clamp(
|
||||
rotationX + (float) (mouseY - lastMouseY) * 0.5f,
|
||||
-30,
|
||||
30
|
||||
);
|
||||
lastMouseX = mouseX;
|
||||
lastMouseY = mouseY;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
|
||||
if (isMouseOver(mouseX, mouseY)) {
|
||||
// Could be used for zoom in the future
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateWidgetNarration(NarrationElementOutput output) {
|
||||
// Narration for accessibility
|
||||
if (entity != null) {
|
||||
output.add(
|
||||
NarratedElementType.TITLE,
|
||||
Component.translatable("gui.tiedup.preview", entity.getName())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Minecraft instance (from AbstractWidget).
|
||||
*/
|
||||
private net.minecraft.client.Minecraft minecraft =
|
||||
net.minecraft.client.Minecraft.getInstance();
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.tiedup.remake.client.gui.widgets;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageItem;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.AbstractWidget;
|
||||
import net.minecraft.client.gui.narration.NarratedElementType;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Modal overlay for selecting a bondage item to equip on a body region.
|
||||
* Filters the player's inventory for compatible IV2BondageItem instances.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ItemPickerOverlay extends AbstractWidget {
|
||||
|
||||
private record PickerEntry(ItemStack stack, int inventorySlot) {}
|
||||
|
||||
private static final int ENTRY_HEIGHT = 28;
|
||||
private static final int PADDING = 10;
|
||||
private static final int CANCEL_BTN_HEIGHT = 22;
|
||||
private static final int CANCEL_BTN_WIDTH = 80;
|
||||
private static final int OVERLAY_BG = 0xCC000000; // Semi-transparent black
|
||||
private static final int WARNING_COLOR = 0xFFF44336;
|
||||
|
||||
private BodyRegionV2 targetRegion;
|
||||
private final List<PickerEntry> entries = new ArrayList<>();
|
||||
private BiConsumer<BodyRegionV2, Integer> onItemSelected; // region, inventorySlot
|
||||
private Runnable onCancelled;
|
||||
private boolean visible = false;
|
||||
|
||||
// ARMS self-equip warning: click-twice-to-confirm
|
||||
private int armsWarningSlot = -1; // -1 = no warning shown
|
||||
private boolean isSelfMode;
|
||||
|
||||
// Scroll
|
||||
private int scrollOffset = 0;
|
||||
|
||||
// Screen dimensions for overlay sizing
|
||||
private int screenWidth;
|
||||
private int screenHeight;
|
||||
|
||||
public ItemPickerOverlay() {
|
||||
super(0, 0, 0, 0, Component.literal("Item Picker"));
|
||||
this.active = false;
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
public void setOnItemSelected(BiConsumer<BodyRegionV2, Integer> cb) { this.onItemSelected = cb; }
|
||||
public void setOnCancelled(Runnable cb) { this.onCancelled = cb; }
|
||||
public boolean isOverlayVisible() { return visible; }
|
||||
|
||||
/**
|
||||
* Open the picker overlay for a specific region.
|
||||
*/
|
||||
public void open(BodyRegionV2 region, boolean selfMode, int screenWidth, int screenHeight) {
|
||||
this.targetRegion = region;
|
||||
this.isSelfMode = selfMode;
|
||||
this.screenWidth = screenWidth;
|
||||
this.screenHeight = screenHeight;
|
||||
this.scrollOffset = 0;
|
||||
this.armsWarningSlot = -1;
|
||||
scanInventory();
|
||||
this.visible = true;
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
this.visible = false;
|
||||
this.active = false;
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
private void scanInventory() {
|
||||
entries.clear();
|
||||
Player player = Minecraft.getInstance().player;
|
||||
if (player == null || targetRegion == null) return;
|
||||
|
||||
for (int i = 0; i < player.getInventory().getContainerSize(); i++) {
|
||||
ItemStack stack = player.getInventory().getItem(i);
|
||||
if (stack.isEmpty()) continue;
|
||||
if (!(stack.getItem() instanceof IV2BondageItem bondageItem)) continue;
|
||||
if (!bondageItem.getOccupiedRegions(stack).contains(targetRegion)) continue;
|
||||
entries.add(new PickerEntry(stack, i));
|
||||
}
|
||||
}
|
||||
|
||||
// Panel bounds (centered on screen)
|
||||
private int getPanelWidth() { return Math.min(280, screenWidth - 40); }
|
||||
private int getPanelHeight() {
|
||||
int contentH = entries.size() * ENTRY_HEIGHT + CANCEL_BTN_HEIGHT + PADDING * 3 + 20;
|
||||
return Math.min(contentH, screenHeight - 60);
|
||||
}
|
||||
private int getPanelX() { return (screenWidth - getPanelWidth()) / 2; }
|
||||
private int getPanelY() { return (screenHeight - getPanelHeight()) / 2; }
|
||||
|
||||
@Override
|
||||
protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
if (!visible) return;
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
// Full-screen overlay
|
||||
graphics.fill(0, 0, screenWidth, screenHeight, OVERLAY_BG);
|
||||
|
||||
int panelX = getPanelX();
|
||||
int panelY = getPanelY();
|
||||
int panelW = getPanelWidth();
|
||||
int panelH = getPanelHeight();
|
||||
|
||||
// MC-style raised panel
|
||||
GuiRenderUtil.drawMCPanel(graphics, panelX, panelY, panelW, panelH);
|
||||
|
||||
// Title (dark text, vanilla style)
|
||||
String title = Component.translatable("gui.tiedup.picker.title",
|
||||
Component.translatable("gui.tiedup.region." + targetRegion.name().toLowerCase())).getString();
|
||||
graphics.drawString(mc.font, title, panelX + PADDING, panelY + PADDING, GuiRenderUtil.MC_TEXT_DARK, false);
|
||||
|
||||
// Entries
|
||||
int listY = panelY + PADDING + mc.font.lineHeight + 8;
|
||||
int maxVisible = (panelH - PADDING * 3 - mc.font.lineHeight - 8 - CANCEL_BTN_HEIGHT) / ENTRY_HEIGHT;
|
||||
|
||||
for (int i = 0; i < Math.min(entries.size(), maxVisible); i++) {
|
||||
int idx = i + scrollOffset;
|
||||
if (idx >= entries.size()) break;
|
||||
PickerEntry entry = entries.get(idx);
|
||||
int entryY = listY + i * ENTRY_HEIGHT;
|
||||
int entryX = panelX + PADDING;
|
||||
int entryW = panelW - PADDING * 2;
|
||||
|
||||
boolean hovered = mouseX >= entryX && mouseX < entryX + entryW
|
||||
&& mouseY >= entryY && mouseY < entryY + ENTRY_HEIGHT;
|
||||
boolean isArmsWarning = (armsWarningSlot == entry.inventorySlot);
|
||||
|
||||
// MC-style slot for each entry
|
||||
GuiRenderUtil.drawMCSlot(graphics, entryX, entryY, entryW, ENTRY_HEIGHT - 2);
|
||||
|
||||
// Gold border for ARMS warning confirmation
|
||||
if (isArmsWarning) {
|
||||
GuiRenderUtil.drawSelectedBorder(graphics, entryX, entryY, entryW, ENTRY_HEIGHT - 2);
|
||||
}
|
||||
|
||||
// Vanilla hover overlay (white semi-transparent)
|
||||
if (hovered && !isArmsWarning) {
|
||||
GuiRenderUtil.drawSlotHover(graphics, entryX, entryY, entryW, ENTRY_HEIGHT - 2);
|
||||
}
|
||||
|
||||
// Item icon (16x16)
|
||||
graphics.renderItem(entry.stack, entryX + 4, entryY + (ENTRY_HEIGHT - 18) / 2);
|
||||
|
||||
// Item name
|
||||
String name = entry.stack.getHoverName().getString();
|
||||
graphics.drawString(mc.font, name, entryX + 24, entryY + (ENTRY_HEIGHT - mc.font.lineHeight) / 2,
|
||||
GuiRenderUtil.MC_TEXT_DARK, false);
|
||||
}
|
||||
|
||||
// ARMS warning text
|
||||
if (armsWarningSlot >= 0) {
|
||||
String warning = Component.translatable("gui.tiedup.picker.arms_warning").getString();
|
||||
int warningY = listY + Math.min(entries.size(), maxVisible) * ENTRY_HEIGHT + 2;
|
||||
graphics.drawString(mc.font, warning, panelX + PADDING, warningY, WARNING_COLOR, false);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (entries.isEmpty()) {
|
||||
String empty = Component.translatable("gui.tiedup.picker.empty").getString();
|
||||
GuiRenderUtil.drawCenteredStringNoShadow(graphics, mc.font, empty,
|
||||
panelX + panelW / 2, listY + 20, GuiRenderUtil.MC_TEXT_GRAY);
|
||||
}
|
||||
|
||||
// Cancel button (MC-style)
|
||||
int cancelX = panelX + (panelW - CANCEL_BTN_WIDTH) / 2;
|
||||
int cancelY = panelY + panelH - CANCEL_BTN_HEIGHT - PADDING;
|
||||
boolean cancelHovered = mouseX >= cancelX && mouseX < cancelX + CANCEL_BTN_WIDTH
|
||||
&& mouseY >= cancelY && mouseY < cancelY + CANCEL_BTN_HEIGHT;
|
||||
GuiRenderUtil.drawMCButton(graphics, cancelX, cancelY, CANCEL_BTN_WIDTH, CANCEL_BTN_HEIGHT, cancelHovered, true);
|
||||
String cancelText = Component.translatable("gui.tiedup.cancel").getString();
|
||||
GuiRenderUtil.drawCenteredStringNoShadow(graphics, mc.font, cancelText,
|
||||
cancelX + CANCEL_BTN_WIDTH / 2, cancelY + (CANCEL_BTN_HEIGHT - mc.font.lineHeight) / 2,
|
||||
GuiRenderUtil.MC_TEXT_DARK);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (!visible || button != 0) return false;
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
int panelX = getPanelX();
|
||||
int panelY = getPanelY();
|
||||
int panelW = getPanelWidth();
|
||||
int panelH = getPanelHeight();
|
||||
|
||||
// Cancel button
|
||||
int cancelX = panelX + (panelW - CANCEL_BTN_WIDTH) / 2;
|
||||
int cancelY = panelY + panelH - CANCEL_BTN_HEIGHT - PADDING;
|
||||
if (mouseX >= cancelX && mouseX < cancelX + CANCEL_BTN_WIDTH
|
||||
&& mouseY >= cancelY && mouseY < cancelY + CANCEL_BTN_HEIGHT) {
|
||||
close();
|
||||
if (onCancelled != null) onCancelled.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Entry clicks
|
||||
int listY = panelY + PADDING + mc.font.lineHeight + 8;
|
||||
int maxVisible = (panelH - PADDING * 3 - mc.font.lineHeight - 8 - CANCEL_BTN_HEIGHT) / ENTRY_HEIGHT;
|
||||
|
||||
for (int i = 0; i < Math.min(entries.size(), maxVisible); i++) {
|
||||
int idx = i + scrollOffset;
|
||||
if (idx >= entries.size()) break;
|
||||
PickerEntry entry = entries.get(idx);
|
||||
int entryY = listY + i * ENTRY_HEIGHT;
|
||||
int entryX = panelX + PADDING;
|
||||
int entryW = panelW - PADDING * 2;
|
||||
|
||||
if (mouseX >= entryX && mouseX < entryX + entryW
|
||||
&& mouseY >= entryY && mouseY < entryY + ENTRY_HEIGHT) {
|
||||
|
||||
// ARMS self-equip warning: double-click confirmation
|
||||
if (isSelfMode && targetRegion == BodyRegionV2.ARMS) {
|
||||
if (armsWarningSlot == entry.inventorySlot) {
|
||||
// Second click — confirm
|
||||
if (onItemSelected != null) onItemSelected.accept(targetRegion, entry.inventorySlot);
|
||||
close();
|
||||
} else {
|
||||
// First click — show warning
|
||||
armsWarningSlot = entry.inventorySlot;
|
||||
}
|
||||
} else {
|
||||
if (onItemSelected != null) onItemSelected.accept(targetRegion, entry.inventorySlot);
|
||||
close();
|
||||
}
|
||||
playDownSound(mc.getSoundManager());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Click outside panel = cancel
|
||||
if (mouseX < panelX || mouseX > panelX + panelW
|
||||
|| mouseY < panelY || mouseY > panelY + panelH) {
|
||||
close();
|
||||
if (onCancelled != null) onCancelled.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
|
||||
if (!visible) return false;
|
||||
int panelH = getPanelHeight();
|
||||
int maxVisible = (panelH - PADDING * 3 - Minecraft.getInstance().font.lineHeight - 8 - CANCEL_BTN_HEIGHT) / ENTRY_HEIGHT;
|
||||
int maxScroll = Math.max(0, entries.size() - maxVisible);
|
||||
scrollOffset = Math.max(0, Math.min(maxScroll, scrollOffset - (int) delta));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||
if (!visible) return false;
|
||||
// ESC closes overlay
|
||||
if (keyCode == 256) { // GLFW_KEY_ESCAPE
|
||||
close();
|
||||
if (onCancelled != null) onCancelled.run();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateWidgetNarration(NarrationElementOutput output) {
|
||||
output.add(NarratedElementType.TITLE, Component.translatable("gui.tiedup.picker.title",
|
||||
targetRegion != null ? Component.translatable("gui.tiedup.region." + targetRegion.name().toLowerCase()) : ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package com.tiedup.remake.client.gui.widgets;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||
import com.tiedup.remake.items.base.IHasResistance;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.AbstractWidget;
|
||||
import net.minecraft.client.gui.narration.NarratedElementType;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* V2 widget representing a single body region equipment slot.
|
||||
* Uses {@link BodyRegionV2} for body region mapping.
|
||||
*
|
||||
* Epic 6A: V2 migration of BondageSlotWidget.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class RegionSlotWidget extends AbstractWidget {
|
||||
|
||||
private final BodyRegionV2 region;
|
||||
private final Supplier<ItemStack> itemGetter;
|
||||
private Consumer<RegionSlotWidget> onClick;
|
||||
private Consumer<RegionSlotWidget> onAdjustClick;
|
||||
private Consumer<RegionSlotWidget> onRemoveClick;
|
||||
private Consumer<RegionSlotWidget> onEquipClick;
|
||||
|
||||
// Visual settings
|
||||
private boolean showAdjustButton = false;
|
||||
private boolean showRemoveButton = false;
|
||||
private boolean showEquipButton = false;
|
||||
private boolean selected = false;
|
||||
|
||||
// Layout constants
|
||||
private static final int ICON_SIZE = 16;
|
||||
private static final int PADDING = 4;
|
||||
private static final int ADJUST_BUTTON_SIZE = 14;
|
||||
private static final int REMOVE_BUTTON_SIZE = 14;
|
||||
private static final int EQUIP_BUTTON_WIDTH = 50;
|
||||
private static final int EQUIP_BUTTON_HEIGHT = 16;
|
||||
private static final int RESISTANCE_BAR_WIDTH = 50;
|
||||
private static final int RESISTANCE_BAR_HEIGHT = 4;
|
||||
|
||||
/**
|
||||
* Create a new region slot widget.
|
||||
*
|
||||
* @param x X position
|
||||
* @param y Y position
|
||||
* @param width Widget width
|
||||
* @param height Widget height
|
||||
* @param region The V2 body region
|
||||
* @param itemGetter Supplier that returns the current ItemStack in this region
|
||||
*/
|
||||
public RegionSlotWidget(
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
int height,
|
||||
BodyRegionV2 region,
|
||||
Supplier<ItemStack> itemGetter
|
||||
) {
|
||||
super(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
Component.translatable(
|
||||
"gui.tiedup.region." + region.name().toLowerCase()
|
||||
)
|
||||
);
|
||||
this.region = region;
|
||||
this.itemGetter = itemGetter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set click handler for the main slot area.
|
||||
*/
|
||||
public void setOnClick(Consumer<RegionSlotWidget> onClick) {
|
||||
this.onClick = onClick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set click handler for the adjust button.
|
||||
*/
|
||||
public void setOnAdjustClick(Consumer<RegionSlotWidget> onAdjustClick) {
|
||||
this.onAdjustClick = onAdjustClick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable the adjust button (for gags/blindfolds).
|
||||
*/
|
||||
public void setShowAdjustButton(boolean show) {
|
||||
this.showAdjustButton = show;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set click handler for the remove button.
|
||||
*/
|
||||
public void setOnRemoveClick(Consumer<RegionSlotWidget> onRemoveClick) {
|
||||
this.onRemoveClick = onRemoveClick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable the remove button.
|
||||
*/
|
||||
public void setShowRemoveButton(boolean show) {
|
||||
this.showRemoveButton = show;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set click handler for the equip button (shown for empty slots).
|
||||
*/
|
||||
public void setOnEquipClick(Consumer<RegionSlotWidget> onEquipClick) {
|
||||
this.onEquipClick = onEquipClick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable the equip button for empty slots.
|
||||
*/
|
||||
public void setShowEquipButton(boolean show) {
|
||||
this.showEquipButton = show;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set selected state.
|
||||
*/
|
||||
public void setSelected(boolean selected) {
|
||||
this.selected = selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the body region.
|
||||
*/
|
||||
public BodyRegionV2 getRegion() {
|
||||
return region;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current item in this region.
|
||||
*/
|
||||
public ItemStack getItem() {
|
||||
return itemGetter.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if adjust button is being hovered.
|
||||
*/
|
||||
private boolean isAdjustButtonHovered(double mouseX, double mouseY) {
|
||||
if (!showAdjustButton) return false;
|
||||
|
||||
int buttonX = getAdjustButtonX();
|
||||
int buttonY = getY() + (height - ADJUST_BUTTON_SIZE) / 2;
|
||||
|
||||
return (
|
||||
mouseX >= buttonX &&
|
||||
mouseX < buttonX + ADJUST_BUTTON_SIZE &&
|
||||
mouseY >= buttonY &&
|
||||
mouseY < buttonY + ADJUST_BUTTON_SIZE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if remove button is being hovered.
|
||||
*/
|
||||
private boolean isRemoveButtonHovered(double mouseX, double mouseY) {
|
||||
if (!showRemoveButton) return false;
|
||||
|
||||
int buttonX = getRemoveButtonX();
|
||||
int buttonY = getY() + (height - REMOVE_BUTTON_SIZE) / 2;
|
||||
|
||||
return (
|
||||
mouseX >= buttonX &&
|
||||
mouseX < buttonX + REMOVE_BUTTON_SIZE &&
|
||||
mouseY >= buttonY &&
|
||||
mouseY < buttonY + REMOVE_BUTTON_SIZE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get X position for adjust button.
|
||||
*/
|
||||
private int getAdjustButtonX() {
|
||||
if (showRemoveButton) {
|
||||
return (
|
||||
getX() +
|
||||
width -
|
||||
ADJUST_BUTTON_SIZE -
|
||||
PADDING -
|
||||
REMOVE_BUTTON_SIZE -
|
||||
PADDING
|
||||
);
|
||||
}
|
||||
return getX() + width - ADJUST_BUTTON_SIZE - PADDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get X position for remove button (always rightmost).
|
||||
*/
|
||||
private int getRemoveButtonX() {
|
||||
return getX() + width - REMOVE_BUTTON_SIZE - PADDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get X position for the equip button (right-aligned in empty slot).
|
||||
*/
|
||||
private int getEquipButtonX() {
|
||||
return getX() + width - EQUIP_BUTTON_WIDTH - PADDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Y position for the equip button (centered vertically).
|
||||
*/
|
||||
private int getEquipButtonY() {
|
||||
return getY() + (height - EQUIP_BUTTON_HEIGHT) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the equip button is being hovered.
|
||||
*/
|
||||
private boolean isEquipButtonHovered(double mouseX, double mouseY) {
|
||||
if (!showEquipButton || !itemGetter.get().isEmpty()) return false;
|
||||
int bx = getEquipButtonX();
|
||||
int by = getEquipButtonY();
|
||||
return mouseX >= bx && mouseX < bx + EQUIP_BUTTON_WIDTH
|
||||
&& mouseY >= by && mouseY < by + EQUIP_BUTTON_HEIGHT;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderWidget(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
float partialTick
|
||||
) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
ItemStack stack = itemGetter.get();
|
||||
boolean hasItem = !stack.isEmpty();
|
||||
boolean hovered = isMouseOver(mouseX, mouseY);
|
||||
boolean adjustHovered = isAdjustButtonHovered(mouseX, mouseY);
|
||||
boolean removeHovered = isRemoveButtonHovered(mouseX, mouseY);
|
||||
boolean equipHovered = isEquipButtonHovered(mouseX, mouseY);
|
||||
boolean anyButtonHovered = adjustHovered || removeHovered || equipHovered;
|
||||
|
||||
// MC-style sunken slot background
|
||||
GuiRenderUtil.drawMCSlot(graphics, getX(), getY(), width, height);
|
||||
|
||||
// Selected: gold highlight border on top of the slot
|
||||
if (selected) {
|
||||
GuiRenderUtil.drawSelectedBorder(graphics, getX(), getY(), width, height);
|
||||
}
|
||||
|
||||
// Hover overlay (vanilla white semi-transparent)
|
||||
if (hovered && !anyButtonHovered && !selected) {
|
||||
GuiRenderUtil.drawSlotHover(graphics, getX(), getY(), width, height);
|
||||
}
|
||||
|
||||
// Region icon (uniform dark gray square)
|
||||
int iconX = getX() + PADDING;
|
||||
int iconY = getY() + (height - ICON_SIZE) / 2;
|
||||
graphics.fill(
|
||||
iconX,
|
||||
iconY,
|
||||
iconX + ICON_SIZE,
|
||||
iconY + ICON_SIZE,
|
||||
0xFF555555
|
||||
);
|
||||
|
||||
// Region label
|
||||
String typeLabel = getSlotLabel();
|
||||
int labelX = iconX + ICON_SIZE + PADDING;
|
||||
int labelY = getY() + PADDING;
|
||||
graphics.drawString(
|
||||
mc.font,
|
||||
typeLabel,
|
||||
labelX,
|
||||
labelY,
|
||||
GuiRenderUtil.MC_TEXT_GRAY,
|
||||
false
|
||||
);
|
||||
|
||||
// Item name or "(empty)"
|
||||
Component itemText;
|
||||
int itemTextColor;
|
||||
if (hasItem) {
|
||||
itemText = stack.getHoverName();
|
||||
itemTextColor = GuiRenderUtil.MC_TEXT_DARK;
|
||||
} else {
|
||||
itemText = Component.translatable("gui.tiedup.empty").withStyle(
|
||||
ChatFormatting.ITALIC
|
||||
);
|
||||
itemTextColor = 0xFF808080;
|
||||
}
|
||||
|
||||
int textY = labelY + mc.font.lineHeight + 2;
|
||||
int maxTextWidth = width - ICON_SIZE - PADDING * 3;
|
||||
if (showAdjustButton && hasItem) {
|
||||
maxTextWidth -= ADJUST_BUTTON_SIZE + PADDING;
|
||||
}
|
||||
if (showRemoveButton && hasItem) {
|
||||
maxTextWidth -= REMOVE_BUTTON_SIZE + PADDING;
|
||||
}
|
||||
|
||||
// Trim text if too long
|
||||
String trimmedText = mc.font.plainSubstrByWidth(
|
||||
itemText.getString(),
|
||||
maxTextWidth
|
||||
);
|
||||
graphics.drawString(
|
||||
mc.font,
|
||||
trimmedText,
|
||||
labelX,
|
||||
textY,
|
||||
itemTextColor,
|
||||
false
|
||||
);
|
||||
|
||||
// Resistance bar (for items that implement IHasResistance)
|
||||
if (hasItem && stack.getItem() instanceof IHasResistance resistanceItem) {
|
||||
Player player = mc.player;
|
||||
if (player != null) {
|
||||
int current = resistanceItem.getCurrentResistance(stack, player);
|
||||
int base = resistanceItem.getBaseResistance(player);
|
||||
if (base > 0) {
|
||||
float ratio = Math.max(0f, Math.min(1f, (float) current / base));
|
||||
int barX = getX() + width - RESISTANCE_BAR_WIDTH - PADDING;
|
||||
int barY = getY() + height - RESISTANCE_BAR_HEIGHT - PADDING;
|
||||
// Sunken bar background
|
||||
graphics.fill(barX, barY, barX + RESISTANCE_BAR_WIDTH, barY + RESISTANCE_BAR_HEIGHT, 0xFF373737);
|
||||
// Colored fill: red below 30%, green otherwise
|
||||
int fillColor = (ratio < 0.30f) ? 0xFFFF4444 : 0xFF44CC44;
|
||||
int fillWidth = Math.round(RESISTANCE_BAR_WIDTH * ratio);
|
||||
if (fillWidth > 0) {
|
||||
graphics.fill(barX, barY, barX + fillWidth, barY + RESISTANCE_BAR_HEIGHT, fillColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Equip button for empty slots (MC-style button)
|
||||
if (!hasItem && showEquipButton) {
|
||||
int bx = getEquipButtonX();
|
||||
int by = getEquipButtonY();
|
||||
GuiRenderUtil.drawMCButton(graphics, bx, by, EQUIP_BUTTON_WIDTH, EQUIP_BUTTON_HEIGHT, equipHovered, true);
|
||||
String equipLabel = Component.translatable("gui.tiedup.equip").getString();
|
||||
GuiRenderUtil.drawCenteredStringNoShadow(
|
||||
graphics,
|
||||
mc.font,
|
||||
equipLabel,
|
||||
bx + EQUIP_BUTTON_WIDTH / 2,
|
||||
by + (EQUIP_BUTTON_HEIGHT - mc.font.lineHeight) / 2,
|
||||
GuiRenderUtil.MC_TEXT_DARK
|
||||
);
|
||||
}
|
||||
|
||||
// Adjust button (for gags/blindfolds) — MC-style small button
|
||||
if (showAdjustButton && hasItem) {
|
||||
int buttonX = getAdjustButtonX();
|
||||
int buttonY = getY() + (height - ADJUST_BUTTON_SIZE) / 2;
|
||||
GuiRenderUtil.drawMCButton(graphics, buttonX, buttonY, ADJUST_BUTTON_SIZE, ADJUST_BUTTON_SIZE, adjustHovered, true);
|
||||
|
||||
// Gear icon placeholder
|
||||
GuiRenderUtil.drawCenteredStringNoShadow(
|
||||
graphics,
|
||||
mc.font,
|
||||
"\u2699",
|
||||
buttonX + ADJUST_BUTTON_SIZE / 2,
|
||||
buttonY + (ADJUST_BUTTON_SIZE - mc.font.lineHeight) / 2 + 1,
|
||||
GuiRenderUtil.MC_TEXT_DARK
|
||||
);
|
||||
}
|
||||
|
||||
// Remove button — MC-style with red tint
|
||||
if (showRemoveButton && hasItem) {
|
||||
int buttonX = getRemoveButtonX();
|
||||
int buttonY = getY() + (height - REMOVE_BUTTON_SIZE) / 2;
|
||||
GuiRenderUtil.drawMCButton(graphics, buttonX, buttonY, REMOVE_BUTTON_SIZE, REMOVE_BUTTON_SIZE, removeHovered, true);
|
||||
// Red-tinted overlay
|
||||
graphics.fill(buttonX + 1, buttonY + 1, buttonX + REMOVE_BUTTON_SIZE - 1, buttonY + REMOVE_BUTTON_SIZE - 1,
|
||||
removeHovered ? 0x40FF0000 : 0x20FF0000);
|
||||
|
||||
// X icon
|
||||
GuiRenderUtil.drawCenteredStringNoShadow(
|
||||
graphics,
|
||||
mc.font,
|
||||
"X",
|
||||
buttonX + REMOVE_BUTTON_SIZE / 2,
|
||||
buttonY + (REMOVE_BUTTON_SIZE - mc.font.lineHeight) / 2 + 1,
|
||||
0xFFCC4444
|
||||
);
|
||||
}
|
||||
|
||||
// Tooltip on hover
|
||||
if (hovered && hasItem && !anyButtonHovered) {
|
||||
renderTooltip(graphics, mouseX, mouseY, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the slot label text from the region's translation key.
|
||||
*/
|
||||
private String getSlotLabel() {
|
||||
return Component.translatable(
|
||||
"gui.tiedup.region." + region.name().toLowerCase()
|
||||
).getString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render item tooltip.
|
||||
*/
|
||||
private void renderTooltip(
|
||||
GuiGraphics graphics,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
ItemStack stack
|
||||
) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
List<Component> tooltip = new ArrayList<>();
|
||||
|
||||
// Item name
|
||||
tooltip.add(stack.getHoverName());
|
||||
|
||||
// Add region info
|
||||
tooltip.add(
|
||||
Component.translatable(
|
||||
"gui.tiedup.region." + region.name().toLowerCase()
|
||||
).withStyle(ChatFormatting.GRAY)
|
||||
);
|
||||
|
||||
graphics.renderTooltip(
|
||||
mc.font,
|
||||
tooltip,
|
||||
java.util.Optional.empty(),
|
||||
mouseX,
|
||||
mouseY
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (isMouseOver(mouseX, mouseY) && button == 0) {
|
||||
// Check if equip button was clicked (empty slot only)
|
||||
if (isEquipButtonHovered(mouseX, mouseY)) {
|
||||
if (onEquipClick != null) {
|
||||
onEquipClick.accept(this);
|
||||
playDownSound(Minecraft.getInstance().getSoundManager());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check if remove button was clicked
|
||||
else if (isRemoveButtonHovered(mouseX, mouseY)) {
|
||||
if (onRemoveClick != null) {
|
||||
onRemoveClick.accept(this);
|
||||
playDownSound(Minecraft.getInstance().getSoundManager());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check if adjust button was clicked
|
||||
else if (isAdjustButtonHovered(mouseX, mouseY)) {
|
||||
if (onAdjustClick != null) {
|
||||
onAdjustClick.accept(this);
|
||||
playDownSound(Minecraft.getInstance().getSoundManager());
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Main slot click
|
||||
if (onClick != null) {
|
||||
onClick.accept(this);
|
||||
playDownSound(Minecraft.getInstance().getSoundManager());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateWidgetNarration(NarrationElementOutput output) {
|
||||
ItemStack stack = itemGetter.get();
|
||||
Component narration;
|
||||
|
||||
if (stack.isEmpty()) {
|
||||
narration = Component.translatable(
|
||||
"gui.tiedup.slot_empty",
|
||||
getSlotLabel()
|
||||
);
|
||||
} else {
|
||||
narration = Component.translatable(
|
||||
"gui.tiedup.slot_item",
|
||||
getSlotLabel(),
|
||||
stack.getHoverName()
|
||||
);
|
||||
}
|
||||
|
||||
output.add(NarratedElementType.TITLE, narration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.tiedup.remake.client.gui.widgets;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.AbstractWidget;
|
||||
import net.minecraft.client.gui.narration.NarratedElementType;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Tab bar for the unified bondage screen. 5 body-zone tabs with occupied indicators.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class RegionTabBar extends AbstractWidget {
|
||||
|
||||
/**
|
||||
* Body zone tabs grouping the 14 BodyRegionV2 values.
|
||||
*/
|
||||
public enum BodyTab {
|
||||
HEAD("gui.tiedup.tab.head", BodyRegionV2.HEAD, BodyRegionV2.EYES, BodyRegionV2.EARS, BodyRegionV2.MOUTH),
|
||||
UPPER("gui.tiedup.tab.upper", BodyRegionV2.NECK, BodyRegionV2.TORSO),
|
||||
ARMS("gui.tiedup.tab.arms", BodyRegionV2.ARMS, BodyRegionV2.HANDS, BodyRegionV2.FINGERS),
|
||||
LOWER("gui.tiedup.tab.lower", BodyRegionV2.WAIST, BodyRegionV2.LEGS, BodyRegionV2.FEET),
|
||||
SPECIAL("gui.tiedup.tab.special", BodyRegionV2.TAIL, BodyRegionV2.WINGS);
|
||||
|
||||
private final String translationKey;
|
||||
private final Set<BodyRegionV2> regions;
|
||||
|
||||
BodyTab(String translationKey, BodyRegionV2... regions) {
|
||||
this.translationKey = translationKey;
|
||||
this.regions = EnumSet.noneOf(BodyRegionV2.class);
|
||||
for (BodyRegionV2 r : regions) this.regions.add(r);
|
||||
}
|
||||
|
||||
public String getTranslationKey() { return translationKey; }
|
||||
public Set<BodyRegionV2> getRegions() { return regions; }
|
||||
|
||||
/** Check if any region in this tab has an equipped item on the entity. */
|
||||
public boolean hasEquippedItems(LivingEntity entity) {
|
||||
for (BodyRegionV2 region : regions) {
|
||||
if (V2EquipmentHelper.isRegionOccupied(entity, region)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Visual constants
|
||||
private static final int TAB_HEIGHT = 26;
|
||||
private static final int TAB_SPACING = 4;
|
||||
private static final int DOT_RADIUS = 3;
|
||||
|
||||
// Colors (vanilla MC style)
|
||||
private static final int BG_ACTIVE = 0xFFC6C6C6; // Same as main panel
|
||||
private static final int BG_INACTIVE = 0xFF8B8B8B; // Darker, slot-like
|
||||
private static final int BG_HOVER = 0xFFA0A0A0; // Between active and inactive
|
||||
private static final int TEXT_ACTIVE = 0xFF404040; // Dark text
|
||||
private static final int TEXT_INACTIVE = 0xFF555555; // Gray text
|
||||
private static final int BAR_BG = 0xFFA0A0A0;
|
||||
|
||||
private BodyTab activeTab = BodyTab.HEAD;
|
||||
private Consumer<BodyTab> onTabChanged;
|
||||
private LivingEntity targetEntity;
|
||||
|
||||
public RegionTabBar(int x, int y, int width) {
|
||||
super(x, y, width, TAB_HEIGHT, Component.literal("Tab Bar"));
|
||||
}
|
||||
|
||||
public void setOnTabChanged(Consumer<BodyTab> callback) { this.onTabChanged = callback; }
|
||||
public void setTargetEntity(LivingEntity entity) { this.targetEntity = entity; }
|
||||
public BodyTab getActiveTab() { return activeTab; }
|
||||
|
||||
public void setActiveTab(BodyTab tab) {
|
||||
if (this.activeTab != tab) {
|
||||
this.activeTab = tab;
|
||||
if (onTabChanged != null) onTabChanged.accept(tab);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
|
||||
// Background bar
|
||||
graphics.fill(getX(), getY(), getX() + width, getY() + height, BAR_BG);
|
||||
|
||||
BodyTab[] tabs = BodyTab.values();
|
||||
int tabWidth = (width - TAB_SPACING * (tabs.length - 1)) / tabs.length;
|
||||
|
||||
for (int i = 0; i < tabs.length; i++) {
|
||||
BodyTab tab = tabs[i];
|
||||
int tabX = getX() + i * (tabWidth + TAB_SPACING);
|
||||
boolean isActive = (tab == activeTab);
|
||||
boolean isHovered = mouseX >= tabX && mouseX < tabX + tabWidth
|
||||
&& mouseY >= getY() && mouseY < getY() + height;
|
||||
|
||||
int bgColor = isActive ? BG_ACTIVE : (isHovered ? BG_HOVER : BG_INACTIVE);
|
||||
graphics.fill(tabX, getY(), tabX + tabWidth, getY() + height, bgColor);
|
||||
|
||||
if (isActive) {
|
||||
// Active tab: raised 3D look, no bottom border (connects to panel below)
|
||||
// Top highlight
|
||||
graphics.fill(tabX, getY(), tabX + tabWidth, getY() + 1, GuiRenderUtil.MC_HIGHLIGHT_OUTER);
|
||||
// Left highlight
|
||||
graphics.fill(tabX, getY(), tabX + 1, getY() + height, GuiRenderUtil.MC_HIGHLIGHT_OUTER);
|
||||
// Right shadow
|
||||
graphics.fill(tabX + tabWidth - 1, getY(), tabX + tabWidth, getY() + height, GuiRenderUtil.MC_SHADOW_OUTER);
|
||||
} else {
|
||||
// Inactive tab: full 3D sunken borders
|
||||
// Top shadow
|
||||
graphics.fill(tabX, getY(), tabX + tabWidth, getY() + 1, GuiRenderUtil.MC_SHADOW_OUTER);
|
||||
// Left shadow
|
||||
graphics.fill(tabX, getY(), tabX + 1, getY() + height, GuiRenderUtil.MC_SHADOW_OUTER);
|
||||
// Bottom highlight
|
||||
graphics.fill(tabX, getY() + height - 1, tabX + tabWidth, getY() + height, GuiRenderUtil.MC_HIGHLIGHT_OUTER);
|
||||
// Right highlight
|
||||
graphics.fill(tabX + tabWidth - 1, getY(), tabX + tabWidth, getY() + height, GuiRenderUtil.MC_HIGHLIGHT_OUTER);
|
||||
}
|
||||
|
||||
// Tab label
|
||||
String label = Component.translatable(tab.getTranslationKey()).getString();
|
||||
int textColor = isActive ? TEXT_ACTIVE : TEXT_INACTIVE;
|
||||
int textX = tabX + (tabWidth - mc.font.width(label)) / 2;
|
||||
int textY = getY() + (height - mc.font.lineHeight) / 2;
|
||||
graphics.drawString(mc.font, label, textX, textY, textColor, false);
|
||||
|
||||
// Occupied dot indicator (top-right corner of tab) — white/light gray
|
||||
if (targetEntity != null && tab.hasEquippedItems(targetEntity)) {
|
||||
int dotX = tabX + tabWidth - DOT_RADIUS - 4;
|
||||
int dotY = getY() + 4;
|
||||
graphics.fill(dotX - DOT_RADIUS, dotY - DOT_RADIUS,
|
||||
dotX + DOT_RADIUS, dotY + DOT_RADIUS, 0xFFCCCCCC);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (!isMouseOver(mouseX, mouseY) || button != 0) return false;
|
||||
|
||||
BodyTab[] tabs = BodyTab.values();
|
||||
int tabWidth = (width - TAB_SPACING * (tabs.length - 1)) / tabs.length;
|
||||
|
||||
for (int i = 0; i < tabs.length; i++) {
|
||||
int tabX = getX() + i * (tabWidth + TAB_SPACING);
|
||||
if (mouseX >= tabX && mouseX < tabX + tabWidth) {
|
||||
setActiveTab(tabs[i]);
|
||||
playDownSound(Minecraft.getInstance().getSoundManager());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateWidgetNarration(NarrationElementOutput output) {
|
||||
output.add(NarratedElementType.TITLE,
|
||||
Component.translatable("gui.tiedup.tab_bar",
|
||||
Component.translatable(activeTab.getTranslationKey())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
package com.tiedup.remake.client.gui.widgets;
|
||||
|
||||
import static com.tiedup.remake.client.gui.util.GuiLayoutConstants.*;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.items.ItemGpsCollar;
|
||||
import com.tiedup.remake.items.base.ItemCollar;
|
||||
import com.tiedup.remake.state.IBondageState;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.Font;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.ContainerObjectSelectionList;
|
||||
import net.minecraft.client.gui.components.events.GuiEventListener;
|
||||
import net.minecraft.client.gui.narration.NarratableEntry;
|
||||
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.joml.Quaternionf;
|
||||
|
||||
/**
|
||||
* List Entry for SlaveManagementScreen.
|
||||
* Displays slave info, preview, status icons, and action buttons.
|
||||
*
|
||||
* Uses ContainerObjectSelectionList.Entry for Minecraft's built-in list scrolling.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class SlaveEntryWidget
|
||||
extends ContainerObjectSelectionList.Entry<SlaveEntryWidget>
|
||||
{
|
||||
|
||||
private final IBondageState slave;
|
||||
private final Minecraft mc;
|
||||
|
||||
// Actions
|
||||
private final Consumer<IBondageState> onAdjust;
|
||||
private final Consumer<IBondageState> onShock;
|
||||
private final Consumer<IBondageState> onLocate;
|
||||
private final Consumer<IBondageState> onFree;
|
||||
|
||||
// Layout constants - 2-column grid layout for buttons
|
||||
private static final int BUTTON_W = 42;
|
||||
private static final int BUTTON_H = 16;
|
||||
private static final int BUTTON_GAP = 3;
|
||||
private static final int BUTTON_COL_GAP = 4;
|
||||
private static final int BUTTON_GRID_W = BUTTON_W * 2 + BUTTON_COL_GAP;
|
||||
|
||||
// Preview size
|
||||
private static final int PREVIEW_SIZE = PREVIEW_WIDTH_S; // 50px
|
||||
|
||||
// Status icon colors (use centralized GuiColors)
|
||||
private static final int COLOR_BOUND = GuiColors.TYPE_BIND;
|
||||
private static final int COLOR_GAGGED = GuiColors.TYPE_GAG;
|
||||
private static final int COLOR_BLIND = GuiColors.TYPE_BLINDFOLD;
|
||||
private static final int COLOR_DEAF = GuiColors.TYPE_EARPLUGS;
|
||||
private static final int COLOR_COLLAR = GuiColors.TYPE_COLLAR;
|
||||
private static final int COLOR_MITTENS = GuiColors.TYPE_MITTENS;
|
||||
|
||||
// Cached button info for click handling
|
||||
private List<ButtonInfo> cachedButtons;
|
||||
private int lastRenderLeft, lastRenderTop, lastRenderWidth;
|
||||
|
||||
public SlaveEntryWidget(
|
||||
IBondageState slave,
|
||||
Consumer<IBondageState> onAdjust,
|
||||
Consumer<IBondageState> onShock,
|
||||
Consumer<IBondageState> onLocate,
|
||||
Consumer<IBondageState> onFree
|
||||
) {
|
||||
this.slave = slave;
|
||||
this.mc = Minecraft.getInstance();
|
||||
this.onAdjust = onAdjust;
|
||||
this.onShock = onShock;
|
||||
this.onLocate = onLocate;
|
||||
this.onFree = onFree;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
GuiGraphics graphics,
|
||||
int index,
|
||||
int top,
|
||||
int left,
|
||||
int width,
|
||||
int height,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
boolean hovering,
|
||||
float partialTick
|
||||
) {
|
||||
// Cache render positions for click handling
|
||||
this.lastRenderLeft = left;
|
||||
this.lastRenderTop = top;
|
||||
this.lastRenderWidth = width;
|
||||
|
||||
Font font = mc.font;
|
||||
|
||||
// Background
|
||||
int bgColor = hovering ? GuiColors.BG_LIGHT : GuiColors.BG_DARK;
|
||||
graphics.fill(left, top, left + width, top + height, bgColor);
|
||||
|
||||
// Border
|
||||
int borderColor = GuiColors.BORDER_LIGHT;
|
||||
graphics.fill(left, top, left + width, top + 1, borderColor);
|
||||
graphics.fill(
|
||||
left,
|
||||
top + height - 1,
|
||||
left + width,
|
||||
top + height,
|
||||
borderColor
|
||||
);
|
||||
graphics.fill(left, top, left + 1, top + height, borderColor);
|
||||
graphics.fill(
|
||||
left + width - 1,
|
||||
top,
|
||||
left + width,
|
||||
top + height,
|
||||
borderColor
|
||||
);
|
||||
|
||||
// === LEFT SECTION: Entity Preview ===
|
||||
renderEntityPreview(graphics, font, left, top, height);
|
||||
|
||||
// === MIDDLE SECTION: Info ===
|
||||
int infoX = left + PREVIEW_SIZE + MARGIN_M;
|
||||
int infoWidth = width - PREVIEW_SIZE - BUTTON_GRID_W - MARGIN_M * 3;
|
||||
renderInfo(graphics, font, infoX, top, infoWidth, height);
|
||||
|
||||
// === RIGHT SECTION: Action Buttons (2-column grid) ===
|
||||
renderButtons(graphics, font, mouseX, mouseY, left, top, width);
|
||||
}
|
||||
|
||||
private void renderEntityPreview(
|
||||
GuiGraphics graphics,
|
||||
Font font,
|
||||
int left,
|
||||
int top,
|
||||
int height
|
||||
) {
|
||||
LivingEntity entity = slave.asLivingEntity();
|
||||
if (entity == null) {
|
||||
// Fallback placeholder
|
||||
graphics.fill(
|
||||
left + MARGIN_S,
|
||||
top + MARGIN_S,
|
||||
left + MARGIN_S + PREVIEW_SIZE,
|
||||
top + height - MARGIN_S,
|
||||
GuiColors.BG_MEDIUM
|
||||
);
|
||||
graphics.drawCenteredString(
|
||||
font,
|
||||
"?",
|
||||
left + MARGIN_S + PREVIEW_SIZE / 2,
|
||||
top + height / 2 - 4,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
int previewX = left + MARGIN_S + PREVIEW_SIZE / 2;
|
||||
int previewY = top + height - MARGIN_XS;
|
||||
int scale = 26;
|
||||
|
||||
try {
|
||||
// Save original entity rotations
|
||||
float origBodyRot = entity.yBodyRot;
|
||||
float origHeadRot = entity.yHeadRot;
|
||||
float origYRot = entity.getYRot();
|
||||
float origXRot = entity.getXRot();
|
||||
float origYHeadRotO = entity.yHeadRotO;
|
||||
float origYBodyRotO = entity.yBodyRotO;
|
||||
float origXRotO = entity.xRotO;
|
||||
|
||||
// Set neutral pose
|
||||
entity.yBodyRot = 180f;
|
||||
entity.yHeadRot = 180f;
|
||||
entity.setYRot(180f);
|
||||
entity.setXRot(0f);
|
||||
entity.yHeadRotO = entity.yHeadRot;
|
||||
entity.yBodyRotO = entity.yBodyRot;
|
||||
entity.xRotO = entity.getXRot();
|
||||
|
||||
// Render entity
|
||||
Quaternionf poseRotation = new Quaternionf()
|
||||
.rotationX((float) Math.PI)
|
||||
.rotateY((float) Math.PI);
|
||||
Quaternionf cameraRotation = new Quaternionf();
|
||||
InventoryScreen.renderEntityInInventory(
|
||||
graphics,
|
||||
previewX,
|
||||
previewY,
|
||||
scale,
|
||||
poseRotation,
|
||||
cameraRotation,
|
||||
entity
|
||||
);
|
||||
|
||||
// Restore rotations
|
||||
entity.yBodyRot = origBodyRot;
|
||||
entity.yHeadRot = origHeadRot;
|
||||
entity.setYRot(origYRot);
|
||||
entity.setXRot(origXRot);
|
||||
entity.yHeadRotO = origYHeadRotO;
|
||||
entity.yBodyRotO = origYBodyRotO;
|
||||
entity.xRotO = origXRotO;
|
||||
} catch (Exception e) {
|
||||
com.tiedup.remake.core.TiedUpMod.LOGGER.debug(
|
||||
"[SlaveEntryWidget] Failed to render entity preview",
|
||||
e
|
||||
);
|
||||
graphics.fill(
|
||||
left + MARGIN_S,
|
||||
top + MARGIN_S,
|
||||
left + MARGIN_S + PREVIEW_SIZE,
|
||||
top + ENTRY_HEIGHT - MARGIN_S,
|
||||
GuiColors.BG_MEDIUM
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderInfo(
|
||||
GuiGraphics graphics,
|
||||
Font font,
|
||||
int infoX,
|
||||
int top,
|
||||
int infoWidth,
|
||||
int height
|
||||
) {
|
||||
LivingEntity entity = slave.asLivingEntity();
|
||||
|
||||
// === Row 1: Name ===
|
||||
int row1Y = top + MARGIN_S;
|
||||
String name = slave.getKidnappedName();
|
||||
if (font.width(name) > infoWidth) {
|
||||
name =
|
||||
font.plainSubstrByWidth(name, infoWidth - font.width("...")) +
|
||||
"...";
|
||||
}
|
||||
graphics.drawString(font, name, infoX, row1Y, GuiColors.TEXT_WHITE);
|
||||
|
||||
// === Row 2: Collar nickname (if present) ===
|
||||
int row2Y = row1Y + LINE_HEIGHT + 2;
|
||||
if (slave.hasNamedCollar()) {
|
||||
String collarName = slave.getNameFromCollar();
|
||||
if (!collarName.isEmpty()) {
|
||||
String displayName = "\"" + collarName + "\"";
|
||||
if (font.width(displayName) > infoWidth) {
|
||||
displayName =
|
||||
font.plainSubstrByWidth(
|
||||
displayName,
|
||||
infoWidth - font.width("...")
|
||||
) +
|
||||
"...";
|
||||
}
|
||||
graphics.drawString(
|
||||
font,
|
||||
displayName,
|
||||
infoX,
|
||||
row2Y,
|
||||
GuiColors.ACCENT_TAN
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Row 3: Status icons ===
|
||||
int row3Y = row2Y + LINE_HEIGHT + 4;
|
||||
List<StatusIcon> statuses = buildStatusList();
|
||||
int iconX = infoX;
|
||||
|
||||
for (StatusIcon status : statuses) {
|
||||
// Icon background
|
||||
graphics.fill(
|
||||
iconX,
|
||||
row3Y,
|
||||
iconX + STATUS_ICON_SIZE,
|
||||
row3Y + STATUS_ICON_SIZE,
|
||||
status.color
|
||||
);
|
||||
graphics.fill(
|
||||
iconX,
|
||||
row3Y,
|
||||
iconX + STATUS_ICON_SIZE,
|
||||
row3Y + 1,
|
||||
GuiColors.darken(status.color, 0.3f)
|
||||
);
|
||||
|
||||
// Letter
|
||||
graphics.drawCenteredString(
|
||||
font,
|
||||
status.letter,
|
||||
iconX + STATUS_ICON_SIZE / 2,
|
||||
row3Y + 3,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
|
||||
iconX += STATUS_ICON_SIZE + STATUS_ICON_SPACING;
|
||||
}
|
||||
|
||||
// If no statuses, show "(no restraints)"
|
||||
if (statuses.isEmpty()) {
|
||||
graphics.drawString(
|
||||
font,
|
||||
"(no restraints)",
|
||||
infoX,
|
||||
row3Y + 2,
|
||||
GuiColors.TEXT_DISABLED
|
||||
);
|
||||
}
|
||||
|
||||
// Distance (right after status icons, only if GPS collar)
|
||||
if (hasGPSCollar() && mc.player != null && entity != null) {
|
||||
double dist = mc.player.distanceTo(entity);
|
||||
String distText = String.format("%.0fm", dist);
|
||||
int distX = iconX + MARGIN_S;
|
||||
graphics.drawString(
|
||||
font,
|
||||
distText,
|
||||
distX,
|
||||
row3Y + 2,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
}
|
||||
|
||||
// === Row 4: Health bar ===
|
||||
if (entity != null) {
|
||||
int row4Y = row3Y + STATUS_ICON_SIZE + 4;
|
||||
float healthRatio = entity.getHealth() / entity.getMaxHealth();
|
||||
int barWidth = 50;
|
||||
|
||||
// Bar background
|
||||
graphics.fill(
|
||||
infoX,
|
||||
row4Y,
|
||||
infoX + barWidth,
|
||||
row4Y + 4,
|
||||
GuiColors.BG_DARK
|
||||
);
|
||||
|
||||
// Bar fill
|
||||
int healthColor =
|
||||
healthRatio > 0.5f
|
||||
? GuiColors.SUCCESS
|
||||
: (healthRatio > 0.25f
|
||||
? GuiColors.WARNING
|
||||
: GuiColors.ERROR);
|
||||
graphics.fill(
|
||||
infoX,
|
||||
row4Y,
|
||||
infoX + (int) (barWidth * healthRatio),
|
||||
row4Y + 4,
|
||||
healthColor
|
||||
);
|
||||
|
||||
// Health percentage
|
||||
String healthText = String.format("%.0f%%", healthRatio * 100);
|
||||
graphics.drawString(
|
||||
font,
|
||||
healthText,
|
||||
infoX + barWidth + MARGIN_S,
|
||||
row4Y - 2,
|
||||
GuiColors.TEXT_GRAY
|
||||
);
|
||||
|
||||
// GPS zone status (right of health)
|
||||
if (hasGPSCollar()) {
|
||||
ItemStack collarStack = slave.getEquipment(BodyRegionV2.NECK);
|
||||
if (collarStack.getItem() instanceof ItemGpsCollar gps) {
|
||||
boolean inSafeZone = isInAnySafeZone(
|
||||
gps,
|
||||
collarStack,
|
||||
entity
|
||||
);
|
||||
String gpsText = inSafeZone ? "Safe" : "Outside!";
|
||||
int gpsColor = inSafeZone
|
||||
? GuiColors.SUCCESS
|
||||
: GuiColors.ERROR;
|
||||
int gpsX =
|
||||
infoX +
|
||||
barWidth +
|
||||
MARGIN_S +
|
||||
font.width(healthText) +
|
||||
MARGIN_M;
|
||||
graphics.drawString(
|
||||
font,
|
||||
gpsText,
|
||||
gpsX,
|
||||
row4Y - 2,
|
||||
gpsColor
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record ButtonInfo(
|
||||
String text,
|
||||
int color,
|
||||
Runnable action,
|
||||
int x,
|
||||
int y
|
||||
) {}
|
||||
|
||||
private List<ButtonInfo> getVisibleButtons(int left, int top, int width) {
|
||||
List<ButtonInfo> buttons = new ArrayList<>();
|
||||
int gridX = left + width - BUTTON_GRID_W - MARGIN_S;
|
||||
int baseY = top + MARGIN_S;
|
||||
|
||||
int index = 0;
|
||||
if (slave.isGagged() || slave.isBlindfolded()) {
|
||||
int col = index % 2;
|
||||
int row = index / 2;
|
||||
buttons.add(
|
||||
new ButtonInfo(
|
||||
"Adjust",
|
||||
GuiColors.INFO,
|
||||
() -> {
|
||||
if (onAdjust != null) onAdjust.accept(slave);
|
||||
},
|
||||
gridX + col * (BUTTON_W + BUTTON_COL_GAP),
|
||||
baseY + row * (BUTTON_H + BUTTON_GAP)
|
||||
)
|
||||
);
|
||||
index++;
|
||||
}
|
||||
if (hasShockCollar()) {
|
||||
int col = index % 2;
|
||||
int row = index / 2;
|
||||
buttons.add(
|
||||
new ButtonInfo(
|
||||
"Shock",
|
||||
GuiColors.WARNING,
|
||||
() -> {
|
||||
if (onShock != null) onShock.accept(slave);
|
||||
},
|
||||
gridX + col * (BUTTON_W + BUTTON_COL_GAP),
|
||||
baseY + row * (BUTTON_H + BUTTON_GAP)
|
||||
)
|
||||
);
|
||||
index++;
|
||||
}
|
||||
if (hasGPSCollar()) {
|
||||
int col = index % 2;
|
||||
int row = index / 2;
|
||||
buttons.add(
|
||||
new ButtonInfo(
|
||||
"Locate",
|
||||
GuiColors.SUCCESS,
|
||||
() -> {
|
||||
if (onLocate != null) onLocate.accept(slave);
|
||||
},
|
||||
gridX + col * (BUTTON_W + BUTTON_COL_GAP),
|
||||
baseY + row * (BUTTON_H + BUTTON_GAP)
|
||||
)
|
||||
);
|
||||
index++;
|
||||
}
|
||||
if (slave.isCaptive()) {
|
||||
int col = index % 2;
|
||||
int row = index / 2;
|
||||
buttons.add(
|
||||
new ButtonInfo(
|
||||
"Free",
|
||||
GuiColors.ERROR,
|
||||
() -> {
|
||||
if (onFree != null) onFree.accept(slave);
|
||||
},
|
||||
gridX + col * (BUTTON_W + BUTTON_COL_GAP),
|
||||
baseY + row * (BUTTON_H + BUTTON_GAP)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
private void renderButtons(
|
||||
GuiGraphics graphics,
|
||||
Font font,
|
||||
int mouseX,
|
||||
int mouseY,
|
||||
int left,
|
||||
int top,
|
||||
int width
|
||||
) {
|
||||
cachedButtons = getVisibleButtons(left, top, width);
|
||||
if (cachedButtons.isEmpty()) return;
|
||||
|
||||
for (ButtonInfo btn : cachedButtons) {
|
||||
boolean hovered =
|
||||
mouseX >= btn.x &&
|
||||
mouseX < btn.x + BUTTON_W &&
|
||||
mouseY >= btn.y &&
|
||||
mouseY < btn.y + BUTTON_H;
|
||||
|
||||
int bgColor = hovered
|
||||
? GuiColors.lighten(btn.color, 0.2f)
|
||||
: btn.color;
|
||||
graphics.fill(
|
||||
btn.x,
|
||||
btn.y,
|
||||
btn.x + BUTTON_W,
|
||||
btn.y + BUTTON_H,
|
||||
bgColor
|
||||
);
|
||||
|
||||
// Border
|
||||
graphics.fill(
|
||||
btn.x,
|
||||
btn.y,
|
||||
btn.x + BUTTON_W,
|
||||
btn.y + 1,
|
||||
GuiColors.darken(btn.color, 0.3f)
|
||||
);
|
||||
graphics.fill(
|
||||
btn.x,
|
||||
btn.y + BUTTON_H - 1,
|
||||
btn.x + BUTTON_W,
|
||||
btn.y + BUTTON_H,
|
||||
GuiColors.darken(btn.color, 0.3f)
|
||||
);
|
||||
|
||||
// Text
|
||||
graphics.drawCenteredString(
|
||||
font,
|
||||
btn.text,
|
||||
btn.x + BUTTON_W / 2,
|
||||
btn.y + (BUTTON_H - 8) / 2,
|
||||
GuiColors.TEXT_WHITE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== STATUS HELPERS ====================
|
||||
|
||||
private record StatusIcon(String letter, int color) {}
|
||||
|
||||
private List<StatusIcon> buildStatusList() {
|
||||
List<StatusIcon> statuses = new ArrayList<>();
|
||||
|
||||
if (slave.isTiedUp()) statuses.add(new StatusIcon("B", COLOR_BOUND));
|
||||
if (slave.isGagged()) statuses.add(new StatusIcon("G", COLOR_GAGGED));
|
||||
if (slave.isBlindfolded()) statuses.add(
|
||||
new StatusIcon("X", COLOR_BLIND)
|
||||
);
|
||||
if (slave.hasEarplugs()) statuses.add(new StatusIcon("D", COLOR_DEAF));
|
||||
if (slave.hasCollar()) statuses.add(new StatusIcon("C", COLOR_COLLAR));
|
||||
if (slave.hasMittens()) statuses.add(
|
||||
new StatusIcon("M", COLOR_MITTENS)
|
||||
);
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
private boolean hasShockCollar() {
|
||||
if (!slave.hasCollar()) return false;
|
||||
ItemStack collar = slave.getEquipment(BodyRegionV2.NECK);
|
||||
return (
|
||||
collar.getItem() instanceof ItemCollar itemCollar &&
|
||||
itemCollar.canShock()
|
||||
);
|
||||
}
|
||||
|
||||
private boolean hasGPSCollar() {
|
||||
if (!slave.hasCollar()) return false;
|
||||
ItemStack collar = slave.getEquipment(BodyRegionV2.NECK);
|
||||
return (
|
||||
collar.getItem() instanceof ItemCollar itemCollar &&
|
||||
itemCollar.hasGPS()
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isInAnySafeZone(
|
||||
ItemGpsCollar gps,
|
||||
ItemStack collarStack,
|
||||
LivingEntity entity
|
||||
) {
|
||||
if (!gps.isActive(collarStack)) return true;
|
||||
|
||||
var safeSpots = gps.getSafeSpots(collarStack);
|
||||
if (safeSpots.isEmpty()) return true;
|
||||
|
||||
for (var spot : safeSpots) {
|
||||
if (spot.isInside(entity)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== INPUT HANDLING ====================
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (button != 0 || cachedButtons == null) return false;
|
||||
|
||||
for (ButtonInfo btn : cachedButtons) {
|
||||
if (
|
||||
mouseX >= btn.x &&
|
||||
mouseX < btn.x + BUTTON_W &&
|
||||
mouseY >= btn.y &&
|
||||
mouseY < btn.y + BUTTON_H
|
||||
) {
|
||||
btn.action.run();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<? extends GuiEventListener> children() {
|
||||
return ImmutableList.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<? extends NarratableEntry> narratables() {
|
||||
return ImmutableList.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.tiedup.remake.client.gui.widgets;
|
||||
|
||||
import com.tiedup.remake.client.gui.util.GuiColors;
|
||||
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
|
||||
import com.tiedup.remake.items.GenericKnife;
|
||||
import com.tiedup.remake.items.ItemKey;
|
||||
import com.tiedup.remake.items.ItemLockpick;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.items.base.IHasResistance;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.AbstractWidget;
|
||||
import net.minecraft.client.gui.narration.NarratedElementType;
|
||||
import net.minecraft.client.gui.narration.NarrationElementOutput;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Bottom status bar for the unified bondage screen.
|
||||
* Self mode: tool status + resistance info.
|
||||
* Master mode: key info + target info.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class StatusBarWidget extends AbstractWidget {
|
||||
|
||||
private static final int PADDING = 8;
|
||||
|
||||
private ActionPanel.ScreenMode mode = ActionPanel.ScreenMode.SELF;
|
||||
private LivingEntity targetEntity;
|
||||
private Runnable onCloseClicked;
|
||||
|
||||
// Close button layout
|
||||
private static final int CLOSE_BTN_WIDTH = 100;
|
||||
private static final int CLOSE_BTN_HEIGHT = 22;
|
||||
|
||||
public StatusBarWidget(int x, int y, int width, int height) {
|
||||
super(x, y, width, height, Component.literal("Status Bar"));
|
||||
}
|
||||
|
||||
public void setMode(ActionPanel.ScreenMode mode) { this.mode = mode; }
|
||||
public void setTargetEntity(LivingEntity entity) { this.targetEntity = entity; }
|
||||
public void setOnCloseClicked(Runnable cb) { this.onCloseClicked = cb; }
|
||||
|
||||
@Override
|
||||
protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
Player player = mc.player;
|
||||
if (player == null) return;
|
||||
|
||||
// MC-style sunken inset panel (slightly darker than main)
|
||||
GuiRenderUtil.drawMCSunkenPanel(graphics, getX(), getY(), width, height);
|
||||
|
||||
int textY1 = getY() + PADDING;
|
||||
int textY2 = textY1 + mc.font.lineHeight + 2;
|
||||
|
||||
if (mode == ActionPanel.ScreenMode.SELF) {
|
||||
renderSelfStatus(graphics, player, textY1, textY2);
|
||||
} else {
|
||||
renderMasterStatus(graphics, player, textY1, textY2);
|
||||
}
|
||||
|
||||
// Close button (right side, MC-style button)
|
||||
int closeBtnX = getX() + width - CLOSE_BTN_WIDTH - PADDING;
|
||||
int closeBtnY = getY() + (height - CLOSE_BTN_HEIGHT) / 2;
|
||||
boolean closeHovered = mouseX >= closeBtnX && mouseX < closeBtnX + CLOSE_BTN_WIDTH
|
||||
&& mouseY >= closeBtnY && mouseY < closeBtnY + CLOSE_BTN_HEIGHT;
|
||||
GuiRenderUtil.drawMCButton(graphics, closeBtnX, closeBtnY, CLOSE_BTN_WIDTH, CLOSE_BTN_HEIGHT, closeHovered, true);
|
||||
String closeText = Component.translatable("gui.tiedup.close_esc").getString();
|
||||
GuiRenderUtil.drawCenteredStringNoShadow(graphics, mc.font, closeText,
|
||||
closeBtnX + CLOSE_BTN_WIDTH / 2, closeBtnY + (CLOSE_BTN_HEIGHT - mc.font.lineHeight) / 2,
|
||||
GuiRenderUtil.MC_TEXT_DARK);
|
||||
}
|
||||
|
||||
private void renderSelfStatus(GuiGraphics graphics, Player player, int y1, int y2) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
int x = getX() + PADDING;
|
||||
|
||||
// Tool status line 1
|
||||
ItemStack lockpick = ItemLockpick.findLockpickInInventory(player);
|
||||
String pickText = lockpick.isEmpty()
|
||||
? Component.translatable("gui.tiedup.status.no_lockpick").getString()
|
||||
: Component.translatable("gui.tiedup.status.lockpick_uses",
|
||||
lockpick.getMaxDamage() - lockpick.getDamageValue()).getString();
|
||||
graphics.drawString(mc.font, pickText, x, y1, GuiRenderUtil.MC_TEXT_DARK, false);
|
||||
|
||||
ItemStack knife = GenericKnife.findKnifeInInventory(player);
|
||||
String knifeText = knife.isEmpty()
|
||||
? Component.translatable("gui.tiedup.status.no_knife").getString()
|
||||
: Component.translatable("gui.tiedup.status.knife_uses",
|
||||
knife.getMaxDamage() - knife.getDamageValue()).getString();
|
||||
graphics.drawString(mc.font, knifeText, x + 150, y1, GuiRenderUtil.MC_TEXT_DARK, false);
|
||||
|
||||
// Arms resistance line 2
|
||||
ItemStack armsBind = V2EquipmentHelper.getInRegion(player, BodyRegionV2.ARMS);
|
||||
if (!armsBind.isEmpty() && armsBind.getItem() instanceof IHasResistance res) {
|
||||
int curr = res.getCurrentResistance(armsBind, player);
|
||||
int max = res.getBaseResistance(player);
|
||||
String resText = Component.translatable("gui.tiedup.status.arms_resistance", curr, max).getString();
|
||||
int color = curr < max * 0.3 ? GuiColors.ERROR : GuiColors.SUCCESS;
|
||||
graphics.drawString(mc.font, resText, x, y2, color, false);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderMasterStatus(GuiGraphics graphics, Player player, int y1, int y2) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
int x = getX() + PADDING;
|
||||
|
||||
// Key info — check for ItemKey first, then master key as fallback
|
||||
ItemStack keyStack = ItemStack.EMPTY;
|
||||
for (int i = 0; i < player.getInventory().getContainerSize(); i++) {
|
||||
ItemStack stack = player.getInventory().getItem(i);
|
||||
if (stack.isEmpty()) continue;
|
||||
if (stack.getItem() instanceof ItemKey) {
|
||||
keyStack = stack;
|
||||
break; // Regular key takes priority
|
||||
}
|
||||
if (keyStack.isEmpty() && stack.is(ModItems.MASTER_KEY.get())) {
|
||||
keyStack = stack; // Remember master key as fallback, keep scanning
|
||||
}
|
||||
}
|
||||
|
||||
String keyText;
|
||||
if (keyStack.isEmpty()) {
|
||||
keyText = Component.translatable("gui.tiedup.status.no_key").getString();
|
||||
} else {
|
||||
keyText = Component.translatable("gui.tiedup.status.key_info",
|
||||
keyStack.getHoverName().getString()).getString();
|
||||
}
|
||||
graphics.drawString(mc.font, keyText, x, y1, GuiRenderUtil.MC_TEXT_DARK, false);
|
||||
|
||||
// Target info
|
||||
if (targetEntity != null) {
|
||||
String targetText = Component.translatable("gui.tiedup.status.target_info",
|
||||
targetEntity.getName().getString()).getString();
|
||||
graphics.drawString(mc.font, targetText, x, y2, GuiRenderUtil.MC_TEXT_DARK, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (!isMouseOver(mouseX, mouseY) || button != 0) return false;
|
||||
|
||||
int closeBtnX = getX() + width - CLOSE_BTN_WIDTH - PADDING;
|
||||
int closeBtnY = getY() + (height - CLOSE_BTN_HEIGHT) / 2;
|
||||
if (mouseX >= closeBtnX && mouseX < closeBtnX + CLOSE_BTN_WIDTH
|
||||
&& mouseY >= closeBtnY && mouseY < closeBtnY + CLOSE_BTN_HEIGHT) {
|
||||
if (onCloseClicked != null) onCloseClicked.run();
|
||||
playDownSound(Minecraft.getInstance().getSoundManager());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateWidgetNarration(NarrationElementOutput output) {
|
||||
output.add(NarratedElementType.TITLE, Component.translatable("gui.tiedup.status_bar"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.tiedup.remake.client.model;
|
||||
|
||||
import java.util.List;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.client.renderer.block.model.BakedQuad;
|
||||
import net.minecraft.client.renderer.block.model.ItemOverrides;
|
||||
import net.minecraft.client.renderer.block.model.ItemTransforms;
|
||||
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
|
||||
import net.minecraft.client.resources.model.BakedModel;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraftforge.client.ChunkRenderTypeSet;
|
||||
import net.minecraftforge.client.model.IDynamicBakedModel;
|
||||
import net.minecraftforge.client.model.data.ModelData;
|
||||
import net.minecraftforge.client.model.data.ModelProperty;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Dynamic baked model for CellCore that supports camouflage.
|
||||
*
|
||||
* When a disguise BlockState is provided via ModelData, renders
|
||||
* the disguise block's quads instead of the default cell_core model.
|
||||
* This makes the Cell Core visually blend into surrounding walls.
|
||||
*/
|
||||
public class CellCoreBakedModel implements IDynamicBakedModel {
|
||||
|
||||
/** @deprecated Use {@link com.tiedup.remake.blocks.entity.CellCoreBlockEntity#DISGUISE_PROPERTY} */
|
||||
@Deprecated
|
||||
public static final ModelProperty<BlockState> DISGUISE_PROPERTY =
|
||||
com.tiedup.remake.blocks.entity.CellCoreBlockEntity.DISGUISE_PROPERTY;
|
||||
|
||||
private final BakedModel original;
|
||||
|
||||
public CellCoreBakedModel(BakedModel original) {
|
||||
this.original = original;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull List<BakedQuad> getQuads(
|
||||
@Nullable BlockState state,
|
||||
@Nullable Direction side,
|
||||
@NotNull RandomSource rand,
|
||||
@NotNull ModelData modelData,
|
||||
@Nullable RenderType renderType
|
||||
) {
|
||||
BlockState disguise = modelData.get(DISGUISE_PROPERTY);
|
||||
if (disguise != null) {
|
||||
BakedModel disguiseModel =
|
||||
net.minecraft.client.Minecraft.getInstance()
|
||||
.getBlockRenderer()
|
||||
.getBlockModel(disguise);
|
||||
return disguiseModel.getQuads(
|
||||
disguise,
|
||||
side,
|
||||
rand,
|
||||
ModelData.EMPTY,
|
||||
renderType
|
||||
);
|
||||
}
|
||||
return original.getQuads(
|
||||
state,
|
||||
side,
|
||||
rand,
|
||||
ModelData.EMPTY,
|
||||
renderType
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean useAmbientOcclusion() {
|
||||
return original.useAmbientOcclusion();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isGui3d() {
|
||||
return original.isGui3d();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean usesBlockLight() {
|
||||
return original.usesBlockLight();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCustomRenderer() {
|
||||
return original.isCustomRenderer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TextureAtlasSprite getParticleIcon() {
|
||||
return original.getParticleIcon();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TextureAtlasSprite getParticleIcon(@NotNull ModelData modelData) {
|
||||
BlockState disguise = modelData.get(DISGUISE_PROPERTY);
|
||||
if (disguise != null) {
|
||||
BakedModel disguiseModel =
|
||||
net.minecraft.client.Minecraft.getInstance()
|
||||
.getBlockRenderer()
|
||||
.getBlockModel(disguise);
|
||||
return disguiseModel.getParticleIcon(ModelData.EMPTY);
|
||||
}
|
||||
return original.getParticleIcon();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ItemOverrides getOverrides() {
|
||||
return original.getOverrides();
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@Override
|
||||
public ItemTransforms getTransforms() {
|
||||
return original.getTransforms();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull ChunkRenderTypeSet getRenderTypes(
|
||||
@NotNull BlockState state,
|
||||
@NotNull RandomSource rand,
|
||||
@NotNull ModelData data
|
||||
) {
|
||||
BlockState disguise = data.get(DISGUISE_PROPERTY);
|
||||
if (disguise != null) {
|
||||
BakedModel disguiseModel =
|
||||
net.minecraft.client.Minecraft.getInstance()
|
||||
.getBlockRenderer()
|
||||
.getBlockModel(disguise);
|
||||
return disguiseModel.getRenderTypes(
|
||||
disguise,
|
||||
rand,
|
||||
ModelData.EMPTY
|
||||
);
|
||||
}
|
||||
return original.getRenderTypes(state, rand, ModelData.EMPTY);
|
||||
}
|
||||
}
|
||||
582
src/main/java/com/tiedup/remake/client/model/DamselModel.java
Normal file
582
src/main/java/com/tiedup/remake/client/model/DamselModel.java
Normal file
@@ -0,0 +1,582 @@
|
||||
package com.tiedup.remake.client.model;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import com.mojang.logging.LogUtils;
|
||||
import com.tiedup.remake.client.animation.StaticPoseApplier;
|
||||
import com.tiedup.remake.client.animation.util.DogPoseHelper;
|
||||
import com.tiedup.remake.entities.AbstractTiedUpNpc;
|
||||
import com.tiedup.remake.entities.EntityKidnapperArcher;
|
||||
import com.tiedup.remake.entities.EntityMaster;
|
||||
import com.tiedup.remake.entities.ai.master.MasterState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.items.clothes.GenericClothes;
|
||||
import dev.kosmx.playerAnim.core.impl.AnimationProcessor;
|
||||
import dev.kosmx.playerAnim.core.util.SetableSupplier;
|
||||
import dev.kosmx.playerAnim.impl.Helper;
|
||||
import dev.kosmx.playerAnim.impl.IMutableModel;
|
||||
import dev.kosmx.playerAnim.impl.IUpperPartHelper;
|
||||
import dev.kosmx.playerAnim.impl.animation.AnimationApplier;
|
||||
import dev.kosmx.playerAnim.impl.animation.IBendHelper;
|
||||
import net.minecraft.client.model.HumanoidModel;
|
||||
import net.minecraft.client.model.PlayerModel;
|
||||
import net.minecraft.client.model.geom.ModelPart;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Model for AbstractTiedUpNpc - Humanoid female NPC.
|
||||
*
|
||||
* Phase 14.2.3: Rendering system
|
||||
* Phase 19: Extends PlayerModel for full layer support (hat, jacket, sleeves, pants)
|
||||
*
|
||||
* Features:
|
||||
* - Extends PlayerModel for player-like rendering with outer layers
|
||||
* - Supports both normal (4px) and slim (3px) arm widths
|
||||
* - Modifies animations based on bondage state
|
||||
* - Has jacket, sleeves, pants layers like player skins
|
||||
*
|
||||
* Uses vanilla ModelLayers.PLAYER / PLAYER_SLIM for geometry.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class DamselModel
|
||||
extends PlayerModel<AbstractTiedUpNpc>
|
||||
implements IMutableModel
|
||||
{
|
||||
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static boolean loggedBendyStatus = false;
|
||||
|
||||
/** Track if bendy-lib has been initialized for this model */
|
||||
private boolean bendyLibInitialized = false;
|
||||
|
||||
/** Emote supplier for bending support - required by IMutableModel */
|
||||
private final SetableSupplier<AnimationProcessor> emoteSupplier =
|
||||
new SetableSupplier<>();
|
||||
|
||||
/**
|
||||
* Create model from baked model part.
|
||||
*
|
||||
* @param root The root model part (baked from vanilla PLAYER layer)
|
||||
* @param slim Whether this is a slim (Alex) arms model
|
||||
*/
|
||||
public DamselModel(ModelPart root, boolean slim) {
|
||||
super(root, slim);
|
||||
initBendyLib(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize bendy-lib bend points on model parts.
|
||||
*
|
||||
* <p>This enables visual bending of knees and elbows when animations
|
||||
* specify bend values. Without this initialization, bend values in
|
||||
* animation JSON files have no visual effect.
|
||||
*
|
||||
* <p>Also marks upper parts (head, arms, hat) for proper bend rendering.
|
||||
*
|
||||
* @param root The root model part
|
||||
*/
|
||||
private void initBendyLib(ModelPart root) {
|
||||
if (bendyLibInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if bendy-lib is available
|
||||
if (IBendHelper.INSTANCE == null) {
|
||||
if (!loggedBendyStatus) {
|
||||
LOGGER.warn(
|
||||
"[DamselModel] IBendHelper.INSTANCE is null - bendy-lib not available"
|
||||
);
|
||||
loggedBendyStatus = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Log bendy-lib status
|
||||
if (!loggedBendyStatus) {
|
||||
LOGGER.info(
|
||||
"[DamselModel] IBendHelper.INSTANCE class: {}",
|
||||
IBendHelper.INSTANCE.getClass().getName()
|
||||
);
|
||||
LOGGER.info(
|
||||
"[DamselModel] Helper.isBendEnabled(): {}",
|
||||
Helper.isBendEnabled()
|
||||
);
|
||||
loggedBendyStatus = true;
|
||||
}
|
||||
|
||||
// Initialize bend points for each body part
|
||||
// Direction indicates which end of the limb bends
|
||||
IBendHelper.INSTANCE.initBend(
|
||||
root.getChild("body"),
|
||||
Direction.DOWN
|
||||
);
|
||||
IBendHelper.INSTANCE.initBend(
|
||||
root.getChild("right_arm"),
|
||||
Direction.UP
|
||||
);
|
||||
IBendHelper.INSTANCE.initBend(
|
||||
root.getChild("left_arm"),
|
||||
Direction.UP
|
||||
);
|
||||
IBendHelper.INSTANCE.initBend(
|
||||
root.getChild("right_leg"),
|
||||
Direction.UP
|
||||
);
|
||||
IBendHelper.INSTANCE.initBend(
|
||||
root.getChild("left_leg"),
|
||||
Direction.UP
|
||||
);
|
||||
|
||||
// Mark upper parts for proper bend rendering
|
||||
// These parts will be rendered after applying body bend rotation
|
||||
((IUpperPartHelper) (Object) this.rightArm).setUpperPart(true);
|
||||
((IUpperPartHelper) (Object) this.leftArm).setUpperPart(true);
|
||||
((IUpperPartHelper) (Object) this.head).setUpperPart(true);
|
||||
((IUpperPartHelper) (Object) this.hat).setUpperPart(true);
|
||||
|
||||
LOGGER.info("[DamselModel] bendy-lib initialized successfully");
|
||||
bendyLibInitialized = true;
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[DamselModel] bendy-lib initialization failed", e);
|
||||
// bendy-lib not available or initialization failed
|
||||
// Animations will still work, just without visual bending
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// IMutableModel Implementation
|
||||
// ========================================
|
||||
|
||||
@Override
|
||||
public void setEmoteSupplier(SetableSupplier<AnimationProcessor> supplier) {
|
||||
if (supplier != null && supplier.get() != null) {
|
||||
this.emoteSupplier.set(supplier.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetableSupplier<AnimationProcessor> getEmoteSupplier() {
|
||||
return this.emoteSupplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup animations for the damsel.
|
||||
*
|
||||
* Modifies arm and leg positions based on bondage state:
|
||||
* - Tied up: Arms behind back, legs frozen (or variant pose based on bind type)
|
||||
* - Free: Normal humanoid animations
|
||||
*
|
||||
* Phase 15: Different poses for different bind types (straitjacket, wrap, latex_sack)
|
||||
* Phase 15.1: Hide arms for wrap/latex_sack (matching original mod)
|
||||
*
|
||||
* @param entity AbstractTiedUpNpc instance
|
||||
* @param limbSwing Limb swing animation value
|
||||
* @param limbSwingAmount Limb swing amount
|
||||
* @param ageInTicks Age in ticks for idle animations
|
||||
* @param netHeadYaw Head yaw rotation
|
||||
* @param headPitch Head pitch rotation
|
||||
*/
|
||||
@Override
|
||||
public void setupAnim(
|
||||
AbstractTiedUpNpc entity,
|
||||
float limbSwing,
|
||||
float limbSwingAmount,
|
||||
float ageInTicks,
|
||||
float netHeadYaw,
|
||||
float headPitch
|
||||
) {
|
||||
// Phase 18: Handle archer arm poses BEFORE super call
|
||||
// Only show bow animation when in ranged mode (has active shooting target)
|
||||
if (entity instanceof EntityKidnapperArcher archer) {
|
||||
if (archer.isInRangedMode()) {
|
||||
// In ranged mode: show bow animation
|
||||
// isAiming() indicates full draw, otherwise ready position
|
||||
this.rightArmPose = HumanoidModel.ArmPose.BOW_AND_ARROW;
|
||||
this.leftArmPose = HumanoidModel.ArmPose.BOW_AND_ARROW;
|
||||
} else {
|
||||
// Not in ranged mode: reset to normal poses (no bow animation)
|
||||
this.rightArmPose = HumanoidModel.ArmPose.EMPTY;
|
||||
this.leftArmPose = HumanoidModel.ArmPose.EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
// Call parent to setup base humanoid animations
|
||||
super.setupAnim(
|
||||
entity,
|
||||
limbSwing,
|
||||
limbSwingAmount,
|
||||
ageInTicks,
|
||||
netHeadYaw,
|
||||
headPitch
|
||||
);
|
||||
|
||||
// Reset all visibility (may have been hidden in previous frame)
|
||||
// Arms
|
||||
this.leftArm.visible = true;
|
||||
this.rightArm.visible = true;
|
||||
// Outer layers (Phase 19)
|
||||
this.hat.visible = true;
|
||||
this.jacket.visible = true;
|
||||
this.leftSleeve.visible = true;
|
||||
this.rightSleeve.visible = true;
|
||||
this.leftPants.visible = true;
|
||||
this.rightPants.visible = true;
|
||||
|
||||
// Animation triggering is handled by NpcAnimationTickHandler (tick-based).
|
||||
// This method only applies transforms, static pose fallback, and layer syncing.
|
||||
boolean inPose =
|
||||
entity.isTiedUp() || entity.isSitting() || entity.isKneeling();
|
||||
|
||||
if (inPose) {
|
||||
ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS);
|
||||
PoseType poseType = PoseType.STANDARD;
|
||||
|
||||
if (bind.getItem() instanceof ItemBind itemBind) {
|
||||
poseType = itemBind.getPoseType();
|
||||
}
|
||||
|
||||
// Hide arms for wrap/latex_sack poses
|
||||
if (poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK) {
|
||||
this.leftArm.visible = false;
|
||||
this.rightArm.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply animation transforms via PlayerAnimator's emote.updatePart()
|
||||
// AbstractTiedUpNpc implements IAnimatedPlayer, so we can call directly
|
||||
AnimationApplier emote = entity.playerAnimator_getAnimation();
|
||||
boolean emoteActive = emote != null && emote.isActive();
|
||||
|
||||
// Track current pose type for DOG pose compensation
|
||||
PoseType currentPoseType = PoseType.STANDARD;
|
||||
if (inPose) {
|
||||
ItemStack bindForPoseType = entity.getEquipment(BodyRegionV2.ARMS);
|
||||
if (bindForPoseType.getItem() instanceof ItemBind itemBindForType) {
|
||||
currentPoseType = itemBindForType.getPoseType();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a Master in human chair mode (head should look around freely)
|
||||
boolean isMasterChairAnim =
|
||||
entity instanceof EntityMaster masterEnt &&
|
||||
masterEnt.getMasterState() == MasterState.HUMAN_CHAIR &&
|
||||
masterEnt.isSitting();
|
||||
|
||||
if (emoteActive) {
|
||||
// Animation is active - apply transforms via AnimationApplier
|
||||
this.emoteSupplier.set(emote);
|
||||
|
||||
// Apply transforms to each body part
|
||||
// Skip head for master chair animation — let vanilla look-at control the head
|
||||
if (!isMasterChairAnim) {
|
||||
emote.updatePart("head", this.head);
|
||||
}
|
||||
emote.updatePart("torso", this.body);
|
||||
emote.updatePart("leftArm", this.leftArm);
|
||||
emote.updatePart("rightArm", this.rightArm);
|
||||
emote.updatePart("leftLeg", this.leftLeg);
|
||||
emote.updatePart("rightLeg", this.rightLeg);
|
||||
|
||||
// DOG pose: PoseStack handles body rotation in setupRotations()
|
||||
// Reset body.xRot to prevent double rotation from animation
|
||||
// Apply head compensation so head looks forward instead of at ground
|
||||
if (currentPoseType == PoseType.DOG) {
|
||||
// Reset body rotation (PoseStack already rotates everything)
|
||||
this.body.xRot = 0;
|
||||
|
||||
// Head compensation: body is horizontal via PoseStack
|
||||
// Head needs to look forward instead of at the ground
|
||||
DogPoseHelper.applyHeadCompensation(
|
||||
this.head,
|
||||
null, // hat is synced via copyFrom below
|
||||
headPitch,
|
||||
netHeadYaw
|
||||
);
|
||||
}
|
||||
|
||||
// Sync outer layers to their parents (Phase 19)
|
||||
this.hat.copyFrom(this.head);
|
||||
this.jacket.copyFrom(this.body);
|
||||
this.leftSleeve.copyFrom(this.leftArm);
|
||||
this.rightSleeve.copyFrom(this.rightArm);
|
||||
this.leftPants.copyFrom(this.leftLeg);
|
||||
this.rightPants.copyFrom(this.rightLeg);
|
||||
} else if (inPose) {
|
||||
// Animation not yet active (1-frame delay) - apply static pose as fallback
|
||||
// This ensures immediate visual feedback when bind is applied
|
||||
ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS);
|
||||
PoseType fallbackPoseType = PoseType.STANDARD;
|
||||
|
||||
if (bind.getItem() instanceof ItemBind itemBind) {
|
||||
fallbackPoseType = itemBind.getPoseType();
|
||||
}
|
||||
|
||||
// Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder)
|
||||
boolean armsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.ARMS);
|
||||
boolean legsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.LEGS);
|
||||
|
||||
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) {
|
||||
armsBound = ItemBind.hasArmsBound(bind);
|
||||
legsBound = ItemBind.hasLegsBound(bind);
|
||||
}
|
||||
|
||||
// Apply static pose directly to model parts
|
||||
StaticPoseApplier.applyStaticPose(
|
||||
this,
|
||||
fallbackPoseType,
|
||||
armsBound,
|
||||
legsBound
|
||||
);
|
||||
|
||||
// DOG pose: Apply head compensation for horizontal body (same as emote case)
|
||||
if (fallbackPoseType == PoseType.DOG) {
|
||||
// Reset body rotation (PoseStack handles it)
|
||||
this.body.xRot = 0;
|
||||
|
||||
// Head compensation for horizontal body
|
||||
DogPoseHelper.applyHeadCompensation(
|
||||
this.head,
|
||||
null, // hat is synced via copyFrom below
|
||||
headPitch,
|
||||
netHeadYaw
|
||||
);
|
||||
}
|
||||
|
||||
// Sync outer layers after static pose
|
||||
this.hat.copyFrom(this.head);
|
||||
this.jacket.copyFrom(this.body);
|
||||
this.leftSleeve.copyFrom(this.leftArm);
|
||||
this.rightSleeve.copyFrom(this.rightArm);
|
||||
this.leftPants.copyFrom(this.leftLeg);
|
||||
this.rightPants.copyFrom(this.rightLeg);
|
||||
|
||||
// Clear emote supplier since we're using static pose
|
||||
this.emoteSupplier.set(null);
|
||||
} else {
|
||||
// Not in pose and no animation - clear emote supplier and reset bends
|
||||
this.emoteSupplier.set(null);
|
||||
resetBends();
|
||||
|
||||
// Sync outer layers
|
||||
this.hat.copyFrom(this.head);
|
||||
this.jacket.copyFrom(this.body);
|
||||
this.leftSleeve.copyFrom(this.leftArm);
|
||||
this.rightSleeve.copyFrom(this.rightArm);
|
||||
this.leftPants.copyFrom(this.leftLeg);
|
||||
this.rightPants.copyFrom(this.rightLeg);
|
||||
}
|
||||
|
||||
// Phase 19: Hide wearer's outer layers based on clothes settings
|
||||
// This MUST happen after super.setupAnim() which can reset visibility
|
||||
hideWearerLayersForClothes(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide wearer's outer layers when clothes are equipped.
|
||||
* Called at the end of setupAnim() to ensure it happens after any visibility resets.
|
||||
*
|
||||
* <p>Logic: When clothes are equipped, hide ALL wearer's outer layers.
|
||||
* The clothes will render their own layers on top.
|
||||
* Exception: If keepHead is enabled, the head/hat layers remain visible.
|
||||
*
|
||||
* @param entity The entity wearing clothes
|
||||
*/
|
||||
private void hideWearerLayersForClothes(AbstractTiedUpNpc entity) {
|
||||
ItemStack clothes = entity.getEquipment(BodyRegionV2.TORSO);
|
||||
if (
|
||||
clothes.isEmpty() ||
|
||||
!(clothes.getItem() instanceof GenericClothes gc)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if keepHead is enabled
|
||||
boolean keepHead = gc.isKeepHeadEnabled(clothes);
|
||||
|
||||
// When wearing clothes, hide wearer's outer layers
|
||||
// Exception: if keepHead is true, don't hide head/hat
|
||||
if (!keepHead) {
|
||||
this.hat.visible = false;
|
||||
}
|
||||
this.jacket.visible = false;
|
||||
this.leftSleeve.visible = false;
|
||||
this.rightSleeve.visible = false;
|
||||
this.leftPants.visible = false;
|
||||
this.rightPants.visible = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset bend values on all body parts.
|
||||
* Called when animation stops to prevent lingering bend effects.
|
||||
*/
|
||||
private void resetBends() {
|
||||
if (IBendHelper.INSTANCE == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
IBendHelper.INSTANCE.bend(this.body, null);
|
||||
IBendHelper.INSTANCE.bend(this.leftArm, null);
|
||||
IBendHelper.INSTANCE.bend(this.rightArm, null);
|
||||
IBendHelper.INSTANCE.bend(this.leftLeg, null);
|
||||
IBendHelper.INSTANCE.bend(this.rightLeg, null);
|
||||
} catch (Exception e) {
|
||||
LOGGER.debug(
|
||||
"[DamselModel] bendy-lib not available for bend reset",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override renderToBuffer to apply body bend rotation.
|
||||
*
|
||||
* <p>When an animation has a body bend value, we need to:
|
||||
* 1. Render non-upper parts (legs) normally
|
||||
* 2. Apply the bend rotation to the matrix stack
|
||||
* 3. Render upper parts (head, arms, body) with the rotation applied
|
||||
*
|
||||
* <p>This creates the visual effect of the body bending (like kneeling).
|
||||
*/
|
||||
@Override
|
||||
public void renderToBuffer(
|
||||
PoseStack matrices,
|
||||
VertexConsumer vertices,
|
||||
int light,
|
||||
int overlay,
|
||||
float red,
|
||||
float green,
|
||||
float blue,
|
||||
float alpha
|
||||
) {
|
||||
// Check if we should use bend rendering
|
||||
if (
|
||||
Helper.isBendEnabled() &&
|
||||
emoteSupplier.get() != null &&
|
||||
emoteSupplier.get().isActive()
|
||||
) {
|
||||
// Render with bend support
|
||||
renderWithBend(
|
||||
matrices,
|
||||
vertices,
|
||||
light,
|
||||
overlay,
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
alpha
|
||||
);
|
||||
} else {
|
||||
// Normal rendering
|
||||
super.renderToBuffer(
|
||||
matrices,
|
||||
vertices,
|
||||
light,
|
||||
overlay,
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
alpha
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render model parts with body bend applied.
|
||||
*
|
||||
* <p>Based on PlayerAnimator's bendRenderToBuffer logic:
|
||||
* - First render non-upper parts (legs) normally
|
||||
* - Then apply body bend rotation
|
||||
* - Then render upper parts (head, body, arms) with rotation
|
||||
*/
|
||||
private void renderWithBend(
|
||||
PoseStack matrices,
|
||||
VertexConsumer vertices,
|
||||
int light,
|
||||
int overlay,
|
||||
float red,
|
||||
float green,
|
||||
float blue,
|
||||
float alpha
|
||||
) {
|
||||
// Get all body parts
|
||||
Iterable<ModelPart> headParts = headParts();
|
||||
Iterable<ModelPart> bodyParts = bodyParts();
|
||||
|
||||
// First pass: render non-upper parts (legs)
|
||||
for (ModelPart part : headParts) {
|
||||
if (!((IUpperPartHelper) (Object) part).isUpperPart()) {
|
||||
part.render(
|
||||
matrices,
|
||||
vertices,
|
||||
light,
|
||||
overlay,
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
alpha
|
||||
);
|
||||
}
|
||||
}
|
||||
for (ModelPart part : bodyParts) {
|
||||
if (!((IUpperPartHelper) (Object) part).isUpperPart()) {
|
||||
part.render(
|
||||
matrices,
|
||||
vertices,
|
||||
light,
|
||||
overlay,
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
alpha
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply body bend rotation
|
||||
matrices.pushPose();
|
||||
IBendHelper.rotateMatrixStack(
|
||||
matrices,
|
||||
emoteSupplier.get().getBend("body")
|
||||
);
|
||||
|
||||
// Second pass: render upper parts (head, body, arms) with bend applied
|
||||
for (ModelPart part : headParts) {
|
||||
if (((IUpperPartHelper) (Object) part).isUpperPart()) {
|
||||
part.render(
|
||||
matrices,
|
||||
vertices,
|
||||
light,
|
||||
overlay,
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
alpha
|
||||
);
|
||||
}
|
||||
}
|
||||
for (ModelPart part : bodyParts) {
|
||||
if (((IUpperPartHelper) (Object) part).isUpperPart()) {
|
||||
part.render(
|
||||
matrices,
|
||||
vertices,
|
||||
light,
|
||||
overlay,
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
alpha
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
matrices.popPose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.tiedup.remake.client.network;
|
||||
|
||||
import com.tiedup.remake.client.gui.screens.PetRequestScreen;
|
||||
import java.util.List;
|
||||
// import com.tiedup.remake.client.gui.screens.ConversationScreen; // DISABLED: Conversation system not in use
|
||||
// import com.tiedup.remake.dialogue.conversation.ConversationTopic; // DISABLED: Conversation system not in use
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Client-side packet handling helper.
|
||||
*
|
||||
* This class is ONLY loaded on the client, preventing server-side crashes
|
||||
* from attempting to load client-only classes like Screen.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ClientPacketHandler {
|
||||
|
||||
/**
|
||||
* Open the conversation screen.
|
||||
* DISABLED: Conversation system not in use
|
||||
*/
|
||||
/*
|
||||
public static void openConversationScreen(int entityId, String npcName, List<ConversationTopic> topics) {
|
||||
Minecraft.getInstance().setScreen(
|
||||
new ConversationScreen(entityId, npcName, topics)
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Open the pet request menu.
|
||||
*/
|
||||
public static void openPetRequestScreen(int entityId, String masterName) {
|
||||
Minecraft.getInstance().setScreen(
|
||||
new PetRequestScreen(entityId, masterName)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.tiedup.remake.client.renderer;
|
||||
|
||||
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import com.mojang.blaze3d.vertex.VertexFormat;
|
||||
import com.tiedup.remake.blocks.entity.CellCoreBlockEntity;
|
||||
import net.minecraft.client.renderer.GameRenderer;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.RenderStateShard;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.world.level.block.SlabBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.SlabType;
|
||||
import org.joml.Matrix4f;
|
||||
|
||||
/**
|
||||
* Block entity renderer for CellCore.
|
||||
*
|
||||
* Renders a small pulsing cyan diamond indicator on the interior face
|
||||
* of the Cell Core block. This helps players identify which side of the
|
||||
* block faces into the cell, and confirms the block is a Cell Core.
|
||||
*
|
||||
* Extends RenderStateShard to access protected render state fields
|
||||
* (standard Forge pattern for custom RenderTypes).
|
||||
*/
|
||||
public class CellCoreRenderer
|
||||
extends RenderStateShard
|
||||
implements BlockEntityRenderer<CellCoreBlockEntity>
|
||||
{
|
||||
|
||||
private static final float DIAMOND_SIZE = 0.15f;
|
||||
private static final float FACE_OFFSET = 0.001f;
|
||||
|
||||
// Cyan indicator color (#44FFFF)
|
||||
private static final float COLOR_R = 0.267f;
|
||||
private static final float COLOR_G = 1.0f;
|
||||
private static final float COLOR_B = 1.0f;
|
||||
|
||||
/**
|
||||
* Custom RenderType: POSITION_COLOR quads with translucent blending, no texture.
|
||||
* Avoids block atlas UV issues that made the diamond invisible.
|
||||
*/
|
||||
private static final RenderType INDICATOR = RenderType.create(
|
||||
"tiedup_indicator",
|
||||
DefaultVertexFormat.POSITION_COLOR,
|
||||
VertexFormat.Mode.QUADS,
|
||||
256,
|
||||
false,
|
||||
true,
|
||||
RenderType.CompositeState.builder()
|
||||
.setShaderState(
|
||||
new ShaderStateShard(GameRenderer::getPositionColorShader)
|
||||
)
|
||||
.setTransparencyState(TRANSLUCENT_TRANSPARENCY)
|
||||
.setCullState(NO_CULL)
|
||||
.setDepthTestState(LEQUAL_DEPTH_TEST)
|
||||
.setWriteMaskState(COLOR_DEPTH_WRITE)
|
||||
.createCompositeState(false)
|
||||
);
|
||||
|
||||
public CellCoreRenderer(BlockEntityRendererProvider.Context context) {
|
||||
super("tiedup_cell_core_renderer", () -> {}, () -> {});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
CellCoreBlockEntity blockEntity,
|
||||
float partialTick,
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource bufferSource,
|
||||
int packedLight,
|
||||
int packedOverlay
|
||||
) {
|
||||
Direction interiorFace = blockEntity.getInteriorFace();
|
||||
if (interiorFace == null) return;
|
||||
if (blockEntity.getLevel() == null) return;
|
||||
|
||||
// Calculate pulsing alpha synced to game time (pauses when game pauses)
|
||||
float time = blockEntity.getLevel().getGameTime() + partialTick;
|
||||
float alpha = 0.4f + 0.2f * (float) Math.sin(time * 0.15);
|
||||
|
||||
// Compute vertical center: adapt to slab shape if disguised as a slab
|
||||
float centerY = 0.5f;
|
||||
BlockState disguise = blockEntity.getDisguiseState();
|
||||
if (disguise == null && blockEntity.getLevel() != null) {
|
||||
// Auto-detect: check resolved model data (same logic as CellCoreBlockEntity)
|
||||
disguise = blockEntity
|
||||
.getModelData()
|
||||
.get(
|
||||
com.tiedup.remake.client.model.CellCoreBakedModel.DISGUISE_PROPERTY
|
||||
);
|
||||
}
|
||||
if (disguise != null && disguise.getBlock() instanceof SlabBlock) {
|
||||
SlabType slabType = disguise.getValue(SlabBlock.TYPE);
|
||||
if (slabType == SlabType.BOTTOM) {
|
||||
centerY = 0.25f; // lower half
|
||||
} else if (slabType == SlabType.TOP) {
|
||||
centerY = 0.75f; // upper half
|
||||
}
|
||||
// DOUBLE = 0.5f (full block)
|
||||
}
|
||||
|
||||
poseStack.pushPose();
|
||||
|
||||
VertexConsumer consumer = bufferSource.getBuffer(INDICATOR);
|
||||
Matrix4f pose = poseStack.last().pose();
|
||||
|
||||
renderDiamond(consumer, pose, interiorFace, alpha, centerY);
|
||||
|
||||
poseStack.popPose();
|
||||
}
|
||||
|
||||
private void renderDiamond(
|
||||
VertexConsumer consumer,
|
||||
Matrix4f pose,
|
||||
Direction face,
|
||||
float alpha,
|
||||
float centerY
|
||||
) {
|
||||
float[][] verts = getDiamondVertices(face, 0.5f, centerY, 0.5f);
|
||||
|
||||
for (float[] v : verts) {
|
||||
consumer
|
||||
.vertex(pose, v[0], v[1], v[2])
|
||||
.color(COLOR_R, COLOR_G, COLOR_B, alpha)
|
||||
.endVertex();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the 4 vertices of a diamond shape on the given face.
|
||||
* The diamond is centered on the face and offset slightly outward to avoid z-fighting.
|
||||
*/
|
||||
private float[][] getDiamondVertices(
|
||||
Direction face,
|
||||
float cx,
|
||||
float cy,
|
||||
float cz
|
||||
) {
|
||||
float s = DIAMOND_SIZE;
|
||||
float o = FACE_OFFSET;
|
||||
|
||||
return switch (face) {
|
||||
case NORTH -> new float[][] {
|
||||
{ cx, cy + s, 0.0f - o }, // top
|
||||
{ cx + s, cy, 0.0f - o }, // right
|
||||
{ cx, cy - s, 0.0f - o }, // bottom
|
||||
{ cx - s, cy, 0.0f - o }, // left
|
||||
};
|
||||
case SOUTH -> new float[][] {
|
||||
{ cx, cy + s, 1.0f + o },
|
||||
{ cx - s, cy, 1.0f + o },
|
||||
{ cx, cy - s, 1.0f + o },
|
||||
{ cx + s, cy, 1.0f + o },
|
||||
};
|
||||
case WEST -> new float[][] {
|
||||
{ 0.0f - o, cy + s, cz },
|
||||
{ 0.0f - o, cy, cz - s },
|
||||
{ 0.0f - o, cy - s, cz },
|
||||
{ 0.0f - o, cy, cz + s },
|
||||
};
|
||||
case EAST -> new float[][] {
|
||||
{ 1.0f + o, cy + s, cz },
|
||||
{ 1.0f + o, cy, cz + s },
|
||||
{ 1.0f + o, cy - s, cz },
|
||||
{ 1.0f + o, cy, cz - s },
|
||||
};
|
||||
case DOWN -> {
|
||||
// Bottom face: y=0.0 for full blocks & bottom slabs, y=0.5 for top slabs only
|
||||
float downY = (cy >= 0.75f) ? 0.5f - o : 0.0f - o;
|
||||
yield new float[][] {
|
||||
{ cx, downY, cz + s },
|
||||
{ cx + s, downY, cz },
|
||||
{ cx, downY, cz - s },
|
||||
{ cx - s, downY, cz },
|
||||
};
|
||||
}
|
||||
case UP -> {
|
||||
// Top face: y=1.0 for full blocks & top slabs, y=0.5 for bottom slabs only
|
||||
float upY = (cy <= 0.25f) ? 0.5f + o : 1.0f + o;
|
||||
yield new float[][] {
|
||||
{ cx, upY, cz - s },
|
||||
{ cx + s, upY, cz },
|
||||
{ cx, upY, cz + s },
|
||||
{ cx - s, upY, cz },
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package com.tiedup.remake.client.renderer;
|
||||
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import com.mojang.blaze3d.vertex.*;
|
||||
import com.tiedup.remake.cells.CellDataV2;
|
||||
import com.tiedup.remake.cells.MarkerType;
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import net.minecraft.client.Camera;
|
||||
import net.minecraft.client.renderer.GameRenderer;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.joml.Matrix4f;
|
||||
|
||||
/**
|
||||
* Utility class for rendering cell outlines in the world.
|
||||
*
|
||||
* Simplified renderer - only filled blocks, no wireframe outlines.
|
||||
* Each marker type gets a semi-transparent colored block overlay.
|
||||
* Spawn point has a pulsating effect.
|
||||
*
|
||||
* Features:
|
||||
* - Depth test enabled (blocks hidden behind world geometry)
|
||||
* - Simple filled block rendering per marker type
|
||||
* - Pulsating spawn point indicator
|
||||
*/
|
||||
public class CellOutlineRenderer {
|
||||
|
||||
/** Enable depth test (true = blocks hidden behind world blocks) */
|
||||
public static boolean DEPTH_TEST_ENABLED = true;
|
||||
|
||||
// Simple colors per type (RGBA)
|
||||
private static final float[] COLOR_WALL = { 0.3f, 0.5f, 1.0f, 1.0f }; // Blue
|
||||
private static final float[] COLOR_ANCHOR = { 1.0f, 0.2f, 0.2f, 1.0f }; // Red
|
||||
private static final float[] COLOR_BED = { 0.9f, 0.4f, 0.9f, 1.0f }; // Violet
|
||||
private static final float[] COLOR_DOOR = { 0.2f, 0.9f, 0.9f, 1.0f }; // Cyan
|
||||
private static final float[] COLOR_ENTRANCE = { 0.2f, 1.0f, 0.4f, 1.0f }; // Green
|
||||
private static final float[] COLOR_PATROL = { 1.0f, 1.0f, 0.2f, 1.0f }; // Yellow
|
||||
private static final float[] COLOR_LOOT = { 1.0f, 0.7f, 0.0f, 1.0f }; // Gold
|
||||
private static final float[] COLOR_SPAWNER = { 0.8f, 0.1f, 0.1f, 1.0f }; // Dark red
|
||||
private static final float[] COLOR_TRADER_SPAWN = {
|
||||
1.0f,
|
||||
0.84f,
|
||||
0.0f,
|
||||
1.0f,
|
||||
}; // Gold
|
||||
private static final float[] COLOR_MAID_SPAWN = {
|
||||
1.0f,
|
||||
0.41f,
|
||||
0.71f,
|
||||
1.0f,
|
||||
}; // Hot pink
|
||||
private static final float[] COLOR_MERCHANT_SPAWN = {
|
||||
0.2f,
|
||||
0.9f,
|
||||
0.9f,
|
||||
1.0f,
|
||||
}; // Cyan
|
||||
private static final float[] COLOR_DELIVERY = { 1.0f, 0.8f, 0.2f, 1.0f }; // Orange/Yellow
|
||||
private static final float[] COLOR_SPAWN = { 1.0f, 0.0f, 1.0f, 1.0f }; // Magenta
|
||||
private static final float[] COLOR_WAYPOINT = { 0.0f, 1.0f, 0.5f, 1.0f }; // Bright green
|
||||
|
||||
/**
|
||||
* Render filled blocks for all positions in a cell.
|
||||
* Renders V2 cell data: walls, anchors, beds, doors, and spawn/core positions.
|
||||
*
|
||||
* @param poseStack The pose stack from the render event
|
||||
* @param cell The cell data to render
|
||||
* @param camera The camera for view offset calculation
|
||||
*/
|
||||
public static void renderCellOutlines(
|
||||
PoseStack poseStack,
|
||||
CellDataV2 cell,
|
||||
Camera camera
|
||||
) {
|
||||
if (cell == null) return;
|
||||
|
||||
Vec3 cameraPos = camera.getPosition();
|
||||
|
||||
// Setup rendering state
|
||||
RenderSystem.enableBlend();
|
||||
RenderSystem.defaultBlendFunc();
|
||||
RenderSystem.enableDepthTest();
|
||||
RenderSystem.depthMask(false);
|
||||
RenderSystem.setShader(GameRenderer::getPositionColorShader);
|
||||
|
||||
// 1. Render spawn point or core pos (pulsating magenta)
|
||||
BlockPos spawnPoint =
|
||||
cell.getSpawnPoint() != null
|
||||
? cell.getSpawnPoint()
|
||||
: cell.getCorePos();
|
||||
float pulse =
|
||||
0.4f + 0.2f * (float) Math.sin(System.currentTimeMillis() / 300.0);
|
||||
float[] spawnColor = {
|
||||
COLOR_SPAWN[0],
|
||||
COLOR_SPAWN[1],
|
||||
COLOR_SPAWN[2],
|
||||
pulse,
|
||||
};
|
||||
renderFilledBlock(poseStack, spawnPoint, cameraPos, spawnColor);
|
||||
|
||||
// 2. Render wall blocks
|
||||
renderPositionCollection(
|
||||
poseStack,
|
||||
cell.getWallBlocks(),
|
||||
cameraPos,
|
||||
COLOR_WALL
|
||||
);
|
||||
|
||||
// 3. Render anchors
|
||||
renderPositionCollection(
|
||||
poseStack,
|
||||
cell.getAnchors(),
|
||||
cameraPos,
|
||||
COLOR_ANCHOR
|
||||
);
|
||||
|
||||
// 4. Render beds
|
||||
renderPositionCollection(
|
||||
poseStack,
|
||||
cell.getBeds(),
|
||||
cameraPos,
|
||||
COLOR_BED
|
||||
);
|
||||
|
||||
// 5. Render doors
|
||||
renderPositionCollection(
|
||||
poseStack,
|
||||
cell.getDoors(),
|
||||
cameraPos,
|
||||
COLOR_DOOR
|
||||
);
|
||||
|
||||
// 6. Render delivery point if present
|
||||
BlockPos deliveryPoint = cell.getDeliveryPoint();
|
||||
if (deliveryPoint != null) {
|
||||
float[] deliveryFillColor = {
|
||||
COLOR_DELIVERY[0],
|
||||
COLOR_DELIVERY[1],
|
||||
COLOR_DELIVERY[2],
|
||||
0.35f,
|
||||
};
|
||||
renderFilledBlock(
|
||||
poseStack,
|
||||
deliveryPoint,
|
||||
cameraPos,
|
||||
deliveryFillColor
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Render path waypoints with pulsating effect and numbers
|
||||
java.util.List<BlockPos> waypoints = cell.getPathWaypoints();
|
||||
if (!waypoints.isEmpty()) {
|
||||
float waypointPulse =
|
||||
0.5f +
|
||||
0.3f * (float) Math.sin(System.currentTimeMillis() / 200.0);
|
||||
float[] waypointColor = {
|
||||
COLOR_WAYPOINT[0],
|
||||
COLOR_WAYPOINT[1],
|
||||
COLOR_WAYPOINT[2],
|
||||
waypointPulse,
|
||||
};
|
||||
|
||||
for (int i = 0; i < waypoints.size(); i++) {
|
||||
BlockPos wp = waypoints.get(i);
|
||||
renderFilledBlock(poseStack, wp, cameraPos, waypointColor);
|
||||
// Render number above waypoint
|
||||
renderWaypointNumber(poseStack, wp, cameraPos, i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore rendering state
|
||||
RenderSystem.depthMask(true);
|
||||
RenderSystem.disableBlend();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a collection of positions with the given base color at 0.35 alpha.
|
||||
*/
|
||||
private static void renderPositionCollection(
|
||||
PoseStack poseStack,
|
||||
Collection<BlockPos> positions,
|
||||
Vec3 cameraPos,
|
||||
float[] baseColor
|
||||
) {
|
||||
if (positions == null || positions.isEmpty()) return;
|
||||
|
||||
float[] fillColor = { baseColor[0], baseColor[1], baseColor[2], 0.35f };
|
||||
|
||||
for (BlockPos pos : positions) {
|
||||
renderFilledBlock(poseStack, pos, cameraPos, fillColor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a filled block (semi-transparent cube).
|
||||
*/
|
||||
public static void renderFilledBlock(
|
||||
PoseStack poseStack,
|
||||
BlockPos pos,
|
||||
Vec3 cameraPos,
|
||||
float[] color
|
||||
) {
|
||||
poseStack.pushPose();
|
||||
|
||||
double x = pos.getX() - cameraPos.x;
|
||||
double y = pos.getY() - cameraPos.y;
|
||||
double z = pos.getZ() - cameraPos.z;
|
||||
|
||||
poseStack.translate(x, y, z);
|
||||
|
||||
Matrix4f matrix = poseStack.last().pose();
|
||||
|
||||
Tesselator tesselator = Tesselator.getInstance();
|
||||
BufferBuilder buffer = tesselator.getBuilder();
|
||||
|
||||
buffer.begin(
|
||||
VertexFormat.Mode.QUADS,
|
||||
DefaultVertexFormat.POSITION_COLOR
|
||||
);
|
||||
|
||||
float r = color[0];
|
||||
float g = color[1];
|
||||
float b = color[2];
|
||||
float a = color[3];
|
||||
|
||||
float min = 0.0f;
|
||||
float max = 1.0f;
|
||||
|
||||
// Bottom face
|
||||
vertex(buffer, matrix, min, min, min, r, g, b, a);
|
||||
vertex(buffer, matrix, max, min, min, r, g, b, a);
|
||||
vertex(buffer, matrix, max, min, max, r, g, b, a);
|
||||
vertex(buffer, matrix, min, min, max, r, g, b, a);
|
||||
|
||||
// Top face
|
||||
vertex(buffer, matrix, min, max, min, r, g, b, a);
|
||||
vertex(buffer, matrix, min, max, max, r, g, b, a);
|
||||
vertex(buffer, matrix, max, max, max, r, g, b, a);
|
||||
vertex(buffer, matrix, max, max, min, r, g, b, a);
|
||||
|
||||
// North face
|
||||
vertex(buffer, matrix, min, min, min, r, g, b, a);
|
||||
vertex(buffer, matrix, min, max, min, r, g, b, a);
|
||||
vertex(buffer, matrix, max, max, min, r, g, b, a);
|
||||
vertex(buffer, matrix, max, min, min, r, g, b, a);
|
||||
|
||||
// South face
|
||||
vertex(buffer, matrix, min, min, max, r, g, b, a);
|
||||
vertex(buffer, matrix, max, min, max, r, g, b, a);
|
||||
vertex(buffer, matrix, max, max, max, r, g, b, a);
|
||||
vertex(buffer, matrix, min, max, max, r, g, b, a);
|
||||
|
||||
// West face
|
||||
vertex(buffer, matrix, min, min, min, r, g, b, a);
|
||||
vertex(buffer, matrix, min, min, max, r, g, b, a);
|
||||
vertex(buffer, matrix, min, max, max, r, g, b, a);
|
||||
vertex(buffer, matrix, min, max, min, r, g, b, a);
|
||||
|
||||
// East face
|
||||
vertex(buffer, matrix, max, min, min, r, g, b, a);
|
||||
vertex(buffer, matrix, max, max, min, r, g, b, a);
|
||||
vertex(buffer, matrix, max, max, max, r, g, b, a);
|
||||
vertex(buffer, matrix, max, min, max, r, g, b, a);
|
||||
|
||||
tesselator.end();
|
||||
|
||||
poseStack.popPose();
|
||||
}
|
||||
|
||||
private static void vertex(
|
||||
BufferBuilder buffer,
|
||||
Matrix4f matrix,
|
||||
float x,
|
||||
float y,
|
||||
float z,
|
||||
float r,
|
||||
float g,
|
||||
float b,
|
||||
float a
|
||||
) {
|
||||
buffer.vertex(matrix, x, y, z).color(r, g, b, a).endVertex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a waypoint number floating above the block.
|
||||
*/
|
||||
public static void renderWaypointNumber(
|
||||
PoseStack poseStack,
|
||||
BlockPos pos,
|
||||
Vec3 cameraPos,
|
||||
int number
|
||||
) {
|
||||
poseStack.pushPose();
|
||||
|
||||
double x = pos.getX() + 0.5 - cameraPos.x;
|
||||
double y = pos.getY() + 1.5 - cameraPos.y;
|
||||
double z = pos.getZ() + 0.5 - cameraPos.z;
|
||||
|
||||
poseStack.translate(x, y, z);
|
||||
|
||||
// Billboard effect - face the camera
|
||||
net.minecraft.client.Minecraft mc =
|
||||
net.minecraft.client.Minecraft.getInstance();
|
||||
poseStack.mulPose(mc.getEntityRenderDispatcher().cameraOrientation());
|
||||
poseStack.scale(-0.05f, -0.05f, 0.05f);
|
||||
|
||||
// Render the number
|
||||
String text = String.valueOf(number);
|
||||
net.minecraft.client.gui.Font font = mc.font;
|
||||
float textWidth = font.width(text);
|
||||
|
||||
// Background
|
||||
net.minecraft.client.renderer.MultiBufferSource.BufferSource buffer = mc
|
||||
.renderBuffers()
|
||||
.bufferSource();
|
||||
|
||||
font.drawInBatch(
|
||||
text,
|
||||
-textWidth / 2,
|
||||
0,
|
||||
0x00FF80, // Bright green
|
||||
false,
|
||||
poseStack.last().pose(),
|
||||
buffer,
|
||||
net.minecraft.client.gui.Font.DisplayMode.NORMAL,
|
||||
0x80000000, // Semi-transparent black background
|
||||
15728880
|
||||
);
|
||||
buffer.endBatch();
|
||||
|
||||
poseStack.popPose();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color for a marker type.
|
||||
*/
|
||||
public static float[] getColorForType(MarkerType type) {
|
||||
return switch (type) {
|
||||
case WALL -> COLOR_WALL;
|
||||
case ANCHOR -> COLOR_ANCHOR;
|
||||
case BED -> COLOR_BED;
|
||||
case DOOR -> COLOR_DOOR;
|
||||
case DELIVERY -> COLOR_DELIVERY;
|
||||
case ENTRANCE -> COLOR_ENTRANCE;
|
||||
case PATROL -> COLOR_PATROL;
|
||||
case LOOT -> COLOR_LOOT;
|
||||
case SPAWNER -> COLOR_SPAWNER;
|
||||
case TRADER_SPAWN -> COLOR_TRADER_SPAWN;
|
||||
case MAID_SPAWN -> COLOR_MAID_SPAWN;
|
||||
case MERCHANT_SPAWN -> COLOR_MERCHANT_SPAWN;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the spawn point color.
|
||||
*/
|
||||
public static float[] getSpawnColor() {
|
||||
return COLOR_SPAWN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.tiedup.remake.client.renderer;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.math.Axis;
|
||||
import com.tiedup.remake.client.animation.render.RenderConstants;
|
||||
import com.tiedup.remake.client.model.DamselModel;
|
||||
import com.tiedup.remake.compat.wildfire.WildfireCompat;
|
||||
import com.tiedup.remake.compat.wildfire.render.WildfireDamselLayer;
|
||||
import com.tiedup.remake.entities.AbstractTiedUpNpc;
|
||||
import net.minecraft.client.model.HumanoidArmorModel;
|
||||
import net.minecraft.client.model.geom.ModelLayers;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.entity.EntityRendererProvider;
|
||||
import net.minecraft.client.renderer.entity.HumanoidMobRenderer;
|
||||
import net.minecraft.client.renderer.entity.layers.HumanoidArmorLayer;
|
||||
import net.minecraft.client.renderer.entity.layers.ItemInHandLayer;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Renderer for AbstractTiedUpNpc and all subtypes (Kidnapper, Elite, Archer, Merchant, Shiny).
|
||||
*
|
||||
* <p>Uses ISkinnedEntity interface for polymorphic texture lookup.
|
||||
* Each entity subclass overrides getSkinTexture() to return the appropriate texture.
|
||||
*
|
||||
* <p><b>Issue #19 fix:</b> Replaced 6+ instanceof checks with single interface call.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class DamselRenderer
|
||||
extends HumanoidMobRenderer<AbstractTiedUpNpc, DamselModel>
|
||||
{
|
||||
|
||||
/**
|
||||
* Normal arms model (4px wide - Steve model).
|
||||
*/
|
||||
private final DamselModel normalModel;
|
||||
|
||||
/**
|
||||
* Slim arms model (3px wide - Alex model).
|
||||
*/
|
||||
private final DamselModel slimModel;
|
||||
|
||||
/**
|
||||
* Create renderer.
|
||||
*
|
||||
* Phase 19: Uses vanilla ModelLayers.PLAYER for full layer support (jacket, sleeves, pants).
|
||||
*/
|
||||
public DamselRenderer(EntityRendererProvider.Context context) {
|
||||
super(
|
||||
context,
|
||||
new DamselModel(context.bakeLayer(ModelLayers.PLAYER), false),
|
||||
0.5f // Shadow radius
|
||||
);
|
||||
// Store both models for runtime swapping
|
||||
this.normalModel = this.getModel();
|
||||
this.slimModel = new DamselModel(
|
||||
context.bakeLayer(ModelLayers.PLAYER_SLIM),
|
||||
true
|
||||
);
|
||||
|
||||
// Add armor render layer (renders equipped armor)
|
||||
this.addLayer(
|
||||
new HumanoidArmorLayer<>(
|
||||
this,
|
||||
new HumanoidArmorModel<>(
|
||||
context.bakeLayer(ModelLayers.PLAYER_INNER_ARMOR)
|
||||
),
|
||||
new HumanoidArmorModel<>(
|
||||
context.bakeLayer(ModelLayers.PLAYER_OUTER_ARMOR)
|
||||
),
|
||||
context.getModelManager()
|
||||
)
|
||||
);
|
||||
|
||||
// Add item in hand layer (renders held items)
|
||||
this.addLayer(
|
||||
new ItemInHandLayer<>(this, context.getItemInHandRenderer())
|
||||
);
|
||||
|
||||
// Add Wildfire breast layer BEFORE bondage (so bondage renders on top of breasts)
|
||||
if (WildfireCompat.isLoaded()) {
|
||||
this.addLayer(
|
||||
new WildfireDamselLayer<>(this, context.getModelSet())
|
||||
);
|
||||
}
|
||||
|
||||
// Add V2 bondage render layer (GLB-based V2 equipment rendering)
|
||||
this.addLayer(new com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer<>(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the entity.
|
||||
* Uses entity's hasSlimArms() for model selection.
|
||||
*
|
||||
* Phase 19: Wearer layer hiding is now handled in DamselModel.setupAnim()
|
||||
* to ensure it happens after visibility resets.
|
||||
*
|
||||
* DOG pose: X rotation is applied in setupRotations() AFTER Y rotation,
|
||||
* so the "belly down" direction follows entity facing.
|
||||
* Head compensation is applied in DamselModel.setupAnim().
|
||||
* Body rotation smoothing is handled in AbstractTiedUpNpc.tick().
|
||||
*/
|
||||
@Override
|
||||
public void render(
|
||||
AbstractTiedUpNpc entity,
|
||||
float entityYaw,
|
||||
float partialTicks,
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource buffer,
|
||||
int packedLight
|
||||
) {
|
||||
// Use entity's hasSlimArms() - each entity type overrides this appropriately
|
||||
boolean useSlim = entity.hasSlimArms();
|
||||
|
||||
// Swap to appropriate model
|
||||
this.model = useSlim ? this.slimModel : this.normalModel;
|
||||
|
||||
// Apply vertical offset for sitting/kneeling/dog poses
|
||||
// This ensures the model AND all layers (gag, blindfold, etc.) move together
|
||||
float verticalOffset = getVerticalOffset(entity);
|
||||
boolean pushedPose = false;
|
||||
if (verticalOffset != 0) {
|
||||
poseStack.pushPose();
|
||||
pushedPose = true;
|
||||
// Convert from model units (16 = 1 block) to render units
|
||||
poseStack.translate(0, verticalOffset / 16.0, 0);
|
||||
}
|
||||
|
||||
// Call parent render
|
||||
// Note: Wearer layer hiding happens in DamselModel.setupAnim()
|
||||
super.render(
|
||||
entity,
|
||||
entityYaw,
|
||||
partialTicks,
|
||||
poseStack,
|
||||
buffer,
|
||||
packedLight
|
||||
);
|
||||
|
||||
if (pushedPose) {
|
||||
poseStack.popPose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vertical offset for sitting/kneeling/dog poses.
|
||||
* Returns offset in model units (16 units = 1 block).
|
||||
*
|
||||
* @param entity The entity to check
|
||||
* @return Vertical offset (negative = down)
|
||||
*/
|
||||
private float getVerticalOffset(AbstractTiedUpNpc entity) {
|
||||
if (entity.isSitting()) {
|
||||
return RenderConstants.DAMSEL_SIT_OFFSET;
|
||||
} else if (entity.isKneeling()) {
|
||||
return RenderConstants.DAMSEL_KNEEL_OFFSET;
|
||||
} else if (entity.isDogPose()) {
|
||||
return RenderConstants.DAMSEL_DOG_OFFSET;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the texture location based on entity type.
|
||||
*
|
||||
* <p>Issue #19 fix: Uses ISkinnedEntity interface instead of instanceof cascade.
|
||||
* Each entity subclass implements getSkinTexture() to return appropriate texture.
|
||||
*/
|
||||
@Override
|
||||
public ResourceLocation getTextureLocation(AbstractTiedUpNpc entity) {
|
||||
// ISkinnedEntity provides polymorphic skin texture lookup
|
||||
// Each entity type (Damsel, Kidnapper, Elite, Archer, Merchant, Shiny)
|
||||
// overrides getSkinTexture() to return the correct texture
|
||||
return entity.getSkinTexture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply scale transformation.
|
||||
*/
|
||||
@Override
|
||||
protected void scale(
|
||||
AbstractTiedUpNpc entity,
|
||||
PoseStack poseStack,
|
||||
float partialTick
|
||||
) {
|
||||
poseStack.scale(0.9375f, 0.9375f, 0.9375f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup rotations for the entity.
|
||||
*
|
||||
* DOG pose: After Y rotation is applied by parent, add X rotation
|
||||
* to make the body horizontal. This is applied in entity-local space,
|
||||
* so the "belly down" direction follows the entity's facing.
|
||||
*/
|
||||
@Override
|
||||
protected void setupRotations(
|
||||
AbstractTiedUpNpc entity,
|
||||
PoseStack poseStack,
|
||||
float ageInTicks,
|
||||
float rotationYaw,
|
||||
float partialTicks
|
||||
) {
|
||||
// Call parent to apply Y rotation (body facing)
|
||||
super.setupRotations(
|
||||
entity,
|
||||
poseStack,
|
||||
ageInTicks,
|
||||
rotationYaw,
|
||||
partialTicks
|
||||
);
|
||||
|
||||
// DOG pose: Apply X rotation to make body horizontal
|
||||
// This happens AFTER Y rotation, so it's in entity-local space
|
||||
if (entity.isDogPose()) {
|
||||
// Rotate -90° on X axis around the model's pivot point
|
||||
// Pivot at waist height (12 model units = 0.75 blocks up from feet)
|
||||
poseStack.translate(0, 0.75, 0);
|
||||
poseStack.mulPose(Axis.XP.rotationDegrees(-90));
|
||||
poseStack.translate(0, -0.75, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.tiedup.remake.client.renderer;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.math.Axis;
|
||||
import com.tiedup.remake.blocks.ModBlocks;
|
||||
import com.tiedup.remake.entities.EntityKidnapBomb;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.block.BlockRenderDispatcher;
|
||||
import net.minecraft.client.renderer.entity.EntityRenderer;
|
||||
import net.minecraft.client.renderer.entity.EntityRendererProvider;
|
||||
import net.minecraft.client.renderer.entity.TntMinecartRenderer;
|
||||
import net.minecraft.client.renderer.texture.TextureAtlas;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.util.Mth;
|
||||
|
||||
/**
|
||||
* Renderer for EntityKidnapBomb.
|
||||
*
|
||||
* Phase 16: Blocks
|
||||
*
|
||||
* Renders the primed kidnap bomb using our custom block texture.
|
||||
*/
|
||||
public class KidnapBombRenderer extends EntityRenderer<EntityKidnapBomb> {
|
||||
|
||||
private final BlockRenderDispatcher blockRenderer;
|
||||
|
||||
public KidnapBombRenderer(EntityRendererProvider.Context context) {
|
||||
super(context);
|
||||
this.shadowRadius = 0.5F;
|
||||
this.blockRenderer = context.getBlockRenderDispatcher();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
EntityKidnapBomb entity,
|
||||
float entityYaw,
|
||||
float partialTicks,
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource buffer,
|
||||
int packedLight
|
||||
) {
|
||||
poseStack.pushPose();
|
||||
poseStack.translate(0.0F, 0.5F, 0.0F);
|
||||
|
||||
int fuse = entity.getFuse();
|
||||
if ((float) fuse - partialTicks + 1.0F < 10.0F) {
|
||||
float scale = 1.0F - ((float) fuse - partialTicks + 1.0F) / 10.0F;
|
||||
scale = Mth.clamp(scale, 0.0F, 1.0F);
|
||||
scale *= scale;
|
||||
scale *= scale;
|
||||
float expand = 1.0F + scale * 0.3F;
|
||||
poseStack.scale(expand, expand, expand);
|
||||
}
|
||||
|
||||
poseStack.mulPose(Axis.YP.rotationDegrees(-90.0F));
|
||||
poseStack.translate(-0.5F, -0.5F, 0.5F);
|
||||
poseStack.mulPose(Axis.YP.rotationDegrees(90.0F));
|
||||
|
||||
// Render our custom block instead of vanilla TNT
|
||||
TntMinecartRenderer.renderWhiteSolidBlock(
|
||||
this.blockRenderer,
|
||||
ModBlocks.KIDNAP_BOMB.get().defaultBlockState(),
|
||||
poseStack,
|
||||
buffer,
|
||||
packedLight,
|
||||
(fuse / 5) % 2 == 0
|
||||
);
|
||||
|
||||
poseStack.popPose();
|
||||
super.render(
|
||||
entity,
|
||||
entityYaw,
|
||||
partialTicks,
|
||||
poseStack,
|
||||
buffer,
|
||||
packedLight
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("deprecation")
|
||||
public ResourceLocation getTextureLocation(EntityKidnapBomb entity) {
|
||||
return TextureAtlas.LOCATION_BLOCKS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.tiedup.remake.client.renderer;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import com.mojang.math.Axis;
|
||||
import com.tiedup.remake.entities.NpcFishingBobber;
|
||||
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 org.joml.Matrix3f;
|
||||
import org.joml.Matrix4f;
|
||||
|
||||
/**
|
||||
* Renderer for NpcFishingBobber.
|
||||
*
|
||||
* Renders a textured quad using the vanilla fishing hook texture.
|
||||
* Billboard-style: always faces the camera.
|
||||
*/
|
||||
public class NpcFishingBobberRenderer extends EntityRenderer<NpcFishingBobber> {
|
||||
|
||||
private static final ResourceLocation TEXTURE = new ResourceLocation(
|
||||
"textures/entity/fishing_hook.png"
|
||||
);
|
||||
private static final RenderType RENDER_TYPE = RenderType.entityCutout(
|
||||
TEXTURE
|
||||
);
|
||||
|
||||
public NpcFishingBobberRenderer(EntityRendererProvider.Context ctx) {
|
||||
super(ctx);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
NpcFishingBobber entity,
|
||||
float yaw,
|
||||
float partialTick,
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource bufferSource,
|
||||
int packedLight
|
||||
) {
|
||||
poseStack.pushPose();
|
||||
poseStack.scale(0.5F, 0.5F, 0.5F);
|
||||
poseStack.mulPose(this.entityRenderDispatcher.cameraOrientation());
|
||||
poseStack.mulPose(Axis.YP.rotationDegrees(180.0F));
|
||||
|
||||
VertexConsumer vertexConsumer = bufferSource.getBuffer(RENDER_TYPE);
|
||||
PoseStack.Pose pose = poseStack.last();
|
||||
Matrix4f matrix4f = pose.pose();
|
||||
Matrix3f matrix3f = pose.normal();
|
||||
|
||||
vertex(vertexConsumer, matrix4f, matrix3f, packedLight, 0.0F, 0, 0, 1);
|
||||
vertex(vertexConsumer, matrix4f, matrix3f, packedLight, 1.0F, 0, 1, 1);
|
||||
vertex(vertexConsumer, matrix4f, matrix3f, packedLight, 1.0F, 1, 1, 0);
|
||||
vertex(vertexConsumer, matrix4f, matrix3f, packedLight, 0.0F, 1, 0, 0);
|
||||
|
||||
poseStack.popPose();
|
||||
super.render(
|
||||
entity,
|
||||
yaw,
|
||||
partialTick,
|
||||
poseStack,
|
||||
bufferSource,
|
||||
packedLight
|
||||
);
|
||||
}
|
||||
|
||||
private static void vertex(
|
||||
VertexConsumer consumer,
|
||||
Matrix4f matrix4f,
|
||||
Matrix3f matrix3f,
|
||||
int packedLight,
|
||||
float x,
|
||||
int y,
|
||||
int u,
|
||||
int v
|
||||
) {
|
||||
consumer
|
||||
.vertex(matrix4f, x - 0.5F, y - 0.5F, 0.0F)
|
||||
.color(255, 255, 255, 255)
|
||||
.uv((float) u, (float) v)
|
||||
.overlayCoords(OverlayTexture.NO_OVERLAY)
|
||||
.uv2(packedLight)
|
||||
.normal(matrix3f, 0.0F, 1.0F, 0.0F)
|
||||
.endVertex();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResourceLocation getTextureLocation(NpcFishingBobber entity) {
|
||||
return TEXTURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.tiedup.remake.client.renderer;
|
||||
|
||||
import com.tiedup.remake.entities.EntityRopeArrow;
|
||||
import net.minecraft.client.renderer.entity.ArrowRenderer;
|
||||
import net.minecraft.client.renderer.entity.EntityRendererProvider;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Renderer for EntityRopeArrow.
|
||||
* Phase 15: Uses vanilla arrow texture for simplicity.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class RopeArrowRenderer extends ArrowRenderer<EntityRopeArrow> {
|
||||
|
||||
/** Texture for the rope arrow (uses vanilla arrow texture) */
|
||||
private static final ResourceLocation TEXTURE =
|
||||
ResourceLocation.fromNamespaceAndPath(
|
||||
"minecraft",
|
||||
"textures/entity/projectiles/arrow.png"
|
||||
);
|
||||
|
||||
public RopeArrowRenderer(EntityRendererProvider.Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResourceLocation getTextureLocation(EntityRopeArrow entity) {
|
||||
return TEXTURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.tiedup.remake.client.renderer.layers;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import net.minecraft.client.model.PlayerModel;
|
||||
import net.minecraft.client.model.geom.EntityModelSet;
|
||||
import net.minecraft.client.model.geom.ModelLayers;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Cache for PlayerModel instances used for clothes rendering.
|
||||
*
|
||||
* <p>Clothes textures are standard Minecraft skins (64x64) and need to be
|
||||
* rendered using PlayerModel with correct UV mappings, NOT the bondage item models.
|
||||
*
|
||||
* <p>Two models are cached:
|
||||
* <ul>
|
||||
* <li>Normal (Steve) - 4px wide arms</li>
|
||||
* <li>Slim (Alex) - 3px wide arms</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Initialize via {@link #init(EntityModelSet)} during AddLayers event.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ClothesModelCache {
|
||||
|
||||
private static PlayerModel<AbstractClientPlayer> normalModel;
|
||||
private static PlayerModel<AbstractClientPlayer> slimModel;
|
||||
private static boolean initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the model cache.
|
||||
* Must be called during EntityRenderersEvent.AddLayers.
|
||||
*
|
||||
* @param modelSet The entity model set from the event
|
||||
*/
|
||||
public static void init(EntityModelSet modelSet) {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalModel = new PlayerModel<>(
|
||||
modelSet.bakeLayer(ModelLayers.PLAYER),
|
||||
false
|
||||
);
|
||||
slimModel = new PlayerModel<>(
|
||||
modelSet.bakeLayer(ModelLayers.PLAYER_SLIM),
|
||||
true
|
||||
);
|
||||
initialized = true;
|
||||
|
||||
TiedUpMod.LOGGER.info(
|
||||
"[ClothesModelCache] Initialized normal and slim player models for clothes rendering"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate player model for clothes rendering.
|
||||
*
|
||||
* @param slim true for slim (Alex) arms, false for normal (Steve) arms
|
||||
* @return The cached PlayerModel
|
||||
*/
|
||||
public static PlayerModel<AbstractClientPlayer> getModel(boolean slim) {
|
||||
if (!initialized) {
|
||||
TiedUpMod.LOGGER.warn(
|
||||
"[ClothesModelCache] Not initialized! Returning null."
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return slim ? slimModel : normalModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the cache is initialized.
|
||||
*/
|
||||
public static boolean isInitialized() {
|
||||
return initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
package com.tiedup.remake.client.renderer.layers;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import com.tiedup.remake.client.state.ClothesClientCache;
|
||||
import com.tiedup.remake.client.texture.DynamicTextureManager;
|
||||
import com.tiedup.remake.items.clothes.ClothesProperties;
|
||||
import com.tiedup.remake.items.clothes.GenericClothes;
|
||||
import java.util.EnumSet;
|
||||
import java.util.UUID;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.client.model.HumanoidModel;
|
||||
import net.minecraft.client.model.PlayerModel;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Helper class for rendering clothes with dynamic textures.
|
||||
*
|
||||
* <p>IMPORTANT: Clothes are rendered using a PlayerModel (not bondage item model)
|
||||
* because clothes textures are standard Minecraft skins (64x64) with player UV mappings.
|
||||
*
|
||||
* <p>Handles:
|
||||
* <ul>
|
||||
* <li>Dynamic texture URL rendering</li>
|
||||
* <li>Full-skin mode (covers entire player model)</li>
|
||||
* <li>Small arms mode (Alex/slim arms)</li>
|
||||
* <li>Layer visibility control</li>
|
||||
* <li>Steve vs Alex model selection</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>This is called from BondageItemRenderLayer when rendering CLOTHES slot.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ClothesRenderHelper {
|
||||
|
||||
/**
|
||||
* Attempt to render clothes with dynamic texture.
|
||||
* Uses PlayerModel (not bondage model) because clothes use skin UV mappings.
|
||||
*
|
||||
* @param clothes The clothes ItemStack
|
||||
* @param entity The entity wearing the clothes
|
||||
* @param poseStack The pose stack
|
||||
* @param buffer The render buffer
|
||||
* @param packedLight The packed light value
|
||||
* @param packedOverlay The packed overlay value (for hit flash effect)
|
||||
* @param parentModel The parent PlayerModel to copy pose from
|
||||
* @return true if dynamic texture was rendered, false otherwise
|
||||
*/
|
||||
public static boolean tryRenderDynamic(
|
||||
ItemStack clothes,
|
||||
LivingEntity entity,
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource buffer,
|
||||
int packedLight,
|
||||
int packedOverlay,
|
||||
PlayerModel<?> parentModel
|
||||
) {
|
||||
if (
|
||||
clothes.isEmpty() ||
|
||||
!(clothes.getItem() instanceof GenericClothes gc)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if ClothesModelCache is initialized
|
||||
if (!ClothesModelCache.isInitialized()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get properties from equipped clothes or remote player cache
|
||||
ClothesProperties props = getClothesProperties(clothes, entity);
|
||||
if (props == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String dynamicUrl = props.dynamicTextureUrl();
|
||||
if (dynamicUrl == null || dynamicUrl.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get texture from manager
|
||||
DynamicTextureManager texManager = DynamicTextureManager.getInstance();
|
||||
ResourceLocation texLocation = texManager.getTextureLocation(
|
||||
dynamicUrl,
|
||||
props.fullSkin()
|
||||
);
|
||||
|
||||
if (texLocation == null) {
|
||||
// Texture not loaded yet (downloading)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine if we should use slim (Alex) arms
|
||||
boolean useSlim = shouldUseSlimArms(clothes, entity, props);
|
||||
|
||||
// Get the appropriate PlayerModel from cache
|
||||
PlayerModel<AbstractClientPlayer> clothesModel =
|
||||
ClothesModelCache.getModel(useSlim);
|
||||
if (clothesModel == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy pose from parent model to clothes model
|
||||
copyPose(parentModel, clothesModel);
|
||||
|
||||
// Apply layer visibility settings
|
||||
applyLayerVisibility(
|
||||
clothesModel,
|
||||
props.visibleLayers(),
|
||||
props.keepHead()
|
||||
);
|
||||
|
||||
// Render the clothes model with dynamic texture
|
||||
// Use entityTranslucent for layered rendering (clothes on top of player)
|
||||
VertexConsumer vertexConsumer = buffer.getBuffer(
|
||||
RenderType.entityTranslucent(texLocation)
|
||||
);
|
||||
clothesModel.renderToBuffer(
|
||||
poseStack,
|
||||
vertexConsumer,
|
||||
packedLight,
|
||||
packedOverlay,
|
||||
1.0f,
|
||||
1.0f,
|
||||
1.0f,
|
||||
1.0f
|
||||
);
|
||||
|
||||
// Restore visibility (model is shared, need to reset for next render)
|
||||
restoreLayerVisibility(clothesModel);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render clothes for NPCs (Damsel, etc.)
|
||||
* Uses PlayerModel from cache, copies pose from parent HumanoidModel.
|
||||
*
|
||||
* @param clothes The clothes ItemStack
|
||||
* @param entity The NPC entity wearing clothes
|
||||
* @param poseStack The pose stack
|
||||
* @param buffer The render buffer
|
||||
* @param packedLight The packed light value
|
||||
* @param packedOverlay The packed overlay value (for hit flash effect)
|
||||
* @param parentModel The parent HumanoidModel to copy pose from
|
||||
* @param hasSlimArms Whether the NPC uses slim arms
|
||||
* @return true if rendered successfully
|
||||
*/
|
||||
public static boolean tryRenderDynamicForNPC(
|
||||
ItemStack clothes,
|
||||
LivingEntity entity,
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource buffer,
|
||||
int packedLight,
|
||||
int packedOverlay,
|
||||
HumanoidModel<?> parentModel,
|
||||
boolean hasSlimArms
|
||||
) {
|
||||
if (
|
||||
clothes.isEmpty() || !(clothes.getItem() instanceof GenericClothes)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ClothesModelCache.isInitialized()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get properties directly from item (NPCs don't use remote cache)
|
||||
ClothesProperties props = ClothesProperties.fromStack(clothes);
|
||||
if (props == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String dynamicUrl = props.dynamicTextureUrl();
|
||||
if (dynamicUrl == null || dynamicUrl.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get texture from manager
|
||||
ResourceLocation texLocation =
|
||||
DynamicTextureManager.getInstance().getTextureLocation(
|
||||
dynamicUrl,
|
||||
props.fullSkin()
|
||||
);
|
||||
if (texLocation == null) {
|
||||
return false; // Still downloading
|
||||
}
|
||||
|
||||
// Use slim if: clothes force it OR NPC has slim arms
|
||||
boolean useSlim = props.smallArms() || hasSlimArms;
|
||||
|
||||
// Get PlayerModel from cache (same cache as players)
|
||||
PlayerModel<AbstractClientPlayer> clothesModel =
|
||||
ClothesModelCache.getModel(useSlim);
|
||||
if (clothesModel == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy pose from HumanoidModel (NPC) to PlayerModel (clothes)
|
||||
copyPoseFromHumanoid(parentModel, clothesModel);
|
||||
|
||||
// Apply layer visibility
|
||||
applyLayerVisibility(
|
||||
clothesModel,
|
||||
props.visibleLayers(),
|
||||
props.keepHead()
|
||||
);
|
||||
|
||||
// Render
|
||||
VertexConsumer vertexConsumer = buffer.getBuffer(
|
||||
RenderType.entityTranslucent(texLocation)
|
||||
);
|
||||
clothesModel.renderToBuffer(
|
||||
poseStack,
|
||||
vertexConsumer,
|
||||
packedLight,
|
||||
packedOverlay,
|
||||
1.0f,
|
||||
1.0f,
|
||||
1.0f,
|
||||
1.0f
|
||||
);
|
||||
|
||||
// Restore visibility
|
||||
restoreLayerVisibility(clothesModel);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy pose from HumanoidModel (NPC) to PlayerModel (clothes).
|
||||
* PlayerModel has extra parts (jacket, sleeves, pants) that HumanoidModel lacks,
|
||||
* so we copy those from the corresponding base parts.
|
||||
*/
|
||||
private static void copyPoseFromHumanoid(
|
||||
HumanoidModel<?> source,
|
||||
PlayerModel<?> dest
|
||||
) {
|
||||
// Base parts (exist in both models)
|
||||
dest.head.copyFrom(source.head);
|
||||
dest.hat.copyFrom(source.hat);
|
||||
dest.body.copyFrom(source.body);
|
||||
dest.rightArm.copyFrom(source.rightArm);
|
||||
dest.leftArm.copyFrom(source.leftArm);
|
||||
dest.rightLeg.copyFrom(source.rightLeg);
|
||||
dest.leftLeg.copyFrom(source.leftLeg);
|
||||
|
||||
// PlayerModel-only parts: copy from corresponding base parts
|
||||
dest.jacket.copyFrom(source.body);
|
||||
dest.leftSleeve.copyFrom(source.leftArm);
|
||||
dest.rightSleeve.copyFrom(source.rightArm);
|
||||
dest.leftPants.copyFrom(source.leftLeg);
|
||||
dest.rightPants.copyFrom(source.rightLeg);
|
||||
|
||||
// Animation flags
|
||||
dest.crouching = source.crouching;
|
||||
dest.riding = source.riding;
|
||||
dest.young = source.young;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy pose from source PlayerModel to destination PlayerModel.
|
||||
* This ensures the clothes model matches the player's current pose/animation.
|
||||
*/
|
||||
private static void copyPose(PlayerModel<?> source, PlayerModel<?> dest) {
|
||||
// Main body parts
|
||||
dest.head.copyFrom(source.head);
|
||||
dest.hat.copyFrom(source.hat);
|
||||
dest.body.copyFrom(source.body);
|
||||
dest.rightArm.copyFrom(source.rightArm);
|
||||
dest.leftArm.copyFrom(source.leftArm);
|
||||
dest.rightLeg.copyFrom(source.rightLeg);
|
||||
dest.leftLeg.copyFrom(source.leftLeg);
|
||||
|
||||
// Outer layer parts (jacket, sleeves, pants)
|
||||
dest.jacket.copyFrom(source.jacket);
|
||||
dest.leftSleeve.copyFrom(source.leftSleeve);
|
||||
dest.rightSleeve.copyFrom(source.rightSleeve);
|
||||
dest.leftPants.copyFrom(source.leftPants);
|
||||
dest.rightPants.copyFrom(source.rightPants);
|
||||
|
||||
// Copy other properties
|
||||
dest.crouching = source.crouching;
|
||||
dest.young = source.young;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if slim (Alex) arms should be used.
|
||||
* Priority: 1) Clothes force small arms, 2) Player's actual model type
|
||||
*/
|
||||
private static boolean shouldUseSlimArms(
|
||||
ItemStack clothes,
|
||||
LivingEntity entity,
|
||||
ClothesProperties props
|
||||
) {
|
||||
// 1. Check if clothes item forces small arms
|
||||
if (props.smallArms()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Check local item setting
|
||||
if (clothes.getItem() instanceof GenericClothes gc) {
|
||||
if (gc.shouldForceSmallArms(clothes)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Follow player's actual model type
|
||||
if (entity instanceof AbstractClientPlayer player) {
|
||||
String modelName = player.getModelName();
|
||||
return "slim".equals(modelName);
|
||||
}
|
||||
|
||||
return false; // Default: normal (Steve) arms
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clothes properties, checking both equipped item and remote player cache.
|
||||
*/
|
||||
@Nullable
|
||||
private static ClothesProperties getClothesProperties(
|
||||
ItemStack clothes,
|
||||
LivingEntity entity
|
||||
) {
|
||||
// First try to get from the equipped item directly
|
||||
ClothesProperties localProps = ClothesProperties.fromStack(clothes);
|
||||
|
||||
// If entity is a player, also check remote cache (for other players' clothes)
|
||||
if (entity instanceof AbstractClientPlayer player) {
|
||||
UUID playerUUID = player.getUUID();
|
||||
ClothesClientCache.CachedClothesData cached =
|
||||
ClothesClientCache.getPlayerClothes(playerUUID);
|
||||
|
||||
if (cached != null && cached.hasDynamicTexture()) {
|
||||
// Use cached data (synced from server)
|
||||
return new ClothesProperties(
|
||||
cached.dynamicUrl(),
|
||||
cached.fullSkin(),
|
||||
cached.smallArms(),
|
||||
cached.keepHead(),
|
||||
cached.visibleLayers()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return localProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply layer visibility from clothes properties.
|
||||
* Controls which parts of the outer layer (jacket, sleeves, pants) are visible.
|
||||
*
|
||||
* @param model The clothes PlayerModel
|
||||
* @param visible Which layers are enabled on the clothes
|
||||
* @param keepHead If true, hide the clothes head/hat (wearer's head shows instead)
|
||||
*/
|
||||
private static void applyLayerVisibility(
|
||||
PlayerModel<?> model,
|
||||
EnumSet<ClothesProperties.LayerPart> visible,
|
||||
boolean keepHead
|
||||
) {
|
||||
// Main body parts visibility
|
||||
// If keepHead is true, hide clothes head so wearer's head shows through
|
||||
model.head.visible = !keepHead;
|
||||
model.body.visible = true;
|
||||
model.rightArm.visible = true;
|
||||
model.leftArm.visible = true;
|
||||
model.rightLeg.visible = true;
|
||||
model.leftLeg.visible = true;
|
||||
|
||||
// Outer layer parts controlled by settings
|
||||
// If keepHead is true, hide clothes hat so wearer's hat shows through
|
||||
model.hat.visible =
|
||||
!keepHead && visible.contains(ClothesProperties.LayerPart.HEAD);
|
||||
model.jacket.visible = visible.contains(
|
||||
ClothesProperties.LayerPart.BODY
|
||||
);
|
||||
model.leftSleeve.visible = visible.contains(
|
||||
ClothesProperties.LayerPart.LEFT_ARM
|
||||
);
|
||||
model.rightSleeve.visible = visible.contains(
|
||||
ClothesProperties.LayerPart.RIGHT_ARM
|
||||
);
|
||||
model.leftPants.visible = visible.contains(
|
||||
ClothesProperties.LayerPart.LEFT_LEG
|
||||
);
|
||||
model.rightPants.visible = visible.contains(
|
||||
ClothesProperties.LayerPart.RIGHT_LEG
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore all layer visibility to default (all visible).
|
||||
* Important because the model is cached and shared.
|
||||
*/
|
||||
private static void restoreLayerVisibility(PlayerModel<?> model) {
|
||||
model.head.visible = true;
|
||||
model.hat.visible = true;
|
||||
model.body.visible = true;
|
||||
model.jacket.visible = true;
|
||||
model.rightArm.visible = true;
|
||||
model.leftArm.visible = true;
|
||||
model.rightSleeve.visible = true;
|
||||
model.leftSleeve.visible = true;
|
||||
model.rightLeg.visible = true;
|
||||
model.leftLeg.visible = true;
|
||||
model.rightPants.visible = true;
|
||||
model.leftPants.visible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if clothes should force small arms rendering.
|
||||
* Public method for BondageItemRenderLayer.
|
||||
*/
|
||||
public static boolean shouldUseSmallArms(
|
||||
ItemStack clothes,
|
||||
LivingEntity entity
|
||||
) {
|
||||
if (
|
||||
clothes.isEmpty() ||
|
||||
!(clothes.getItem() instanceof GenericClothes gc)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check local item
|
||||
if (gc.shouldForceSmallArms(clothes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check remote cache
|
||||
if (entity instanceof AbstractClientPlayer player) {
|
||||
return ClothesClientCache.isSmallArmsForced(player.getUUID());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if clothes have a dynamic texture (for deciding render path).
|
||||
*/
|
||||
public static boolean hasDynamicTexture(
|
||||
ItemStack clothes,
|
||||
LivingEntity entity
|
||||
) {
|
||||
if (
|
||||
clothes.isEmpty() ||
|
||||
!(clothes.getItem() instanceof GenericClothes gc)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check local item
|
||||
String localUrl = gc.getDynamicTextureUrl(clothes);
|
||||
if (localUrl != null && !localUrl.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check remote cache
|
||||
if (entity instanceof AbstractClientPlayer player) {
|
||||
String cachedUrl = ClothesClientCache.getPlayerDynamicUrl(
|
||||
player.getUUID()
|
||||
);
|
||||
return cachedUrl != null && !cachedUrl.isEmpty();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if full-skin mode is enabled for clothes.
|
||||
*/
|
||||
public static boolean isFullSkinMode(
|
||||
ItemStack clothes,
|
||||
LivingEntity entity
|
||||
) {
|
||||
if (
|
||||
clothes.isEmpty() ||
|
||||
!(clothes.getItem() instanceof GenericClothes gc)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check local item
|
||||
if (gc.isFullSkinEnabled(clothes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check remote cache
|
||||
if (entity instanceof AbstractClientPlayer player) {
|
||||
return ClothesClientCache.isFullSkinEnabled(player.getUUID());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== Wearer Layer Hiding ====================
|
||||
|
||||
/**
|
||||
* Hide wearer's outer layers when clothes are equipped.
|
||||
* Called BEFORE rendering the base model.
|
||||
*
|
||||
* <p>Logic: When clothes are equipped, hide ALL wearer's outer layers.
|
||||
* The clothes will render their own layers on top.
|
||||
* Exception: If keepHead is enabled, the head/hat layers remain visible.
|
||||
*
|
||||
* @param model The wearer's PlayerModel
|
||||
* @param props Clothes properties (used to confirm clothes are valid)
|
||||
* @return Original visibility state for restoration (6 booleans)
|
||||
*/
|
||||
public static boolean[] hideWearerLayers(
|
||||
PlayerModel<?> model,
|
||||
ClothesProperties props
|
||||
) {
|
||||
if (props == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Save original state
|
||||
boolean[] original = {
|
||||
model.hat.visible,
|
||||
model.jacket.visible,
|
||||
model.leftSleeve.visible,
|
||||
model.rightSleeve.visible,
|
||||
model.leftPants.visible,
|
||||
model.rightPants.visible,
|
||||
};
|
||||
|
||||
// When wearing clothes, hide wearer's outer layers
|
||||
// Exception: if keepHead is true, don't hide head/hat
|
||||
if (!props.keepHead()) {
|
||||
model.hat.visible = false;
|
||||
}
|
||||
model.jacket.visible = false;
|
||||
model.leftSleeve.visible = false;
|
||||
model.rightSleeve.visible = false;
|
||||
model.leftPants.visible = false;
|
||||
model.rightPants.visible = false;
|
||||
|
||||
return original;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore wearer's layer visibility after rendering.
|
||||
*
|
||||
* @param model The wearer's PlayerModel
|
||||
* @param original Original visibility state from hideWearerLayers()
|
||||
*/
|
||||
public static void restoreWearerLayers(
|
||||
PlayerModel<?> model,
|
||||
boolean[] original
|
||||
) {
|
||||
if (original == null || original.length != 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.hat.visible = original[0];
|
||||
model.jacket.visible = original[1];
|
||||
model.leftSleeve.visible = original[2];
|
||||
model.rightSleeve.visible = original[3];
|
||||
model.leftPants.visible = original[4];
|
||||
model.rightPants.visible = original[5];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clothes properties for wearer layer hiding.
|
||||
* Works for both players (with remote cache) and NPCs (direct from item).
|
||||
*
|
||||
* @param clothes The clothes ItemStack
|
||||
* @param entity The entity wearing clothes
|
||||
* @return ClothesProperties or null if not available
|
||||
*/
|
||||
public static ClothesProperties getPropsForLayerHiding(
|
||||
ItemStack clothes,
|
||||
LivingEntity entity
|
||||
) {
|
||||
if (
|
||||
clothes.isEmpty() || !(clothes.getItem() instanceof GenericClothes)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For players, check remote cache first
|
||||
if (entity instanceof AbstractClientPlayer player) {
|
||||
ClothesClientCache.CachedClothesData cached =
|
||||
ClothesClientCache.getPlayerClothes(player.getUUID());
|
||||
if (cached != null) {
|
||||
return new ClothesProperties(
|
||||
cached.dynamicUrl(),
|
||||
cached.fullSkin(),
|
||||
cached.smallArms(),
|
||||
cached.keepHead(),
|
||||
cached.visibleLayers()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to direct item properties
|
||||
return ClothesProperties.fromStack(clothes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.tiedup.remake.client.renderer.models;
|
||||
|
||||
import net.minecraft.client.model.geom.ModelLayerLocation;
|
||||
import net.minecraft.client.model.geom.PartPose;
|
||||
import net.minecraft.client.model.geom.builders.CubeDeformation;
|
||||
import net.minecraft.client.model.geom.builders.CubeListBuilder;
|
||||
import net.minecraft.client.model.geom.builders.LayerDefinition;
|
||||
import net.minecraft.client.model.geom.builders.MeshDefinition;
|
||||
import net.minecraft.client.model.geom.builders.PartDefinition;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
public class BondageLayerDefinitions {
|
||||
|
||||
public static final ModelLayerLocation BONDAGE_LAYER =
|
||||
new ModelLayerLocation(
|
||||
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_layer"),
|
||||
"main"
|
||||
);
|
||||
|
||||
public static final ModelLayerLocation BONDAGE_LAYER_SLIM =
|
||||
new ModelLayerLocation(
|
||||
ResourceLocation.fromNamespaceAndPath(
|
||||
"tiedup",
|
||||
"bondage_layer_slim"
|
||||
),
|
||||
"main"
|
||||
);
|
||||
|
||||
/**
|
||||
* Create bondage layer for normal arms (4px wide - Steve model).
|
||||
*/
|
||||
public static LayerDefinition createBodyLayer() {
|
||||
return createBondageLayer(4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create bondage layer for slim arms (3px wide - Alex model).
|
||||
*/
|
||||
public static LayerDefinition createSlimBodyLayer() {
|
||||
return createBondageLayer(3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create bondage layer with specified arm width.
|
||||
*
|
||||
* @param armWidth Arm width in pixels (4 for Steve, 3 for Alex)
|
||||
*/
|
||||
private static LayerDefinition createBondageLayer(int armWidth) {
|
||||
MeshDefinition meshdefinition = new MeshDefinition();
|
||||
PartDefinition partdefinition = meshdefinition.getRoot();
|
||||
|
||||
// Inflation for tight fit on body
|
||||
CubeDeformation deformation = new CubeDeformation(0.35F);
|
||||
|
||||
// Head at standard position
|
||||
partdefinition.addOrReplaceChild(
|
||||
"head",
|
||||
CubeListBuilder.create()
|
||||
.texOffs(0, 0)
|
||||
.addBox(-4.0F, -8.0F, -4.0F, 8.0F, 8.0F, 8.0F, deformation),
|
||||
PartPose.offset(0.0F, 0.0F, 0.0F)
|
||||
);
|
||||
|
||||
partdefinition.addOrReplaceChild(
|
||||
"hat",
|
||||
CubeListBuilder.create()
|
||||
.texOffs(32, 0)
|
||||
.addBox(
|
||||
-4.0F,
|
||||
-8.0F,
|
||||
-4.0F,
|
||||
8.0F,
|
||||
8.0F,
|
||||
8.0F,
|
||||
deformation.extend(0.1F)
|
||||
),
|
||||
PartPose.offset(0.0F, 0.0F, 0.0F)
|
||||
);
|
||||
|
||||
partdefinition.addOrReplaceChild(
|
||||
"body",
|
||||
CubeListBuilder.create()
|
||||
.texOffs(16, 16)
|
||||
.addBox(-4.0F, 0.0F, -2.0F, 8.0F, 12.0F, 4.0F, deformation),
|
||||
PartPose.offset(0.0F, 0.0F, 0.0F)
|
||||
);
|
||||
|
||||
// Arms - width varies based on model type
|
||||
float armWidthF = (float) armWidth;
|
||||
float rightArmX = -(armWidthF - 1.0F); // -3 for 4px, -2 for 3px
|
||||
|
||||
partdefinition.addOrReplaceChild(
|
||||
"right_arm",
|
||||
CubeListBuilder.create()
|
||||
.texOffs(40, 16)
|
||||
.addBox(
|
||||
rightArmX,
|
||||
-2.0F,
|
||||
-2.0F,
|
||||
armWidthF,
|
||||
12.0F,
|
||||
4.0F,
|
||||
deformation
|
||||
),
|
||||
PartPose.offset(-5.0F, 2.0F, 0.0F)
|
||||
);
|
||||
|
||||
partdefinition.addOrReplaceChild(
|
||||
"left_arm",
|
||||
CubeListBuilder.create()
|
||||
.texOffs(40, 16)
|
||||
.mirror()
|
||||
.addBox(
|
||||
-1.0F,
|
||||
-2.0F,
|
||||
-2.0F,
|
||||
armWidthF,
|
||||
12.0F,
|
||||
4.0F,
|
||||
deformation
|
||||
),
|
||||
PartPose.offset(5.0F, 2.0F, 0.0F)
|
||||
);
|
||||
|
||||
partdefinition.addOrReplaceChild(
|
||||
"right_leg",
|
||||
CubeListBuilder.create()
|
||||
.texOffs(0, 16)
|
||||
.addBox(-2.0F, 0.0F, -2.0F, 4.0F, 12.0F, 4.0F, deformation),
|
||||
PartPose.offset(-1.9F, 12.0F, 0.0F)
|
||||
);
|
||||
|
||||
partdefinition.addOrReplaceChild(
|
||||
"left_leg",
|
||||
CubeListBuilder.create()
|
||||
.texOffs(0, 16)
|
||||
.mirror()
|
||||
.addBox(-2.0F, 0.0F, -2.0F, 4.0F, 12.0F, 4.0F, deformation),
|
||||
PartPose.offset(1.9F, 12.0F, 0.0F)
|
||||
);
|
||||
|
||||
return LayerDefinition.create(meshdefinition, 64, 32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.tiedup.remake.client.renderer.obj;
|
||||
|
||||
/**
|
||||
* Immutable record representing a triangular face in an OBJ model.
|
||||
* Contains exactly 3 vertices forming a triangle.
|
||||
*/
|
||||
public record ObjFace(ObjVertex v0, ObjVertex v1, ObjVertex v2) {
|
||||
/**
|
||||
* Get the vertices as an array for iteration.
|
||||
*/
|
||||
public ObjVertex[] vertices() {
|
||||
return new ObjVertex[] { v0, v1, v2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the face normal from vertices (cross product of edges).
|
||||
* Useful when the OBJ file doesn't provide normals.
|
||||
*/
|
||||
public float[] calculateNormal() {
|
||||
// Edge vectors
|
||||
float e1x = v1.x() - v0.x();
|
||||
float e1y = v1.y() - v0.y();
|
||||
float e1z = v1.z() - v0.z();
|
||||
|
||||
float e2x = v2.x() - v0.x();
|
||||
float e2y = v2.y() - v0.y();
|
||||
float e2z = v2.z() - v0.z();
|
||||
|
||||
// Cross product
|
||||
float nx = e1y * e2z - e1z * e2y;
|
||||
float ny = e1z * e2x - e1x * e2z;
|
||||
float nz = e1x * e2y - e1y * e2x;
|
||||
|
||||
// Normalize
|
||||
float len = (float) Math.sqrt(nx * nx + ny * ny + nz * nz);
|
||||
if (len > 0.0001f) {
|
||||
nx /= len;
|
||||
ny /= len;
|
||||
nz /= len;
|
||||
}
|
||||
|
||||
return new float[] { nx, ny, nz };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.tiedup.remake.client.renderer.obj;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Immutable record representing a material from an MTL file.
|
||||
* Contains the material name, diffuse color (Kd), and optional diffuse texture (map_Kd).
|
||||
*/
|
||||
public record ObjMaterial(
|
||||
String name,
|
||||
float r,
|
||||
float g,
|
||||
float b,
|
||||
@Nullable String texturePath
|
||||
) {
|
||||
/**
|
||||
* Default white material used when no material is specified.
|
||||
*/
|
||||
public static final ObjMaterial DEFAULT = new ObjMaterial(
|
||||
"default",
|
||||
1.0f,
|
||||
1.0f,
|
||||
1.0f,
|
||||
null
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a material with just color (no texture).
|
||||
*/
|
||||
public ObjMaterial(String name, float r, float g, float b) {
|
||||
this(name, r, g, b, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a material with grayscale color.
|
||||
*/
|
||||
public static ObjMaterial ofGrayscale(String name, float value) {
|
||||
return new ObjMaterial(name, value, value, value, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this material has a diffuse texture.
|
||||
*/
|
||||
public boolean hasTexture() {
|
||||
return texturePath != null && !texturePath.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color as a packed ARGB int (with full alpha).
|
||||
*/
|
||||
public int toARGB() {
|
||||
int ri = (int) (r * 255) & 0xFF;
|
||||
int gi = (int) (g * 255) & 0xFF;
|
||||
int bi = (int) (b * 255) & 0xFF;
|
||||
return 0xFF000000 | (ri << 16) | (gi << 8) | bi;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.tiedup.remake.client.renderer.obj;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Container for a loaded OBJ model.
|
||||
* Holds faces grouped by material and material definitions.
|
||||
* Immutable after construction.
|
||||
*/
|
||||
public class ObjModel {
|
||||
|
||||
private final Map<String, List<ObjFace>> facesByMaterial;
|
||||
private final Map<String, ObjMaterial> materials;
|
||||
private final int totalFaces;
|
||||
private final int totalVertices;
|
||||
|
||||
/** Base path for resolving relative texture paths (e.g., "tiedup:textures/models/obj/") */
|
||||
@Nullable
|
||||
private final String textureBasePath;
|
||||
|
||||
// Bounding box (AABB)
|
||||
private final float minX, minY, minZ;
|
||||
private final float maxX, maxY, maxZ;
|
||||
|
||||
private ObjModel(Builder builder) {
|
||||
this.facesByMaterial = Collections.unmodifiableMap(
|
||||
new HashMap<>(builder.facesByMaterial)
|
||||
);
|
||||
this.materials = Collections.unmodifiableMap(
|
||||
new HashMap<>(builder.materials)
|
||||
);
|
||||
this.textureBasePath = builder.textureBasePath;
|
||||
|
||||
// Calculate totals
|
||||
int faces = 0;
|
||||
for (List<ObjFace> faceList : this.facesByMaterial.values()) {
|
||||
faces += faceList.size();
|
||||
}
|
||||
this.totalFaces = faces;
|
||||
this.totalVertices = faces * 3;
|
||||
|
||||
// Calculate AABB
|
||||
float minX = Float.MAX_VALUE,
|
||||
minY = Float.MAX_VALUE,
|
||||
minZ = Float.MAX_VALUE;
|
||||
float maxX = -Float.MAX_VALUE,
|
||||
maxY = -Float.MAX_VALUE,
|
||||
maxZ = -Float.MAX_VALUE;
|
||||
|
||||
for (List<ObjFace> faceList : this.facesByMaterial.values()) {
|
||||
for (ObjFace face : faceList) {
|
||||
for (ObjVertex v : face.vertices()) {
|
||||
minX = Math.min(minX, v.x());
|
||||
minY = Math.min(minY, v.y());
|
||||
minZ = Math.min(minZ, v.z());
|
||||
maxX = Math.max(maxX, v.x());
|
||||
maxY = Math.max(maxY, v.y());
|
||||
maxZ = Math.max(maxZ, v.z());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.minX = minX == Float.MAX_VALUE ? 0 : minX;
|
||||
this.minY = minY == Float.MAX_VALUE ? 0 : minY;
|
||||
this.minZ = minZ == Float.MAX_VALUE ? 0 : minZ;
|
||||
this.maxX = maxX == -Float.MAX_VALUE ? 0 : maxX;
|
||||
this.maxY = maxY == -Float.MAX_VALUE ? 0 : maxY;
|
||||
this.maxZ = maxZ == -Float.MAX_VALUE ? 0 : maxZ;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all faces grouped by material name.
|
||||
*/
|
||||
public Map<String, List<ObjFace>> getFacesByMaterial() {
|
||||
return facesByMaterial;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a material by name.
|
||||
* Returns default white material if not found.
|
||||
*/
|
||||
public ObjMaterial getMaterial(String name) {
|
||||
return materials.getOrDefault(name, ObjMaterial.DEFAULT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all materials.
|
||||
*/
|
||||
public Map<String, ObjMaterial> getMaterials() {
|
||||
return materials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of triangles in the model.
|
||||
*/
|
||||
public int getTotalFaces() {
|
||||
return totalFaces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of vertices (faces * 3).
|
||||
*/
|
||||
public int getTotalVertices() {
|
||||
return totalVertices;
|
||||
}
|
||||
|
||||
// AABB getters
|
||||
public float getMinX() {
|
||||
return minX;
|
||||
}
|
||||
|
||||
public float getMinY() {
|
||||
return minY;
|
||||
}
|
||||
|
||||
public float getMinZ() {
|
||||
return minZ;
|
||||
}
|
||||
|
||||
public float getMaxX() {
|
||||
return maxX;
|
||||
}
|
||||
|
||||
public float getMaxY() {
|
||||
return maxY;
|
||||
}
|
||||
|
||||
public float getMaxZ() {
|
||||
return maxZ;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the center point of the model's bounding box.
|
||||
*/
|
||||
public float[] getCenter() {
|
||||
return new float[] {
|
||||
(minX + maxX) / 2f,
|
||||
(minY + maxY) / 2f,
|
||||
(minZ + maxZ) / 2f,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the dimensions of the bounding box.
|
||||
*/
|
||||
public float[] getDimensions() {
|
||||
return new float[] { maxX - minX, maxY - minY, maxZ - minZ };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a texture filename to a full ResourceLocation.
|
||||
* Uses the textureBasePath set during loading.
|
||||
*
|
||||
* @param filename The texture filename (e.g., "ball_gag.png")
|
||||
* @return Full ResourceLocation, or null if no base path is set
|
||||
*/
|
||||
@Nullable
|
||||
public ResourceLocation resolveTexture(String filename) {
|
||||
if (textureBasePath == null || filename == null) {
|
||||
return null;
|
||||
}
|
||||
return ResourceLocation.fromNamespaceAndPath(
|
||||
"tiedup",
|
||||
textureBasePath + filename
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a texture filename with a color suffix inserted before the extension.
|
||||
* Example: "texture.png" + "red" -> "texture_red.png"
|
||||
*
|
||||
* @param filename The base texture filename (e.g., "texture.png")
|
||||
* @param colorSuffix The color suffix to insert (e.g., "red"), or null for no suffix
|
||||
* @return Full ResourceLocation with color suffix, or null if no base path is set
|
||||
*/
|
||||
@Nullable
|
||||
public ResourceLocation resolveTextureWithColorSuffix(
|
||||
String filename,
|
||||
@Nullable String colorSuffix
|
||||
) {
|
||||
if (textureBasePath == null || filename == null) {
|
||||
return null;
|
||||
}
|
||||
if (colorSuffix == null || colorSuffix.isEmpty()) {
|
||||
return resolveTexture(filename);
|
||||
}
|
||||
// Insert color suffix before extension: "texture.png" -> "texture_red.png"
|
||||
int dotIndex = filename.lastIndexOf('.');
|
||||
String newFilename;
|
||||
if (dotIndex > 0) {
|
||||
newFilename =
|
||||
filename.substring(0, dotIndex) +
|
||||
"_" +
|
||||
colorSuffix +
|
||||
filename.substring(dotIndex);
|
||||
} else {
|
||||
newFilename = filename + "_" + colorSuffix;
|
||||
}
|
||||
return ResourceLocation.fromNamespaceAndPath(
|
||||
"tiedup",
|
||||
textureBasePath + newFilename
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the texture base path.
|
||||
*/
|
||||
@Nullable
|
||||
public String getTextureBasePath() {
|
||||
return textureBasePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for constructing ObjModel instances.
|
||||
*/
|
||||
public static class Builder {
|
||||
|
||||
private final Map<String, List<ObjFace>> facesByMaterial =
|
||||
new HashMap<>();
|
||||
private final Map<String, ObjMaterial> materials = new HashMap<>();
|
||||
private String textureBasePath;
|
||||
|
||||
public Builder addFaces(String materialName, List<ObjFace> faces) {
|
||||
if (faces != null && !faces.isEmpty()) {
|
||||
facesByMaterial.put(materialName, List.copyOf(faces));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addMaterial(ObjMaterial material) {
|
||||
if (material != null) {
|
||||
materials.put(material.name(), material);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setTextureBasePath(String path) {
|
||||
this.textureBasePath = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ObjModel build() {
|
||||
return new ObjModel(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new builder.
|
||||
*/
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package com.tiedup.remake.client.renderer.obj;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.packs.resources.Resource;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Parses .obj and .mtl files from ResourceLocations.
|
||||
* Supports:
|
||||
* - Vertex positions (v)
|
||||
* - Texture coordinates (vt)
|
||||
* - Vertex normals (vn)
|
||||
* - Faces (f) with v/vt/vn format, triangles and quads
|
||||
* - Material library (mtllib)
|
||||
* - Material assignment (usemtl)
|
||||
* - MTL diffuse color (Kd)
|
||||
*/
|
||||
public class ObjModelLoader {
|
||||
|
||||
private ObjModelLoader() {
|
||||
// Utility class
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an OBJ model from a ResourceLocation.
|
||||
*
|
||||
* @param location ResourceLocation pointing to the .obj file
|
||||
* @return Loaded ObjModel, or null if loading failed
|
||||
*/
|
||||
@Nullable
|
||||
public static ObjModel load(ResourceLocation location) {
|
||||
ResourceManager resourceManager =
|
||||
Minecraft.getInstance().getResourceManager();
|
||||
|
||||
try {
|
||||
Optional<Resource> resourceOpt = resourceManager.getResource(
|
||||
location
|
||||
);
|
||||
if (resourceOpt.isEmpty()) {
|
||||
TiedUpMod.LOGGER.warn("OBJ file not found: {}", location);
|
||||
return null;
|
||||
}
|
||||
|
||||
Resource resource = resourceOpt.get();
|
||||
|
||||
// Parse context
|
||||
List<float[]> positions = new ArrayList<>();
|
||||
List<float[]> texCoords = new ArrayList<>();
|
||||
List<float[]> normals = new ArrayList<>();
|
||||
Map<String, List<ObjFace>> facesByMaterial = new HashMap<>();
|
||||
Map<String, ObjMaterial> materials = new HashMap<>();
|
||||
|
||||
String currentMaterial = "default";
|
||||
String mtlLib = null;
|
||||
|
||||
// Parse OBJ file
|
||||
try (
|
||||
BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(
|
||||
resource.open(),
|
||||
StandardCharsets.UTF_8
|
||||
)
|
||||
)
|
||||
) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty() || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String[] parts = line.split("\\s+");
|
||||
if (parts.length == 0) continue;
|
||||
|
||||
switch (parts[0]) {
|
||||
case "v" -> {
|
||||
// Vertex position: v x y z
|
||||
if (parts.length >= 4) {
|
||||
positions.add(
|
||||
new float[] {
|
||||
parseFloat(parts[1]),
|
||||
parseFloat(parts[2]),
|
||||
parseFloat(parts[3]),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
case "vt" -> {
|
||||
// Texture coordinate: vt u v
|
||||
if (parts.length >= 3) {
|
||||
texCoords.add(
|
||||
new float[] {
|
||||
parseFloat(parts[1]),
|
||||
parseFloat(parts[2]),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
case "vn" -> {
|
||||
// Vertex normal: vn x y z
|
||||
if (parts.length >= 4) {
|
||||
normals.add(
|
||||
new float[] {
|
||||
parseFloat(parts[1]),
|
||||
parseFloat(parts[2]),
|
||||
parseFloat(parts[3]),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
case "mtllib" -> {
|
||||
// Material library: mtllib filename.mtl
|
||||
if (parts.length >= 2) {
|
||||
mtlLib = parts[1];
|
||||
}
|
||||
}
|
||||
case "usemtl" -> {
|
||||
// Use material: usemtl MaterialName
|
||||
if (parts.length >= 2) {
|
||||
currentMaterial = parts[1];
|
||||
}
|
||||
}
|
||||
case "f" -> {
|
||||
// Face: f v/vt/vn v/vt/vn v/vt/vn [v/vt/vn]
|
||||
List<ObjVertex> faceVerts = new ArrayList<>();
|
||||
for (int i = 1; i < parts.length; i++) {
|
||||
ObjVertex vert = parseVertex(
|
||||
parts[i],
|
||||
positions,
|
||||
texCoords,
|
||||
normals
|
||||
);
|
||||
if (vert != null) {
|
||||
faceVerts.add(vert);
|
||||
}
|
||||
}
|
||||
|
||||
// Triangulate if needed (quad -> 2 triangles)
|
||||
List<ObjFace> faces = triangulate(faceVerts);
|
||||
facesByMaterial
|
||||
.computeIfAbsent(currentMaterial, k ->
|
||||
new ArrayList<>()
|
||||
)
|
||||
.addAll(faces);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load MTL file if referenced
|
||||
if (mtlLib != null) {
|
||||
ResourceLocation mtlLocation = resolveMtlPath(location, mtlLib);
|
||||
Map<String, ObjMaterial> loadedMaterials = loadMtl(mtlLocation);
|
||||
materials.putAll(loadedMaterials);
|
||||
}
|
||||
|
||||
// Build and return model
|
||||
ObjModel.Builder builder = ObjModel.builder();
|
||||
for (Map.Entry<
|
||||
String,
|
||||
List<ObjFace>
|
||||
> entry : facesByMaterial.entrySet()) {
|
||||
builder.addFaces(entry.getKey(), entry.getValue());
|
||||
}
|
||||
for (ObjMaterial mat : materials.values()) {
|
||||
builder.addMaterial(mat);
|
||||
}
|
||||
|
||||
// Set texture base path (textures are now in the same directory as the OBJ)
|
||||
String objPath = location.getPath();
|
||||
int lastSlash = objPath.lastIndexOf('/');
|
||||
String directory =
|
||||
lastSlash >= 0 ? objPath.substring(0, lastSlash + 1) : "";
|
||||
// Textures are in the same folder as the OBJ file
|
||||
String textureBasePath = directory;
|
||||
builder.setTextureBasePath(textureBasePath);
|
||||
|
||||
ObjModel model = builder.build();
|
||||
TiedUpMod.LOGGER.info(
|
||||
"Loaded OBJ model: {} ({} faces, {} materials)",
|
||||
location,
|
||||
model.getTotalFaces(),
|
||||
materials.size()
|
||||
);
|
||||
|
||||
return model;
|
||||
} catch (Exception e) {
|
||||
TiedUpMod.LOGGER.error("Failed to load OBJ model: {}", location, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single vertex from face data.
|
||||
* Format: v/vt/vn or v//vn or v/vt or v
|
||||
*/
|
||||
@Nullable
|
||||
private static ObjVertex parseVertex(
|
||||
String data,
|
||||
List<float[]> positions,
|
||||
List<float[]> texCoords,
|
||||
List<float[]> normals
|
||||
) {
|
||||
String[] indices = data.split("/");
|
||||
if (indices.length == 0) return null;
|
||||
|
||||
// Position index (required)
|
||||
int posIdx = parseInt(indices[0]) - 1; // OBJ indices are 1-based
|
||||
if (posIdx < 0 || posIdx >= positions.size()) return null;
|
||||
float[] pos = positions.get(posIdx);
|
||||
|
||||
// Texture coordinate index (optional)
|
||||
float u = 0,
|
||||
v = 0;
|
||||
if (indices.length >= 2 && !indices[1].isEmpty()) {
|
||||
int texIdx = parseInt(indices[1]) - 1;
|
||||
if (texIdx >= 0 && texIdx < texCoords.size()) {
|
||||
float[] tex = texCoords.get(texIdx);
|
||||
u = tex[0];
|
||||
v = tex[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Normal index (optional)
|
||||
float nx = 0,
|
||||
ny = 1,
|
||||
nz = 0;
|
||||
if (indices.length >= 3 && !indices[2].isEmpty()) {
|
||||
int normIdx = parseInt(indices[2]) - 1;
|
||||
if (normIdx >= 0 && normIdx < normals.size()) {
|
||||
float[] norm = normals.get(normIdx);
|
||||
nx = norm[0];
|
||||
ny = norm[1];
|
||||
nz = norm[2];
|
||||
}
|
||||
}
|
||||
|
||||
return new ObjVertex(pos[0], pos[1], pos[2], u, v, nx, ny, nz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triangulate a face (convert quad to 2 triangles).
|
||||
*/
|
||||
private static List<ObjFace> triangulate(List<ObjVertex> vertices) {
|
||||
List<ObjFace> result = new ArrayList<>();
|
||||
|
||||
if (vertices.size() < 3) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Triangle
|
||||
if (vertices.size() == 3) {
|
||||
result.add(
|
||||
new ObjFace(vertices.get(0), vertices.get(1), vertices.get(2))
|
||||
);
|
||||
}
|
||||
// Quad -> 2 triangles (fan triangulation)
|
||||
else if (vertices.size() >= 4) {
|
||||
ObjVertex v0 = vertices.get(0);
|
||||
for (int i = 1; i < vertices.size() - 1; i++) {
|
||||
result.add(
|
||||
new ObjFace(v0, vertices.get(i), vertices.get(i + 1))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the MTL file path relative to the OBJ file.
|
||||
*/
|
||||
private static ResourceLocation resolveMtlPath(
|
||||
ResourceLocation objLocation,
|
||||
String mtlFileName
|
||||
) {
|
||||
String objPath = objLocation.getPath();
|
||||
int lastSlash = objPath.lastIndexOf('/');
|
||||
String directory =
|
||||
lastSlash >= 0 ? objPath.substring(0, lastSlash + 1) : "";
|
||||
return ResourceLocation.fromNamespaceAndPath(
|
||||
objLocation.getNamespace(),
|
||||
directory + mtlFileName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load materials from an MTL file.
|
||||
*/
|
||||
private static Map<String, ObjMaterial> loadMtl(ResourceLocation location) {
|
||||
Map<String, ObjMaterial> materials = new HashMap<>();
|
||||
ResourceManager resourceManager =
|
||||
Minecraft.getInstance().getResourceManager();
|
||||
|
||||
try {
|
||||
Optional<Resource> resourceOpt = resourceManager.getResource(
|
||||
location
|
||||
);
|
||||
if (resourceOpt.isEmpty()) {
|
||||
TiedUpMod.LOGGER.warn("MTL file not found: {}", location);
|
||||
return materials;
|
||||
}
|
||||
|
||||
Resource resource = resourceOpt.get();
|
||||
String currentName = null;
|
||||
float r = 1,
|
||||
g = 1,
|
||||
b = 1;
|
||||
String texturePath = null;
|
||||
|
||||
try (
|
||||
BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(
|
||||
resource.open(),
|
||||
StandardCharsets.UTF_8
|
||||
)
|
||||
)
|
||||
) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty() || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String[] parts = line.split("\\s+");
|
||||
if (parts.length == 0) continue;
|
||||
|
||||
switch (parts[0]) {
|
||||
case "newmtl" -> {
|
||||
// Save previous material
|
||||
if (currentName != null) {
|
||||
materials.put(
|
||||
currentName,
|
||||
new ObjMaterial(
|
||||
currentName,
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
texturePath
|
||||
)
|
||||
);
|
||||
}
|
||||
// Start new material
|
||||
currentName =
|
||||
parts.length >= 2 ? parts[1] : "unnamed";
|
||||
r = 1;
|
||||
g = 1;
|
||||
b = 1; // Reset to default
|
||||
texturePath = null;
|
||||
}
|
||||
case "Kd" -> {
|
||||
// Diffuse color: Kd r g b
|
||||
if (parts.length >= 4) {
|
||||
r = parseFloat(parts[1]);
|
||||
g = parseFloat(parts[2]);
|
||||
b = parseFloat(parts[3]);
|
||||
}
|
||||
}
|
||||
case "map_Kd" -> {
|
||||
// Diffuse texture map: map_Kd filename.png
|
||||
if (parts.length >= 2) {
|
||||
texturePath = parts[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save last material
|
||||
if (currentName != null) {
|
||||
materials.put(
|
||||
currentName,
|
||||
new ObjMaterial(currentName, r, g, b, texturePath)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TiedUpMod.LOGGER.debug(
|
||||
"Loaded {} materials from MTL: {}",
|
||||
materials.size(),
|
||||
location
|
||||
);
|
||||
} catch (IOException e) {
|
||||
TiedUpMod.LOGGER.error("Failed to load MTL file: {}", location, e);
|
||||
}
|
||||
|
||||
return materials;
|
||||
}
|
||||
|
||||
private static float parseFloat(String s) {
|
||||
try {
|
||||
return Float.parseFloat(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return 0f;
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseInt(String s) {
|
||||
try {
|
||||
return Integer.parseInt(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.tiedup.remake.client.renderer.obj;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Singleton registry/cache for loaded OBJ models.
|
||||
* Models are loaded on-demand and cached for reuse.
|
||||
* Client-side only.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ObjModelRegistry {
|
||||
|
||||
private static final Map<ResourceLocation, ObjModel> CACHE =
|
||||
new HashMap<>();
|
||||
private static boolean initialized = false;
|
||||
|
||||
private ObjModelRegistry() {
|
||||
// Singleton utility class
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the registry and preload known models.
|
||||
* Called during FMLClientSetupEvent.
|
||||
*/
|
||||
public static void init() {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
TiedUpMod.LOGGER.info("Initializing ObjModelRegistry...");
|
||||
|
||||
// Preload known 3D models
|
||||
preloadModel("tiedup:models/obj/ball_gag/model.obj");
|
||||
preloadModel("tiedup:models/obj/choke_collar_leather/model.obj");
|
||||
|
||||
initialized = true;
|
||||
TiedUpMod.LOGGER.info(
|
||||
"ObjModelRegistry initialized with {} models",
|
||||
CACHE.size()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload a model into the cache.
|
||||
*
|
||||
* @param path Resource path (e.g., "tiedup:models/obj/ball_gag.obj")
|
||||
*/
|
||||
private static void preloadModel(String path) {
|
||||
ResourceLocation location = ResourceLocation.tryParse(path);
|
||||
if (location != null) {
|
||||
ObjModel model = ObjModelLoader.load(location);
|
||||
if (model != null) {
|
||||
CACHE.put(location, model);
|
||||
TiedUpMod.LOGGER.debug("Preloaded OBJ model: {}", path);
|
||||
} else {
|
||||
TiedUpMod.LOGGER.warn("Failed to preload OBJ model: {}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a model from the cache, loading it if not present.
|
||||
*
|
||||
* @param location ResourceLocation of the .obj file
|
||||
* @return The loaded model, or null if not found/failed to load
|
||||
*/
|
||||
@Nullable
|
||||
public static ObjModel get(ResourceLocation location) {
|
||||
if (location == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CACHE.computeIfAbsent(location, ObjModelLoader::load);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a model by string path.
|
||||
*
|
||||
* @param path Resource path (e.g., "tiedup:models/obj/ball_gag.obj")
|
||||
* @return The loaded model, or null if not found/failed to load
|
||||
*/
|
||||
@Nullable
|
||||
public static ObjModel get(String path) {
|
||||
ResourceLocation location = ResourceLocation.tryParse(path);
|
||||
return location != null ? get(location) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is loaded in the cache.
|
||||
*/
|
||||
public static boolean isLoaded(ResourceLocation location) {
|
||||
return CACHE.containsKey(location);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cache.
|
||||
* Useful for resource reload events.
|
||||
*/
|
||||
public static void clearCache() {
|
||||
CACHE.clear();
|
||||
initialized = false;
|
||||
TiedUpMod.LOGGER.info("ObjModelRegistry cache cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of loaded models.
|
||||
*/
|
||||
public static int getCachedCount() {
|
||||
return CACHE.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package com.tiedup.remake.client.renderer.obj;
|
||||
|
||||
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||
import com.mojang.blaze3d.vertex.VertexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.RenderStateShard;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.joml.Matrix3f;
|
||||
import org.joml.Matrix4f;
|
||||
|
||||
/**
|
||||
* Renders OBJ models using Minecraft's rendering system.
|
||||
* Uses VertexConsumer for hardware-accelerated rendering.
|
||||
* Client-side only.
|
||||
*
|
||||
* <p><b>Important:</b> Uses TRIANGLES mode RenderType, not QUADS,
|
||||
* because OBJ models are triangulated during loading.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ObjModelRenderer extends RenderStateShard {
|
||||
|
||||
/** White texture for vertex color rendering */
|
||||
public static final ResourceLocation WHITE_TEXTURE =
|
||||
ResourceLocation.fromNamespaceAndPath(
|
||||
"tiedup",
|
||||
"models/obj/shared/white.png"
|
||||
);
|
||||
|
||||
private ObjModelRenderer() {
|
||||
super("tiedup_obj_renderer", () -> {}, () -> {});
|
||||
// Utility class - extends RenderStateShard to access protected members
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TRIANGLES-mode RenderType for OBJ rendering.
|
||||
* Standard entityCutoutNoCull uses QUADS which causes spiky artifacts
|
||||
* when we submit triangles.
|
||||
*/
|
||||
private static RenderType createTriangleRenderType(
|
||||
ResourceLocation texture
|
||||
) {
|
||||
RenderType.CompositeState state = RenderType.CompositeState.builder()
|
||||
.setShaderState(RENDERTYPE_ENTITY_CUTOUT_NO_CULL_SHADER)
|
||||
.setTextureState(
|
||||
new RenderStateShard.TextureStateShard(texture, false, false)
|
||||
)
|
||||
.setTransparencyState(NO_TRANSPARENCY)
|
||||
.setCullState(NO_CULL)
|
||||
.setLightmapState(LIGHTMAP)
|
||||
.setOverlayState(OVERLAY)
|
||||
.createCompositeState(true);
|
||||
|
||||
return RenderType.create(
|
||||
"tiedup_obj_triangles",
|
||||
DefaultVertexFormat.NEW_ENTITY,
|
||||
VertexFormat.Mode.TRIANGLES, // Key fix: TRIANGLES not QUADS
|
||||
1536,
|
||||
true,
|
||||
false,
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an OBJ model with vertex colors from materials.
|
||||
*
|
||||
* @param model The OBJ model to render
|
||||
* @param poseStack The current pose stack (with transformations applied)
|
||||
* @param buffer The multi-buffer source
|
||||
* @param packedLight Packed light value
|
||||
* @param packedOverlay Packed overlay value (for hit flash etc.)
|
||||
*/
|
||||
public static void render(
|
||||
ObjModel model,
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource buffer,
|
||||
int packedLight,
|
||||
int packedOverlay
|
||||
) {
|
||||
render(
|
||||
model,
|
||||
poseStack,
|
||||
buffer,
|
||||
packedLight,
|
||||
packedOverlay,
|
||||
1.0f,
|
||||
1.0f,
|
||||
1.0f,
|
||||
1.0f
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an OBJ model with textures from materials (map_Kd) or vertex colors as fallback.
|
||||
*
|
||||
* @param model The OBJ model to render
|
||||
* @param poseStack The current pose stack (with transformations applied)
|
||||
* @param buffer The multi-buffer source
|
||||
* @param packedLight Packed light value
|
||||
* @param packedOverlay Packed overlay value (for hit flash etc.)
|
||||
* @param tintR Red tint multiplier (0-1)
|
||||
* @param tintG Green tint multiplier (0-1)
|
||||
* @param tintB Blue tint multiplier (0-1)
|
||||
* @param alpha Alpha value (0-1)
|
||||
*/
|
||||
public static void render(
|
||||
ObjModel model,
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource buffer,
|
||||
int packedLight,
|
||||
int packedOverlay,
|
||||
float tintR,
|
||||
float tintG,
|
||||
float tintB,
|
||||
float alpha
|
||||
) {
|
||||
if (model == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Matrix4f pose = poseStack.last().pose();
|
||||
Matrix3f normal = poseStack.last().normal();
|
||||
|
||||
// Render faces grouped by material
|
||||
for (Map.Entry<String, List<ObjFace>> entry : model
|
||||
.getFacesByMaterial()
|
||||
.entrySet()) {
|
||||
ObjMaterial material = model.getMaterial(entry.getKey());
|
||||
|
||||
// Determine texture and color based on material
|
||||
ResourceLocation texture;
|
||||
float r, g, b;
|
||||
|
||||
if (material.hasTexture()) {
|
||||
// Use texture from map_Kd - resolve to full path
|
||||
texture = model.resolveTexture(material.texturePath());
|
||||
if (texture == null) {
|
||||
texture = WHITE_TEXTURE;
|
||||
}
|
||||
// White color to let texture show through, with tint applied
|
||||
r = tintR;
|
||||
g = tintG;
|
||||
b = tintB;
|
||||
} else {
|
||||
// Use vertex color from Kd
|
||||
texture = WHITE_TEXTURE;
|
||||
r = material.r() * tintR;
|
||||
g = material.g() * tintG;
|
||||
b = material.b() * tintB;
|
||||
}
|
||||
|
||||
// Get buffer for this material's texture (TRIANGLES mode)
|
||||
VertexConsumer vertexConsumer = buffer.getBuffer(
|
||||
createTriangleRenderType(texture)
|
||||
);
|
||||
|
||||
for (ObjFace face : entry.getValue()) {
|
||||
ObjVertex[] verts = face.vertices();
|
||||
|
||||
// Use vertex normals from the OBJ file for smooth shading
|
||||
for (ObjVertex vertex : verts) {
|
||||
vertexConsumer
|
||||
.vertex(pose, vertex.x(), vertex.y(), vertex.z())
|
||||
.color(r, g, b, alpha)
|
||||
.uv(vertex.u(), 1.0f - vertex.v())
|
||||
.overlayCoords(packedOverlay)
|
||||
.uv2(packedLight)
|
||||
.normal(normal, vertex.nx(), vertex.ny(), vertex.nz())
|
||||
.endVertex();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an OBJ model with selective material tinting.
|
||||
* Only materials in the tintMaterials set will be tinted.
|
||||
*
|
||||
* @param model The OBJ model to render
|
||||
* @param poseStack The current pose stack
|
||||
* @param buffer The multi-buffer source
|
||||
* @param packedLight Packed light value
|
||||
* @param packedOverlay Packed overlay value
|
||||
* @param tintR Red tint (0-1)
|
||||
* @param tintG Green tint (0-1)
|
||||
* @param tintB Blue tint (0-1)
|
||||
* @param tintMaterials Set of material names to apply tint to (e.g., "Ball")
|
||||
*/
|
||||
public static void renderWithSelectiveTint(
|
||||
ObjModel model,
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource buffer,
|
||||
int packedLight,
|
||||
int packedOverlay,
|
||||
float tintR,
|
||||
float tintG,
|
||||
float tintB,
|
||||
java.util.Set<String> tintMaterials
|
||||
) {
|
||||
if (model == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Matrix4f pose = poseStack.last().pose();
|
||||
Matrix3f normal = poseStack.last().normal();
|
||||
|
||||
for (Map.Entry<String, List<ObjFace>> entry : model
|
||||
.getFacesByMaterial()
|
||||
.entrySet()) {
|
||||
String materialName = entry.getKey();
|
||||
ObjMaterial material = model.getMaterial(materialName);
|
||||
|
||||
// Check if this material should be tinted
|
||||
boolean shouldTint = tintMaterials.contains(materialName);
|
||||
|
||||
ResourceLocation texture;
|
||||
float r, g, b;
|
||||
|
||||
if (material.hasTexture()) {
|
||||
texture = model.resolveTexture(material.texturePath());
|
||||
if (texture == null) {
|
||||
texture = WHITE_TEXTURE;
|
||||
}
|
||||
// Apply tint only to specified materials
|
||||
if (shouldTint) {
|
||||
r = tintR;
|
||||
g = tintG;
|
||||
b = tintB;
|
||||
} else {
|
||||
r = 1.0f;
|
||||
g = 1.0f;
|
||||
b = 1.0f;
|
||||
}
|
||||
} else {
|
||||
// Vertex color from Kd
|
||||
texture = WHITE_TEXTURE;
|
||||
if (shouldTint) {
|
||||
r = material.r() * tintR;
|
||||
g = material.g() * tintG;
|
||||
b = material.b() * tintB;
|
||||
} else {
|
||||
r = material.r();
|
||||
g = material.g();
|
||||
b = material.b();
|
||||
}
|
||||
}
|
||||
|
||||
VertexConsumer vertexConsumer = buffer.getBuffer(
|
||||
createTriangleRenderType(texture)
|
||||
);
|
||||
|
||||
for (ObjFace face : entry.getValue()) {
|
||||
for (ObjVertex vertex : face.vertices()) {
|
||||
vertexConsumer
|
||||
.vertex(pose, vertex.x(), vertex.y(), vertex.z())
|
||||
.color(r, g, b, 1.0f)
|
||||
.uv(vertex.u(), 1.0f - vertex.v())
|
||||
.overlayCoords(packedOverlay)
|
||||
.uv2(packedLight)
|
||||
.normal(normal, vertex.nx(), vertex.ny(), vertex.nz())
|
||||
.endVertex();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an OBJ model with color-suffixed textures.
|
||||
* When colorSuffix is provided, ALL textured materials use texture_COLOR.png.
|
||||
*
|
||||
* @param model The OBJ model to render
|
||||
* @param poseStack The current pose stack
|
||||
* @param buffer The multi-buffer source
|
||||
* @param packedLight Packed light value
|
||||
* @param packedOverlay Packed overlay value
|
||||
* @param colorSuffix The color suffix (e.g., "red", "blue"), or null for default texture
|
||||
*/
|
||||
public static void renderWithColoredTextures(
|
||||
ObjModel model,
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource buffer,
|
||||
int packedLight,
|
||||
int packedOverlay,
|
||||
String colorSuffix
|
||||
) {
|
||||
if (model == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Matrix4f pose = poseStack.last().pose();
|
||||
Matrix3f normal = poseStack.last().normal();
|
||||
|
||||
for (Map.Entry<String, List<ObjFace>> entry : model
|
||||
.getFacesByMaterial()
|
||||
.entrySet()) {
|
||||
ObjMaterial material = model.getMaterial(entry.getKey());
|
||||
|
||||
ResourceLocation texture;
|
||||
|
||||
if (material.hasTexture()) {
|
||||
// Apply color suffix to texture path if provided
|
||||
if (colorSuffix != null && !colorSuffix.isEmpty()) {
|
||||
texture = model.resolveTextureWithColorSuffix(
|
||||
material.texturePath(),
|
||||
colorSuffix
|
||||
);
|
||||
} else {
|
||||
texture = model.resolveTexture(material.texturePath());
|
||||
}
|
||||
if (texture == null) {
|
||||
texture = WHITE_TEXTURE;
|
||||
}
|
||||
} else {
|
||||
// No texture - use vertex color from Kd
|
||||
texture = WHITE_TEXTURE;
|
||||
}
|
||||
|
||||
VertexConsumer vertexConsumer = buffer.getBuffer(
|
||||
createTriangleRenderType(texture)
|
||||
);
|
||||
|
||||
// Determine vertex color - white for textured, Kd for untextured
|
||||
float r, g, b;
|
||||
if (material.hasTexture()) {
|
||||
r = 1.0f;
|
||||
g = 1.0f;
|
||||
b = 1.0f;
|
||||
} else {
|
||||
r = material.r();
|
||||
g = material.g();
|
||||
b = material.b();
|
||||
}
|
||||
|
||||
for (ObjFace face : entry.getValue()) {
|
||||
for (ObjVertex vertex : face.vertices()) {
|
||||
vertexConsumer
|
||||
.vertex(pose, vertex.x(), vertex.y(), vertex.z())
|
||||
.color(r, g, b, 1.0f)
|
||||
.uv(vertex.u(), 1.0f - vertex.v())
|
||||
.overlayCoords(packedOverlay)
|
||||
.uv2(packedLight)
|
||||
.normal(normal, vertex.nx(), vertex.ny(), vertex.nz())
|
||||
.endVertex();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an OBJ model with a specific texture instead of vertex colors.
|
||||
*
|
||||
* @param model The OBJ model to render
|
||||
* @param poseStack The current pose stack (with transformations applied)
|
||||
* @param buffer The multi-buffer source
|
||||
* @param packedLight Packed light value
|
||||
* @param packedOverlay Packed overlay value
|
||||
* @param texture The texture to apply
|
||||
*/
|
||||
public static void renderTextured(
|
||||
ObjModel model,
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource buffer,
|
||||
int packedLight,
|
||||
int packedOverlay,
|
||||
ResourceLocation texture
|
||||
) {
|
||||
if (model == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Matrix4f pose = poseStack.last().pose();
|
||||
Matrix3f normal = poseStack.last().normal();
|
||||
|
||||
// Use custom TRIANGLES-mode RenderType
|
||||
VertexConsumer vertexConsumer = buffer.getBuffer(
|
||||
createTriangleRenderType(texture)
|
||||
);
|
||||
|
||||
// Render all faces with white color (let texture provide color)
|
||||
for (Map.Entry<String, List<ObjFace>> entry : model
|
||||
.getFacesByMaterial()
|
||||
.entrySet()) {
|
||||
for (ObjFace face : entry.getValue()) {
|
||||
ObjVertex[] verts = face.vertices();
|
||||
|
||||
for (ObjVertex vertex : verts) {
|
||||
vertexConsumer
|
||||
.vertex(pose, vertex.x(), vertex.y(), vertex.z())
|
||||
.color(1.0f, 1.0f, 1.0f, 1.0f)
|
||||
.uv(vertex.u(), 1.0f - vertex.v())
|
||||
.overlayCoords(packedOverlay)
|
||||
.uv2(packedLight)
|
||||
.normal(normal, vertex.nx(), vertex.ny(), vertex.nz())
|
||||
.endVertex();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.tiedup.remake.client.renderer.obj;
|
||||
|
||||
/**
|
||||
* Immutable record representing a single vertex in an OBJ model.
|
||||
* Contains position (x, y, z), texture coordinates (u, v), and normal (nx, ny, nz).
|
||||
*/
|
||||
public record ObjVertex(
|
||||
float x,
|
||||
float y,
|
||||
float z,
|
||||
float u,
|
||||
float v,
|
||||
float nx,
|
||||
float ny,
|
||||
float nz
|
||||
) {
|
||||
/**
|
||||
* Create a vertex with only position data.
|
||||
* UV and normal will be set to defaults.
|
||||
*/
|
||||
public static ObjVertex ofPosition(float x, float y, float z) {
|
||||
return new ObjVertex(x, y, z, 0f, 0f, 0f, 1f, 0f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a vertex with position and UV.
|
||||
* Normal will be set to default (pointing up).
|
||||
*/
|
||||
public static ObjVertex ofPositionUV(
|
||||
float x,
|
||||
float y,
|
||||
float z,
|
||||
float u,
|
||||
float v
|
||||
) {
|
||||
return new ObjVertex(x, y, z, u, v, 0f, 1f, 0f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.tiedup.remake.client.state;
|
||||
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Client-side state for tracking active labor task progress.
|
||||
* Synchronized from server via PacketSyncLaborProgress.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class ClientLaborState {
|
||||
|
||||
private static boolean hasTask = false;
|
||||
private static String taskDescription = "";
|
||||
private static int progress = 0;
|
||||
private static int quota = 0;
|
||||
private static int valueEmeralds = 0;
|
||||
|
||||
/**
|
||||
* Set the current labor task.
|
||||
*
|
||||
* @param description Task description (e.g., "Kill 8 Spiders")
|
||||
* @param currentProgress Current progress count
|
||||
* @param targetQuota Target quota to complete
|
||||
* @param value Task value in emeralds
|
||||
*/
|
||||
public static void setTask(
|
||||
String description,
|
||||
int currentProgress,
|
||||
int targetQuota,
|
||||
int value
|
||||
) {
|
||||
hasTask = true;
|
||||
taskDescription = description;
|
||||
progress = currentProgress;
|
||||
quota = targetQuota;
|
||||
valueEmeralds = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current task (no active task).
|
||||
*/
|
||||
public static void clearTask() {
|
||||
hasTask = false;
|
||||
taskDescription = "";
|
||||
progress = 0;
|
||||
quota = 0;
|
||||
valueEmeralds = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is an active labor task.
|
||||
*/
|
||||
public static boolean hasActiveTask() {
|
||||
return hasTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the task description.
|
||||
*/
|
||||
public static String getTaskDescription() {
|
||||
return taskDescription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current progress.
|
||||
*/
|
||||
public static int getProgress() {
|
||||
return progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target quota.
|
||||
*/
|
||||
public static int getQuota() {
|
||||
return quota;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the task value in emeralds.
|
||||
*/
|
||||
public static int getValueEmeralds() {
|
||||
return valueEmeralds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the progress as a float (0.0 to 1.0).
|
||||
*/
|
||||
public static float getProgressFraction() {
|
||||
if (quota <= 0) return 0.0f;
|
||||
return Math.min(1.0f, (float) progress / quota);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a formatted progress string (e.g., "5/10").
|
||||
*/
|
||||
public static String getProgressString() {
|
||||
return progress + "/" + quota;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user