Загрузка данных



-- CatPlacementClient
-- LocalScript в StarterPlayer > StarterPlayerScripts

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local UserInputService = game:GetService("UserInputService")
local RunService = game:GetService("RunService")
local GuiService = game:GetService("GuiService")
local Workspace = game:GetService("Workspace")

local player = Players.LocalPlayer
local placeEvent = ReplicatedStorage:WaitForChild("PlaceCatEvent")

local ROTATION_STEP = math.rad(15)

local currentTool = nil
local previewModel = nil
local currentFloor = nil
local currentPosition = nil
local rotationY = 0
local canPlace = false
local lastTouchPosition = nil
local renderConnection = nil
local characterConnections = {}

--==================================================
-- МОБИЛЬНЫЙ ИНТЕРФЕЙС
--==================================================

local screenGui = Instance.new("ScreenGui")
screenGui.Name = "CatPlacementGui"
screenGui.ResetOnSpawn = false
screenGui.Enabled = false
screenGui.DisplayOrder = 20
screenGui.Parent = player:WaitForChild("PlayerGui")

local function makeButton(name, text, position, size, color)
    local button = Instance.new("TextButton")
    button.Name = name
    button.Text = text
    button.Position = position
    button.Size = size
    button.BackgroundColor3 = color
    button.BackgroundTransparency = 0.1
    button.TextColor3 = Color3.new(1, 1, 1)
    button.TextScaled = true
    button.Font = Enum.Font.GothamBold
    button.AutoButtonColor = true
    button.BorderSizePixel = 0
    button.Parent = screenGui

    local corner = Instance.new("UICorner")
    corner.CornerRadius = UDim.new(0, 12)
    corner.Parent = button

    local constraint = Instance.new("UITextSizeConstraint")
    constraint.MinTextSize = 12
    constraint.MaxTextSize = 24
    constraint.Parent = button

    return button
end

local rotateLeftButton = makeButton(
    "RotateLeft",
    "↺ 15°",
    UDim2.new(1, -196, 1, -150),
    UDim2.fromOffset(88, 48),
    Color3.fromRGB(65, 85, 125)
)

local rotateRightButton = makeButton(
    "RotateRight",
    "↻ 15°",
    UDim2.new(1, -100, 1, -150),
    UDim2.fromOffset(88, 48),
    Color3.fromRGB(65, 85, 125)
)

local placeButton = makeButton(
    "Place",
    "ПОСТАВИТЬ",
    UDim2.new(1, -196, 1, -92),
    UDim2.fromOffset(184, 48),
    Color3.fromRGB(45, 155, 75)
)

local cancelButton = makeButton(
    "Cancel",
    "ОТМЕНА",
    UDim2.new(1, -196, 1, -36),
    UDim2.fromOffset(184, 36),
    Color3.fromRGB(165, 65, 65)
)

--==================================================
-- ОЧИСТКА
--==================================================

local function destroyPreview()
    if renderConnection then
        renderConnection:Disconnect()
        renderConnection = nil
    end

    if previewModel then
        previewModel:Destroy()
        previewModel = nil
    end

    currentTool = nil
    currentFloor = nil
    currentPosition = nil
    canPlace = false
    rotationY = 0
    screenGui.Enabled = false
end

--==================================================
-- СОЗДАНИЕ ПРЕДПРОСМОТРА
--==================================================

local function createPreview(tool)
    local model = Instance.new("Model")
    model.Name = "CatPlacementPreview"

    for _, child in ipairs(tool:GetChildren()) do
        if child:IsA("BasePart") then
            local part = child:Clone()

            for _, descendant in ipairs(part:GetDescendants()) do
                if descendant:IsA("Script")
                    or descendant:IsA("LocalScript")
                    or descendant:IsA("ProximityPrompt") then
                    descendant:Destroy()
                end
            end

            part.Anchored = true
            part.CanCollide = false
            part.CanTouch = false
            part.CanQuery = false
            part.Transparency = math.max(part.Transparency, 0.45)
            part.Parent = model
        end
    end

    local handle = model:FindFirstChild("Handle")

    if not handle or not handle:IsA("BasePart") then
        model:Destroy()
        warn("У кота отсутствует BasePart с именем Handle:", tool.Name)
        return nil
    end

    model.PrimaryPart = handle
    model.Parent = Workspace

    return model
end

--==================================================
-- ПРОВЕРКА СОБСТВЕННОЙ БАЗЫ
--==================================================

local function isOurFloor(target)
    if not target or not target:IsA("BasePart") then
        return false
    end

    if target.Name ~= "Floor" then
        return false
    end

    local baseName = player:GetAttribute("BaseName")

    if typeof(baseName) ~= "string" then
        return false
    end

    local base = Workspace:FindFirstChild(baseName)

    if not base or not base:IsA("Model") then
        return false
    end

    local floor = base:FindFirstChild("Floor")

    return target == floor
        and base:GetAttribute("OwnerUserId") == player.UserId
end

local function pointInsideFloor(floor, position)
    local localPosition = floor.CFrame:PointToObjectSpace(position)

    return math.abs(localPosition.X) <= floor.Size.X / 2
        and math.abs(localPosition.Z) <= floor.Size.Z / 2
end

--==================================================
-- ПОВОРОТ И ПОЗИЦИЯ
--==================================================

local function rotateCat(direction)
    if not previewModel then
        return
    end

    rotationY += ROTATION_STEP * direction
end

local function positionPreview(position, floor)
    if not previewModel then
        return
    end

    -- Свободное размещение без сетки.
    -- Угол поворота хранится отдельно для каждой установки.
    previewModel:PivotTo(
        CFrame.new(position) * CFrame.Angles(0, rotationY, 0)
    )

    -- Ставим нижнюю точку модели на поверхность пола.
    local boxCFrame, boxSize = previewModel:GetBoundingBox()
    local bottomY = boxCFrame.Position.Y - boxSize.Y / 2
    local floorTopY = floor.Position.Y + floor.Size.Y / 2
    local correction = floorTopY - bottomY

    previewModel:PivotTo(
        previewModel:GetPivot()
            + Vector3.new(0, correction, 0)
    )
end

--==================================================
-- ПОДТВЕРЖДЕНИЕ И ОТМЕНА
--==================================================

local function confirmPlacement()
    if not previewModel
        or not currentTool
        or not currentFloor
        or not currentPosition
        or not canPlace then
        return
    end

    placeEvent:FireServer(
        currentFloor,
        currentPosition,
        rotationY
    )
end

local function cancelPlacement()
    destroyPreview()
end

rotateLeftButton.Activated:Connect(function()
    rotateCat(-1)
end)

rotateRightButton.Activated:Connect(function()
    rotateCat(1)
end)

placeButton.Activated:Connect(confirmPlacement)
cancelButton.Activated:Connect(cancelPlacement)

--==================================================
-- ПОИСК ПОЛА ПОД УКАЗАТЕЛЕМ
--==================================================

local function isTouchDevice()
    return UserInputService.TouchEnabled
        and not UserInputService.MouseEnabled
end

local function getScreenPosition()
    if isTouchDevice() then
        return lastTouchPosition
    end

    return UserInputService:GetMouseLocation()
end

local function getFloorUnderPointer()
    local screenPosition = getScreenPosition()

    if not screenPosition then
        return nil, nil
    end

    local camera = Workspace.CurrentCamera

    if not camera then
        return nil, nil
    end

    -- ScreenPointToRay использует экранные координаты указателя.
    local ray = camera:ScreenPointToRay(
        screenPosition.X,
        screenPosition.Y
    )

    local params = RaycastParams.new()
    params.FilterType = Enum.RaycastFilterType.Exclude

    local exclusions = {}

    if player.Character then
        table.insert(exclusions, player.Character)
    end

    if previewModel then
        table.insert(exclusions, previewModel)
    end

    params.FilterDescendantsInstances = exclusions
    params.IgnoreWater = true

    local result = Workspace:Raycast(
        ray.Origin,
        ray.Direction * 1000,
        params
    )

    if not result then
        return nil, nil
    end

    if not isOurFloor(result.Instance) then
        return nil, nil
    end

    if not pointInsideFloor(result.Instance, result.Position) then
        return nil, nil
    end

    return result.Instance, result.Position
end

--==================================================
-- КАСАНИЯ И МОБИЛЬНЫЕ КНОПКИ
--==================================================

local function isTouchOverGui(position)
    local guiObjects = GuiService:GetGuiObjectsAtPosition(
        position.X,
        position.Y
    )

    for _, object in ipairs(guiObjects) do
        if object:IsDescendantOf(screenGui) then
            return true
        end
    end

    return false
end

UserInputService.InputBegan:Connect(function(input, processed)
    if input.UserInputType == Enum.UserInputType.Touch then
        local position = Vector2.new(
            input.Position.X,
            input.Position.Y
        )

        if not isTouchOverGui(position) then
            lastTouchPosition = position
        end

        return
    end

    if processed or not previewModel then
        return
    end

    if input.KeyCode == Enum.KeyCode.R then
        rotateCat(1)

    elseif input.KeyCode == Enum.KeyCode.T then
        rotateCat(-1)

    elseif input.KeyCode == Enum.KeyCode.Escape then
        cancelPlacement()

    elseif input.UserInputType == Enum.UserInputType.MouseButton1 then
        confirmPlacement()
    end
end)

UserInputService.InputChanged:Connect(function(input)
    if input.UserInputType == Enum.UserInputType.Touch then
        local position = Vector2.new(
            input.Position.X,
            input.Position.Y
        )

        if not isTouchOverGui(position) then
            lastTouchPosition = position
        end
    end
end)

--==================================================
-- ПОИСК ЭКИПИРОВАННОГО КОТА
--==================================================

local function findEquippedCat()
    local character = player.Character

    if not character then
        return nil
    end

    for _, item in ipairs(character:GetChildren()) do
        if item:IsA("Tool")
            and item:GetAttribute("Income") ~= nil
            and item:GetAttribute("Rarity") ~= nil then
            return item
        end
    end

    return nil
end

--==================================================
-- ЗАПУСК РЕЖИМА РАЗМЕЩЕНИЯ
--==================================================

local function startPlacement(tool)
    if currentTool == tool and previewModel then
        return
    end

    destroyPreview()

    local model = createPreview(tool)

    if not model then
        return
    end

    currentTool = tool
    previewModel = model
    rotationY = 0
    screenGui.Enabled = true

    renderConnection = RunService.RenderStepped:Connect(function()
        if not currentTool
            or not currentTool.Parent
            or not previewModel
            or not previewModel.Parent then
            destroyPreview()
            return
        end

        local floor, position = getFloorUnderPointer()

        currentFloor = floor
        currentPosition = position
        canPlace = floor ~= nil and position ~= nil

        if canPlace then
            positionPreview(position, floor)
        end

        -- Зелёный = допустимая позиция.
        -- Красный = вне собственной базы.
        local previewColor

        if canPlace then
            previewColor = Color3.fromRGB(100, 255, 120)
        else
            previewColor = Color3.fromRGB(255, 90, 90)
        end

        for _, part in ipairs(previewModel:GetDescendants()) do
            if part:IsA("BasePart") then
                part.Color = previewColor
            end
        end
    end)
end

local function disconnectCharacterConnections()
    for _, connection in ipairs(characterConnections) do
        connection:Disconnect()
    end

    table.clear(characterConnections)
end

local function watchCharacter(character)
    destroyPreview()
    disconnectCharacterConnections()

    table.insert(
        characterConnections,
        character.ChildAdded:Connect(function(child)
            if child:IsA("Tool")
                and child:GetAttribute("Income") ~= nil
                and child:GetAttribute("Rarity") ~= nil then

                task.defer(function()
                    if child.Parent == character then
                        startPlacement(child)
                    end
                end)
            end
        end)
    )

    table.insert(
        characterConnections,
        character.ChildRemoved:Connect(function(child)
            if child == currentTool then
                destroyPreview()
            end
        end)
    )

    task.defer(function()
        if player.Character ~= character then
            return
        end

        local tool = findEquippedCat()

        if tool then
            startPlacement(tool)
        end
    end)
end

player.CharacterAdded:Connect(watchCharacter)

player.CharacterRemoving:Connect(function()
    destroyPreview()
    disconnectCharacterConnections()
end)

if player.Character then
    watchCharacter(player.Character)
end

print("CatPlacementClient запущен")