Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- local ZombieAI = {}
- local ServerStorage = game:GetService("ServerStorage")
- local PathfindingService = game:GetService("PathfindingService")
- local Workspace = game:GetService("Workspace")
- local Terrain = Workspace.Terrain
- local NPCSound = require(game.ServerScriptService["Kaizer's Modules"].NPCSound)
- local CONFIG = {
- DetectionRange = 30,
- ChaseAbandonRange = 50,
- PatrolSpeed = 7,
- ChaseSpeed = 17,
- AttackDamage = 15,
- HealthRange = {50, 100},
- PatrolRadius = 25,
- ReturnDistanceThreshold = 5,
- ExplosionRadius = 10,
- ExplosionDamagePercent = 0.5,
- PatrolTimeout = 5,
- JumpHeight = 12,
- JumpStuckTimeout = 3,
- JumpHeightThreshold = 5, -- Chase: 5, Patrol: 7
- JumpMovementThreshold = 0.75,
- DescentHeightThreshold = 5,
- EdgeDetectionDistance = 25,
- LineOfSightAngle = 90,
- MaxSlopeAngle = 30,
- MinPatrolPointDelay = 2,
- MinWaypointDistance = 4,
- MaxLOSDistance = 50,
- JumpCooldown = 3,
- PatrolJumpCooldown = 5,
- }
- local zombieModel = ServerStorage:FindFirstChild("Sentinel")
- local activeZombies = {}
- local spawnedZombies = {}
- -- Check if a position is on water
- local function isWater(position)
- local voxelSize = 4
- local alignedPos = Vector3.new(
- math.floor(position.X / voxelSize) * voxelSize,
- math.floor(position.Y / voxelSize) * voxelSize,
- math.floor(position.Z / voxelSize) * voxelSize
- )
- local region = Region3.new(
- alignedPos - Vector3.new(voxelSize/2, voxelSize/2, voxelSize/2),
- alignedPos + Vector3.new(voxelSize/2, voxelSize/2, voxelSize/2)
- )
- local materials, _ = Terrain:ReadVoxels(region, voxelSize)
- if materials and materials[1] and materials[1][1] and materials[1][1][1] then
- return materials[1][1][1] == Enum.Material.Water
- end
- warn("Failed to read terrain voxel at position: " .. tostring(position))
- return false
- end
- -- Check if a position is walkable
- local function isWalkable(position)
- local rayParams = RaycastParams.new()
- rayParams.FilterType = Enum.RaycastFilterType.Exclude
- rayParams.FilterDescendantsInstances = {Workspace:FindFirstChild("Sentinel") or {}}
- local offsets = {
- Vector3.new(0, 0, 0),
- Vector3.new(0.5, 0, 0), Vector3.new(-0.5, 0, 0),
- Vector3.new(0, 0, 0.5), Vector3.new(0, 0, -0.5),
- }
- local slopeAngles = {}
- local isValid = false
- for _, offset in ipairs(offsets) do
- local rayResult = Workspace:Raycast(position + Vector3.new(0, 50, 0) + offset, Vector3.new(0, -100, 0), rayParams)
- if rayResult and rayResult.Normal then
- local slopeAngle = math.deg(math.acos(rayResult.Normal.Y))
- local isMushroom2 = rayResult.Instance and rayResult.Instance.Name == "Mushroom2"
- local isTree = rayResult.Instance and string.find(rayResult.Instance.Name:lower(), "tree") ~= nil
- if slopeAngle <= CONFIG.MaxSlopeAngle and not isWater(rayResult.Position) and not isMushroom2 and not isTree then
- table.insert(slopeAngles, slopeAngle)
- isValid = true
- end
- end
- end
- if isValid and #slopeAngles > 0 then
- local avgSlope = 0
- for _, angle in ipairs(slopeAngles) do
- avgSlope = avgSlope + angle
- end
- avgSlope = avgSlope / #slopeAngles
- -- print("isWalkable hit at " .. tostring(position) .. ", avg slope: " .. avgSlope)
- return true, avgSlope
- end
- return false, 0
- end
- -- Check if a path is clear
- local function isPathClear(startPos, endPos, zombie)
- local rayParams = RaycastParams.new()
- rayParams.FilterType = Enum.RaycastFilterType.Exclude
- rayParams.FilterDescendantsInstances = {zombie, Workspace:FindFirstChild("Sentinel") or {}}
- local direction = (endPos - startPos)
- local rayResult = Workspace:Raycast(startPos + Vector3.new(0, 2, 0), direction, rayParams)
- return not rayResult or (rayResult.Position - endPos).Magnitude < 1
- end
- -- Check if an obstacle is in the path
- local function hasObstacle(startPos, endPos, zombie)
- local rayParams = RaycastParams.new()
- rayParams.FilterType = Enum.RaycastFilterType.Exclude
- rayParams.FilterDescendantsInstances = {zombie, Workspace:FindFirstChild("Sentinel") or {}}
- local direction = (endPos - startPos).Unit * 5 -- Check 5 studs ahead
- local rayResult = Workspace:Raycast(startPos + Vector3.new(0, 2, 0), direction, rayParams)
- if rayResult and rayResult.Instance then
- local isMushroom2 = rayResult.Instance.Name == "Mushroom2"
- local isTree = string.find(rayResult.Instance.Name:lower(), "tree") ~= nil
- return not isMushroom2 and not isTree, rayResult.Position
- end
- return false, nil
- end
- -- Check line of sight
- local function hasLineOfSight(zombie, target)
- local zombieHead = zombie:FindFirstChild("Head")
- local targetTorso = target:FindFirstChild("HumanoidRootPart")
- if not zombieHead or not targetTorso then
- return false
- end
- local directionToTarget = (targetTorso.Position - zombieHead.Position).Unit
- local zombieForward = zombie.HumanoidRootPart.CFrame.LookVector
- local angle = math.deg(math.acos(directionToTarget:Dot(zombieForward)))
- if angle > CONFIG.LineOfSightAngle / 2 then
- return false
- end
- local rayParams = RaycastParams.new()
- rayParams.FilterType = Enum.RaycastFilterType.Exclude
- rayParams.FilterDescendantsInstances = {zombie, target}
- local rayResult = Workspace:Raycast(zombieHead.Position, (targetTorso.Position - zombieHead.Position), rayParams)
- return not rayResult or (rayResult.Instance and rayResult.Instance:IsDescendantOf(target))
- end
- -- Find a valid patrol position
- local function getValidPatrolPosition(spawnPos, currentPos, zombie)
- if not spawnPos or not currentPos then
- warn("getValidPatrolPosition: Invalid inputs")
- return spawnPos or Vector3.new(0, 0, 0)
- end
- local rayParams = RaycastParams.new()
- rayParams.FilterType = Enum.RaycastFilterType.Exclude
- rayParams.FilterDescendantsInstances = {Workspace:FindFirstChild("Sentinel") or {}}
- for _ = 1, 30 do
- local offset = Vector3.new(math.random(-CONFIG.PatrolRadius, CONFIG.PatrolRadius), 0, math.random(-CONFIG.PatrolRadius, CONFIG.PatrolRadius))
- local newPos = spawnPos + offset
- local rayResult = Workspace:Raycast(newPos + Vector3.new(0, 50, 0), Vector3.new(0, -100, 0), rayParams)
- if rayResult then
- local isWalkablePoint, slopeAngle = isWalkable(rayResult.Position)
- if isWalkablePoint and slopeAngle <= 5 and isPathClear(currentPos, rayResult.Position, zombie) then
- local path = PathfindingService:CreatePath()
- path:ComputeAsync(currentPos, rayResult.Position)
- if path.Status == Enum.PathStatus.Success then
- -- print("Selected new patrol point: " .. tostring(rayResult.Position) .. ", slope: " .. slopeAngle)
- return rayResult.Position
- end
- end
- end
- end
- for _ = 1, 15 do
- local offset = Vector3.new(math.random(-5, 5), 0, math.random(-5, 5))
- local newPos = spawnPos + offset
- local rayResult = Workspace:Raycast(newPos + Vector3.new(0, 50, 0), Vector3.new(0, -100, 0), rayParams)
- if rayResult then
- local isWalkablePoint, slopeAngle = isWalkable(rayResult.Position)
- if isWalkablePoint and slopeAngle <= 5 and isPathClear(currentPos, rayResult.Position, zombie) then
- local path = PathfindingService:CreatePath()
- path:ComputeAsync(currentPos, rayResult.Position)
- if path.Status == Enum.PathStatus.Success then
- -- print("Selected fallback patrol point: " .. tostring(rayResult.Position) .. ", slope: " .. slopeAngle)
- return rayResult.Position
- end
- end
- end
- end
- -- print("Using spawnPos as patrol point fallback")
- return spawnPos
- end
- -- Find a safe descent position
- local function findDescentPosition(currentPos)
- local rayParams = RaycastParams.new()
- rayParams.FilterType = Enum.RaycastFilterType.Exclude
- rayParams.FilterDescendantsInstances = {Workspace:FindFirstChild("Sentinel") or {}}
- local directions = {
- Vector3.new(1, 0, 0), Vector3.new(-1, 0, 0),
- Vector3.new(0, 0, 1), Vector3.new(0, 0, -1),
- Vector3.new(1, 0, 1).Unit, Vector3.new(-1, 0, -1).Unit,
- Vector3.new(1, 0, -1).Unit, Vector3.new(-1, 0, 1).Unit,
- }
- local bestDescentPos = nil
- local minDistanceToGround = math.huge
- for _, direction in directions do
- for distance = 1, CONFIG.EdgeDetectionDistance do
- local checkPos = currentPos + direction * distance
- local rayResult = Workspace:Raycast(checkPos + Vector3.new(0, 5, 0), Vector3.new(0, -50, 0), rayParams)
- if rayResult then
- local groundHeight = rayResult.Position.Y
- local heightDifference = currentPos.Y - groundHeight
- if heightDifference > 0 and heightDifference <= CONFIG.JumpHeight * 1.5 and heightDifference < minDistanceToGround then
- bestDescentPos = checkPos
- minDistanceToGround = heightDifference
- end
- end
- end
- end
- return bestDescentPos
- end
- -- Find a climb position to reach an elevated target
- local function findClimbPosition(currentPos, targetPos)
- local rayParams = RaycastParams.new()
- rayParams.FilterType = Enum.RaycastFilterType.Exclude
- rayParams.FilterDescendantsInstances = {Workspace:FindFirstChild("Sentinel") or {}}
- local directions = {
- Vector3.new(1, 0, 0), Vector3.new(-1, 0, 0),
- Vector3.new(0, 0, 1), Vector3.new(0, 0, -1),
- Vector3.new(1, 0, 1).Unit, Vector3.new(-1, 0, -1).Unit,
- Vector3.new(1, 0, -1).Unit, Vector3.new(-1, 0, 1).Unit,
- }
- local bestClimbPos = nil
- local minDistanceToTarget = math.huge
- for _, direction in directions do
- for distance = 0.3, CONFIG.EdgeDetectionDistance, 0.3 do -- Tighter steps
- local checkPos = currentPos + direction * distance
- local rayResult = Workspace:Raycast(checkPos + Vector3.new(0, 50, 0), Vector3.new(0, -100, 0), rayParams)
- if rayResult then
- local surfacePos = rayResult.Position
- local heightDifference = targetPos.Y - surfacePos.Y
- local horizontalDistance = (Vector3.new(targetPos.X, 0, targetPos.Z) - Vector3.new(surfacePos.X, 0, surfacePos.Z)).Magnitude
- if heightDifference > 0 and heightDifference <= CONFIG.JumpHeight and horizontalDistance < minDistanceToTarget then
- local isWalkablePoint, _ = isWalkable(surfacePos)
- if isWalkablePoint then
- bestClimbPos = surfacePos
- minDistanceToTarget = horizontalDistance
- end
- end
- end
- end
- end
- if not bestClimbPos then
- local closestPos = Vector3.new(targetPos.X, currentPos.Y, targetPos.Z)
- local isWalkablePoint, _ = isWalkable(closestPos)
- if isWalkablePoint then
- -- print("No climb position found, using closest horizontal point: " .. tostring(closestPos))
- return closestPos
- end
- end
- return bestClimbPos
- end
- -- Check if NPC or target is on an elevated surface
- local function isElevated(position)
- local rayParams = RaycastParams.new()
- rayParams.FilterType = Enum.RaycastFilterType.Exclude
- rayParams.FilterDescendantsInstances = {Workspace:FindFirstChild("Sentinel") or {}}
- local offsets = {
- Vector3.new(0, 0, 0),
- Vector3.new(0.5, 0, 0), Vector3.new(-0.5, 0, 0),
- Vector3.new(0, 0, 0.5), Vector3.new(0, 0, -0.5),
- Vector3.new(1, 0, 0), Vector3.new(-1, 0, 0),
- Vector3.new(0, 0, 1), Vector3.new(0, 0, -1),
- }
- for _, offset in ipairs(offsets) do
- local rayResult = Workspace:Raycast(position + Vector3.new(0, 5, 0) + offset, Vector3.new(0, -50, 0), rayParams)
- if rayResult then
- local heightDifference = position.Y - rayResult.Position.Y
- -- print("isElevated hit: " .. (rayResult.Instance and rayResult.Instance.Name or "none") .. " at " .. tostring(position))
- return heightDifference > CONFIG.DescentHeightThreshold, rayResult.Instance
- end
- end
- return false, nil
- end
- local function getSmoothedTargetPosition(target, lastTargetPos)
- if not target or not target.HumanoidRootPart then
- return lastTargetPos
- end
- local currentPos = target.HumanoidRootPart.Position
- if lastTargetPos then
- return lastTargetPos:Lerp(currentPos, 0.3)
- end
- return currentPos
- end
- -- Spawn a single zombie
- function ZombieAI:SpawnZombieAt(zombieSpawnPart)
- if not zombieModel or not zombieSpawnPart then
- warn("Sentinel or ZombieSpawn part not found")
- return
- end
- if spawnedZombies[zombieSpawnPart] then
- warn("Zombie already spawned at " .. zombieSpawnPart.Name)
- return
- end
- local zombie = zombieModel:Clone()
- local humanoid = zombie.Humanoid
- humanoid.WalkSpeed = CONFIG.PatrolSpeed
- humanoid.HipHeight = 2
- humanoid.JumpPower = CONFIG.JumpHeight * 5
- humanoid.AutoJumpEnabled = true
- humanoid.Health = math.random(CONFIG.HealthRange[1], CONFIG.HealthRange[2])
- zombie:SetPrimaryPartCFrame(zombieSpawnPart.CFrame)
- zombie.Parent = Workspace
- spawnedZombies[zombieSpawnPart] = true
- table.insert(activeZombies, zombie)
- local initialPatrolPos = getValidPatrolPosition(zombieSpawnPart.Position, zombie.HumanoidRootPart.Position, zombie)
- coroutine.wrap(function()
- self:StartZombieAI(zombie, zombieSpawnPart, initialPatrolPos)
- end)()
- end
- -- AI behavior
- function ZombieAI:StartZombieAI(zombie, zombieSpawnPart, initialPatrolPos)
- local humanoid = zombie.Humanoid
- local rootPart = zombie.HumanoidRootPart
- local spawnPos = zombieSpawnPart.Position
- if not spawnPos then
- warn("StartZombieAI: Invalid spawnPos")
- return
- end
- local currentTarget = nil
- local returningToSpawn = false
- local lastPatrolPos = initialPatrolPos
- local patrolTimer = 0
- local lastPosition = rootPart.Position
- local stuckTimer = 0
- local stuckCount = 0
- local descentTarget = nil
- local climbTarget = nil
- local losTimer = nil
- local lastPatrolChangeTime = 0
- local currentWaypointIndex = 1
- local cachedWaypoints = nil
- local lastJumpTime = 0
- local jumpState = { shouldJump = false, reason = nil }
- local lastTargetPos = nil
- local lastPathUpdate = 0
- local jumpAttempts = 0
- local function resetJumpState()
- jumpState.shouldJump = false
- jumpState.reason = nil
- humanoid.Jump = false
- lastJumpTime = 0
- jumpAttempts = 0
- end
- local function handleDeath()
- local index = table.find(activeZombies, zombie)
- if index then
- table.remove(activeZombies, index)
- end
- for spawnPart, _ in spawnedZombies do
- if (spawnPart.Position - spawnPos).Magnitude < 1 then
- spawnedZombies[spawnPart] = nil
- break
- end
- end
- NPCSound:StopSounds(zombie)
- zombie:Destroy()
- self:SpawnZombieAt(zombieSpawnPart)
- end
- humanoid.Died:Connect(handleDeath)
- while humanoid.Health > 0 do
- -- Reset jump state at start of loop
- if not (climbTarget or descentTarget) then
- resetJumpState()
- end
- if isWater(rootPart.Position) then
- local explosion = Instance.new("Explosion")
- explosion.Position = rootPart.Position
- explosion.BlastRadius = CONFIG.ExplosionRadius
- explosion.BlastPressure = 20
- explosion.DestroyJointRadiusPercent = 0
- explosion.ExplosionType = Enum.ExplosionType.NoCraters
- explosion.Parent = Workspace
- explosion.Hit:Connect(function(part)
- local playerHumanoid = part.Parent:FindFirstChildOfClass("Humanoid")
- if playerHumanoid and playerHumanoid ~= humanoid then
- local distance = (part.Position - explosion.Position).Magnitude
- if distance <= CONFIG.ExplosionRadius then
- playerHumanoid:TakeDamage(playerHumanoid.Health * CONFIG.ExplosionDamagePercent)
- end
- end
- end)
- NPCSound:StopSounds(zombie)
- handleDeath()
- break
- end
- -- Check if NPC is on an elevated surface
- local isElevatedFlag, hitInstance = isElevated(rootPart.Position)
- local onMushroom2 = hitInstance and hitInstance.Name == "Mushroom2"
- local onTree = hitInstance and string.find(hitInstance.Name:lower(), "tree") ~= nil
- local onAvoidableModel = onMushroom2 or onTree
- -- Immediate descent if on tree or Mushroom2
- if onAvoidableModel and not descentTarget then
- descentTarget = findDescentPosition(rootPart.Position)
- if descentTarget then
- local direction = (descentTarget - rootPart.Position).Unit * humanoid.WalkSpeed
- humanoid:Move(direction, false)
- jumpState.shouldJump = true
- jumpState.reason = "Descent from " .. (onMushroom2 and "Mushroom2" or "tree")
- -- print("Descending from " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. " to " .. tostring(descentTarget))
- if (rootPart.Position - descentTarget).Magnitude < 1 then
- descentTarget = nil
- resetJumpState()
- end
- lastPosition = rootPart.Position
- wait(0.05)
- continue
- end
- end
- -- Target selection
- if not currentTarget then
- for _, player in game.Players:GetPlayers() do
- if player.Character and player.Character.Humanoid and player.Character.Humanoid.Health > 0 then
- local distance = (player.Character.HumanoidRootPart.Position - rootPart.Position).Magnitude
- local inLOS = distance <= CONFIG.MaxLOSDistance and hasLineOfSight(zombie, player.Character)
- if distance <= CONFIG.DetectionRange or inLOS then
- local targetPlayer = player.Character
- if inLOS and not losTimer then
- losTimer = task.delay(0.3, function()
- if targetPlayer and targetPlayer.Humanoid and targetPlayer.Humanoid.Health > 0 and hasLineOfSight(zombie, targetPlayer) then
- currentTarget = targetPlayer
- humanoid.WalkSpeed = CONFIG.ChaseSpeed
- returningToSpawn = false
- lastPatrolPos = nil
- patrolTimer = 0
- stuckTimer = 0
- stuckCount = 0
- descentTarget = nil
- climbTarget = nil
- currentWaypointIndex = 1
- cachedWaypoints = nil
- lastTargetPos = nil
- lastPathUpdate = 0
- resetJumpState()
- NPCSound:PlayChaseSound(zombie)
- -- print("Chasing player (LOS) at distance: " .. distance)
- end
- losTimer = nil
- end)
- elseif distance <= CONFIG.DetectionRange then
- currentTarget = targetPlayer
- humanoid.WalkSpeed = CONFIG.ChaseSpeed
- returningToSpawn = false
- lastPatrolPos = nil
- patrolTimer = 0
- stuckTimer = 0
- stuckCount = 0
- descentTarget = nil
- climbTarget = nil
- currentWaypointIndex = 1
- cachedWaypoints = nil
- lastTargetPos = nil
- lastPathUpdate = 0
- resetJumpState()
- NPCSound:PlayChaseSound(zombie)
- -- print("Chasing player (distance) at distance: " .. distance)
- end
- break
- end
- end
- end
- end
- if currentTarget then
- local targetPos = getSmoothedTargetPosition(currentTarget, lastTargetPos)
- lastTargetPos = targetPos
- local distanceToTarget = (targetPos - rootPart.Position).Magnitude
- local targetInWater = isWater(targetPos)
- if not currentTarget.Humanoid or currentTarget.Humanoid.Health <= 0 or distanceToTarget > CONFIG.ChaseAbandonRange or targetInWater then
- currentTarget = nil
- humanoid.WalkSpeed = CONFIG.PatrolSpeed
- humanoid:Move(Vector3.new(0, 0, 0), false)
- returningToSpawn = true
- lastPatrolPos = nil
- patrolTimer = 0
- stuckTimer = 0
- stuckCount = 0
- descentTarget = nil
- climbTarget = nil
- losTimer = nil
- currentWaypointIndex = 1
- cachedWaypoints = nil
- lastTargetPos = nil
- lastPathUpdate = 0
- resetJumpState()
- NPCSound:StopSounds(zombie)
- -- print("Stopped chasing, transitioning to patrol")
- else
- -- Check if target is elevated
- local isTargetElevated, targetInstance = isElevated(targetPos)
- local effectiveTargetPos = targetPos
- if isTargetElevated then
- local rayParams = RaycastParams.new()
- rayParams.FilterType = Enum.RaycastFilterType.Exclude
- rayParams.FilterDescendantsInstances = {Workspace:FindFirstChild("Sentinel") or {}}
- local offsets = {
- Vector3.new(0, 0, 0),
- Vector3.new(1, 0, 0), Vector3.new(-1, 0, 0),
- Vector3.new(0, 0, 1), Vector3.new(0, 0, -1),
- Vector3.new(0.5, 0, 0.5), Vector3.new(-0.5, 0, -0.5),
- }
- for _, offset in ipairs(offsets) do
- local rayResult = Workspace:Raycast(targetPos + Vector3.new(0, 5, 0) + offset, Vector3.new(0, -50, 0), rayParams)
- if rayResult then
- effectiveTargetPos = Vector3.new(targetPos.X, rayResult.Position.Y + 0.1, targetPos.Z)
- -- print("Target is elevated, pathfinding to base: " .. tostring(effectiveTargetPos))
- break
- end
- end
- end
- -- Handle climbing to reach elevated target
- if isTargetElevated and not climbTarget then
- climbTarget = findClimbPosition(rootPart.Position, targetPos)
- if climbTarget then
- local direction = (climbTarget - rootPart.Position).Unit * humanoid.WalkSpeed
- humanoid:Move(direction, false)
- local heightDifference = targetPos.Y - rootPart.Position.Y
- if heightDifference > CONFIG.JumpHeightThreshold and heightDifference <= CONFIG.JumpHeight and tick() - lastJumpTime >= CONFIG.JumpCooldown and (rootPart.Position - climbTarget).Magnitude < 4 then
- jumpState.shouldJump = true
- jumpState.reason = "Climbing to crate"
- -- print("Climbing to " .. tostring(climbTarget) .. ", height: " .. heightDifference)
- end
- if (rootPart.Position - climbTarget).Magnitude < 4 then
- climbTarget = nil
- resetJumpState()
- end
- lastPosition = rootPart.Position
- wait(0.05)
- continue
- end
- end
- -- Validate effectiveTargetPos
- local isValidTarget, _ = isWalkable(effectiveTargetPos)
- if not isValidTarget then
- effectiveTargetPos = Vector3.new(effectiveTargetPos.X, rootPart.Position.Y, effectiveTargetPos.Z)
- end
- -- Update path less frequently
- if tick() - lastPathUpdate >= 0.5 then
- local path = PathfindingService:CreatePath()
- path:ComputeAsync(rootPart.Position, effectiveTargetPos)
- if path.Status == Enum.PathStatus.Success then
- local waypoints = path:GetWaypoints()
- if #waypoints > 1 then
- local nextWaypoint = waypoints[2].Position
- local isWaypointElevated, waypointInstance = isElevated(nextWaypoint)
- local isWaypointOnMushroom2 = waypointInstance and waypointInstance.Name == "Mushroom2"
- local isWaypointOnTree = waypointInstance and string.find(waypointInstance.Name:lower(), "tree") ~= nil
- local isWaypointOnAvoidableModel = isWaypointOnMushroom2 or isWaypointOnTree
- -- print("Chase waypoint at " .. tostring(nextWaypoint) .. ": Mushroom2=" .. tostring(isWaypointOnMushroom2) .. ", Tree=" .. tostring(isWaypointOnTree))
- if isWaypointOnAvoidableModel then
- currentWaypointIndex = 1
- path:ComputeAsync(rootPart.Position, effectiveTargetPos)
- waypoints = path:GetWaypoints()
- if #waypoints > 1 then
- nextWaypoint = waypoints[2].Position
- else
- humanoid:Move(Vector3.new(0, 0, 0), false)
- stuckTimer = 0
- stuckCount = 0
- resetJumpState()
- -- print("No valid chase waypoints after skipping " .. (isWaypointOnMushroom2 and "Mushroom2" or "tree model " .. (waypointInstance and waypointInstance.Name or "unknown")))
- wait(0.05)
- continue
- end
- -- print("Walking around " .. (isWaypointOnMushroom2 and "Mushroom2" or "tree model " .. (waypointInstance and waypointInstance.Name or "unknown")) .. " in chase")
- end
- if not isWater(nextWaypoint) and isPathClear(rootPart.Position, nextWaypoint, zombie) then
- local heightDifferenceToTarget = targetPos.Y - rootPart.Position.Y
- if heightDifferenceToTarget > CONFIG.DescentHeightThreshold and not descentTarget and not climbTarget then
- descentTarget = findDescentPosition(rootPart.Position)
- if descentTarget then
- local direction = (descentTarget - rootPart.Position).Unit * humanoid.WalkSpeed
- humanoid:Move(direction, false)
- jumpState.shouldJump = true
- jumpState.reason = "Descent during chase"
- if (rootPart.Position - descentTarget).Magnitude < 1 then
- descentTarget = nil
- resetJumpState()
- end
- lastPosition = rootPart.Position
- wait(0.05)
- continue
- end
- else
- descentTarget = nil
- end
- local heightDifference = nextWaypoint.Y - rootPart.Position.Y
- local isWalkableWaypoint, slopeAngle = isWalkable(nextWaypoint)
- local hasObstacleFlag, obstaclePos = hasObstacle(rootPart.Position, nextWaypoint, zombie)
- if ((heightDifference > CONFIG.JumpHeightThreshold and heightDifference <= CONFIG.JumpHeight) or (isWalkableWaypoint and slopeAngle > 45)) and hasObstacleFlag and tick() - lastJumpTime >= CONFIG.JumpCooldown then
- jumpState.shouldJump = true
- jumpState.reason = "Terrain navigation (height: " .. heightDifference .. ", slope: " .. slopeAngle .. ", obstacle at: " .. tostring(obstaclePos) .. ")"
- -- print("Jump triggered during chase at " .. tostring(nextWaypoint) .. ", slope: " .. slopeAngle .. ", height: " .. heightDifference .. ", obstacle at: " .. tostring(obstaclePos))
- end
- local movedDistance = (rootPart.Position - lastPosition).Magnitude
- if movedDistance < CONFIG.JumpMovementThreshold then
- stuckTimer = stuckTimer + 0.05
- if stuckTimer >= CONFIG.JumpStuckTimeout then
- stuckCount = stuckCount + 1
- if stuckCount >= 12 and onAvoidableModel then
- descentTarget = findDescentPosition(rootPart.Position)
- if descentTarget then
- -- print("Stuck on " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. ", forcing descent")
- else
- currentWaypointIndex = 1
- path:ComputeAsync(rootPart.Position, effectiveTargetPos)
- -- print("Stuck on " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. ", recomputing path")
- end
- stuckTimer = 0
- stuckCount = 0
- resetJumpState()
- elseif stuckCount >= 12 and hasObstacleFlag then
- currentWaypointIndex = 1
- path:ComputeAsync(rootPart.Position, effectiveTargetPos)
- -- print("NPC stuck during chase at " .. tostring(rootPart.Position) .. ", recomputing path")
- stuckTimer = 0
- stuckCount = 0
- if tick() - lastJumpTime >= CONFIG.JumpCooldown then
- jumpState.shouldJump = true
- jumpState.reason = "Stuck during chase (obstacle at: " .. tostring(obstaclePos) .. ")"
- -- print("Jump triggered due to stuck during chase at " .. tostring(nextWaypoint) .. ", obstacle at: " .. tostring(obstaclePos))
- end
- else
- stuckTimer = 0
- end
- end
- else
- stuckTimer = 0
- stuckCount = 0
- end
- local direction = (nextWaypoint - rootPart.Position).Unit * humanoid.WalkSpeed
- humanoid:Move(direction, false)
- end
- else
- humanoid:Move(Vector3.new(0, 0, 0), false)
- stuckTimer = 0
- stuckCount = 0
- resetJumpState()
- end
- lastPathUpdate = tick()
- else
- -- Try alternative positions
- local offsets = {
- Vector3.new(5, 0, 0), Vector3.new(-5, 0, 0),
- Vector3.new(0, 0, 5), Vector3.new(0, 0, -5),
- Vector3.new(3, 0, 3), Vector3.new(-3, 0, -3),
- Vector3.new(7, 0, 0), Vector3.new(-7, 0, 0),
- Vector3.new(0, 0, 7), Vector3.new(0, 0, -7),
- }
- local pathFound = false
- for _, offset in ipairs(offsets) do
- local altPos = effectiveTargetPos + offset
- if isWalkable(altPos) then
- path:ComputeAsync(rootPart.Position, altPos)
- if path.Status == Enum.PathStatus.Success then
- local waypoints = path:GetWaypoints()
- if #waypoints > 1 then
- local nextWaypoint = waypoints[2].Position
- local direction = (nextWaypoint - rootPart.Position).Unit * humanoid.WalkSpeed
- humanoid:Move(direction, false)
- -- print("Path failed to " .. tostring(effectiveTargetPos) .. ", using alternative: " .. tostring(altPos))
- pathFound = true
- lastPathUpdate = tick()
- break
- end
- end
- end
- end
- if not pathFound then
- local horizontalTargetPos = Vector3.new(targetPos.X, rootPart.Position.Y, targetPos.Z)
- local direction = (horizontalTargetPos - rootPart.Position).Unit * humanoid.WalkSpeed
- if (horizontalTargetPos - rootPart.Position).Magnitude > 4 then
- humanoid:Move(direction, false)
- -- print("Path failed to " .. tostring(effectiveTargetPos) .. ", moving to horizontal: " .. tostring(horizontalTargetPos))
- else
- if tick() - lastJumpTime >= CONFIG.JumpCooldown and jumpAttempts < 3 then
- jumpState.shouldJump = true
- jumpState.reason = "Path failed, attempting jump (attempt " .. (jumpAttempts + 1) .. ")"
- jumpAttempts = jumpAttempts + 1
- -- print("Path failed, attempting jump at " .. tostring(rootPart.Position) .. ", attempt " .. jumpAttempts)
- end
- end
- stuckTimer = 0
- stuckCount = 0
- end
- end
- end
- if distanceToTarget <= 5 then
- currentTarget.Humanoid:TakeDamage(CONFIG.AttackDamage)
- end
- end
- end
- if not currentTarget then
- local distanceToSpawn = (rootPart.Position - spawnPos).Magnitude
- if returningToSpawn and distanceToSpawn > CONFIG.ReturnDistanceThreshold then
- if tick() - lastPathUpdate >= 0.5 then
- local path = PathfindingService:CreatePath()
- path:ComputeAsync(rootPart.Position, spawnPos)
- if path.Status == Enum.PathStatus.Success then
- local waypoints = path:GetWaypoints()
- if #waypoints > 1 then
- local nextWaypoint = waypoints[2].Position
- local isWaypointElevated, waypointInstance = isElevated(nextWaypoint)
- local isWaypointOnMushroom2 = waypointInstance and waypointInstance.Name == "Mushroom2"
- local isWaypointOnTree = waypointInstance and string.find(waypointInstance.Name:lower(), "tree") ~= nil
- local isWaypointOnAvoidableModel = isWaypointOnMushroom2 or isWaypointOnTree
- -- print("Return waypoint at " .. tostring(nextWaypoint) .. ": Mushroom2=" .. tostring(isWaypointOnMushroom2) .. ", Tree=" .. tostring(isWaypointOnTree))
- if isWaypointOnAvoidableModel then
- currentWaypointIndex = 1
- path:ComputeAsync(rootPart.Position, spawnPos)
- waypoints = path:GetWaypoints()
- if #waypoints > 1 then
- nextWaypoint = waypoints[2].Position
- else
- humanoid:Move(Vector3.new(0, 0, 0), false)
- stuckTimer = 0
- stuckCount = 0
- resetJumpState()
- -- print("No valid return waypoints after skipping " .. (isWaypointOnMushroom2 and "Mushroom2" or "tree model " .. (waypointInstance and waypointInstance.Name or "unknown")))
- wait(0.05)
- continue
- end
- -- print("Walking around " .. (isWaypointOnMushroom2 and "Mushroom2" or "tree model " .. (waypointInstance and waypointInstance.Name or "unknown")) .. " in return")
- end
- if not isWater(nextWaypoint) and isPathClear(rootPart.Position, nextWaypoint, zombie) then
- local heightDifferenceToSpawn = rootPart.Position.Y - spawnPos.Y
- if heightDifferenceToSpawn > CONFIG.DescentHeightThreshold and not descentTarget then
- descentTarget = findDescentPosition(rootPart.Position)
- if descentTarget then
- local direction = (descentTarget - rootPart.Position).Unit * CONFIG.PatrolSpeed
- humanoid:Move(direction, false)
- jumpState.shouldJump = true
- jumpState.reason = "Descent during return"
- if (rootPart.Position - descentTarget).Magnitude < 1 then
- descentTarget = nil
- resetJumpState()
- end
- lastPosition = rootPart.Position
- wait(0.05)
- continue
- end
- else
- descentTarget = nil
- end
- local heightDifference = nextWaypoint.Y - rootPart.Position.Y
- local isWalkableWaypoint, slopeAngle = isWalkable(nextWaypoint)
- local hasObstacleFlag, obstaclePos = hasObstacle(rootPart.Position, nextWaypoint, zombie)
- if ((heightDifference > 7 and heightDifference <= CONFIG.JumpHeight) or (isWalkableWaypoint and slopeAngle > 50)) and hasObstacleFlag and tick() - lastJumpTime >= CONFIG.PatrolJumpCooldown then
- jumpState.shouldJump = true
- jumpState.reason = "Terrain navigation in return (height: " .. heightDifference .. ", slope: " .. slopeAngle .. ", obstacle at: " .. tostring(obstaclePos) .. ")"
- -- print("Jump triggered during return at " .. tostring(nextWaypoint) .. ", slope: " .. slopeAngle .. ", height: " .. heightDifference .. ", obstacle at: " .. tostring(obstaclePos))
- end
- local movedDistance = (rootPart.Position - lastPosition).Magnitude
- if movedDistance < CONFIG.JumpMovementThreshold then
- stuckTimer = stuckTimer + 0.05
- if stuckTimer >= CONFIG.JumpStuckTimeout then
- stuckCount = stuckCount + 1
- if stuckCount >= 15 and onAvoidableModel then
- descentTarget = findDescentPosition(rootPart.Position)
- if descentTarget then
- -- print("Stuck on " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. ", forcing descent")
- else
- currentWaypointIndex = 1
- path:ComputeAsync(rootPart.Position, spawnPos)
- -- print("Stuck on " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. ", recomputing path")
- end
- stuckTimer = 0
- stuckCount = 0
- resetJumpState()
- elseif stuckCount >= 15 and hasObstacleFlag then
- currentWaypointIndex = 1
- path:ComputeAsync(rootPart.Position, spawnPos)
- -- print("NPC stuck returning to spawn at " .. tostring(rootPart.Position))
- stuckTimer = 0
- stuckCount = 0
- if tick() - lastJumpTime >= CONFIG.PatrolJumpCooldown then
- jumpState.shouldJump = true
- jumpState.reason = "Stuck during return (obstacle at: " .. tostring(obstaclePos) .. ")"
- -- print("Jump triggered due to stuck during return at " .. tostring(nextWaypoint) .. ", obstacle at: " .. tostring(obstaclePos))
- end
- else
- stuckTimer = 0
- end
- end
- else
- stuckTimer = 0
- stuckCount = 0
- end
- local direction = (nextWaypoint - rootPart.Position).Unit * CONFIG.PatrolSpeed
- humanoid:Move(direction, false)
- end
- else
- humanoid:Move(Vector3.new(0, 0, 0), false)
- stuckTimer = 0
- stuckCount = 0
- resetJumpState()
- end
- lastPathUpdate = tick()
- else
- humanoid:Move(Vector3.new(0, 0, 0), false)
- stuckTimer = 0
- stuckCount = 0
- resetJumpState()
- warn("Path computation failed for zombie returning to spawn at " .. tostring(spawnPos))
- end
- end
- lastPatrolPos = nil
- patrolTimer = 0
- currentWaypointIndex = 1
- cachedWaypoints = nil
- stuckCount = 0
- else
- returningToSpawn = false
- patrolTimer = patrolTimer + 0.05
- stuckTimer = 0
- descentTarget = nil
- climbTarget = nil
- resetJumpState()
- NPCSound:PlayPatrolSound(zombie)
- if not lastPatrolPos or patrolTimer >= CONFIG.PatrolTimeout then
- local currentTime = tick()
- if not lastPatrolPos or currentTime - lastPatrolChangeTime >= CONFIG.MinPatrolPointDelay then
- lastPatrolPos = getValidPatrolPosition(spawnPos, rootPart.Position, zombie)
- patrolTimer = 0
- lastPatrolChangeTime = currentTime
- currentWaypointIndex = 1
- cachedWaypoints = nil
- stuckCount = 0
- resetJumpState()
- -- print("New patrol point selected: " .. tostring(lastPatrolPos))
- end
- end
- if lastPatrolPos then
- if not cachedWaypoints or tick() - lastPathUpdate >= 0.5 then
- local path = PathfindingService:CreatePath()
- path:ComputeAsync(rootPart.Position, lastPatrolPos)
- if path.Status == Enum.PathStatus.Success then
- cachedWaypoints = path:GetWaypoints()
- -- print("Path computed with " .. #cachedWaypoints .. " waypoints")
- lastPathUpdate = tick()
- else
- -- print("Path computation failed for patrol to " .. tostring(lastPatrolPos) .. ", selecting new point")
- lastPatrolPos = getValidPatrolPosition(spawnPos, rootPart.Position, zombie)
- currentWaypointIndex = 1
- cachedWaypoints = nil
- humanoid:Move(Vector3.new(0, 0, 0), false)
- resetJumpState()
- wait(0.05)
- continue
- end
- end
- if cachedWaypoints and #cachedWaypoints > currentWaypointIndex then
- local nextWaypoint = cachedWaypoints[currentWaypointIndex].Position
- local isWaypointElevated, waypointInstance = isElevated(nextWaypoint)
- local isWaypointOnMushroom2 = waypointInstance and waypointInstance.Name == "Mushroom2"
- local isWaypointOnTree = waypointInstance and string.find(waypointInstance.Name:lower(), "tree") ~= nil
- local isWaypointOnAvoidableModel = isWaypointOnMushroom2 or isWaypointOnTree
- -- print("Patrol waypoint at " .. tostring(nextWaypoint) .. ": Mushroom2=" .. tostring(isWaypointOnMushroom2) .. ", Tree=" .. tostring(isWaypointOnTree))
- if isWaypointOnAvoidableModel then
- -- print("Avoiding " .. (isWaypointOnMushroom2 and "Mushroom2" or "tree model " .. (waypointInstance and waypointInstance.Name or "unknown")) .. " in patrol")
- lastPatrolPos = getValidPatrolPosition(spawnPos, rootPart.Position, zombie)
- currentWaypointIndex = 1
- cachedWaypoints = nil
- resetJumpState()
- wait(0.05)
- continue
- end
- if not isWater(nextWaypoint) and (nextWaypoint - rootPart.Position).Magnitude >= CONFIG.MinWaypointDistance and isPathClear(rootPart.Position, nextWaypoint, zombie) then
- local heightDifference = nextWaypoint.Y - rootPart.Position.Y
- local isWalkableWaypoint, slopeAngle = isWalkable(nextWaypoint)
- local hasObstacleFlag, obstaclePos = hasObstacle(rootPart.Position, nextWaypoint, zombie)
- if ((heightDifference > 7 and heightDifference <= CONFIG.JumpHeight) or (isWalkableWaypoint and slopeAngle > 50)) and hasObstacleFlag and tick() - lastJumpTime >= CONFIG.PatrolJumpCooldown then
- jumpState.shouldJump = true
- jumpState.reason = "Terrain navigation in patrol (height: " .. heightDifference .. ", slope: " .. slopeAngle .. ", obstacle at: " .. tostring(obstaclePos) .. ")"
- -- print("Jump triggered during patrol at " .. tostring(nextWaypoint) .. ", slope: " .. slopeAngle .. ", height: " .. heightDifference .. ", obstacle at: " .. tostring(obstaclePos))
- end
- local movedDistance = (rootPart.Position - lastPosition).Magnitude
- if movedDistance < CONFIG.JumpMovementThreshold then
- stuckTimer = stuckTimer + 0.05
- if stuckTimer >= CONFIG.JumpStuckTimeout then
- stuckCount = stuckCount + 1
- if stuckCount >= 15 and onAvoidableModel then
- descentTarget = findDescentPosition(rootPart.Position)
- if descentTarget then
- -- print("Stuck on " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. ", forcing descent")
- else
- lastPatrolPos = getValidPatrolPosition(spawnPos, rootPart.Position, zombie)
- currentWaypointIndex = 1
- cachedWaypoints = nil
- -- print("Stuck on " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. ", resetting patrol point")
- end
- stuckTimer = 0
- stuckCount = 0
- resetJumpState()
- elseif stuckCount >= 15 and hasObstacleFlag then
- lastPatrolPos = getValidPatrolPosition(spawnPos, rootPart.Position, zombie)
- currentWaypointIndex = 1
- cachedWaypoints = nil
- stuckTimer = 0
- stuckCount = 0
- if tick() - lastJumpTime >= CONFIG.PatrolJumpCooldown then
- jumpState.shouldJump = true
- jumpState.reason = "Stuck during patrol (obstacle at: " .. tostring(obstaclePos) .. ")"
- -- print("NPC stuck during patrol at " .. tostring(rootPart.Position) .. ", resetting patrol point, obstacle at: " .. tostring(obstaclePos))
- end
- else
- stuckTimer = 0
- end
- end
- else
- stuckTimer = 0
- stuckCount = 0
- end
- local direction = (nextWaypoint - rootPart.Position).Unit * CONFIG.PatrolSpeed
- humanoid:Move(direction, false)
- if (rootPart.Position - nextWaypoint).Magnitude < 3 then
- currentWaypointIndex = currentWaypointIndex + 1
- -- print("Moving to patrol waypoint " .. currentWaypointIndex .. " at " .. tostring(nextWaypoint))
- end
- else
- currentWaypointIndex = currentWaypointIndex + 1
- -- print("Skipping patrol waypoint " .. currentWaypointIndex .. " at " .. tostring(nextWaypoint) .. " (invalid, too close, or obstructed)")
- end
- else
- if lastPatrolPos and (rootPart.Position - lastPatrolPos).Magnitude < 3 then
- lastPatrolPos = nil
- patrolTimer = CONFIG.PatrolTimeout
- currentWaypointIndex = 1
- cachedWaypoints = nil
- stuckCount = 0
- resetJumpState()
- -- print("Reached patrol point, resetting")
- end
- end
- else
- humanoid:Move(Vector3.new(0, 0, 0), false)
- resetJumpState()
- end
- end
- end
- -- Apply jump state
- if jumpState.shouldJump and tick() - lastJumpTime >= (currentTarget and CONFIG.JumpCooldown or CONFIG.PatrolJumpCooldown) then
- humanoid.Jump = true
- lastJumpTime = tick()
- -- print("Applying jump: " .. (jumpState.reason or "Unknown reason") .. " at " .. tostring(rootPart.Position))
- else
- humanoid.Jump = false
- end
- lastPosition = rootPart.Position
- wait(0.05)
- end
- end
- function ZombieAI:Init()
- local zombieSpawnParts = {}
- for _, part in Workspace:GetChildren() do
- if part.Name == "ZombieSpawn" then
- table.insert(zombieSpawnParts, part)
- end
- end
- spawnedZombies = {}
- activeZombies = {}
- for _, zombieSpawnPart in zombieSpawnParts do
- self:SpawnZombieAt(zombieSpawnPart)
- end
- end
- return ZombieAI
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement