Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- --[[
- ZombieController.server.lua
- Drop this Server Script inside ServerScriptService.
- ✅ What this does
- - Spawns & controls Zombies whose TEMPLATES live in ReplicatedStorage.
- - Handles pathfinding, target acquisition, line-of-sight, and attacks (melee/shooter/explosive).
- - Safely loads animations (no hard errors if an AnimationId is invalid or private).
- - Keeps logic server-authoritative (sets network ownership to the server).
- 🧩 Folder structure (expected)
- ReplicatedStorage
- └─ ZOMBIE_TEMPLATES -- <== your NPC templates live here (models with Humanoid)
- ├─ Bomber
- ├─ Clown
- ├─ Cowboy
- ├─ Cop
- ├─ Crow
- └─ Chef
- Workspace
- └─ ZOMBIES -- <== where live NPC instances will be parented (auto-created)
- 💡 Notes
- - Works if you manually place NPCs into Workspace/ZOMBIES as well (they will be controlled).
- - If your templates already exist in Workspace/ZOMBIES, controller will just attach to them.
- - If animations fail to load (ownership/permissions), NPCs still move/attack. Fix IDs later.
- --]]
- ------------------------
- -- Services
- ------------------------
- local Players = game:GetService("Players")
- local ReplicatedStorage = game:GetService("ReplicatedStorage")
- local PathfindingService = game:GetService("PathfindingService")
- local RunService = game:GetService("RunService")
- local Debris = game:GetService("Debris")
- local CollectionService = game:GetService("CollectionService")
- ------------------------
- -- Global settings
- ------------------------
- local PATH_RECALC_TIME = 1.25 -- seconds between path recompute
- local MOVE_RETRY_TIME = 0.25 -- retry MoveTo pulse
- local DEFAULT_ATTACK_RANGE = 5
- local DEFAULT_ATTACK_COOLDOWN= 1.2
- local LOS_MAX_DISTANCE = 200
- local CAN_SEE_TAG = "Cannot_See" -- optional tag we can reuse
- -- Server should own physics for smoother NPC motion
- local function setServerOwnership(root)
- if typeof(root) == "Instance" and root:IsA("BasePart") then
- pcall(function() root:SetNetworkOwner(nil) end)
- end
- end
- ------------------------
- -- Zombie type settings
- ------------------------
- local ZOMBIE_SETTINGS = {
- Bomber = {AttackType="Explosive", Damage=30, AttackRange=6, AttackCooldown=2.0, WalkSpeed=10, RunSpeed=14},
- Chef = {AttackType="Melee", Damage=15, AttackRange=4, AttackCooldown=1.0, WalkSpeed=9, RunSpeed=12},
- Clown = {AttackType="Explosive", Damage=25, AttackRange=5, AttackCooldown=1.5, WalkSpeed=10, RunSpeed=13},
- Cop = {AttackType="Shooter", Damage=10, AttackRange=16,AttackCooldown=1.5, WalkSpeed=10, RunSpeed=13},
- Cowboy = {AttackType="Shooter", Damage=12, AttackRange=18,AttackCooldown=1.5, WalkSpeed=10, RunSpeed=13},
- Crow = {AttackType="Melee", Damage=20, AttackRange=4, AttackCooldown=1.2, WalkSpeed=11, RunSpeed=14},
- }
- ------------------------
- -- Folders (templates & live)
- ------------------------
- local TEMPLATE_FOLDER = ReplicatedStorage:WaitForChild("ZOMBIE_TEMPLATES", 10)
- assert(TEMPLATE_FOLDER, "[ZombieController] Missing ReplicatedStorage/ZOMBIE_TEMPLATES folder!")
- local LIVE_FOLDER = workspace:FindFirstChild("ZOMBIES")
- if not LIVE_FOLDER then
- LIVE_FOLDER = Instance.new("Folder")
- LIVE_FOLDER.Name = "ZOMBIES"
- LIVE_FOLDER.Parent = workspace
- end
- ------------------------
- -- Utilities
- ------------------------
- -- Safe Animator retrieval
- local function getAnimator(humanoid)
- local animator = humanoid:FindFirstChildOfClass("Animator")
- if not animator then
- animator = Instance.new("Animator")
- animator.Parent = humanoid
- end
- return animator
- end
- -- Safe animation cache on the model
- local function buildAnimTable(zombieModel)
- local t = {}
- local hum = zombieModel:FindFirstChildOfClass("Humanoid")
- if not hum then return t end
- local animator = getAnimator(hum)
- local animFolder = zombieModel:FindFirstChild("Animations")
- if not animFolder then return t end
- for _, animObj in ipairs(animFolder:GetChildren()) do
- if animObj:IsA("Animation") and animObj.AnimationId ~= "" then
- local ok, track = pcall(function()
- return animator:LoadAnimation(animObj)
- end)
- if ok and track then
- t[animObj.Name] = track
- else
- warn(("[ZombieController] Failed to load animation '%s' (%s) on %s")
- :format(animObj.Name, tostring(animObj.AnimationId), zombieModel.Name))
- end
- end
- end
- return t
- end
- local function playAnim(animTable, name, looped)
- local tr = animTable[name]
- if not tr then return end
- if typeof(looped) == "boolean" then tr.Looped = looped end
- tr:Play()
- end
- local function stopAnim(animTable, name)
- local tr = animTable[name]
- if tr then tr:Stop() end
- end
- -- Basic nearest target (players only)
- local function getNearestPlayer(rootPart)
- local nearestChar, nearestDist = nil, math.huge
- for _, plr in ipairs(Players:GetPlayers()) do
- local ch = plr.Character
- local hum = ch and ch:FindFirstChildOfClass("Humanoid")
- local hrp = ch and ch:FindFirstChild("HumanoidRootPart")
- if hum and hrp and hum.Health > 0 then
- local d = (hrp.Position - rootPart.Position).Magnitude
- if d < nearestDist then
- nearestDist = d
- nearestChar = ch
- end
- end
- end
- return nearestChar, nearestDist
- end
- -- Simple line-of-sight using raycast
- local function hasLineOfSight(fromPos, toPos, ignoreList)
- local params = RaycastParams.new()
- params.FilterType = Enum.RaycastFilterType.Exclude
- params.FilterDescendantsInstances = ignoreList or {}
- local result = workspace:Raycast(fromPos, (toPos - fromPos), params)
- return result == nil
- end
- -- Step along waypoints with MoveTo
- local function followPath(humanoid, root, goalPosition)
- local path = PathfindingService:CreatePath({
- AgentHeight = humanoid.HipHeight + (root.Size.Y),
- AgentRadius = math.max(2, root.Size.X * 0.5),
- AgentCanJump = false,
- AgentMaxSlope = 45
- })
- path:ComputeAsync(root.Position, goalPosition)
- if path.Status ~= Enum.PathStatus.Success then
- humanoid:MoveTo(goalPosition) -- fallback
- return
- end
- local waypoints = path:GetWaypoints()
- for i = 1, #waypoints do
- humanoid:MoveTo(waypoints[i].Position)
- local reached = humanoid.MoveToFinished:Wait()
- if not reached then break end
- end
- end
- ------------------------
- -- Attack behaviours
- ------------------------
- local function doMeleeAttack(attackerModel, targetChar, damage)
- local hum = targetChar:FindFirstChildOfClass("Humanoid")
- if hum and hum.Health > 0 then
- hum:TakeDamage(damage)
- end
- end
- local function doShooterAttack(attackerModel, targetChar, damage)
- -- Minimalistic: we raycast line between attacker head and target torso, then apply damage.
- local head = attackerModel:FindFirstChild("Head")
- local hrp = targetChar:FindFirstChild("HumanoidRootPart")
- local hum = targetChar:FindFirstChildOfClass("Humanoid")
- if not (head and hrp and hum and hum.Health > 0) then return end
- local params = RaycastParams.new()
- params.FilterType = Enum.RaycastFilterType.Exclude
- params.FilterDescendantsInstances = {attackerModel}
- local res = workspace:Raycast(head.Position, (hrp.Position - head.Position).Unit * 300, params)
- local didHit = res and res.Instance and res.Instance:IsDescendantOf(targetChar)
- if didHit then hum:TakeDamage(damage) end
- end
- local function doExplosiveAttack(attackerModel, targetChar, damage, radius)
- local root = attackerModel:FindFirstChild("HumanoidRootPart")
- if not root then return end
- local explosion = Instance.new("Explosion")
- explosion.BlastRadius = radius or 10
- explosion.BlastPressure = 0
- explosion.DestroyJointRadiusPercent = 0
- explosion.ExplosionType = Enum.ExplosionType.NoCraters
- explosion.Position = root.Position
- explosion.Visible = false
- explosion.Hit:Connect(function(part, dist)
- local c = part and part.Parent
- local hum = c and c:FindFirstChildOfClass("Humanoid")
- if hum and not c:FindFirstChild("IsZombie") then
- local minD = damage * 0.7
- local maxD = damage
- local falloff = (1 - math.clamp(dist / explosion.BlastRadius, 0, 1))
- hum:TakeDamage(minD + (maxD - minD) * falloff)
- end
- end)
- explosion.Parent = workspace
- Debris:AddItem(explosion, 2)
- end
- ------------------------
- -- Core controller per zombie instance
- ------------------------
- local function controlZombie(zombieModel)
- local hum = zombieModel:FindFirstChildOfClass("Humanoid")
- local root = zombieModel:FindFirstChild("HumanoidRootPart")
- if not (hum and root) then return end
- -- Match by template name or fall back to model name
- local zType = ZOMBIE_SETTINGS[zombieModel.Name] and zombieModel.Name
- or (ZOMBIE_SETTINGS[zombieModel:GetAttribute("Type")] and zombieModel:GetAttribute("Type"))
- local settings = ZOMBIE_SETTINGS[zType or zombieModel.Name]
- if not settings then
- warn(("[ZombieController] No settings for zombie '%s'"):format(zombieModel.Name))
- return
- end
- setServerOwnership(root)
- hum:SetStateEnabled(Enum.HumanoidStateType.FallingDown, false)
- hum:SetStateEnabled(Enum.HumanoidStateType.Ragdoll, false)
- hum.WalkSpeed = settings.WalkSpeed or 10
- local anims = buildAnimTable(zombieModel)
- playAnim(anims, "Spawn")
- task.delay(1.0, function() stopAnim(anims, "Spawn") end)
- local lastPathTime = 0
- local canAttack = true
- -- Main loop
- task.spawn(function()
- while zombieModel.Parent and hum.Health > 0 do
- local targetChar, distance = getNearestPlayer(root)
- if targetChar then
- local targetHRP = targetChar:FindFirstChild("HumanoidRootPart")
- if targetHRP then
- -- Movement
- if tick() - lastPathTime > PATH_RECALC_TIME then
- lastPathTime = tick()
- playAnim(anims, "Walk", true)
- followPath(hum, root, targetHRP.Position)
- else
- hum:MoveTo(targetHRP.Position)
- end
- -- LOS check (optional but recommended)
- local los = hasLineOfSight(root.Position, targetHRP.Position, {zombieModel})
- if not los and distance > 8 then
- -- No LOS → still path toward target
- end
- -- Attack
- local attackRange = settings.AttackRange or DEFAULT_ATTACK_RANGE
- if distance <= attackRange and canAttack then
- canAttack = false
- stopAnim(anims, "Walk")
- playAnim(anims, "Attack")
- if settings.AttackType == "Melee" then
- doMeleeAttack(zombieModel, targetChar, settings.Damage)
- elseif settings.AttackType == "Shooter" then
- doShooterAttack(zombieModel, targetChar, settings.Damage)
- elseif settings.AttackType == "Explosive" then
- doExplosiveAttack(zombieModel, targetChar, settings.Damage, 10)
- -- self-destruct flavor (optional)
- hum.Health = 0
- end
- task.delay(settings.AttackCooldown or DEFAULT_ATTACK_COOLDOWN, function()
- canAttack = true
- end)
- end
- end
- else
- stopAnim(anims, "Walk")
- playAnim(anims, "Idle", true)
- end
- RunService.Heartbeat:Wait()
- end
- -- Death cleanup anim
- stopAnim(anims, "Walk")
- playAnim(anims, "Death")
- Debris:AddItem(zombieModel, 4)
- end)
- end
- ------------------------
- -- Spawning helpers
- ------------------------
- -- Clone & spawn one template
- local function spawnZombieFromTemplate(template)
- if not (template and template:IsA("Model")) then return end
- local clone = template:Clone()
- clone.Parent = LIVE_FOLDER
- local root = clone:FindFirstChild("HumanoidRootPart")
- if root then
- -- Spawn near template's PrimaryPart or at 0,0,0
- if template.PrimaryPart then
- clone:PivotTo(template.PrimaryPart.CFrame)
- else
- clone:PivotTo(CFrame.new(0, 5, 0))
- end
- end
- controlZombie(clone)
- end
- -- Auto-connect to any new models appearing in LIVE_FOLDER
- LIVE_FOLDER.ChildAdded:Connect(function(child)
- task.defer(function()
- if child:IsA("Model") and child:FindFirstChildOfClass("Humanoid") then
- controlZombie(child)
- end
- end)
- end)
- ------------------------
- -- Boot
- ------------------------
- -- 1) Control existing NPCs in LIVE_FOLDER (if you already placed some there)
- for _, m in ipairs(LIVE_FOLDER:GetChildren()) do
- if m:IsA("Model") and m:FindFirstChildOfClass("Humanoid") then
- controlZombie(m)
- end
- end
- -- 2) (Optional) Auto-spawn one instance per template at start
- for _, template in ipairs(TEMPLATE_FOLDER:GetChildren()) do
- if template:IsA("Model") and ZOMBIE_SETTINGS[template.Name] then
- spawnZombieFromTemplate(template)
- else
- -- ignore unknown templates; add their settings above if you want them controlled
- end
- end
- -- 3) If you add new templates later, auto-spawn a copy immediately (optional)
- TEMPLATE_FOLDER.ChildAdded:Connect(function(newTemplate)
- if newTemplate:IsA("Model") then
- task.wait(0.1)
- if ZOMBIE_SETTINGS[newTemplate.Name] then
- spawnZombieFromTemplate(newTemplate)
- end
- end
- end)
Advertisement
Add Comment
Please, Sign In to add comment