V0.1.1: iasy: schema-based InputActionSystem wrapper

iasy: schema-based InputActionSystem.

Usage:

-- in the module iasy.luau
-- you can just set this to {[string]: Enum.KeyCode} if you think your binding_names will be inconsistent
-- otherwise, hint each known binding_name as "Enum.KeyCode?"
export type binding_schema = {keyboard: Enum.KeyCode?, gamepad: Enum.KeyCode?, mobile: Enum.KeyCode?}

-- can be set to string for the same reasons as above; using new type solver, you could do keyof<binding_schema>
export type binding_name = "keyboard" | "gamepad" | "mobile"

local schema = {
	-- contexts - must have at least 1 action in them
	core = {
		-- actions - you can leave bindings entirely empty, or just specific bindings, or add new ones
		-- a mobile binding is automatically created with Enum.KeyCode.Unknown
		sprint = _create_action {
			type = Enum.InputActionType.Bool,
			bindings = {keyboard = Enum.KeyCode.LeftShift, gamepad = Enum.KeyCode.ButtonR2}
		},
		mouse_trail = _create_action {
			type =  Enum.InputActionType.ViewportPosition,
			bindings = {}
		}
	}
}

having set up your schema, you can use it as follows:

local iasy = require(path.to.iasy)

local ui_button = path.to.ui.button

local sprint_action = iasy.schema.core.sprint
sprint_action.Pressed:Connect(function() print("client is sprinting!") end)

iasy.set_action_ui_button(sprint_action, ui_button, false)

task.wait(5); print("disabled keyboard sprint")
local sprint_bindings = iasy.get_binding_schema_for_action(sprint_action) -- NB: this is a fresh table and is not deep-frozen like schema is.
sprint_bindings.keyboard = nil -- remove keyboard binding from schema
iasy.set_binding_schema_for_action(sprint_action, sprint_bindings) -- pass that here in order to update the actual InputBindings

task.wait(5); print("switched sprint to B")
iasy.set_binding_for_action(sprint_action, "keyboard", Enum.KeyCode.B)

task.wait(5); print("waiting for user to rebind") -- rebind appropriate platform binding
ui_button.Text = "Waiting for input..."

local previous_input_type = iasy.get_last_binding_type() 
local result = iasy.wait_for_rebind(iasy.schema.core.sprint) -- only use this if you are using the default binding_name type

-- a use case for actually processing result would be to set custom console icons for keybind hints, for example.
if not result then 
	print("client did not send any valid input for >5s! did not set any bindings")
	local previous_keycode = iasy.get_keycode_for_binding(sprint_action, previous_input_type)
	ui_button.Text = previous_input_type .. " " .. if previous_keycode then previous_keycode.Name else "none!"
else 
	print("successfully set ".. result.binding_name .." to ".. result.keycode.Name)
	if result.binding_name == "keyboard" then ui_button.Text = "Keyboard " .. result.keycode.Name
	else ui_button.Text = "Console " .. result.keycode.Name	
	end
end

task.wait(5); print("disabled core context")
iasy.get_context(iasy.schema.core).Enabled = false

Get the luau file and/or rbxm here:

v0.1.0

+ added binding_name type (you can set this to string but it is not recommended as you will lose intellisense)

some functions expect the default binding_schema and binding_name config; they have been marked below with the [STANDARD] tag

+ added the following functions:

  • get_last_binding_type() -> binding_name [STANDARD]
    • uses user_input_service to get the last user_input_type to determine the appropriate binding_name
  • wait_for_rebind(action: InputAction) -> {binding_name: binding_name, keycode: Enum.KeyCode} [YIELDS], [STANDARD]
  • get_keycode_for_binding(action: InputAction, binding_name: binding_name) -> Enum.KeyCode

v0.1.1

fixed minor bugs (disabled automatic creation of mobile binding on non-boolean inputactions) and rephrased some api descriptions.

iasy.rbxm (5.5 KB)
or use it here:

--!strict
--!native

--[[
	iasy - a thin, performant IAS wrapper with good intellisense and some utilities.

	API:
	> local schema = iasy.schema 
    Gets the schema. It is frozen (immutable)
	
	> local core = iasy.get_context(schema.core)
    Gets an InputContext generated from the schema.

	> local sprint_action = core.sprint
    Gets the InputAction generated from the the schema

	> local sprint_bindings = iasy.get_binding_schema_for_action(sprint_action)
    Get a fresh (unfrozen) table of bindings for an action. Computed via :GetChildren().
	NB: Does not return InputBindings, only {[binding_name]: Enum.KeyCode} (same as schema). Call sprint_action:GetChildren()
	
    You can modify this returned schema (with intellisense on binding_name) and pass it into the below, for example.

	> iasy.set_binding_schema_for_action(sprint_action, sprint_bindings)
    Clear all previous bindings (except the internal mobile binding) and add these new bindings
	
    > iasy.set_binding_for_action(sprint_action, "keyboard", Enum.KeyCode.B)
	Returns the InputBinding for further property changes if necessary

    > iasy.get_keycode_for_binding(sprint_action, "keyboard") --> Enum.KeyCode.B
    Returns the Enum.KeyCode for a specific binding_name on an action.

    > iasy.get_last_binding_type()
    Returns the last binding_name input type (i.e.: gamepad, keyboard, mobile). Uses UserInputType under the hood.
    Should ONLY be used with default binding_name config.
	
    > iasy.wait_for_rebind(iasy.schema.core.sprint)
    YIELDS for the next valid input press. Should ONLY be used with default binding_name config.
    Returns {binding_name: binding_name, keycode: Enum.KeyCode}. Times out after 5 seconds, returning nil
	NB: this returns which binding was changed (ex: gamepad binding changed to ButtonR2, for example)
	In most cases, you shouldn't need to use the return, and only need to check if it is non-nil, unless you want custom IAS hints.

	> iasy.set_action_ui_button(sprint_action, ui_button, true)
	Sets InputAction.UIButton. Last parameter is optional and subscribes the button for auto-hiding/showing (for touch-screens)
	Will fail if you try and set this on an InputAction whose Type is not Enum.InputActionType.Bool
--]]


-- you can just set this to {[string]: Enum.KeyCode} if you think your binding_names will be inconsistent
-- otherwise, hint each known binding_name as "Enum.KeyCode?"
export type binding_schema = {keyboard: Enum.KeyCode?, gamepad: Enum.KeyCode?, mobile: Enum.KeyCode?}

-- can be set to string for the same reasons as above; using new type solver, you could do keyof<binding_schema>
export type binding_name = "keyboard" | "gamepad" | "mobile" 

export type action_schema = {type: Enum.InputActionType, bindings: binding_schema, enabled: boolean?}; export type context_schema = {[string]: InputAction}; export type input_schema = {[string]: context_schema}
local function _create_binding(name: string, keycode: Enum.KeyCode?): InputBinding local b = Instance.new("InputBinding"); b.Name = name; b.KeyCode = keycode or Enum.KeyCode.Unknown; return b end
local function _create_action(schema: action_schema): InputAction local a = Instance.new("InputAction"); a.Type = schema.type; if schema.enabled == nil then a.Enabled = true else a.Enabled = schema.enabled end; local has_mobile_binding = false; for name: any, keycode: any in schema.bindings do if name == "mobile" then has_mobile_binding = true end; _create_binding(name, keycode).Parent=a end; if not has_mobile_binding and schema.type == Enum.InputActionType.Bool then _create_binding("mobile", Enum.KeyCode.Unknown).Parent=a end; return a end

local schema = {
	-- contexts - must have at least 1 action in them
	core = {
		-- actions - you can leave bindings entirely empty, or just specific bindings, or add new ones
		-- a mobile binding is automatically created with Enum.KeyCode.Unknown if type = Enum.InputActionType.Bool
		shift_lock = _create_action {
			type = Enum.InputActionType.Bool,
			bindings = {gamepad = Enum.KeyCode.ButtonR2}
		},
	}
}


local user_input_service = game:GetService("UserInputService")
local players = game:GetService("Players")
local player = players.LocalPlayer
local player_gui = player.PlayerGui

local contexts_folder = Instance.new("Folder"); contexts_folder.Name = "contexts"; contexts_folder.Parent = player_gui

local subscribed_buttons: {[GuiButton]: InputAction} = {}
local last_input_type: Enum.UserInputType

local function _create_context(name: string)

	local new_context = Instance.new("InputContext")
	new_context.Name = name
	new_context.Parent = contexts_folder

	return new_context

end 

for context_name, context_schema in schema :: input_schema do
	local new_context = _create_context(context_name)

	local non_empty_context = false
	for action_name, action in context_schema do
		non_empty_context = true
		action.Name = action_name
		action.Parent = new_context
	end
	table.freeze(context_schema)

	assert(non_empty_context, "cannot have empty context in schema: " .. context_name) -- contexts are indexed by action.Parent via next()
end

table.freeze(schema)


local function set_action_ui_button(action: InputAction, button: GuiButton, subscribed: boolean?)
	local binding = action:FindFirstChild("mobile") :: InputBinding
	assert(binding, "unable to find mobile binding for action: ".. action.Name)
	if binding.UIButton then subscribed_buttons[binding.UIButton] = nil end -- for a pre-existing uibutton that was subscribed
	binding.UIButton = button
	if subscribed then subscribed_buttons[button] = action :: InputAction else subscribed_buttons[button] = nil end
end

local function set_binding_for_action(action: InputAction, binding_name: binding_name, keycode: Enum.KeyCode)
	local binding = action:FindFirstChild(binding_name) :: InputBinding
	if not binding then binding = _create_binding(binding_name, keycode); (binding::any).Parent = action else binding.KeyCode = keycode end
	return binding -- to allow set properties
end

local function get_binding_schema_for_action(action: InputAction): binding_schema
	local t = {}
	for _, v in action:GetChildren() do if v:IsA("InputBinding") then t[(v.Name) :: any] = v.KeyCode end end
	return t
end

local function get_keycode_for_binding(action: InputAction, binding_name: string): Enum.KeyCode?
	local input_binding = action:FindFirstChild(binding_name) :: InputBinding
	return if input_binding then input_binding.KeyCode else nil
end

local function set_binding_schema_for_action(action: InputAction, bindings: binding_schema)
	for _, v in action:GetChildren() do if v.Name == "mobile" then continue end; v:Destroy() end -- clear old bindings
	for binding_name, keycode in bindings do
		set_binding_for_action(action, binding_name :: binding_name, keycode :: Enum.KeyCode)
	end
end


local function get_context(schema: context_schema): InputContext
	local _, any_action = next(schema)
	return any_action.Parent :: InputContext
end


local function on_last_input_type_changed(new_input_type: Enum.UserInputType)
	if new_input_type == Enum.UserInputType.Focus then return end
	for button, action in subscribed_buttons do 
		button.Visible = 
			new_input_type == Enum.UserInputType.Touch and 
			action.Enabled and 
			(action.Parent :: InputContext).Enabled 
	end
	last_input_type = new_input_type
end
on_last_input_type_changed(user_input_service:GetLastInputType())
user_input_service.LastInputTypeChanged:Connect(on_last_input_type_changed)

local function get_last_binding_type(user_input_type: Enum.UserInputType?): binding_name
	user_input_type = user_input_type or last_input_type
	local value = last_input_type.Value
	if (value >= 0 and value < 5) or value == 8 then return "keyboard"
	elseif value > 11 and value < 20 then return "gamepad"
	end
	return "mobile" -- everything else is mobile lol
end

-- you should only use this if your binding_name is set to {keyboard: Enum.KeyCode?, gamepad: Enum.KeyCode?, ...}
-- nil return means that the client timed out and did not enter an input (>5s)
local function wait_for_rebind(action: InputAction): {binding_name: binding_name, keycode: Enum.KeyCode}?
	local user_input_service = game:GetService("UserInputService")

	local result_binding_name: binding_name = nil
	local result_keycode: Enum.KeyCode = nil

	local connection
	connection = user_input_service.InputBegan:Connect(function(input_object, game_processed_event)
		-- 2 upvalues
		if game_processed_event then return end

		local user_input_type = input_object.UserInputType
		local last_binding_type = get_last_binding_type(user_input_type)
		if last_binding_type == "keyboard" or last_binding_type == "gamepad" then
			result_binding_name = last_binding_type
			result_keycode = input_object.KeyCode :: Enum.KeyCode
			if connection and connection.Connected then connection:Disconnect(); connection = nil :: any end
		end
	end)

	local elapsed = 0
	while not result_binding_name and elapsed < 5 do elapsed += task.wait() end

	if connection and connection.Connected then
		connection:Disconnect()
	end

	if result_binding_name == "gamepad" or result_binding_name == "keyboard" then
		set_binding_for_action(action, result_binding_name, result_keycode)
		return {binding_name = result_binding_name, keycode = result_keycode}
	end

	return nil
end


return {
	schema = schema,
	set_action_ui_button = set_action_ui_button,

	set_binding_for_action = set_binding_for_action,
	set_binding_schema_for_action = set_binding_schema_for_action,
	get_binding_schema_for_action = get_binding_schema_for_action,
	get_keycode_for_binding = get_keycode_for_binding,

	get_last_binding_type = get_last_binding_type,
	wait_for_rebind = wait_for_rebind,

	get_context = get_context,

	-- these are supposed to be private but might as well return them for easier mutability of the IAS
	_create_binding = _create_binding,
	_create_action = _create_action,
	_create_context = _create_context
}