Scripting a camera for use with sensitivity controls

I’m building a game that uses weapons. Some weapons have the ability to zoom in on a target using a scope. A sniper rifle uses this. The issue that I’m having is that when I zoom in to max, it’s hard to get a good aim on the target because the mouse bounces around a lot when the camera’s field of view is set that low. Now I did some looking and found this script in the Roblox documentation dealing with input control sensitivity, but the script acts really strange when I tested it.

local UserInputService = game:GetService("UserInputService")
local Players = game:GetService("Players")

local player = Players.LocalPlayer
local character = player.CharacterAdded:Wait()
local head = character:WaitForChild("Head", false)

local mouse = player:GetMouse()

local zoomed = false
local camera = game.Workspace.CurrentCamera
local target = nil
local originalProperties = {
	FieldOfView = nil,
	_CFrame = nil,
	MouseBehavior = nil,
	MouseDeltaSensitivity = nil,
}

local AngleX, TargetAngleX = 0, 0
local AngleY, TargetAngleY = 0, 0

-- Reset camera back to CFrame and FieldOfView before zoom
local function ResetCamera()
	target = nil
	camera.CameraType = Enum.CameraType.Custom
	camera.CFrame = originalProperties._CFrame
	camera.FieldOfView = originalProperties.FieldOfView

	UserInputService.MouseBehavior = originalProperties.MouseBehavior
	UserInputService.MouseDeltaSensitivity = originalProperties.MouseDeltaSensitivity
end

local function ZoomCamera()
	-- Allow camera to be changed by script
	camera.CameraType = Enum.CameraType.Scriptable

	-- Store camera properties before zoom
	originalProperties._CFrame = camera.CFrame
	originalProperties.FieldOfView = camera.FieldOfView
	originalProperties.MouseBehavior = UserInputService.MouseBehavior
	originalProperties.MouseDeltaSensitivity = UserInputService.MouseDeltaSensitivity

	-- Zoom camera
	target = mouse.Hit.Position
	local eyesight = head.Position
	camera.CFrame = CFrame.new(eyesight, target)
	camera.Focus = CFrame.new(target)
	camera.FieldOfView = 10

	-- Lock and slow down mouse
	UserInputService.MouseBehavior = Enum.MouseBehavior.LockCenter
	UserInputService.MouseDeltaSensitivity = 1

	-- Reset zoom angles
	AngleX, TargetAngleX = 0, 0
	AngleY, TargetAngleY = 0, 0
end

-- Toggle camera zoom/unzoom
local function MouseClick()
	if zoomed then
		-- Unzoom camera
		ResetCamera()
	else
		-- Zoom in camera
		ZoomCamera()
	end

	zoomed = not zoomed
end

local function MouseMoved(input)
	if zoomed then
		local sensitivity = 0.6 -- anything higher would make looking up and down harder; recommend anything between 0~1
		local smoothness = 0.05 -- recommend anything between 0~1

		local delta = Vector2.new(input.Delta.x / sensitivity, input.Delta.y / sensitivity) * smoothness

		local X = TargetAngleX - delta.y
		local Y = TargetAngleY - delta.x
		TargetAngleX = (X >= 80 and 80) or (X <= -80 and -80) or X
		TargetAngleY = (Y >= 80 and 80) or (Y <= -80 and -80) or Y

		AngleX = AngleX + (TargetAngleX - AngleX) * 0.35
		AngleY = AngleY + (TargetAngleY - AngleY) * 0.15

		camera.CFrame = CFrame.new(head.Position, target)
			* CFrame.Angles(0, math.rad(AngleY), 0)
			* CFrame.Angles(math.rad(AngleX), 0, 0)
	end
end

local function InputBegan(input, _gameProcessedEvent)
	if input.UserInputType == Enum.UserInputType.MouseButton1 then
		MouseClick()
	end
end

local function InputChanged(input, _gameProcessedEvent)
	if input.UserInputType == Enum.UserInputType.MouseMovement then
		MouseMoved(input)
	end
end

if UserInputService.MouseEnabled then
	UserInputService.InputBegan:Connect(InputBegan)
	UserInputService.InputChanged:Connect(InputChanged)
end

One problem with this script is that if you move the mouse to the far left or right, the horizon does not remain level, but becomes tilted. Another issue is that if you move, the camera doesn’t follow. I have modified the script by adding RunService:RenderStepped to constantly update the camera’s CFrame, and it does work. However, the original problem remains. I’ve tried looking through the Roblox camera code and it’s like looking for a needle in a haystack.

Now I can modify input sensitivity for the mouse using the camera’s field of view, but for the other input types (touch and gamepad), I haven’t been able to really find anything.

So how do I deal with this?

After a significant amount of research and testing, I think I might have solved it. However, there are two issues that I have not been able to fix.

  1. Sometimes the camera will glitch and roll on the Z-Asix, which is what I do not want. I want a flat horizon at all times.
  2. The camera position itself seems to move on a circle when moving up and down. Run the below script and you will see what I mean. I want the camera position to follow the position of the character’s head.

The things that I have done to try to correct the above issues are:

  1. Manually set the Z-Axis to 0 before assignment to currentCameraCFrame.
  2. Take the CFrame apart and put it back together again with Z-Axis set to 0 in the render step.
  3. Different combinations of positions, offsets, CFrame:ToWorldSpace(), CFrame:ToObjectSpace()

I’m at a loss as to how to correct these issues. Does anyone have any suggestions that I might try?




The following posts were helpful in getting to this point:
How do I stop camera from rotating on Z axis?
Camera has strange behavior when rotating in more than 1 direction
How to make camera never orientate based on Z axis
Camera View Rolling/Tilting not Working




The below code is a local script.

--[[

Created by Roblox
Modified by Maelstorm_1973

This code was adapted from Roblox's reference implementation of
binoculars which can be found at the following URL:

https://create.roblox.com/docs/reference/engine/classes/InputObject#Delta

NOTES:

This is a test script for taking control of the user's camera
and make it follow the mouse.  Eventually, this will be adapted
to also use Touch and Gamepad Thumbstick inputs as well.

All references to the mouse was excised from the script.  The
target location is now calculated from the player's head position
and look vector when the zoom is initiated.  Eventually, the
sensitivity settings will be separated into different categories
depending on what the input device is.

BUGS:

*	There is a glitch that sometimes the camera will still roll
	on the Z-Axis, but it seems to self correct after a few mouse
	movements.
*	The camera position itself seems to move on a circle when
	moving up and down.  Not sure how to correct this one.
*	Performance of the camera update in the render stepped event
	is sluggish.  Possibly due to the tween that adjusts the
	camera from the previous CFrame to the current CFrame.

--]]



-- ******** Requirements

-- Required Game Services and Facilities
local userInputService = game:GetService("UserInputService")
local playerService = game:GetService("Players")
local runService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")



-- ******** Local Data

-- Player
local player = playerService.LocalPlayer
local character = player.CharacterAdded:Wait()
local head = character:WaitForChild("Head", false)
local root = character:WaitForChild("HumanoidRootPart")

-- Camera
local zoomed = false
local camera = game.Workspace.CurrentCamera
local target = nil
local screenCenter = camera.ViewportSize / 2

-- Camera Original Parameters
local originalProperties = {
	fieldOfView = nil;
	cframe = nil;
	mouseBehavior = nil;
	mouseDeltaSensitivity = nil;
}

-- Camera Orientation
local angleLimit = 80
local angleX, targetAngleX = 0, 0
local angleY, targetAngleY = 0, 0

local currentCameraCFrame = nil
local previousCameraCFrame = nil

-- Camera Field Of View
local minFieldOfView = 70
local maxFieldOfView = 1
local minSensitivity = 1.8
local maxSensitivity = 0.1
local currentFieldOfView

-- Sensitivity Scale
local currentScale = 0

-- Event References
local renderSteppedEvent = nil



-- ******** Functions / Methods

-- Convert number from one scale into a different scale.
local function convertScale(x, fromLow, fromHigh, toLow, toHigh, invert)
	x = math.clamp(x, fromLow, fromHigh)
	local percent = (x - fromLow) / (fromHigh - fromLow)
	if invert == true then
		percent = 1 - percent
	end
	return math.clamp(percent * (toHigh - toLow) + toLow, toLow, toHigh)
end

-- Reset camera back to CFrame and FieldOfView before zoom
local function resetCamera()
	if renderSteppedEvent ~= nil then
		renderSteppedEvent:Disconnect()
		renderSteppedEvent = nil
	end
	
	-- Clear Target
	target = nil
	
	-- Restore Original Parameters
	camera.CameraType = Enum.CameraType.Custom
	camera.CFrame = originalProperties.cframe
	camera.FieldOfView = originalProperties.fieldOfView
	userInputService.MouseBehavior = originalProperties.mouseBehavior
	userInputService.MouseDeltaSensitivity = originalProperties.mouseDeltaSensitivity
end

local function zoomCamera()
	-- Allow camera to be changed by script.
	camera.CameraType = Enum.CameraType.Scriptable

	-- Store camera properties before zoom.
	originalProperties.cframe = camera.CFrame
	originalProperties.fieldOfView = camera.FieldOfView
	originalProperties.mouseBehavior = userInputService.MouseBehavior
	originalProperties.mouseDeltaSensitivity = userInputService.MouseDeltaSensitivity
		
	-- Zoom camera
	target = head.Position + head.CFrame.LookVector * 20
	currentCameraCFrame = CFrame.new(head.Position, target) * CFrame.new(0, 1, -5)
	currentFieldOfView = 40
	currentScale = convertScale(currentFieldOfView, maxFieldOfView, minFieldOfView, maxSensitivity, minSensitivity, true)
	camera.Focus = CFrame.new(target)

	-- Lock and slow down mouse
	userInputService.MouseBehavior = Enum.MouseBehavior.LockCenter
	userInputService.MouseDeltaSensitivity = 1

	-- Reset zoom angles
	angleX, targetAngleX = 0, 0
	angleY, targetAngleY = 0, 0
	
	-- Update camera every frame if needed.
	-- XXX: This is a bit sluggish, probably due to the tween.  This
	-- will need to be adjusted or even recoded to use a different
	-- method.
	renderSteppedEvent = runService.RenderStepped:Connect(function(deltaTime)
		if currentCameraCFrame ~= nil then
			if previousCameraCFrame ~= currentCameraCFrame then
				local twinfo = TweenInfo.new(deltaTime)
				local goal = {}
				goal.CFrame = currentCameraCFrame
				local tween = TweenService:Create(camera, twinfo, goal)
				tween:Play()
				previousCameraCFrame = currentCameraCFrame
			end
		end
		camera.FieldOfView = currentFieldOfView
	end)

end

-- Toggle camera zoom/unzoom
local function mouseClick()
	if zoomed then
		-- Unzoom camera
		resetCamera()
		zoomed = false
	else
		-- Zoom in camera
		zoomCamera()
		zoomed = true
	end
end

-- Called when the mouse is moved.
local function mouseMoved(input)
	if zoomed then
		--local sensitivity = .6 -- anything higher would make looking up and down harder; recommend anything between 0~1
		local smoothness = 0.05 -- recommend anything between 0~1
		local sensitivity = currentScale
		print(currentScale)
		
		-- Compute new delta based on sensitivity and smoothness
		local delta = Vector2.new(input.Delta.x / sensitivity, input.Delta.y / sensitivity) * smoothness
		
		-- I'm not sure why the delta and targetAngle axes are
		-- crossed like this, but undoing this makes the camera
		-- go insane.
		local X = targetAngleX - delta.Y
		local Y = targetAngleY - delta.X
		
		-- Apply Angle Limits
		targetAngleX = (X >= angleLimit and angleLimit) or (X <= -angleLimit and -angleLimit) or X
		targetAngleY = (Y >= angleLimit and angleLimit) or (Y <= -angleLimit and -angleLimit) or Y
		
		-- Not entirely sure what the significance of this
		-- strange scaling method is, but it seems to be
		-- reqired.
		angleX = angleX + (targetAngleX - angleX) * 0.35
		angleY = angleY + (targetAngleY - angleY) * 0.15
		
		-- Calculate a new CFrame for the camera.
		-- **** ORDER IS IMPORTANT ****
		local cframe = CFrame.new()
		cframe *= cframe:ToObjectSpace(CFrame.Angles(0, math.rad(angleY), 0))
		cframe *= CFrame.Angles(math.rad(angleX), 0, 0)
		cframe *= CFrame.new(head.Position, target)
		cframe *= CFrame.new(0, 1, -5)
		currentCameraCFrame = cframe
		
		-- Calculate a new camera focus.
		camera.Focus = CFrame.new(cframe.Position + cframe.LookVector * 20)
	end
end

-- Mouse wheel event handler.
local function mouseWheel(input)
	if input.Position.Z == 1 then
		local temp = currentFieldOfView
		temp = math.clamp(temp - 3, maxFieldOfView, minFieldOfView)
		currentScale = convertScale(temp, maxFieldOfView, minFieldOfView, maxSensitivity, minSensitivity, true)
		currentFieldOfView = temp
	elseif input.Position.Z == -1 then
		local temp = currentFieldOfView
		temp = math.clamp(temp + 3, maxFieldOfView, minFieldOfView)
		currentScale = convertScale(temp, maxFieldOfView, minFieldOfView, maxSensitivity, minSensitivity, true)
		currentFieldOfView = temp
	end
end

-- Function call lookup table for input begin event handlers.
local inputBeginCallTable = {
	[Enum.UserInputType.MouseButton1] = mouseClick;
	[Enum.UserInputType.MouseWheel] = mouseWheel;
}

-- Function call lookup table for input changed event handlers.
local inputChangedCallTable = {
	[Enum.UserInputType.MouseMovement] = mouseMoved;
	[Enum.UserInputType.MouseWheel] = mouseWheel;
}

-- Input begin event handler dispatcher.
local function inputBegan(input, _gameProcessedEvent)
	if inputBeginCallTable[input.UserInputType] ~= nil then
		inputBeginCallTable[input.UserInputType](input)
	end
end

-- Input changed event handler dispatcher.
local function inputChanged(input, _gameProcessedEvent)
	if inputChangedCallTable[input.UserInputType] ~= nil then
		inputChangedCallTable[input.UserInputType](input)
	end
end



-- ******** Events

-- Enable mouse events if a mouse is detected.
if userInputService.MouseEnabled then
	userInputService.InputBegan:Connect(inputBegan)
	userInputService.InputChanged:Connect(inputChanged)
end