Advertisement
KingAesthetic

Kaizer's NPC AI System

Apr 27th, 2025
202
0
13 days
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Lua 41.76 KB | Source Code | 0 0
  1. local ZombieAI = {}
  2.  
  3. local ServerStorage = game:GetService("ServerStorage")
  4. local PathfindingService = game:GetService("PathfindingService")
  5. local Workspace = game:GetService("Workspace")
  6. local Terrain = Workspace.Terrain
  7. local NPCSound = require(game.ServerScriptService["Kaizer's Modules"].NPCSound)
  8.  
  9. local CONFIG = {
  10.     DetectionRange = 30,
  11.     ChaseAbandonRange = 50,
  12.     PatrolSpeed = 7,
  13.     ChaseSpeed = 17,
  14.     AttackDamage = 15,
  15.     HealthRange = {50, 100},
  16.     PatrolRadius = 25,
  17.     ReturnDistanceThreshold = 5,
  18.     ExplosionRadius = 10,
  19.     ExplosionDamagePercent = 0.5,
  20.     PatrolTimeout = 5,
  21.     JumpHeight = 12,
  22.     JumpStuckTimeout = 3,
  23.     JumpHeightThreshold = 5, -- Chase: 5, Patrol: 7
  24.     JumpMovementThreshold = 0.75,
  25.     DescentHeightThreshold = 5,
  26.     EdgeDetectionDistance = 25,
  27.     LineOfSightAngle = 90,
  28.     MaxSlopeAngle = 30,
  29.     MinPatrolPointDelay = 2,
  30.     MinWaypointDistance = 4,
  31.     MaxLOSDistance = 50,
  32.     JumpCooldown = 3,
  33.     PatrolJumpCooldown = 5,
  34. }
  35.  
  36. local zombieModel = ServerStorage:FindFirstChild("Sentinel")
  37. local activeZombies = {}
  38. local spawnedZombies = {}
  39.  
  40. -- Check if a position is on water
  41. local function isWater(position)
  42.     local voxelSize = 4
  43.     local alignedPos = Vector3.new(
  44.         math.floor(position.X / voxelSize) * voxelSize,
  45.         math.floor(position.Y / voxelSize) * voxelSize,
  46.         math.floor(position.Z / voxelSize) * voxelSize
  47.     )
  48.     local region = Region3.new(
  49.         alignedPos - Vector3.new(voxelSize/2, voxelSize/2, voxelSize/2),
  50.         alignedPos + Vector3.new(voxelSize/2, voxelSize/2, voxelSize/2)
  51.     )
  52.     local materials, _ = Terrain:ReadVoxels(region, voxelSize)
  53.     if materials and materials[1] and materials[1][1] and materials[1][1][1] then
  54.         return materials[1][1][1] == Enum.Material.Water
  55.     end
  56.     warn("Failed to read terrain voxel at position: " .. tostring(position))
  57.     return false
  58. end
  59.  
  60. -- Check if a position is walkable
  61. local function isWalkable(position)
  62.     local rayParams = RaycastParams.new()
  63.     rayParams.FilterType = Enum.RaycastFilterType.Exclude
  64.     rayParams.FilterDescendantsInstances = {Workspace:FindFirstChild("Sentinel") or {}}
  65.     local offsets = {
  66.         Vector3.new(0, 0, 0),
  67.         Vector3.new(0.5, 0, 0), Vector3.new(-0.5, 0, 0),
  68.         Vector3.new(0, 0, 0.5), Vector3.new(0, 0, -0.5),
  69.     }
  70.     local slopeAngles = {}
  71.     local isValid = false
  72.     for _, offset in ipairs(offsets) do
  73.         local rayResult = Workspace:Raycast(position + Vector3.new(0, 50, 0) + offset, Vector3.new(0, -100, 0), rayParams)
  74.         if rayResult and rayResult.Normal then
  75.             local slopeAngle = math.deg(math.acos(rayResult.Normal.Y))
  76.             local isMushroom2 = rayResult.Instance and rayResult.Instance.Name == "Mushroom2"
  77.             local isTree = rayResult.Instance and string.find(rayResult.Instance.Name:lower(), "tree") ~= nil
  78.             if slopeAngle <= CONFIG.MaxSlopeAngle and not isWater(rayResult.Position) and not isMushroom2 and not isTree then
  79.                 table.insert(slopeAngles, slopeAngle)
  80.                 isValid = true
  81.             end
  82.         end
  83.     end
  84.     if isValid and #slopeAngles > 0 then
  85.         local avgSlope = 0
  86.         for _, angle in ipairs(slopeAngles) do
  87.             avgSlope = avgSlope + angle
  88.         end
  89.         avgSlope = avgSlope / #slopeAngles
  90.         -- print("isWalkable hit at " .. tostring(position) .. ", avg slope: " .. avgSlope)
  91.         return true, avgSlope
  92.     end
  93.     return false, 0
  94. end
  95.  
  96. -- Check if a path is clear
  97. local function isPathClear(startPos, endPos, zombie)
  98.     local rayParams = RaycastParams.new()
  99.     rayParams.FilterType = Enum.RaycastFilterType.Exclude
  100.     rayParams.FilterDescendantsInstances = {zombie, Workspace:FindFirstChild("Sentinel") or {}}
  101.     local direction = (endPos - startPos)
  102.     local rayResult = Workspace:Raycast(startPos + Vector3.new(0, 2, 0), direction, rayParams)
  103.     return not rayResult or (rayResult.Position - endPos).Magnitude < 1
  104. end
  105.  
  106. -- Check if an obstacle is in the path
  107. local function hasObstacle(startPos, endPos, zombie)
  108.     local rayParams = RaycastParams.new()
  109.     rayParams.FilterType = Enum.RaycastFilterType.Exclude
  110.     rayParams.FilterDescendantsInstances = {zombie, Workspace:FindFirstChild("Sentinel") or {}}
  111.     local direction = (endPos - startPos).Unit * 5 -- Check 5 studs ahead
  112.     local rayResult = Workspace:Raycast(startPos + Vector3.new(0, 2, 0), direction, rayParams)
  113.     if rayResult and rayResult.Instance then
  114.         local isMushroom2 = rayResult.Instance.Name == "Mushroom2"
  115.         local isTree = string.find(rayResult.Instance.Name:lower(), "tree") ~= nil
  116.         return not isMushroom2 and not isTree, rayResult.Position
  117.     end
  118.     return false, nil
  119. end
  120.  
  121. -- Check line of sight
  122. local function hasLineOfSight(zombie, target)
  123.     local zombieHead = zombie:FindFirstChild("Head")
  124.     local targetTorso = target:FindFirstChild("HumanoidRootPart")
  125.     if not zombieHead or not targetTorso then
  126.         return false
  127.     end
  128.     local directionToTarget = (targetTorso.Position - zombieHead.Position).Unit
  129.     local zombieForward = zombie.HumanoidRootPart.CFrame.LookVector
  130.     local angle = math.deg(math.acos(directionToTarget:Dot(zombieForward)))
  131.     if angle > CONFIG.LineOfSightAngle / 2 then
  132.         return false
  133.     end
  134.     local rayParams = RaycastParams.new()
  135.     rayParams.FilterType = Enum.RaycastFilterType.Exclude
  136.     rayParams.FilterDescendantsInstances = {zombie, target}
  137.     local rayResult = Workspace:Raycast(zombieHead.Position, (targetTorso.Position - zombieHead.Position), rayParams)
  138.     return not rayResult or (rayResult.Instance and rayResult.Instance:IsDescendantOf(target))
  139. end
  140.  
  141. -- Find a valid patrol position
  142. local function getValidPatrolPosition(spawnPos, currentPos, zombie)
  143.     if not spawnPos or not currentPos then
  144.         warn("getValidPatrolPosition: Invalid inputs")
  145.         return spawnPos or Vector3.new(0, 0, 0)
  146.     end
  147.     local rayParams = RaycastParams.new()
  148.     rayParams.FilterType = Enum.RaycastFilterType.Exclude
  149.     rayParams.FilterDescendantsInstances = {Workspace:FindFirstChild("Sentinel") or {}}
  150.     for _ = 1, 30 do
  151.         local offset = Vector3.new(math.random(-CONFIG.PatrolRadius, CONFIG.PatrolRadius), 0, math.random(-CONFIG.PatrolRadius, CONFIG.PatrolRadius))
  152.         local newPos = spawnPos + offset
  153.         local rayResult = Workspace:Raycast(newPos + Vector3.new(0, 50, 0), Vector3.new(0, -100, 0), rayParams)
  154.         if rayResult then
  155.             local isWalkablePoint, slopeAngle = isWalkable(rayResult.Position)
  156.             if isWalkablePoint and slopeAngle <= 5 and isPathClear(currentPos, rayResult.Position, zombie) then
  157.                 local path = PathfindingService:CreatePath()
  158.                 path:ComputeAsync(currentPos, rayResult.Position)
  159.                 if path.Status == Enum.PathStatus.Success then
  160.                     -- print("Selected new patrol point: " .. tostring(rayResult.Position) .. ", slope: " .. slopeAngle)
  161.                     return rayResult.Position
  162.                 end
  163.             end
  164.         end
  165.     end
  166.     for _ = 1, 15 do
  167.         local offset = Vector3.new(math.random(-5, 5), 0, math.random(-5, 5))
  168.         local newPos = spawnPos + offset
  169.         local rayResult = Workspace:Raycast(newPos + Vector3.new(0, 50, 0), Vector3.new(0, -100, 0), rayParams)
  170.         if rayResult then
  171.             local isWalkablePoint, slopeAngle = isWalkable(rayResult.Position)
  172.             if isWalkablePoint and slopeAngle <= 5 and isPathClear(currentPos, rayResult.Position, zombie) then
  173.                 local path = PathfindingService:CreatePath()
  174.                 path:ComputeAsync(currentPos, rayResult.Position)
  175.                 if path.Status == Enum.PathStatus.Success then
  176.                     -- print("Selected fallback patrol point: " .. tostring(rayResult.Position) .. ", slope: " .. slopeAngle)
  177.                     return rayResult.Position
  178.                 end
  179.             end
  180.         end
  181.     end
  182.     -- print("Using spawnPos as patrol point fallback")
  183.     return spawnPos
  184. end
  185.  
  186. -- Find a safe descent position
  187. local function findDescentPosition(currentPos)
  188.     local rayParams = RaycastParams.new()
  189.     rayParams.FilterType = Enum.RaycastFilterType.Exclude
  190.     rayParams.FilterDescendantsInstances = {Workspace:FindFirstChild("Sentinel") or {}}
  191.     local directions = {
  192.         Vector3.new(1, 0, 0), Vector3.new(-1, 0, 0),
  193.         Vector3.new(0, 0, 1), Vector3.new(0, 0, -1),
  194.         Vector3.new(1, 0, 1).Unit, Vector3.new(-1, 0, -1).Unit,
  195.         Vector3.new(1, 0, -1).Unit, Vector3.new(-1, 0, 1).Unit,
  196.     }
  197.     local bestDescentPos = nil
  198.     local minDistanceToGround = math.huge
  199.     for _, direction in directions do
  200.         for distance = 1, CONFIG.EdgeDetectionDistance do
  201.             local checkPos = currentPos + direction * distance
  202.             local rayResult = Workspace:Raycast(checkPos + Vector3.new(0, 5, 0), Vector3.new(0, -50, 0), rayParams)
  203.             if rayResult then
  204.                 local groundHeight = rayResult.Position.Y
  205.                 local heightDifference = currentPos.Y - groundHeight
  206.                 if heightDifference > 0 and heightDifference <= CONFIG.JumpHeight * 1.5 and heightDifference < minDistanceToGround then
  207.                     bestDescentPos = checkPos
  208.                     minDistanceToGround = heightDifference
  209.                 end
  210.             end
  211.         end
  212.     end
  213.     return bestDescentPos
  214. end
  215.  
  216. -- Find a climb position to reach an elevated target
  217. local function findClimbPosition(currentPos, targetPos)
  218.     local rayParams = RaycastParams.new()
  219.     rayParams.FilterType = Enum.RaycastFilterType.Exclude
  220.     rayParams.FilterDescendantsInstances = {Workspace:FindFirstChild("Sentinel") or {}}
  221.     local directions = {
  222.         Vector3.new(1, 0, 0), Vector3.new(-1, 0, 0),
  223.         Vector3.new(0, 0, 1), Vector3.new(0, 0, -1),
  224.         Vector3.new(1, 0, 1).Unit, Vector3.new(-1, 0, -1).Unit,
  225.         Vector3.new(1, 0, -1).Unit, Vector3.new(-1, 0, 1).Unit,
  226.     }
  227.     local bestClimbPos = nil
  228.     local minDistanceToTarget = math.huge
  229.     for _, direction in directions do
  230.         for distance = 0.3, CONFIG.EdgeDetectionDistance, 0.3 do -- Tighter steps
  231.             local checkPos = currentPos + direction * distance
  232.             local rayResult = Workspace:Raycast(checkPos + Vector3.new(0, 50, 0), Vector3.new(0, -100, 0), rayParams)
  233.             if rayResult then
  234.                 local surfacePos = rayResult.Position
  235.                 local heightDifference = targetPos.Y - surfacePos.Y
  236.                 local horizontalDistance = (Vector3.new(targetPos.X, 0, targetPos.Z) - Vector3.new(surfacePos.X, 0, surfacePos.Z)).Magnitude
  237.                 if heightDifference > 0 and heightDifference <= CONFIG.JumpHeight and horizontalDistance < minDistanceToTarget then
  238.                     local isWalkablePoint, _ = isWalkable(surfacePos)
  239.                     if isWalkablePoint then
  240.                         bestClimbPos = surfacePos
  241.                         minDistanceToTarget = horizontalDistance
  242.                     end
  243.                 end
  244.             end
  245.         end
  246.     end
  247.     if not bestClimbPos then
  248.         local closestPos = Vector3.new(targetPos.X, currentPos.Y, targetPos.Z)
  249.         local isWalkablePoint, _ = isWalkable(closestPos)
  250.         if isWalkablePoint then
  251.             -- print("No climb position found, using closest horizontal point: " .. tostring(closestPos))
  252.             return closestPos
  253.         end
  254.     end
  255.     return bestClimbPos
  256. end
  257.  
  258. -- Check if NPC or target is on an elevated surface
  259. local function isElevated(position)
  260.     local rayParams = RaycastParams.new()
  261.     rayParams.FilterType = Enum.RaycastFilterType.Exclude
  262.     rayParams.FilterDescendantsInstances = {Workspace:FindFirstChild("Sentinel") or {}}
  263.     local offsets = {
  264.         Vector3.new(0, 0, 0),
  265.         Vector3.new(0.5, 0, 0), Vector3.new(-0.5, 0, 0),
  266.         Vector3.new(0, 0, 0.5), Vector3.new(0, 0, -0.5),
  267.         Vector3.new(1, 0, 0), Vector3.new(-1, 0, 0),
  268.         Vector3.new(0, 0, 1), Vector3.new(0, 0, -1),
  269.     }
  270.     for _, offset in ipairs(offsets) do
  271.         local rayResult = Workspace:Raycast(position + Vector3.new(0, 5, 0) + offset, Vector3.new(0, -50, 0), rayParams)
  272.         if rayResult then
  273.             local heightDifference = position.Y - rayResult.Position.Y
  274.             -- print("isElevated hit: " .. (rayResult.Instance and rayResult.Instance.Name or "none") .. " at " .. tostring(position))
  275.             return heightDifference > CONFIG.DescentHeightThreshold, rayResult.Instance
  276.         end
  277.     end
  278.     return false, nil
  279. end
  280.  
  281.  
  282. local function getSmoothedTargetPosition(target, lastTargetPos)
  283.     if not target or not target.HumanoidRootPart then
  284.         return lastTargetPos
  285.     end
  286.     local currentPos = target.HumanoidRootPart.Position
  287.     if lastTargetPos then
  288.         return lastTargetPos:Lerp(currentPos, 0.3)
  289.     end
  290.     return currentPos
  291. end
  292.  
  293. -- Spawn a single zombie
  294. function ZombieAI:SpawnZombieAt(zombieSpawnPart)
  295.     if not zombieModel or not zombieSpawnPart then
  296.         warn("Sentinel or ZombieSpawn part not found")
  297.         return
  298.     end
  299.     if spawnedZombies[zombieSpawnPart] then
  300.         warn("Zombie already spawned at " .. zombieSpawnPart.Name)
  301.         return
  302.     end
  303.     local zombie = zombieModel:Clone()
  304.     local humanoid = zombie.Humanoid
  305.     humanoid.WalkSpeed = CONFIG.PatrolSpeed
  306.     humanoid.HipHeight = 2
  307.     humanoid.JumpPower = CONFIG.JumpHeight * 5
  308.     humanoid.AutoJumpEnabled = true
  309.     humanoid.Health = math.random(CONFIG.HealthRange[1], CONFIG.HealthRange[2])
  310.     zombie:SetPrimaryPartCFrame(zombieSpawnPart.CFrame)
  311.     zombie.Parent = Workspace
  312.     spawnedZombies[zombieSpawnPart] = true
  313.     table.insert(activeZombies, zombie)
  314.     local initialPatrolPos = getValidPatrolPosition(zombieSpawnPart.Position, zombie.HumanoidRootPart.Position, zombie)
  315.     coroutine.wrap(function()
  316.         self:StartZombieAI(zombie, zombieSpawnPart, initialPatrolPos)
  317.     end)()
  318. end
  319.  
  320. -- AI behavior
  321. function ZombieAI:StartZombieAI(zombie, zombieSpawnPart, initialPatrolPos)
  322.     local humanoid = zombie.Humanoid
  323.     local rootPart = zombie.HumanoidRootPart
  324.     local spawnPos = zombieSpawnPart.Position
  325.     if not spawnPos then
  326.         warn("StartZombieAI: Invalid spawnPos")
  327.         return
  328.     end
  329.     local currentTarget = nil
  330.     local returningToSpawn = false
  331.     local lastPatrolPos = initialPatrolPos
  332.     local patrolTimer = 0
  333.     local lastPosition = rootPart.Position
  334.     local stuckTimer = 0
  335.     local stuckCount = 0
  336.     local descentTarget = nil
  337.     local climbTarget = nil
  338.     local losTimer = nil
  339.     local lastPatrolChangeTime = 0
  340.     local currentWaypointIndex = 1
  341.     local cachedWaypoints = nil
  342.     local lastJumpTime = 0
  343.     local jumpState = { shouldJump = false, reason = nil }
  344.     local lastTargetPos = nil
  345.     local lastPathUpdate = 0
  346.     local jumpAttempts = 0
  347.  
  348.     local function resetJumpState()
  349.         jumpState.shouldJump = false
  350.         jumpState.reason = nil
  351.         humanoid.Jump = false
  352.         lastJumpTime = 0
  353.         jumpAttempts = 0
  354.     end
  355.  
  356.     local function handleDeath()
  357.         local index = table.find(activeZombies, zombie)
  358.         if index then
  359.             table.remove(activeZombies, index)
  360.         end
  361.         for spawnPart, _ in spawnedZombies do
  362.             if (spawnPart.Position - spawnPos).Magnitude < 1 then
  363.                 spawnedZombies[spawnPart] = nil
  364.                 break
  365.             end
  366.         end
  367.         NPCSound:StopSounds(zombie)
  368.         zombie:Destroy()
  369.         self:SpawnZombieAt(zombieSpawnPart)
  370.     end
  371.  
  372.     humanoid.Died:Connect(handleDeath)
  373.  
  374.     while humanoid.Health > 0 do
  375.         -- Reset jump state at start of loop
  376.         if not (climbTarget or descentTarget) then
  377.             resetJumpState()
  378.         end
  379.  
  380.         if isWater(rootPart.Position) then
  381.             local explosion = Instance.new("Explosion")
  382.             explosion.Position = rootPart.Position
  383.             explosion.BlastRadius = CONFIG.ExplosionRadius
  384.             explosion.BlastPressure = 20
  385.             explosion.DestroyJointRadiusPercent = 0
  386.             explosion.ExplosionType = Enum.ExplosionType.NoCraters
  387.             explosion.Parent = Workspace
  388.             explosion.Hit:Connect(function(part)
  389.                 local playerHumanoid = part.Parent:FindFirstChildOfClass("Humanoid")
  390.                 if playerHumanoid and playerHumanoid ~= humanoid then
  391.                     local distance = (part.Position - explosion.Position).Magnitude
  392.                     if distance <= CONFIG.ExplosionRadius then
  393.                         playerHumanoid:TakeDamage(playerHumanoid.Health * CONFIG.ExplosionDamagePercent)
  394.                     end
  395.                 end
  396.             end)
  397.             NPCSound:StopSounds(zombie)
  398.             handleDeath()
  399.             break
  400.         end
  401.  
  402.         -- Check if NPC is on an elevated surface
  403.         local isElevatedFlag, hitInstance = isElevated(rootPart.Position)
  404.         local onMushroom2 = hitInstance and hitInstance.Name == "Mushroom2"
  405.         local onTree = hitInstance and string.find(hitInstance.Name:lower(), "tree") ~= nil
  406.         local onAvoidableModel = onMushroom2 or onTree
  407.  
  408.         -- Immediate descent if on tree or Mushroom2
  409.         if onAvoidableModel and not descentTarget then
  410.             descentTarget = findDescentPosition(rootPart.Position)
  411.             if descentTarget then
  412.                 local direction = (descentTarget - rootPart.Position).Unit * humanoid.WalkSpeed
  413.                 humanoid:Move(direction, false)
  414.                 jumpState.shouldJump = true
  415.                 jumpState.reason = "Descent from " .. (onMushroom2 and "Mushroom2" or "tree")
  416.                 -- print("Descending from " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. " to " .. tostring(descentTarget))
  417.                 if (rootPart.Position - descentTarget).Magnitude < 1 then
  418.                     descentTarget = nil
  419.                     resetJumpState()
  420.                 end
  421.                 lastPosition = rootPart.Position
  422.                 wait(0.05)
  423.                 continue
  424.             end
  425.         end
  426.  
  427.         -- Target selection
  428.         if not currentTarget then
  429.             for _, player in game.Players:GetPlayers() do
  430.                 if player.Character and player.Character.Humanoid and player.Character.Humanoid.Health > 0 then
  431.                     local distance = (player.Character.HumanoidRootPart.Position - rootPart.Position).Magnitude
  432.                     local inLOS = distance <= CONFIG.MaxLOSDistance and hasLineOfSight(zombie, player.Character)
  433.                     if distance <= CONFIG.DetectionRange or inLOS then
  434.                         local targetPlayer = player.Character
  435.                         if inLOS and not losTimer then
  436.                             losTimer = task.delay(0.3, function()
  437.                                 if targetPlayer and targetPlayer.Humanoid and targetPlayer.Humanoid.Health > 0 and hasLineOfSight(zombie, targetPlayer) then
  438.                                     currentTarget = targetPlayer
  439.                                     humanoid.WalkSpeed = CONFIG.ChaseSpeed
  440.                                     returningToSpawn = false
  441.                                     lastPatrolPos = nil
  442.                                     patrolTimer = 0
  443.                                     stuckTimer = 0
  444.                                     stuckCount = 0
  445.                                     descentTarget = nil
  446.                                     climbTarget = nil
  447.                                     currentWaypointIndex = 1
  448.                                     cachedWaypoints = nil
  449.                                     lastTargetPos = nil
  450.                                     lastPathUpdate = 0
  451.                                     resetJumpState()
  452.                                     NPCSound:PlayChaseSound(zombie)
  453.                                     -- print("Chasing player (LOS) at distance: " .. distance)
  454.                                 end
  455.                                 losTimer = nil
  456.                             end)
  457.                         elseif distance <= CONFIG.DetectionRange then
  458.                             currentTarget = targetPlayer
  459.                             humanoid.WalkSpeed = CONFIG.ChaseSpeed
  460.                             returningToSpawn = false
  461.                             lastPatrolPos = nil
  462.                             patrolTimer = 0
  463.                             stuckTimer = 0
  464.                             stuckCount = 0
  465.                             descentTarget = nil
  466.                             climbTarget = nil
  467.                             currentWaypointIndex = 1
  468.                             cachedWaypoints = nil
  469.                             lastTargetPos = nil
  470.                             lastPathUpdate = 0
  471.                             resetJumpState()
  472.                             NPCSound:PlayChaseSound(zombie)
  473.                             -- print("Chasing player (distance) at distance: " .. distance)
  474.                         end
  475.                         break
  476.                     end
  477.                 end
  478.             end
  479.         end
  480.  
  481.         if currentTarget then
  482.             local targetPos = getSmoothedTargetPosition(currentTarget, lastTargetPos)
  483.             lastTargetPos = targetPos
  484.             local distanceToTarget = (targetPos - rootPart.Position).Magnitude
  485.             local targetInWater = isWater(targetPos)
  486.  
  487.             if not currentTarget.Humanoid or currentTarget.Humanoid.Health <= 0 or distanceToTarget > CONFIG.ChaseAbandonRange or targetInWater then
  488.                 currentTarget = nil
  489.                 humanoid.WalkSpeed = CONFIG.PatrolSpeed
  490.                 humanoid:Move(Vector3.new(0, 0, 0), false)
  491.                 returningToSpawn = true
  492.                 lastPatrolPos = nil
  493.                 patrolTimer = 0
  494.                 stuckTimer = 0
  495.                 stuckCount = 0
  496.                 descentTarget = nil
  497.                 climbTarget = nil
  498.                 losTimer = nil
  499.                 currentWaypointIndex = 1
  500.                 cachedWaypoints = nil
  501.                 lastTargetPos = nil
  502.                 lastPathUpdate = 0
  503.                 resetJumpState()
  504.                 NPCSound:StopSounds(zombie)
  505.                 -- print("Stopped chasing, transitioning to patrol")
  506.             else
  507.                 -- Check if target is elevated
  508.                 local isTargetElevated, targetInstance = isElevated(targetPos)
  509.                 local effectiveTargetPos = targetPos
  510.                 if isTargetElevated then
  511.                     local rayParams = RaycastParams.new()
  512.                     rayParams.FilterType = Enum.RaycastFilterType.Exclude
  513.                     rayParams.FilterDescendantsInstances = {Workspace:FindFirstChild("Sentinel") or {}}
  514.                     local offsets = {
  515.                         Vector3.new(0, 0, 0),
  516.                         Vector3.new(1, 0, 0), Vector3.new(-1, 0, 0),
  517.                         Vector3.new(0, 0, 1), Vector3.new(0, 0, -1),
  518.                         Vector3.new(0.5, 0, 0.5), Vector3.new(-0.5, 0, -0.5),
  519.                     }
  520.                     for _, offset in ipairs(offsets) do
  521.                         local rayResult = Workspace:Raycast(targetPos + Vector3.new(0, 5, 0) + offset, Vector3.new(0, -50, 0), rayParams)
  522.                         if rayResult then
  523.                             effectiveTargetPos = Vector3.new(targetPos.X, rayResult.Position.Y + 0.1, targetPos.Z)
  524.                             -- print("Target is elevated, pathfinding to base: " .. tostring(effectiveTargetPos))
  525.                             break
  526.                         end
  527.                     end
  528.                 end
  529.  
  530.                 -- Handle climbing to reach elevated target
  531.                 if isTargetElevated and not climbTarget then
  532.                     climbTarget = findClimbPosition(rootPart.Position, targetPos)
  533.                     if climbTarget then
  534.                         local direction = (climbTarget - rootPart.Position).Unit * humanoid.WalkSpeed
  535.                         humanoid:Move(direction, false)
  536.                         local heightDifference = targetPos.Y - rootPart.Position.Y
  537.                         if heightDifference > CONFIG.JumpHeightThreshold and heightDifference <= CONFIG.JumpHeight and tick() - lastJumpTime >= CONFIG.JumpCooldown and (rootPart.Position - climbTarget).Magnitude < 4 then
  538.                             jumpState.shouldJump = true
  539.                             jumpState.reason = "Climbing to crate"
  540.                             -- print("Climbing to " .. tostring(climbTarget) .. ", height: " .. heightDifference)
  541.                         end
  542.                         if (rootPart.Position - climbTarget).Magnitude < 4 then
  543.                             climbTarget = nil
  544.                             resetJumpState()
  545.                         end
  546.                         lastPosition = rootPart.Position
  547.                         wait(0.05)
  548.                         continue
  549.                     end
  550.                 end
  551.  
  552.                 -- Validate effectiveTargetPos
  553.                 local isValidTarget, _ = isWalkable(effectiveTargetPos)
  554.                 if not isValidTarget then
  555.                     effectiveTargetPos = Vector3.new(effectiveTargetPos.X, rootPart.Position.Y, effectiveTargetPos.Z)
  556.                 end
  557.  
  558.                 -- Update path less frequently
  559.                 if tick() - lastPathUpdate >= 0.5 then
  560.                     local path = PathfindingService:CreatePath()
  561.                     path:ComputeAsync(rootPart.Position, effectiveTargetPos)
  562.                     if path.Status == Enum.PathStatus.Success then
  563.                         local waypoints = path:GetWaypoints()
  564.                         if #waypoints > 1 then
  565.                             local nextWaypoint = waypoints[2].Position
  566.                             local isWaypointElevated, waypointInstance = isElevated(nextWaypoint)
  567.                             local isWaypointOnMushroom2 = waypointInstance and waypointInstance.Name == "Mushroom2"
  568.                             local isWaypointOnTree = waypointInstance and string.find(waypointInstance.Name:lower(), "tree") ~= nil
  569.                             local isWaypointOnAvoidableModel = isWaypointOnMushroom2 or isWaypointOnTree
  570.                             -- print("Chase waypoint at " .. tostring(nextWaypoint) .. ": Mushroom2=" .. tostring(isWaypointOnMushroom2) .. ", Tree=" .. tostring(isWaypointOnTree))
  571.                             if isWaypointOnAvoidableModel then
  572.                                 currentWaypointIndex = 1
  573.                                 path:ComputeAsync(rootPart.Position, effectiveTargetPos)
  574.                                 waypoints = path:GetWaypoints()
  575.                                 if #waypoints > 1 then
  576.                                     nextWaypoint = waypoints[2].Position
  577.                                 else
  578.                                     humanoid:Move(Vector3.new(0, 0, 0), false)
  579.                                     stuckTimer = 0
  580.                                     stuckCount = 0
  581.                                     resetJumpState()
  582.                                     -- print("No valid chase waypoints after skipping " .. (isWaypointOnMushroom2 and "Mushroom2" or "tree model " .. (waypointInstance and waypointInstance.Name or "unknown")))
  583.                                     wait(0.05)
  584.                                     continue
  585.                                 end
  586.                                 -- print("Walking around " .. (isWaypointOnMushroom2 and "Mushroom2" or "tree model " .. (waypointInstance and waypointInstance.Name or "unknown")) .. " in chase")
  587.                             end
  588.  
  589.                             if not isWater(nextWaypoint) and isPathClear(rootPart.Position, nextWaypoint, zombie) then
  590.                                 local heightDifferenceToTarget = targetPos.Y - rootPart.Position.Y
  591.                                 if heightDifferenceToTarget > CONFIG.DescentHeightThreshold and not descentTarget and not climbTarget then
  592.                                     descentTarget = findDescentPosition(rootPart.Position)
  593.                                     if descentTarget then
  594.                                         local direction = (descentTarget - rootPart.Position).Unit * humanoid.WalkSpeed
  595.                                         humanoid:Move(direction, false)
  596.                                         jumpState.shouldJump = true
  597.                                         jumpState.reason = "Descent during chase"
  598.                                         if (rootPart.Position - descentTarget).Magnitude < 1 then
  599.                                             descentTarget = nil
  600.                                             resetJumpState()
  601.                                         end
  602.                                         lastPosition = rootPart.Position
  603.                                         wait(0.05)
  604.                                         continue
  605.                                     end
  606.                                 else
  607.                                     descentTarget = nil
  608.                                 end
  609.  
  610.                                 local heightDifference = nextWaypoint.Y - rootPart.Position.Y
  611.                                 local isWalkableWaypoint, slopeAngle = isWalkable(nextWaypoint)
  612.                                 local hasObstacleFlag, obstaclePos = hasObstacle(rootPart.Position, nextWaypoint, zombie)
  613.                                 if ((heightDifference > CONFIG.JumpHeightThreshold and heightDifference <= CONFIG.JumpHeight) or (isWalkableWaypoint and slopeAngle > 45)) and hasObstacleFlag and tick() - lastJumpTime >= CONFIG.JumpCooldown then
  614.                                     jumpState.shouldJump = true
  615.                                     jumpState.reason = "Terrain navigation (height: " .. heightDifference .. ", slope: " .. slopeAngle .. ", obstacle at: " .. tostring(obstaclePos) .. ")"
  616.                                     -- print("Jump triggered during chase at " .. tostring(nextWaypoint) .. ", slope: " .. slopeAngle .. ", height: " .. heightDifference .. ", obstacle at: " .. tostring(obstaclePos))
  617.                                 end
  618.                                 local movedDistance = (rootPart.Position - lastPosition).Magnitude
  619.                                 if movedDistance < CONFIG.JumpMovementThreshold then
  620.                                     stuckTimer = stuckTimer + 0.05
  621.                                     if stuckTimer >= CONFIG.JumpStuckTimeout then
  622.                                         stuckCount = stuckCount + 1
  623.                                         if stuckCount >= 12 and onAvoidableModel then
  624.                                             descentTarget = findDescentPosition(rootPart.Position)
  625.                                             if descentTarget then
  626.                                                 -- print("Stuck on " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. ", forcing descent")
  627.                                             else
  628.                                                 currentWaypointIndex = 1
  629.                                                 path:ComputeAsync(rootPart.Position, effectiveTargetPos)
  630.                                                 -- print("Stuck on " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. ", recomputing path")
  631.                                             end
  632.                                             stuckTimer = 0
  633.                                             stuckCount = 0
  634.                                             resetJumpState()
  635.                                         elseif stuckCount >= 12 and hasObstacleFlag then
  636.                                             currentWaypointIndex = 1
  637.                                             path:ComputeAsync(rootPart.Position, effectiveTargetPos)
  638.                                             -- print("NPC stuck during chase at " .. tostring(rootPart.Position) .. ", recomputing path")
  639.                                             stuckTimer = 0
  640.                                             stuckCount = 0
  641.                                             if tick() - lastJumpTime >= CONFIG.JumpCooldown then
  642.                                                 jumpState.shouldJump = true
  643.                                                 jumpState.reason = "Stuck during chase (obstacle at: " .. tostring(obstaclePos) .. ")"
  644.                                                 -- print("Jump triggered due to stuck during chase at " .. tostring(nextWaypoint) .. ", obstacle at: " .. tostring(obstaclePos))
  645.                                             end
  646.                                         else
  647.                                             stuckTimer = 0
  648.                                         end
  649.                                     end
  650.                                 else
  651.                                     stuckTimer = 0
  652.                                     stuckCount = 0
  653.                                 end
  654.                                 local direction = (nextWaypoint - rootPart.Position).Unit * humanoid.WalkSpeed
  655.                                 humanoid:Move(direction, false)
  656.                             end
  657.                         else
  658.                             humanoid:Move(Vector3.new(0, 0, 0), false)
  659.                             stuckTimer = 0
  660.                             stuckCount = 0
  661.                             resetJumpState()
  662.                         end
  663.                         lastPathUpdate = tick()
  664.                     else
  665.                         -- Try alternative positions
  666.                         local offsets = {
  667.                             Vector3.new(5, 0, 0), Vector3.new(-5, 0, 0),
  668.                             Vector3.new(0, 0, 5), Vector3.new(0, 0, -5),
  669.                             Vector3.new(3, 0, 3), Vector3.new(-3, 0, -3),
  670.                             Vector3.new(7, 0, 0), Vector3.new(-7, 0, 0),
  671.                             Vector3.new(0, 0, 7), Vector3.new(0, 0, -7),
  672.                         }
  673.                         local pathFound = false
  674.                         for _, offset in ipairs(offsets) do
  675.                             local altPos = effectiveTargetPos + offset
  676.                             if isWalkable(altPos) then
  677.                                 path:ComputeAsync(rootPart.Position, altPos)
  678.                                 if path.Status == Enum.PathStatus.Success then
  679.                                     local waypoints = path:GetWaypoints()
  680.                                     if #waypoints > 1 then
  681.                                         local nextWaypoint = waypoints[2].Position
  682.                                         local direction = (nextWaypoint - rootPart.Position).Unit * humanoid.WalkSpeed
  683.                                         humanoid:Move(direction, false)
  684.                                         -- print("Path failed to " .. tostring(effectiveTargetPos) .. ", using alternative: " .. tostring(altPos))
  685.                                         pathFound = true
  686.                                         lastPathUpdate = tick()
  687.                                         break
  688.                                     end
  689.                                 end
  690.                             end
  691.                         end
  692.                         if not pathFound then
  693.                             local horizontalTargetPos = Vector3.new(targetPos.X, rootPart.Position.Y, targetPos.Z)
  694.                             local direction = (horizontalTargetPos - rootPart.Position).Unit * humanoid.WalkSpeed
  695.                             if (horizontalTargetPos - rootPart.Position).Magnitude > 4 then
  696.                                 humanoid:Move(direction, false)
  697.                                 -- print("Path failed to " .. tostring(effectiveTargetPos) .. ", moving to horizontal: " .. tostring(horizontalTargetPos))
  698.                             else
  699.                                 if tick() - lastJumpTime >= CONFIG.JumpCooldown and jumpAttempts < 3 then
  700.                                     jumpState.shouldJump = true
  701.                                     jumpState.reason = "Path failed, attempting jump (attempt " .. (jumpAttempts + 1) .. ")"
  702.                                     jumpAttempts = jumpAttempts + 1
  703.                                     -- print("Path failed, attempting jump at " .. tostring(rootPart.Position) .. ", attempt " .. jumpAttempts)
  704.                                 end
  705.                             end
  706.                             stuckTimer = 0
  707.                             stuckCount = 0
  708.                         end
  709.                     end
  710.                 end
  711.                 if distanceToTarget <= 5 then
  712.                     currentTarget.Humanoid:TakeDamage(CONFIG.AttackDamage)
  713.                 end
  714.             end
  715.         end
  716.  
  717.         if not currentTarget then
  718.             local distanceToSpawn = (rootPart.Position - spawnPos).Magnitude
  719.             if returningToSpawn and distanceToSpawn > CONFIG.ReturnDistanceThreshold then
  720.                 if tick() - lastPathUpdate >= 0.5 then
  721.                     local path = PathfindingService:CreatePath()
  722.                     path:ComputeAsync(rootPart.Position, spawnPos)
  723.                     if path.Status == Enum.PathStatus.Success then
  724.                         local waypoints = path:GetWaypoints()
  725.                         if #waypoints > 1 then
  726.                             local nextWaypoint = waypoints[2].Position
  727.                             local isWaypointElevated, waypointInstance = isElevated(nextWaypoint)
  728.                             local isWaypointOnMushroom2 = waypointInstance and waypointInstance.Name == "Mushroom2"
  729.                             local isWaypointOnTree = waypointInstance and string.find(waypointInstance.Name:lower(), "tree") ~= nil
  730.                             local isWaypointOnAvoidableModel = isWaypointOnMushroom2 or isWaypointOnTree
  731.                             -- print("Return waypoint at " .. tostring(nextWaypoint) .. ": Mushroom2=" .. tostring(isWaypointOnMushroom2) .. ", Tree=" .. tostring(isWaypointOnTree))
  732.                             if isWaypointOnAvoidableModel then
  733.                                 currentWaypointIndex = 1
  734.                                 path:ComputeAsync(rootPart.Position, spawnPos)
  735.                                 waypoints = path:GetWaypoints()
  736.                                 if #waypoints > 1 then
  737.                                     nextWaypoint = waypoints[2].Position
  738.                                 else
  739.                                     humanoid:Move(Vector3.new(0, 0, 0), false)
  740.                                     stuckTimer = 0
  741.                                     stuckCount = 0
  742.                                     resetJumpState()
  743.                                     -- print("No valid return waypoints after skipping " .. (isWaypointOnMushroom2 and "Mushroom2" or "tree model " .. (waypointInstance and waypointInstance.Name or "unknown")))
  744.                                     wait(0.05)
  745.                                     continue
  746.                                 end
  747.                                 -- print("Walking around " .. (isWaypointOnMushroom2 and "Mushroom2" or "tree model " .. (waypointInstance and waypointInstance.Name or "unknown")) .. " in return")
  748.                             end
  749.                             if not isWater(nextWaypoint) and isPathClear(rootPart.Position, nextWaypoint, zombie) then
  750.                                 local heightDifferenceToSpawn = rootPart.Position.Y - spawnPos.Y
  751.                                 if heightDifferenceToSpawn > CONFIG.DescentHeightThreshold and not descentTarget then
  752.                                     descentTarget = findDescentPosition(rootPart.Position)
  753.                                     if descentTarget then
  754.                                         local direction = (descentTarget - rootPart.Position).Unit * CONFIG.PatrolSpeed
  755.                                         humanoid:Move(direction, false)
  756.                                         jumpState.shouldJump = true
  757.                                         jumpState.reason = "Descent during return"
  758.                                         if (rootPart.Position - descentTarget).Magnitude < 1 then
  759.                                             descentTarget = nil
  760.                                             resetJumpState()
  761.                                         end
  762.                                         lastPosition = rootPart.Position
  763.                                         wait(0.05)
  764.                                         continue
  765.                                     end
  766.                                 else
  767.                                     descentTarget = nil
  768.                                 end
  769.                                 local heightDifference = nextWaypoint.Y - rootPart.Position.Y
  770.                                 local isWalkableWaypoint, slopeAngle = isWalkable(nextWaypoint)
  771.                                 local hasObstacleFlag, obstaclePos = hasObstacle(rootPart.Position, nextWaypoint, zombie)
  772.                                 if ((heightDifference > 7 and heightDifference <= CONFIG.JumpHeight) or (isWalkableWaypoint and slopeAngle > 50)) and hasObstacleFlag and tick() - lastJumpTime >= CONFIG.PatrolJumpCooldown then
  773.                                     jumpState.shouldJump = true
  774.                                     jumpState.reason = "Terrain navigation in return (height: " .. heightDifference .. ", slope: " .. slopeAngle .. ", obstacle at: " .. tostring(obstaclePos) .. ")"
  775.                                     -- print("Jump triggered during return at " .. tostring(nextWaypoint) .. ", slope: " .. slopeAngle .. ", height: " .. heightDifference .. ", obstacle at: " .. tostring(obstaclePos))
  776.                                 end
  777.                                 local movedDistance = (rootPart.Position - lastPosition).Magnitude
  778.                                 if movedDistance < CONFIG.JumpMovementThreshold then
  779.                                     stuckTimer = stuckTimer + 0.05
  780.                                     if stuckTimer >= CONFIG.JumpStuckTimeout then
  781.                                         stuckCount = stuckCount + 1
  782.                                         if stuckCount >= 15 and onAvoidableModel then
  783.                                             descentTarget = findDescentPosition(rootPart.Position)
  784.                                             if descentTarget then
  785.                                                 -- print("Stuck on " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. ", forcing descent")
  786.                                             else
  787.                                                 currentWaypointIndex = 1
  788.                                                 path:ComputeAsync(rootPart.Position, spawnPos)
  789.                                                 -- print("Stuck on " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. ", recomputing path")
  790.                                             end
  791.                                             stuckTimer = 0
  792.                                             stuckCount = 0
  793.                                             resetJumpState()
  794.                                         elseif stuckCount >= 15 and hasObstacleFlag then
  795.                                             currentWaypointIndex = 1
  796.                                             path:ComputeAsync(rootPart.Position, spawnPos)
  797.                                             -- print("NPC stuck returning to spawn at " .. tostring(rootPart.Position))
  798.                                             stuckTimer = 0
  799.                                             stuckCount = 0
  800.                                             if tick() - lastJumpTime >= CONFIG.PatrolJumpCooldown then
  801.                                                 jumpState.shouldJump = true
  802.                                                 jumpState.reason = "Stuck during return (obstacle at: " .. tostring(obstaclePos) .. ")"
  803.                                                 -- print("Jump triggered due to stuck during return at " .. tostring(nextWaypoint) .. ", obstacle at: " .. tostring(obstaclePos))
  804.                                             end
  805.                                         else
  806.                                             stuckTimer = 0
  807.                                         end
  808.                                     end
  809.                                 else
  810.                                     stuckTimer = 0
  811.                                     stuckCount = 0
  812.                                 end
  813.                                 local direction = (nextWaypoint - rootPart.Position).Unit * CONFIG.PatrolSpeed
  814.                                 humanoid:Move(direction, false)
  815.                             end
  816.                         else
  817.                             humanoid:Move(Vector3.new(0, 0, 0), false)
  818.                             stuckTimer = 0
  819.                             stuckCount = 0
  820.                             resetJumpState()
  821.                         end
  822.                         lastPathUpdate = tick()
  823.                     else
  824.                         humanoid:Move(Vector3.new(0, 0, 0), false)
  825.                         stuckTimer = 0
  826.                         stuckCount = 0
  827.                         resetJumpState()
  828.                         warn("Path computation failed for zombie returning to spawn at " .. tostring(spawnPos))
  829.                     end
  830.                 end
  831.                 lastPatrolPos = nil
  832.                 patrolTimer = 0
  833.                 currentWaypointIndex = 1
  834.                 cachedWaypoints = nil
  835.                 stuckCount = 0
  836.             else
  837.                 returningToSpawn = false
  838.                 patrolTimer = patrolTimer + 0.05
  839.                 stuckTimer = 0
  840.                 descentTarget = nil
  841.                 climbTarget = nil
  842.                 resetJumpState()
  843.                 NPCSound:PlayPatrolSound(zombie)
  844.  
  845.                 if not lastPatrolPos or patrolTimer >= CONFIG.PatrolTimeout then
  846.                     local currentTime = tick()
  847.                     if not lastPatrolPos or currentTime - lastPatrolChangeTime >= CONFIG.MinPatrolPointDelay then
  848.                         lastPatrolPos = getValidPatrolPosition(spawnPos, rootPart.Position, zombie)
  849.                         patrolTimer = 0
  850.                         lastPatrolChangeTime = currentTime
  851.                         currentWaypointIndex = 1
  852.                         cachedWaypoints = nil
  853.                         stuckCount = 0
  854.                         resetJumpState()
  855.                         -- print("New patrol point selected: " .. tostring(lastPatrolPos))
  856.                     end
  857.                 end
  858.                 if lastPatrolPos then
  859.                     if not cachedWaypoints or tick() - lastPathUpdate >= 0.5 then
  860.                         local path = PathfindingService:CreatePath()
  861.                         path:ComputeAsync(rootPart.Position, lastPatrolPos)
  862.                         if path.Status == Enum.PathStatus.Success then
  863.                             cachedWaypoints = path:GetWaypoints()
  864.                             -- print("Path computed with " .. #cachedWaypoints .. " waypoints")
  865.                             lastPathUpdate = tick()
  866.                         else
  867.                             -- print("Path computation failed for patrol to " .. tostring(lastPatrolPos) .. ", selecting new point")
  868.                             lastPatrolPos = getValidPatrolPosition(spawnPos, rootPart.Position, zombie)
  869.                             currentWaypointIndex = 1
  870.                             cachedWaypoints = nil
  871.                             humanoid:Move(Vector3.new(0, 0, 0), false)
  872.                             resetJumpState()
  873.                             wait(0.05)
  874.                             continue
  875.                         end
  876.                     end
  877.                     if cachedWaypoints and #cachedWaypoints > currentWaypointIndex then
  878.                         local nextWaypoint = cachedWaypoints[currentWaypointIndex].Position
  879.                         local isWaypointElevated, waypointInstance = isElevated(nextWaypoint)
  880.                         local isWaypointOnMushroom2 = waypointInstance and waypointInstance.Name == "Mushroom2"
  881.                         local isWaypointOnTree = waypointInstance and string.find(waypointInstance.Name:lower(), "tree") ~= nil
  882.                         local isWaypointOnAvoidableModel = isWaypointOnMushroom2 or isWaypointOnTree
  883.                         -- print("Patrol waypoint at " .. tostring(nextWaypoint) .. ": Mushroom2=" .. tostring(isWaypointOnMushroom2) .. ", Tree=" .. tostring(isWaypointOnTree))
  884.                         if isWaypointOnAvoidableModel then
  885.                             -- print("Avoiding " .. (isWaypointOnMushroom2 and "Mushroom2" or "tree model " .. (waypointInstance and waypointInstance.Name or "unknown")) .. " in patrol")
  886.                             lastPatrolPos = getValidPatrolPosition(spawnPos, rootPart.Position, zombie)
  887.                             currentWaypointIndex = 1
  888.                             cachedWaypoints = nil
  889.                             resetJumpState()
  890.                             wait(0.05)
  891.                             continue
  892.                         end
  893.                         if not isWater(nextWaypoint) and (nextWaypoint - rootPart.Position).Magnitude >= CONFIG.MinWaypointDistance and isPathClear(rootPart.Position, nextWaypoint, zombie) then
  894.                             local heightDifference = nextWaypoint.Y - rootPart.Position.Y
  895.                             local isWalkableWaypoint, slopeAngle = isWalkable(nextWaypoint)
  896.                             local hasObstacleFlag, obstaclePos = hasObstacle(rootPart.Position, nextWaypoint, zombie)
  897.                             if ((heightDifference > 7 and heightDifference <= CONFIG.JumpHeight) or (isWalkableWaypoint and slopeAngle > 50)) and hasObstacleFlag and tick() - lastJumpTime >= CONFIG.PatrolJumpCooldown then
  898.                                 jumpState.shouldJump = true
  899.                                 jumpState.reason = "Terrain navigation in patrol (height: " .. heightDifference .. ", slope: " .. slopeAngle .. ", obstacle at: " .. tostring(obstaclePos) .. ")"
  900.                                 -- print("Jump triggered during patrol at " .. tostring(nextWaypoint) .. ", slope: " .. slopeAngle .. ", height: " .. heightDifference .. ", obstacle at: " .. tostring(obstaclePos))
  901.                             end
  902.                             local movedDistance = (rootPart.Position - lastPosition).Magnitude
  903.                             if movedDistance < CONFIG.JumpMovementThreshold then
  904.                                 stuckTimer = stuckTimer + 0.05
  905.                                 if stuckTimer >= CONFIG.JumpStuckTimeout then
  906.                                     stuckCount = stuckCount + 1
  907.                                     if stuckCount >= 15 and onAvoidableModel then
  908.                                         descentTarget = findDescentPosition(rootPart.Position)
  909.                                         if descentTarget then
  910.                                             -- print("Stuck on " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. ", forcing descent")
  911.                                         else
  912.                                             lastPatrolPos = getValidPatrolPosition(spawnPos, rootPart.Position, zombie)
  913.                                             currentWaypointIndex = 1
  914.                                             cachedWaypoints = nil
  915.                                             -- print("Stuck on " .. (onMushroom2 and "Mushroom2" or "tree model " .. (hitInstance and hitInstance.Name or "unknown")) .. ", resetting patrol point")
  916.                                         end
  917.                                         stuckTimer = 0
  918.                                         stuckCount = 0
  919.                                         resetJumpState()
  920.                                     elseif stuckCount >= 15 and hasObstacleFlag then
  921.                                         lastPatrolPos = getValidPatrolPosition(spawnPos, rootPart.Position, zombie)
  922.                                         currentWaypointIndex = 1
  923.                                         cachedWaypoints = nil
  924.                                         stuckTimer = 0
  925.                                         stuckCount = 0
  926.                                         if tick() - lastJumpTime >= CONFIG.PatrolJumpCooldown then
  927.                                             jumpState.shouldJump = true
  928.                                             jumpState.reason = "Stuck during patrol (obstacle at: " .. tostring(obstaclePos) .. ")"
  929.                                             -- print("NPC stuck during patrol at " .. tostring(rootPart.Position) .. ", resetting patrol point, obstacle at: " .. tostring(obstaclePos))
  930.                                         end
  931.                                     else
  932.                                         stuckTimer = 0
  933.                                     end
  934.                                 end
  935.                             else
  936.                                 stuckTimer = 0
  937.                                 stuckCount = 0
  938.                             end
  939.                             local direction = (nextWaypoint - rootPart.Position).Unit * CONFIG.PatrolSpeed
  940.                             humanoid:Move(direction, false)
  941.                             if (rootPart.Position - nextWaypoint).Magnitude < 3 then
  942.                                 currentWaypointIndex = currentWaypointIndex + 1
  943.                                 -- print("Moving to patrol waypoint " .. currentWaypointIndex .. " at " .. tostring(nextWaypoint))
  944.                             end
  945.                         else
  946.                             currentWaypointIndex = currentWaypointIndex + 1
  947.                             -- print("Skipping patrol waypoint " .. currentWaypointIndex .. " at " .. tostring(nextWaypoint) .. " (invalid, too close, or obstructed)")
  948.                         end
  949.                     else
  950.                         if lastPatrolPos and (rootPart.Position - lastPatrolPos).Magnitude < 3 then
  951.                             lastPatrolPos = nil
  952.                             patrolTimer = CONFIG.PatrolTimeout
  953.                             currentWaypointIndex = 1
  954.                             cachedWaypoints = nil
  955.                             stuckCount = 0
  956.                             resetJumpState()
  957.                             -- print("Reached patrol point, resetting")
  958.                         end
  959.                     end
  960.                 else
  961.                     humanoid:Move(Vector3.new(0, 0, 0), false)
  962.                     resetJumpState()
  963.                 end
  964.             end
  965.         end
  966.  
  967.         -- Apply jump state
  968.         if jumpState.shouldJump and tick() - lastJumpTime >= (currentTarget and CONFIG.JumpCooldown or CONFIG.PatrolJumpCooldown) then
  969.             humanoid.Jump = true
  970.             lastJumpTime = tick()
  971.             -- print("Applying jump: " .. (jumpState.reason or "Unknown reason") .. " at " .. tostring(rootPart.Position))
  972.         else
  973.             humanoid.Jump = false
  974.         end
  975.  
  976.         lastPosition = rootPart.Position
  977.         wait(0.05)
  978.     end
  979. end
  980.  
  981. function ZombieAI:Init()
  982.     local zombieSpawnParts = {}
  983.     for _, part in Workspace:GetChildren() do
  984.         if part.Name == "ZombieSpawn" then
  985.             table.insert(zombieSpawnParts, part)
  986.         end
  987.     end
  988.     spawnedZombies = {}
  989.     activeZombies = {}
  990.     for _, zombieSpawnPart in zombieSpawnParts do
  991.         self:SpawnZombieAt(zombieSpawnPart)
  992.     end
  993. end
  994.  
  995. return ZombieAI
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement