PlayerScripts/StarterPlayerScripts/CameraScript/RootCamera/OrbitalCamera.lua
--[[	
	Orbital Camera 1.0.0
	AllYourBlox
		
	Derived from ClassicCamera, adds camera angle constraints, and represents position values in spherical
	coordinates (azimuth, elevation, radius), instead of Cartesian coordinates (x, y, z). Azimuth is the
	angle of rotation about the Y axis, with the zero-angle reference point corresponding to offsetting
	the camera in the +Z direction, from where it will be looking in the -Z direction by default. Elevation
	is the angle up from the XZ plane, where zero degrees is on the plane, and +90 degrees is on the +Y
	axis. Distance is the camera-to-subject distance, the spherical coordinates radius, specified in studs.
--]]

-- Do not edit these values, they are not the developer-set limits, they are limits
-- to the values the camera system equations can correctly handle
local MIN_ALLOWED_ELEVATION_DEG = -80
local MAX_ALLOWED_ELEVATION_DEG = 80

local externalProperties = {}
externalProperties["InitialDistance"] 	= 25
externalProperties["MinDistance"] 		= 10
externalProperties["MaxDistance"] 		= 100
externalProperties["InitialElevation"] 	= 35
externalProperties["MinElevation"] 		= 35
externalProperties["MaxElevation"] 		= 35
externalProperties["ReferenceAzimuth"] 	= -45	-- Angle around the Y axis where the camera starts. -45 offsets the camera in the -X and +Z directions equally
externalProperties["CWAzimuthTravel"] 	= 90	-- How many degrees the camera is allowed to rotate from the reference position, CW as seen from above
externalProperties["CCWAzimuthTravel"] 	= 90	-- How many degrees the camera is allowed to rotate from the reference position, CCW as seen from above
externalProperties["UseAzimuthLimits"] 	= false -- Full rotation around Y axis available by default

local refAzimuthRad
local curAzimuthRad
local minAzimuthAbsoluteRad
local maxAzimuthAbsoluteRad
local useAzimuthLimits
local curElevationRad
local minElevationRad
local maxElevationRad
local curDistance
local minDistance
local maxDistance

local UNIT_Z = Vector3.new(0,0,1)
local TAU = 2 * math.pi

local changedSignalConnections = {}

-- End of OrbitalCamera additions
local PlayersService = game:GetService('Players')
local VRService = game:GetService("VRService")

local RootCameraCreator = require(script.Parent)

local UP_VECTOR = Vector3.new(0, 1, 0)
local XZ_VECTOR = Vector3.new(1, 0, 1)
local ZERO_VECTOR2 = Vector2.new(0, 0)

local VR_PITCH_FRACTION = 0.25

local Vector3_new = Vector3.new
local CFrame_new = CFrame.new
local math_min = math.min
local math_max = math.max
local math_atan2 = math.atan2
local math_rad = math.rad
local math_abs = math.abs

--[[ Gamepad Support ]]--
local THUMBSTICK_DEADZONE = 0.2
local r3ButtonDown = false
local l3ButtonDown = false
local currentZoomSpeed = 1 -- Multiplier, so 1 == no zooming

local function clamp(value, minValue, maxValue)
	if maxValue < minValue then
		maxValue = minValue
	end
	return math.clamp(value, minValue, maxValue)
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

-- May return NaN or inf or -inf
-- This is a way of finding the angle between the two vectors:
local function findAngleBetweenXZVectors(vec2, vec1)
	return math_atan2(vec1.X*vec2.Z-vec1.Z*vec2.X, vec1.X*vec2.X + vec1.Z*vec2.Z)
end

local function GetValueObject(name, defaultValue)
	local valueObj = script:FindFirstChild(name)
	if valueObj then
		return valueObj.Value
	end
	return defaultValue
end

local function LoadOrCreateNumberValueParameter(name, valueType, updateFunction)
	local valueObj = script:FindFirstChild(name)
	
	if valueObj and valueObj:isA(valueType) then
		-- Value object exists and is the correct type, use its value
		externalProperties[name] = valueObj.Value
	elseif externalProperties[name] ~= nil then
		-- Create missing (or replace incorrectly-typed) valueObject with default value
		valueObj = Instance.new(valueType)
		valueObj.Name = name
		valueObj.Parent = script
		valueObj.Value = externalProperties[name]
	else
		print("externalProperties table has no entry for ",name)
		return
	end
	
	if updateFunction then
		if changedSignalConnections[name] then
			changedSignalConnections[name]:disconnect()
		end
		changedSignalConnections[name] = valueObj.Changed:connect(function(newValue)
			externalProperties[name] = newValue
			updateFunction()
		end)
	end
end

local function SetAndBoundsCheckAzimuthValues()
	minAzimuthAbsoluteRad = math.rad(externalProperties["ReferenceAzimuth"]) - math.abs(math.rad(externalProperties["CWAzimuthTravel"]))
	maxAzimuthAbsoluteRad = math.rad(externalProperties["ReferenceAzimuth"]) + math.abs(math.rad(externalProperties["CCWAzimuthTravel"]))
	useAzimuthLimits = externalProperties["UseAzimuthLimits"]
	if useAzimuthLimits then
		curAzimuthRad = math.max(curAzimuthRad, minAzimuthAbsoluteRad)
		curAzimuthRad = math.min(curAzimuthRad, maxAzimuthAbsoluteRad)
	end
end

local function SetAndBoundsCheckElevationValues()
	-- These degree values are the direct user input values. It is deliberate that they are
	-- ranged checked only against the extremes, and not against each other. Any time one
	-- is changed, both of the internal values in radians are recalculated. This allows for
	-- A developer to change the values in any order and for the end results to be that the
	-- internal values adjust to match intent as best as possible.
	local minElevationDeg = math.max(externalProperties["MinElevation"], MIN_ALLOWED_ELEVATION_DEG)
	local maxElevationDeg = math.min(externalProperties["MaxElevation"], MAX_ALLOWED_ELEVATION_DEG)
	
	-- Set internal values in radians
	minElevationRad = math.rad(math.min(minElevationDeg, maxElevationDeg))
	maxElevationRad = math.rad(math.max(minElevationDeg, maxElevationDeg))
	curElevationRad = math.max(curElevationRad, minElevationRad)
	curElevationRad = math.min(curElevationRad, maxElevationRad)
end

local function SetAndBoundsCheckDistanceValues()
	minDistance = externalProperties["MinDistance"]
	maxDistance = externalProperties["MaxDistance"]
	curDistance = math.max(curDistance, minDistance)
	curDistance = math.min(curDistance, maxDistance)
end

-- This loads from, or lazily creates, NumberValue objects for exposed parameters
local function LoadNumberValueParameters()
	-- These initial values do not require change listeners since they are read only once
	LoadOrCreateNumberValueParameter("InitialElevation", "NumberValue", nil)
	LoadOrCreateNumberValueParameter("InitialDistance", "NumberValue", nil)
	
	-- Note: ReferenceAzimuth is also used as an initial value, but needs a change listener because it is used in the calculation of the limits
	LoadOrCreateNumberValueParameter("ReferenceAzimuth", "NumberValue", SetAndBoundsCheckAzimuthValues)
	LoadOrCreateNumberValueParameter("CWAzimuthTravel", "NumberValue", SetAndBoundsCheckAzimuthValues)
	LoadOrCreateNumberValueParameter("CCWAzimuthTravel", "NumberValue", SetAndBoundsCheckAzimuthValues)
	LoadOrCreateNumberValueParameter("MinElevation", "NumberValue", SetAndBoundsCheckElevationValues)
	LoadOrCreateNumberValueParameter("MaxElevation", "NumberValue", SetAndBoundsCheckElevationValues)
	LoadOrCreateNumberValueParameter("MinDistance", "NumberValue", SetAndBoundsCheckDistanceValues)
	LoadOrCreateNumberValueParameter("MaxDistance", "NumberValue", SetAndBoundsCheckDistanceValues)
	LoadOrCreateNumberValueParameter("UseAzimuthLimits", "BoolValue", SetAndBoundsCheckAzimuthValues)
	
	-- Internal values set (in radians, from degrees), plus sanitization
	curAzimuthRad = math.rad(externalProperties["ReferenceAzimuth"])
	curElevationRad = math.rad(externalProperties["InitialElevation"])
	curDistance = externalProperties["InitialDistance"]
	
	SetAndBoundsCheckAzimuthValues()
	SetAndBoundsCheckElevationValues()
	SetAndBoundsCheckDistanceValues()
end

local function CreateOrbitalCamera()
	local module = RootCameraCreator()
	
	LoadNumberValueParameters()	
	
	module.DefaultZoom = curDistance
	
	local tweenAcceleration = math_rad(220)
	local tweenSpeed = math_rad(0)
	local tweenMaxSpeed = math_rad(250)
	local timeBeforeAutoRotate = 2
	
	local lastUpdate = tick()
	module.LastUserPanCamera = tick()
	
	function module:Update()
		module:ProcessTweens()
		local now = tick()
		local timeDelta = (now - lastUpdate)
		local userPanningTheCamera = (self.UserPanningTheCamera == true)
		local camera = 	workspace.CurrentCamera
		local player = PlayersService.LocalPlayer
		local humanoid = self:GetHumanoid()
		local cameraSubject = camera and camera.CameraSubject
		local isInVehicle = cameraSubject and cameraSubject:IsA('VehicleSeat')
		local isOnASkateboard = cameraSubject and cameraSubject:IsA('SkateboardPlatform')
		
		if lastUpdate == nil or now - lastUpdate > 1 then
			module:ResetCameraLook()
			self.LastCameraTransform = nil
		end	
		
		if lastUpdate then
			local gamepadRotation = self:UpdateGamepad()
			
			if self:ShouldUseVRRotation() then				
				self.RotateInput = self.RotateInput + self:GetVRRotationInput()
			else
				-- Cap out the delta to 0.1 so we don't get some crazy things when we re-resume from
				local delta = math_min(0.1, now - lastUpdate)
				
				if gamepadRotation ~= ZERO_VECTOR2 then
					userPanningTheCamera = true
					self.RotateInput = self.RotateInput + (gamepadRotation * delta)
				end

				local angle = 0
				if not (isInVehicle or isOnASkateboard) then
					angle = angle + (self.TurningLeft and -120 or 0)
					angle = angle + (self.TurningRight and 120 or 0)
				end
				
				if angle ~= 0 then
					self.RotateInput = self.RotateInput +  Vector2.new(math_rad(angle * delta), 0)
					userPanningTheCamera = true
				end
			end
		end

		-- Reset tween speed if user is panning
		if userPanningTheCamera then
			tweenSpeed = 0
			module.LastUserPanCamera = tick()
		end
		
		local userRecentlyPannedCamera = now - module.LastUserPanCamera < timeBeforeAutoRotate
		local subjectPosition = self:GetSubjectPosition()
		
		if subjectPosition and player and camera then
		
			
			local zoom = self:ZoomCamera(curDistance * currentZoomSpeed)
			
			
			if not userPanningTheCamera and self.LastCameraTransform then
				local isInFirstPerson = self:IsInFirstPerson()
				if (isInVehicle or isOnASkateboard) and lastUpdate and humanoid and humanoid.Torso then
					if isInFirstPerson then
						if self.LastSubjectCFrame and (isInVehicle or isOnASkateboard) and cameraSubject:IsA('BasePart') then
							local y = -findAngleBetweenXZVectors(self.LastSubjectCFrame.lookVector, cameraSubject.CFrame.lookVector)
							if IsFinite(y) then
								self.RotateInput = self.RotateInput + Vector2.new(y, 0)
							end
							tweenSpeed = 0
						end
					elseif not userRecentlyPannedCamera then
						local forwardVector = humanoid.Torso.CFrame.lookVector
						if isOnASkateboard then
							forwardVector = cameraSubject.CFrame.lookVector
						end
						
						tweenSpeed = clamp(0, tweenMaxSpeed, tweenSpeed + tweenAcceleration * timeDelta)

						local percent = clamp(0, 1, tweenSpeed * timeDelta)
						if self:IsInFirstPerson() then
							percent = 1
						end
						
						local y = findAngleBetweenXZVectors(forwardVector, self:GetCameraLook())
						if IsFinite(y) and math_abs(y) > 0.0001 then
							self.RotateInput = self.RotateInput + Vector2.new(y * percent, 0)
						end
					end
				end
			end
			
			local VREnabled = VRService.VREnabled
			camera.Focus = VREnabled and self:GetVRFocus(subjectPosition, timeDelta) or CFrame_new(subjectPosition)

			local cameraFocusP = camera.Focus.p
			if VREnabled and not self:IsInFirstPerson() then
				local cameraHeight = self:GetCameraHeight()
				local vecToSubject = (subjectPosition - camera.CFrame.p)
				local distToSubject = vecToSubject.magnitude

				-- Only move the camera if it exceeded a maximum distance to the subject in VR
				if distToSubject > zoom or self.RotateInput.x ~= 0 then
					local desiredDist = math_min(distToSubject, zoom)
					vecToSubject = self:RotateCamera(vecToSubject.unit * XZ_VECTOR, Vector2.new(self.RotateInput.x, 0)) * desiredDist
					local newPos = cameraFocusP - vecToSubject
					local desiredLookDir = camera.CFrame.lookVector
					if self.RotateInput.x ~= 0 then
						desiredLookDir = vecToSubject
					end
					local lookAt = Vector3.new(newPos.x + desiredLookDir.x, newPos.y, newPos.z + desiredLookDir.z)
					self.RotateInput = ZERO_VECTOR2
					
					camera.CFrame = CFrame_new(newPos, lookAt) + Vector3_new(0, cameraHeight, 0)
				end
			else
				-- self.RotateInput is a Vector2 of mouse movement deltas since last update
				curAzimuthRad = curAzimuthRad - self.RotateInput.x
				
				if useAzimuthLimits then
					curAzimuthRad = clamp(curAzimuthRad, minAzimuthAbsoluteRad, maxAzimuthAbsoluteRad)
				else
					curAzimuthRad = (curAzimuthRad ~= 0) and (math.sign(curAzimuthRad) * (math.abs(curAzimuthRad) % TAU)) or 0
				end

				curDistance = clamp(zoom, minDistance, maxDistance)
				
				curElevationRad = clamp(curElevationRad + self.RotateInput.y, minElevationRad,maxElevationRad)
				
				local cameraPosVector = curDistance * ( CFrame.fromEulerAnglesYXZ( -curElevationRad, curAzimuthRad, 0 ) * UNIT_Z ) 
				local camPos = subjectPosition + cameraPosVector
				
				camera.CFrame = CFrame.new(camPos, subjectPosition)
			
				self.RotateInput = ZERO_VECTOR2
			end

			self.LastCameraTransform = camera.CFrame
			self.LastCameraFocus = camera.Focus
			if (isInVehicle or isOnASkateboard) and cameraSubject:IsA('BasePart') then
				self.LastSubjectCFrame = cameraSubject.CFrame
			else
				self.LastSubjectCFrame = nil
			end
		end
		
		lastUpdate = now
	end
	
	function module:GetCameraZoom()
		return curDistance
	end
	
	function module:ZoomCamera(desiredZoom)
		local player = PlayersService.LocalPlayer
		if player then
			curDistance = clamp(desiredZoom, minDistance, maxDistance)
		end

		local isFirstPerson = self:GetCameraZoom() < 2

		--ShiftLockController:SetIsInFirstPerson(isFirstPerson)
		-- set mouse behavior
		self:UpdateMouseBehavior()
		return self:GetCameraZoom()
	end
	
	function module:ZoomCameraBy(zoomScale)
		local newDist = curDistance
		-- Can break into more steps to get more accurate integration
        newDist = self:rk4Integrator(curDistance, zoomScale, 1)
		self:ZoomCamera(newDist)
		return self:GetCameraZoom()
	end

	function module:ZoomCameraFixedBy(zoomIncrement)
		return self:ZoomCamera(self:GetCameraZoom() + zoomIncrement)
	end
	
	function module:SetInitialOrientation(humanoid)
		local newDesiredLook = (humanoid.Torso.CFrame.lookVector - Vector3.new(0,0.23,0)).unit
		local horizontalShift = findAngleBetweenXZVectors(newDesiredLook, self:GetCameraLook())
		local vertShift = math.asin(self:GetCameraLook().y) - math.asin(newDesiredLook.y)
		if not IsFinite(horizontalShift) then
			horizontalShift = 0
		end
		if not IsFinite(vertShift) then
			vertShift = 0
		end
		self.RotateInput = Vector2.new(horizontalShift, vertShift)
	end

	function module.getGamepadPan(name, state, input)
		if input.UserInputType == module.activeGamepad and input.KeyCode == Enum.KeyCode.Thumbstick2 then
			if r3ButtonDown or l3ButtonDown then
			-- R3 or L3 Thumbstick is depressed, right stick controls dolly in/out
				if (input.Position.Y > THUMBSTICK_DEADZONE) then
					currentZoomSpeed = 0.96
				elseif (input.Position.Y < -THUMBSTICK_DEADZONE) then
					currentZoomSpeed = 1.04
				else 
					currentZoomSpeed = 1.00
				end
			else
				if state == Enum.UserInputState.Cancel then
					module.GamepadPanningCamera = ZERO_VECTOR2
					return
				end		
				
				local inputVector = Vector2.new(input.Position.X, -input.Position.Y)
				if inputVector.magnitude > THUMBSTICK_DEADZONE then
					module.GamepadPanningCamera = Vector2.new(input.Position.X, -input.Position.Y)
				else
					module.GamepadPanningCamera = ZERO_VECTOR2
				end
			end
		end
	end

	function module.doGamepadZoom(name, state, input)
		if input.UserInputType == module.activeGamepad and (input.KeyCode == Enum.KeyCode.ButtonR3 or input.KeyCode == Enum.KeyCode.ButtonL3) then
			if (state == Enum.UserInputState.Begin) then
				r3ButtonDown = input.KeyCode == Enum.KeyCode.ButtonR3
				l3ButtonDown = input.KeyCode == Enum.KeyCode.ButtonL3
			elseif (state == Enum.UserInputState.End) then
				if (input.KeyCode == Enum.KeyCode.ButtonR3) then
					r3ButtonDown = false
				elseif (input.KeyCode == Enum.KeyCode.ButtonL3) then
					l3ButtonDown = false
				end
				if (not r3ButtonDown) and (not l3ButtonDown) then
					currentZoomSpeed = 1.00
				end
			end
		end
	end
	
	function module:BindGamepadInputActions()
		local ContextActionService = game:GetService('ContextActionService')
		ContextActionService:BindAction("OrbitalCamGamepadPan", module.getGamepadPan, false, Enum.KeyCode.Thumbstick2)
		ContextActionService:BindAction("OrbitalCamGamepadZoom", module.doGamepadZoom, false, Enum.KeyCode.ButtonR3)
		ContextActionService:BindAction("OrbitalCamGamepadZoomAlt", module.doGamepadZoom, false, Enum.KeyCode.ButtonL3)
	end
	
	return module
end

return CreateOrbitalCamera
