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 — друг с другом не сталкиваются
Где: 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)Где: 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Где: 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Где: 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Где: 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Где: 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)Где: 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)Где: 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) и убедись: