Example
Note: Pressing “Q” simulates the sphere’s position during a spherecast and “E” simulates the collision point
--[!] SERVICES
local RunService = game:GetService('RunService')
local PlayerService = game:GetService('Players')
local UserInputService = game:GetService('UserInputService')
local ContextActionService = game:GetService('ContextActionService')
--[!] CONST
local RAY_RADIUS = 2 -- describes the radius of the sphere we'll cast
local RAY_DISTANCE = 1000 -- describes the max magnitude/distance of the ray(s)
local RAY_OPTIONS = { -- describes optional flags / params for simulateSphereCast
CleanupDelay = 3,
UpdateFrequency = 1 / 60,
PlaybackDuration = 2,
}
local RAY_SHOW_TOUCH = true -- whether we should a touch button to dash
local RAY_KEYBIND_CODES = { -- the key(s) that can be used to dash
SPHERE = {
Enum.KeyCode.Q,
Enum.KeyCode.ButtonB, -- i.e. B on XBOX, Circle on PlayStation
},
INTERSECT = {
Enum.KeyCode.E,
Enum.KeyCode.ButtonX, -- i.e. X on XBOX, Square on PlayStation
},
}
local MOBILE_INPUTS = { --
[Enum.UserInputType.Gyro] = true,
[Enum.UserInputType.Touch] = true,
[Enum.UserInputType.Accelerometer] = true,
}
--[!] UTILS
--> used to check whether a player is alive
-- i.e. one that's alive and has both a humanoid + a root part
local function isAlive(character)
if typeof(character) ~= 'Instance' or not character:IsA('Model') or not character:IsDescendantOf(workspace) then
return false
end
local humanoid = character:FindFirstChildOfClass('Humanoid')
local humanoidState = humanoid and humanoid:GetState() or Enum.HumanoidStateType.Dead
local humanoidRootPart = humanoid and humanoid.RootPart or nil
if humanoidState == Enum.HumanoidStateType.Dead or not humanoidRootPart then
return false
end
return true
end
--> used to await a player's character
-- i.e. one that exists + is alive
local function tryGetCharacter(player)
if typeof(player) ~= 'Instance' or not player:IsA('Player') then
return nil
end
local character
while not character do
if not player or not player:IsDescendantOf(PlayerService) then
break
end
local char = player.Character
if not char then
player.CharacterAdded:Wait()
continue
end
if not isAlive(char) then
RunService.Stepped:Wait()
continue
end
character = char
end
return character
end
--> used to cleanup any connections/instances
local function cleanupDisposables(disposables)
for _, disposable in next, disposables do
local t = typeof(disposable)
if t == 'RBXScriptConnection' then
pcall(disposable.Disconnect, disposable)
elseif t == 'Instance' then
pcall(disposable.Destroy, disposable)
elseif t == 'function' then
disposable()
end
end
table.clear(disposables)
end
--> a non-perfect method of quickly deriving the device type
-- from the given input type, _e.g._ UserInputService::GetLastInputType
local function getDeviceName(inputType)
if typeof(inputType) == 'EnumItem' then
if MOBILE_INPUTS[inputType] then
return 'Touch'
elseif inputType.Name:match('Gamepad') then
return 'Gamepad'
end
end
return 'MouseAndKeyboard'
end
--> raycast from camera to either (a) mouse position; or (b) camera centre
-- dependent on the device type
local function getTargetResults(player, camera, params, device, maxDistance)
device = device or 'MouseAndKeyboard'
maxDistance = maxDistance or RAY_DISTANCE
local character = player.Character
local isValidCharacter = isAlive(character)
if not params then
params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.IgnoreWater = true
params.RespectCanCollide = false
if isValidCharacter then
params:AddToFilter(character)
end
end
local coords
if device == 'MouseAndKeyboard' or device == 'Gamepad' then
coords = UserInputService:GetMouseLocation()
else
coords = camera.ViewportSize*0.5
end
local ray = camera:ViewportPointToRay(coords.X, coords.Y)
local vec = ray.Direction*maxDistance
local result = workspace:Raycast(ray.Origin, vec, params)
local hit, pos, normal, material, distance
if not result then
hit = nil
pos = ray.Origin + vec
normal = Vector3.yAxis
material = Enum.Material.Air
distance = maxDistance
else
hit = result.Instance
pos = result.Position
normal = result.Normal
material = result.Material
distance = result.Distance
end
return {
Normal = normal,
Instance = hit,
Position = pos,
Material = material,
Distance = distance
}
end
local function deepCopy(orig, copies)
copies = copies or { }
local t = typeof(orig)
if t == 'table' then
if copies[orig] then
return copies[orig]
end
local copy = { }
copies[orig] = copy
for key, val in next, orig, nil do
copy[deepCopy(key, copies)] = deepCopy(val, copies)
end
setmetatable(copy, deepCopy(getmetatable(orig), copies))
return copy
end
return orig
end
--> merge tables together
-- where first table = source, and following tables
-- will overwrite those beneath it
local function mergeTables(...)
local result = { }
local len = select('#', ...)
for i = 1, len, 1 do
local trg = select(i, ...)
if typeof(trg) ~= 'table' then
continue
end
for key, value in next, trg do
local t = typeof(value)
if t == 'table' then
value = deepCopy(value)
end
if t ~= 'nil' then
result[key] = value
end
end
end
return result
end
--> animates the sphere cast
-- will show the intersection point if `ShowIntersection` opt flag
-- is provided
local function simulateSphereCast(origin, rayDirection, rayParams, sphereRadius, opts)
-- set up
opts = mergeTables(RAY_OPTIONS, opts or { })
sphereRadius = sphereRadius or RAY_RADIUS
local scaleFactor = opts.ScaleFactor or 1
local cleanupDelay = opts.CleanupDelay
local updateFrequency = opts.UpdateFrequency
local playbackDuration = opts.PlaybackDuration
local showIntersection = opts.ShowIntersection
local disposables = { }
local totalElapsed = 0
local lastFrameUpdate = 0
-- cast our ray
local result = workspace:Spherecast(origin, sphereRadius, rayDirection, rayParams)
local hit, pos, normal, material, distance
if not result then
hit = nil
pos = origin + rayDirection
normal = Vector3.yAxis
material = Enum.Material.Air
distance = rayDirection.Magnitude
else
hit = result.Instance
pos = result.Position
normal = result.Normal
material = result.Material
distance = result.Distance
end
-- det. the desired position
local desiredPosition
if not showIntersection then
desiredPosition = origin + rayDirection.Unit*distance
else
local displaced = pos - origin
local magnitude = displaced.Magnitude
local direction = displaced.Unit
desiredPosition = origin + direction*magnitude
end
-- simulate it
local scale = math.max(sphereRadius*2*scaleFactor, 0.1)
local part = Instance.new('Part')
part.Name = '__SPHERE_CAST'
part.Size = Vector3.one*scale
part.Shape = Enum.PartType.Ball
part.Anchored = true
part.CanQuery = false
part.CanTouch = false
part.Position = origin
part.CanCollide = false
part.CastShadow = false
part.BrickColor = BrickColor.Random()
part.TopSurface = 0
part.BottomSurface = 0
part.Transparency = 0.15
part.Parent = workspace
table.insert(disposables, function ()
task.delay(cleanupDelay, pcall, part.Destroy, part)
end)
local runtime
runtime = RunService.Stepped:Connect(function (gt, dt)
lastFrameUpdate += dt
if lastFrameUpdate < updateFrequency then
return
end
local frameTime = lastFrameUpdate
lastFrameUpdate = math.fmod(lastFrameUpdate, updateFrequency)
-- interpolate towards our target at time t
totalElapsed += frameTime - lastFrameUpdate
local alpha = math.min(totalElapsed / playbackDuration, 1)
part.Position = origin:Lerp(desiredPosition, alpha)
if alpha >= 1 then
-- cleanup when finished
cleanupDisposables(disposables)
return
end
end)
table.insert(disposables, runtime)
end
--[!] MAIN
local function beginTracking(player)
local character = tryGetCharacter(player)
if not character then
return
end
-- cleanup
local disposables = { }
-- set up
local camera = game.Workspace.CurrentCamera
local humanoid = character:FindFirstChildOfClass('Humanoid')
local rootPart = humanoid.RootPart
local rayParams = RaycastParams.new()
rayParams.FilterType = Enum.RaycastFilterType.Exclude
rayParams.IgnoreWater = true
rayParams.RespectCanCollide = false
rayParams:AddToFilter(character)
-- set up listener(s)
local died
died = humanoid.Died:Connect(function ()
cleanupDisposables(disposables)
end)
table.insert(disposables, died)
local function handleAction(action, inputState, inputObject)
if not isAlive(character) or inputState ~= Enum.UserInputState.Begin then
return Enum.ContextActionResult.Pass
end
local origin = rootPart.Position
local device = getDeviceName(inputObject.UserInputType)
local target = getTargetResults(player, camera, rayParams, device)
local displacement = target.Position - origin
local rayDirection = displacement.Unit
local useIntersect = action == 'ShowIntersection'
local scalingFactor = useIntersect and 0.1 or 1
simulateSphereCast(
origin, rayDirection*RAY_DISTANCE,
rayParams, RAY_RADIUS,
{
ScaleFactor = scalingFactor,
ShowIntersection = useIntersect,
}
)
return Enum.ContextActionResult.Sink
end
-- bind the keybindings +/- the touch button
ContextActionService:BindAction('ShowSphere', handleAction, RAY_SHOW_TOUCH, table.unpack(RAY_KEYBIND_CODES.SPHERE))
ContextActionService:BindAction('ShowIntersection', handleAction, RAY_SHOW_TOUCH, table.unpack(RAY_KEYBIND_CODES.INTERSECT))
table.insert(disposables, function ()
pcall(ContextActionService.UnbindAction, ContextActionService, 'ShowSphere')
pcall(ContextActionService.UnbindAction, ContextActionService, 'ShowIntersection')
end)
end
--[!] INIT
local player = PlayerService.LocalPlayer
if typeof(player.Character) ~= 'nil' then
beginTracking(player)
end
player.CharacterAdded:Connect(function ()
return beginTracking(player)
end)