InputController - Use keymaps, binds and actions

InputController is a module designed to make it easier to manage client input. It primarily uses Keymaps, Actions and Binds.

A Keymap is a group of Actions and Binds. They can be turned on/off using at any time and make it much easier to manage input during different game states. An example setup would be having one Keymap for character movement, one for camera movement, one for each tool, and one for each UI panel. Keymaps have a priority value that allows Binds to optionally sink input for other Keymaps below the priority of the Bind.

An Action is a function linked to a Bind, and Binds determine when the Action should be triggered.

An example keymap:

local Keymap = {
    Priority = 1, --Higher priorities are triggered first

    Actions = {
        --A dictionary of actions, read down for more info

        ["ExampleAction"] = function(Bind, InputObject)
            print("ExampleAction triggered")
        end,
    },

    Binds = {
        --An array of binds, read down for more info

        {Action = "ExampleAction", Trigger = Enum.KeyCode.E, TriggerState = "Press"},
    },
}

InputController.RegisterKeymap(Name, Keymap, SetActive)
Registers a Keymap with the InputController. Name should be unique, for example “Tool_Pistol”, “Tool_Rifle”, “UI_Store”, “UI_RobuxStore”, “Game_CharacterMovement”, “Game_CameraMovement”. Third parameter is optional, defaults to false, and if set to true then the Keymap will be instantly active

InputController.SetKeymapActive(Name, IsActive)
if IsActive is true, the keymap is being listened to. If it’s false, the keymap will be ignored.

Demo

local InputController = require(game.ReplicatedStorage.InputController)

local ExampleKeymap = InputController.RegisterKeymap("ExmapleKeymap", {
	Priority = 1, --Higher priority goes first
	
	Actions = {
		["ExampleAction1"] = function(Bind, Input)
			print("ExampleAction1 was triggered by " .. tostring(Bind.Trigger))
		end,
		
		["PressAnyButtonToContinue"] = function(Bind, Input)
			print("Pressed some button on keyboard", Input.KeyCode)
		end,
		
		["MoveCharacter"] = function(Bind, Input)
			Bind.Active = (Input.UserInputState.Name == "Begin" and true or false)
			
			local MoveDirection = Vector3.new()
			
			for _, Bind in pairs(Bind.Keymap.Binds) do
				if (Bind.Action == "MoveCharacter" and Bind.Active) then
					MoveDirection = MoveDirection + Bind.Direction
				end
			end
			
			print("Moving in", MoveDirection.Magnitude > 0 and MoveDirection.Unit or MoveDirection)
		end,
	},
	
	Binds = {
		{
			--Required
			Action = "ExampleAction1", --The name of the action. Must be in the same keymap
			Trigger = Enum.UserInputType.MouseButton1, --What key will trigger the action. Must be `Enum.KeyCode.` or `Enum.UserInputType.`
			TriggerState = "Press", --When should it be triggered. Supports "Down", "Press", "Up" and "Any"
			
			--Optional
			SinkInput = true, --If set to true, keymaps with a lower priority will not be triggered if this bind gets triggered. Defaults to `false`
			IgnoreGameProcessed = true, --If set to true, the action will be triggered regardless if `gameProcessedEvent` is true, see https://developer.roblox.com/en-us/api-reference/event/UserInputService/InputBegan. Defaults to `false`
		
			--Any custom bind-specific data can be added after here. Example use case would be for a character controller where there is a single `MoveCharacter` action, and inside each bind there could be a `Direction` value
			Direction = Vector3.new(1, 0, 0),
		},
		
		{Action = "PressAnyButtonToContinue", Trigger = Enum.UserInputType.Keyboard, TriggerState = "Down"},
		
		{Action = "MoveCharacter", Trigger = Enum.KeyCode.W, TriggerState = "Any", Direction = Vector3.new(0, 0, -1)},
		{Action = "MoveCharacter", Trigger = Enum.KeyCode.A, TriggerState = "Any", Direction = Vector3.new(-1, 0, 0)},
		{Action = "MoveCharacter", Trigger = Enum.KeyCode.S, TriggerState = "Any", Direction = Vector3.new(0, 0, 1)},
		{Action = "MoveCharacter", Trigger = Enum.KeyCode.D, TriggerState = "Any", Direction = Vector3.new(1, 0, 0)},
	},
 }, true)  --If third parameter of `InputController.RegisterKeymap` is true, then they keymap is activated instantly. Otherwise call `InputController.SetKeymapActive("Name", true)`

InputController.rbxmx (5.1 KB)

Lua source
local UserInputService = game:GetService("UserInputService")

local Controller = {
	Keymaps = {},
	
	Cache = {
		Triggers = {},
	},
}

local function mpairs(...)
	--mpairs is used to loop through multiple tables at once
	
	local Tables = {...}
	local TableIndex, Table = next(Tables)
	local Key, Value = nil, nil

	return function()
		if (not Table) then return end
		
		repeat
			Key, Value = next(Table, Key)
			
			if (Key == nil) then
				TableIndex, Table = next(Tables, TableIndex)
			end
		until (Key ~= nil or not Table)
		
		return Key, Value
	end
end

function Controller.Main()
	UserInputService.InputBegan:Connect(Controller.HandleInput)
	UserInputService.InputChanged:Connect(Controller.HandleInput)
	UserInputService.InputEnded:Connect(Controller.HandleInput)
	
	return Controller
end

function Controller.HandleInput(Input, GameProcessed)
	local InputStateValue = Input.UserInputState.Value
	
	local Container = (Controller.Cache.Triggers[Input.KeyCode] or Controller.Cache.Triggers[Input.UserInputType])
	
	local SinkInputAtPriority
	
	if (InputStateValue == 0) then
		for _, Bind in mpairs(Controller.Cache.Triggers[Input.KeyCode], Controller.Cache.Triggers[Input.UserInputType]) do
			if (SinkInputAtPriority and (Bind.Priority or Bind.Keymap.Priority) < SinkInputAtPriority) then break end
			if (not Bind.Keymap.Active) then continue end
			if (GameProcessed and not Bind.IgnoreGameProcessed) then continue end
			
			if (Bind.TriggerState == "Press") then
				Bind._TriggerDown = tick()
			elseif (Bind.TriggerState == "Down" or Bind.TriggerState == "Any") then
				if (Bind.Keymap.Actions[Bind.Action]) then
					coroutine.wrap(Bind.Keymap.Actions[Bind.Action])(Bind, Input)
				end
				
				if (Bind.SinkInput) then 
					SinkInputAtPriority = (Bind.Priority or Bind.Keymap.Priority) 
				end
			end
		end
	elseif (InputStateValue == 2) then
		for _, Bind in mpairs(Controller.Cache.Triggers[Input.KeyCode], Controller.Cache.Triggers[Input.UserInputType]) do
			if (SinkInputAtPriority and (Bind.Priority or Bind.Keymap.Priority) < SinkInputAtPriority) then break end
			if (not Bind.Keymap.Active) then continue end
			if (GameProcessed and not Bind.IgnoreGameProcessed) then continue end
			
			if (Bind.TriggerState == "Press") then
				if (tick() - (Bind._TriggerDown or 0) <= 0.25) then
					if (Bind.Keymap.Actions[Bind.Action]) then
						coroutine.wrap(Bind.Keymap.Actions[Bind.Action])(Bind, Input)
					end
					
					if (Bind.SinkInput) then 
						SinkInputAtPriority = (Bind.Priority or Bind.Keymap.Priority) 
					end
				end	
			elseif (Bind.TriggerState == "Up" or Bind.TriggerState == "Any") then
				if (Bind.Keymap.Actions[Bind.Action]) then
					coroutine.wrap(Bind.Keymap.Actions[Bind.Action])(Bind, Input)
				end
				
				if (Bind.SinkInput) then 
					SinkInputAtPriority = (Bind.Priority or Bind.Keymap.Priority) 
				end
			end
		end
	end
end

function Controller.RegisterKeymap(Name, Keymap, SetActive)
	if (not Name or not Keymap) then return end
	
	Keymap.Active = (SetActive and true or false)
	Keymap.Priority = (tonumber(Keymap.Priority) or 0)

	if (Controller.Keymaps[Name]) then
		warn("InputController: Overwriting keymap '" .. tostring(Name) .. "'")
		
		if (Controller.Keymaps[Name].Binds) then
			for _, Bind in pairs(Controller.Keymaps[Name].Binds) do
				local Index = table.find(Controller.Cache.Triggers[Bind.Trigger], Bind)
				if (Index) then
					table.remove(Controller.Cache.Triggers[Bind.Trigger], Index)
					
					if (#Controller.Cache.Triggers[Bind.Trigger] <= 0) then
						Controller.Cache.Triggers[Bind.Trigger] = nil
					end
				end
			end
		end
	end
	
	Controller.Keymaps[Name] = Keymap
	
	if (Keymap.Binds) then
		for _, Bind in pairs(Keymap.Binds) do
			Bind.Keymap = Keymap
			
			if (not Controller.Cache.Triggers[Bind.Trigger]) then
				Controller.Cache.Triggers[Bind.Trigger] = {}
			end
			
			table.insert(Controller.Cache.Triggers[Bind.Trigger], Bind)
			
			table.sort(Controller.Cache.Triggers[Bind.Trigger], function(Bind0, Bind1)
				return (Bind0.Priority or Bind0.Keymap.Priority) > (Bind1.Priority or Bind1.Keymap.Priority)
			end)
		end
	end
	
	return Keymap
end

function Controller.SetKeymapActive(Name, IsActive)
	if (not Controller.Keymaps[Name]) then
		return warn("InputController: " .. tostring(Name) .. " is not a valid keymap")
	end
	
	Controller.Keymaps[Name].Active = (IsActive and true or false)
end

return Controller.Main()
16 Likes