PlayerScripts/StarterPlayerScripts/ControlScript/MasterControl/VRNavigation.lua
local VRService = game:GetService("VRService")
local UserInputService = game:GetService("UserInputService")
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local PathfindingService = game:GetService("PathfindingService")
local ContextActionService = game:GetService("ContextActionService")
local StarterGui = game:GetService("StarterGui")

local MasterControl = require(script.Parent)
local PathDisplay = nil
local LocalPlayer = Players.LocalPlayer

local VRNavigation = {}

local RECALCULATE_PATH_THRESHOLD = 4
local NO_PATH_THRESHOLD = 12
local MAX_PATHING_DISTANCE = 200
local POINT_REACHED_THRESHOLD = 1
local STOPPING_DISTANCE = 4
local OFFTRACK_TIME_THRESHOLD = 2

local ZERO_VECTOR3 = Vector3.new(0, 0, 0)
local XZ_VECTOR3 = Vector3.new(1, 0, 1)

local THUMBSTICK_DEADZONE = 0.22

local navigationRequestedConn = nil
local heartbeatConn = nil

local currentDestination = nil
local currentPath = nil
local currentPoints = nil
local currentPointIdx = 0
local currentMoveVector = Vector3.new(0, 0, 0)

local expectedTimeToNextPoint = 0
local timeReachedLastPoint = tick()

local movementUpdateEvent = Instance.new("BindableEvent")
movementUpdateEvent.Name = "MovementUpdate"
movementUpdateEvent.Parent = script

coroutine.wrap(function()
	local PathDisplayModule = script.Parent.Parent:WaitForChild("PathDisplay")
	if PathDisplayModule then
		PathDisplay = require(PathDisplayModule)
	end
end)()

local function setLaserPointerMode(mode)
	pcall(function()
		StarterGui:SetCore("VRLaserPointerMode", mode)
	end)
end

local function getLocalHumanoid()
	local character = LocalPlayer.Character
	if not character then
		return
	end
	
	for _, child in pairs(character:GetChildren()) do
		if child:IsA("Humanoid") then
			return child
		end
	end
	return nil
end

local function hasBothHandControllers()
	return VRService:GetUserCFrameEnabled(Enum.UserCFrame.RightHand) and VRService:GetUserCFrameEnabled(Enum.UserCFrame.LeftHand)
end

local function hasAnyHandControllers()
	return VRService:GetUserCFrameEnabled(Enum.UserCFrame.RightHand) or VRService:GetUserCFrameEnabled(Enum.UserCFrame.LeftHand)
end

local function isMobileVR()
	return UserInputService.TouchEnabled
end

local function hasGamepad()
	return UserInputService.GamepadEnabled
end

local function shouldUseNavigationLaser()
	--Places where we use the navigation laser:
	-- mobile VR with any number of hands tracked
	-- desktop VR with only one hand tracked
	-- desktop VR with no hands and no gamepad (i.e. with Oculus remote?)
	--using an Xbox controller with a desktop VR headset means no laser since the user has a thumbstick.
	--in the future, we should query thumbstick presence with a features API
	if isMobileVR() then
		return true		
	else
		if hasBothHandControllers() then
			return false
		end
		if not hasAnyHandControllers() then
			return not hasGamepad()
		end
		return true
	end
end

local function IsFinite(num)
	return num == num and num ~= 1/0 and num ~= -1/0
end

local function IsFiniteVector3(vec3)
	return IsFinite(vec3.x) and IsFinite(vec3.y) and IsFinite(vec3.z)
end

local moving = false

local function startFollowingPath(newPath)
	currentPath = newPath
	currentPoints = currentPath:GetPointCoordinates()
	currentPointIdx = 1
	moving = true
	
	timeReachedLastPoint = tick()
	
	local humanoid = getLocalHumanoid()
	if humanoid and humanoid.Torso and #currentPoints >= 1 then
		local dist = (currentPoints[1] - humanoid.Torso.Position).magnitude
		expectedTimeToNextPoint = dist / humanoid.WalkSpeed
	end
	
	movementUpdateEvent:Fire("targetPoint", currentDestination)	
end

local function goToPoint(point)
	currentPath = true
	currentPoints = { point }
	currentPointIdx = 1
	moving = true

	local humanoid = getLocalHumanoid()
	local distance = (humanoid.Torso.Position - point).magnitude	
	local estimatedTimeRemaining = distance / humanoid.WalkSpeed 
	
	timeReachedLastPoint = tick()
	expectedTimeToNextPoint = estimatedTimeRemaining
	
	movementUpdateEvent:Fire("targetPoint", point)
end

local function stopFollowingPath()
	currentPath = nil
	currentPoints = nil
	currentPointIdx = 0
	moving = false
	MasterControl:AddToPlayerMovement(-currentMoveVector)
	currentMoveVector = ZERO_VECTOR3
end

local function tryComputePath(startPos, destination)
	local numAttempts = 0
	local newPath = nil
	
	while not newPath and numAttempts < 5 do
		newPath = PathfindingService:ComputeSmoothPathAsync(startPos, destination, MAX_PATHING_DISTANCE)
		numAttempts = numAttempts + 1
		
		if newPath.Status == Enum.PathStatus.ClosestNoPath or newPath.Status == Enum.PathStatus.ClosestOutOfRange then
			newPath = nil
			break
		end
		
		if newPath and newPath.Status == Enum.PathStatus.FailStartNotEmpty then
			startPos = startPos + (destination - startPos).unit
			newPath = nil
		end
		
		if newPath and newPath.Status == Enum.PathStatus.FailFinishNotEmpty then
			destination = destination + Vector3.new(0, 1, 0)
			newPath = nil
		end
	end
	
	return newPath
end

local function onNavigationRequest(destinationCFrame, requestedWith)
	local destinationPosition = destinationCFrame.p
	local lastDestination = currentDestination

	if not IsFiniteVector3(destinationPosition) then
		return
	end
	
	currentDestination = destinationPosition
		
	local humanoid = getLocalHumanoid()
	if not humanoid or not humanoid.Torso then
		return
	end
		
	local currentPosition = humanoid.Torso.Position
	local distanceToDestination = (currentDestination - currentPosition).magnitude
		
	if distanceToDestination < NO_PATH_THRESHOLD then
		goToPoint(currentDestination)
		return
	end		
		
	if not lastDestination or (currentDestination - lastDestination).magnitude > RECALCULATE_PATH_THRESHOLD then
		local newPath = tryComputePath(currentPosition, currentDestination)	
		if newPath then
			startFollowingPath(newPath)
			if PathDisplay then
				PathDisplay.setCurrentPoints(currentPoints)
				PathDisplay.renderPath()
			end
		else
			stopFollowingPath()
			if PathDisplay then
				PathDisplay.clearRenderedPath()
			end
		end
	else
		if moving then
			currentPoints[#currentPoints] = currentDestination
		else
			goToPoint(currentDestination)
		end
	end
end

local isJumpBound = false
local function onJumpAction(actionName, inputState, inputObj)
	if inputState == Enum.UserInputState.Begin then
		MasterControl:DoJump()
	end
end

local function bindJumpAction(active)
	if active then
		if not isJumpBound then
			isJumpBound = true
			ContextActionService:BindAction("VRJumpAction", onJumpAction, false, Enum.KeyCode.ButtonA)
		end
	else
		if isJumpBound then
			isJumpBound = false
			ContextActionService:UnbindAction("VRJumpAction")
		end
	end
end

local moveLatch = false
local controlCharacterGamepad = function(actionName, inputState, inputObject)
	if inputObject.KeyCode ~= Enum.KeyCode.Thumbstick1 then return end
	
	if inputState == Enum.UserInputState.Cancel then
		MasterControl:AddToPlayerMovement(-currentMoveVector)
		currentMoveVector =  Vector3.new(0,0,0)
		return
	end
	
	if inputState ~= Enum.UserInputState.End then
		stopFollowingPath()		
		if PathDisplay then
			PathDisplay.clearRenderedPath()
		end
		
		if shouldUseNavigationLaser() then
			bindJumpAction(true)
			setLaserPointerMode("Hidden")
		end
		
		if inputObject.Position.magnitude > THUMBSTICK_DEADZONE then
			MasterControl:AddToPlayerMovement(-currentMoveVector)
			currentMoveVector = Vector3.new(inputObject.Position.X, 0, -inputObject.Position.Y)
			if currentMoveVector.magnitude > 0 then
				currentMoveVector = currentMoveVector.unit * math.min(1, inputObject.Position.magnitude)
			end
			MasterControl:AddToPlayerMovement(currentMoveVector)

			moveLatch = true
		end
	else
		MasterControl:AddToPlayerMovement(-currentMoveVector)
		currentMoveVector =  Vector3.new(0,0,0)
		
		if shouldUseNavigationLaser() then
			bindJumpAction(false)
			setLaserPointerMode("Navigation")
		end
		
		if moveLatch then
			moveLatch = false
			movementUpdateEvent:Fire("offtrack")
		end
	end
end

local function onHeartbeat(dt)
	local newMoveVector = currentMoveVector
	local humanoid = getLocalHumanoid()
	if not humanoid or not humanoid.Torso then
		return
	end

	if moving and currentPoints then
		local currentPosition = humanoid.Torso.Position
		local goalPosition = currentPoints[1]
		local vectorToGoal = (goalPosition - currentPosition) * XZ_VECTOR3
		local moveDist = vectorToGoal.magnitude
		local moveDir = vectorToGoal / moveDist
			
		if moveDist < POINT_REACHED_THRESHOLD then
			local estimatedTimeRemaining = 0
			local prevPoint = currentPoints[1]
			for i, point in pairs(currentPoints) do
				if i ~= 1 then
					local dist = (point - prevPoint).magnitude
					prevPoint = point
					estimatedTimeRemaining = estimatedTimeRemaining + (dist / humanoid.WalkSpeed)
				end
			end					
			
			table.remove(currentPoints, 1)
			currentPointIdx = currentPointIdx + 1

			if #currentPoints == 0 then
				stopFollowingPath()
				if PathDisplay then
					PathDisplay.clearRenderedPath()
				end
				return
			else
				if PathDisplay then
					PathDisplay.setCurrentPoints(currentPoints)
					PathDisplay.renderPath()
				end
				
				local newGoal = currentPoints[1]
				local distanceToGoal = (newGoal - currentPosition).magnitude
				expectedTimeToNextPoint = distanceToGoal / humanoid.WalkSpeed
				timeReachedLastPoint = tick()
			end
		else
			local ignoreTable = { 
				game.Players.LocalPlayer.Character, 
				workspace.CurrentCamera 
			}
			local obstructRay = Ray.new(currentPosition - Vector3.new(0, 1, 0), moveDir * 3)
			local obstructPart, obstructPoint, obstructNormal = workspace:FindPartOnRayWithIgnoreList(obstructRay, ignoreTable)		
				
			if obstructPart then
				local heightOffset = Vector3.new(0, 100, 0)
				local jumpCheckRay = Ray.new(obstructPoint + moveDir * 0.5 + heightOffset, -heightOffset)
				local jumpCheckPart, jumpCheckPoint, jumpCheckNormal = workspace:FindPartOnRayWithIgnoreList(jumpCheckRay, ignoreTable)		
				
				local heightDifference = jumpCheckPoint.Y - currentPosition.Y
				if heightDifference < 6 and heightDifference > -2 then
					humanoid.Jump = true
				end
			end
			
			local timeSinceLastPoint = tick() - timeReachedLastPoint
			if timeSinceLastPoint > expectedTimeToNextPoint + OFFTRACK_TIME_THRESHOLD then
				stopFollowingPath()
				if PathDisplay then
					PathDisplay.clearRenderedPath()
				end
				
				movementUpdateEvent:Fire("offtrack")
			end
				
			newMoveVector = currentMoveVector:Lerp(moveDir, dt * 10)
		end
	end
	
	if IsFiniteVector3(newMoveVector) then
		MasterControl:AddToPlayerMovement(newMoveVector - currentMoveVector)
		currentMoveVector = newMoveVector
	end
end

local userCFrameEnabledConn = nil
local function onUserCFrameEnabled()
	if shouldUseNavigationLaser() then
		bindJumpAction(false)
		setLaserPointerMode("Navigation")
	else
		bindJumpAction(true)
		setLaserPointerMode("Hidden")
	end
end

function VRNavigation:Enable()
	navigationRequestedConn = VRService.NavigationRequested:connect(onNavigationRequest)
	heartbeatConn = RunService.Heartbeat:connect(onHeartbeat)
	
	ContextActionService:BindAction("MoveThumbstick", controlCharacterGamepad, false, Enum.KeyCode.Thumbstick1)
	ContextActionService:BindActivate(Enum.UserInputType.Gamepad1, Enum.KeyCode.ButtonR2)	
	
	userCFrameEnabledConn = VRService.UserCFrameEnabled:connect(onUserCFrameEnabled)
	onUserCFrameEnabled()

	pcall(function()
		VRService:SetTouchpadMode(Enum.VRTouchpad.Left, Enum.VRTouchpadMode.VirtualThumbstick)
		VRService:SetTouchpadMode(Enum.VRTouchpad.Right, Enum.VRTouchpadMode.ABXY)
	end)
end

function VRNavigation:Disable()
	stopFollowingPath()
	
	ContextActionService:UnbindAction("MoveThumbstick")
	ContextActionService:UnbindActivate(Enum.UserInputType.Gamepad1, Enum.KeyCode.ButtonR2)
	
	bindJumpAction(false)
	setLaserPointerMode("Disabled")
	
	if navigationRequestedConn then
		navigationRequestedConn:disconnect()
		navigationRequestedConn = nil
	end
	if heartbeatConn then
		heartbeatConn:disconnect()
		heartbeatConn = nil
	end
	if userCFrameEnabledConn then
		userCFrameEnabledConn:disconnect()
		userCFrameEnabledConn = nil
	end
end

return VRNavigation
