package ar.com.companeros.horror; import java.util.*; import ar.com.companeros.horror.client.ParasiteRig; import net.minecraft.core.BlockPos; import net.minecraft.nbt.CompoundTag; import net.minecraft.network.syncher.*; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.damagesource.DamageSource; import net.minecraft.world.entity.*; import net.minecraft.world.entity.ai.attributes.*; import net.minecraft.world.entity.ai.navigation.*; import net.minecraft.world.entity.ai.control.JumpControl; import net.minecraft.world.entity.monster.Monster; import net.minecraft.world.level.Level; import net.minecraft.world.level.ClipContext; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.HitResult; import net.minecraft.util.Mth; /** Cuerpo físico original. Luna puede elegir la intención; la locomoción se ejecuta localmente. */ public final class ParasiteEntity extends Monster { public enum Intent { STALK, RETREAT, AMBUSH, SCARE, CLIMB } // Se conservan los ordinales históricos 0/1; las preferencias nuevas no cambian saves antiguos. public enum BodyPosture { HUNCHED, CRAWL, AUTO, ALL_FOURS, WALL_BRACE } public static final int MAX_POSTURE_TICKS = 1200, DEFAULT_POSTURE_TICKS = 600; private static final EntityDataAccessor STAGE = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.INT); private static final EntityDataAccessor> HOST = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.OPTIONAL_UUID); private static final EntityDataAccessor HOST_NAME = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.STRING); private static final EntityDataAccessor FLAGS = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.BYTE); private static final EntityDataAccessor ENCOUNTER_START = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.LONG); private static final EntityDataAccessor ATTACK_START = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.LONG); private static final EntityDataAccessor POSTURE = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.BYTE); private static final EntityDataAccessor COMPACT_PROGRESS = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.FLOAT); private static final EntityDataAccessor HOST_SKIN_SOURCE = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.STRING); private static final EntityDataAccessor VISUAL_UPGRADES = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.INT); public static final int ENCOUNTER_DURATION_TICKS = 1220; public static final int ENCOUNTER_DURATION = ENCOUNTER_DURATION_TICKS; public static final int ENCOUNTER_INTRO = 140; public static final int ENCOUNTER_COOLDOWN = 6000; private Intent intent = Intent.STALK; private long intentUntil; private long nextEncounterAt; private long toolPauseUntil; private UUID encounterAudience; private int attackCooldown; private long nextStepSoundAt; private long successfulAttacks, deliveredScares; private float previousCompactProgress; private float clientCompactProgress; private int compactHoldTicks; private float heldCompactTarget; private long bodyPostureUntil; public ParasiteEntity(EntityType type, Level level) { super(type, level); setPersistenceRequired(); setCanPickUpLoot(false); xpReward = 0; jumpControl = new JumpControl(this) { @Override public void tick() { super.tick(); Vec3 next = getNavigation() instanceof ParasiteNavigation navigation ? navigation.nextWaypoint() : null; // Vanilla intenta saltar dentro de la celda de una escotilla abierta. Su placa lateral // no exige un salto; el cuerpo plegado conserva el contacto con el suelo plano. if (isCompact() && !isInWater() && !isInLava() && (next == null || next.y <= getY() + .15)) setJumping(false); } }; } public static AttributeSupplier.Builder attributes() { return Monster.createMonsterAttributes().add(Attributes.MAX_HEALTH, 40).add(Attributes.MOVEMENT_SPEED, .27) .add(Attributes.ATTACK_DAMAGE, 4).add(Attributes.FOLLOW_RANGE, 40).add(Attributes.KNOCKBACK_RESISTANCE, .35); } @Override protected void defineSynchedData() { super.defineSynchedData(); entityData.define(STAGE, 0); entityData.define(HOST, Optional.empty()); entityData.define(HOST_NAME, ""); entityData.define(FLAGS, (byte) 0); entityData.define(ENCOUNTER_START, -1L); entityData.define(ATTACK_START, -1L); entityData.define(POSTURE, (byte) 0); entityData.define(COMPACT_PROGRESS, 0F); entityData.define(HOST_SKIN_SOURCE, ""); entityData.define(VISUAL_UPGRADES, 0); } @Override protected PathNavigation createNavigation(Level level) { return new ParasiteNavigation(this, level); } @Override protected void registerGoals() { } public int getMutationStage() { return entityData.get(STAGE); } public Optional hostId() { return entityData.get(HOST); } public String getHostName() { return entityData.get(HOST_NAME); } public void setHost(UUID id, String name) { entityData.set(HOST, Optional.of(id)); entityData.set(HOST_NAME, name); } public String getHostSkinSource() { return entityData.get(HOST_SKIN_SOURCE); } public int getVisualUpgrades() { return entityData.get(VISUAL_UPGRADES); } public void setVisualUpgrades(int mask) { if (!level().isClientSide) entityData.set(VISUAL_UPGRADES, mask & 511); } public void setHostSkinSource(String source) { if (source == null || source.length() > 256 || (!source.isEmpty() && !source.matches("[A-Za-z0-9_]{1,16}|[a-fA-F0-9-]{32,36}|https://textures\\.minecraft\\.net/texture/[a-fA-F0-9]{64}"))) throw new IllegalArgumentException("Origen de skin inválido"); if (!level().isClientSide) entityData.set(HOST_SKIN_SOURCE, source); } public void setMutationStage(int stage) { if (stage < 0 || stage > 4) throw new IllegalArgumentException("Etapa inválida"); float nextHeight = Mth.lerp(entityData.get(COMPACT_PROGRESS), 1.8F + stage * .45F, ParasiteClearance.COMPACT_HEIGHT); if (stage != getMutationStage() && !level().isClientSide && !ParasiteClearance.fits(level(), this, position(), nextHeight)) { float standing = 1.8F + stage * .45F; float retainedHeight = Math.min(getBbHeight(), standing); float retainedProgress = Mth.clamp((standing - retainedHeight) / (standing - ParasiteClearance.COMPACT_HEIGHT), entityData.get(COMPACT_PROGRESS), 1); entityData.set(COMPACT_PROGRESS, ParasiteClearance.fits(level(), this, position(), retainedHeight) ? retainedProgress : 1F); } entityData.set(STAGE, stage); if (!level().isClientSide) { getAttribute(Attributes.ATTACK_DAMAGE).setBaseValue(4 + stage * 1.5); getAttribute(Attributes.MOVEMENT_SPEED).setBaseValue(.27 + stage * .015); } } public boolean isClimbing() { return (entityData.get(FLAGS) & 1) != 0; } public boolean isAmbushing() { return (entityData.get(FLAGS) & 2) != 0; } public boolean isPursuing() { return (entityData.get(FLAGS) & 4) != 0; } public long successfulAttackCount() { return successfulAttacks; } public long deliveredScareCount() { return deliveredScares; } public boolean isCrawling() { return entityData.get(POSTURE) == 1; } public BodyPosture getBodyPosture() { int ordinal = entityData.get(POSTURE); return ordinal >= 0 && ordinal < BodyPosture.values().length ? BodyPosture.values()[ordinal] : BodyPosture.AUTO; } public boolean isCompact() { return entityData.get(COMPACT_PROGRESS) > .001F; } public float getStandingHeight() { return 1.8F + getMutationStage() * .45F; } public float getCompactProgress(float partialTick) { return Mth.lerp(Mth.clamp(partialTick, 0, 1), previousCompactProgress, level().isClientSide ? clientCompactProgress : entityData.get(COMPACT_PROGRESS)); } @Override public EntityDimensions getDimensions(Pose pose) { return EntityDimensions.scalable(ParasiteClearance.WIDTH, Mth.lerp(entityData.get(COMPACT_PROGRESS), getStandingHeight(), ParasiteClearance.COMPACT_HEIGHT)); } @Override protected float getStandingEyeHeight(Pose pose, EntityDimensions dimensions) { return dimensions.height * .85F; } @Override public void refreshDimensions() { // La expansión ya se valida aquí antes de aplicarse. Se conservan los pies en su lugar: // refreshDimensions vanilla busca otra posición al crecer y produciría saltos del cuerpo. fixupDimensions(); reapplyPosition(); } @Override public void onSyncedDataUpdated(EntityDataAccessor data) { super.onSyncedDataUpdated(data); if (COMPACT_PROGRESS.equals(data) && level().isClientSide && tickCount == 0) previousCompactProgress = clientCompactProgress = entityData.get(COMPACT_PROGRESS); if (STAGE.equals(data) || COMPACT_PROGRESS.equals(data)) refreshDimensions(); } @Override public void tick() { previousCompactProgress = level().isClientSide ? clientCompactProgress : entityData.get(COMPACT_PROGRESS); if (level().isClientSide) clientCompactProgress = Mth.approach(clientCompactProgress, entityData.get(COMPACT_PROGRESS), 1F / ParasiteClearance.TRANSITION_TICKS); else updateBodyClearance(); super.tick(); } // Se pliega antes del túnel y sólo se incorpora si el volumen ampliado está libre. private void updateBodyClearance() { // Una preferencia de Luna caduca y pierde el apoyo si el bloque desapareció. // La física puede exigir un plegado mayor que el preferido, nunca uno menor. if ((bodyPostureUntil > 0 && level().getGameTime() >= bodyPostureUntil) || (getBodyPosture() == BodyPosture.WALL_BRACE && !bodyPoseEnvironment().wallAvailable())) { setBodyPosture(BodyPosture.AUTO); } Vec3 waypoint = getNavigation() instanceof ParasiteNavigation navigation ? navigation.nextWaypoint() : null; Vec3 ahead = clearanceLookAhead(waypoint); float preferred = switch (getBodyPosture()) { case CRAWL -> 1; case ALL_FOURS -> .63636F; case WALL_BRACE -> .27272F; default -> 0; }; float desired = Math.max(preferred, requiredCompactProgress(ahead)); // La postura cambia también el volumen físico. No hay una animación baja escondida // dentro de un collider de pie; crecer sigue requiriendo espacio libre en el mundo. if (!isCrawling() && !isClimbing() && getMutationStage() >= 2 && getTarget() instanceof ServerPlayer human && level() instanceof ServerLevel serverLevel && HorrorModule.mayTarget(serverLevel.getServer(), human)) { int encounter = getEncounterTicks(); float presentation = encounter >= 0 ? ParasiteRig.encounterCompact(encounter, getMutationStage()) : bodyPostureUntil > 0 ? 0 : isPursuing() && intent == Intent.AMBUSH ? ParasiteRig.chaseCompact(tickCount, getMutationStage()) : intent == Intent.STALK || intent == Intent.SCARE ? ParasiteRig.stalkCompact(tickCount, getMutationStage()) : 0; desired = Math.max(desired, presentation); } if (desired > 0) { compactHoldTicks = 8; heldCompactTarget = desired; } else if (compactHoldTicks > 0) { compactHoldTicks--; desired = heldCompactTarget; } float old = entityData.get(COMPACT_PROGRESS); float next = Mth.approach(old, desired, 1F / ParasiteClearance.TRANSITION_TICKS); float nextHeight = Mth.lerp(next, getStandingHeight(), ParasiteClearance.COMPACT_HEIGHT); if (next >= old || ParasiteClearance.fits(level(), this, position(), nextHeight)) entityData.set(COMPACT_PROGRESS, next); } private float requiredCompactProgress(Vec3 ahead) { for (float height : new float[]{getStandingHeight(), Math.min(getStandingHeight(), 2.85F), Math.min(getStandingHeight(), 1.85F), ParasiteClearance.COMPACT_HEIGHT}) { if (ParasiteClearance.fits(level(), this, position(), height) && (ahead == null || ParasiteClearance.clearSegment(level(), this, position(), ahead, height))) return Mth.clamp((getStandingHeight() - height) / (getStandingHeight() - ParasiteClearance.COMPACT_HEIGHT), 0, 1); } return 1; } private Vec3 clearanceLookAhead(Vec3 waypoint) { if (waypoint == null) return null; Vec3 offset = new Vec3(waypoint.x - getX(), 0, waypoint.z - getZ()); if (offset.lengthSqr() < .0025) return null; Vec3 horizontal = position().add(offset.normalize().scale(Math.min(1.15, offset.length()))); return new Vec3(horizontal.x, Math.max(getY(), waypoint.y), horizontal.z); } boolean needsClearancePause(Vec3 waypoint) { Vec3 ahead = clearanceLookAhead(waypoint); return ahead != null && getBbHeight() > ParasiteClearance.COMPACT_HEIGHT + .001F && !ParasiteClearance.clearSegment(level(), this, position(), ahead, getBbHeight()) && ParasiteClearance.clearSegment(level(), this, position(), ahead, ParasiteClearance.COMPACT_HEIGHT); } public void setBodyPosture(BodyPosture posture) { if (posture == null) throw new IllegalArgumentException("Postura inválida"); if (!level().isClientSide) { entityData.set(POSTURE, (byte) posture.ordinal()); bodyPostureUntil = 0; } } // REPERTORIO: la IA pide una postura, los apoyos y el espacio se comprueban localmente. public static BodyPosture parseBodyPosture(String mode) { if (mode == null) throw new IllegalArgumentException("Postura inválida"); return switch (mode) { case "auto" -> BodyPosture.AUTO; case "hunched" -> BodyPosture.HUNCHED; case "crawl" -> BodyPosture.CRAWL; case "all_fours" -> BodyPosture.ALL_FOURS; case "wall_brace" -> BodyPosture.WALL_BRACE; default -> throw new IllegalArgumentException("Postura fuera del repertorio"); }; } public boolean requestBodyPosture(String mode, int durationTicks) { if (durationTicks < 20 || durationTicks > MAX_POSTURE_TICKS) throw new IllegalArgumentException("Duración de postura fuera de límite"); if (level().isClientSide) return false; BodyPosture posture = parseBodyPosture(mode); BodyPoseEnvironment environment = bodyPoseEnvironment(); if ((posture == BodyPosture.WALL_BRACE && (getMutationStage() < 2 || !environment.grounded() || !environment.wallAvailable())) || (posture == BodyPosture.ALL_FOURS && (getMutationStage() < 2 || !environment.grounded())) || (posture == BodyPosture.CRAWL && !environment.grounded())) return false; setBodyPosture(posture); if (posture != BodyPosture.AUTO) bodyPostureUntil = level().getGameTime() + durationTicks; return true; } public record BodyPoseEnvironment(boolean grounded, float leftWall, float rightWall, boolean standingClear) { public boolean wallAvailable() { return leftWall > 0 || rightWall > 0; } public String compact() { return "ground=" + grounded + " left_wall=" + distance(leftWall) + " right_wall=" + distance(rightWall) + " standing_clear=" + standingClear; } private static String distance(float value) { return value > 0 ? String.format(Locale.ROOT, "%.2f", value) : "none"; } } public BodyPoseEnvironment bodyPoseEnvironment() { Vec3 floorStart = position().add(0, .08, 0), floorEnd = position().add(0, -.18, 0); BlockHitResult floor = supportRay(floorStart, floorEnd); boolean grounded = floor != null && floor.getDirection() == net.minecraft.core.Direction.UP; float yaw = yBodyRot * Mth.DEG_TO_RAD; Vec3 origin = position().add(0, 1.65, 0); Vec3 right = new Vec3(Mth.cos(yaw), 0, Mth.sin(yaw)); return new BodyPoseEnvironment(grounded, wallSupportDistance(origin, right), wallSupportDistance(origin, right.scale(-1)), ParasiteClearance.fits(level(), this, position(), getStandingHeight())); } private float wallSupportDistance(Vec3 start, Vec3 direction) { BlockHitResult hit = supportRay(start, start.add(direction.scale(.9))); if (hit == null || hit.getDirection().getAxis() == net.minecraft.core.Direction.Axis.Y) return 0; float distance = (float) hit.getLocation().distanceTo(start); return distance >= .3F && distance <= .9F ? distance : 0; } private BlockHitResult supportRay(Vec3 start, Vec3 end) { if (!level().hasChunkAt(BlockPos.containing(start)) || !level().hasChunkAt(BlockPos.containing(end))) return null; BlockHitResult hit = level().clip(new ClipContext(start, end, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this)); return hit.getType() == HitResult.Type.BLOCK ? hit : null; } public void pauseForTool(int ticks) { if (ticks < 0 || ticks > 1200) throw new IllegalArgumentException("Pausa fuera de límite"); if (!level().isClientSide) { toolPauseUntil = level().getGameTime() + ticks; getNavigation().stop(); setDeltaMovement(0, getDeltaMovement().y, 0); } } public int getEncounterTicks() { long started = entityData.get(ENCOUNTER_START), elapsed = level().getGameTime() - started; return started < 0 || elapsed < 0 || elapsed >= ENCOUNTER_DURATION ? -1 : (int) elapsed; } public int getAttackAnimationTicks() { long started = entityData.get(ATTACK_START), elapsed = level().getGameTime() - started; return started < 0 || elapsed < 0 || elapsed >= 10 ? 0 : 10 - (int) elapsed; } public Intent intent() { return intent; } public void setIntent(Intent intent, int durationTicks) { if (intent == null || durationTicks < 20 || durationTicks > 12_000) throw new IllegalArgumentException("Intención inválida"); this.intent = intent; intentUntil = level().getGameTime() + durationTicks; } @Override public boolean onClimbable() { return isClimbing(); } @Override public AABB getBoundingBoxForCulling() { return getBoundingBox().inflate(2, 3, 2); } // El encuentro requiere una mirada real. Nunca gira la cámara ni modifica controles humanos. public boolean tryBeginGazeEncounter(ServerPlayer player) { if (!canBeginEncounter(player)) return false; double distance = distanceToSqr(player); if (distance < 3 * 3 || distance > 24 * 24 || !hasLineOfSight(player)) return false; Vec3 toCreature = getEyePosition().subtract(player.getEyePosition()).normalize(); return player.getLookAngle().dot(toCreature) >= Math.cos(Math.toRadians(15)) && beginEncounter(player); } private boolean canBeginEncounter(ServerPlayer player) { return level() instanceof ServerLevel level && player.level() == level && getMutationStage() >= 1 && HorrorModule.mayTarget(level.getServer(), player) && level.getGameTime() >= nextEncounterAt && HorrorPacing.get(level.getServer()).available(player.getUUID(), level.getGameTime()) && HorrorPacing.get(level.getServer()).soundReady(player.getUUID(), level.getGameTime()) && getEncounterTicks() < 0; } public boolean beginEncounter(ServerPlayer player) { if (!canBeginEncounter(player) || distanceToSqr(player) > 32 * 32 || !hasLineOfSight(player)) return false; long time = level().getGameTime(); entityData.set(ENCOUNTER_START, time); nextEncounterAt = time + ENCOUNTER_COOLDOWN; encounterAudience = player.getUUID(); setTarget(player); getNavigation().stop(); setIntent(Intent.STALK, ENCOUNTER_DURATION); HorrorModule.playForEligibleHumans(this, HorrorModule.PARASITE_LORE.get(), 1, 1); return true; } public void excludeSensitivePlayer(ServerPlayer player) { if (player.getUUID().equals(encounterAudience)) endEncounter(false); if (getTarget() == player) { setTarget(null); getNavigation().stop(); } } private void endEncounter(boolean pursue) { entityData.set(ENCOUNTER_START, -1L); encounterAudience = null; if (pursue && getMutationStage() >= 2) setIntent(Intent.AMBUSH, 1200); } private boolean advanceEncounter(ServerLevel level) { if (entityData.get(ENCOUNTER_START) < 0) return false; ServerPlayer audience = encounterAudience == null ? null : level.getServer().getPlayerList().getPlayer(encounterAudience); if (audience == null || audience.level() != level || !HorrorModule.mayTarget(level.getServer(), audience)) { endEncounter(false); getNavigation().stop(); return false; } long elapsed = level.getGameTime() - entityData.get(ENCOUNTER_START); if (elapsed >= ENCOUNTER_DURATION) { endEncounter(true); return false; } setTarget(audience); getLookControl().setLookAt(audience, 30, 30); if (elapsed < ENCOUNTER_INTRO) { getNavigation().stop(); setDeltaMovement(0, getDeltaMovement().y, 0); return true; } return false; } // Selección humana y sensibilidad se revisan antes de navegar, mirar, intimidar o causar daño. @Override public void aiStep() { super.aiStep(); if (!(level() instanceof ServerLevel level) || !isAlive()) return; HorrorState state = HorrorState.get(level.getServer()); if (tickCount % 20 == 0) ParasiteUpgrades.get(level.getServer()).applyAttributes(this); if (hostId().isEmpty() || !state.host().filter(hostId().get()::equals).isPresent() || !state.entityId().filter(getUUID()::equals).isPresent()) { discard(); return; } if (attackCooldown > 0) attackCooldown--; HorrorPacing pacing = HorrorPacing.get(level.getServer()); if (tickCount % 20 == 0) { for (ServerPlayer human : level.players()) if (HorrorModule.mayTarget(level.getServer(), human)) pacing.observe(human, level.getGameTime()); if (getTarget() instanceof ServerPlayer human && (!human.isAlive() || pacing.exhausted(human.getUUID(), level.getGameTime()))) releaseAudience(); } if (level.getGameTime() >= intentUntil) intent = getTarget() instanceof ServerPlayer human ? pacing.baseline(human.getUUID()) : Intent.STALK; if (getTarget() != null && !HorrorModule.mayTarget(level.getServer(), getTarget())) { setTarget(null); getNavigation().stop(); } if (getEncounterTicks() < 0 && level.getGameTime() >= nextEncounterAt && tickCount % 5 == 0) { level.players().stream().filter(player -> HorrorModule.mayTarget(level.getServer(), player)) .sorted(Comparator.comparingDouble(this::distanceToSqr)).anyMatch(this::tryBeginGazeEncounter); } boolean encounterIntro = advanceEncounter(level) || level.getGameTime() < toolPauseUntil; if (encounterIntro) { getNavigation().stop(); setDeltaMovement(0, getDeltaMovement().y, 0); } if (tickCount % 20 == 0 && getTarget() == null && pacing.intensity() > 0) { level.players().stream().filter(player -> HorrorModule.mayTarget(level.getServer(), player)) .filter(player -> pacing.available(player.getUUID(), level.getGameTime())) .filter(player -> distanceToSqr(player) < Math.pow(getAttributeValue(Attributes.FOLLOW_RANGE), 2)) .max(Comparator.comparingDouble(player -> pacing.priority(player.getUUID(), level.getGameTime()) - distanceTo(player) * .2)).ifPresent(this::setTarget); } LivingEntity target = getTarget(); boolean climbing = !encounterIntro && target != null && getMutationStage() >= 2 && horizontalCollision && (intent == Intent.CLIMB || target.getY() > getY() + 1); if (climbing) setDeltaMovement(getDeltaMovement().x, Math.max(.18, getDeltaMovement().y), getDeltaMovement().z); if (tickCount % 20 == 0) state.location(level.dimension().location().toString(), blockPosition()); if (target == null) { entityData.set(FLAGS, (byte) 0); return; } if (!encounterIntro && tickCount % 10 == 0) executeLocomotion(target); boolean pursuing = !encounterIntro && !climbing && intent != Intent.RETREAT && getMutationStage() >= 2 && !getNavigation().isDone() && HorrorModule.mayTarget(level.getServer(), target); entityData.set(FLAGS, (byte) ((climbing ? 1 : 0) | (intent == Intent.AMBUSH ? 2 : 0) | (pursuing ? 4 : 0))); if (intent == Intent.AMBUSH && target instanceof ServerPlayer human && pacing.mayAttack(human.getUUID(), level.getGameTime(), getMutationStage())) pacing.hunting(human.getUUID(), level.getGameTime()); if (!encounterIntro && intent == Intent.AMBUSH && getMutationStage() >= 2 && distanceToSqr(target) < 2.8 * 2.8 && hasLineOfSight(target) && attackCooldown == 0) { doHurtTarget(target); attackCooldown = 30; } } private void executeLocomotion(LivingEntity target) { if (!HorrorModule.mayTarget(((ServerLevel) level()).getServer(), target)) { setTarget(null); getNavigation().stop(); return; } getLookControl().setLookAt(target, 25, 25); Vec3 away = position().subtract(target.position()); if (intent == Intent.RETREAT || (getMutationStage() < 2 && distanceToSqr(target) < 7 * 7)) { Vec3 direction = away.lengthSqr() < .01 ? new Vec3(1, 0, 1) : away.normalize(); Vec3 destination = position().add(direction.scale(9)); getNavigation().moveTo(destination.x, destination.y, destination.z, 1.1); } else if (intent == Intent.AMBUSH || intent == Intent.CLIMB || distanceToSqr(target) > 16 * 16) { boolean hunting = target instanceof ServerPlayer human && HorrorPacing.get(((ServerLevel)level()).getServer()) .mayAttack(human.getUUID(), level().getGameTime(), getMutationStage()); getNavigation().moveTo(target, intent == Intent.AMBUSH && hunting ? 1.45 : .55); } else if (distanceToSqr(target) < 9 * 9 && intent != Intent.AMBUSH) { Vec3 direction = away.lengthSqr() < .01 ? new Vec3(1, 0, 1) : away.normalize(); Vec3 destination = position().add(direction.scale(5)); getNavigation().moveTo(destination.x, destination.y, destination.z, .7); } else getNavigation().stop(); } @Override public void setTarget(LivingEntity target) { if (target != null && level() instanceof ServerLevel level && !HorrorModule.mayTarget(level.getServer(), target)) target = null; if (target instanceof ServerPlayer human && level() instanceof ServerLevel level && !HorrorPacing.get(level.getServer()).available(human.getUUID(), level.getGameTime())) target = null; if (getTarget() instanceof ServerPlayer previous && previous != target && level() instanceof ServerLevel level) HorrorPacing.get(level.getServer()).release(previous.getUUID(), level.getGameTime()); super.setTarget(target); if (target instanceof ServerPlayer human && level() instanceof ServerLevel level) HorrorPacing.get(level.getServer()).begin(human.getUUID(), level.getGameTime()); if (target == null) entityData.set(FLAGS, (byte) (entityData.get(FLAGS) & ~4)); } @Override public boolean doHurtTarget(Entity target) { if (!(level() instanceof ServerLevel level) || !HorrorModule.mayTarget(level.getServer(), target) || (getEncounterTicks() >= 0 && getEncounterTicks() < ENCOUNTER_INTRO)) return false; if (level.getGameTime() < toolPauseUntil) return false; if (!(target instanceof ServerPlayer human) || !HorrorPacing.get(level.getServer()) .mayAttack(human.getUUID(), level.getGameTime(), getMutationStage())) return false; boolean damaged = super.doHurtTarget(target); if (damaged) { entityData.set(ATTACK_START, level.getGameTime()); successfulAttacks++; HorrorPacing.get(level.getServer()).hit(human.getUUID()); if (!human.isAlive()) { HorrorPacing.get(level.getServer()).killed(human.getUUID(), level.getGameTime()); releaseAudience(); } else if (HorrorPacing.get(level.getServer()).exhausted(human.getUUID(), level.getGameTime())) releaseAudience(); } return damaged; } @Override public boolean isPushable() { return false; } @Override public void push(Entity entity) { if (!(level() instanceof ServerLevel level) || !HorrorModule.mayTarget(level.getServer(), entity)) return; super.push(entity); } @Override protected void playStepSound(BlockPos pos, BlockState state) { if (level().isClientSide || state.isAir() || isClimbing() || level().getGameTime() < nextStepSoundAt) return; var material = state.getSoundType(level(), pos, this); float weight = isPursuing() ? .24F : .11F; HorrorModule.playPhysicalCue(this, material.getStepSound(), material.getVolume() * weight, material.getPitch() * .86F); nextStepSoundAt = level().getGameTime() + (isPursuing() ? 6 : 11); } @Override public void die(DamageSource source) { if (level() instanceof ServerLevel level) { HorrorState.get(level.getServer()).died(); ParasiteUpgrades.get(level.getServer()).combatResult(true); } super.die(source); } @Override public boolean hurt(DamageSource source, float amount) { boolean changed = super.hurt(source, amount); if (changed && amount > 0 && level() instanceof ServerLevel level) ParasiteUpgrades.get(level.getServer()).damageObserved(source.getMsgId()); if (changed && source.getEntity() instanceof ServerPlayer human && level() instanceof ServerLevel level && HorrorModule.mayTarget(level.getServer(), human)) HorrorPacing.get(level.getServer()).provoked(human.getUUID(), level.getGameTime()); return changed; } public void releaseAudience() { Vec3 destination = getTarget() == null ? null : position().add(position().subtract(getTarget().position()).normalize().scale(9)); endEncounter(false); setTarget(null); getNavigation().stop(); setIntent(Intent.RETREAT, 200); if (destination != null) getNavigation().moveTo(destination.x, destination.y, destination.z, .8); } @Override public boolean removeWhenFarAway(double distance) { return false; } @Override protected boolean shouldDespawnInPeaceful() { return false; } @Override public void addAdditionalSaveData(CompoundTag tag) { super.addAdditionalSaveData(tag); tag.putInt("MutationStage", getMutationStage()); hostId().ifPresent(id -> tag.putUUID("Host", id)); tag.putString("HostName", getHostName()); tag.putString("Intent", intent.name()); tag.putLong("IntentUntil", intentUntil); tag.putLong("NextEncounterAt", nextEncounterAt); tag.putLong("EncounterStart", entityData.get(ENCOUNTER_START)); tag.putString("BodyPosture", getBodyPosture().name()); tag.putLong("BodyPostureUntil", bodyPostureUntil); tag.putFloat("CompactProgress", entityData.get(COMPACT_PROGRESS)); tag.putString("HostSkinSource", getHostSkinSource()); if (encounterAudience != null) tag.putUUID("EncounterAudience", encounterAudience); } @Override public void readAdditionalSaveData(CompoundTag tag) { super.readAdditionalSaveData(tag); setMutationStage(Math.max(0, Math.min(4, tag.getInt("MutationStage")))); if (tag.hasUUID("Host")) setHost(tag.getUUID("Host"), tag.getString("HostName")); try { intent = Intent.valueOf(tag.getString("Intent")); } catch (IllegalArgumentException ignored) { intent = Intent.STALK; } intentUntil = tag.getLong("IntentUntil"); nextEncounterAt = Math.max(0, tag.getLong("NextEncounterAt")); entityData.set(ENCOUNTER_START, tag.contains("EncounterStart", net.minecraft.nbt.Tag.TAG_LONG) ? tag.getLong("EncounterStart") : -1L); encounterAudience = tag.hasUUID("EncounterAudience") ? tag.getUUID("EncounterAudience") : null; try { setBodyPosture(BodyPosture.valueOf(tag.getString("BodyPosture"))); } catch (IllegalArgumentException ignored) { setBodyPosture(BodyPosture.AUTO); } bodyPostureUntil = Math.max(0, Math.min(tag.getLong("BodyPostureUntil"), level().getGameTime() + MAX_POSTURE_TICKS)); float progress = tag.getFloat("CompactProgress"); entityData.set(COMPACT_PROGRESS, Float.isFinite(progress) ? Mth.clamp(progress, 0, 1) : 1F); try { setHostSkinSource(tag.getString("HostSkinSource")); } catch (IllegalArgumentException ignored) { setHostSkinSource(""); } } }