From 59f1e28bde068269c99f4d5c26c14d0f2cc9a681 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Wed, 1 Jul 2026 15:53:00 +0200 Subject: [PATCH 01/29] feat(#1): controls mapper tool with remap, skins, presets and live test Add framework-agnostic controls data helpers (getMapping/setMapping, assignBinding/removeBinding, createDefaultMapping, preset serialize/parse, localStorage persistence, CONTROL_SKINS), a Pinia store holding reactive state, and a standalone /tools/ControlsMapper view built from a reusable ControlsMapper component tree (bindings, style, presets, tester). Add a skin prop to TouchControl for previewable on-screen control styles. Co-Authored-By: Claude Opus 4.8 --- packages/controls/src/core.test.ts | 41 +++++ packages/controls/src/core.ts | 22 ++- packages/controls/src/index.ts | 16 +- packages/controls/src/mapping.test.ts | 68 ++++++++ packages/controls/src/mapping.ts | 81 +++++++++ packages/controls/src/presets.test.ts | 50 ++++++ packages/controls/src/presets.ts | 74 ++++++++ packages/controls/src/skins.test.ts | 24 +++ packages/controls/src/skins.ts | 20 +++ packages/controls/src/types.ts | 17 ++ src/assets/styles/_variables.scss | 2 + .../ControlsMapper/ControlsMapper.vue | 83 +++++++++ .../ControlsMapper/ControlsMapperBindings.vue | 148 ++++++++++++++++ .../ControlsMapper/ControlsMapperPresets.vue | 163 ++++++++++++++++++ .../ControlsMapper/ControlsMapperStyle.vue | 96 +++++++++++ .../ControlsMapper/ControlsMapperTester.vue | 106 ++++++++++++ src/components/ControlsMapper/config.ts | 27 +++ .../ControlsMapper/useBindingCapture.ts | 69 ++++++++ src/components/TouchControl.vue | 62 ++++++- src/stores/controlsMapper.test.ts | 90 ++++++++++ src/stores/controlsMapper.ts | 120 +++++++++++++ src/style.css | 3 + .../Tools/ControlsMapper/ControlsMapper.vue | 41 +++++ 23 files changed, 1412 insertions(+), 11 deletions(-) create mode 100644 packages/controls/src/mapping.test.ts create mode 100644 packages/controls/src/mapping.ts create mode 100644 packages/controls/src/presets.test.ts create mode 100644 packages/controls/src/presets.ts create mode 100644 packages/controls/src/skins.test.ts create mode 100644 packages/controls/src/skins.ts create mode 100644 src/components/ControlsMapper/ControlsMapper.vue create mode 100644 src/components/ControlsMapper/ControlsMapperBindings.vue create mode 100644 src/components/ControlsMapper/ControlsMapperPresets.vue create mode 100644 src/components/ControlsMapper/ControlsMapperStyle.vue create mode 100644 src/components/ControlsMapper/ControlsMapperTester.vue create mode 100644 src/components/ControlsMapper/config.ts create mode 100644 src/components/ControlsMapper/useBindingCapture.ts create mode 100644 src/stores/controlsMapper.test.ts create mode 100644 src/stores/controlsMapper.ts create mode 100644 src/views/Tools/ControlsMapper/ControlsMapper.vue 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/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..b5807690 --- /dev/null +++ b/src/components/ControlsMapper/ControlsMapper.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/src/components/ControlsMapper/ControlsMapperBindings.vue b/src/components/ControlsMapper/ControlsMapperBindings.vue new file mode 100644 index 00000000..5ef5d5fb --- /dev/null +++ b/src/components/ControlsMapper/ControlsMapperBindings.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/src/components/ControlsMapper/ControlsMapperPresets.vue b/src/components/ControlsMapper/ControlsMapperPresets.vue new file mode 100644 index 00000000..11b5b853 --- /dev/null +++ b/src/components/ControlsMapper/ControlsMapperPresets.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/src/components/ControlsMapper/ControlsMapperStyle.vue b/src/components/ControlsMapper/ControlsMapperStyle.vue new file mode 100644 index 00000000..cd80af2a --- /dev/null +++ b/src/components/ControlsMapper/ControlsMapperStyle.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/src/components/ControlsMapper/ControlsMapperTester.vue b/src/components/ControlsMapper/ControlsMapperTester.vue new file mode 100644 index 00000000..24f5fc1b --- /dev/null +++ b/src/components/ControlsMapper/ControlsMapperTester.vue @@ -0,0 +1,106 @@ + + + + + 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..30001ae3 --- /dev/null +++ b/src/components/ControlsMapper/useBindingCapture.ts @@ -0,0 +1,69 @@ +import { ref } from 'vue' +import { DEFAULT_BUTTON_MAP } from '@webgamekit/controls' +import type { ControlDevice } from '@webgamekit/controls' + +/** + * Capture the next physical input for a device so it can be assigned to an + * action. Keyboard resolves with the pressed key; gamepad polls until a button + * is pressed and resolves with its mapped name. This is UI event wiring; the + * resulting binding is applied through the package/store. + * + * @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 captureKeyboard = (): Promise => + new Promise((resolve, reject) => { + const onKeyDown = (event: KeyboardEvent) => { + event.preventDefault() + window.removeEventListener('keydown', onKeyDown) + resolve(event.key) + } + window.addEventListener('keydown', onKeyDown) + cancelActive = () => { + window.removeEventListener('keydown', onKeyDown) + reject(new Error('cancelled')) + } + }) + + const captureGamepad = (): Promise => + new Promise((resolve, reject) => { + let frame = 0 + const poll = () => { + const pad = navigator.getGamepads?.().find((entry) => entry) + const pressedIndex = pad?.buttons.findIndex((button) => button.pressed) ?? -1 + if (pressedIndex >= 0) { + resolve(DEFAULT_BUTTON_MAP[pressedIndex] ?? `button-${pressedIndex}`) + return + } + frame = requestAnimationFrame(poll) + } + frame = requestAnimationFrame(poll) + cancelActive = () => { + cancelAnimationFrame(frame) + reject(new Error('cancelled')) + } + }) + + const capture = async (device: ControlDevice): Promise => { + cancel() + listeningDevice.value = device + try { + const trigger = device === 'gamepad' ? await captureGamepad() : await captureKeyboard() + return trigger + } finally { + cancelActive = null + listeningDevice.value = null + } + } + + return { listeningDevice, capture, cancel } +} diff --git a/src/components/TouchControl.vue b/src/components/TouchControl.vue index 6981c368..20fc3255 100644 --- a/src/components/TouchControl.vue +++ b/src/components/TouchControl.vue @@ -6,13 +6,23 @@ import { type FauxPadOptions } from '@webgamekit/controls' -const props = defineProps<{ - mapping: Record - options?: FauxPadOptions - mode?: 'faux-pad' | 'button' // Default is 'faux-pad' - currentActions?: Record // Reference to currentActions from createControls - onAction: (action: string) => void -}>() +const props = withDefaults( + defineProps<{ + mapping: Record + options?: FauxPadOptions + mode?: 'faux-pad' | 'button' // Default is 'faux-pad' + skin?: string + inline?: boolean // Render in-flow (for previews) instead of fixed to the viewport + currentActions?: Record // Reference to currentActions from createControls + onAction: (action: string) => void + }>(), + { skin: 'default', inline: false } +) + +const rootClasses = computed(() => [ + `touch-control--${props.skin}`, + { 'touch-control--inline': props.inline } +]) const touchControlInside = ref(null) const touchControlEdge = ref(null) @@ -71,6 +81,7 @@ const handleButtonAction = (action: string, event: Event) => {
@@ -79,7 +90,7 @@ const handleButtonAction = (action: string, event: Event) => {
-
+
-
+
@@ -119,6 +130,11 @@ const handleButtonAction = (action: string, event: Event) => { justify-content: flex-start; } +.touch-control--inline { + position: relative; + z-index: auto; +} + .touch-control__edge { position: absolute; inset: 0; @@ -165,4 +181,32 @@ const handleButtonAction = (action: string, event: Event) => { user-select: none; touch-action: none; } + +.touch-control--neon .touch-control__edge { + background-color: transparent; + box-shadow: + inset 0 0 0 2px var(--touch-skin-neon-color), + 0 0 16px var(--touch-skin-neon-glow); + opacity: 0.9; +} + +.touch-control--neon .touch-control__inside, +.touch-control--neon .touch-control__button { + background-color: var(--touch-skin-neon-color); + box-shadow: 0 0 12px var(--touch-skin-neon-glow); + opacity: 0.9; +} + +.touch-control--minimal .touch-control__edge { + background-color: transparent; + box-shadow: inset 0 0 0 1px var(--touch-skin-minimal-color); + opacity: 0.4; +} + +.touch-control--minimal .touch-control__inside, +.touch-control--minimal .touch-control__button { + background-color: transparent; + box-shadow: inset 0 0 0 1px var(--touch-skin-minimal-color); + opacity: 0.4; +} diff --git a/src/stores/controlsMapper.test.ts b/src/stores/controlsMapper.test.ts new file mode 100644 index 00000000..3f095269 --- /dev/null +++ b/src/stores/controlsMapper.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { PRESETS_STORAGE_KEY } from '@webgamekit/controls' +import { useControlsMapperStore } from './controlsMapper' + +describe('useControlsMapperStore', () => { + beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) + }) + + it('starts from the default mapping and default skin', () => { + const store = useControlsMapperStore() + expect(Object.values(store.mapping.keyboard ?? {})).toContain('move-forward') + expect(store.selectedSkin).toBe('default') + }) + + it('bindTrigger reassigns an action to a new trigger on a device', () => { + const store = useControlsMapperStore() + store.bindTrigger('keyboard', 'ArrowUp', 'jump') + expect(store.mapping.keyboard?.ArrowUp).toBe('jump') + }) + + it('clearTrigger removes a trigger', () => { + const store = useControlsMapperStore() + store.bindTrigger('keyboard', 'ArrowUp', 'jump') + store.clearTrigger('keyboard', 'ArrowUp') + expect(store.mapping.keyboard?.ArrowUp).toBeUndefined() + }) + + it('savePreset persists the current mapping and skin', () => { + const store = useControlsMapperStore() + store.selectSkin('neon') + store.bindTrigger('keyboard', 'ArrowUp', 'jump') + store.savePreset('My layout') + + const raw = localStorage.getItem(PRESETS_STORAGE_KEY) + expect(raw).toContain('My layout') + + setActivePinia(createPinia()) + const reloaded = useControlsMapperStore() + expect(reloaded.presets.map((preset) => preset.name)).toContain('My layout') + }) + + it('applyPreset restores a stored mapping and skin', () => { + const store = useControlsMapperStore() + store.selectSkin('neon') + store.bindTrigger('keyboard', 'ArrowUp', 'jump') + store.savePreset('My layout') + + store.resetMapping() + store.selectSkin('default') + store.applyPreset('My layout') + + expect(store.mapping.keyboard?.ArrowUp).toBe('jump') + expect(store.selectedSkin).toBe('neon') + }) + + it('deletePreset removes a stored preset', () => { + const store = useControlsMapperStore() + store.savePreset('A') + store.deletePreset('A') + expect(store.presets.map((preset) => preset.name)).not.toContain('A') + }) + + it('importPreset applies a valid JSON preset', () => { + const store = useControlsMapperStore() + const json = JSON.stringify({ + name: 'Imported', + mapping: { keyboard: { z: 'jump' } }, + skin: 'minimal' + }) + store.importPreset(json) + expect(store.mapping.keyboard?.z).toBe('jump') + expect(store.selectedSkin).toBe('minimal') + }) + + it('importPreset throws on invalid JSON', () => { + const store = useControlsMapperStore() + expect(() => store.importPreset('{ bad')).toThrow() + }) + + it('records and clears live actions', () => { + const store = useControlsMapperStore() + store.recordAction('jump', ' ', 'keyboard') + expect(store.liveActions).toContain('jump') + store.recordRelease('jump') + expect(store.liveActions).not.toContain('jump') + }) +}) diff --git a/src/stores/controlsMapper.ts b/src/stores/controlsMapper.ts new file mode 100644 index 00000000..aac3f49f --- /dev/null +++ b/src/stores/controlsMapper.ts @@ -0,0 +1,120 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { + assignBinding, + removeBinding, + createDefaultMapping, + getDefaultSkinId, + serializePreset, + parsePreset, + savePresets, + loadPresets +} from '@webgamekit/controls' +import type { + ControlDevice, + ControlMapping, + ControlPreset, + ControlSkinId +} from '@webgamekit/controls' + +interface LiveEvent { + action: string + trigger: string + device: string + type: 'action' | 'release' + timestamp: number +} + +const RECENT_EVENTS_LIMIT = 30 + +export const useControlsMapperStore = defineStore('controlsMapper', () => { + const mapping = ref(createDefaultMapping()) + const selectedSkin = ref(getDefaultSkinId()) + const presets = ref(loadPresets()) + const liveActions = ref([]) + const recentEvents = ref([]) + + const bindTrigger = (device: ControlDevice, trigger: string, action: string) => { + mapping.value = assignBinding(mapping.value, device, trigger, action) + } + + const clearTrigger = (device: ControlDevice, trigger: string) => { + mapping.value = removeBinding(mapping.value, device, trigger) + } + + const resetMapping = () => { + mapping.value = createDefaultMapping() + } + + const selectSkin = (skin: ControlSkinId) => { + selectedSkin.value = skin + } + + const persist = () => { + savePresets(presets.value) + } + + const savePreset = (name: string) => { + const preset: ControlPreset = { name, mapping: mapping.value, skin: selectedSkin.value } + presets.value = [...presets.value.filter((entry) => entry.name !== name), preset] + persist() + } + + const applyPreset = (name: string) => { + const preset = presets.value.find((entry) => entry.name === name) + if (!preset) return + mapping.value = preset.mapping + selectedSkin.value = preset.skin + } + + const deletePreset = (name: string) => { + presets.value = presets.value.filter((entry) => entry.name !== name) + persist() + } + + const exportCurrent = (name: string): string => + serializePreset({ name, mapping: mapping.value, skin: selectedSkin.value }) + + const importPreset = (json: string) => { + const preset = parsePreset(json) + mapping.value = preset.mapping + selectedSkin.value = preset.skin + } + + const recordAction = (action: string, trigger: string, device: string) => { + if (!liveActions.value.includes(action)) { + liveActions.value = [...liveActions.value, action] + } + recentEvents.value = [ + { action, trigger, device, type: 'action', timestamp: Date.now() }, + ...recentEvents.value + ].slice(0, RECENT_EVENTS_LIMIT) + } + + const recordRelease = (action: string) => { + liveActions.value = liveActions.value.filter((entry) => entry !== action) + recentEvents.value = [ + { action, trigger: '', device: '', type: 'release', timestamp: Date.now() }, + ...recentEvents.value + ].slice(0, RECENT_EVENTS_LIMIT) + } + + return { + mapping, + selectedSkin, + presets, + liveActions, + recentEvents, + bindTrigger, + clearTrigger, + resetMapping, + selectSkin, + savePreset, + applyPreset, + deletePreset, + exportCurrent, + importPreset, + recordAction, + recordRelease + } +}) diff --git a/src/style.css b/src/style.css index 6dce98e0..2f88321f 100644 --- a/src/style.css +++ b/src/style.css @@ -12,6 +12,9 @@ --touch-color-background-light: rgb(255, 255, 255, 0.3); --touch-color-background-dark: rgb(0, 0, 0, 0.3); + --touch-skin-neon-color: rgb(0, 229, 255, 0.85); + --touch-skin-neon-glow: rgb(0, 229, 255, 0.55); + --touch-skin-minimal-color: rgb(0, 0, 0, 0.3); } a { diff --git a/src/views/Tools/ControlsMapper/ControlsMapper.vue b/src/views/Tools/ControlsMapper/ControlsMapper.vue new file mode 100644 index 00000000..1f9db847 --- /dev/null +++ b/src/views/Tools/ControlsMapper/ControlsMapper.vue @@ -0,0 +1,41 @@ + + + + + From 43e8afe079a955ee2012d346b519ec32d139000f Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Wed, 1 Jul 2026 16:01:52 +0200 Subject: [PATCH 02/29] feat(#1): move mapper to Tests, add tabs, reset, scroll fix and IO dialog - Relocate the view to src/views/Tests (route /tests/ControlsMapper) - Bindings use tabs per control device (keyboard/gamepad/faux-pad) - Add resetToDefaults store action and a Reset to defaults button - Make the view its own scroll container (global body scroll is locked) - Move preset import/export into a reusable Dialog component Co-Authored-By: Claude Opus 4.8 --- .../ControlsMapper/ControlsMapper.vue | 60 +++++++--- .../ControlsMapper/ControlsMapperBindings.vue | 49 ++++---- .../ControlsMapper/ControlsMapperPresets.vue | 46 ++++---- src/components/ui/dialog/Dialog.vue | 107 ++++++++++++++++++ src/components/ui/dialog/index.ts | 1 + src/stores/controlsMapper.test.ts | 15 ++- src/stores/controlsMapper.ts | 5 +- .../ControlsMapper/ControlsMapper.vue | 2 + 8 files changed, 222 insertions(+), 63 deletions(-) create mode 100644 src/components/ui/dialog/Dialog.vue create mode 100644 src/components/ui/dialog/index.ts rename src/views/{Tools => Tests}/ControlsMapper/ControlsMapper.vue (96%) diff --git a/src/components/ControlsMapper/ControlsMapper.vue b/src/components/ControlsMapper/ControlsMapper.vue index b5807690..edfdcca7 100644 --- a/src/components/ControlsMapper/ControlsMapper.vue +++ b/src/components/ControlsMapper/ControlsMapper.vue @@ -2,6 +2,7 @@ import { onMounted, onUnmounted, watch } from 'vue' import { createControls } from '@webgamekit/controls' import type { ControlsExtras } from '@webgamekit/controls' +import { Button } from '@/components/ui/button' import { useControlsMapperStore } from '@/stores/controlsMapper' import ControlsMapperBindings from './ControlsMapperBindings.vue' import ControlsMapperStyle from './ControlsMapperStyle.vue' @@ -36,34 +37,59 @@ onUnmounted(() => { diff --git a/src/components/ControlsMapper/ControlsMapperPresets.vue b/src/components/ControlsMapper/ControlsMapperPresets.vue index 11b5b853..0c4b75f5 100644 --- a/src/components/ControlsMapper/ControlsMapperPresets.vue +++ b/src/components/ControlsMapper/ControlsMapperPresets.vue @@ -2,6 +2,7 @@ import { ref } from 'vue' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Dialog } from '@/components/ui/dialog' import { useControlsMapperStore } from '@/stores/controlsMapper' const store = useControlsMapperStore() @@ -10,6 +11,8 @@ const presetName = ref('') const importText = ref('') const exportText = ref('') const importError = ref('') +const exportOpen = ref(false) +const importOpen = ref(false) const save = () => { const name = presetName.value.trim() @@ -18,8 +21,15 @@ const save = () => { presetName.value = '' } -const exportCurrent = () => { +const openExport = () => { exportText.value = store.exportCurrent(presetName.value.trim() || 'preset') + exportOpen.value = true +} + +const openImport = () => { + importText.value = '' + importError.value = '' + importOpen.value = true } const importPreset = () => { @@ -27,6 +37,7 @@ const importPreset = () => { try { store.importPreset(importText.value) importText.value = '' + importOpen.value = false } catch { importError.value = 'Invalid preset JSON' } @@ -40,14 +51,6 @@ const importPreset = () => { -
    @@ -77,29 +80,35 @@ const importPreset = () => { variant="outline" size="sm" title="Export the current mapping as JSON" - @click="exportCurrent" + @click="openExport" > - Export JSON + Export + + +
+ + - + -
+ -

{{ importError }}

-
+ + @@ -138,13 +147,12 @@ const importPreset = () => { .mapper-presets__io { display: flex; - flex-direction: column; gap: var(--spacing-2); } .mapper-presets__json { width: 100%; - min-height: 4rem; + min-height: 8rem; border-radius: var(--radius-md); border: 1px solid var(--color-input); background-color: var(--color-background); diff --git a/src/components/ui/dialog/Dialog.vue b/src/components/ui/dialog/Dialog.vue new file mode 100644 index 00000000..bdc50867 --- /dev/null +++ b/src/components/ui/dialog/Dialog.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/src/components/ui/dialog/index.ts b/src/components/ui/dialog/index.ts new file mode 100644 index 00000000..d1df446a --- /dev/null +++ b/src/components/ui/dialog/index.ts @@ -0,0 +1 @@ +export { default as Dialog } from './Dialog.vue' diff --git a/src/stores/controlsMapper.test.ts b/src/stores/controlsMapper.test.ts index 3f095269..5b5452b4 100644 --- a/src/stores/controlsMapper.test.ts +++ b/src/stores/controlsMapper.test.ts @@ -48,8 +48,7 @@ describe('useControlsMapperStore', () => { store.bindTrigger('keyboard', 'ArrowUp', 'jump') store.savePreset('My layout') - store.resetMapping() - store.selectSkin('default') + store.resetToDefaults() store.applyPreset('My layout') expect(store.mapping.keyboard?.ArrowUp).toBe('jump') @@ -80,6 +79,18 @@ describe('useControlsMapperStore', () => { expect(() => store.importPreset('{ bad')).toThrow() }) + it('resetToDefaults restores the default mapping and skin', () => { + const store = useControlsMapperStore() + store.selectSkin('neon') + store.bindTrigger('keyboard', 'z', 'jump') + + store.resetToDefaults() + + expect(store.selectedSkin).toBe('default') + expect(store.mapping.keyboard?.z).toBeUndefined() + expect(Object.values(store.mapping.keyboard ?? {})).toContain('move-forward') + }) + it('records and clears live actions', () => { const store = useControlsMapperStore() store.recordAction('jump', ' ', 'keyboard') diff --git a/src/stores/controlsMapper.ts b/src/stores/controlsMapper.ts index aac3f49f..c1170e75 100644 --- a/src/stores/controlsMapper.ts +++ b/src/stores/controlsMapper.ts @@ -42,8 +42,9 @@ export const useControlsMapperStore = defineStore('controlsMapper', () => { mapping.value = removeBinding(mapping.value, device, trigger) } - const resetMapping = () => { + const resetToDefaults = () => { mapping.value = createDefaultMapping() + selectedSkin.value = getDefaultSkinId() } const selectSkin = (skin: ControlSkinId) => { @@ -107,7 +108,7 @@ export const useControlsMapperStore = defineStore('controlsMapper', () => { recentEvents, bindTrigger, clearTrigger, - resetMapping, + resetToDefaults, selectSkin, savePreset, applyPreset, diff --git a/src/views/Tools/ControlsMapper/ControlsMapper.vue b/src/views/Tests/ControlsMapper/ControlsMapper.vue similarity index 96% rename from src/views/Tools/ControlsMapper/ControlsMapper.vue rename to src/views/Tests/ControlsMapper/ControlsMapper.vue index 1f9db847..aa85e7f1 100644 --- a/src/views/Tools/ControlsMapper/ControlsMapper.vue +++ b/src/views/Tests/ControlsMapper/ControlsMapper.vue @@ -17,6 +17,8 @@ import ControlsMapper from '@/components/ControlsMapper/ControlsMapper.vue' diff --git a/src/components/ui/dialog/Dialog.vue b/src/components/ui/dialog/Dialog.vue deleted file mode 100644 index bdc50867..00000000 --- a/src/components/ui/dialog/Dialog.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - - - diff --git a/src/components/ui/dialog/index.ts b/src/components/ui/dialog/index.ts deleted file mode 100644 index d1df446a..00000000 --- a/src/components/ui/dialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as Dialog } from './Dialog.vue' From 8ca8f57c61535c1fe937d7f3a6c908984f2f4b92 Mon Sep 17 00:00:00 2001 From: Giuseppe Leo Date: Wed, 1 Jul 2026 17:44:15 +0200 Subject: [PATCH 04/29] feat(#1): restructure mapper into Bindings/Test tabs for mobile Two top-level tabs: Bindings (presets Add menu + reset + device bindings) and Test (style + live tester). Bindings rows wrap for narrow screens and fonts step up for readability on phones. Co-Authored-By: Claude Opus 4.8 --- .../ControlsMapper/ControlsMapper.vue | 88 +++++++------------ .../ControlsMapper/ControlsMapperBindings.vue | 15 ++-- .../ControlsMapper/ControlsMapperPresets.vue | 8 +- .../ControlsMapper/ControlsMapperStyle.vue | 2 +- .../ControlsMapper/ControlsMapperTester.vue | 8 +- 5 files changed, 51 insertions(+), 70 deletions(-) diff --git a/src/components/ControlsMapper/ControlsMapper.vue b/src/components/ControlsMapper/ControlsMapper.vue index edfdcca7..be584b30 100644 --- a/src/components/ControlsMapper/ControlsMapper.vue +++ b/src/components/ControlsMapper/ControlsMapper.vue @@ -1,8 +1,9 @@ diff --git a/src/components/ControlsMapper/ControlsMapperBindings.vue b/src/components/ControlsMapper/ControlsMapperBindings.vue index 518b6e6b..4e3f008b 100644 --- a/src/components/ControlsMapper/ControlsMapperBindings.vue +++ b/src/components/ControlsMapper/ControlsMapperBindings.vue @@ -69,7 +69,6 @@ const isListening = computed(() => listeningDevice.value !== null)