Roblox · Tower Defense · урок 8

Общий: вся игра целиком

Итог курса на одной странице: полная структура Tower Defense и финальные версии всех скриптов — без «добавь строку» и «замени блок». Удобно сверить свою игру или собрать её заново.

Структура

Синим — что добавляем на этом уроке.

Workspace
├─ Grass (Folder) — вся карта
│  ├─ Grass (Part) — земля — только на неё ставятся башни
│  ├─ Road (Folder) — парты дороги
│  ├─ SpawnPart (Part) — старт врагов: CanCollide выкл.
│  └─ Base (Model) — в конце маршрута
│     ├─ Core (Part) — главный парт базы
│     └─ HealthGui (BillboardGui) — Adornee = Core, AlwaysOnTop
│        ├─ MaxHealth (Frame) — красная полоса
│        ├─ Health (Frame) — зелёная полоса поверх
│        └─ Title (TextLabel) — «Base: 500 / 500»
├─ Waypoints (Folder)
│  └─ 1, 2, 3, … (Part) — точки поворота по порядку
├─ Mobs (Folder) — враги на карте
└─ Towers (Folder) — поставленные башни
ReplicatedStorage
├─ Towers (Folder) — образцы башен
│  └─ Tower (Model) — PrimaryPart задан, все части Anchored
│     └─ Configuration (Configuration)
│        ├─ Range, Damage, Cooldown, Price (NumberValue) — радиус, урон, перезарядка, цена
│        ├─ Image (Decal) — картинка для кнопки
│        └─ Trail (Color3Value) — цвет луча
├─ Events (Folder)
│  └─ TowerEvent (RemoteEvent) — клиент → сервер: поставь башню
├─ Functions (Folder)
│  └─ SpawnTowerFunction (RemoteFunction) — клиент спрашивает: хватает денег?
└─ Values (Folder)
   ├─ Waves (IntValue) — номер волны
   └─ State (StringValue) — пусто / Victory / Game Over
ServerStorage
├─ Mobs (Folder) — образцы врагов
│  └─ NormalRig, FastRig, StrongRig (Model) — PrimaryPart = HumanoidRootPart
│     ├─ Animation (Animation) — ходьба
│     └─ Configuration (Configuration)
│        └─ Reward (NumberValue) — награда за убийство
└─ Bindables (Folder)
   ├─ DamageEvent (BindableEvent) — Mob → Base
   └─ GameOverEvent (BindableEvent) — Base → MainScript
ServerScriptService
├─ PlayerController (Script)
└─ MainScript (Script)
   ├─ Mob (ModuleScript)
   ├─ Tower (ModuleScript)
   └─ Base (ModuleScript)
StarterGui
├─ TowerGui (ScreenGui)
│  ├─ TowerFrame (Frame) — панель кнопок
│  │  ├─ Title (TextLabel) — «Башни»
│  │  ├─ UICorner (UICorner)
│  │  ├─ UIListLayout (UIListLayout)
│  │  └─ Sample (ImageButton) — шаблон кнопки, Visible выкл.
│  ├─ Money (TextLabel) — деньги игрока
│  ├─ TowerPlacementPreview (LocalScript)
│  └─ MoneyLocal (LocalScript)
└─ InfoGui (ScreenGui)
   ├─ Wave (TextLabel) — вверху экрана
   ├─ State (TextLabel) — по центру, Visible выкл.
   └─ InfoLocal (LocalScript)
Model → Collision Groups — Mob, Player, Tower — друг с другом не сталкиваются

Шаги

  1. Сверяй сверху вниз: сначала Explorer со «Структурой», потом каждый скрипт целиком.
  2. Model → Collision Groups: группы Mob, Player, Tower; галочки сняты у пар Mob–Mob, Player–Player, Mob–Player, Tower–Player, Tower–Mob, Tower–Tower.
  3. Имена важны: Grass, SpawnPart, Base, Waypoints 1…N, Towers, Mobs, Configuration и значения внутри — скрипты ищут именно их.
  4. Waypoints — в Workspace, а не внутри Grass; Base и SpawnPart — внутри Grass (скрипты берут их из map = workspace.Grass).
  5. Новая башня — копия Tower в ReplicatedStorage.Towers со своими числами в Configuration: кнопка появится сама.
  6. Новый враг — копия в ServerStorage.Mobs и строка mob.Spawn("Имя", …) в MainScript.

Скрипты

PlayerController

Где: ServerScriptService · Script
Зачем: Кошелёк Money (300) и группа коллизий персонажа.

-- игрок зашёл в игру
game.Players.PlayerAdded:Connect(function(player)
    -- кошелёк внутри игрока
    local money = Instance.new("IntValue", player)
    -- называем Money
    money.Name = "Money"
    -- стартовые деньги — хватит на первую башню
    money.Value = 300
    -- появился персонаж
    player.CharacterAdded:Connect(function(character)
        -- все его части
        for _, obj in pairs(character:GetDescendants()) do
            -- только физические
            if obj:IsA("BasePart") then
                -- группа коллизий Player
                obj.CollisionGroup = "Player"
            -- конец проверки
            end
        -- конец перебора
        end
    -- конец обработчика персонажа
    end)
-- конец обработчика игрока
end)

MainScript

Где: ServerScriptService · Script
Зачем: База 500, 5 волн, номер волны и итог игры в Values.

-- модуль врагов
local mob = require(script.Mob)
-- модуль башен
local tower = require(script.Tower)
-- модуль базы
local base = require(script.Base)
-- карта
local map = workspace.Grass

-- серверное хранилище
local SS = game:GetService("ServerStorage")
-- события сервер → сервер
local bindables = SS:WaitForChild("Bindables")
-- база разрушена
local gameOverEvent = bindables:WaitForChild("GameOverEvent")
-- игра ещё идёт
local gameOver = false
-- общее хранилище
local RS = game:GetService("ReplicatedStorage")
-- номер волны и итог игры — их видят игроки
local values = RS:WaitForChild("Values")
-- сколько всего волн
local maxWaves = 5

-- пришёл сигнал о разрушении базы
gameOverEvent.Event:Connect(function()
    -- игра окончена
    gameOver = true
-- конец обработчика
end)

-- у базы 500 здоровья
base.SetParams(map, 500)

-- волны по очереди
for wave = 1, maxWaves do
    -- сообщаем игрокам номер волны
    values.Waves.Value = wave
    -- обычные: 2, 4, 6…
    mob.Spawn("NormalRig", 2 * wave, map)
    -- быстрые
    mob.Spawn("FastRig", 2 * wave, map)
    -- сильные
    mob.Spawn("StrongRig", 2 * wave, map)
    -- ждём: карта пуста или база разрушена
    repeat task.wait(1) until #workspace.Mobs:GetChildren() == 0 or gameOver
    -- база разрушена
    if gameOver then
        -- поражение
        values.State.Value = "Game Over"
        -- убираем оставшихся врагов
        workspace.Mobs:ClearAllChildren()
        -- волн больше нет
        break
    -- последняя волна отбита
    elseif wave == maxWaves then
        -- победа
        values.State.Value = "Victory"
    -- конец проверки
    end
-- следующая волна
end

Mob

Где: ServerScriptService → MainScript · ModuleScript
Зачем: Появление врагов, движение по точкам, урон базе в конце пути.

-- хранилище с образцами врагов
local SS = game:GetService("ServerStorage")
-- папка с событиями сервер → сервер
local bindables = SS:WaitForChild("Bindables")
-- событие «враг дошёл до базы»
local damageEvent = bindables:WaitForChild("DamageEvent")
-- таблица модуля — сюда кладём функции
local mob = {}

-- ведёт врага по маршруту
function mob.Move(enemy, map)
    -- Humanoid управляет движением
    local humanoid = enemy:WaitForChild("Humanoid")
    -- точки маршрута
    local waypoints = workspace:FindFirstChild("Waypoints")
    -- анимация ходьбы внутри врага
    local animation = enemy:FindFirstChild("Animation")
    -- загружаем её в Humanoid
    local animTrack = humanoid:LoadAnimation(animation)
    -- запускаем ходьбу
    animTrack:Play()

    -- по точкам по порядку
    for waypoint = 1, #waypoints:GetChildren() do
        -- идём к точке
        humanoid:MoveTo(waypoints[waypoint].Position)
        -- ждём, пока дойдёт
        humanoid.MoveToFinished:Wait()
        -- враг погиб по дороге — дальше не идёт
        if humanoid.Health <= 0 then return end
    -- следующая точка
    end
    -- урон базе = оставшееся здоровье врага
    damageEvent:Fire(humanoid.Health)
    -- дошёл до конца — убираем
    enemy:Destroy()
-- конец функции
end

-- создаёт quantity врагов с именем name
function mob.Spawn(name, quantity, map)
    -- ищем образец
    local mobModel = SS.Mobs:FindFirstChild(name)
    -- образец найден
    if mobModel then
        -- нужное количество раз
        for i = 1, quantity do
            -- пауза между врагами
            task.wait(0.5)
            -- копия врага
            local mobClone = mobModel:Clone()
            -- ставим на старт
            mobClone.PrimaryPart.CFrame = map.SpawnPart.CFrame
            -- кладём на карту
            mobClone.Parent = workspace.Mobs
            -- все детали врага
            for _, obj in pairs(mobClone:GetDescendants()) do
                -- только физические
                if obj:IsA("BasePart") then
                    -- группа коллизий Mob
                    obj.CollisionGroup = "Mob"
                -- конец проверки
                end
            -- конец перебора
            end
            -- враг погиб
            mobClone.Humanoid.Died:Connect(function()
                -- короткая пауза
                task.wait(0.5)
                -- убираем тело с карты
                mobClone:Destroy()
            -- конец обработчика смерти
            end)
            -- движение параллельно — не ждём, пока враг пройдёт путь
            coroutine.wrap(mob.Move)(mobClone, map)
        -- следующий враг
        end
    -- конец проверки
    end
-- конец функции
end

-- отдаём таблицу тому, кто вызвал require
return mob

Tower

Где: ServerScriptService → MainScript · ModuleScript
Зачем: Проверка денег, установка башни, атака с лучом, награда за врага.

-- общее хранилище
local RS = game:GetService("ReplicatedStorage")
-- удаление объектов по таймеру
local Debris = game:GetService("Debris")
-- сигнал «поставь башню»
local towerEvent = RS.Events:WaitForChild("TowerEvent")
-- вопрос клиента «хватает денег?»
local spawnTowerFunction = RS.Functions:WaitForChild("SpawnTowerFunction")

-- таблица модуля
local tower = {}

-- ищет ближайшего живого врага в радиусе range
local function FindNearestTarget(towerModel, range)
    -- пока никого
    local nearestTarget = nil
    -- все враги на карте
    for _, target in ipairs(workspace.Mobs:GetChildren()) do
        -- мёртвых не считаем
        if target.Humanoid.Health > 0 then
            -- расстояние до башни
            local distance = (target.HumanoidRootPart.Position - towerModel.PrimaryPart.Position).Magnitude
            -- ближе лучшего найденного
            if distance < range then
                -- запоминаем врага
                nearestTarget = target
                -- теперь ищем ещё ближе
                range = distance
            -- конец проверки расстояния
            end
        -- конец проверки здоровья
        end
    -- конец перебора
    end
    -- ближайший враг или nil
    return nearestTarget
-- конец функции
end

-- луч от башни к врагу
local function Trail(towerModel, target)
    -- новый парт-луч
    local trail = Instance.new("Part")
    -- длина луча
    local distance = (towerModel.PrimaryPart.Position - target.PrimaryPart.Position).Magnitude
    -- тонкий и длинный
    trail.Size = Vector3.new(0.1, 0.1, distance)
    -- сдвиг на полдлины, чтобы луч начинался у башни
    local offset = CFrame.new(0, 0, -distance / 2)
    -- направляем на врага
    trail.CFrame = CFrame.new(towerModel.PrimaryPart.Position, target.PrimaryPart.Position) * offset
    -- не падает
    trail.Anchored = true
    -- ни с чем не сталкивается
    trail.CanCollide = false
    -- полупрозрачный
    trail.Transparency = 0.5
    -- цвет луча этой башни
    trail.Color = towerModel.Configuration.Trail.Value
    -- светится
    trail.Material = Enum.Material.Neon
    -- показываем
    trail.Parent = workspace
    -- через 0,1 с удаляется сам
    Debris:AddItem(trail, 0.1)
-- конец функции
end

-- башня стреляет и платит владельцу за убийства
function tower.Attack(towerModel, player)
    -- характеристики башни
    local configuration = towerModel:WaitForChild("Configuration")
    -- башня ещё на карте
    while towerModel.Parent do
        -- живая цель в радиусе
        local target = FindNearestTarget(towerModel, configuration.Range.Value)
        -- цель есть
        if target then
            -- рисуем луч
            Trail(towerModel, target)
            -- наносим урон
            target.Humanoid:TakeDamage(configuration.Damage.Value)
            -- этот выстрел убил врага, игрок ещё в игре
            if target.Humanoid.Health <= 0 and player.Parent then
                -- награда за врага
                player.Money.Value += target.Configuration.Reward.Value
            -- конец проверки
            end
            -- перезарядка
            task.wait(configuration.Cooldown.Value)
        -- целей нет
        else
            -- проверим снова чуть позже
            task.wait(0.1)
        -- конец проверки
        end
    -- следующий выстрел
    end
-- конец функции
end

-- можно ли игроку поставить башню name
function tower.CanSpawn(player, name)
    -- образец башни
    local towerModel = RS.Towers:FindFirstChild(name)
    -- такая башня есть
    if towerModel then
        -- денег хватает
        if towerModel.Configuration.Price.Value <= player.Money.Value then
            -- можно
            return true
        -- денег мало
        else
            -- сообщение в Output
            warn("No money")
        -- конец проверки денег
        end
    -- башни нет
    else
        -- сообщение в Output
        warn("No tower")
    -- конец проверки
    end
    -- нельзя
    return false
-- конец функции
end

-- ставит башню, если хватает денег
function tower.Spawn(player, name, cframe)
    -- сервер сам ещё раз проверяет деньги
    if tower.CanSpawn(player, name) then
        -- копия башни
        local towerClone = RS.Towers[name]:Clone()
        -- ставим всю модель на место
        towerClone:PivotTo(cframe)
        -- на карту
        towerClone.Parent = workspace.Towers
        -- все детали
        for _, obj in pairs(towerClone:GetDescendants()) do
            -- только физические
            if obj:IsA("BasePart") then
                -- группа коллизий Tower
                obj.CollisionGroup = "Tower"
            -- конец проверки
            end
        -- конец перебора
        end
        -- списываем цену
        player.Money.Value -= towerClone.Configuration.Price.Value
        -- атака параллельно
        coroutine.wrap(tower.Attack)(towerClone, player)
    -- конец проверки
    end
-- конец функции
end

-- клиент просит поставить башню
towerEvent.OnServerEvent:Connect(tower.Spawn)
-- клиент спрашивает «хватает денег?» — отвечаем
spawnTowerFunction.OnServerInvoke = tower.CanSpawn

-- отдаём таблицу модуля
return tower

Base

Где: ServerScriptService → MainScript · ModuleScript
Зачем: Здоровье базы, полоска, сигнал о конце игры.

-- серверное хранилище
local SS = game:GetService("ServerStorage")
-- события сервер → сервер
local bindables = SS:WaitForChild("Bindables")
-- враг дошёл до базы
local damageEvent = bindables:WaitForChild("DamageEvent")
-- база разрушена
local gameOverEvent = bindables:WaitForChild("GameOverEvent")

-- таблица модуля
local base = {}

-- задаёт базу и её здоровье
function base.SetParams(map, health)
    -- модель базы на карте
    base.Model = map:WaitForChild("Base")
    -- максимум здоровья
    base.MaxHealth = health
    -- текущее здоровье
    base.Health = health
    -- сразу показываем полоску
    base.NewHealth(0)
-- конец функции
end

-- получает урон и обновляет полоску
function base.NewHealth(damage)
    -- база уже разрушена — ничего не делаем
    if base.Health <= 0 then return end
    -- вычитаем урон, но не ниже нуля
    base.Health = math.max(base.Health - damage, 0)
    -- полоска над базой
    local gui = base.Model.HealthGui
    -- доля оставшегося здоровья
    local percent = base.Health / base.MaxHealth
    -- зелёная полоса укорачивается
    gui.Health.Size = UDim2.new(percent, 0, 0.5, 0)
    -- здоровье кончилось
    if base.Health <= 0 then
        -- пишем, что база разрушена
        gui.Title.Text = "Base: Destroyed"
        -- сообщаем главному скрипту
        gameOverEvent:Fire()
    -- база ещё стоит
    else
        -- текст «Base: 450 / 500»
        gui.Title.Text = "Base: " .. base.Health .. " / " .. base.MaxHealth
    -- конец проверки
    end
-- конец функции
end

-- враг дошёл → урон базе
damageEvent.Event:Connect(base.NewHealth)

-- отдаём таблицу модуля
return base

TowerPlacementPreview

Где: StarterGui → TowerGui · LocalScript
Зачем: Кнопки башен, превью у курсора, R — поворот, ЛКМ — поставить.

-- ввод: мышь и клавиатура
local UIS = game:GetService("UserInputService")
-- события каждого кадра
local RunS = game:GetService("RunService")
-- общее хранилище клиента и сервера
local RS = game:GetService("ReplicatedStorage")
-- сигнал на сервер
local towerEvent = RS.Events:WaitForChild("TowerEvent")
-- вопрос серверу «хватает денег?»
local spawnTowerFunction = RS.Functions:WaitForChild("SpawnTowerFunction")
-- камера игрока
local camera = workspace.CurrentCamera
-- TowerGui
local gui = script.Parent
-- образцы башен
local towers = RS:WaitForChild("Towers")

-- башня-превью, пока её нет
local spawnTower = nil
-- можно ли поставить башню сюда
local canPlace = false
-- поворот превью в градусах
local rotation = 0

-- что под курсором в 3D
local function MouseRaycast(exclude)
    -- курсор на экране (2D)
    local mousePosition = UIS:GetMouseLocation()
    -- луч из камеры через курсор
    local mouseRay = camera:ViewportPointToRay(mousePosition.X, mousePosition.Y)
    -- настройки луча
    local raycastParams = RaycastParams.new()
    -- режим «не замечать объекты из списка»
    raycastParams.FilterType = Enum.RaycastFilterType.Exclude
    -- список: само превью
    raycastParams.FilterDescendantsInstances = exclude
    -- пускаем луч на 1000 studs
    local raycastResult = workspace:Raycast(mouseRay.Origin, mouseRay.Direction * 1000, raycastParams)
    -- точка и объект попадания или nil
    return raycastResult
-- конец функции
end

-- убирает превью
local function RemoveTowerPreview()
    -- превью есть
    if spawnTower then
        -- удаляем модель
        spawnTower:Destroy()
        -- забываем её
        spawnTower = nil
        -- сбрасываем поворот
        rotation = 0
    -- конец проверки
    end
-- конец функции
end

-- создаёт превью башни name
local function AddTowerPreview(name)
    -- образец
    local tower = towers:FindFirstChild(name)
    -- нашли
    if tower then
        -- убираем прошлое превью
        RemoveTowerPreview()
        -- копия для превью
        spawnTower = tower:Clone()
        -- показываем на карте
        spawnTower.Parent = workspace.Towers
        -- все детали
        for _, obj in pairs(spawnTower:GetDescendants()) do
            -- только физические
            if obj:IsA("BasePart") then
                -- превью ни с чем не сталкивается
                obj.CollisionGroup = "Tower"
                -- «призрачный» материал
                obj.Material = Enum.Material.ForceField
            -- конец проверки
            end
        -- конец перебора
        end
    -- конец проверки
    end
-- конец функции
end

-- красит превью
local function ColorTowerPreview(color)
    -- все детали
    for _, obj in pairs(spawnTower:GetDescendants()) do
        -- только физические
        if obj:IsA("BasePart") then
            -- новый цвет
            obj.Color = color
        -- конец проверки
        end
    -- конец перебора
    end
-- конец функции
end

-- по каждой башне
for _, tower in pairs(towers:GetChildren()) do
    -- копия шаблона кнопки
    local button = gui.TowerFrame.Sample:Clone()
    -- характеристики башни
    local configuration = tower:WaitForChild("Configuration")
    -- имя кнопки = имя башни
    button.Name = tower.Name
    -- картинка башни
    button.Image = configuration.Image.Texture
    -- показываем копию
    button.Visible = true
    -- в список кнопок
    button.Parent = gui.TowerFrame
    -- нажали на кнопку
    button.Activated:Connect(function()
        -- спрашиваем сервер: хватает денег?
        if spawnTowerFunction:InvokeServer(tower.Name) then
            -- хватает — показываем превью
            AddTowerPreview(tower.Name)
        -- конец проверки
        end
    -- конец обработчика
    end)
-- следующая башня
end

-- нажата клавиша или кнопка мыши
UIS.InputBegan:Connect(function(input, gp)
    -- нажатие ушло в интерфейс — пропускаем
    if gp then return end
    -- сейчас показано превью
    if spawnTower then
        -- левая кнопка мыши
        if input.UserInputType == Enum.UserInputType.MouseButton1 then
            -- место подходит
            if canPlace then
                -- просим сервер поставить башню сюда
                towerEvent:FireServer(spawnTower.Name, spawnTower.PrimaryPart.CFrame)
                -- убираем превью
                RemoveTowerPreview()
            -- конец проверки места
            end
        -- клавиша R
        elseif input.KeyCode == Enum.KeyCode.R then
            -- поворот на 90°
            rotation += 90
        -- конец выбора
        end
    -- конец проверки превью
    end
-- конец обработчика
end)

-- каждый кадр
RunS.RenderStepped:Connect(function()
    -- есть превью
    if spawnTower then
        -- что под курсором (превью не считаем)
        local raycastResult = MouseRaycast({spawnTower})
        -- луч во что-то попал
        if raycastResult and raycastResult.Instance then
            -- под курсором трава
            if raycastResult.Instance.Name == "Grass" then
                -- ставить можно
                canPlace = true
                -- превью зелёное
                ColorTowerPreview(Color3.new(0, 1, 0))
            -- дорога или что-то ещё
            else
                -- ставить нельзя
                canPlace = false
                -- превью красное
                ColorTowerPreview(Color3.new(1, 0, 0))
            -- конец проверки
            end
            -- точка под курсором: X
            local x = raycastResult.Position.X
            -- Y, поднятый на полвысоты башни
            local y = raycastResult.Position.Y + (spawnTower.PrimaryPart.Size.Y / 2)
            -- Z
            local z = raycastResult.Position.Z
            -- позиция + поворот
            local cframe = CFrame.new(x, y, z) * CFrame.Angles(0, math.rad(rotation), 0)
            -- двигаем превью за курсором
            spawnTower:PivotTo(cframe)
        -- курсор в небе — луч никуда не попал
        else
            -- ставить нельзя
            canPlace = false
        -- конец проверки попадания
        end
    -- конец проверки превью
    end
-- конец обработчика кадра
end)

MoneyLocal

Где: StarterGui → TowerGui · LocalScript
Зачем: Показывает деньги игрока.

-- этот игрок
local player = game.Players.LocalPlayer
-- его кошелёк (создаёт сервер)
local money = player:WaitForChild("Money")
-- надпись с деньгами
local moneyText = script.Parent.Money

-- обновляет надпись
local function UpdateMoney()
    -- «$ 300»
    moneyText.Text = "$ " .. money.Value
-- конец функции
end

-- показываем сразу
UpdateMoney()
-- и при каждом изменении
money.Changed:Connect(UpdateMoney)

InfoLocal

Где: StarterGui → InfoGui · LocalScript
Зачем: Показывает номер волны и итог игры.

-- общее хранилище
local RS = game:GetService("ReplicatedStorage")
-- Waves и State от сервера
local values = RS:WaitForChild("Values")
-- InfoGui
local gui = script.Parent

-- обновляет номер волны
local function UpdateWave()
    -- «Волна 3»
    gui.Wave.Text = "Волна " .. values.Waves.Value
-- конец функции
end

-- показывает итог игры
local function UpdateState()
    -- Victory или Game Over
    gui.State.Text = values.State.Value
    -- пока игра идёт — надпись скрыта
    gui.State.Visible = values.State.Value ~= ""
-- конец функции
end

-- показываем сразу
UpdateWave()
-- показываем сразу
UpdateState()
-- новая волна
values.Waves.Changed:Connect(UpdateWave)
-- игра закончилась
values.State.Changed:Connect(UpdateState)

Проверка

Запусти Play (или Run) и убедись:

Собрать всю игру одной командой