The zoom-zoom: a tutorial-resource for sprinting, dashing, and double-jumps

The zoom-zoom: a tutorial-resource for sprinting, dashing, and double-jumps

Contents

  1. Github repo, experience
  2. Category compliance, README
  3. Brief Luau practices used in tutorial (for those uninformed)
  4. Full scripts
  5. Step-by-step

Github repo, experience


1. Category compliance, README

Compliance with #resources:community-tutorials


I have been approached by a sizable number of developers regarding my dashing post
It would be a disservice to these developers by providing supplementary info/tweaks/fixes to code that’s nearly 2 years old

As I am still learning the hokey-pokey of Luau (: operators, etc). I apologize in advance for any confusion or misinformation that is imparted from this tutorial. If there are any corrections to be made to the definitions please let me know.

All code written in Visual Studio and synced with Rojo, and I’d highly recommended it for all developers

Cross-platform compatibility

In terms of services, I use both ContextActionService and UserInputService
There are, however, some “ghetto” workarounds that I use
(such as the mobile jump button .Activated event firing when the input ends [makes zero sense])


2. Luau practices used

To avoid confusion down the line
Skip if you already know type definitions; only covers the Luau practices used in this tutorial
To the best of my knowledge, these do not directly affect scripts or their performance; they are simply used for readability

2a. type definitions

local x : number = 5
--      ^   ^ ":" tells us that variable x 's type is a number
local mightbeanumber : number? = nil
--[[
	a `?` can be added at the end of the type declaration, showing it *may* be that type or nil
	for example: ``
	in this case our variable `mightbeanumber` is nil
]]

2b. return types

adding “: type” after declaring a function says the type of variable that the function returns

function isanumber(x : any): boolean
	return type(x) == "number"
end

if more than 1 variable is returned, the return types must be wrapped in parentheses:

function multiplyanddivide(x : number, y : number): (number, number)
	return x * y, x / y
end

local product, quotient = multiplyanddivide(5, 10)
-- 50, 0.5

3. Full scripts:

MoverUtil (src/Movement/MoverUtil.lua)

A handy Util for applying a BodyMover to a BasePart (imported from my projects); I added explanations to the best of my ability
In the scope of this tutorial, it is used to handle Dash physics

-- ModuleScript
assert(script.ClassName == "ModuleScript", "script is not a ModuleScript")

-- create new type MoverInfo -- THIS IS ONLY FOR READABILITY/VISUAL AID
export type MoverInfo	= { --create new type "MoverInfo"
	BasePart : BasePart; -- basepart that the bodymover will act on
	Type : string; -- type of bodymover ("BodyVelocity", "BodyGyro", etc)
	Properties : Dictionary<any>; -- properties of bodymover ^
	Lifetime : number? -- how long will the bodymover act upon the basepart?
}

local Main = {}

--@desc		Applies Instance.new(`MoverInfo.Type`) to part `MoverInfo.BasePart` with properties `MoverInfo.Properties`, for an optional duration of `MoverInfo.Lifetime` seconds
--@param	{MoverInfo : MoverInfo} type MoverInfo
--@returns	returns BodyMover

function Main:ApplyMover(MoverInfo : MoverInfo): BodyMover
	local Mover = Instance.new(MoverInfo.Type) -- create new BodyMover as defined by MoverInfo
	assert(Mover, ("%s is not instance or no Mover type provided"):format(MoverInfo.Type or "nil")) -- assert() failsafe
	for Property, Value in pairs(MoverInfo.Properties) do -- apply properties to Mover
		Mover[Property] = Value
	end
	Mover.Parent = MoverInfo.BasePart -- parent Mover to target BasePart
	if MoverInfo.Lifetime then -- if lifetime is provided, then set lifetime
		coroutine.wrap(function() -- Debris service is inefficient, better-off writing your own
			task.wait(MoverInfo.Lifetime)
			Mover:Destroy()
		end)()
	end
	return Mover -- return mover being applied
end

--@desc		see @returns
--@param	{Direction : Vector3} normalized Vector3
--@param	{RelativeTo : CFrame} CFrame
--@returns	returns a normalized Vector3, where the CFrame (Y-rotation only) is oriented in the direction of the Direction vector

function Main:GetRelativeDirection(Direction : Vector3, RelativeTo : CFrame): Vector3
	return CFrame.lookAt(RelativeTo.Position, (RelativeTo.Position + Direction)).LookVector
end

return Main

LocalScript (src/Movement/Local.client.lua)

-- LocalScript
assert(script.ClassName == "LocalScript", "script is not a LocalScript")

-- services
local UIS					= game:GetService("UserInputService")
local CAS					= game:GetService("ContextActionService")

local MoverUtil				= require(script.Parent:WaitForChild("MoverUtil"))

-- variables, instances
local Player				= game:GetService("Players").LocalPlayer
local PlayerGui				= Player:FindFirstChild("PlayerGui") or Player:WaitForChild("PlayerGui")
local Character				= Player.Character or Player.CharacterAdded:Wait()
local RootPart : BasePart	= Character:WaitForChild("HumanoidRootPart")
local Humanoid : Humanoid	= Character:WaitForChild("Humanoid")

local Camera				= workspace.CurrentCamera

-- settings
local BaseSpeed				= game:GetService("StarterPlayer").CharacterWalkSpeed -- variable, default value is 16
local SprintSpeed			= 32		-- variable
local DoubleJumpPower		= 80		-- variable
local DashVelocity			= 80		-- variable
local DashCooldown			= 1			-- variable

local DoubleTapTimeout		= 0.2		-- grace period to trigger double-tap

-- booleans
local CanSprint				= false		-- can client trigger the sprint double-tap?
local DoubleJumped			= false		-- did client already use their double-jump?
local DashReset				= true		-- did client reset their air-dash?

local LastDash				= 0			-- time of last successful dash

-- base functions
local function Dash(_, InputState : Enum.UserInputState, InputObject : InputObject) -- dash
	if InputState == Enum.UserInputState.Begin then
		-- cooldown logic
		local TimeOfRequest = tick()
		local Delta = TimeOfRequest - LastDash
		if Delta < DashCooldown then return end

		-- can only-dash-once-in-air logic
		if Humanoid.FloorMaterial == Enum.Material.Air then
			if not DashReset then return end
			DashReset = false
		end

		-- now that all the checks are passed, actually do the dash
		print("dash")
		local Direction = Humanoid.MoveDirection -- default .movedirection
		if Direction.Magnitude == 0 then -- if the player is not moving, fallback on the direction their character is facing
			Direction = RootPart.CFrame.LookVector
		end

		local RelativeDirection = MoverUtil:GetRelativeDirection(Direction, Camera.CFrame) -- moverutil function
		MoverUtil:ApplyMover({ -- moverutil function; apply mover to HumanoidRootPart
			BasePart = RootPart; -- target of function
			Lifetime = 0.12; -- duration of bodymover
			Type = "BodyVelocity"; -- will use Instance.new("BodyVelocity") to act upon the HumanoidRootPart
			Properties = { -- properties of BodyVelocity
				Velocity = RelativeDirection * DashVelocity; -- v = direction * speed
				MaxForce = Vector3.new(30000, 0, 30000) -- y-component is 0 so they cannot dash up/down
			}
		})
	end
end

local function DoubleJump(_, InputState : Enum.UserInputState, InputObject : InputObject) -- double jump
	if InputState == Enum.UserInputState.Begin then
		if Humanoid.FloorMaterial ~= Enum.Material.Air then return end -- cannot double-jump when on ground
		if DoubleJumped then return end -- cannot double jump more than once
		DoubleJumped = true -- set boolean to true, indicating that the player just triggered the double-jump
		print("double jump")
		RootPart.AssemblyLinearVelocity *= Vector3.new(1, 0, 1) -- neutralize y-component of HumanoidRootPart.AssemblyLinearVelocity
		RootPart.AssemblyLinearVelocity += Vector3.new(0, DoubleJumpPower, 0) -- add velocity to HumanoidRootPart.AssemblyLinearVelocity
	end
end

local function ToggleSprint(_, InputState : Enum.UserInputState, InputObject : InputObject) -- sprint
	if InputState == Enum.UserInputState.Begin then
		Character:SetAttribute("Sprinting", true)
	elseif InputState == Enum.UserInputState.End then
		Character:SetAttribute("Sprinting", nil)
	end
end

-- input detection
CAS:BindAction("Sprint", ToggleSprint, true, Enum.KeyCode.ButtonL3) -- bind sprint for console
CAS:BindAction("Dash", Dash, true, Enum.KeyCode.Q, Enum.KeyCode.ButtonL1) -- bind dash for pc, console

-- DoubleJump and Sprint cannot be bound using CAS because it uses the spacebar, so it will be connected here
UIS.InputBegan:Connect(function(Input : InputObject, Typing : boolean)
	if Input.KeyCode == Enum.KeyCode.W then -- sprint for pc
		if Typing then return end
		-- logic is explained in post
		if CanSprint == false then
			CanSprint = true
			task.spawn(function()
				task.wait(DoubleTapTimeout)
				if CanSprint == true then CanSprint = false end
			end)
		else
			Character:SetAttribute("Sprinting", true)
			CanSprint = false
		end
	elseif Input.KeyCode == Enum.KeyCode.Space or Input.KeyCode == Enum.KeyCode.ButtonA then -- double jump fpr pc, console
		if Typing then return end
		DoubleJump(nil, Input.UserInputState)
	end
end)
UIS.InputEnded:Connect(function(Input : InputObject, Typing : boolean)
	if Input.KeyCode == Enum.KeyCode.W then -- sprint for PC
		if Typing then return end
		Character:SetAttribute("Sprinting", nil)
	end
end)

-- double-jump -- aforementioned ghetto workaround for TouchGui (mobile compatibility)
-- for some reason .Activated and .Mouse1ButtonClick will fire when the input is RELEASED (LOL)
-- accounts for mobile
if UIS.TouchEnabled then
	local TouchGui : ScreenGui = PlayerGui:FindFirstChild("TouchGui") or PlayerGui:WaitForChild("TouchGui") -- get mobile TouchGUI
	local JumpButton : ImageButton = TouchGui.TouchControlFrame:WaitForChild("JumpButton") -- get mobile jump button
	JumpButton.MouseButton1Down:Connect(function() -- .Mouse1ButtonDown appears to be the only fix
		DoubleJump(nil, Enum.UserInputState.Begin) -- call DoubleJump function with necessary arguments
	end)
end

-- .changed connections

Character.AttributeChanged:Connect(function(Attribute : string)
	if Attribute ~= "Sprinting" then return end
	local Value = Character:GetAttribute("Sprinting")
	print(("sprint %s"):format(Value == true and "start" or "end"))

	if Value == true then -- is now sprinting
		Humanoid.WalkSpeed = SprintSpeed
	else -- no longer sprinting
		Humanoid.WalkSpeed = BaseSpeed
	end
end)

Humanoid.StateChanged:Connect(function(_, NewState : Enum.HumanoidStateType)
	if NewState == Enum.HumanoidStateType.Landed then
		print("landed, resetting double jump, dash")
		DoubleJumped = false
		DashReset = true
	end
end)

4. Step-by-step

Variable definitions

Define what you’re going to use later on
Make sure variable names are as simple as driving a John Deere model X9 along a busy stretch of the californian interstate.

-- LocalScript
assert(script.ClassName == "LocalScript", "script is not a LocalScript")

-- services
local UIS					= game:GetService("UserInputService")
local CAS					= game:GetService("ContextActionService")

local MoverUtil				= require(script.Parent:WaitForChild("MoverUtil"))

-- variables, instances
local Player				= game:GetService("Players").LocalPlayer
local PlayerGui				= Player:FindFirstChild("PlayerGui") or Player:WaitForChild("PlayerGui")
local Character				= Player.Character or Player.CharacterAdded:Wait()
local RootPart : BasePart	= Character:WaitForChild("HumanoidRootPart")
local Humanoid : Humanoid	= Character:WaitForChild("Humanoid")

local Camera				= workspace.CurrentCamera

-- settings
local BaseSpeed				= game:GetService("StarterPlayer").CharacterWalkSpeed -- variable, default value is 16
local SprintSpeed			= 32		-- variable
local DoubleJumpPower		= 80		-- variable
local DashVelocity			= 80		-- variable
local DashCooldown			= 1			-- variable

local DoubleTapTimeout		= 0.2		-- grace period to trigger double-tap

-- booleans
local CanSprint				= false		-- can client trigger the sprint double-tap?
local DoubleJumped			= false		-- did client already use their double-jump?
local DashReset				= true		-- did client reset their air-dash?

local LastDash				= 0			-- time of last successful dash

Function definitions

Break down what you want to do into simple functions to be used later

local function Dash(_, InputState : Enum.UserInputState, InputObject : InputObject)

end

local function DoubleJump(_, InputState : Enum.UserInputState, InputObject : InputObject)

end

local function ToggleSprint(_, InputState : Enum.UserInputState, InputObject : InputObject)

end

Dash function

local function Dash(_, InputState : Enum.UserInputState, InputObject : InputObject) -- dash
	if InputState == Enum.UserInputState.Begin then
		-- cooldown logic
		local TimeOfRequest = tick()
		local Delta = TimeOfRequest - LastDash
		if Delta < DashCooldown then return end

		-- can only-dash-once-in-air logic
		if Humanoid.FloorMaterial == Enum.Material.Air then
			if not DashReset then return end
			DashReset = false
		end

		-- now that all the checks are passed, actually do the dash
		print("dash")
		local Direction = Humanoid.MoveDirection -- default .movedirection
		if Direction.Magnitude == 0 then -- if the player is not moving, fallback on the direction their character is facing
			Direction = RootPart.CFrame.LookVector
		end

		local RelativeDirection = MoverUtil:GetRelativeDirection(Direction, Camera.CFrame) -- moverutil function
		MoverUtil:ApplyMover({ -- moverutil function; apply mover to HumanoidRootPart
			BasePart = RootPart; -- target of function
			Lifetime = 0.12; -- duration of bodymover
			Type = "BodyVelocity"; -- will use Instance.new("BodyVelocity") to act upon the HumanoidRootPart
			Properties = { -- properties of BodyVelocity
				Velocity = RelativeDirection * DashVelocity; -- v = direction * speed
				MaxForce = Vector3.new(30000, 0, 30000) -- y-component is 0 so they cannot dash up/down
			}
		})
	end
end

Double-jump function

local function DoubleJump(_, InputState : Enum.UserInputState, InputObject : InputObject) -- double jump
	if InputState == Enum.UserInputState.Begin then
		if Humanoid.FloorMaterial ~= Enum.Material.Air then return end -- cannot double-jump when on ground
		if DoubleJumped then return end -- cannot double jump more than once
		DoubleJumped = true -- set boolean to true, indicating that the player just triggered the double-jump
		print("double jump")
		RootPart.AssemblyLinearVelocity *= Vector3.new(1, 0, 1) -- neutralize y-component of HumanoidRootPart.AssemblyLinearVelocity
		RootPart.AssemblyLinearVelocity += Vector3.new(0, DoubleJumpPower, 0) -- add velocity to HumanoidRootPart.AssemblyLinearVelocity
	end
end

Sprint function

local function ToggleSprint(_, InputState : Enum.UserInputState, InputObject : InputObject) -- sprint
	if InputState == Enum.UserInputState.Begin then
		Character:SetAttribute("Sprinting", true)
	elseif InputState == Enum.UserInputState.End then
		Character:SetAttribute("Sprinting", nil)
	end
end

(An aside: Detecting double-taps)

In essence, it’s a debounce with extra steps
Logic:

if CanTrigger == false then
	CanTrigger = true
	task.spawn(function()
		task.wait(DoubleTapTimeout : number)
		if CanTrigger == true then CanTrigger = false end
	end)
else
	-- ACTION FUNCTION HERE (CLIENT TRIGGERED DOUBLE TAP EVENT)
	CanTrigger = false
end

Input detection, logic

In my experience with writing movement systems, I’ve only really needed detection for taps and double-taps. The latter is typically used for sprinting and dashing

CAS:BindAction("Sprint", ToggleSprint, true, Enum.KeyCode.ButtonL3) -- bind sprint for console
CAS:BindAction("Dash", Dash, true, Enum.KeyCode.Q, Enum.KeyCode.ButtonL1) -- bind dash for pc, console

-- DoubleJump and Sprint cannot be bound using CAS because it uses the spacebar, so it will be connected here
UIS.InputBegan:Connect(function(Input : InputObject, Typing : boolean)
	if Input.KeyCode == Enum.KeyCode.W then -- sprint for pc
		if Typing then return end
		-- logic is explained in post
		if CanSprint == false then
			CanSprint = true
			task.spawn(function()
				task.wait(DoubleTapTimeout)
				if CanSprint == true then CanSprint = false end
			end)
		else
			Character:SetAttribute("Sprinting", true)
			CanSprint = false
		end
	elseif Input.KeyCode == Enum.KeyCode.Space or Input.KeyCode == Enum.KeyCode.ButtonA then -- double jump fpr pc, console
		if Typing then return end
		DoubleJump(nil, Input.UserInputState)
	end
end)
UIS.InputEnded:Connect(function(Input : InputObject, Typing : boolean)
	if Input.KeyCode == Enum.KeyCode.W then -- sprint for PC
		if Typing then return end
		Character:SetAttribute("Sprinting", nil)
	end
end)

.Changed connections

Detecting .StateChanged and .AttributeChanged to assist with dash and double-jump logic

Character.AttributeChanged:Connect(function(Attribute : string)
	if Attribute ~= "Sprinting" then return end
	local Value = Character:GetAttribute("Sprinting")
	print(("sprint %s"):format(Value == true and "start" or "end"))

	if Value == true then -- is now sprinting
		Humanoid.WalkSpeed = SprintSpeed
	else -- no longer sprinting
		Humanoid.WalkSpeed = BaseSpeed
	end
end)

Humanoid.StateChanged:Connect(function(_, NewState : Enum.HumanoidStateType)
	if NewState == Enum.HumanoidStateType.Landed then
		print("landed, resetting double jump, dash")
		DoubleJumped = false
		DashReset = true
	end
end)

now your players can go zoom-zoom :D!

35 Likes

cute thing bro very neat good yes

1 Like

Seems like a very good system.

Why is this called “zoom-zoom”? Based on that, I assumed this would modify a Player’s FoV based off these different actions lol

3 Likes