package com.tiedup.remake.mixin; import com.tiedup.remake.entities.LeashProxyEntity; import com.tiedup.remake.network.ModNetwork; import com.tiedup.remake.network.sync.PacketSyncLeashProxy; import com.tiedup.remake.state.IPlayerLeashAccess; import net.minecraft.network.protocol.game.ClientboundSetEntityMotionPacket; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.decoration.LeashFenceKnotEntity; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; /** * Mixin for ServerPlayer to add leash proxy functionality. * * This replaces the old EntityInvisibleSlaveTransporter mount-based system * with a proxy-based system where: * - The player does NOT ride an entity * - A LeashProxyEntity follows the player and holds the leash * - Traction is applied via push() when the player is too far from the holder */ @Mixin(ServerPlayer.class) public abstract class MixinServerPlayer implements IPlayerLeashAccess { @Unique private final ServerPlayer tiedup$self = (ServerPlayer) (Object) this; /** The proxy entity that follows this player and renders the leash */ @Unique private LeashProxyEntity tiedup$leashProxy; /** The entity holding this player's leash (master or fence knot) */ @Unique private Entity tiedup$leashHolder; /** Tick counter since last leash attachment (prevents immediate detach) */ @Unique private int tiedup$leashAge; /** Previous X position for stuck detection */ @Unique private double tiedup$prevX; /** Previous Z position for stuck detection */ @Unique private double tiedup$prevZ; /** Ticks spent stuck (not moving towards holder) */ @Unique private int tiedup$leashStuckCounter; /** Extra slack on leash - increases pull/max distances (for "pet leads" dogwalk) */ @Unique private double tiedup$leashSlack = 0.0; /** Tick counter for periodic leash proxy resync (for late-joining clients) */ @Unique private int tiedup$leashResyncTimer = 0; // ==================== Leash Constants ==================== /** Distance at which pull force starts (4 free blocks before any pull) */ @Unique private static final double LEASH_PULL_START_DISTANCE = 4.0; /** Maximum distance before instant teleport (6-block elastic zone) */ @Unique private static final double LEASH_MAX_DISTANCE = 10.0; /** Distance at which stuck detection activates (middle of elastic zone) */ @Unique private static final double LEASH_TELEPORT_DISTANCE = 7.0; /** Ticks of being stuck before safety teleport (2 seconds) */ @Unique private static final int LEASH_STUCK_THRESHOLD = 40; /** Maximum pull force cap */ @Unique private static final double LEASH_MAX_FORCE = 0.14; /** Force ramp per block beyond pull start */ @Unique private static final double LEASH_FORCE_RAMP = 0.04; /** Blend factor for pull vs momentum (0.6 = 60% pull, 40% momentum) */ @Unique private static final double LEASH_BLEND_FACTOR = 0.6; // ==================== IPlayerLeashAccess Implementation ==================== @Override public void tiedup$attachLeash(Entity holder) { if (holder == null) return; tiedup$leashHolder = holder; // Create proxy if not exists if (tiedup$leashProxy == null) { tiedup$leashProxy = new LeashProxyEntity(tiedup$self); tiedup$leashProxy.setPos( tiedup$self.getX(), tiedup$self.getY(), tiedup$self.getZ() ); tiedup$self.level().addFreshEntity(tiedup$leashProxy); } // Attach leash from proxy to holder tiedup$leashProxy.setLeashedTo(tiedup$leashHolder, true); tiedup$leashAge = tiedup$self.tickCount; tiedup$leashStuckCounter = 0; tiedup$leashResyncTimer = 0; // Send sync packet to all tracking clients for smooth rendering PacketSyncLeashProxy packet = PacketSyncLeashProxy.attach( tiedup$self.getUUID(), tiedup$leashProxy.getId() ); ModNetwork.sendToAllTrackingAndSelf(packet, tiedup$self); } @Override public void tiedup$detachLeash() { tiedup$leashHolder = null; if (tiedup$leashProxy != null) { if ( tiedup$leashProxy.isAlive() && !tiedup$leashProxy.proxyIsRemoved() ) { tiedup$leashProxy.proxyRemove(); } tiedup$leashProxy = null; // Send detach packet to all tracking clients PacketSyncLeashProxy packet = PacketSyncLeashProxy.detach( tiedup$self.getUUID() ); ModNetwork.sendToAllTrackingAndSelf(packet, tiedup$self); } } @Override public void tiedup$dropLeash() { // Don't drop if player is disconnected or dead (position may be invalid) if (tiedup$self.hasDisconnected() || !tiedup$self.isAlive()) { return; } tiedup$self.drop(new ItemStack(Items.LEAD), false, true); } @Override public boolean tiedup$isLeashed() { return ( tiedup$leashHolder != null && tiedup$leashProxy != null && !tiedup$leashProxy.proxyIsRemoved() ); } @Override public Entity tiedup$getLeashHolder() { return tiedup$leashHolder; } @Override public LeashProxyEntity tiedup$getLeashProxy() { return tiedup$leashProxy; } @Override public void tiedup$setLeashSlack(double slack) { this.tiedup$leashSlack = slack; } @Override public double tiedup$getLeashSlack() { return this.tiedup$leashSlack; } // ==================== Tick Update (called from Forge event) ==================== /** * Update leash state and apply traction if needed. * Called from LeashTickHandler via Forge TickEvent. */ @Override public void tiedup$tickLeash() { // Check if this player is still valid if (!tiedup$self.isAlive() || tiedup$self.hasDisconnected()) { tiedup$detachLeash(); // Don't drop leash if we're disconnected (we won't be there to pick it up) return; } // Check if holder is still valid if (tiedup$leashHolder != null) { boolean holderInvalid = !tiedup$leashHolder.isAlive() || tiedup$leashHolder.isRemoved(); // If holder is a player, also check if they disconnected if ( !holderInvalid && tiedup$leashHolder instanceof ServerPlayer holderPlayer ) { holderInvalid = holderPlayer.hasDisconnected(); } // If player is being used as vehicle, break leash if (!holderInvalid && tiedup$self.isVehicle()) { holderInvalid = true; } if (holderInvalid) { tiedup$detachLeash(); tiedup$dropLeash(); return; } } // Sync proxy state with actual leash holder if (tiedup$leashProxy != null) { if (tiedup$leashProxy.proxyIsRemoved()) { tiedup$leashProxy = null; } else { Entity holderActual = tiedup$leashHolder; Entity holderFromProxy = tiedup$leashProxy.getLeashHolder(); // Leash was broken externally (by another player) if (holderFromProxy == null && holderActual != null) { tiedup$detachLeash(); tiedup$dropLeash(); return; } // Holder changed (shouldn't happen normally) else if (holderFromProxy != holderActual) { tiedup$leashHolder = holderFromProxy; } } } // Periodic resync for late-joining clients (every 5 seconds) if (tiedup$leashProxy != null && ++tiedup$leashResyncTimer >= 100) { tiedup$leashResyncTimer = 0; PacketSyncLeashProxy packet = PacketSyncLeashProxy.attach( tiedup$self.getUUID(), tiedup$leashProxy.getId() ); ModNetwork.sendToAllTrackingAndSelf(packet, tiedup$self); } // Apply traction force tiedup$applyLeashPull(); } /** * Apply pull force towards the leash holder if player is too far. * * Uses normalized direction, progressive capped force, velocity blending, * and conditional sync to prevent jitter, oscillation, and runaway velocity. * Modeled after DamselAIController.tickLeashTraction(). */ @Unique private void tiedup$applyLeashPull() { if (tiedup$leashHolder == null) return; // Cross-dimension: detach leash cleanly instead of silently ignoring if (tiedup$leashHolder.level() != tiedup$self.level()) { tiedup$detachLeash(); tiedup$dropLeash(); return; } float distance = tiedup$self.distanceTo(tiedup$leashHolder); // Apply slack to effective distances (for "pet leads" dogwalk) double effectivePullStart = LEASH_PULL_START_DISTANCE + tiedup$leashSlack; double effectiveMaxDistance = LEASH_MAX_DISTANCE + tiedup$leashSlack; double effectiveTeleportDist = LEASH_TELEPORT_DISTANCE + tiedup$leashSlack; // Close enough: no pull needed, reset stuck counter if (distance < effectivePullStart) { tiedup$leashStuckCounter = 0; return; } // Too far: teleport to holder instead of breaking if (distance > effectiveMaxDistance) { tiedup$teleportToSafePositionNearHolder(); tiedup$leashStuckCounter = 0; return; } // Direction to holder double dx = tiedup$leashHolder.getX() - tiedup$self.getX(); double dy = tiedup$leashHolder.getY() - tiedup$self.getY(); double dz = tiedup$leashHolder.getZ() - tiedup$self.getZ(); // Normalized horizontal direction (replaces Math.signum bang-bang) double horizontalDist = Math.sqrt(dx * dx + dz * dz); double dirX = horizontalDist > 0.01 ? dx / horizontalDist : 0.0; double dirZ = horizontalDist > 0.01 ? dz / horizontalDist : 0.0; // Calculate how much the player moved since last check double movedX = tiedup$self.getX() - tiedup$prevX; double movedZ = tiedup$self.getZ() - tiedup$prevZ; double movedHorizontal = Math.sqrt(movedX * movedX + movedZ * movedZ); // Store current position for next stuck check tiedup$prevX = tiedup$self.getX(); tiedup$prevZ = tiedup$self.getZ(); // Stuck detection - slack-aware threshold boolean isStuck = distance > effectiveTeleportDist && tiedup$self.getDeltaMovement().lengthSqr() < 0.001 && movedHorizontal < 0.05; if (isStuck) { tiedup$leashStuckCounter++; if (tiedup$leashStuckCounter >= LEASH_STUCK_THRESHOLD) { tiedup$teleportToSafePositionNearHolder(); tiedup$leashStuckCounter = 0; return; } } else { tiedup$leashStuckCounter = 0; } // Progressive capped force: 0.04 per block beyond pull start, max 0.14 double distanceBeyond = distance - effectivePullStart; double forceFactor = Math.min( LEASH_MAX_FORCE, distanceBeyond * LEASH_FORCE_RAMP ); // Fence knots are static, need 1.3x pull if (tiedup$leashHolder instanceof LeashFenceKnotEntity) { forceFactor *= 1.3; } // Velocity blending: 60% pull direction + 40% existing momentum net.minecraft.world.phys.Vec3 currentMotion = tiedup$self.getDeltaMovement(); double pullVelX = dirX * forceFactor * 3.0; double pullVelZ = dirZ * forceFactor * 3.0; double newVelX = currentMotion.x * (1.0 - LEASH_BLEND_FACTOR) + pullVelX * LEASH_BLEND_FACTOR; double newVelZ = currentMotion.z * (1.0 - LEASH_BLEND_FACTOR) + pullVelZ * LEASH_BLEND_FACTOR; // Soft auto-step (replaces 0.42 vanilla jump velocity) double newVelY = currentMotion.y; if ( tiedup$self.onGround() && movedHorizontal < 0.1 && distanceBeyond > 0.5 ) { if (dy > 0.3) { newVelY += 0.08; // Holder is above: gentle upward boost } else { newVelY += 0.05; // Normal step-up } } else if (dy > 0.5 && !tiedup$self.onGround()) { newVelY += 0.02; // Gentle aerial drift } tiedup$self.setDeltaMovement(newVelX, newVelY, newVelZ); // Conditional velocity sync: only send packet when force is meaningful if (forceFactor > 0.02 && tiedup$self.connection != null) { tiedup$self.connection.send( new ClientboundSetEntityMotionPacket(tiedup$self) ); // Only suppress impulse flag after we've actually synced to the client tiedup$self.hasImpulse = false; } } /** * Teleport player to a safe position near the leash holder. * Used when player is stuck and can't path to holder. */ @Unique private void tiedup$teleportToSafePositionNearHolder() { if (tiedup$leashHolder == null) return; // Target: 2 blocks away from holder in player's direction double dx = tiedup$self.getX() - tiedup$leashHolder.getX(); double dz = tiedup$self.getZ() - tiedup$leashHolder.getZ(); double dist = Math.sqrt(dx * dx + dz * dz); double offsetX = 0; double offsetZ = 0; if (dist > 0.1) { offsetX = (dx / dist) * 2.0; offsetZ = (dz / dist) * 2.0; } double targetX = tiedup$leashHolder.getX() + offsetX; double targetZ = tiedup$leashHolder.getZ() + offsetZ; // Find safe Y (ground level) double targetY = tiedup$findSafeY( targetX, tiedup$leashHolder.getY(), targetZ ); tiedup$self.teleportTo(targetX, targetY, targetZ); // Sync position to client (null check for fake players) if (tiedup$self.connection != null) { tiedup$self.connection.send( new ClientboundSetEntityMotionPacket(tiedup$self) ); } } /** * Find a safe Y coordinate for teleporting. * * @param x Target X coordinate * @param startY Starting Y to search from * @param z Target Z coordinate * @return Safe Y coordinate on solid ground */ @Unique private double tiedup$findSafeY(double x, double startY, double z) { net.minecraft.core.BlockPos.MutableBlockPos mutable = new net.minecraft.core.BlockPos.MutableBlockPos(); // Search down first (max 5 blocks) for (int y = 0; y > -5; y--) { mutable.set((int) x, (int) startY + y, (int) z); if ( tiedup$self .level() .getBlockState(mutable) .isSolidRender(tiedup$self.level(), mutable) && tiedup$self.level().getBlockState(mutable.above()).isAir() ) { return mutable.getY() + 1; } } // Search up (max 5 blocks) for (int y = 1; y < 5; y++) { mutable.set((int) x, (int) startY + y, (int) z); if ( tiedup$self .level() .getBlockState(mutable.below()) .isSolidRender(tiedup$self.level(), mutable.below()) && tiedup$self.level().getBlockState(mutable).isAir() ) { return mutable.getY(); } } // Fallback: use holder's Y return startY; } }