Roblox · Doors · урок 9

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

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

Структура

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

Workspace
├─ StartRoom (Model) — Build (+ Lights), Exit, SpawnLocation
├─ EndRoom (Model) — Build (+ Lights), Enter, CameraPart
├─ GeneratedRooms (Folder) — комнаты появляются при запуске
└─ Sounds (Folder)
   └─ Lights (Sound) — мигание света
ServerStorage
├─ RoomSamples (Folder)
│  └─ SmallRoom, LargeRoom, LeftCorner, RightCorner, … (Model) — PrimaryPart = Enter
│     ├─ Build (Model) — стены, пол, потолок
│     │  └─ Lights (Model) — лампы с PointLight
│     ├─ Enter, Exit (Part) — смотрят в одну сторону
│     ├─ Furniture (Folder) — локаторы Bedside, Table, Wardrobe, Closet
│     └─ Waypoints (Folder) — точки 1, 2… — в извитых комнатах
├─ FurnitureSamples (Folder)
│  ├─ Bedside, Table (Model) — Primary + каркас + Bottoms (Bottom с Location и Handle)
│  ├─ Wardrobe (Model) — + Spinner с дверцей
│  └─ Closet (Model) — Primary, Barrier, Outside, Inside, Left/RightSpinner с дверями
├─ Door (Model) — Primary, Spinner, Door (Open, Unlock), Label → NumberGui → Number, Lock, Trigger
├─ Items (Folder)
│  └─ Key (Tool) — Handle
├─ Enemies (Folder)
│  └─ RedMonster (Model) — Monster (Decal, Appear, Disappear) + Emitter (ParticleEmitter)
└─ EnterEvent (BindableEvent)
ReplicatedStorage
└─ EndEvent (RemoteEvent)
ServerScriptService
└─ MainScript (Script)
   ├─ Room (ModuleScript)
   │  ├─ RoomInfo (ModuleScript)
   │  ├─ Door (ModuleScript)
   │  ├─ Furniture (ModuleScript)
   │  │  └─ Closet (ModuleScript)
   │  └─ Item (ModuleScript)
   └─ Monster (ModuleScript)
StarterGui
└─ EndGui (ScreenGui)
   └─ Frame (Frame)
      ├─ EndText (TextLabel)
      └─ EndLocal (LocalScript)

Шаги

  1. Сверяй сверху вниз: сначала Explorer со «Структурой», потом каждый скрипт целиком.
  2. Все образцы — в ServerStorage: комнаты, мебель, дверь, ключ, монстр. В Workspace только StartRoom, EndRoom, GeneratedRooms и Sounds.
  3. У каждой комнаты: Enter и Exit смотрят в одну сторону; Enter — PrimaryPart; локаторы в Furniture называются как модели в FurnitureSamples; хотя бы один локатор Closet.
  4. Невидимые служебные детали (Enter, Exit, Primary, Trigger, локаторы, Outside, Inside): Transparency 1, CanCollide выкл., CanQuery выкл. Барьер шкафа — CanCollide вкл.: он прячет от монстра.
  5. Новая комната — модель в RoomSamples и строка в RoomInfo. Новая мебель — модель в FurnitureSamples и локаторы с её именем.
  6. Число комнат и частота монстра — ROOMS и MONSTER_EVERY в начале MainScript.

Скрипты

MainScript

Где: ServerScriptService · Script
Зачем: Строит 10 комнат, запускает монстра и финал.

-- модуль комнат
local room = require(script.Room)
-- модуль дверей
local door = require(script.Room.Door)
-- модуль монстра
local monster = require(script.Monster)
-- номер открытой двери
local enterEvent = game.ServerStorage:WaitForChild("EnterEvent")
-- финал — всем игрокам
local endEvent = game.ReplicatedStorage:WaitForChild("EndEvent")
-- сколько комнат вместе со стартовой
local ROOMS = 10
-- монстр после каждой 3-й двери
local MONSTER_EVERY = 3

-- первая «предыдущая» — стартовая
local prevRoom = workspace.StartRoom
-- финальная комната
local endRoom = workspace.EndRoom
-- все комнаты по номерам: 1 — стартовая
local generatedRooms = {prevRoom}
-- дверь 01 в стартовой комнате
door.New(prevRoom, 1, false)
-- комнаты 2…10
for i = 2, ROOMS do
    -- новая комната с дверью номер i
    prevRoom = room.Generate(prevRoom, i)
    -- запоминаем под номером
    generatedRooms[i] = prevRoom
-- конец цикла
end
-- двигаем финальную комнату за вход
endRoom.PrimaryPart = endRoom.Enter
-- ставим за последней комнатой
endRoom:PivotTo(prevRoom.Exit.CFrame)

-- финал уже показан?
local finished = false
-- открыта дверь number
enterEvent.Event:Connect(function(number)
    -- открыта последняя дверь — финал
    if number == #generatedRooms then
        -- уже показали
        if finished then return end
        -- больше не показываем
        finished = true
        -- игрок заходит в финальную комнату
        task.wait(2)
        -- катсцена у всех игроков
        endEvent:FireAllClients()
        -- камера разворачивается
        task.wait(2)
        -- дверь за спиной медленно закрывается
        door.Close(prevRoom.Door)
        -- свет мигает
        room.Lights(endRoom)
    -- номер делится на 3 — монстр
    elseif number % MONSTER_EVERY == 0 then
        -- комната, куда вошёл игрок
        local nextRoom = generatedRooms[number + 1]
        -- звук мигания
        local sound = workspace:FindFirstChild("Sounds") and workspace.Sounds:FindFirstChild("Lights")
        -- играем, если есть
        if sound then sound:Play() end
        -- лампы мигают — предупреждение
        room.Lights(nextRoom)
        -- 2 секунды, чтобы спрятаться
        task.wait(2)
        -- монстр пролетает комнаты
        monster.New(number, generatedRooms)
    -- конец проверки
    end
-- конец обработчика
end)

Room

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

-- плавное мигание света
local TS = game:GetService("TweenService")
-- здесь лежат образцы комнат
local SS = game:GetService("ServerStorage")
-- модуль дверей
local door = require(script.Door)
-- модуль мебели
local furniture = require(script.Furniture)
-- модуль предметов (ключ)
local item = require(script.Item)

-- таблица модуля
local room = {}
-- редкость и правила комнат
room.info = require(script.RoomInfo)
-- генератор случайных чисел
room.random = Random.new()
-- куда был последний поворот
room.lastTurnDirection = nil

-- одна лампа мигает
function room.TweenLight(light)
    -- обычная яркость (запоминаем один раз)
    local base = light:GetAttribute("Base") or light.Brightness
    -- храним в самой лампе
    light:SetAttribute("Base", base)
    -- резкое дёрганое мигание
    local tweenInfo = TweenInfo.new(0.3, Enum.EasingStyle.Elastic, Enum.EasingDirection.InOut)
    -- погасить
    local off = TS:Create(light, tweenInfo, {Brightness = 0})
    -- вспышка
    local on = TS:Create(light, tweenInfo, {Brightness = base * 4})
    -- гаснет
    off:Play()
    -- ждём
    off.Completed:Wait()
    -- вспыхивает
    on:Play()
    -- ждём
    on.Completed:Wait()
    -- снова гаснет
    off:Play()
    -- ждём
    off.Completed:Wait()
    -- за секунду возвращаем обычный свет
    TS:Create(light, TweenInfo.new(1), {Brightness = base}):Play()
-- конец функции
end

-- мигают все лампы комнаты
function room.Lights(roomModel)
    -- постройка комнаты
    local build = roomModel:FindFirstChild("Build")
    -- модель с лампами
    local lights = build and build:FindFirstChild("Lights")
    -- ламп нет — нечему мигать
    if not lights then return end
    -- всё внутри ламп
    for _, obj in ipairs(lights:GetDescendants()) do
        -- источник света: PointLight, SpotLight, SurfaceLight
        if obj:IsA("Light") then
            -- мигает одновременно с остальными
            task.spawn(room.TweenLight, obj)
        -- конец проверки
        end
    -- конец перебора
    end
-- конец функции
end

-- можно ли поставить эту комнату следующей
local function Allowed(name, info, prevRoom)
    -- настройки предыдущей (у стартовой их нет)
    local prevInfo = room.info[prevRoom.Name] or {}
    -- образца нет — пропускаем
    if not SS.RoomSamples:FindFirstChild(name) then return false end
    -- две одинаковые подряд — скучно
    if name == prevRoom.Name then return false end
    -- два поворота в одну сторону — коридор замкнётся
    if info.Direction and info.Direction == room.lastTurnDirection then return false end
    -- две лестницы подряд — нельзя
    if info.Stairs and prevInfo.Stairs then return false end
    -- подходит
    return true
-- конец функции
end

-- случайная комната с учётом редкости
function room.GetRandom(prevRoom)
    -- сумма весов подходящих комнат
    local totalRarity = 0
    -- все комнаты из RoomInfo
    for name, info in pairs(room.info) do
        -- подходящие складываем
        if Allowed(name, info, prevRoom) then totalRarity += info.Rarity end
    -- конец перебора
    end
    -- понятная ошибка вместо зависания
    if totalRarity == 0 then error("Нет подходящих комнат в ServerStorage.RoomSamples") end
    -- случайное число от 1 до суммы
    local randomRarity = room.random:NextInteger(1, totalRarity)
    -- накопленный вес
    local currentRarity = 0
    -- снова по комнатам (порядок тот же)
    for name, info in pairs(room.info) do
        -- только подходящие
        if Allowed(name, info, prevRoom) then
            -- прибавляем вес
            currentRarity += info.Rarity
            -- число попало на эту комнату
            if randomRarity <= currentRarity then
                -- запоминаем поворот
                if info.Direction then room.lastTurnDirection = info.Direction end
                -- отдаём образец
                return SS.RoomSamples[name]
            -- конец проверки
            end
        -- конец проверки
        end
    -- конец перебора
    end
-- конец функции
end

-- ставит новую комнату за предыдущей
function room.Generate(prevRoom, number)
    -- копия случайной комнаты
    local newRoom = room.GetRandom(prevRoom):Clone()
    -- двигаем комнату за вход
    newRoom.PrimaryPart = newRoom.Enter
    -- вход новой — точно на выход предыдущей
    newRoom:PivotTo(prevRoom.Exit.CFrame)
    -- дверь пока без замка
    local keyNeeded = false
    -- расставляем мебель; получаем места для ключа
    local locations = furniture.Generate(newRoom)
    -- есть ящики и выпал шанс 1 из 4
    if locations and room.random:NextInteger(1, 4) == 4 then
        -- дверь этой комнаты будет заперта
        keyNeeded = true
        -- кладём ключ в случайный ящик
        item.New(locations[room.random:NextInteger(1, #locations)], "Key")
    -- конец проверки
    end
    -- дверь на выходе — с замком или без
    door.New(newRoom, number, keyNeeded)
    -- показываем комнату
    newRoom.Parent = workspace.GeneratedRooms
    -- она станет «предыдущей» для следующей
    return newRoom
-- конец функции
end

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

RoomInfo

Где: ServerScriptService → MainScript → Room · ModuleScript
Зачем: Редкость и правила комнат.

-- таблица: имя комнаты → её настройки
local roomInfo = {
    -- маленькая; Rarity — вес: чем больше, тем чаще
    ["SmallRoom"] = {Rarity = 4},
    -- большая
    ["LargeRoom"] = {Rarity = 4},
    -- лестница вверх
    ["Upstairs"] = {Stairs = true, Rarity = 2},
    -- лестница вниз
    ["Downstairs"] = {Stairs = true, Rarity = 2},
    -- поворот налево
    ["LeftCorner"] = {Direction = "Left", Rarity = 6},
    -- поворот направо
    ["RightCorner"] = {Direction = "Right", Rarity = 6},
    -- зигзаг влево
    ["LeftTwist"] = {Rarity = 5},
    -- зигзаг вправо
    ["RightTwist"] = {Rarity = 5},
-- конец таблицы
}

-- отдаём таблицу модулю Room
return roomInfo

Door

Где: ServerScriptService → MainScript → Room · ModuleScript
Зачем: Дверь с номером, замок, открытие и закрытие.

-- плавный поворот двери
local TS = game:GetService("TweenService")
-- здесь лежит образец двери
local SS = game:GetService("ServerStorage")
-- сообщает главному скрипту номер открытой двери
local enterEvent = SS:WaitForChild("EnterEvent")

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

-- открывает дверь
function door.Open(doorModel, number)
    -- отмечаем: открыта
    doorModel:SetAttribute("Open", true)
    -- петля повёрнута на 90°
    local cframe = doorModel.Spinner.CFrame * CFrame.Angles(0, math.rad(90), 0)
    -- за полсекунды
    local tweenInfo = TweenInfo.new(0.5, Enum.EasingStyle.Linear)
    -- поворачиваем петлю — дверь приварена и едет следом
    TS:Create(doorModel.Spinner, tweenInfo, {CFrame = cframe}):Play()
    -- скрип двери
    doorModel.Door.Open:Play()
    -- игрок вошёл в комнату number + 1
    enterEvent:Fire(number)
-- конец функции
end

-- медленно захлопывает дверь (финал)
function door.Close(doorModel)
    -- обратно на 90°
    local cframe = doorModel.Spinner.CFrame * CFrame.Angles(0, math.rad(-90), 0)
    -- 10 секунд, с отскоком в конце
    local tweenInfo = TweenInfo.new(10, Enum.EasingStyle.Bounce, Enum.EasingDirection.Out)
    -- закрываем
    TS:Create(doorModel.Spinner, tweenInfo, {CFrame = cframe}):Play()
-- конец функции
end

-- ключ в руках или в рюкзаке
local function FindKey(character)
    -- чей это персонаж
    local player = game.Players:GetPlayerFromCharacter(character)
    -- Tool «Key» или nil
    return character:FindFirstChild("Key") or (player and player.Backpack:FindFirstChild("Key"))
-- конец функции
end

-- ставит дверь на выход комнаты
function door.New(roomModel, number, locked)
    -- копия образца
    local doorModel = SS.Door:Clone()
    -- в дверной проём
    doorModel:PivotTo(roomModel.Exit.CFrame)
    -- закрыта
    doorModel:SetAttribute("Open", false)
    -- номер на табличке: 01, 02…
    doorModel.Label.NumberGui.Number.Text = string.format("%02d", number)
    -- замок виден, только если дверь заперта
    doorModel.Lock.Transparency = locked and 0 or 1
    -- кто-то подошёл к двери
    doorModel.Trigger.Touched:Connect(function(otherPart)
        -- модель, которой принадлежит деталь
        local character = otherPart.Parent
        -- это персонаж?
        local humanoid = character:FindFirstChildOfClass("Humanoid")
        -- не персонаж или уже открыта
        if not humanoid or doorModel:GetAttribute("Open") then return end
        -- замка нет
        if not locked then
            -- просто открываем
            door.Open(doorModel, number)
            -- готово
            return
        -- конец проверки
        end
        -- есть ли у игрока ключ
        local key = FindKey(character)
        -- есть
        if key then
            -- дверь больше не заперта
            locked = false
            -- ключ тратится
            key:Destroy()
            -- замок исчезает
            doorModel.Lock:Destroy()
            -- щелчок замка
            doorModel.Door.Unlock:Play()
            -- открываем
            door.Open(doorModel, number)
        -- конец проверки
        end
    -- конец обработчика
    end)
    -- дверь — часть комнаты
    doorModel.Parent = roomModel
    -- отдаём дверь
    return doorModel
-- конец функции
end

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

Furniture

Где: ServerScriptService → MainScript → Room · ModuleScript
Зачем: Мебель по локаторам, ящики и дверцы.

-- плавное движение ящиков
local TS = game:GetService("TweenService")
-- здесь лежат образцы мебели
local SS = game:GetService("ServerStorage")
-- шкаф для пряток
local closet = require(script.Closet)
-- на сколько studs выдвигается ящик
local SLIDE = 1.5

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

-- открыть или закрыть ящик / дверцу
function furniture.Open(bottom)
    -- двигается — второе нажатие не примем
    bottom:SetAttribute("Moving", true)
    -- сейчас открыт?
    local opened = bottom:GetAttribute("Open")
    -- открыт — задвигаем (1), закрыт — выдвигаем (−1)
    local direction = opened and 1 or -1
    -- Bottom → папка Bottoms → модель мебели
    local model = bottom.Parent.Parent
    -- анимация
    local tween
    -- у мебели есть петля — это дверца (шкаф-гардероб)
    if model:FindFirstChild("Spinner") then
        -- поворот петли на 90°
        local cframe = model.Spinner.CFrame * CFrame.Angles(0, math.rad(90 * direction), 0)
        -- поворачиваем петлю
        tween = TS:Create(model.Spinner, TweenInfo.new(0.5), {CFrame = cframe})
    -- ящик
    else
        -- сдвиг вперёд или назад
        local cframe = bottom.CFrame * CFrame.new(0, 0, SLIDE * direction)
        -- двигаем дно — стенки приварены
        tween = TS:Create(bottom, TweenInfo.new(0.5), {CFrame = cframe})
    -- конец проверки
    end
    -- запускаем
    tween:Play()
    -- ждём конца
    tween.Completed:Wait()
    -- запоминаем новое положение
    bottom:SetAttribute("Open", not opened)
    -- можно нажимать снова
    bottom:SetAttribute("Moving", false)
-- конец функции
end

-- ставит мебель на место локатора
function furniture.New(sample)
    -- образец с тем же именем
    local template = SS.FurnitureSamples:FindFirstChild(sample.Name)
    -- образца нет
    if not template then
        -- подсказка в Output
        warn("Нет мебели " .. sample.Name .. " в ServerStorage.FurnitureSamples")
        -- ничего не ставим
        return nil
    -- конец проверки
    end
    -- копия мебели
    local model = template:Clone()
    -- на место и с поворотом локатора
    model:PivotTo(sample.CFrame)
    -- места внутри ящиков, куда можно положить ключ
    local locations = {}
    -- папка с донышками ящиков
    local bottoms = model:FindFirstChild("Bottoms")
    -- ящики есть
    if bottoms then
        -- каждый ящик
        for _, bottom in ipairs(bottoms:GetChildren()) do
            -- запоминаем место внутри
            table.insert(locations, bottom.Location)
            -- закрыт
            bottom:SetAttribute("Open", false)
            -- не двигается
            bottom:SetAttribute("Moving", false)
            -- надпись «Открыть» (E / тап / X)
            local prompt = Instance.new("ProximityPrompt")
            -- текст
            prompt.ActionText = "Открыть"
            -- с 5 studs
            prompt.MaxActivationDistance = 5
            -- невидимая деталь Primary не мешает
            prompt.RequiresLineOfSight = false
            -- на ручке ящика
            prompt.Parent = bottom.Handle
            -- нажали
            prompt.Triggered:Connect(function()
                -- ещё едет — ждём
                if bottom:GetAttribute("Moving") then return end
                -- открываем или закрываем
                furniture.Open(bottom)
                -- меняем надпись
                prompt.ActionText = bottom:GetAttribute("Open") and "Закрыть" or "Открыть"
            -- конец обработчика
            end)
        -- конец перебора
        end
    -- конец проверки
    end
    -- мебель в комнату
    model.Parent = sample.Parent
    -- локатор больше не нужен
    sample:Destroy()
    -- отдаём места для ключа
    return locations
-- конец функции
end

-- расставляет всю мебель комнаты
function furniture.Generate(roomModel)
    -- все места для ключа в этой комнате
    local roomLocations = {}
    -- папка с локаторами
    local folder = roomModel:FindFirstChild("Furniture")
    -- мебели в комнате нет
    if not folder then return nil end
    -- каждый локатор
    for _, sample in ipairs(folder:GetChildren()) do
        -- локатор шкафа для пряток
        if sample.Name == "Closet" then
            -- у него свой модуль
            closet.New(sample)
        -- обычная мебель
        else
            -- ставим мебель
            local locations = furniture.New(sample)
            -- поставили
            if locations then
                -- её места
                for _, location in ipairs(locations) do
                    -- добавляем к местам комнаты
                    table.insert(roomLocations, location)
                -- конец перебора
                end
            -- конец проверки
            end
        -- конец проверки
        end
    -- конец перебора
    end
    -- есть куда положить ключ
    if #roomLocations > 0 then return roomLocations end
    -- ящиков нет
    return nil
-- конец функции
end

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

Closet

Где: ServerScriptService → MainScript → Room → Furniture · ModuleScript
Зачем: Шкаф для пряток.

-- плавное движение дверей
local TS = game:GetService("TweenService")
-- здесь лежит образец шкафа
local SS = game:GetService("ServerStorage")
-- обычная скорость и прыжок персонажа
local SP = game:GetService("StarterPlayer")
-- таблица модуля
local closet = {}

-- поворачивает одну петлю
function closet.MoveSpinner(spinner, direction)
    -- на 100° в нужную сторону
    local cframe = spinner.CFrame * CFrame.Angles(0, math.rad(100 * direction), 0)
    -- за полсекунды
    TS:Create(spinner, TweenInfo.new(0.5), {CFrame = cframe}):Play()
-- конец функции
end

-- обе двери: 1 — открыть, −1 — закрыть
function closet.MoveDoors(model, direction)
    -- левая
    closet.MoveSpinner(model.LeftSpinner, direction)
    -- правая — в зеркальную сторону
    closet.MoveSpinner(model.RightSpinner, -direction)
    -- ждём, пока повернутся
    task.wait(0.5)
-- конец функции
end

-- запретить или вернуть ходьбу и прыжок
local function Freeze(humanoid, frozen)
    -- скорость
    humanoid.WalkSpeed = frozen and 0 or SP.CharacterWalkSpeed
    -- прыжок (если в игре JumpPower)
    humanoid.JumpPower = frozen and 0 or SP.CharacterJumpPower
    -- прыжок (если в игре JumpHeight)
    humanoid.JumpHeight = frozen and 0 or SP.CharacterJumpHeight
-- конец функции
end

-- игрок прячется
function closet.Enter(player, model)
    -- его персонаж
    local character = player.Character
    -- Humanoid
    local humanoid = character and character:FindFirstChildOfClass("Humanoid")
    -- персонажа нет или погиб
    if not humanoid or humanoid.Health <= 0 then return false end
    -- шкаф занят
    model.HasPlayer.Value = player
    -- стоит на месте
    Freeze(humanoid, true)
    -- открываем двери
    closet.MoveDoors(model, 1)
    -- переносим внутрь
    character:PivotTo(model.Inside.CFrame)
    -- закрываем
    closet.MoveDoors(model, -1)
    -- спрятался
    return true
-- конец функции
end

-- игрок выходит
function closet.Exit(player, model)
    -- его персонаж
    local character = player.Character
    -- Humanoid
    local humanoid = character and character:FindFirstChildOfClass("Humanoid")
    -- открываем двери
    closet.MoveDoors(model, 1)
    -- персонаж на месте
    if humanoid then
        -- переносим наружу
        character:PivotTo(model.Outside.CFrame)
        -- снова может ходить
        Freeze(humanoid, false)
    -- конец проверки
    end
    -- секунду двери открыты
    task.wait(1)
    -- закрываем
    closet.MoveDoors(model, -1)
    -- шкаф свободен
    model.HasPlayer.Value = nil
-- конец функции
end

-- ставит шкаф на место локатора
function closet.New(sample)
    -- копия образца
    local model = SS.FurnitureSamples.Closet:Clone()
    -- на место локатора
    model:PivotTo(sample.CFrame)
    -- в комнату
    model.Parent = sample.Parent
    -- локатор больше не нужен
    sample:Destroy()
    -- кто сейчас внутри
    local hasPlayer = Instance.new("ObjectValue")
    -- имя
    hasPlayer.Name = "HasPlayer"
    -- в шкаф
    hasPlayer.Parent = model
    -- надпись снаружи
    local outsidePrompt = Instance.new("ProximityPrompt")
    -- текст
    outsidePrompt.ActionText = "Спрятаться"
    -- с 5 studs
    outsidePrompt.MaxActivationDistance = 5
    -- невидимые детали шкафа не мешают
    outsidePrompt.RequiresLineOfSight = false
    -- перед шкафом
    outsidePrompt.Parent = model.Outside
    -- надпись внутри
    local insidePrompt = outsidePrompt:Clone()
    -- текст
    insidePrompt.ActionText = "Выйти"
    -- только изнутри
    insidePrompt.MaxActivationDistance = 3
    -- пока внутри никого
    insidePrompt.Enabled = false
    -- внутри шкафа
    insidePrompt.Parent = model.Inside

    -- освободить шкаф
    local function Free()
        -- внутри никого
        hasPlayer.Value = nil
        -- «Выйти» не нужно
        insidePrompt.Enabled = false
        -- снова можно спрятаться
        outsidePrompt.Enabled = true
    -- конец функции
    end

    -- нажали «Спрятаться»
    outsidePrompt.Triggered:Connect(function(player)
        -- занято (и тот игрок ещё в игре)
        if hasPlayer.Value and hasPlayer.Value.Parent then return end
        -- второй не войдёт
        outsidePrompt.Enabled = false
        -- не получилось
        if not closet.Enter(player, model) then
            -- шкаф снова свободен
            Free()
            -- выходим
            return
        -- конец проверки
        end
        -- можно выйти
        insidePrompt.Enabled = true
        -- погиб внутри
        player.Character.Humanoid.Died:Once(function()
            -- шкаф не останется занятым навсегда
            if hasPlayer.Value == player then Free() end
        -- конец обработчика
        end)
    -- конец обработчика
    end)
    -- нажали «Выйти»
    insidePrompt.Triggered:Connect(function(player)
        -- выйти может только тот, кто внутри
        if hasPlayer.Value ~= player then return end
        -- второй раз не нажать
        insidePrompt.Enabled = false
        -- выходим
        closet.Exit(player, model)
        -- шкаф свободен
        Free()
    -- конец обработчика
    end)
-- конец функции
end

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

Item

Где: ServerScriptService → MainScript → Room · ModuleScript
Зачем: Ключ в ящике.

-- здесь лежат предметы
local SS = game:GetService("ServerStorage")
-- таблица модуля
local item = {}

-- игрок взял предмет
function item.Interact(player, prompt, handle, itemName)
    -- его персонаж
    local humanoid = player.Character and player.Character:FindFirstChildOfClass("Humanoid")
    -- персонажа нет
    if not humanoid then return end
    -- второй раз не взять
    prompt.Enabled = false
    -- предмет пропадает из ящика
    handle:Destroy()
    -- это ключ
    if itemName == "Key" then
        -- копия Tool «Key»
        local tool = SS.Items.Key:Clone()
        -- в рюкзак
        tool.Parent = player.Backpack
        -- и сразу в руки
        humanoid:EquipTool(tool)
    -- конец проверки
    end
-- конец функции
end

-- кладёт предмет в ящик
function item.New(location, itemName)
    -- образец предмета
    local template = SS.Items:FindFirstChild(itemName)
    -- нет такого
    if not template then return end
    -- дно ящика, где точка Location
    local bottom = location.Parent
    -- только модель, без Tool
    local handle = template.Handle:Clone()
    -- поедет вместе с ящиком
    handle.Anchored = false
    -- не мешает ящику двигаться
    handle.CanCollide = false
    -- ничего не весит
    handle.Massless = true
    -- в точку Location
    handle.CFrame = location.WorldCFrame
    -- в ящик
    handle.Parent = bottom
    -- приварка к дну
    local weld = Instance.new("WeldConstraint")
    -- предмет
    weld.Part0 = handle
    -- к дну ящика
    weld.Part1 = bottom
    -- в предмет
    weld.Parent = handle
    -- надпись «Взять»
    local prompt = Instance.new("ProximityPrompt")
    -- текст
    prompt.ActionText = "Взять"
    -- что берём
    prompt.ObjectText = itemName
    -- с 5 studs
    prompt.MaxActivationDistance = 5
    -- мебель не загораживает
    prompt.RequiresLineOfSight = false
    -- видно, только когда ящик открыт
    prompt.Enabled = bottom:GetAttribute("Open") == true
    -- на предмете
    prompt.Parent = handle
    -- ящик открыли или закрыли
    bottom:GetAttributeChangedSignal("Open"):Connect(function()
        -- предмет ещё здесь — показываем/прячем
        if handle.Parent then prompt.Enabled = bottom:GetAttribute("Open") == true end
    -- конец обработчика
    end)
    -- нажали «Взять»
    prompt.Triggered:Connect(function(player)
        -- отдаём игроку
        item.Interact(player, prompt, handle, itemName)
    -- конец обработчика
    end)
-- конец функции
end

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

Monster

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

-- события каждого кадра
local RunService = game:GetService("RunService")
-- здесь лежит образец монстра
local SS = game:GetService("ServerStorage")
-- все игроки
local Players = game:GetService("Players")

-- таблица модуля
local monster = {}
-- скорость: studs в секунду
monster.speed = 20

-- видит ли монстр игрока (нет ли стены между ними)
local function CanSee(model, root)
    -- откуда смотрит
    local origin = model.Monster.Position
    -- настройки луча
    local params = RaycastParams.new()
    -- режим «не замечать список»
    params.FilterType = Enum.RaycastFilterType.Exclude
    -- сам монстр не мешает
    local ignore = {model}
    -- не больше 20 попыток
    for _ = 1, 20 do
        -- обновляем список
        params.FilterDescendantsInstances = ignore
        -- луч к игроку
        local result = workspace:Raycast(origin, root.Position - origin, params)
        -- ни во что не попал
        if not result then return false end
        -- попал в игрока — видит
        if result.Instance:IsDescendantOf(root.Parent) then return true end
        -- стена, шкаф, барьер — не видит
        if result.Instance.CanCollide then return false end
        -- невидимая деталь (Trigger, Enter…) — смотрим сквозь неё
        table.insert(ignore, result.Instance)
    -- следующая попытка
    end
    -- слишком много преград
    return false
-- конец функции
end

-- ищет игроков в зоне монстра
function monster.FindPlayers(model)
    -- персонажи всех игроков
    local characters = {}
    -- каждый игрок
    for _, player in ipairs(Players:GetPlayers()) do
        -- есть персонаж — в список
        if player.Character then table.insert(characters, player.Character) end
    -- конец перебора
    end
    -- настройки поиска
    local params = OverlapParams.new()
    -- искать только среди списка
    params.FilterType = Enum.RaycastFilterType.Include
    -- список — персонажи
    params.FilterDescendantsInstances = characters
    -- детали персонажей внутри зоны Emitter
    for _, part in ipairs(workspace:GetPartsInPart(model.Emitter, params)) do
        -- центр персонажа
        if part.Name == "HumanoidRootPart" then
            -- его здоровье
            local humanoid = part.Parent:FindFirstChildOfClass("Humanoid")
            -- жив и на виду
            if humanoid and humanoid.Health > 0 and CanSee(model, part) then
                -- монстр настиг
                humanoid.Health = 0
            -- конец проверки
            end
        -- конец проверки
        end
    -- конец перебора
    end
-- конец функции
end

-- плавно ведёт монстра к детали target
function monster.LerpTo(model, target)
    -- откуда
    local start = model:GetPivot()
    -- сколько лететь
    local distance = (start.Position - target.Position).Magnitude
    -- сколько секунд (не ноль)
    local duration = math.max(distance / monster.speed, 0.05)
    -- доля пути: 0 — начало, 1 — конец
    local alpha = 0
    -- пока не долетел
    while alpha < 1 do
        -- прибавляем долю за прошедший кадр
        alpha = math.min(alpha + RunService.Heartbeat:Wait() / duration, 1)
        -- точка между началом и целью
        model:PivotTo(start:Lerp(target.CFrame, alpha))
        -- каждый кадр ищем игроков
        monster.FindPlayers(model)
    -- конец цикла
    end
-- конец функции
end

-- проводит монстра через комнаты
function monster.Navigate(model, fromNumber, toNumber, rooms)
    -- каждая комната по порядку
    for i = fromNumber, toNumber do
        -- комната
        local roomModel = rooms[i]
        -- ко входу
        if roomModel:FindFirstChild("Enter") then monster.LerpTo(model, roomModel.Enter) end
        -- точки поворота в извитых комнатах
        local waypoints = roomModel:FindFirstChild("Waypoints")
        -- есть
        if waypoints then
            -- по номерам 1, 2, 3…
            for w = 1, #waypoints:GetChildren() do
                -- точка с этим номером
                local point = waypoints:FindFirstChild(tostring(w))
                -- летим к ней
                if point then monster.LerpTo(model, point) end
            -- конец перебора
            end
        -- конец проверки
        end
        -- к выходу
        monster.LerpTo(model, roomModel.Exit)
    -- конец перебора
    end
-- конец функции
end

-- монстр проходит комнату number и следующую
function monster.New(number, rooms)
    -- дальше последней комнаты не идёт
    local toNumber = math.min(number + 1, #rooms)
    -- откуда начинает
    local startRoom = rooms[number]
    -- копия монстра
    local model = SS.Enemies.RedMonster:Clone()
    -- ставим на вход комнаты
    model:PivotTo((startRoom:FindFirstChild("Enter") or startRoom.Exit).CFrame)
    -- появляется
    model.Parent = workspace
    -- гул появления
    model.Monster.Appear:Play()
    -- проходит комнаты
    monster.Navigate(model, number, toNumber, rooms)
    -- исчезает (звук Disappear сыграет сам — PlayOnRemove)
    model:Destroy()
-- конец функции
end

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

EndLocal

Где: StarterGui → EndGui → Frame · LocalScript
Зачем: Финальная катсцена.

-- плавные изменения
local TS = game:GetService("TweenService")
-- чёрный экран
local frame = script.Parent
-- надпись
local text = frame:WaitForChild("EndText")
-- камера игрока
local camera = workspace.CurrentCamera
-- сигнал финала
local endEvent = game.ReplicatedStorage:WaitForChild("EndEvent")
-- 2 секунды, ровно
local tweenInfo = TweenInfo.new(2, Enum.EasingStyle.Linear)

-- экран пока прозрачный
frame.BackgroundTransparency = 1
-- текст тоже
text.TextTransparency = 1

-- сервер прислал финал
endEvent.OnClientEvent:Connect(function()
    -- точка камеры — берём сейчас, когда комната уже на месте
    local cameraPart = workspace.EndRoom:WaitForChild("CameraPart")
    -- развернуться к двери
    local goal = cameraPart.CFrame * CFrame.Angles(0, math.rad(180), 0)
    -- камерой управляет скрипт
    camera.CameraType = Enum.CameraType.Scriptable
    -- камера плывёт и разворачивается
    TS:Create(camera, tweenInfo, {CFrame = goal}):Play()
    -- смотрим, как закрывается дверь
    task.wait(10)
    -- экран темнеет
    TS:Create(frame, tweenInfo, {BackgroundTransparency = 0}):Play()
    -- чуть позже
    task.wait(0.4)
    -- появляется надпись
    TS:Create(text, tweenInfo, {TextTransparency = 0}):Play()
-- конец обработчика
end)

Проверка

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

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