--[[ OrbitalCamera - Spherical coordinates control camera for top-down games 2018 Camera Update - AllYourBlox --]] -- Local private variables and constants local UNIT_X = Vector3.new(1,0,0) local UNIT_Y = Vector3.new(0,1,0) local UNIT_Z = Vector3.new(0,0,1) local X1_Y0_Z1 = Vector3.new(1,0,1) --Note: not a unit vector, used for projecting onto XZ plane local ZERO_VECTOR3 = Vector3.new(0,0,0) local ZERO_VECTOR2 = Vector2.new(0,0) local TAU = 2 * math.pi local VR_PITCH_FRACTION = 0.25 local tweenAcceleration = math.rad(220) --Radians/Second^2 local tweenSpeed = math.rad(0) --Radians/Second local tweenMaxSpeed = math.rad(250) --Radians/Second local TIME_BEFORE_AUTO_ROTATE = 2.0 --Seconds, used when auto-aligning camera with vehicles local PORTRAIT_OFFSET = Vector3.new(0,-3,0) --[[ Gamepad Support ]]-- local THUMBSTICK_DEADZONE = 0.2 -- 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 Util = require(script.Parent:WaitForChild("CameraUtils")) --[[ Services ]]-- local PlayersService = game:GetService('Players') local VRService = game:GetService("VRService") --[[ Utility functions specific to OrbitalCamera ]]-- local function GetValueObject(name, defaultValue) local valueObj = script:FindFirstChild(name) if valueObj then return valueObj.Value end return defaultValue end --[[ The Module ]]-- local BaseCamera = require(script.Parent:WaitForChild("BaseCamera")) local OrbitalCamera = setmetatable({}, BaseCamera) OrbitalCamera.__index = OrbitalCamera function OrbitalCamera.new() local self = setmetatable(BaseCamera.new(), OrbitalCamera) self.lastUpdate = tick() -- OrbitalCamera-specific members self.changedSignalConnections = {} self.refAzimuthRad = nil self.curAzimuthRad = nil self.minAzimuthAbsoluteRad = nil self.maxAzimuthAbsoluteRad = nil self.useAzimuthLimits = nil self.curElevationRad = nil self.minElevationRad = nil self.maxElevationRad = nil self.curDistance = nil self.minDistance = nil self.maxDistance = nil -- Gamepad self.r3ButtonDown = false self.l3ButtonDown = false self.gamepadDollySpeedMultiplier = 1 self.lastUserPanCamera = tick() self.externalProperties = {} self.externalProperties["InitialDistance"] = 25 self.externalProperties["MinDistance"] = 10 self.externalProperties["MaxDistance"] = 100 self.externalProperties["InitialElevation"] = 35 self.externalProperties["MinElevation"] = 35 self.externalProperties["MaxElevation"] = 35 self.externalProperties["ReferenceAzimuth"] = -45 -- Angle around the Y axis where the camera starts. -45 offsets the camera in the -X and +Z directions equally self.externalProperties["CWAzimuthTravel"] = 90 -- How many degrees the camera is allowed to rotate from the reference position, CW as seen from above self.externalProperties["CCWAzimuthTravel"] = 90 -- How many degrees the camera is allowed to rotate from the reference position, CCW as seen from above self.externalProperties["UseAzimuthLimits"] = false -- Full rotation around Y axis available by default self:LoadNumberValueParameters() return self end function OrbitalCamera: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 self.externalProperties[name] = valueObj.Value elseif self.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 = self.externalProperties[name] else print("externalProperties table has no entry for ",name) return end if updateFunction then if self.changedSignalConnections[name] then self.changedSignalConnections[name]:Disconnect() end self.changedSignalConnections[name] = valueObj.Changed:Connect(function(newValue) self.externalProperties[name] = newValue updateFunction(self) end) end end function OrbitalCamera:SetAndBoundsCheckAzimuthValues() self.minAzimuthAbsoluteRad = math.rad(self.externalProperties["ReferenceAzimuth"]) - math.abs(math.rad(self.externalProperties["CWAzimuthTravel"])) self.maxAzimuthAbsoluteRad = math.rad(self.externalProperties["ReferenceAzimuth"]) + math.abs(math.rad(self.externalProperties["CCWAzimuthTravel"])) self.useAzimuthLimits = self.externalProperties["UseAzimuthLimits"] if self.useAzimuthLimits then self.curAzimuthRad = math.max(self.curAzimuthRad, self.minAzimuthAbsoluteRad) self.curAzimuthRad = math.min(self.curAzimuthRad, self.maxAzimuthAbsoluteRad) end end function OrbitalCamera: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(self.externalProperties["MinElevation"], MIN_ALLOWED_ELEVATION_DEG) local maxElevationDeg = math.min(self.externalProperties["MaxElevation"], MAX_ALLOWED_ELEVATION_DEG) -- Set internal values in radians self.minElevationRad = math.rad(math.min(minElevationDeg, maxElevationDeg)) self.maxElevationRad = math.rad(math.max(minElevationDeg, maxElevationDeg)) self.curElevationRad = math.max(self.curElevationRad, self.minElevationRad) self.curElevationRad = math.min(self.curElevationRad, self.maxElevationRad) end function OrbitalCamera:SetAndBoundsCheckDistanceValues() self.minDistance = self.externalProperties["MinDistance"] self.maxDistance = self.externalProperties["MaxDistance"] self.curDistance = math.max(self.curDistance, self.minDistance) self.curDistance = math.min(self.curDistance, self.maxDistance) end -- This loads from, or lazily creates, NumberValue objects for exposed parameters function OrbitalCamera:LoadNumberValueParameters() -- These initial values do not require change listeners since they are read only once self:LoadOrCreateNumberValueParameter("InitialElevation", "NumberValue", nil) self: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 self:LoadOrCreateNumberValueParameter("ReferenceAzimuth", "NumberValue", self.SetAndBoundsCheckAzimuthValue) self:LoadOrCreateNumberValueParameter("CWAzimuthTravel", "NumberValue", self.SetAndBoundsCheckAzimuthValues) self:LoadOrCreateNumberValueParameter("CCWAzimuthTravel", "NumberValue", self.SetAndBoundsCheckAzimuthValues) self:LoadOrCreateNumberValueParameter("MinElevation", "NumberValue", self.SetAndBoundsCheckElevationValues) self:LoadOrCreateNumberValueParameter("MaxElevation", "NumberValue", self.SetAndBoundsCheckElevationValues) self:LoadOrCreateNumberValueParameter("MinDistance", "NumberValue", self.SetAndBoundsCheckDistanceValues) self:LoadOrCreateNumberValueParameter("MaxDistance", "NumberValue", self.SetAndBoundsCheckDistanceValues) self:LoadOrCreateNumberValueParameter("UseAzimuthLimits", "BoolValue", self.SetAndBoundsCheckAzimuthValues) -- Internal values set (in radians, from degrees), plus sanitization self.curAzimuthRad = math.rad(self.externalProperties["ReferenceAzimuth"]) self.curElevationRad = math.rad(self.externalProperties["InitialElevation"]) self.curDistance = self.externalProperties["InitialDistance"] self:SetAndBoundsCheckAzimuthValues() self:SetAndBoundsCheckElevationValues() self:SetAndBoundsCheckDistanceValues() end function OrbitalCamera:GetModuleName() return "OrbitalCamera" end function OrbitalCamera:SetInitialOrientation(humanoid) if not humanoid or not humanoid.RootPart then warn("OrbitalCamera could not set initial orientation due to missing humanoid") return end local newDesiredLook = (humanoid.RootPart.CFrame.lookVector - Vector3.new(0,0.23,0)).unit local horizontalShift = Util.GetAngleBetweenXZVectors(newDesiredLook, self:GetCameraLookVector()) local vertShift = math.asin(self:GetCameraLookVector().y) - math.asin(newDesiredLook.y) if not Util.IsFinite(horizontalShift) then horizontalShift = 0 end if not Util.IsFinite(vertShift) then vertShift = 0 end self.rotateInput = Vector2.new(horizontalShift, vertShift) end --[[ Functions of BaseCamera that are overridden by OrbitalCamera ]]-- function OrbitalCamera:GetCameraToSubjectDistance() return self.curDistance end function OrbitalCamera:SetCameraToSubjectDistance(desiredSubjectDistance) print("OrbitalCamera SetCameraToSubjectDistance ",desiredSubjectDistance) local player = PlayersService.LocalPlayer if player then self.currentSubjectDistance = Util.Clamp(self.minDistance, self.maxDistance, desiredSubjectDistance) -- OrbitalCamera is not allowed to go into the first-person range self.currentSubjectDistance = math.max(self.currentSubjectDistance, self.FIRST_PERSON_DISTANCE_THRESHOLD) end self.inFirstPerson = false self:UpdateMouseBehavior() return self.currentSubjectDistance end function OrbitalCamera:CalculateNewLookVector(suppliedLookVector, xyRotateVector) local currLookVector = suppliedLookVector or self:GetCameraLookVector() local currPitchAngle = math.asin(currLookVector.y) local yTheta = Util.Clamp(currPitchAngle - math.rad(MAX_ALLOWED_ELEVATION_DEG), currPitchAngle - math.rad(MIN_ALLOWED_ELEVATION_DEG), xyRotateVector.y) local constrainedRotateInput = Vector2.new(xyRotateVector.x, yTheta) local startCFrame = CFrame.new(ZERO_VECTOR3, currLookVector) local newLookVector = (CFrame.Angles(0, -constrainedRotateInput.x, 0) * startCFrame * CFrame.Angles(-constrainedRotateInput.y,0,0)).lookVector return newLookVector end function OrbitalCamera:GetGamepadPan(name, state, input) if input.UserInputType == self.activeGamepad and input.KeyCode == Enum.KeyCode.Thumbstick2 then if self.r3ButtonDown or self.l3ButtonDown then -- R3 or L3 Thumbstick is depressed, right stick controls dolly in/out if (input.Position.Y > THUMBSTICK_DEADZONE) then self.gamepadDollySpeedMultiplier = 0.96 elseif (input.Position.Y < -THUMBSTICK_DEADZONE) then self.gamepadDollySpeedMultiplier = 1.04 else self.gamepadDollySpeedMultiplier = 1.00 end else if state == Enum.UserInputState.Cancel then self.gamepadPanningCamera = ZERO_VECTOR2 return end local inputVector = Vector2.new(input.Position.X, -input.Position.Y) if inputVector.magnitude > THUMBSTICK_DEADZONE then self.gamepadPanningCamera = Vector2.new(input.Position.X, -input.Position.Y) else self.gamepadPanningCamera = ZERO_VECTOR2 end end end end function OrbitalCamera:DoGamepadZoom(name, state, input) if input.UserInputType == self.activeGamepad and (input.KeyCode == Enum.KeyCode.ButtonR3 or input.KeyCode == Enum.KeyCode.ButtonL3) then if (state == Enum.UserInputState.Begin) then self.r3ButtonDown = input.KeyCode == Enum.KeyCode.ButtonR3 self.l3ButtonDown = input.KeyCode == Enum.KeyCode.ButtonL3 elseif (state == Enum.UserInputState.End) then if (input.KeyCode == Enum.KeyCode.ButtonR3) then self.r3ButtonDown = false elseif (input.KeyCode == Enum.KeyCode.ButtonL3) then self.l3ButtonDown = false end if (not self.r3ButtonDown) and (not self.l3ButtonDown) then self.gamepadDollySpeedMultiplier = 1.00 end end end end function OrbitalCamera:BindGamepadInputActions() local ContextActionService = game:GetService('ContextActionService') ContextActionService:BindAction("OrbitalCamGamepadPan", function(name, state, input) self:GetGamepadPan(name, state, input) end, false, Enum.KeyCode.Thumbstick2) ContextActionService:BindAction("OrbitalCamGamepadZoom", function(name, state, input) self:DoGamepadZoom(name, state, input) end, false, Enum.KeyCode.ButtonR3) ContextActionService:BindAction("OrbitalCamGamepadZoomAlt", function(name, state, input) self:DoGamepadZoom(name, state, input) end, false, Enum.KeyCode.ButtonL3) end -- [[ Update ]]-- function OrbitalCamera:Update(dt) local now = tick() local timeDelta = (now - self.lastUpdate) local userPanningTheCamera = (self.UserPanningTheCamera == true) local camera = workspace.CurrentCamera local newCameraCFrame = camera.CFrame local newCameraFocus = camera.Focus 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 self.lastUpdate == nil or timeDelta > 1 then self.lastCameraTransform = nil end if self.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, timeDelta) 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 self.lastUserPanCamera = tick() end local userRecentlyPannedCamera = now - self.lastUserPanCamera < TIME_BEFORE_AUTO_ROTATE local subjectPosition = self:GetSubjectPosition() if subjectPosition and player and camera then -- Process any dollying being done by gamepad -- TODO: Move this if self.gamepadDollySpeedMultiplier ~= 1 then self:SetCameraToSubjectDistance(self.currentSubjectDistance * self.gamepadDollySpeedMultiplier) end local VREnabled = VRService.VREnabled newCameraFocus = VREnabled and self:GetVRFocus(subjectPosition, timeDelta) or CFrame.new(subjectPosition) local cameraFocusP = newCameraFocus.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 > self.currentSubjectDistance or self.rotateInput.x ~= 0 then local desiredDist = math.min(distToSubject, self.currentSubjectDistance) -- Note that CalculateNewLookVector is overriden from BaseCamera vecToSubject = self:CalculateNewLookVector(vecToSubject.unit * X1_Y0_Z1, 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 newCameraCFrame = CFrame.new(newPos, lookAt) + Vector3.new(0, cameraHeight, 0) end else -- self.RotateInput is a Vector2 of mouse movement deltas since last update self.curAzimuthRad = self.curAzimuthRad - self.rotateInput.x if self.useAzimuthLimits then self.curAzimuthRad = Util.Clamp(self.minAzimuthAbsoluteRad, self.maxAzimuthAbsoluteRad, self.curAzimuthRad) else self.curAzimuthRad = (self.curAzimuthRad ~= 0) and (math.sign(self.curAzimuthRad) * (math.abs(self.curAzimuthRad) % TAU)) or 0 end self.curElevationRad = Util.Clamp(self.minElevationRad, self.maxElevationRad, self.curElevationRad + self.rotateInput.y) local cameraPosVector = self.currentSubjectDistance * ( CFrame.fromEulerAnglesYXZ( -self.curElevationRad, self.curAzimuthRad, 0 ) * UNIT_Z ) local camPos = subjectPosition + cameraPosVector newCameraCFrame = CFrame.new(camPos, subjectPosition) self.rotateInput = ZERO_VECTOR2 end self.lastCameraTransform = newCameraCFrame self.lastCameraFocus = newCameraFocus if (isInVehicle or isOnASkateboard) and cameraSubject:IsA('BasePart') then self.lastSubjectCFrame = cameraSubject.CFrame else self.lastSubjectCFrame = nil end end self.lastUpdate = now return newCameraCFrame, newCameraFocus end return OrbitalCamera