[!CAUTION]
๐จ๐จ TACT IS UNDER DEVELOPMENT!! ๐จ๐จ
everything is half-broken right now.. just gimmie a minute to finish coding this, will ya?
๐ฎ @benev/tact
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
๐ 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 components.
๐น 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({
portCount: 4,
kv: tact.localStorageKv(),
bindings: {
walking: {forward: "KeyW", jump: "Space"},
gunning: {
shoot: ["or", "pointer.button.left", "gamepad.trigger.right"],
},
},
})
๐น plug devices into the hub
๐น do your gameplay
- poll the deck, interrogate actions
myGameLoop(() => {
const [p1, p2, p3, p4] = deck.hub.poll()
p1.actions.walking.forward.pressed
p2.actions.gunning.shoot.value
})
๐น deck ui: the overlay
๐ 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
๐ฎ samples explained
- 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 or gamepad.trigger.right
- a sample has a
value number
0 means "nothing is going on"
1 means "pressed"
- we don't like negative numbers
- values between
0 and 1, like 0.123, are how triggers and thumbsticks express themselves
- sometimes we use numbers greater then
1, like for dots of pointer movement like in pointer.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
- 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
- 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
๐ 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"],
},
})
walking and gunning are modes
forward, jump, and shoot are 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
scale is sensitivity, the value gets multiplied by this
invert will invert a value by subtracting it from 1
clamp clamps the value with a lower and upper bound
range restricts value to the given range, and remaps that range 0 to 1
bottom zeroes the value if it's less than the given bottom value
top clamps the value to an upper bound
timing 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
๐ 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
๐ 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
๐ now we're gaming
๐ tact nubs
mobile ui like virtual thumbsticks and buttons
๐ฑ nubs setup
- register nub components to dom
tact.registerNubs()
- place nub components onto your html page
<nub-stick></nub-stick>
๐ฑ nub stick
building the future of web games