Skip to content

Commit 6dc2d1f

Browse files
authored
Merge pull request #709 from Quenty/users/alex-y-z/camera
feat: ConstrainedLookCamera
2 parents 1acafc7 + 8247ba8 commit 6dc2d1f

2 files changed

Lines changed: 363 additions & 0 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
--!strict
2+
--[=[
3+
Input handler that drives a [ConstrainedLookCamera]. Reacts to right-click
4+
drag on mouse, drag on touchscreen, and right thumbstick on gamepad.
5+
6+
@class ConstrainedLookControls
7+
]=]
8+
9+
local require = require(script.Parent.loader).load(script)
10+
11+
local ContextActionService = game:GetService("ContextActionService")
12+
local RunService = game:GetService("RunService")
13+
local UserInputService = game:GetService("UserInputService")
14+
15+
local ConstrainedLookCamera = require("ConstrainedLookCamera")
16+
local GamepadRotateModel = require("GamepadRotateModel")
17+
local InputObjectUtils = require("InputObjectUtils")
18+
local Maid = require("Maid")
19+
20+
local ConstrainedLookControls = {}
21+
ConstrainedLookControls.__index = ConstrainedLookControls
22+
ConstrainedLookControls.ClassName = "ConstrainedLookControls"
23+
24+
ConstrainedLookControls.MOUSE_SENSITIVITY = Vector2.new(math.pi * 4, math.pi * 1.9)
25+
ConstrainedLookControls.GAMEPAD_SENSITIVITY = 0.1
26+
ConstrainedLookControls._dragBeginTypes = { Enum.UserInputType.MouseButton2, Enum.UserInputType.Touch }
27+
28+
export type ConstrainedLookControls = typeof(setmetatable(
29+
{} :: {
30+
_camera: ConstrainedLookCamera.ConstrainedLookCamera,
31+
_enabled: boolean,
32+
_key: string,
33+
_maid: Maid.Maid?,
34+
_gamepadRotateModel: GamepadRotateModel.GamepadRotateModel,
35+
_lastMousePosition: Vector3?,
36+
_mouseSensitivity: Vector2,
37+
_gamepadSensitivity: number,
38+
},
39+
{} :: typeof({ __index = ConstrainedLookControls })
40+
))
41+
42+
--[=[
43+
Constructs a new ConstrainedLookControls bound to the given camera.
44+
]=]
45+
function ConstrainedLookControls.new(camera: ConstrainedLookCamera.ConstrainedLookCamera): ConstrainedLookControls
46+
local self: ConstrainedLookControls = setmetatable({} :: any, ConstrainedLookControls)
47+
48+
self._camera = assert(camera, "Bad camera")
49+
self._enabled = false
50+
self._key = tostring(self) .. "ConstrainedLookControls"
51+
self._mouseSensitivity = ConstrainedLookControls.MOUSE_SENSITIVITY
52+
self._gamepadSensitivity = ConstrainedLookControls.GAMEPAD_SENSITIVITY
53+
self._gamepadRotateModel = GamepadRotateModel.new()
54+
55+
return self
56+
end
57+
58+
--[=[
59+
Sets mouse sensitivity.
60+
]=]
61+
function ConstrainedLookControls.SetMouseSensitivity(self: ConstrainedLookControls, sensitivity: Vector2)
62+
self._mouseSensitivity = sensitivity
63+
end
64+
65+
--[=[
66+
Sets gamepad sensitivity.
67+
]=]
68+
function ConstrainedLookControls.SetGamepadSensitivity(self: ConstrainedLookControls, sensitivity: number)
69+
self._gamepadSensitivity = sensitivity
70+
end
71+
72+
--[=[
73+
Returns whether controls are enabled.
74+
]=]
75+
function ConstrainedLookControls.IsEnabled(self: ConstrainedLookControls): boolean
76+
return self._enabled
77+
end
78+
79+
--[=[
80+
Enables input. Binds drag (MouseButton2 / Touch) and gamepad Thumbstick2.
81+
]=]
82+
function ConstrainedLookControls.Enable(self: ConstrainedLookControls)
83+
if self._enabled then
84+
return
85+
end
86+
assert(not self._maid, "Maid already defined")
87+
self._enabled = true
88+
89+
local maid = Maid.new()
90+
self._maid = maid
91+
92+
maid:GiveTask(self._gamepadRotateModel.IsRotating.Changed:Connect(function()
93+
if self._gamepadRotateModel.IsRotating.Value then
94+
self:_handleGamepadRotateStart()
95+
else
96+
self:_handleGamepadRotateStop()
97+
end
98+
end))
99+
100+
ContextActionService:BindAction(self._key .. "Drag", function(_, userInputState, inputObject)
101+
if userInputState == Enum.UserInputState.Begin then
102+
self:_beginDrag(inputObject)
103+
end
104+
end, false, unpack(self._dragBeginTypes))
105+
106+
ContextActionService:BindAction(self._key .. "Rotate", function(_, _, inputObject)
107+
self._gamepadRotateModel:HandleThumbstickInput(inputObject)
108+
end, false, Enum.KeyCode.Thumbstick2)
109+
110+
maid:GiveTask(function()
111+
ContextActionService:UnbindAction(self._key .. "Drag")
112+
ContextActionService:UnbindAction(self._key .. "Rotate")
113+
end)
114+
115+
maid:GiveTask(function()
116+
self._camera:Release()
117+
end)
118+
end
119+
120+
--[=[
121+
Disables input and releases the camera.
122+
]=]
123+
function ConstrainedLookControls.Disable(self: ConstrainedLookControls)
124+
if not self._enabled then
125+
return
126+
end
127+
assert(self._maid, "Must be enabled")
128+
self._enabled = false
129+
130+
self._maid:DoCleaning()
131+
self._maid = nil
132+
self._lastMousePosition = nil
133+
end
134+
135+
function ConstrainedLookControls._beginDrag(self: ConstrainedLookControls, beginInputObject: InputObject)
136+
assert(self._maid, "Must be enabled")
137+
138+
local maid = Maid.new()
139+
self._lastMousePosition = beginInputObject.Position
140+
141+
local isMouse = InputObjectUtils.isMouseUserInputType(beginInputObject.UserInputType)
142+
if isMouse then
143+
UserInputService.MouseBehavior = Enum.MouseBehavior.LockCurrentPosition
144+
maid:GiveTask(function()
145+
UserInputService.MouseBehavior = Enum.MouseBehavior.Default
146+
end)
147+
end
148+
149+
maid:GiveTask(UserInputService.InputEnded:Connect(function(inputObject: InputObject)
150+
if inputObject == beginInputObject then
151+
self:_endDrag()
152+
end
153+
end))
154+
155+
maid:GiveTask(UserInputService.InputChanged:Connect(function(inputObject: InputObject)
156+
if InputObjectUtils.isMouseUserInputType(inputObject.UserInputType) or inputObject == beginInputObject then
157+
self:_handleDragMovement(inputObject)
158+
end
159+
end))
160+
161+
maid:GiveTask(function()
162+
self._lastMousePosition = nil
163+
self._camera:Release()
164+
end)
165+
166+
self._maid._dragMaid = maid
167+
end
168+
169+
function ConstrainedLookControls._endDrag(self: ConstrainedLookControls)
170+
assert(self._maid, "Must be enabled")
171+
self._maid._dragMaid = nil
172+
end
173+
174+
function ConstrainedLookControls._handleDragMovement(self: ConstrainedLookControls, inputObject: InputObject)
175+
if not self._lastMousePosition then
176+
return
177+
end
178+
179+
local delta = -inputObject.Delta
180+
if InputObjectUtils.isMouseUserInputType(inputObject.UserInputType) then
181+
delta += self._lastMousePosition - inputObject.Position
182+
end
183+
184+
local xTheta = delta.X / 1920
185+
local yTheta = delta.Y / 1200
186+
local deltaAngle = Vector2.new(xTheta, yTheta) * self._mouseSensitivity
187+
self._camera:RotateXY(deltaAngle)
188+
189+
self._lastMousePosition = inputObject.Position
190+
end
191+
192+
function ConstrainedLookControls._handleGamepadRotateStart(self: ConstrainedLookControls)
193+
assert(self._maid, "Must be enabled")
194+
195+
local maid = Maid.new()
196+
197+
maid:GiveTask(RunService.Stepped:Connect(function()
198+
local deltaAngle = self._gamepadSensitivity * self._gamepadRotateModel:GetThumbstickDeltaAngle()
199+
self._camera:RotateXY(deltaAngle)
200+
end))
201+
202+
maid:GiveTask(function()
203+
self._camera:Release()
204+
end)
205+
206+
self._maid._dragMaid = maid
207+
end
208+
209+
function ConstrainedLookControls._handleGamepadRotateStop(self: ConstrainedLookControls)
210+
assert(self._maid, "Must be enabled")
211+
self._maid._dragMaid = nil
212+
end
213+
214+
function ConstrainedLookControls.Destroy(self: ConstrainedLookControls)
215+
self:Disable()
216+
self._gamepadRotateModel:Destroy()
217+
setmetatable(self :: any, nil)
218+
end
219+
220+
return ConstrainedLookControls
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
--!strict
2+
--[=[
3+
Constrains pitch and yaw within a cone.
4+
5+
@class ConstrainedLookCamera
6+
]=]
7+
8+
local require = require(script.Parent.loader).load(script)
9+
10+
local CameraEffectUtils = require("CameraEffectUtils")
11+
local CameraState = require("CameraState")
12+
local Spring = require("Spring")
13+
local SummedCamera = require("SummedCamera")
14+
15+
local ConstrainedLookCamera = {}
16+
ConstrainedLookCamera.ClassName = "ConstrainedLookCamera"
17+
18+
export type ConstrainedLookCamera =
19+
typeof(setmetatable(
20+
{} :: {
21+
CameraState: CameraState.CameraState,
22+
CFrame: CFrame,
23+
AngleYaw: number,
24+
AnglePitch: number,
25+
TargetAngleYaw: number,
26+
TargetAnglePitch: number,
27+
MaxYawOffset: number,
28+
MaxPitchOffset: number,
29+
Speed: number,
30+
SpeedYaw: number,
31+
SpeedPitch: number,
32+
Damper: number,
33+
SpringYaw: Spring.Spring<number>,
34+
SpringPitch: Spring.Spring<number>,
35+
},
36+
{} :: typeof({ __index = ConstrainedLookCamera })
37+
))
38+
& CameraEffectUtils.CameraEffect
39+
40+
ConstrainedLookCamera._maxYawOffset = math.rad(20)
41+
ConstrainedLookCamera._maxPitchOffset = math.rad(15)
42+
43+
--[=[
44+
Constructs a new ConstrainedLookCamera.
45+
]=]
46+
function ConstrainedLookCamera.new(): ConstrainedLookCamera
47+
local self: ConstrainedLookCamera = setmetatable({} :: any, ConstrainedLookCamera)
48+
49+
self.SpringYaw = Spring.new(0)
50+
self.SpringPitch = Spring.new(0)
51+
self.Speed = 15
52+
self.Damper = 1
53+
54+
return self
55+
end
56+
57+
function ConstrainedLookCamera.__add(self: ConstrainedLookCamera, other: CameraEffectUtils.CameraEffect)
58+
return SummedCamera.new(self, other)
59+
end
60+
61+
--[=[
62+
Rotates the target yaw (X) and pitch (Y) by the given delta, clamped within the max offsets.
63+
]=]
64+
function ConstrainedLookCamera.RotateXY(self: ConstrainedLookCamera, delta: Vector2)
65+
self.TargetAngleYaw += delta.X
66+
self.TargetAnglePitch += delta.Y
67+
end
68+
69+
--[=[
70+
Releases input, sending spring targets back to the origin.
71+
]=]
72+
function ConstrainedLookCamera.Release(self: ConstrainedLookCamera)
73+
self.TargetAngleYaw = 0
74+
self.TargetAnglePitch = 0
75+
end
76+
77+
--[=[
78+
Snaps springs to the origin without animating.
79+
]=]
80+
function ConstrainedLookCamera.SnapToOrigin(self: ConstrainedLookCamera)
81+
self.SpringYaw:SetTarget(0, true)
82+
self.SpringPitch:SetTarget(0, true)
83+
end
84+
85+
function ConstrainedLookCamera.__newindex(self: ConstrainedLookCamera, index, value)
86+
if index == "AngleYaw" then
87+
self.SpringYaw.Position = math.clamp(value, -self.MaxYawOffset, self.MaxYawOffset)
88+
elseif index == "AnglePitch" then
89+
self.SpringPitch.Position = math.clamp(value, -self.MaxPitchOffset, self.MaxPitchOffset)
90+
elseif index == "TargetAngleYaw" then
91+
self.SpringYaw.Target = math.clamp(value, -self.MaxYawOffset, self.MaxYawOffset)
92+
elseif index == "TargetAnglePitch" then
93+
self.SpringPitch.Target = math.clamp(value, -self.MaxPitchOffset, self.MaxPitchOffset)
94+
elseif index == "MaxYawOffset" then
95+
assert(value >= 0, "MaxYawOffset must be non-negative")
96+
self._maxYawOffset = value
97+
self.TargetAngleYaw = self.SpringYaw.Target
98+
elseif index == "MaxPitchOffset" then
99+
assert(value >= 0, "MaxPitchOffset must be non-negative")
100+
self._maxPitchOffset = value
101+
self.TargetAnglePitch = self.SpringPitch.Target
102+
elseif index == "SpeedYaw" then
103+
self.SpringYaw.Speed = value
104+
elseif index == "SpeedPitch" then
105+
self.SpringPitch.Speed = value
106+
elseif index == "Speed" then
107+
self.SpringYaw.Speed = value
108+
self.SpringPitch.Speed = value
109+
elseif index == "Damper" then
110+
self.SpringYaw.Damper = value
111+
self.SpringPitch.Damper = value
112+
elseif ConstrainedLookCamera[index] ~= nil or index == "SpringYaw" or index == "SpringPitch" then
113+
rawset(self, index, value)
114+
else
115+
error(`{tostring(index)} is not a valid member of ConstrainedLookCamera`)
116+
end
117+
end
118+
119+
function ConstrainedLookCamera.__index(self: ConstrainedLookCamera, index)
120+
if index == "CameraState" then
121+
local state = CameraState.new()
122+
state.CFrame = self.CFrame
123+
return state
124+
elseif index == "CFrame" then
125+
return CFrame.Angles(0, self.AngleYaw, 0) * CFrame.Angles(self.AnglePitch, 0, 0)
126+
elseif index == "AngleYaw" then
127+
return self.SpringYaw.Position
128+
elseif index == "AnglePitch" then
129+
return self.SpringPitch.Position
130+
elseif index == "TargetAngleYaw" then
131+
return self.SpringYaw.Target
132+
elseif index == "TargetAnglePitch" then
133+
return self.SpringPitch.Target
134+
elseif index == "MaxYawOffset" then
135+
return self._maxYawOffset
136+
elseif index == "MaxPitchOffset" then
137+
return self._maxPitchOffset
138+
else
139+
return ConstrainedLookCamera[index]
140+
end
141+
end
142+
143+
return ConstrainedLookCamera

0 commit comments

Comments
 (0)