@@ -3,6 +3,7 @@ import InputService from "../src/engine/input/InputService.js";
33import { gamepadProfileInputNames } from "../src/engine/input/GamepadInputClassifier.js" ;
44import {
55 normalizeProfileInputMappings ,
6+ physicalInputSensitivityDescriptor ,
67 normalizedInputIsAnalog ,
78 physicalInputIsAnalog ,
89} from "../src/engine/input/NormalizedInputRegistry.js" ;
@@ -89,6 +90,31 @@ function labeledControl(labelText, control) {
8990 return label ;
9091}
9192
93+ function rangeValueLabel ( value , unit = "" ) {
94+ return `${ value } ${ unit } ` ;
95+ }
96+
97+ function createSliderControl ( { ariaLabel, dataName, defaultValue, index, max, min, step, unit, value } ) {
98+ const input = document . createElement ( "input" ) ;
99+ input . type = "range" ;
100+ input . min = String ( min ) ;
101+ input . max = String ( max ) ;
102+ input . step = String ( step ) ;
103+ input . value = String ( value ) ;
104+ input . dataset [ dataName ] = String ( index ) ;
105+ input . dataset . defaultValue = String ( defaultValue ) ;
106+ input . dataset . unit = unit ;
107+ input . setAttribute ( "aria-label" , ariaLabel ) ;
108+ const valueLabel = document . createElement ( "span" ) ;
109+ valueLabel . className = "status" ;
110+ valueLabel . dataset . sliderValueFor = dataName ;
111+ valueLabel . textContent = rangeValueLabel ( input . value , unit ) ;
112+ const wrapper = document . createElement ( "div" ) ;
113+ wrapper . className = "content-cluster" ;
114+ wrapper . append ( input , valueLabel ) ;
115+ return wrapper ;
116+ }
117+
92118export class AccountUserControlsPage {
93119 constructor ( root ) {
94120 this . root = root ;
@@ -102,6 +128,7 @@ export class AccountUserControlsPage {
102128 deviceStatus : root . querySelector ( "[data-account-user-controls-device-status]" ) ,
103129 list : root . querySelector ( "[data-account-user-controls-list]" ) ,
104130 refresh : root . querySelector ( "[data-account-user-controls-refresh]" ) ,
131+ saveAll : root . querySelector ( "[data-account-user-controls-save-all]" ) ,
105132 status : root . querySelector ( "[data-account-user-controls-status]" ) ,
106133 types : root . querySelector ( "[data-account-user-controls-types]" ) ,
107134 } ;
@@ -115,12 +142,15 @@ export class AccountUserControlsPage {
115142 this . renderProfiles ( ) ;
116143 this . elements . refresh ?. addEventListener ( "click" , ( ) => {
117144 this . renderDeviceSelect ( ) ;
118- this . setStatus ( "Device list refreshed." ) ;
145+ this . setStatus ( this . deviceRefreshMessage ( ) ) ;
119146 } ) ;
120147 this . elements . addProfile ?. addEventListener ( "click" , ( ) => this . addProfileForSelectedDevice ( ) ) ;
148+ this . elements . saveAll ?. addEventListener ( "click" , ( ) => this . saveCurrentState ( ) ) ;
121149 this . elements . deviceSelect ?. addEventListener ( "change" , ( ) => this . renderDeviceStatus ( ) ) ;
122150 this . elements . list ?. addEventListener ( "click" , ( event ) => this . handleListClick ( event ) ) ;
123151 this . elements . list ?. addEventListener ( "change" , ( event ) => this . handleListChange ( event ) ) ;
152+ this . elements . list ?. addEventListener ( "input" , ( event ) => this . handleListInput ( event ) ) ;
153+ this . elements . list ?. addEventListener ( "dblclick" , ( event ) => this . handleListDoubleClick ( event ) ) ;
124154 }
125155
126156 setStatus ( message ) {
@@ -165,6 +195,14 @@ export class AccountUserControlsPage {
165195 return [ keyboardMouse , ...gamepads ] ;
166196 }
167197
198+ deviceRefreshMessage ( ) {
199+ const gamepadCount = this . availableGamepads ( ) . length ;
200+ if ( gamepadCount > 0 ) {
201+ return `PASS: Device list refreshed. Keyboard/Mouse and ${ gamepadCount } game controller${ gamepadCount === 1 ? "" : "s" } available.` ;
202+ }
203+ return "PASS: Keyboard/Mouse available. To enumerate a game controller, connect it, press a button, allow browser gamepad access, then refresh devices." ;
204+ }
205+
168206 selectedDevice ( ) {
169207 const selectedValue = this . elements . deviceSelect ?. value || "" ;
170208 return this . deviceOptions ( ) . find ( ( device ) => device . value === selectedValue ) || null ;
@@ -246,7 +284,7 @@ export class AccountUserControlsPage {
246284 renderDeviceStatus ( ) {
247285 const device = this . selectedDevice ( ) ;
248286 if ( ! device ) {
249- this . setDeviceStatus ( "Choose a physical controller before creating a user control profile." ) ;
287+ this . setDeviceStatus ( this . deviceRefreshMessage ( ) ) ;
250288 return ;
251289 }
252290 this . setDeviceStatus ( `${ device . label } selected. Create a user control profile to map physical inputs to normalized controls.` ) ;
@@ -278,7 +316,7 @@ export class AccountUserControlsPage {
278316 if ( ! rows . length ) {
279317 const row = document . createElement ( "tr" ) ;
280318 const cell = document . createElement ( "td" ) ;
281- cell . colSpan = 6 ;
319+ cell . colSpan = 7 ;
282320 cell . textContent = "No account user control profiles saved yet." ;
283321 row . append ( cell ) ;
284322 rows . push ( row ) ;
@@ -311,6 +349,18 @@ export class AccountUserControlsPage {
311349 return `${ analogMappings . length } analog axes, ${ inverted } inverted` ;
312350 }
313351
352+ profileSensitivitySummary ( profile ) {
353+ const sensitiveInputs = profile . inputMappings . filter ( ( mapping ) => physicalInputSensitivityDescriptor ( mapping . physicalInput ) ) ;
354+ if ( ! sensitiveInputs . length ) {
355+ return "No sensitivity controls" ;
356+ }
357+ const adjusted = sensitiveInputs . filter ( ( mapping ) => {
358+ const descriptor = physicalInputSensitivityDescriptor ( mapping . physicalInput ) ;
359+ return descriptor && Number ( mapping . sensitivity ?? descriptor . defaultValue ) !== descriptor . defaultValue ;
360+ } ) . length ;
361+ return `${ sensitiveInputs . length } sensitivity controls, ${ adjusted } adjusted` ;
362+ }
363+
314364 renderProfileRow ( profile ) {
315365 const row = document . createElement ( "tr" ) ;
316366 row . dataset . accountUserControlsProfileRow = profile . id ;
@@ -320,6 +370,7 @@ export class AccountUserControlsPage {
320370 tableCell ( this . profileInputSummary ( profile ) ) ,
321371 tableCell ( this . profileAnalogSummary ( profile ) ) ,
322372 tableCell ( profile . inputMappings . some ( ( mapping ) => mapping . invert ) ? "Invert configured" : "No invert" ) ,
373+ tableCell ( this . profileSensitivitySummary ( profile ) ) ,
323374 ) ;
324375 const actions = document . createElement ( "td" ) ;
325376 const group = document . createElement ( "div" ) ;
@@ -382,6 +433,28 @@ export class AccountUserControlsPage {
382433 invert . dataset . accountUserControlsInvert = String ( index ) ;
383434 stack . append ( labeledControl ( "Deadzone" , deadzone ) , labeledControl ( "Invert" , invert ) ) ;
384435 }
436+ const sensitivity = physicalInputSensitivityDescriptor ( inputMapping . physicalInput ) ;
437+ if ( sensitivity ) {
438+ const value = Number . isFinite ( Number ( inputMapping . sensitivity ) )
439+ ? Number ( inputMapping . sensitivity )
440+ : sensitivity . defaultValue ;
441+ const slider = createSliderControl ( {
442+ ariaLabel : sensitivity . label ,
443+ dataName : "accountUserControlsSensitivity" ,
444+ defaultValue : sensitivity . defaultValue ,
445+ index,
446+ max : sensitivity . max ,
447+ min : sensitivity . min ,
448+ step : sensitivity . step ,
449+ unit : sensitivity . unit ,
450+ value,
451+ } ) ;
452+ stack . append ( labeledControl ( sensitivity . label , slider ) ) ;
453+ }
454+ const validation = document . createElement ( "p" ) ;
455+ validation . className = "status" ;
456+ validation . dataset . accountUserControlsInputValidation = String ( index ) ;
457+ stack . append ( validation ) ;
385458 wrapper . append ( label , stack ) ;
386459 return wrapper ;
387460 }
@@ -404,13 +477,14 @@ export class AccountUserControlsPage {
404477 tableCell ( this . profileInputSummary ( profile ) ) ,
405478 tableCell ( this . profileAnalogSummary ( profile ) ) ,
406479 tableCell ( profile . inputMappings . some ( ( mapping ) => mapping . invert ) ? "Invert configured" : "No invert" ) ,
480+ tableCell ( this . profileSensitivitySummary ( profile ) ) ,
407481 actions ,
408482 ) ;
409483
410484 const detailsRow = document . createElement ( "tr" ) ;
411485 detailsRow . dataset . accountUserControlsEditingDetails = "true" ;
412486 const detailsCell = document . createElement ( "td" ) ;
413- detailsCell . colSpan = 6 ;
487+ detailsCell . colSpan = 7 ;
414488 const grid = document . createElement ( "div" ) ;
415489 grid . className = "content-grid content-grid--three" ;
416490 grid . append ( ...profile . inputMappings . map ( ( inputMapping , index ) => this . inputControl ( profile , inputMapping , index ) ) ) ;
@@ -447,6 +521,7 @@ export class AccountUserControlsPage {
447521 const positiveSelect = details ?. querySelector ( `[data-account-user-controls-input-positive="${ index } "]` ) ;
448522 const deadzoneInput = details ?. querySelector ( `[data-account-user-controls-deadzone="${ index } "]` ) ;
449523 const invertInput = details ?. querySelector ( `[data-account-user-controls-invert="${ index } "]` ) ;
524+ const sensitivityInput = details ?. querySelector ( `[data-account-user-controls-sensitivity="${ index } "]` ) ;
450525 const negativeNormalizedInput = normalizeText ( negativeSelect ?. value ?? mapping . negativeNormalizedInput ) ;
451526 const positiveNormalizedInput = normalizeText ( positiveSelect ?. value ?? mapping . positiveNormalizedInput ) ;
452527 return {
@@ -456,6 +531,7 @@ export class AccountUserControlsPage {
456531 normalizedInput : positiveNormalizedInput || normalizeText ( normalizedSelect ?. value ?? mapping . normalizedInput ) || negativeNormalizedInput ,
457532 physicalInput : mapping . physicalInput ,
458533 positiveNormalizedInput,
534+ sensitivity : sensitivityInput ? Number ( sensitivityInput . value ) : mapping . sensitivity ,
459535 } ;
460536 } ) ;
461537 return this . normalizeProfile ( {
@@ -465,17 +541,103 @@ export class AccountUserControlsPage {
465541 } ) ;
466542 }
467543
544+ mappingHasNormalizedControl ( mapping ) {
545+ return Boolean (
546+ normalizeText ( mapping . normalizedInput )
547+ || normalizeText ( mapping . negativeNormalizedInput )
548+ || normalizeText ( mapping . positiveNormalizedInput ) ,
549+ ) ;
550+ }
551+
552+ validateProfile ( profile ) {
553+ if ( ! normalizeText ( profile . controllerId ) || ! normalizeText ( profile . mappingProfile ) ) {
554+ return {
555+ invalidIndexes : [ ] ,
556+ message : "Choose a physical controller before saving." ,
557+ ok : false ,
558+ } ;
559+ }
560+ const invalidIndexes = [ ] ;
561+ let assignedCount = 0 ;
562+ profile . inputMappings . forEach ( ( mapping , index ) => {
563+ if ( this . mappingHasNormalizedControl ( mapping ) ) {
564+ assignedCount += 1 ;
565+ }
566+ const deadzone = Number ( mapping . deadzone ) ;
567+ const descriptor = physicalInputSensitivityDescriptor ( mapping . physicalInput ) ;
568+ const sensitivity = Number ( mapping . sensitivity ?? descriptor ?. defaultValue ) ;
569+ const invalidDeadzone = Number . isFinite ( deadzone ) && ( deadzone < 0 || deadzone > 1 ) ;
570+ const invalidSensitivity = descriptor && ( ! Number . isFinite ( sensitivity ) || sensitivity < descriptor . min || sensitivity > descriptor . max ) ;
571+ if ( invalidDeadzone || invalidSensitivity ) {
572+ invalidIndexes . push ( index ) ;
573+ }
574+ } ) ;
575+ if ( ! assignedCount ) {
576+ return {
577+ invalidIndexes : profile . inputMappings . map ( ( _ , index ) => index ) ,
578+ message : "Assign at least one physical input to a normalized control before saving." ,
579+ ok : false ,
580+ } ;
581+ }
582+ if ( invalidIndexes . length ) {
583+ return {
584+ invalidIndexes,
585+ message : "Resolve highlighted input rows before saving." ,
586+ ok : false ,
587+ } ;
588+ }
589+ return {
590+ invalidIndexes : [ ] ,
591+ message : "" ,
592+ ok : true ,
593+ } ;
594+ }
595+
596+ renderInputValidation ( validation ) {
597+ const invalidIndexes = new Set ( validation . invalidIndexes || [ ] ) ;
598+ this . elements . list ?. querySelectorAll ( "[data-account-user-controls-input-validation]" ) . forEach ( ( status ) => {
599+ const index = Number ( status . dataset . accountUserControlsInputValidation ) ;
600+ status . textContent = invalidIndexes . has ( index )
601+ ? "Needs normalized control, valid deadzone, and valid sensitivity."
602+ : "" ;
603+ } ) ;
604+ }
605+
468606 saveEditingProfile ( ) {
469607 const profile = this . profileFromEditingRow ( ) ;
608+ const validation = this . validateProfile ( profile ) ;
609+ this . renderInputValidation ( validation ) ;
610+ if ( ! validation . ok ) {
611+ this . setStatus ( `FAIL: ${ validation . message } ` ) ;
612+ return ;
613+ }
470614 const nextProfiles = this . editingProfile ?. id && this . profiles . some ( ( candidate ) => candidate . id === this . editingProfile . id )
471615 ? this . profiles . map ( ( candidate ) => ( candidate . id === this . editingProfile . id ? profile : candidate ) )
472616 : [ profile , ...this . profiles ] ;
473617 if ( ! this . saveProfiles ( nextProfiles ) ) {
618+ this . setStatus ( "FAIL: Account user controls could not reach the shared DB adapter." ) ;
474619 return ;
475620 }
476621 this . editingProfile = null ;
477622 this . renderProfiles ( ) ;
478- this . setStatus ( `Saved ${ profile . mappingProfile } .` ) ;
623+ this . setStatus ( `PASS: Saved ${ profile . mappingProfile } .` ) ;
624+ }
625+
626+ saveCurrentState ( ) {
627+ if ( this . editingProfile ) {
628+ this . saveEditingProfile ( ) ;
629+ return ;
630+ }
631+ if ( ! this . profiles . length ) {
632+ this . setStatus ( "FAIL: Create a user control profile before saving." ) ;
633+ return ;
634+ }
635+ if ( ! this . saveProfiles ( this . profiles ) ) {
636+ this . setStatus ( "FAIL: Account user controls could not reach the shared DB adapter." ) ;
637+ return ;
638+ }
639+ this . renderProfiles ( ) ;
640+ this . setStatus ( "PASS: Saved account user controls." ) ;
479641 }
480642
481643 handleListClick ( event ) {
@@ -509,4 +671,28 @@ export class AccountUserControlsPage {
509671 handleListChange ( ) {
510672 this . setStatus ( "Unsaved user control profile changes." ) ;
511673 }
674+
675+ handleListInput ( event ) {
676+ const target = event . target instanceof HTMLInputElement ? event . target : null ;
677+ if ( target ?. matches ( "[data-account-user-controls-sensitivity]" ) ) {
678+ const valueLabel = target . parentElement ?. querySelector ( "[data-slider-value-for='accountUserControlsSensitivity']" ) ;
679+ if ( valueLabel ) {
680+ valueLabel . textContent = rangeValueLabel ( target . value , target . dataset . unit || "" ) ;
681+ }
682+ }
683+ this . setStatus ( "Unsaved user control profile changes." ) ;
684+ }
685+
686+ handleListDoubleClick ( event ) {
687+ const target = event . target instanceof HTMLInputElement ? event . target : null ;
688+ if ( ! target ?. matches ( "[data-account-user-controls-sensitivity]" ) ) {
689+ return ;
690+ }
691+ target . value = target . dataset . defaultValue || "100" ;
692+ const valueLabel = target . parentElement ?. querySelector ( "[data-slider-value-for='accountUserControlsSensitivity']" ) ;
693+ if ( valueLabel ) {
694+ valueLabel . textContent = rangeValueLabel ( target . value , target . dataset . unit || "" ) ;
695+ }
696+ target . dispatchEvent ( new Event ( "input" , { bubbles : true } ) ) ;
697+ }
512698}
0 commit comments