Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
59f1e28
feat(#1): controls mapper tool with remap, skins, presets and live test
cnotv Jul 1, 2026
43e8afe
feat(#1): move mapper to Tests, add tabs, reset, scroll fix and IO di…
cnotv Jul 1, 2026
7cc6f18
feat(#1): replace preset panel with an Add dropdown (import/download/…
cnotv Jul 1, 2026
8ca8f57
feat(#1): restructure mapper into Bindings/Test tabs for mobile
cnotv Jul 1, 2026
a63eaf1
fix(#1): use a non-empty sentinel for the faux-pad None option
cnotv Jul 1, 2026
7a4ebbb
fix(#1): stop tabs from boxing content into scroll areas; add reset icon
cnotv Jul 1, 2026
316f5b0
fix(#1): hide inactive tab panel and use readable text color
cnotv Jul 1, 2026
4f5cb95
feat(#1): drive the faux-pad preview with the gamepad stick
cnotv Jul 1, 2026
ff1cbc0
feat(#1): auto-select the binding device tab from the last input
cnotv Jul 1, 2026
7bb3832
fix(#1): make the faux-pad preview reactive to mouse and gamepad
cnotv Jul 1, 2026
4772f6c
feat(#1): convert controls mapper to LobbyUI with full joypad navigation
cnotv Jul 1, 2026
78d606b
feat(#1): refine LobbyUI mapper — flat buttons, icons, custom font, l…
cnotv Jul 1, 2026
c74350c
feat(#1): presets tab, header actions, more spacing, signal icon
cnotv Jul 1, 2026
92f8855
feat(#1): use waves icon for the listen button
cnotv Jul 1, 2026
cb3086c
feat(#1): icon polish — pencil/undo, drop-shadows, alignment, spacing
cnotv Jul 2, 2026
0c65943
feat(#1): highlight the selected toggle option with the focus color text
cnotv Jul 2, 2026
2055f82
feat(#1): focus border for joypad nav + arrow stepper for faux-pad
cnotv Jul 2, 2026
9345b25
feat(#1): drop the yellow text on the selected toggle option (fill only)
cnotv Jul 2, 2026
3bf3cea
fix(#1): center LobbyUI button/toggle text with line-height 1
cnotv Jul 2, 2026
ca95c5e
feat(#1): round LobbyUIIconButton, arrow config field, optical centering
cnotv Jul 2, 2026
388db52
feat(#1): align bindings into a shared grid; decorative config-field …
cnotv Jul 2, 2026
7dc35dd
style(#1): flip lui-toggle__btn vertical padding to nudge text up
cnotv Jul 2, 2026
28c3173
fix(#1): gamepad binding and device selection
cnotv Jul 2, 2026
7ad1c0e
feat(#1): bind by editing the value directly; X resets
cnotv Jul 2, 2026
0fc7d52
feat(#1): hint hide on blur, pause nav while binding, up/down edit, f…
cnotv Jul 2, 2026
33780cf
fix(#1): bind gamepad confirm button and stick axes
cnotv Jul 2, 2026
5bdd984
feat(#1): faux-pad button binding for non-directional actions; keep t…
cnotv Jul 2, 2026
2913341
feat(#1): interrupt on wrong device, no test recording while binding,…
cnotv Jul 2, 2026
08cff4a
refactor(#1): extract LobbyUIFocusHint; add focus border to config fi…
cnotv Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/controls/src/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,44 @@ describe('createControls - Multi-trigger support', () => {
controls.destroyControls()
})
})

describe('createControls - getMapping / setMapping', () => {
const disabledDevices = { keyboard: false, gamepad: false, touch: false, mouse: false }

it('getMapping returns a copy that does not mutate internal state', () => {
const controls = createControls({
mapping: { keyboard: { w: 'move-forward' } },
...disabledDevices
})

const snapshot = controls.getMapping()
snapshot.keyboard!.w = 'tampered'

expect(controls.getMapping().keyboard!.w).toBe('move-forward')

controls.destroyControls()
})

it('setMapping replaces the mapping while preserving the action callback', () => {
const onActionMock = vi.fn()
const controls = createControls({
mapping: { keyboard: { w: 'move-forward' } },
onAction: onActionMock,
...disabledDevices
})

controls.setMapping({ keyboard: { ArrowUp: 'move-forward' } })

expect(controls.getMapping()).toEqual({ keyboard: { ArrowUp: 'move-forward' } })

controls.currentActions['move-forward'] = {
action: 'move-forward',
trigger: 'ArrowUp',
device: 'keyboard',
triggers: new Set(['keyboard:ArrowUp'])
}
expect(controls.currentActions['move-forward']).toBeDefined()

controls.destroyControls()
})
})
22 changes: 21 additions & 1 deletion packages/controls/src/core.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
ControlAction,
ControlMapping,
ControlsOptions,
ControlsExtras,
ControlsCurrents,
Expand Down Expand Up @@ -189,7 +190,26 @@ export function createControls(options: ControlsOptions): ControlsExtras {
autoBind()
}

function getMapping(): ControlMapping {
return structuredClone(mappingReference.current)
}

function setMapping(newMapping: ControlMapping) {
unbindAll()
options.mapping = newMapping
mappingReference.current = newMapping
autoBind()
}

autoBind()

return { destroyControls, remapControlsOptions, currentActions, logs, buttonMap }
return {
destroyControls,
remapControlsOptions,
getMapping,
setMapping,
currentActions,
logs,
buttonMap
}
}
59 changes: 59 additions & 0 deletions packages/controls/src/fauxpad.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,65 @@ describe('createFauxPadController', () => {
expect(onActionMock).not.toHaveBeenCalled()
})

describe('mouse interaction', () => {
const createMouseEvent = (type: string, clientX: number, clientY: number): MouseEvent =>
new MouseEvent(type, { clientX, clientY, bubbles: true, cancelable: true })

it('should become active on mousedown', () => {
const edgeElement = createMockElement(100, 100)
const insideElement = createMockElement(50, 50)

const controller = createFauxPadController(mappingReference, handlers)
controller.bind(edgeElement, insideElement)

insideElement.dispatchEvent(createMouseEvent('mousedown', 50, 50))

expect(controller.isActive()).toBe(true)
})

it('should trigger RIGHT action when dragging right with the mouse', () => {
const edgeElement = createMockElement(100, 100)
const insideElement = createMockElement(50, 50)

const controller = createFauxPadController(mappingReference, handlers, { deadzone: 0.1 })
controller.bind(edgeElement, insideElement)

insideElement.dispatchEvent(createMouseEvent('mousedown', 50, 50))
window.dispatchEvent(createMouseEvent('mousemove', 100, 50))

expect(onActionMock).toHaveBeenCalledWith('move-right', 'right', 'faux-pad')
})

it('should release actions on mouseup', () => {
const edgeElement = createMockElement(100, 100)
const insideElement = createMockElement(50, 50)

const controller = createFauxPadController(mappingReference, handlers, { deadzone: 0.1 })
controller.bind(edgeElement, insideElement)

insideElement.dispatchEvent(createMouseEvent('mousedown', 50, 50))
window.dispatchEvent(createMouseEvent('mousemove', 100, 50))
window.dispatchEvent(createMouseEvent('mouseup', 100, 50))

expect(onReleaseMock).toHaveBeenCalled()
expect(controller.isActive()).toBe(false)
})

it('should stop tracking mouse movement after unbind', () => {
const edgeElement = createMockElement(100, 100)
const insideElement = createMockElement(50, 50)

const controller = createFauxPadController(mappingReference, handlers, { deadzone: 0.1 })
controller.bind(edgeElement, insideElement)
controller.unbind(edgeElement, insideElement)

insideElement.dispatchEvent(createMouseEvent('mousedown', 50, 50))
window.dispatchEvent(createMouseEvent('mousemove', 100, 50))

expect(onActionMock).not.toHaveBeenCalled()
})
})

describe('8-way direction detection', () => {
it.each([
// [label, moveToX, moveToY, expectedDirections]
Expand Down
79 changes: 63 additions & 16 deletions packages/controls/src/fauxpad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,52 @@ function activateDirections(state: DirectionState, directions: Set<string>) {
})
}

interface DragCallbacks {
start: (x: number, y: number) => void
move: (x: number, y: number) => void
end: () => void
}

/**
* Wire touch and mouse dragging on the inside element to the drag callbacks.
* Mouse move/up are bound on window so a drag keeps tracking outside the pad.
*
* @param {HTMLElement} inside The draggable knob element
* @param {DragCallbacks} callbacks Start/move/end handlers
* @returns {() => void} A function that removes every listener
*/
function attachDragListeners(inside: HTMLElement, callbacks: DragCallbacks): () => void {
const onTouchStart = (event: TouchEvent) => {
event.preventDefault()
callbacks.start(event.touches[0].clientX, event.touches[0].clientY)
}
const onTouchMove = (event: TouchEvent) => {
event.preventDefault()
callbacks.move(event.touches[0].clientX, event.touches[0].clientY)
}
const onMouseDown = (event: MouseEvent) => {
event.preventDefault()
callbacks.start(event.clientX, event.clientY)
}
const onMouseMove = (event: MouseEvent) => callbacks.move(event.clientX, event.clientY)

inside.addEventListener('touchstart', onTouchStart as EventListener)
inside.addEventListener('touchmove', onTouchMove as EventListener)
inside.addEventListener('touchend', callbacks.end)
inside.addEventListener('mousedown', onMouseDown as EventListener)
window.addEventListener('mousemove', onMouseMove as EventListener)
window.addEventListener('mouseup', callbacks.end)

return () => {
inside.removeEventListener('touchstart', onTouchStart as EventListener)
inside.removeEventListener('touchmove', onTouchMove as EventListener)
inside.removeEventListener('touchend', callbacks.end)
inside.removeEventListener('mousedown', onMouseDown as EventListener)
window.removeEventListener('mousemove', onMouseMove as EventListener)
window.removeEventListener('mouseup', callbacks.end)
}
}

/**
* Create a virtual faux-pad controller that interprets touch/mouse input into directional actions
*
Expand Down Expand Up @@ -164,17 +210,15 @@ export function createFauxPadController(
isActive = false
}

const onTouchStart = (event: TouchEvent) => {
event.preventDefault()
initialPosition = { x: event.touches[0].clientX, y: event.touches[0].clientY }
const startDrag = (x: number, y: number) => {
initialPosition = { x, y }
isActive = true
}

const onTouchMove = (event: TouchEvent) => {
const moveDrag = (x: number, y: number) => {
if (!isActive) return
event.preventDefault()
const rawX = event.touches[0].clientX - initialPosition.x
const rawY = event.touches[0].clientY - initialPosition.y
const rawX = x - initialPosition.x
const rawY = y - initialPosition.y
const distance = Math.hypot(rawX, rawY)
const scale = distance > threshold.x ? threshold.x / distance : 1
currentPosition = { x: rawX * scale, y: rawY * scale }
Expand All @@ -184,25 +228,28 @@ export function createFauxPadController(
updateDirections()
}

const onTouchEnd = () => {
reset()
const endDrag = () => {
if (isActive) reset()
}

let detachDrag: (() => void) | null = null

function bind(edgeElement: HTMLElement, insideElement: HTMLElement) {
insideElement_ = insideElement
threshold = {
x: edgeElement.offsetWidth / 2 - insideElement.offsetWidth / 2,
y: edgeElement.offsetHeight / 2 - insideElement.offsetHeight / 2
}
insideElement.addEventListener('touchstart', onTouchStart as EventListener)
insideElement.addEventListener('touchmove', onTouchMove as EventListener)
insideElement.addEventListener('touchend', onTouchEnd)
detachDrag = attachDragListeners(insideElement, {
start: startDrag,
move: moveDrag,
end: endDrag
})
}

function unbind(_edgeElement: HTMLElement, insideElement: HTMLElement) {
insideElement.removeEventListener('touchstart', onTouchStart as EventListener)
insideElement.removeEventListener('touchmove', onTouchMove as EventListener)
insideElement.removeEventListener('touchend', onTouchEnd)
function unbind(_edgeElement: HTMLElement, _insideElement: HTMLElement) {
detachDrag?.()
detachDrag = null
insideElement_ = null
}

Expand Down
16 changes: 15 additions & 1 deletion packages/controls/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
// Public API - Barrel export pattern
export type {
ControlAction,
ControlDevice,
ControlMapping,
ControlsOptions,
ControlsExtras,
ControlsCurrents,
ControlsLogs,
ControlHandlers,
ControlEvent
ControlEvent,
ControlSkin,
ControlSkinId,
ControlPreset
} from './types'

export type { FauxPadController, FauxPadPosition, FauxPadOptions } from './fauxpad'
Expand All @@ -16,3 +20,13 @@ export { DEFAULT_BUTTON_MAP } from './constants'
export { createControls, isMobile } from './core'
export { createFauxPadController } from './fauxpad'
export type { FauxPadOptions as FauxPadControllerOptions } from './fauxpad'

export { assignBinding, removeBinding, createDefaultMapping } from './mapping'
export { CONTROL_SKINS, getDefaultSkinId } from './skins'
export {
serializePreset,
parsePreset,
savePresets,
loadPresets,
PRESETS_STORAGE_KEY
} from './presets'
68 changes: 68 additions & 0 deletions packages/controls/src/mapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest'
import { assignBinding, removeBinding, createDefaultMapping } from './mapping'
import type { ControlMapping } from './types'

describe('assignBinding', () => {
it('sets a trigger to an action for a device without mutating the input', () => {
const mapping: ControlMapping = { keyboard: { w: 'move-forward' } }

const next = assignBinding(mapping, 'keyboard', 'ArrowUp', 'move-forward')

expect(next.keyboard).toEqual({ ArrowUp: 'move-forward' })
expect(mapping.keyboard).toEqual({ w: 'move-forward' })
})

it('removes any other trigger already bound to the same action on that device', () => {
const mapping: ControlMapping = { keyboard: { w: 'move-forward', s: 'move-back' } }

const next = assignBinding(mapping, 'keyboard', 'ArrowUp', 'move-forward')

expect(next.keyboard).toEqual({ ArrowUp: 'move-forward', s: 'move-back' })
})

it('leaves other devices untouched', () => {
const mapping: ControlMapping = {
keyboard: { w: 'move-forward' },
gamepad: { 'dpad-up': 'move-forward' }
}

const next = assignBinding(mapping, 'keyboard', 'ArrowUp', 'move-forward')

expect(next.gamepad).toEqual({ 'dpad-up': 'move-forward' })
})
})

describe('removeBinding', () => {
it('removes a trigger without mutating the input', () => {
const mapping: ControlMapping = { keyboard: { w: 'move-forward', s: 'move-back' } }

const next = removeBinding(mapping, 'keyboard', 'w')

expect(next.keyboard).toEqual({ s: 'move-back' })
expect(mapping.keyboard).toEqual({ w: 'move-forward', s: 'move-back' })
})

it('is a no-op when the trigger is absent', () => {
const mapping: ControlMapping = { keyboard: { w: 'move-forward' } }

const next = removeBinding(mapping, 'keyboard', 'missing')

expect(next.keyboard).toEqual({ w: 'move-forward' })
})
})

describe('createDefaultMapping', () => {
it('provides keyboard, gamepad and faux-pad bindings', () => {
const mapping = createDefaultMapping()

expect(Object.values(mapping.keyboard ?? {})).toContain('move-forward')
expect(Object.values(mapping.gamepad ?? {})).toContain('move-forward')
expect(Object.keys(mapping['faux-pad'] ?? {})).toEqual(
expect.arrayContaining(['up', 'down', 'left', 'right'])
)
})

it('returns a fresh object each call', () => {
expect(createDefaultMapping()).not.toBe(createDefaultMapping())
})
})
Loading
Loading