Announcing DragDetectors!

albeit complicated, yeah it fits my needs. i wonder if there’ll be a way to support this just from drag controllers

7 Likes

Exciting! I don’t see anything wrong with this, I think everybody can agree that this is a very cool update!

5 Likes

Finalmente! Estava muito ansioso para esse recurso dentro do Roblox! (I’am speaking portuguese)

4 Likes

@xynxae

DragDetectors work with lines and planes in space, and in general, they try to keep your object at the location on those lines or planes that is below, or closest to, the cursor.

So if you are dragging on the ground plane, it’s going to try to keep the object below your cursor on the ground, regardless of where you move. Imagine you are moving a chess piece and your cursor is over the square you are interested in. You don’t want that piece to move if your cursor stays over that spot, regardless of where your player is going.

So when you’re in first person and rotate the camera, watch where your cursor is; it’s probably gliding over different locations at different distances which explains the behavior.

Okay, great, so that explains it, but in this case, it’s not what you want. That’s where setting DragDetector.DragStyle = Enum.DragDetectorDragStyle.Scriptable comes in. As you see in “Lift and Carry” we use that, and then we get to add a function that says exactly where we want to place the object, given a cursorRay as input.

In general, we prototype new built-in DragStyles by scripting lua examples. There are a few in the example worlds. One is the “Lift And Carry” (which as you see is a bit complex) and another very short one is “Throw-It” (which picks a new plane of motion when you click, so you move it parallel to the face of the cube. you’ve clicked on; it gives neat control). The lua examples show you ways that you can create new behaviors and we hope you do.

We can always fold these in to be automatically provided, but [a] we don’t want a super long list, and [b] we only want to do that with things we are certain are perfect for the behavior they promise. “Lift And Carry” is pretty complex, and not perfect, so we wouldn’t add it until, at the very least, we have enough feedback that it’s high enough quality. Plus, if you look at the script, there are a bunch of parameters like CARRY_DISTANCE and 4 parameters to limit the range of motion. We wouldn’t want to add the DragStyle without the controls, but we don’t want to add 4 new Properties just to support that DragStyle. Providing the best API is all a balance of utility and elegance, and it’s not an exact science.

So we encourage you to innovate and share; let us know what you want most, and if you figure something out, share your methods, if you are willing.

We are listening; that’s why we prototyped “Lift and Carry” as an example.

6 Likes

Love everything about it!!! It really reminds me back when Proximity Prompts released, which was a feature that heavily improved development while requiring 90% less scripting, making it far more friendly for beginner developers. Drag detectors are basically that, but 10 times better.

One question I had while experimenting, while the ResponseStyle property is set to “Physical”, there is a property called “MaxTorque” and “MaxForce”, which I can’t find the difference between these two in the documentation. What makes them different?

Otherwise, very exciting feature, keep it the nice work!!

4 Likes

@RealCedminer66
MaxForce and MaxTorque are only relevant in Physical Reponse style when moving a non-anchored part.

The MaxForce is the most force the DragDetector can apply to change an object’s position. It’s used with every DragStyle, because even RotateAxis and RotateTrackball can translate the object if the rotation makes it orbit around a point instead of its own pivot (which happens if you set the DragDetector.ReferenceInstance to be an object with a different pivot than the part).

The MaxTorque is the greatest torque (kind of like a rotational force, if you’re not used to physics) that the DragDetector can apply to change an object’s orientation. It’s only going to come into play for RotateAxis, RotateTrackball, Scriptable, and BestForDevice in the case where you’re using VR (BestForDevice in VR gives you full 6DOF control).

By default we set them very large so you can move most anything. But what if you want to limit the strength of users when they lift it? You might have a boulder with four draggable handles welded to it, each of which has a lower MaxForce, You can tune this so the boulder only lifts when you get 3 other players to help you! Or you could set the maxForce to different amounts as players gain strength; or when different players attempt to lift it depending on their ability.

9 Likes

Ever since this feature’s beta was announced, I’ve been looking forward to using this for my Puzzle Dungeon game, where dungeon rooms are moved around via a control panel.

I just started the rework a few days ago, and it works splendidly. I especially appreciate the inclusion of ray parameters, making it convenient to check for overlapping parts.
NewRoomSwapping
Thank you for this wonderful feature, and for working with developers throughout the beta to ensure this released well!

17 Likes

Just had a cool game idea with this new update!

4 Likes

Great work ensuring all platforms are supported for this feature on release! Pass on :raised_hands: to the team members who worked on this for us!

6 Likes

I just wanted to mention that it looks like the DragStyleFunction is called before DragStart is called. This means it’s not possible to have the currently dragged part already tracked for doing calculations in the DragStyleFunction, which is giving me trouble adapting the Lift and Carry demo. I have to set the DragStyleFunction inside DragStart the first time it’s fired.

RobloxStudioBeta_2023-10-11_20-57-07

local dragger = script.Parent

dragger.DragStart:Connect(function()
	print("Start")
end)

dragger.DragContinue:Connect(function()
	print("Continue")
end)

dragger.DragEnd:Connect(function()
	print("End")
end)

dragger.DragStyle = Enum.DragDetectorDragStyle.Scriptable
dragger:SetDragStyleFunction(function(cursorRay)
	print("DragStyleFunction")
	return nil
end)
8 Likes

@PeZsmistic interesting.
In the server case, where runLocally is default and you write server scripts (the default) then the calls are made in the order you expect. You can save the clickedPart given to the dragStart and use it the first time the dragStyle function is called.

But if runLocally is true and yours is a client script, then the order is reversed and your dragStyle function is called once before the dragStart is invoked.

We will look into the issue.

To help us out:
What is the fallout of the problem when you run your code? I guess you don’t know what to return on the first invocation of dragStyle because you don’t know which part to care about?

Can you share just what you are doing to call the dragStyle function inside your dragStart callback that makes it work better?

Thanks

8 Likes

This is so cool, I can totally apply this to my ID project and make it even better! Thank you! :smiley:

4 Likes

Yeah, this is effectively the issue. I tried returning CFrame.new() and it seemed fine, but this doesn’t feel right.

I’ve wrapped the Lift and Drag code into a Lua class that allows me to wrap it around a prop model. Below is the new() method. Note, in this, myDraggedPart is renamed to lastDraggedPart.

function Class.new(instance, dragDetector)
	local self = setmetatable({}, Class)
	self.janitor = Janitor.new()
	
	self.mainInstance = instance
	self.dragDetector = dragDetector
	self.lastViewFrame = CFrame.new()
	self.lastDraggedPart = nil
	self.isFirstDragInitialized = false
	
	if self.mainInstance:IsA("Model") then
		local _,size = self.mainInstance:GetBoundingBox()
		self.mainInstanceMaxDimension =  self:GetLargestDimension(size)
	else 
		self.mainInstanceMaxDimension = self:GetLargestDimension(self.mainInstance.Size)
	end
	
	dragDetector.DragStyle = Enum.DragDetectorDragStyle.Scriptable
	
	self.janitor:Add(dragDetector.DragStart:Connect(function(player, ray, viewFrame, hitFrame, clickedPart)
		if not self.isFirstDragInitialized then
			dragDetector:SetDragStyleFunction(function(cursorRay)
				return self:GetDesiredNewWorldCFrame(cursorRay)
			end)
			self.isFirstDragInitialized = true
		end
		
		if self.lastDraggedPart ~= nil and clickedPart ~= self.lastDraggedPart then
			-- Release the ownership here so there's no funny delays if you're yeeting stuff
			DraggableDragStartedEvent:FireServer(self.mainInstance, self.lastDraggedPart, false) 
		end
		
		self.lastDraggedPart = clickedPart
		DraggableDragStartedEvent:FireServer(self.mainInstance, self.lastDraggedPart, true)
	end))
	

	self.janitor:Add(dragDetector.DragContinue:Connect(function(player, ray, viewFrame)
		self.lastViewFrame = viewFrame
	end))
	
	
	return self
end
If it's useful to anyone, here's a more complete collection of scripts. There's some game-specific abstraction that I can't include here, but it should be straight-forward to adjust.

Calling Draggable

local model -- Some prop model
local dragDetector = Instance.new("DragDetector")
dragDetector.RunLocally = true
dragDetector.MaxForce = 100000
dragDetector.Responsiveness = 15
local Draggable = Draggable.new(model, dragDetector)	
dragDetector.Parent = model

Draggable

local RS = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local LocalPlayer = Players.LocalPlayer

local modules = require(RS:WaitForChild("Modules"))
local Janitor = modules.require("Janitor")
local RemoteGetter = modules.require("RemoteGetter")

local DraggableDragStartedEvent = RemoteGetter.getRemoteEvent("DraggableDragStarted")

-- This specifies how far in front of the player the object will be carried.
local MAX_CARRY_DISTANCE = 4

-- These parameters control how far the object can be dragged to the left/right or up/down,
-- relative to the player, when you are carrying it
local MAX_X_PLANE_OFFSET = 4
local MAX_Y_PLANE_OFFSET = 4

local Class = {}
Class.__index = Class

----------------------------------------

function Class:GetLargestDimension(vector:Vector3)
	return math.max(math.max(vector.X, vector.Y), vector.Z)
end


function Class:GetCarryDistance(character:Model)
	if character ~= nil then
		local _,charSize:Vector3 = character:GetBoundingBox()
		return MAX_CARRY_DISTANCE + charSize.Z/2 + self.mainInstanceMaxDimension/2
	end

	return MAX_CARRY_DISTANCE + self.mainInstanceMaxDimension/2
end


function Class:GetMaxXPlaneOffset(character:Model)
	if character ~= nil then
		local _,charSize:Vector3 = character:GetBoundingBox()
		return MAX_X_PLANE_OFFSET + charSize.X/2 + self.mainInstanceMaxDimension/2
	end
	
	return MAX_X_PLANE_OFFSET + self.mainInstanceMaxDimension/2
end


function Class:GetMaxYPlaneOffset(character:Model)
	if character ~= nil then
		local _,charSize:Vector3 = character:GetBoundingBox()
		return MAX_Y_PLANE_OFFSET + charSize.Y/2 + self.mainInstanceMaxDimension/2
	end

	return MAX_Y_PLANE_OFFSET + self.mainInstanceMaxDimension/2
end


-- Blockcast tells us how far a box the size of the part will travel before hitting something.
-- This is an approximation, but better than a raycast, which does not consider the size of the part
function Class:GetBlockcastAvoidingCharacter(rayToShoot, characterToAvoid)
	local raycastParams = RaycastParams.new()
	raycastParams.FilterDescendantsInstances = {self.dragDetector.Parent, characterToAvoid }
	raycastParams.FilterType = Enum.RaycastFilterType.Exclude
	local direction = rayToShoot.Direction
	local fromFrame =  self.lastDraggedPart:GetPivot().Rotation + rayToShoot.Origin
	return workspace:Blockcast(fromFrame, self.lastDraggedPart.Size, direction, raycastParams)
end


function Class:IsPhysicalDrag()
	return self.dragDetector and self.dragDetector.ResponseStyle == Enum.DragDetectorResponseStyle.Physical and self.lastDraggedPart.Anchored == false
end


function Class:GetNewFrameGivenRayFromPlayer(rayFromPlayer, carryDistance)
	local rayToShoot = Ray.new(rayFromPlayer.Origin, rayFromPlayer.Direction.Unit * carryDistance)
	local player = game.Players.LocalPlayer
	local blockcastResult = self:GetBlockcastAvoidingCharacter(rayToShoot, player.character)
	
	-- if we don't hit anything closer than carryDistance, we'll place it carryDistance ahead of us.
	local finalDistance = carryDistance
	if blockcastResult then
		finalDistance = blockcastResult.Distance
		if self:IsPhysicalDrag() then
			-- this will attempt to place it slightly beyond the surface.
			-- in the physical case, this helps push/slide along walls. (We don't to this in the geometric case or we'd penetrate the other objects)
			finalDistance = finalDistance + 0.2
		end
	end	
	
	local finalPosition = rayFromPlayer.Origin + rayFromPlayer.Direction.Unit * finalDistance
	return self.lastDraggedPart:GetPivot().Rotation + finalPosition
end


local EPSILON = 0.00001
function Class:IntersectRayPlane(ray, planeOrigin, _planeNormal)
	local planeNormal = _planeNormal.Unit
	local planeDistance = -1 * planeNormal:Dot(planeOrigin)	
	local unitRayDir = ray.Direction.Unit
	local rate = unitRayDir:Dot(planeNormal)	
	if math.abs(rate) < EPSILON then
		-- ray is parellel to plane surface; cannot intersect
		return false, Vector3.new()
	end
	local t = -(planeDistance + ray.Origin:Dot(planeNormal)) / rate
	return true, ray.Origin + unitRayDir * t
end


function Class:IntersectPlayerPlaneWithinBounds(ray, playerPlaneOrigin, playerForward)
	local char = LocalPlayer.Character
	local maxXPlaneOffset = self:GetMaxXPlaneOffset(char)
	local maxYPlaneOffset = self:GetMaxYPlaneOffset(char)
	
	-- the playerPlane is a plane in front of the player a distance of carryDistance
	-- it should be parallel to the ground; remove the Y component just in case
	local levelPlayerForward = Vector3.new(playerForward.x, 0, playerForward.z)
	local success, intersection = self:IntersectRayPlane(ray, playerPlaneOrigin, levelPlayerForward)
	if not success then
		return success, intersection
	end
	
	-- the plane intersection could be way off to the side, or too high or low.
	-- clamp it within bounds of the playerPlaneOrigin, which is a point in front of the player
	local offsetFromOrigin = intersection - playerPlaneOrigin
	local planeXDir = levelPlayerForward:Cross(Vector3.yAxis).Unit
	local planeYDir = Vector3.yAxis
	local xDist = offsetFromOrigin:Dot(planeXDir)
	local yDist = offsetFromOrigin:Dot(planeYDir)
	xDist = if xDist < -maxXPlaneOffset then -maxXPlaneOffset else xDist
	xDist = if xDist > maxXPlaneOffset then maxXPlaneOffset else xDist
	yDist = if yDist < -maxYPlaneOffset then -maxYPlaneOffset else yDist
	yDist = if yDist > maxYPlaneOffset then maxYPlaneOffset else yDist
	local newIntersection = playerPlaneOrigin + xDist * planeXDir + yDist * planeYDir
	return true, newIntersection	
end


function Class:GetRayEmanatingFromPlayer(cursorRay, carryDistance)
	local player = game.Players.LocalPlayer
	local headPart = nil
	local rootPart = nil
	if player and player.Character then
		headPart = player.Character:FindFirstChild("Head")
		rootPart = player.Character:WaitForChild("HumanoidRootPart")
	end
	if not headPart or not rootPart then
		-- we can't get any ray based on the player, fall back to the cursorRay
		return cursorRay
	end
	local isFirstPerson = headPart.LocalTransparencyModifier > 0.6
	if isFirstPerson then
		-- when we are in first person, the cursorRay has an origin in the center of the view and a 
		-- direction forward in the view direction. So we are shooting a direction straight ahead from our POV
		return cursorRay
	end

	-- when we are not in first person, we intersect with a plane facing us, but in front of the player's root, part to find the 
	-- desired location.
	-- then we construct a ray from the player's character directed toward the intersection with that plane.
	local playerLocation = rootPart:GetPivot().Position
	local viewFrameLookAt = self.lastViewFrame.LookVector.Unit
	local playerPlaneOrigin = playerLocation + viewFrameLookAt * carryDistance
	local success, planeIntersection = self:IntersectPlayerPlaneWithinBounds(cursorRay, playerPlaneOrigin, viewFrameLookAt)
	if not success then
		return cursorRay
	end
	-- a ray from the playerLocation to the intersection we just found
	return Ray.new(playerLocation, planeIntersection - playerLocation)
end


function Class:GetDesiredNewWorldCFrame(cursorRay)
	local char = LocalPlayer.Character
	local carryDistance = self:GetCarryDistance(char)
	
	local rayFromPlayer = self:GetRayEmanatingFromPlayer(cursorRay, carryDistance)
	return self:GetNewFrameGivenRayFromPlayer(rayFromPlayer, carryDistance)
end


function Class.new(instance, dragDetector)
	local self = setmetatable({}, Class)
	self.janitor = Janitor.new()
	
	self.mainInstance = instance
	self.dragDetector = dragDetector
	self.lastViewFrame = CFrame.new()
	self.lastDraggedPart = nil
	self.isFirstDragInitialized = false
	
	if self.mainInstance:IsA("Model") then
		local _,size = self.mainInstance:GetBoundingBox()
		self.mainInstanceMaxDimension =  self:GetLargestDimension(size)
	else 
		self.mainInstanceMaxDimension = self:GetLargestDimension(self.mainInstance.Size)
	end
	
	dragDetector.DragStyle = Enum.DragDetectorDragStyle.Scriptable
	
	self.janitor:Add(dragDetector.DragStart:Connect(function(player, ray, viewFrame, hitFrame, clickedPart)
		if not self.isFirstDragInitialized then
			dragDetector:SetDragStyleFunction(function(cursorRay)
				return self:GetDesiredNewWorldCFrame(cursorRay)
			end)
			self.isFirstDragInitialized = true
		end
		
		if self.lastDraggedPart ~= nil and clickedPart ~= self.lastDraggedPart then
			-- Release the ownership here so there's no funny delays if you're yeeting stuff
			DraggableDragStartedEvent:FireServer(self.mainInstance, self.lastDraggedPart, false) 
		end
		
		self.lastDraggedPart = clickedPart
		DraggableDragStartedEvent:FireServer(self.mainInstance, self.lastDraggedPart, true)
	end))
	

	self.janitor:Add(dragDetector.DragContinue:Connect(function(player, ray, viewFrame)
		self.lastViewFrame = viewFrame
	end))
	
	
	return self
end


function Class:Destroy()
	self.mainInstance = nil
	self.dragDetector = nil
	self.lastViewFrame = nil
	self.lastDraggedPart = nil
	self.firstDragInitialized = nil
	self.mainInstanceMaxDimension = nil
	
	self.janitor:Destroy()
	self.janitor = nil
end

----------------------------------------

return Class

DraggableDragStarted Listener

local Players = game:GetService("Players")
local RS = game:GetService("ReplicatedStorage")

local modules = require(RS:WaitForChild("Modules"))
local CS = modules.require("CollectionService")
local RemoteGetter = modules.require("RemoteGetter")
local DraggableDragStartedEvent = RemoteGetter.getRemoteEvent("DraggableDragStarted")

local MAX_ALLOWABLE_DISTANCE_TO_DRAG = 100

-----------------------------------------

return function()
	DraggableDragStartedEvent.OnServerEvent:Connect(function(player:Player, mainDraggable:PVInstance, draggedPart:BasePart, setOwnershipToPlayer:boolean)
		if draggedPart == nil or mainDraggable == nil then return end
		if not draggedPart:IsA("BasePart") then return end
		if not draggedPart:IsDescendantOf(workspace) then return end
		if not mainDraggable:HasTag(CS.TAG_DRAGGABLE) then return end
		if not draggedPart:IsDescendantOf(mainDraggable) then return end
		
		local character = player.Character
		if not character then return end
		local pivot = character:GetPivot()
		if (pivot.Position - draggedPart.Position).Magnitude > MAX_ALLOWABLE_DISTANCE_TO_DRAG then return end
		
		-- Assign network ownership
		if setOwnershipToPlayer then
			draggedPart:SetNetworkOwner(player)
		else
			draggedPart:SetNetworkOwner(nil)
		end
	end)
end
10 Likes

With that issue worked around wow I can’t believe how well this works. :sob:

7 Likes

:tada::tada::tada:

DragDectectors are amazing and make so many things much much easier.

I personally have used them to make a simple and easy to use build mode for one of my upcoming games!

Please keep releasing tools like this that make complex code simpler, they are really useful!

5 Likes

We’re excited to announce that Drag Detecto is now available! When this was in beta, I used the drag detector’s events to create something like this I can’t imagine how creative people will get with this! From a developer’s perspective. It’s nice to see a lot of updates these days.

9 Likes

Ohh love this update, this is so cool!
It’s going to be better and easy to make drag system
Can’t wait to see people make some funny stuff with this DragDetectors!

4 Likes

Nice! Now it’s gonna be easier to all developers if they need “drag” scripts or something like that.

4 Likes

🥳🥳🥳 yeeee🎉🎉🎉

Dragdettectors are out now

I will tomorrow make my dragdettectors world public

@PrinceTybalt what are the towers in test world 2 for

5 Likes

DragDetector doesn’t work in my Roblox Studio right now (it works with public Roblox), is it the same for you all? Do I need to do anything? I can’t test it in Roblox Studio, so I’m testing it publicly.

5 Likes