491 lines
15 KiB
Lua
491 lines
15 KiB
Lua
--Panel3D: 3D GUI panels for VR
|
|
--written by 0xBAADF00D
|
|
local PIXELS_PER_STUD = 64
|
|
local SETTINGS_DISTANCE = 3.5
|
|
|
|
local CURSOR_HIDE_TIME = 2
|
|
local CURSOR_FADE_TIME = 0.125
|
|
|
|
local CoreGui = game:GetService('CoreGui')
|
|
local RobloxGui = CoreGui:WaitForChild("RobloxGui")
|
|
|
|
local Panel3D = {}
|
|
|
|
Panel3D.Panels = {
|
|
Lower = 1,
|
|
Hamburger = 2,
|
|
Settings = 3,
|
|
Keyboard = 4,
|
|
Chat = 5
|
|
}
|
|
|
|
Panel3D.Orientation = {
|
|
Horizontal = 1,
|
|
Fixed = 2
|
|
}
|
|
|
|
Panel3D.Visibility = {
|
|
BelowAngleThreshold = 1,
|
|
Modal = 2,
|
|
Normal = 3
|
|
}
|
|
|
|
local panelDefaultVectors = {
|
|
[Panel3D.Panels.Lower] = CFrame.Angles(math.rad(-45), 0, 0):vectorToWorldSpace(Vector3.new(0, 0, -5)),
|
|
[Panel3D.Panels.Hamburger] = CFrame.Angles(math.rad(-55), 0, 0):vectorToWorldSpace(Vector3.new(0, 0, -5)),
|
|
[Panel3D.Panels.Settings] = Vector3.new(0, 0, -SETTINGS_DISTANCE),
|
|
[Panel3D.Panels.Keyboard] = CFrame.Angles(math.rad(-22.5), 0, 0):vectorToWorldSpace(Vector3.new(0, 0, -5)),
|
|
[Panel3D.Panels.Chat] = CFrame.Angles(math.rad(5), 0, 0):vectorToWorldSpace(Vector3.new(0, 0, -5))
|
|
}
|
|
local DEFAULT_PANEL_LOCK_THRESHOLD = math.rad(-25)
|
|
local panelTransparencyBias = { --tuned values; raise the opacity value to this power
|
|
[Panel3D.Panels.Lower] = 6.5,
|
|
[Panel3D.Panels.Hamburger] = 8,
|
|
[Panel3D.Panels.Settings] = 0,
|
|
[Panel3D.Panels.Keyboard] = 1.5,
|
|
[Panel3D.Panels.Chat] = 1.5
|
|
}
|
|
local panels = {}
|
|
|
|
local headScale = 1
|
|
|
|
local renderStepName = "Panel3D"
|
|
|
|
local cursor = Instance.new("ImageLabel")
|
|
cursor.Image = "rbxasset://textures/Cursors/Gamepad/Pointer.png"
|
|
cursor.Size = UDim2.new(0, 8, 0, 8)
|
|
cursor.BackgroundTransparency = 1
|
|
cursor.ZIndex = 10
|
|
|
|
local resetOrientationFlag = false
|
|
local currentModalPanel = nil
|
|
|
|
local GuiService = game:GetService("GuiService")
|
|
local UserInputService = game:GetService("UserInputService")
|
|
|
|
local cursorHidden = false
|
|
local hasTool = false
|
|
local lastMouseMove = tick()
|
|
|
|
local function OnCharacterAdded(character)
|
|
hasTool = false
|
|
for i, v in ipairs(character:GetChildren()) do
|
|
if v:IsA("Tool") then
|
|
hasTool = true
|
|
end
|
|
end
|
|
character.ChildAdded:connect(function(child)
|
|
if child:IsA("Tool") then
|
|
hasTool = true
|
|
lastMouseMove = tick() --kick the mouse when a tool is equipped
|
|
end
|
|
end)
|
|
character.ChildRemoved:connect(function(child)
|
|
if child:IsA("Tool") then
|
|
hasTool = false
|
|
end
|
|
end)
|
|
end
|
|
spawn(function()
|
|
while not game.Players.LocalPlayer do wait() end
|
|
game.Players.LocalPlayer.CharacterAdded:connect(OnCharacterAdded)
|
|
if game.Players.LocalPlayer.Character then OnCharacterAdded(game.Players.LocalPlayer.Character) end
|
|
end)
|
|
local function autoHideCursor(hide)
|
|
if not UserInputService.VREnabled then
|
|
cursorHidden = false
|
|
return
|
|
end
|
|
if hide then
|
|
--don't hide if there's a tool in the character
|
|
local character = game.Players.LocalPlayer.Character
|
|
if character and hasTool then
|
|
return
|
|
end
|
|
cursorHidden = true
|
|
UserInputService.OverrideMouseIconBehavior = Enum.OverrideMouseIconBehavior.ForceHide
|
|
else
|
|
cursorHidden = false
|
|
UserInputService.OverrideMouseIconBehavior = Enum.OverrideMouseIconBehavior.None
|
|
end
|
|
end
|
|
|
|
local function isCursorVisible()
|
|
--if ForceShow, the cursor is definitely visible at all times
|
|
if UserInputService.OverrideMouseIconBehavior == Enum.OverrideMouseIconBehavior.ForceShow then
|
|
return true
|
|
end
|
|
--if ForceHide, the cursor is definitely NOT visible
|
|
if UserInputService.OverrideMouseIconBehavior == Enum.OverrideMouseIconBehavior.ForceHide then
|
|
return false
|
|
end
|
|
--Otherwise, we need to check if the developer set MouseIconEnabled=false
|
|
if UserInputService.MouseIconEnabled and UserInputService.OverrideMouseIconBehavior == Enum.OverrideMouseIconBehavior.None then
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
UserInputService.InputChanged:connect(function(inputObj, processed)
|
|
if inputObj.UserInputType == Enum.UserInputType.MouseMovement then
|
|
lastMouseMove = tick()
|
|
autoHideCursor(false)
|
|
end
|
|
end)
|
|
game:GetService("RunService").Heartbeat:connect(function()
|
|
if isCursorVisible() then
|
|
cursorHidden = false
|
|
end
|
|
if lastMouseMove + CURSOR_HIDE_TIME < tick() and not GuiService.MenuIsOpen and not cursorHidden then
|
|
autoHideCursor(true)
|
|
end
|
|
end)
|
|
|
|
local function createPanel()
|
|
local panelPart = Instance.new("Part")
|
|
panelPart.Transparency = 1
|
|
panelPart.CanCollide = false
|
|
panelPart.Anchored = true
|
|
panelPart.Archivable = false
|
|
panelPart.Size = Vector3.new(1.5, 1.5, 1)
|
|
panelPart.Parent = workspace.CurrentCamera
|
|
local panelGUI = Instance.new("SurfaceGui", CoreGui)
|
|
panelGUI.Name = "GUI"
|
|
panelGUI.Adornee = panelPart
|
|
panelGUI.ToolPunchThroughDistance = 1000
|
|
panelGUI.Active = true
|
|
pcall(function() --todo: remove this when api is live
|
|
panelGUI.AlwaysOnTop = true
|
|
end)
|
|
return panelPart, panelGUI
|
|
end
|
|
|
|
function Panel3D.Get(panelId)
|
|
local panel = panels[panelId]
|
|
if not panel then
|
|
local panelName
|
|
for name, value in pairs(Panel3D.Panels) do
|
|
if value == panelId then
|
|
panelName = name
|
|
break
|
|
end
|
|
end
|
|
if not panelName then
|
|
error("Tried to request an invalid 3D panel")
|
|
end
|
|
|
|
local part, gui = createPanel()
|
|
|
|
panel = {}
|
|
panel.part = part
|
|
panel.part.Name = "GUI"--("%sPanel3D"):format(panelName)
|
|
panel.gui = gui
|
|
panel.gui.Name = ("%sPanelGUI"):format(panelName)
|
|
|
|
panel.vector = panelDefaultVectors[panelId]
|
|
panel.horizontalVector = Vector3.new(panel.vector.x, 0, panel.vector.z).unit
|
|
panel.pitchAngle = math.asin(panel.vector.unit.y)
|
|
panel.verticalRange = math.rad(5)
|
|
panel.horizontalRange = math.rad(5)
|
|
|
|
panel.transparencyCallbacks = {}
|
|
|
|
panel.OnMouseEnter = nil
|
|
panel.OnMouseLeave = nil
|
|
|
|
panel.pixelScale = 1
|
|
|
|
panel.visible = true
|
|
panel.visibilityBehavior = Panel3D.Visibility.BelowAngleThreshold
|
|
|
|
panel.orientationMode = Panel3D.Orientation.Horizontal
|
|
panel.orientation = nil
|
|
|
|
panel.cursorEnabled = true
|
|
|
|
panel.width = 1
|
|
panel.height = 1
|
|
|
|
function panel:AddTransparencyCallback(callback)
|
|
table.insert(panel.transparencyCallbacks, callback)
|
|
end
|
|
|
|
function panel:Resize(width, height, pixelsPerStud)
|
|
panel.width = width
|
|
panel.height = height
|
|
|
|
pixelsPerStud = pixelsPerStud or PIXELS_PER_STUD
|
|
panel.pixelScale = pixelsPerStud / PIXELS_PER_STUD
|
|
panel.part.Size = Vector3.new(panel.width * headScale, panel.height * headScale, 1)
|
|
panel.gui.CanvasSize = Vector2.new(pixelsPerStud * panel.width, pixelsPerStud * panel.height)
|
|
|
|
local distance = panel.vector.magnitude
|
|
panel.verticalRange = math.atan(panel.part.Size.Y / (2 * distance)) * 2
|
|
panel.horizontalRange = math.atan(panel.part.Size.X / (2 * distance)) * 2
|
|
end
|
|
|
|
function panel:ResizePixels(width, height)
|
|
local widthStuds = width / PIXELS_PER_STUD
|
|
local heightStuds = height / PIXELS_PER_STUD
|
|
panel:Resize(widthStuds, heightStuds)
|
|
end
|
|
|
|
function panel:SetModal()
|
|
panel.visible = false
|
|
panel.visibilityBehavior = Panel3D.Visibility.Modal
|
|
|
|
if panel.visible then
|
|
currentModalPanel = panel
|
|
if panel.orientationMode == Panel3D.Orientation.Fixed then
|
|
local userHeadCFrame = UserInputService:GetUserCFrame(Enum.UserCFrame.Head)
|
|
panel.orientation = userHeadCFrame * CFrame.new(0, 0, -panel.vector.magnitude) * CFrame.Angles(0, math.pi, 0)
|
|
end
|
|
end
|
|
end
|
|
|
|
function panel:SetVisible(visible)
|
|
panel.visible = visible
|
|
|
|
if visible and panel.visibilityBehavior == Panel3D.Visibility.Modal then
|
|
currentModalPanel = panel
|
|
if panel.orientationMode == Panel3D.Orientation.Fixed then
|
|
local userHeadCFrame = UserInputService:GetUserCFrame(Enum.UserCFrame.Head)
|
|
panel.orientation = userHeadCFrame * CFrame.new(0, 0, -panel.vector.magnitude) * CFrame.Angles(0, math.pi, 0)
|
|
end
|
|
else
|
|
if currentModalPanel == panel then
|
|
currentModalPanel = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
panels[panelId] = panel
|
|
end
|
|
return panel
|
|
end
|
|
|
|
function Panel3D.ResetOrientation()
|
|
resetOrientationFlag = true
|
|
end
|
|
|
|
function Panel3D.GetGUI(panel)
|
|
local panelGUI = panelGUIs[panel]
|
|
if not panelGUI then
|
|
local part = Panel3D.GetPart(panel)
|
|
panelGUI = panelGUIs[panel]
|
|
end
|
|
return panelGUI
|
|
end
|
|
|
|
local zeroVector = Vector3.new(0, 0, 0)
|
|
local headXZ = CFrame.new()
|
|
local headOffset = Vector3.new()
|
|
local currentHoverPanel = nil
|
|
local savedMouseBehavior = Enum.MouseBehavior.Default
|
|
function Panel3D.OnRenderStep()
|
|
if not UserInputService.VREnabled then
|
|
return
|
|
end
|
|
local cameraCFrame = workspace.CurrentCamera.CFrame
|
|
local cameraRenderCFrame = workspace.CurrentCamera:GetRenderCFrame()
|
|
local userHeadCFrame = UserInputService:GetUserCFrame(Enum.UserCFrame.Head)
|
|
|
|
local userHeadLook = userHeadCFrame.lookVector
|
|
local userHeadHorizontalVector = Vector3.new(userHeadLook.X, 0, userHeadLook.Z).unit
|
|
local userHeadPitch = math.asin(userHeadLook.Y)
|
|
|
|
local panelLockThreshold = DEFAULT_PANEL_LOCK_THRESHOLD
|
|
|
|
for panelId, panel in pairs(panels) do
|
|
if panel.pitchLockThreshold and panel.visible then
|
|
panelLockThreshold = math.max(panelLockThreshold, panel.pitchLockThreshold)
|
|
end
|
|
end
|
|
|
|
local isAboveThreshold = false
|
|
if userHeadPitch > panelLockThreshold or resetOrientationFlag then
|
|
isAboveThreshold = true
|
|
headXZ = CFrame.new(zeroVector, userHeadHorizontalVector)
|
|
headOffset = userHeadCFrame.p
|
|
resetOrientationFlag = false
|
|
end
|
|
|
|
local panelsOrigin = cameraCFrame * CFrame.new(headOffset) * headXZ
|
|
for panelId, panel in pairs(panels) do
|
|
if panel.visibilityBehavior == Panel3D.Visibility.BelowAngleThreshold then
|
|
panel.visible = not (isAboveThreshold or currentModalPanel)
|
|
end
|
|
|
|
if not panel.visible then
|
|
panel.part.Parent = nil
|
|
panel.gui.Adornee = nil
|
|
else
|
|
panel.part.Parent = workspace.CurrentCamera --TODO: move to new 3D gui space
|
|
panel.gui.Adornee = panel.part
|
|
|
|
local panelCFrame;
|
|
if panel.orientationMode == Panel3D.Orientation.Fixed and panel.orientation then
|
|
local pos = panel.orientation.p * headScale
|
|
panel.part.CFrame = workspace.CurrentCamera.CFrame * ((panel.orientation - panel.orientation.p) + pos)
|
|
panelCFrame = panel.part.CFrame
|
|
else
|
|
local panelPosition = panelsOrigin:pointToWorldSpace(panel.vector * headScale)
|
|
panelCFrame = CFrame.new(panelPosition, panelsOrigin.p)
|
|
panel.part.CFrame = panelCFrame
|
|
end
|
|
|
|
local toPanel = (panelCFrame.p - cameraRenderCFrame.p).unit
|
|
|
|
local transparency = panel.visible and 1 - (math.max(0, cameraRenderCFrame.lookVector:Dot(toPanel)) ^ panelTransparencyBias[panelId]) or 1
|
|
for _, callback in pairs(panel.transparencyCallbacks) do
|
|
callback(transparency)
|
|
end
|
|
end
|
|
end
|
|
|
|
--Render a cursor overlaid onto the panels
|
|
local cframe = cameraRenderCFrame
|
|
local ray = Ray.new(cframe.p, cframe.lookVector * 999)
|
|
local ignoreList = { game.Players.LocalPlayer.Character }
|
|
local part, endpoint = workspace:FindPartOnRayWithIgnoreList(ray, ignoreList)
|
|
local hitPanel = nil
|
|
local hitPanelId = nil
|
|
for panelId, panel in pairs(panels) do
|
|
if part == panel.part then
|
|
hitPanel = panel
|
|
hitPanelId = panelId
|
|
end
|
|
end
|
|
if hitPanel then
|
|
if not currentHoverPanel then
|
|
savedMouseBehavior = UserInputService.MouseBehavior
|
|
UserInputService.MouseBehavior = Enum.MouseBehavior.LockCenter
|
|
else
|
|
if currentHoverPanel ~= hitPanel then
|
|
if currentHoverPanel.OnMouseLeave then
|
|
currentHoverPanel:OnMouseLeave()
|
|
end
|
|
end
|
|
end
|
|
if hitPanel.OnMouseEnter and currentHoverPanel ~= hitPanel then
|
|
hitPanel:OnMouseEnter()
|
|
end
|
|
|
|
currentHoverPanel = hitPanel
|
|
|
|
UserInputService.OverrideMouseIconBehavior = Enum.OverrideMouseIconBehavior.ForceHide
|
|
if hitPanel.cursorEnabled then
|
|
cursor.Parent = hitPanel.gui
|
|
else
|
|
cursor.Parent = nil
|
|
end
|
|
|
|
local localEndpoint = part:GetRenderCFrame():pointToObjectSpace(endpoint)
|
|
local x = (localEndpoint.X / part.Size.X) + 0.5
|
|
local y = (localEndpoint.Y / part.Size.Y) + 0.5
|
|
x = 1 - x
|
|
y = 1 - y
|
|
cursor.Size = UDim2.new(0, 8 * hitPanel.pixelScale, 0, 8 * hitPanel.pixelScale)
|
|
cursor.Position = UDim2.new(x, -cursor.AbsoluteSize.x * 0.5, y, -cursor.AbsoluteSize.y * 0.5)
|
|
|
|
if hitPanel.visible and not modalPanelIsOpen then
|
|
hitPanel.part.Parent = workspace.CurrentCamera
|
|
for _, callback in pairs(hitPanel.transparencyCallbacks) do
|
|
callback(0)
|
|
end
|
|
end
|
|
else
|
|
if currentHoverPanel then
|
|
UserInputService.MouseBehavior = savedMouseBehavior
|
|
|
|
if currentHoverPanel.OnMouseLeave then
|
|
currentHoverPanel:OnMouseLeave()
|
|
end
|
|
end
|
|
currentHoverPanel = false
|
|
cursor.Parent = nil
|
|
end
|
|
end
|
|
|
|
|
|
|
|
-- RayPlaneIntersection
|
|
|
|
-- http://www.siggraph.org/education/materials/HyperGraph/raytrace/rayplane_intersection.htm
|
|
local function RayPlaneIntersection(ray, planeNormal, pointOnPlane)
|
|
planeNormal = planeNormal.unit
|
|
ray = ray.Unit
|
|
-- compute Pn (dot) Rd = Vd and check if Vd == 0 then we know ray is parallel to plane
|
|
local Vd = planeNormal:Dot(ray.Direction)
|
|
|
|
-- could fuzzy equals this a little bit to account for imprecision or very close angles to zero
|
|
if Vd == 0 then -- parallel, no intersection
|
|
return nil
|
|
end
|
|
|
|
local V0 = planeNormal:Dot(pointOnPlane - ray.Origin)
|
|
local t = V0 / Vd
|
|
|
|
if t < 0 then --plane is behind ray origin, and thus there is no intersection
|
|
return nil
|
|
end
|
|
|
|
return ray.Origin + ray.Direction * t
|
|
end
|
|
|
|
function Panel3D.FindHoveredGuiElement(panel, elements)
|
|
local cameraRenderCFrame = workspace.CurrentCamera and workspace.CurrentCamera:GetRenderCFrame()
|
|
local panelPart = panel.part
|
|
if cameraRenderCFrame and panelPart then
|
|
local panelPartSize = panelPart.Size
|
|
local panelSurfaceCFrame = panelPart.CFrame + panelPart.CFrame.lookVector * (panelPartSize.Z * 0.5)
|
|
local intersectionPt = RayPlaneIntersection(Ray.new(cameraRenderCFrame.p, cameraRenderCFrame.lookVector), panelSurfaceCFrame.lookVector, panelSurfaceCFrame.p)
|
|
if intersectionPt then
|
|
local localPoint = panelSurfaceCFrame:pointToObjectSpace(intersectionPt) * Vector3.new(-1, 1, 1) + Vector3.new(panelPartSize.X/2, -panelPartSize.Y/2, 0)
|
|
local guiPoint = Vector2.new((localPoint.X / panelPartSize.X) * panel.gui.AbsoluteSize.X, (localPoint.Y / panelPartSize.Y) * -panel.gui.AbsoluteSize.Y)
|
|
|
|
local x = guiPoint.x
|
|
local y = guiPoint.y
|
|
for _, item in pairs(elements) do
|
|
local minPt = item.AbsolutePosition
|
|
local maxPt = item.AbsolutePosition + item.AbsoluteSize
|
|
if minPt.X <= x and maxPt.X >= x and minPt.Y <= y and maxPt.Y >= y then
|
|
return item
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
game:GetService("RunService"):BindToRenderStep(renderStepName, Enum.RenderPriority.Last.Value, Panel3D.OnRenderStep)
|
|
|
|
local function OnCameraChanged(prop)
|
|
if prop == "HeadScale" then
|
|
pcall(function()
|
|
headScale = workspace.CurrentCamera.HeadScale
|
|
end)
|
|
for i, v in pairs(panels) do
|
|
v:Resize(v.width, v.height, v.pixelScale * PIXELS_PER_STUD)
|
|
end
|
|
end
|
|
end
|
|
local cameraChangedConn = nil
|
|
workspace.Changed:connect(function(prop)
|
|
if prop == "CurrentCamera" then
|
|
OnCameraChanged("HeadScale")
|
|
if cameraChangedConn then
|
|
cameraChangedConn:disconnect()
|
|
end
|
|
cameraChangedConn = workspace.CurrentCamera.Changed:connect(OnCameraChanged)
|
|
end
|
|
end)
|
|
if workspace.CurrentCamera then
|
|
OnCameraChanged("HeadScale")
|
|
cameraChangedConn = workspace.CurrentCamera.Changed:connect(OnCameraChanged)
|
|
end
|
|
|
|
return Panel3D |