Volver al índice
src/main/java/ar/com/companeros/horror/ParasiteEntity.java
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<Integer> STAGE = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.INT);
private static final EntityDataAccessor<Optional<UUID>> HOST = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.OPTIONAL_UUID);
private static final EntityDataAccessor<String> HOST_NAME = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.STRING);
private static final EntityDataAccessor<Byte> FLAGS = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.BYTE);
private static final EntityDataAccessor<Long> ENCOUNTER_START = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.LONG);
private static final EntityDataAccessor<Long> ATTACK_START = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.LONG);
private static final EntityDataAccessor<Byte> POSTURE = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.BYTE);
private static final EntityDataAccessor<Float> COMPACT_PROGRESS = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.FLOAT);
private static final EntityDataAccessor<String> HOST_SKIN_SOURCE = SynchedEntityData.defineId(ParasiteEntity.class, EntityDataSerializers.STRING);
private static final EntityDataAccessor<Integer> 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<? extends Monster> 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<UUID> 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(""); }
}
}