Clients/Client2018/content/scripts/PlayerScripts/StarterPlayerScripts_NewStr.../RobloxPlayerScript/CameraScript/OrbitalCamera.lua

418 lines
17 KiB
Lua

--[[
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