using System.Collections.Generic; using UnityEngine; namespace UAI { /// /// V4 vision and sensing utilities: line-of-sight, shoot checks, see-cache access, /// nearby-entity queries, and facing tests. /// /// Differences from : /// /// All magic numbers replaced with constants. /// no longer emits unconditional Debug.Log calls; /// the unreachable second code path is removed. /// and use /// to avoid per-call list allocation. /// /// /// public static class VisionUtils { private static readonly string AdvFeatureClass = "AdvancedTroubleshootingFeatures"; private static readonly string Feature = "UtilityAILoggingMin"; // ── CanSee ─────────────────────────────────────────────────────────────── public static bool CanSee(EntityAlive sourceEntity, EntityAlive targetEntity, float maxDistance = -1f) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, $"CanSee: {sourceEntity.EntityName} Looking at {targetEntity.EntityName} ( {targetEntity.entityId} )"); if (targetEntity.IsDead()) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tTarget is Dead. False."); return false; } var distanceSq = sourceEntity.GetDistanceSq(targetEntity); // Skip view-cone for very close targets; apply it once they're further away. if (distanceSq > AIConstants.ViewConeSkipDistSq) { if (!sourceEntity.IsInViewCone(targetEntity.position)) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tTarget is far away, but not in my view cone. False."); return false; } } // Positive see-cache hit → fast path. if (TryGetSeeCache(sourceEntity, targetEntity, out var isSeen) && isSeen) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tTarget is in the Source's CanSee cache. True"); return true; } // Attack/revenge target that is very close counts as visible. var knownTarget = EntityUtilities.GetAttackOrRevengeTarget(sourceEntity.entityId); if (knownTarget != null && targetEntity.entityId == knownTarget.entityId) { if (distanceSq < AIConstants.CloseDistanceSq) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tTarget is the Source's attack/revenge target and is close. True"); return true; } } // Distance vs. see-distance check. float seeDistance = maxDistance > -1f ? maxDistance : sourceEntity.GetSeeDistance(); var headPos = sourceEntity.getHeadPosition(); var headPos2 = targetEntity.getHeadPosition(); var direction = headPos2 - headPos; if (direction.magnitude > seeDistance) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tTarget is out of range of see distance. False"); return false; } // Raycast. var ray = new Ray(headPos, direction); ray.origin += direction.normalized * AIConstants.RaycastOriginNudge; var hitMask = GetHitMaskByWeaponBuff(sourceEntity); if (!Voxel.Raycast(sourceEntity.world, ray, seeDistance, hitMask, 0f)) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tSource's raycast failed to find anything. False"); return false; } var hitRoot = GameUtils.GetHitRootTransform( Voxel.voxelRayHitInfo.tag, Voxel.voxelRayHitInfo.transform); if (hitRoot == null) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tSource sees something, but it's not an entity. False"); return false; } var component = hitRoot.GetComponent(); if (component == null || !component.IsAlive() || targetEntity != component) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tSource sees something but it's not the right alive entity. False"); return false; } // Secondary see-cache check (belt-and-suspenders). if (TryGetSeeCache(sourceEntity, targetEntity, out isSeen) && isSeen) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tSource has target in its CanSee cache (double-check). True"); return true; } // Crouching leader + sleeping target → don't break stealth. var leader = EntityUtilities.GetLeaderOrOwner(sourceEntity.entityId) as EntityAlive; if (leader != null && leader.IsCrouching && component.IsSleeping) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tLeader is crouching and target is sleeping. False"); return false; } // Secondary view-cone re-check for mid-range targets. if (sourceEntity.GetDistanceSq(targetEntity) > AIConstants.ViewConeReCheckDistSq) { if (!sourceEntity.IsInViewCone(targetEntity.position)) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tTarget is mid-range but not in the view cone. False"); return false; } } // Player stealth check. var targetAsPlayer = knownTarget as EntityPlayer; if (targetAsPlayer != null) { var dist = sourceEntity.GetDistance(targetAsPlayer); if (!sourceEntity.CanSeeStealth(dist, targetAsPlayer.Stealth.lightLevel)) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tPlayer passes stealth check; source cannot see them. False"); return false; } } // Don't pull a Leroy Jenkins on the leader's stealth run. if (leader is EntityPlayer playerLeader && !targetEntity.CanSeeStealth( targetEntity.GetDistance(playerLeader), playerLeader.Stealth.lightLevel)) { AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tTarget cannot see the leader (stealth). False"); return false; } sourceEntity.SetCanSee(targetEntity); AdvLogging.DisplayLog(AdvFeatureClass, Feature, "\tSource passes all checks. Adding to see cache. True"); return true; } // ── CanShoot ───────────────────────────────────────────────────────────── public static bool CanShoot(EntityAlive sourceEntity, EntityAlive targetEntity, float maxDistance = -1f) { var headPos = sourceEntity.getHeadPosition(); var headPos2 = targetEntity.getHeadPosition(); var direction = headPos2 - headPos; float seeDistance = maxDistance > -1f ? maxDistance : sourceEntity.GetSeeDistance(); if (direction.magnitude > seeDistance) return false; var ray = new Ray(headPos, direction); ray.origin += direction.normalized * AIConstants.RaycastOriginNudge; if (!Voxel.Raycast(sourceEntity.world, ray, seeDistance, true, true)) return false; return !GameUtils.IsBlockOrTerrain(Voxel.voxelRayHitInfo.tag); } // ── See Cache ───────────────────────────────────────────────────────────── /// /// Checks the source entity's positive and negative see-caches for the target. /// Returns true and sets when the target is found in /// either cache; returns false when neither entity is valid or the target is absent /// from both caches. /// public static bool TryGetSeeCache(EntityAlive sourceEntity, EntityAlive targetEntity, out bool isSeen) { isSeen = false; if (targetEntity == null) return false; if (sourceEntity?.seeCache?.positiveCache?.Contains(targetEntity.entityId) == true) { isSeen = true; return true; } return sourceEntity?.seeCache?.negativeCache?.Contains(targetEntity.entityId) == true; } // ── Nearby Entity Queries ───────────────────────────────────────────────── /// /// Returns true if any live, non-self is within /// units of the context entity. /// Uses to avoid a per-call list allocation. /// public static bool IsAnyoneNearby(Context context, float distance = AIConstants.DefaultNearbySearchRadius) { var buffer = NPCFrameCache.EntityBuffer; buffer.Clear(); var bounds = new Bounds(context.Self.position, new Vector3(distance, distance, distance)); context.Self.world.GetEntitiesInBounds(typeof(EntityAlive), bounds, buffer); for (var i = buffer.Count - 1; i >= 0; i--) { var other = buffer[i] as EntityAlive; if (other == null || other == context.Self || other.IsDead()) continue; return true; } return false; } /// /// Returns true if any living enemy is within /// units of and can be seen. /// Uses to avoid a per-call list allocation. /// public static bool IsEnemyNearby(Context context, float distance = AIConstants.DefaultNearbySearchRadius) => IsEnemyNearby(context.Self, distance); public static bool IsEnemyNearby(EntityAlive self, float distance = AIConstants.DefaultNearbySearchRadius) { // A revenge target at any distance keeps the entity on alert. var revengeTarget = self.GetRevengeTarget(); if (revengeTarget && !EntityTargetingUtilities.ShouldForgiveDamage(self, revengeTarget)) return true; var buffer = NPCFrameCache.EntityBuffer; buffer.Clear(); var bounds = new Bounds(self.position, new Vector3(distance, distance, distance)); self.world.GetEntitiesInBounds(typeof(EntityAlive), bounds, buffer); for (var i = buffer.Count - 1; i >= 0; i--) { var other = buffer[i] as EntityAlive; if (other == null || other == self || other.IsDead()) continue; if (!EntityTargetingUtilities.IsEnemy(self, other)) continue; if (!CanSee(self, other, distance)) continue; return true; } return false; } // ── IsFacing ───────────────────────────────────────────────────────────── /// /// Returns true when the angle between 's look vector and /// the direction to is within . /// public static bool IsFacing(EntityAlive source, Vector3 targetPos) { var lookPos = source.GetLookVector(); var angle = Vector3.Angle(lookPos, targetPos - lookPos); return angle < AIConstants.FacingAngleThreshold; } // ── Hit Mask ───────────────────────────────────────────────────────────── public static int GetHitMaskByWeaponBuff(EntityAlive entity) { // Base value matches the bitmask used by the two-bool Voxel.Raycast overload. int baseMask = (int) HitMasks.CollideMovement | (int) HitMasks.Liquid; if (entity.Buffs.HasBuff("LBowUser") || entity.Buffs.HasBuff("XBowUser")) return baseMask | (int) HitMasks.CollideArrows; if (entity.Buffs.HasBuff("RocketLUser")) return baseMask | (int) HitMasks.CollideRockets; if (entity.HasAnyTags(FastTags.Parse("ranged"))) return baseMask | (int) HitMasks.CollideBullets; return baseMask | (int) HitMasks.CollideMelee; } // ── Private ─────────────────────────────────────────────────────────────── /// /// Raycast hit-mask values taken verbatim from Voxel.raycastNew. /// Each flag indicates whether a raycast should collide with that block type. /// private enum HitMasks : int { Transparent = 1, Liquid = 2, NotCollidable = 4, CollideBullets = 8, CollideRockets = 0x10, CollideArrows = 0x20, CollideMovement = 0x40, CollideMelee = 0x80, } } }