@benev/tact
v0.2.0-0
Published
keybindings and gamepad support for web games
Maintainers
Readme
🎮 @benev/tact
web game input library, from keypress to couch co-op
npm install @benev/tacttact 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
🍋 tact deck
full setup with ui, batteries included
the deck ties together all the important pieces of tact into a single user experience, complete with ui views.
tact's ui is built with @e280/sly shadow views.
🛹 deck setup
- 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 devices into the hub
- plug a keyboard/mouse player into the hub
deck.hub.plug(new tact.PrimaryDevice()) - automatically detect and plug gamepads
tact.autoGamepads(deck.hub.plug)
🛹 do your gameplay
- 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 })
🛹 deck ui: the overlay
- render the deck overlay with sly
import {dom} from "@e280/sly" dom.render( dom("#game-ui"), tact.DeckOverlay(deck), ) - place the ui on top of your game canvas
<div id="game-ui"></div>
🍋 tact devices
sources of user input "samples"
🎮 polling is good, actually
- 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 🗿
🎮 basically how a device works
- 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()
🎮 samples explained
- a sample is a raw input tuple of type
[code: string, value: number] - a sample has a
codestring- it's either a standard keycode, like
KeyA - or it's something we made up, like
pointer.button.leftorgamepad.trigger.right
- it's either a standard keycode, like
- a sample has a
valuenumber0means "nothing is going on"1means "pressed"- we don't like negative numbers
- values between
0and1, 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
🎮 sample code reference
- KeyboardDevice
- any standard keycode
KeyASpaceDigit2- etc
- any standard keycode
- PointerDevice
- mouse buttons
pointer.button.leftpointer.button.rightpointer.button.middlepointer.button.4pointer.button.5
- mouse wheel
pointer.wheel.uppointer.wheel.downpointer.wheel.leftpointer.wheel.right
- mouse movements
pointer.move.uppointer.move.downpointer.move.leftpointer.move.right
- mouse buttons
- GamepadDevice
- gamepad buttons
gamepad.agamepad.bgamepad.xgamepad.ygamepad.bumper.leftgamepad.bumper.rightgamepad.trigger.leftgamepad.trigger.rightgamepad.alphagamepad.betagamepad.stick.left.clickgamepad.stick.right.clickgamepad.upgamepad.downgamepad.leftgamepad.rightgamepad.gamma
- gamepad sticks
gamepad.stick.left.upgamepad.stick.left.downgamepad.stick.left.leftgamepad.stick.left.rightgamepad.stick.right.upgamepad.stick.right.downgamepad.stick.right.leftgamepad.stick.right.right
- gamepad buttons
🍋 tact bindings
keybindings! they describe how actions interpret samples
🧩 bindings example
- 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"], }, })walkingandgunningare modesforward,jump, andshootare actions- note that whole modes can be enabled or disabled during gameplay
🧩 bindings are a lispy domain-specific-language
- 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
🧩 bindings atom reference
- 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
scaleis sensitivity, the value gets multiplied by thisinvertwill invert a value by subtracting it from 1clampclamps the value with a lower and upper boundrangerestricts value to the given range, and remaps that range 0 to 1bottomzeroes the value if it's less than the given bottom valuetopclamps the value to an upper boundtiminglets 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,shiftare available
🍋 tact port
polling gives you "actions"
a port represents a single playable port, and you poll it each frame to resolve actions for you to read.
🔌 port setup
- 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) })
🔌 interrogating actions
- poll the port every frame
port.poll() - now you can inspect the
actionsport.actions.walking.forward.value // 1walkingis amodeforwardis anactionaction.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
🍋 tact hub
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.
🛞 create a hub with 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 🤯
🛞 plug in some devices
- 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
GroupDeviceto 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)
🛞 now we're gaming
- 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
🍋 tact nubs
mobile ui like virtual thumbsticks and buttons
📱 nubs setup
- render nub views with sly
import {dom} from "@e280/sly" const stick = new tact.StickDevice() dom.render( dom("#controls"), tact.NubStick(stick), )
📱 nub stick
- place a mount point onto your page
<div id="controls"></div> - make the stick device yourself, then plug it into your hub or whatever
const stick = new tact.StickDevice() dom.render( dom("#controls"), tact.NubStick(stick), ) deck.hub.plug(stick)
🍋 tact is by https://benevolent.games/
building the future of web games
