Caution
everything is half-broken right now.. just gimmie a minute to finish coding this, will ya?
web game input library, from keypress to couch co-op
npm install @benev/tact
tact is a toolkit for handling user inputs on the web.
it's good at user-customizable keybindings, multiple gamepad support, and mobile ui.
- ๐น #deck full setup with localstorage persistence
- ๐ฎ #devices produce user input samples
- ๐งฉ #bindings describe how actions interpret samples
- ๐ #port updates actions by interpreting samples
- ๐ #hub plugs devices into ports (multi-gamepad couch co-op!)
- ๐ฑ #nubs is mobile ui virtual gamepad stuff
full setup with ui, batteries included
the deck ties together all the important pieces of tact into a single user experience, complete with ui components.
- import stuff from tact
import * as tact from "@benev/tact"
- setup your deck, and your game's bindings
const deck = await tact.Deck.load({ // how many player ports are possible? 1 is fine.. portCount: 4, // where to store the user-customized bindings kv: tact.localStorageKv(), // default archetypal bindings for your game bindings: { walking: {forward: "KeyW", jump: "Space"}, gunning: { shoot: ["or", "pointer.button.left", "gamepad.trigger.right"], }, }, })
- plug a keyboard/mouse player into the hub
deck.hub.plug(new tact.PrimaryDevice())
- automatically detect and plug gamepads
tact.autoGamepads(deck.hub.plug)
- poll the deck, interrogate actions
myGameLoop(() => { // do your polling const [p1, p2, p3, p4] = deck.hub.poll() // check if the first player is pressing "forward" action p1.actions.walking.forward.pressed // true // check how hard the second player is pulling that trigger p2.actions.gunning.shoot.value // 0.123 })
- register the deck's web components to the dom
deck.registerComponents()
- place the ui on top of your game canvas
<deck-overlay></deck-overlay>
sources of user input "samples"
- tact operates on the basis of polling
- "but polling is bad" says you โ but no โ you're wrong โ polling is unironically based, and you should do it
- the gift of polling is total control over when inputs are processed, this is good for games
- i will elaborate no further ๐ฟ
- make a device
const keyboard = new tact.KeyboardDevice()
- reading samples looks like this
for (const sample of keyboard.samples()) console.log(sample) // ["KeyA", 1]
- some devices have disposers to call when you're done with them
keyboard.dispose()
- a sample is a raw input tuple of type
[code: string, value: number]
- a sample has a
code
string- it's either a standard keycode, like
KeyA
- or it's something we made up, like
pointer.button.left
orgamepad.trigger.right
- it's either a standard keycode, like
- a sample has a
value
number0
means "nothing is going on"1
means "pressed"- we don't like negative numbers
- values between
0
and1
, like0.123
, are how triggers and thumbsticks express themselves - sometimes we use numbers greater then
1
, like for dots of pointer movement like inpointer.move.up
- don't worry about sensitivity, deadzones, values like
0.00001
โ actions will account for all that using bindings later on
- KeyboardDevice
- any standard keycode
KeyA
Space
Digit2
- etc
- any standard keycode
- PointerDevice
- mouse buttons
pointer.button.left
pointer.button.right
pointer.button.middle
pointer.button.4
pointer.button.5
- mouse wheel
pointer.wheel.up
pointer.wheel.down
pointer.wheel.left
pointer.wheel.right
- mouse movements
pointer.move.up
pointer.move.down
pointer.move.left
pointer.move.right
- mouse buttons
- GamepadDevice
- gamepad buttons
gamepad.a
gamepad.b
gamepad.x
gamepad.y
gamepad.bumper.left
gamepad.bumper.right
gamepad.trigger.left
gamepad.trigger.right
gamepad.alpha
gamepad.beta
gamepad.stick.left.click
gamepad.stick.right.click
gamepad.up
gamepad.down
gamepad.left
gamepad.right
gamepad.gamma
- gamepad sticks
gamepad.stick.left.up
gamepad.stick.left.down
gamepad.stick.left.left
gamepad.stick.left.right
gamepad.stick.right.up
gamepad.stick.right.down
gamepad.stick.right.left
gamepad.stick.right.right
- gamepad buttons
keybindings! they describe how actions interpret samples
- let's start with a small example:
const bindings = tact.asBindings({ walking: {forward: "KeyW", jump: "Space"}, gunning: { shoot: ["or", "pointer.button.left", "gamepad.trigger.right"], }, })
walking
andgunning
are modesforward
,jump
, andshoot
are actions- note that whole modes can be enabled or disabled during gameplay
- you can do complex stuff
["or", "KeyQ", ["and", "KeyA", "KeyD", ["not", "KeyS"], ], ]
- press Q, or
- press A + D, while not pressing S
- you can get really weird
["cond", ["code", "gamepad.trigger.right", {range: [0, 0.5], timing: ["tap"]}], ["and", "gamepad.bumper.left", ["not", "gamepad.trigger.left"]], ]
- hold LB and tap RT halfway while not holding LT
- string โ strings are interpreted as "code" atoms with default settings
- "code" โ allows you to customize the settings
["code", "KeyA", { scale: 1, invert: false, timing: ["direct"], }]
- defaults shown
scale
is sensitivity, the value gets multiplied by thisinvert
will invert a value by subtracting it from 1clamp
clamps the value with a lower and upper boundrange
restricts value to the given range, and remaps that range 0 to 1bottom
zeroes the value if it's less than the given bottom valuetop
clamps the value to an upper boundtiming
lets you specify special timing considerations["direct"]
ignores timing considerations["tap", 250]
only fires for taps under 250ms["hold", 250]
only fires for holds over 250ms
- "or" โ resolves to the maximum value
["or", "KeyA", "KeyB", "KeyC"]
- "and" โ resolves to the minimum value
["and", "KeyA", "KeyB", "KeyC"]
- "not" โ resolves to the opposite effect
["not", "KeyA"]
- "cond" โ conditional situation (example for modifiers shown)
["cond", "KeyA", ["and", ["or", "ControlLeft", "ControlRight"], ["not", ["or", "AltLeft", "AltRight"]], ["not", ["or", "MetaLeft", "MetaRight"]], ["not", ["or", "ShiftLeft", "ShiftRight"]], ]]
- KeyA is the value that gets used
- but only if the following condition passes
- "mods" โ macro for modifiers
["mods", "KeyA", {ctrl: true}]
- equivalent to the "cond" example above
ctrl
,alt
,meta
,shift
are available
polling gives you "actions"
a port represents a single playable port, and you poll it each frame to resolve actions for you to read.
- make a port
const port = new tact.Port(bindings)
- attach some devices to the port
port.devices .add(new tact.KeyboardDevice()) .add(new tact.PointerDevice()) .add(new tact.VpadDevice())
- you can add/delete devices from the set any time
- manipulate modes
port.modes.clear() port.modes.add("walking")
- actions only happen for enabled modes
- you can toggle modes on and off by adding/deleting them from the modes set
- you can update the bindings any time
port.bindings = freshBindings
- wire up gamepad auto connect/disconnect
tact.autoGamepads(device => { port.devices.add(device) return () => port.devices.delete(device) })
- poll the port every frame
port.poll()
- now you can inspect the
actions
port.actions.walking.forward.value // 1
walking
is amode
forward
is anaction
action.value
โ current valueaction.previous
โ last frame's valueaction.changed
โ true if value and previous are differentaction.pressed
โ true if the value > 0action.down
โ true for one frame when the key goes from up to downaction.up
โ true for one frame when the key goes from down to up
multiple gamepads! couch co-op is so back
you know the way old-timey game consoles had four controller ports on the front?
the hub embraces that analogy, helping you coordinate the plugging and unplugging of virtual controller devices into its virtual ports.
- make hub with multiple ports at the ready
const hub = new tact.Hub([ new tact.Port(bindings), new tact.Port(bindings), new tact.Port(bindings), new tact.Port(bindings), ])
- yes that's right โ each player port gets its own bindings ๐คฏ
- let's plug in the keyboard/mouse player
hub.plug(new tact.PrimaryDevice())
- the hub requires a single device to represent a player, so you can use a
GroupDevice
to combine multple devices into one
- the hub requires a single device to represent a player, so you can use a
- wire up gamepad auto connect/disconnect
tact.autoGamepads(hub.plug)
- do your polling, interrogate those actions
const [p1, p2, p3, p4] = hub.poll() p1.actions.walking.jump.value // 1 p2.actions.walking.jump.value // 0
mobile ui like virtual thumbsticks and buttons
- register nub components to dom
tact.registerNubs()
- place nub components onto your html page
<nub-stick></nub-stick>
- place a nub-stick onto your page
<nub-stick></nub-stick>
- get the stick device, plug it into your hub or whatever
const nubStick = document.queryElement<tact.NubStick>("nub-stick")! deck.hub.plug(nubStick.device)
๐ tact is by https://benevolent.games/
building the future of web games