diff --git a/packages/controls/src/core.test.ts b/packages/controls/src/core.test.ts index db17229d..a544887c 100644 --- a/packages/controls/src/core.test.ts +++ b/packages/controls/src/core.test.ts @@ -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() + }) +}) diff --git a/packages/controls/src/core.ts b/packages/controls/src/core.ts index 2c664b4e..dbebb63c 100644 --- a/packages/controls/src/core.ts +++ b/packages/controls/src/core.ts @@ -1,5 +1,6 @@ import type { ControlAction, + ControlMapping, ControlsOptions, ControlsExtras, ControlsCurrents, @@ -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 + } } diff --git a/packages/controls/src/fauxpad.test.ts b/packages/controls/src/fauxpad.test.ts index ad5e51b6..04f0c7c0 100644 --- a/packages/controls/src/fauxpad.test.ts +++ b/packages/controls/src/fauxpad.test.ts @@ -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] diff --git a/packages/controls/src/fauxpad.ts b/packages/controls/src/fauxpad.ts index b3c545f8..36958c10 100644 --- a/packages/controls/src/fauxpad.ts +++ b/packages/controls/src/fauxpad.ts @@ -83,6 +83,52 @@ function activateDirections(state: DirectionState, directions: Set) { }) } +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 * @@ -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 } @@ -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 } diff --git a/packages/controls/src/index.ts b/packages/controls/src/index.ts index da51d75e..01099572 100644 --- a/packages/controls/src/index.ts +++ b/packages/controls/src/index.ts @@ -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' @@ -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' diff --git a/packages/controls/src/mapping.test.ts b/packages/controls/src/mapping.test.ts new file mode 100644 index 00000000..e6b2286d --- /dev/null +++ b/packages/controls/src/mapping.test.ts @@ -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()) + }) +}) diff --git a/packages/controls/src/mapping.ts b/packages/controls/src/mapping.ts new file mode 100644 index 00000000..b218bac0 --- /dev/null +++ b/packages/controls/src/mapping.ts @@ -0,0 +1,81 @@ +import type { ControlAction, ControlDevice, ControlMapping } from './types' + +/** + * Assign a trigger to an action on a device, returning a new mapping. + * Any other trigger already bound to the same action on that device is removed, + * so each action keeps a single trigger per device. + * + * @param {ControlMapping} mapping Current mapping (not mutated) + * @param {ControlDevice} device Device to update + * @param {string} trigger Trigger key/button/direction to bind + * @param {ControlAction} action Action the trigger should fire + * @returns {ControlMapping} A new mapping with the binding applied + */ +export function assignBinding( + mapping: ControlMapping, + device: ControlDevice, + trigger: string, + action: ControlAction +): ControlMapping { + const deviceMap = mapping[device] ?? {} + const withoutAction = Object.fromEntries( + Object.entries(deviceMap).filter(([, boundAction]) => boundAction !== action) + ) + return { ...mapping, [device]: { ...withoutAction, [trigger]: action } } +} + +/** + * Remove a trigger from a device, returning a new mapping. + * + * @param {ControlMapping} mapping Current mapping (not mutated) + * @param {ControlDevice} device Device to update + * @param {string} trigger Trigger to remove + * @returns {ControlMapping} A new mapping without the trigger + */ +export function removeBinding( + mapping: ControlMapping, + device: ControlDevice, + trigger: string +): ControlMapping { + const deviceMap = mapping[device] ?? {} + const next = Object.fromEntries( + Object.entries(deviceMap).filter(([boundTrigger]) => boundTrigger !== trigger) + ) + return { ...mapping, [device]: next } +} + +/** + * Build the default control mapping used as a starting point in the mapper. + * Covers keyboard (WASD + arrows), gamepad (face buttons + dpad) and faux-pad + * directions for the standard movement actions. + * + * @returns {ControlMapping} A fresh default mapping + */ +export function createDefaultMapping(): ControlMapping { + return { + keyboard: { + w: 'move-forward', + s: 'move-back', + a: 'move-left', + d: 'move-right', + ArrowUp: 'move-forward', + ArrowDown: 'move-back', + ArrowLeft: 'move-left', + ArrowRight: 'move-right', + ' ': 'jump' + }, + gamepad: { + 'dpad-up': 'move-forward', + 'dpad-down': 'move-back', + 'dpad-left': 'move-left', + 'dpad-right': 'move-right', + cross: 'jump' + }, + 'faux-pad': { + up: 'move-forward', + down: 'move-back', + left: 'move-left', + right: 'move-right' + } + } +} diff --git a/packages/controls/src/presets.test.ts b/packages/controls/src/presets.test.ts new file mode 100644 index 00000000..1e130438 --- /dev/null +++ b/packages/controls/src/presets.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + serializePreset, + parsePreset, + savePresets, + loadPresets, + PRESETS_STORAGE_KEY +} from './presets' +import type { ControlPreset } from './types' + +const preset: ControlPreset = { + name: 'My layout', + mapping: { keyboard: { w: 'move-forward' } }, + skin: 'default' +} + +describe('serializePreset / parsePreset', () => { + it('round-trips a preset through JSON', () => { + expect(parsePreset(serializePreset(preset))).toEqual(preset) + }) + + it('throws on malformed JSON', () => { + expect(() => parsePreset('{ not json')).toThrow() + }) + + it('throws when required fields are missing or of the wrong type', () => { + expect(() => parsePreset(JSON.stringify({ name: 'x', skin: 'default' }))).toThrow() + expect(() => parsePreset(JSON.stringify({ name: 1, mapping: {}, skin: 'default' }))).toThrow() + }) +}) + +describe('savePresets / loadPresets', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('returns an empty list when nothing is stored', () => { + expect(loadPresets()).toEqual([]) + }) + + it('round-trips presets through localStorage', () => { + savePresets([preset]) + expect(loadPresets()).toEqual([preset]) + }) + + it('returns an empty list when the stored value is corrupt', () => { + localStorage.setItem(PRESETS_STORAGE_KEY, '{ not json') + expect(loadPresets()).toEqual([]) + }) +}) diff --git a/packages/controls/src/presets.ts b/packages/controls/src/presets.ts new file mode 100644 index 00000000..d5c8f090 --- /dev/null +++ b/packages/controls/src/presets.ts @@ -0,0 +1,74 @@ +import type { ControlMapping, ControlPreset } from './types' + +export const PRESETS_STORAGE_KEY = 'webgamekit:controls-presets' + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const isMapping = (value: unknown): value is ControlMapping => + isRecord(value) && + Object.values(value).every( + (device) => + isRecord(device) && Object.values(device).every((action) => typeof action === 'string') + ) + +const toPreset = (value: unknown): ControlPreset => { + if ( + !isRecord(value) || + typeof value.name !== 'string' || + typeof value.skin !== 'string' || + !isMapping(value.mapping) + ) { + throw new Error('Invalid control preset') + } + return { name: value.name, mapping: value.mapping, skin: value.skin } +} + +/** + * Serialize a preset to a JSON string for export. + * + * @param {ControlPreset} preset Preset to serialize + * @returns {string} JSON representation of the preset + */ +export function serializePreset(preset: ControlPreset): string { + return JSON.stringify(preset) +} + +/** + * Parse and validate a preset from a JSON string. Throws when the JSON is + * malformed or does not match the preset shape. + * + * @param {string} json JSON string to parse + * @returns {ControlPreset} The validated preset + */ +export function parsePreset(json: string): ControlPreset { + return toPreset(JSON.parse(json)) +} + +/** + * Persist a list of presets to localStorage. + * + * @param {ControlPreset[]} presets Presets to store + * @returns {void} + */ +export function savePresets(presets: ControlPreset[]): void { + localStorage.setItem(PRESETS_STORAGE_KEY, JSON.stringify(presets)) +} + +/** + * Load stored presets from localStorage. Returns an empty list when nothing is + * stored or the stored value is corrupt. + * + * @returns {ControlPreset[]} The stored presets, or an empty list + */ +export function loadPresets(): ControlPreset[] { + const raw = localStorage.getItem(PRESETS_STORAGE_KEY) + if (!raw) return [] + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.map(toPreset) + } catch { + return [] + } +} diff --git a/packages/controls/src/skins.test.ts b/packages/controls/src/skins.test.ts new file mode 100644 index 00000000..4f8a8aa1 --- /dev/null +++ b/packages/controls/src/skins.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { CONTROL_SKINS, getDefaultSkinId } from './skins' + +describe('CONTROL_SKINS', () => { + it('provides a default plus at least one alternate skin', () => { + expect(CONTROL_SKINS.length).toBeGreaterThanOrEqual(2) + }) + + it('marks exactly one skin as default', () => { + expect(CONTROL_SKINS.filter((skin) => skin.isDefault)).toHaveLength(1) + }) + + it('has unique skin ids', () => { + const ids = CONTROL_SKINS.map((skin) => skin.id) + expect(new Set(ids).size).toBe(ids.length) + }) +}) + +describe('getDefaultSkinId', () => { + it('returns the id of the skin flagged as default', () => { + const defaultSkin = CONTROL_SKINS.find((skin) => skin.isDefault) + expect(getDefaultSkinId()).toBe(defaultSkin!.id) + }) +}) diff --git a/packages/controls/src/skins.ts b/packages/controls/src/skins.ts new file mode 100644 index 00000000..0254e8e7 --- /dev/null +++ b/packages/controls/src/skins.ts @@ -0,0 +1,20 @@ +import type { ControlSkin, ControlSkinId } from './types' + +/** + * Registry of provided on-screen control skins. The UI renders the visual style + * for each id; this data only declares which skins exist and which is default. + */ +export const CONTROL_SKINS: ControlSkin[] = [ + { id: 'default', label: 'Default', isDefault: true }, + { id: 'neon', label: 'Neon' }, + { id: 'minimal', label: 'Minimal' } +] + +/** + * Get the id of the skin flagged as default, falling back to the first skin. + * + * @returns {ControlSkinId} The default skin id + */ +export function getDefaultSkinId(): ControlSkinId { + return (CONTROL_SKINS.find((skin) => skin.isDefault) ?? CONTROL_SKINS[0]).id +} diff --git a/packages/controls/src/types.ts b/packages/controls/src/types.ts index 5baaceeb..06c15790 100644 --- a/packages/controls/src/types.ts +++ b/packages/controls/src/types.ts @@ -1,4 +1,5 @@ export type ControlAction = string +export type ControlDevice = 'keyboard' | 'gamepad' | 'touch' | 'faux-pad' export type ControlEvent = 'touchstart' | 'touchend' | 'mousedown' | 'mouseup' export interface ControlMapping { @@ -44,11 +45,27 @@ export type ControlsLogs = Array<{ export type ControlsExtras = { destroyControls: () => void remapControlsOptions: (newOptions: ControlsOptions) => void + getMapping: () => ControlMapping + setMapping: (mapping: ControlMapping) => void currentActions: ControlsCurrents logs: ControlsLogs buttonMap: string[] } +export type ControlSkinId = string + +export interface ControlSkin { + id: ControlSkinId + label: string + isDefault?: boolean +} + +export interface ControlPreset { + name: string + mapping: ControlMapping + skin: ControlSkinId +} + export interface ControlHandlers { onAction: (action: ControlAction, trigger: string, device: string) => void onRelease: (action: ControlAction, trigger: string, device: string) => void diff --git a/src/assets/styles/_variables.scss b/src/assets/styles/_variables.scss index 5d987b1d..c9c6a6d6 100644 --- a/src/assets/styles/_variables.scss +++ b/src/assets/styles/_variables.scss @@ -145,6 +145,8 @@ --font-size-sm: 0.75rem; // 12px --font-size-base: 0.875rem; // 14px --font-size-md: 1rem; // 16px + --font-size-lg: 1.25rem; // 20px + --font-size-xl: 1.5rem; // 24px // Opacity --opacity-muted: 0.5; diff --git a/src/components/ControlsMapper/ControlsMapper.vue b/src/components/ControlsMapper/ControlsMapper.vue new file mode 100644 index 00000000..17d31c68 --- /dev/null +++ b/src/components/ControlsMapper/ControlsMapper.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/src/components/ControlsMapper/ControlsMapperBindings.vue b/src/components/ControlsMapper/ControlsMapperBindings.vue new file mode 100644 index 00000000..dc4165cd --- /dev/null +++ b/src/components/ControlsMapper/ControlsMapperBindings.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/src/components/ControlsMapper/ControlsMapperPresets.vue b/src/components/ControlsMapper/ControlsMapperPresets.vue new file mode 100644 index 00000000..4753ce63 --- /dev/null +++ b/src/components/ControlsMapper/ControlsMapperPresets.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/src/components/ControlsMapper/ControlsMapperStyle.vue b/src/components/ControlsMapper/ControlsMapperStyle.vue new file mode 100644 index 00000000..36178de9 --- /dev/null +++ b/src/components/ControlsMapper/ControlsMapperStyle.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/src/components/ControlsMapper/ControlsMapperTester.vue b/src/components/ControlsMapper/ControlsMapperTester.vue new file mode 100644 index 00000000..7fd83126 --- /dev/null +++ b/src/components/ControlsMapper/ControlsMapperTester.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/components/ControlsMapper/config.ts b/src/components/ControlsMapper/config.ts new file mode 100644 index 00000000..85aa3384 --- /dev/null +++ b/src/components/ControlsMapper/config.ts @@ -0,0 +1,27 @@ +import type { ControlDevice } from '@webgamekit/controls' + +export interface MapperActionConfig { + id: string + label: string +} + +export interface MapperDeviceConfig { + id: ControlDevice + label: string +} + +export const MAPPER_ACTIONS: MapperActionConfig[] = [ + { id: 'move-forward', label: 'Move forward' }, + { id: 'move-back', label: 'Move back' }, + { id: 'move-left', label: 'Move left' }, + { id: 'move-right', label: 'Move right' }, + { id: 'jump', label: 'Jump' } +] + +export const MAPPER_DEVICES: MapperDeviceConfig[] = [ + { id: 'keyboard', label: 'Keyboard' }, + { id: 'gamepad', label: 'Gamepad' }, + { id: 'faux-pad', label: 'Faux-pad' } +] + +export const FAUX_PAD_DIRECTIONS = ['up', 'down', 'left', 'right'] as const diff --git a/src/components/ControlsMapper/useBindingCapture.ts b/src/components/ControlsMapper/useBindingCapture.ts new file mode 100644 index 00000000..02ba74fb --- /dev/null +++ b/src/components/ControlsMapper/useBindingCapture.ts @@ -0,0 +1,162 @@ +import { ref } from 'vue' +import { DEFAULT_BUTTON_MAP } from '@webgamekit/controls' +import type { ControlDevice } from '@webgamekit/controls' + +const AXIS_THRESHOLD = 0.6 + +const buttonName = (index: number): string => DEFAULT_BUTTON_MAP[index] ?? `button-${index}` + +const axisName = (index: number, value: number): string => { + const dir = index % 2 === 0 ? (value < 0 ? 'left' : 'right') : value < 0 ? 'up' : 'down' + return `axis${index}-${dir}` +} + +const pressedButtons = (pad: Gamepad | undefined): number[] => + pad ? pad.buttons.flatMap((button, index) => (button.pressed ? [index] : [])) : [] + +const deflectedAxes = (pad: Gamepad | undefined): number[] => + pad ? pad.axes.flatMap((value, index) => (Math.abs(value) > AXIS_THRESHOLD ? [index] : [])) : [] + +const findFresh = (values: number[], ignored: number[]): number | undefined => + values.find((value) => !ignored.includes(value)) + +type RegisterCancel = (cancel: () => void) => void + +// Capture a key, but interrupt if a gamepad input (wrong device) is used. +const captureKeyboard = (registerCancel: RegisterCancel): Promise => + new Promise((resolve, reject) => { + let frame = 0 + let ignoredButtons: number[] | null = null + let ignoredAxes: number[] | null = null + + const onKeyDown = (event: KeyboardEvent) => { + event.preventDefault() + window.removeEventListener('keydown', onKeyDown) + cancelAnimationFrame(frame) + resolve(event.key) + } + + const poll = () => { + const pad = navigator.getGamepads?.().find((entry) => entry) ?? undefined + const buttons = pressedButtons(pad) + const axes = deflectedAxes(pad) + if (ignoredButtons === null) ignoredButtons = buttons + if (ignoredAxes === null) ignoredAxes = axes + if ( + findFresh(buttons, ignoredButtons) !== undefined || + findFresh(axes, ignoredAxes) !== undefined + ) { + window.removeEventListener('keydown', onKeyDown) + cancelAnimationFrame(frame) + reject(new Error('interrupted')) + return + } + ignoredButtons = ignoredButtons.filter((index) => buttons.includes(index)) + ignoredAxes = ignoredAxes.filter((index) => axes.includes(index)) + frame = requestAnimationFrame(poll) + } + + window.addEventListener('keydown', onKeyDown) + frame = requestAnimationFrame(poll) + registerCancel(() => { + window.removeEventListener('keydown', onKeyDown) + cancelAnimationFrame(frame) + reject(new Error('cancelled')) + }) + }) + +// Capture a gamepad button (on release) or stick axis, but interrupt if a key +// (wrong device) is pressed. Inputs already active when capture starts are +// ignored until they return to rest. +const captureGamepad = (registerCancel: RegisterCancel): Promise => + new Promise((resolve, reject) => { + let frame = 0 + let ignoredButtons: number[] | null = null + let ignoredAxes: number[] | null = null + let pendingButton: number | null = null + + const onKeyDown = (event: KeyboardEvent) => { + event.preventDefault() + window.removeEventListener('keydown', onKeyDown) + cancelAnimationFrame(frame) + reject(new Error('interrupted')) + } + + const settle = (trigger: string) => { + window.removeEventListener('keydown', onKeyDown) + cancelAnimationFrame(frame) + resolve(trigger) + } + + const poll = () => { + const pad = navigator.getGamepads?.().find((entry) => entry) ?? undefined + const buttons = pressedButtons(pad) + const axes = deflectedAxes(pad) + if (ignoredButtons === null) ignoredButtons = buttons + if (ignoredAxes === null) ignoredAxes = axes + + if (pendingButton !== null) { + if (!buttons.includes(pendingButton)) { + settle(buttonName(pendingButton)) + return + } + } else { + const freshButton = findFresh(buttons, ignoredButtons) + const freshAxis = findFresh(axes, ignoredAxes) + if (freshButton !== undefined) { + pendingButton = freshButton + } else if (freshAxis !== undefined && pad) { + settle(axisName(freshAxis, pad.axes[freshAxis])) + return + } else { + ignoredButtons = ignoredButtons.filter((index) => buttons.includes(index)) + ignoredAxes = ignoredAxes.filter((index) => axes.includes(index)) + } + } + frame = requestAnimationFrame(poll) + } + + window.addEventListener('keydown', onKeyDown) + frame = requestAnimationFrame(poll) + registerCancel(() => { + window.removeEventListener('keydown', onKeyDown) + cancelAnimationFrame(frame) + reject(new Error('cancelled')) + }) + }) + +/** + * Capture the next physical input for a device so it can be assigned to an + * action. Keyboard resolves with the pressed key; gamepad resolves with its + * mapped button/axis name. Input from the other device interrupts the capture. + * + * @returns {{ listeningDevice: import('vue').Ref, capture: (device: ControlDevice) => Promise, cancel: () => void }} Capture helpers + */ +export function useBindingCapture() { + const listeningDevice = ref(null) + let cancelActive: (() => void) | null = null + + const cancel = () => { + cancelActive?.() + cancelActive = null + listeningDevice.value = null + } + + const capture = async (device: ControlDevice): Promise => { + cancel() + listeningDevice.value = device + const registerCancel: RegisterCancel = (fn) => { + cancelActive = fn + } + try { + return device === 'gamepad' + ? await captureGamepad(registerCancel) + : await captureKeyboard(registerCancel) + } finally { + cancelActive = null + listeningDevice.value = null + } + } + + return { listeningDevice, capture, cancel } +} diff --git a/src/components/ControlsMapper/useGamepadStick.ts b/src/components/ControlsMapper/useGamepadStick.ts new file mode 100644 index 00000000..de753f0c --- /dev/null +++ b/src/components/ControlsMapper/useGamepadStick.ts @@ -0,0 +1,37 @@ +import { ref, onMounted, onUnmounted } from 'vue' + +const DEFAULT_DEADZONE = 0.15 + +/** + * Track the first connected gamepad's left stick as a normalized -1..1 vector, + * polled each animation frame. Used to drive the faux-pad preview visually. + * + * @param {number} deadzone Minimum absolute axis value before it registers + * @returns {{ position: import('vue').Ref<{ x: number; y: number }> }} Reactive stick position + */ +export function useGamepadStick(deadzone: number = DEFAULT_DEADZONE) { + const position = ref({ x: 0, y: 0 }) + let frame = 0 + + const applyDeadzone = (value: number): number => (Math.abs(value) > deadzone ? value : 0) + + const poll = () => { + const pad = navigator.getGamepads?.().find((entry) => entry) + const x = applyDeadzone(pad?.axes[0] ?? 0) + const y = applyDeadzone(pad?.axes[1] ?? 0) + if (x !== position.value.x || y !== position.value.y) { + position.value = { x, y } + } + frame = requestAnimationFrame(poll) + } + + onMounted(() => { + frame = requestAnimationFrame(poll) + }) + + onUnmounted(() => { + cancelAnimationFrame(frame) + }) + + return { position } +} diff --git a/src/components/LobbyUI/LobbyUIButton.vue b/src/components/LobbyUI/LobbyUIButton.vue index f4d8f6a1..d47893f6 100644 --- a/src/components/LobbyUI/LobbyUIButton.vue +++ b/src/components/LobbyUI/LobbyUIButton.vue @@ -2,17 +2,18 @@ withDefaults( defineProps<{ variant?: 'cta' | 'primary' | 'ghost' + size?: 'sm' | 'md' | 'lg' disabled?: boolean autofocus?: boolean }>(), - { variant: 'primary', disabled: false, autofocus: false } + { variant: 'primary', size: 'md', disabled: false, autofocus: false } )