From d25096600e53bcc62ff23f75dde05d91d0690e4a Mon Sep 17 00:00:00 2001 From: z Date: Sat, 3 Jan 2026 18:00:47 +1300 Subject: [PATCH 1/7] virtual gamepad editting full implementation --- data/emulator.css | 173 +++++++ data/loader.js | 1 + data/localization/en.json | 7 +- data/src/emulator.js | 439 ++++++++++++++---- data/src/virtualGamepadEditor.js | 758 +++++++++++++++++++++++++++++++ 5 files changed, 1279 insertions(+), 99 deletions(-) create mode 100644 data/src/virtualGamepadEditor.js diff --git a/data/emulator.css b/data/emulator.css index 873547b..41d2843 100644 --- a/data/emulator.css +++ b/data/emulator.css @@ -1650,3 +1650,176 @@ display: flex; align-items: center; } + +/* Virtual Gamepad Edit Mode */ +.ejs_virtualGamepad_edit_container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10000; + background: rgba(0, 0, 0, 0.5); + pointer-events: none; +} + +/* Enable pointer events for toolbar */ +.ejs_virtualGamepad_edit_toolbar { + pointer-events: auto; +} + +/* Style for original elements when in edit mode */ +.ejs_virtualGamepad_edit_mode .ejs_virtualGamepad_button { + outline: 2px dashed rgba(255, 255, 255, 0.8); + outline-offset: 2px; + cursor: move; + touch-action: none; + pointer-events: none; +} + +/* Dpad container styling in edit mode */ +.ejs_virtualGamepad_edit_mode .ejs_dpad_main { + pointer-events: none; +} + +.ejs_virtualGamepad_edit_mode [class*="b_dpad"], +.ejs_virtualGamepad_edit_mode [class*="b_stick"] { + outline: 2px dashed rgba(255, 255, 255, 0.8); + outline-offset: 2px; + cursor: move; + touch-action: none; + pointer-events: none; +} + +.ejs_virtualGamepad_edit_toolbar { + position: absolute; + top: 0; + left: 0; + width: 100%; + background: linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0)); + padding: 5px 5px 15px; + display: flex; + justify-content: center; + gap: 4px; + z-index: 10010; + pointer-events: none; +} + +.ejs_virtualGamepad_edit_toolbar button { + background-color: rgba(var(--ejs-primary-color), 0.9); + border: none; + border-radius: 3px; + padding: 4px 6px; + color: #fff; + cursor: pointer; + touch-action: manipulation; + user-select: none; + display: flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + pointer-events: auto; + z-index: 10005; + position: relative; +} + +.ejs_virtualGamepad_edit_toolbar button:hover { + opacity: 0.85; +} + +.ejs_virtualGamepad_edit_toolbar button:active { + opacity: 0.7; +} + +.ejs_virtualGamepad_edit_toolbar button.ejs_edit_btn_close { + background-color: #28a745; +} + +.ejs_virtualGamepad_edit_toolbar button.ejs_edit_btn_default { + background-color: #e6a817; +} + +.ejs_virtualGamepad_edit_toolbar button.ejs_edit_btn_clear { + background-color: #dc3545; +} + +.ejs_virtualGamepad_edit_toolbar button.ejs_edit_btn_undo, +.ejs_virtualGamepad_edit_toolbar button.ejs_edit_btn_redo { + background-color: #555; +} + +.ejs_virtualGamepad_edit_toolbar button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + + +.ejs_virtualGamepad_resize_handle { + position: absolute; + width: 16px; + height: 16px; + background: #007bff; + border: 2px solid #fff; + border-radius: 50%; + z-index: 10003; + cursor: se-resize; + touch-action: none; + pointer-events: auto; +} + +.ejs_virtualGamepad_resize_handle.ejs_resize_se { + bottom: -8px; + right: -8px; +} + +/* Overlay container for edit mode */ +.ejs_virtualGamepad_overlay_container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 10002; +} + +/* Individual overlay element (captures touch events for dragging) */ +.ejs_edit_overlay_element { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + pointer-events: auto; + touch-action: none; + cursor: move; + box-sizing: border-box; + outline: 2px dashed rgba(255, 255, 255, 0.9); + outline-offset: 2px; + opacity: 0.9; + user-select: none; +} + +/* Zone (joystick) overlay styling */ +.ejs_edit_overlay_element[data-type="zone"] { + border-radius: 50%; + background: radial-gradient(circle, rgba(100,100,100,0.8) 0%, rgba(60,60,60,0.9) 100%); + border: 3px solid rgba(255,255,255,0.5); + box-shadow: 0 0 10px rgba(0,0,0,0.5), inset 0 0 20px rgba(0,0,0,0.3); +} + +.ejs_edit_overlay_element span { + pointer-events: none; + user-select: none; +} + +/* Highlight when dragging */ +.ejs_edit_overlay_element.ejs_dragging { + outline-color: #007bff !important; + background: rgba(0, 123, 255, 0.3) !important; +} + +/* Fade original gamepad during editing */ +.ejs_virtualGamepad_edit_mode_faded { + opacity: 0.3; +} diff --git a/data/loader.js b/data/loader.js index 2baadfe..26e41f8 100644 --- a/data/loader.js +++ b/data/loader.js @@ -1,5 +1,6 @@ (async function() { const scripts = [ + "virtualGamepadEditor.js", "emulator.js", "nipplejs.js", "shaders.js", diff --git a/data/localization/en.json b/data/localization/en.json index aeee06e..b456370 100644 --- a/data/localization/en.json +++ b/data/localization/en.json @@ -335,5 +335,10 @@ "System Save interval": "System Save interval", "Menu Bar Button": "Menu Bar Button", "visible": "visible", - "hidden": "hidden" + "hidden": "hidden", + "EDIT_VIRTUAL_GAMEPAD": "Edit Virtual Gamepad", + "UNDO_VIRTUAL_GAMEPAD": "Undo", + "REDO_VIRTUAL_GAMEPAD": "Redo", + "RESET_TO_DEFAULT_VIRTUAL_GAMEPAD": "Reset to Default", + "SAVE_AND_CLOSE_VIRTUAL_GAMEPAD": "Save & Close" } diff --git a/data/src/emulator.js b/data/src/emulator.js index f78cf1c..9867465 100644 --- a/data/src/emulator.js +++ b/data/src/emulator.js @@ -89,6 +89,12 @@ class EmulatorJS { data[i].elem.removeEventListener(data[i].listener, data[i].cb); } } + findElementId(classList, fallback) { + for (const cls of classList) { + if (cls.startsWith("b_")) return cls; + } + return fallback; + } downloadFile(path, progressCB, notWithPath, opts) { return new Promise(async cb => { const data = this.toData(path); //check other data types @@ -1070,6 +1076,10 @@ class EmulatorJS { } this.setupSettingsMenu(); this.loadSettings(); + // Apply virtual gamepad layout after settings are loaded + if (this.virtualGamepadLayout && this.virtualGamepadDefaults) { + this.applyVirtualGamepadLayout(); + } this.updateCheatUI(); this.updateGamepadLabels(); if (!this.muted) this.setVolume(this.volume); @@ -3869,6 +3879,9 @@ class EmulatorJS { const blockCSS = "height:31px;text-align:center;border:1px solid #ccc;border-radius:5px;line-height:31px;"; const controlSchemeCls = `cs_${this.getControlScheme()}`.split(/\s/g).join("_"); + // Store default positions for each element + this.virtualGamepadDefaults = {}; + for (let i = 0; i < info.length; i++) { if (info[i].type !== "button") continue; if (leftHandedMode && ["left", "right"].includes(info[i].location)) { @@ -3906,13 +3919,25 @@ class EmulatorJS { button.style = style; button.innerText = info[i].text; button.classList.add("ejs_virtualGamepad_button", controlSchemeCls); - if (info[i].id) { - button.classList.add(`b_${info[i].id}`); - } + const buttonId = info[i].id + ? `b_${info[i].id}` + : `b_${(info[i].text || info[i].input_value || `button_${i}`).toString().replace(/\s+/g, '_')}`; + button.classList.add(buttonId); elems[info[i].location].appendChild(button); + // Store default position (styles and absolute position after render) + this.virtualGamepadDefaults[buttonId] = { + left: button.style.left, + right: button.style.right, + top: button.style.top, + transform: "", + width: button.style.width, + height: button.style.height, + element: button // Store reference to calculate absolute pos later + }; const value = info[i].input_new_cores || info[i].input_value; let downValue = info[i].joystickInput === true ? 0x7fff : 1; this.addEventListener(button, "touchstart touchend touchcancel", (e) => { + if (this.virtualGamepadEditMode) return; e.preventDefault(); if (e.type === "touchend" || e.type === "touchcancel") { e.target.classList.remove("ejs_virtualGamepad_button_down"); @@ -3947,6 +3972,7 @@ class EmulatorJS { dpadMain.appendChild(horizontal); const updateCb = (e) => { + if (this.virtualGamepadEditMode) return; e.preventDefault(); const touch = e.targetTouches[0]; if (!touch) return; @@ -3994,6 +4020,7 @@ class EmulatorJS { callback(up, down, left, right); } const cancelCb = (e) => { + if (this.virtualGamepadEditMode) return; e.preventDefault(); dpadMain.classList.remove("ejs_dpad_up_pressed"); dpadMain.classList.remove("ejs_dpad_down_pressed"); @@ -4034,11 +4061,25 @@ class EmulatorJS { style += "top:" + dpad.top + ";"; } elem.classList.add(controlSchemeCls); - if (dpad.id) { - elem.classList.add(`b_${dpad.id}`); - } + const dpadId = dpad.id + ? `b_${dpad.id}` + : `b_dpad_${dpad.location || index}`; + elem.classList.add(dpadId); elem.style = style; elems[dpad.location].appendChild(elem); + + // Store default position for dpad container + this.virtualGamepadDefaults[dpadId] = { + left: elem.style.left, + right: elem.style.right, + top: elem.style.top, + transform: "", + width: elem.style.width, + height: elem.style.height, + container: elem, + // measureElement will be set after dpad is created + }; + createDPad({ container: elem, event: (up, down, left, right) => { @@ -4054,6 +4095,12 @@ class EmulatorJS { this.gameManager.simulateInput(0, dpad.inputValues[3], right); } }); + + // Store reference to inner element for measuring + const dpadMain = elem.querySelector(".ejs_dpad_main"); + if (dpadMain) { + this.virtualGamepadDefaults[dpadId].element = dpadMain; + } }) info.forEach((zone, index) => { @@ -4070,12 +4117,14 @@ class EmulatorJS { } const elem = this.createElement("div"); this.addEventListener(elem, "touchstart touchmove touchend touchcancel", (e) => { + if (this.virtualGamepadEditMode) return; e.preventDefault(); }); elem.classList.add(controlSchemeCls); - if (zone.id) { - elem.classList.add(`b_${zone.id}`); - } + const zoneId = zone.id + ? `b_${zone.id}` + : `b_zone_${zone.location || index}`; + elem.classList.add(zoneId); elems[zone.location].appendChild(elem); const zoneObj = nipplejs.create({ "zone": elem, @@ -4086,95 +4135,28 @@ class EmulatorJS { }, "color": zone.color || "red" }); - zoneObj.on("end", () => { - this.gameManager.simulateInput(0, zone.inputValues[0], 0); - this.gameManager.simulateInput(0, zone.inputValues[1], 0); - this.gameManager.simulateInput(0, zone.inputValues[2], 0); - this.gameManager.simulateInput(0, zone.inputValues[3], 0); - }); - zoneObj.on("move", (e, info) => { - const degree = info.angle.degree; - const distance = info.distance; - if (zone.joystickInput === true) { - let x = 0, y = 0; - if (degree > 0 && degree <= 45) { - x = distance / 50; - y = -0.022222222222222223 * degree * distance / 50; - } - if (degree > 45 && degree <= 90) { - x = 0.022222222222222223 * (90 - degree) * distance / 50; - y = -distance / 50; - } - if (degree > 90 && degree <= 135) { - x = 0.022222222222222223 * (90 - degree) * distance / 50; - y = -distance / 50; - } - if (degree > 135 && degree <= 180) { - x = -distance / 50; - y = -0.022222222222222223 * (180 - degree) * distance / 50; - } - if (degree > 135 && degree <= 225) { - x = -distance / 50; - y = -0.022222222222222223 * (180 - degree) * distance / 50; - } - if (degree > 225 && degree <= 270) { - x = -0.022222222222222223 * (270 - degree) * distance / 50; - y = distance / 50; - } - if (degree > 270 && degree <= 315) { - x = -0.022222222222222223 * (270 - degree) * distance / 50; - y = distance / 50; - } - if (degree > 315 && degree <= 359.9) { - x = distance / 50; - y = 0.022222222222222223 * (360 - degree) * distance / 50; - } - if (x > 0) { - this.gameManager.simulateInput(0, zone.inputValues[0], 0x7fff * x); - this.gameManager.simulateInput(0, zone.inputValues[1], 0); - } else { - this.gameManager.simulateInput(0, zone.inputValues[1], 0x7fff * -x); - this.gameManager.simulateInput(0, zone.inputValues[0], 0); - } - if (y > 0) { - this.gameManager.simulateInput(0, zone.inputValues[2], 0x7fff * y); - this.gameManager.simulateInput(0, zone.inputValues[3], 0); - } else { - this.gameManager.simulateInput(0, zone.inputValues[3], 0x7fff * -y); - this.gameManager.simulateInput(0, zone.inputValues[2], 0); - } + this.bindZoneEventHandlers(zoneObj, zone); - } else { - if (degree >= 30 && degree < 150) { - this.gameManager.simulateInput(0, zone.inputValues[0], 1); - } else { - window.setTimeout(() => { - this.gameManager.simulateInput(0, zone.inputValues[0], 0); - }, 30); - } - if (degree >= 210 && degree < 330) { - this.gameManager.simulateInput(0, zone.inputValues[1], 1); - } else { - window.setTimeout(() => { - this.gameManager.simulateInput(0, zone.inputValues[1], 0); - }, 30); - } - if (degree >= 120 && degree < 240) { - this.gameManager.simulateInput(0, zone.inputValues[2], 1); - } else { - window.setTimeout(() => { - this.gameManager.simulateInput(0, zone.inputValues[2], 0); - }, 30); - } - if (degree >= 300 || degree >= 0 && degree < 60) { - this.gameManager.simulateInput(0, zone.inputValues[3], 1); - } else { - window.setTimeout(() => { - this.gameManager.simulateInput(0, zone.inputValues[3], 0); - }, 30); - } - } - }); + // Store default position and nipplejs config for zone + const nipple = elem.querySelector(".nipple"); + const back = nipple ? nipple.querySelector(".back") : null; + // nipplejs default size is 100 + const originalSize = zone.size || 100; + this.virtualGamepadDefaults[zoneId] = { + // Original nipplejs position config + originalLeft: zone.left, + originalTop: zone.top, + originalSize: originalSize, + // Zone container reference + container: elem, + nippleElement: nipple, + // nipplejs manager for recreation + nippleManager: zoneObj, + // Zone config for recreation + zoneConfig: zone, + // Use .back (outer circle) for measuring if available + element: back || nipple + }; }) if (this.touch || this.hasTouchScreen) { @@ -4207,6 +4189,248 @@ class EmulatorJS { this.virtualGamepad.style.display = "none"; } + /** Enter virtual gamepad edit mode using the external editor class */ + enterVirtualGamepadEditMode() { + if (!this.gamepadEditor && window.VirtualGamepadEditor) { + this.gamepadEditor = new window.VirtualGamepadEditor(this); + } + if (!this.gamepadEditor) return; + this.gamepadEditor.enter(); + } + /** Check if virtual gamepad edit mode is active */ + get isVirtualGamepadEditMode() { + return this.gamepadEditor?.isActive || false; + } + saveVirtualGamepadLayout(elements) { + const layout = {}; + const editElements = elements || []; + + editElements.forEach(item => { + // OverlayElement from external class has .original and .id/.type properties + const element = item.original || item.element; + const id = item.id; + const type = item.type; + + if (type === "zone") { + // For zones, get the nipple position and size from virtualGamepadDefaults + const defaults = this.virtualGamepadDefaults[id]; + const nipple = element.querySelector(".nipple"); + if (nipple && defaults) { + layout[id] = { + left: nipple.style.left, + top: nipple.style.top, + size: defaults.currentSize || defaults.originalSize || 100 + }; + } + } else { + layout[id] = { + left: element.style.left, + top: element.style.top, + right: element.style.right, + transform: element.style.transform, + transformOrigin: element.style.transformOrigin + }; + } + }); + + this.virtualGamepadLayout = layout; + this.saveSettings(); + } + bindZoneEventHandlers(zoneObj, zone) { + const endHandler = () => { + if (this.virtualGamepadEditMode) return; + this.gameManager.simulateInput(0, zone.inputValues[0], 0); + this.gameManager.simulateInput(0, zone.inputValues[1], 0); + this.gameManager.simulateInput(0, zone.inputValues[2], 0); + this.gameManager.simulateInput(0, zone.inputValues[3], 0); + }; + const moveHandler = (e, info) => { + if (this.virtualGamepadEditMode) return; + this.handleZoneMove(zone, info.angle.degree, info.distance); + }; + + zoneObj.on("end", endHandler); + zoneObj.on("move", moveHandler); + + // Store references for cleanup + zoneObj._ejsHandlers = { end: endHandler, move: moveHandler }; + } + unbindZoneEventHandlers(zoneObj) { + if (zoneObj._ejsHandlers) { + zoneObj.off("end", zoneObj._ejsHandlers.end); + zoneObj.off("move", zoneObj._ejsHandlers.move); + delete zoneObj._ejsHandlers; + } + } + handleZoneMove(zone, degree, distance) { + if (zone.joystickInput === true) { + let x = 0, y = 0; + if (degree > 0 && degree <= 45) { + x = distance / 50; + y = -0.022222222222222223 * degree * distance / 50; + } + if (degree > 45 && degree <= 90) { + x = 0.022222222222222223 * (90 - degree) * distance / 50; + y = -distance / 50; + } + if (degree > 90 && degree <= 135) { + x = 0.022222222222222223 * (90 - degree) * distance / 50; + y = -distance / 50; + } + if (degree > 135 && degree <= 180) { + x = -distance / 50; + y = -0.022222222222222223 * (180 - degree) * distance / 50; + } + if (degree > 135 && degree <= 225) { + x = -distance / 50; + y = -0.022222222222222223 * (180 - degree) * distance / 50; + } + if (degree > 225 && degree <= 270) { + x = -0.022222222222222223 * (270 - degree) * distance / 50; + y = distance / 50; + } + if (degree > 270 && degree <= 315) { + x = -0.022222222222222223 * (270 - degree) * distance / 50; + y = distance / 50; + } + if (degree > 315 && degree <= 359.9) { + x = distance / 50; + y = 0.022222222222222223 * (360 - degree) * distance / 50; + } + if (x > 0) { + this.gameManager.simulateInput(0, zone.inputValues[0], 0x7fff * x); + this.gameManager.simulateInput(0, zone.inputValues[1], 0); + } else { + this.gameManager.simulateInput(0, zone.inputValues[1], 0x7fff * -x); + this.gameManager.simulateInput(0, zone.inputValues[0], 0); + } + if (y > 0) { + this.gameManager.simulateInput(0, zone.inputValues[2], 0x7fff * y); + this.gameManager.simulateInput(0, zone.inputValues[3], 0); + } else { + this.gameManager.simulateInput(0, zone.inputValues[3], 0x7fff * -y); + this.gameManager.simulateInput(0, zone.inputValues[2], 0); + } + } else { + if (degree >= 30 && degree < 150) { + this.gameManager.simulateInput(0, zone.inputValues[0], 1); + } else { + window.setTimeout(() => { + this.gameManager.simulateInput(0, zone.inputValues[0], 0); + }, 30); + } + if (degree >= 210 && degree < 330) { + this.gameManager.simulateInput(0, zone.inputValues[1], 1); + } else { + window.setTimeout(() => { + this.gameManager.simulateInput(0, zone.inputValues[1], 0); + }, 30); + } + if (degree >= 120 && degree < 240) { + this.gameManager.simulateInput(0, zone.inputValues[2], 1); + } else { + window.setTimeout(() => { + this.gameManager.simulateInput(0, zone.inputValues[2], 0); + }, 30); + } + if (degree >= 300 || degree >= 0 && degree < 60) { + this.gameManager.simulateInput(0, zone.inputValues[3], 1); + } else { + window.setTimeout(() => { + this.gameManager.simulateInput(0, zone.inputValues[3], 0); + }, 30); + } + } + } + measureVirtualGamepadDefaults() { + if (this._virtualGamepadMeasured) return; + const parentRect = this.elements.parent.getBoundingClientRect(); + for (const id in this.virtualGamepadDefaults) { + const def = this.virtualGamepadDefaults[id]; + if (def.element) { + const rect = def.element.getBoundingClientRect(); + def.absoluteLeft = rect.left - parentRect.left; + def.absoluteTop = rect.top - parentRect.top; + def.absoluteWidth = rect.width; + def.absoluteHeight = rect.height; + } + } + this._virtualGamepadMeasured = true; + } + applyVirtualGamepadLayout() { + if (!this.virtualGamepadLayout) return; + + const applyToElement = (element, id) => { + if (this.virtualGamepadLayout[id]) { + const saved = this.virtualGamepadLayout[id]; + if (saved.left) element.style.left = saved.left; + if (saved.top) element.style.top = saved.top; + if (saved.right !== undefined) element.style.right = saved.right; + if (saved.transform) { + element.style.transform = saved.transform; + element.style.transformOrigin = saved.transformOrigin || "top left"; + } + } + }; + + const buttons = this.virtualGamepad.querySelectorAll(".ejs_virtualGamepad_button"); + const dpads = this.virtualGamepad.querySelectorAll(".ejs_dpad_main"); + const nipples = this.virtualGamepad.querySelectorAll(".nipple"); + + buttons.forEach((btn, index) => { + const id = this.findElementId(btn.classList, `b_button_${index}`); + applyToElement(btn, id); + }); + + dpads.forEach((dpad, index) => { + const dpadContainer = dpad.parentElement; + if (!dpadContainer) return; + const id = this.findElementId(dpadContainer.classList, `b_dpad_${index}`); + applyToElement(dpadContainer, id); + }); + + // Apply to zones (joysticks) - recreate nipplejs with saved position and size + nipples.forEach((nipple, index) => { + const zone = nipple.parentElement; + if (!zone) return; + const id = this.findElementId(zone.classList, `b_zone_${index}`); + + const saved = this.virtualGamepadLayout[id]; + const defaults = this.virtualGamepadDefaults[id]; + if (!saved || !defaults || !defaults.nippleManager || !defaults.zoneConfig) return; + + // Destroy and recreate nipplejs with saved position and size + this.unbindZoneEventHandlers(defaults.nippleManager); + // Remove existing nipple DOM elements before destroying to prevent memory leaks + const existingNipple = zone.querySelector(".nipple"); + if (existingNipple) { + existingNipple.remove(); + } + defaults.nippleManager.destroy(); + + const zoneConfig = defaults.zoneConfig; + const savedSize = saved.size || defaults.originalSize || 100; + const newZoneObj = nipplejs.create({ + "zone": zone, + "mode": "static", + "position": { + "left": saved.left || defaults.originalLeft, + "top": saved.top || defaults.originalTop + }, + "size": savedSize, + "color": zoneConfig.color || "red" + }); + + this.bindZoneEventHandlers(newZoneObj, zoneConfig); + + // Update stored references + defaults.nippleManager = newZoneObj; + defaults.currentSize = savedSize; + defaults.nippleElement = zone.querySelector(".nipple"); + const newBack = defaults.nippleElement ? defaults.nippleElement.querySelector(".back") : null; + defaults.element = newBack || defaults.nippleElement; + }); + } handleResize() { if (this.virtualGamepad) { if (this.virtualGamepad.style.display === "none") { @@ -4245,7 +4469,8 @@ class EmulatorJS { const coreSpecific = { controlSettings: this.controls, settings: this.settings, - cheats: this.cheats + cheats: this.cheats, + virtualGamepadLayout: this.virtualGamepadLayout } const ejs_settings = { volume: this.volume, @@ -4345,6 +4570,10 @@ class EmulatorJS { if (includes) continue; this.cheats.push(cheat); } + // Load virtual gamepad layout + if (coreSpecific.virtualGamepadLayout) { + this.virtualGamepadLayout = coreSpecific.virtualGamepadLayout; + } } catch(e) { console.warn("Could not load previous settings", e); @@ -5134,6 +5363,20 @@ class EmulatorJS { "enabled": this.localization("Enabled"), "disabled": this.localization("Disabled") }, "disabled", virtualGamepad, true); + + // Add Edit Virtual Gamepad button + const editVirtualGamepadRow = this.createElement("div"); + editVirtualGamepadRow.classList.add("ejs_settings_main_bar"); + const editVirtualGamepadSpan = this.createElement("span"); + editVirtualGamepadSpan.innerText = this.localization("EDIT_VIRTUAL_GAMEPAD"); + editVirtualGamepadRow.appendChild(editVirtualGamepadSpan); + virtualGamepad.appendChild(editVirtualGamepadRow); + this.addEventListener(editVirtualGamepadRow, "click", () => { + this.settingsMenu.style.display = "none"; + this.settingsMenuOpen = false; + this.enterVirtualGamepadEditMode(); + }); + checkForEmptyMenu(virtualGamepad); } diff --git a/data/src/virtualGamepadEditor.js b/data/src/virtualGamepadEditor.js new file mode 100644 index 0000000..ce1fe8e --- /dev/null +++ b/data/src/virtualGamepadEditor.js @@ -0,0 +1,758 @@ +/** + * HistoryManager - Command pattern for undo/redo operations + */ +class HistoryManager { + constructor(onUpdate) { + this.undoStack = []; + this.redoStack = []; + this.onUpdate = onUpdate; + } + + push(action) { + // action = { undo: Function, redo: Function } + this.undoStack.push(action); + this.redoStack = []; + this.onUpdate(); + } + + undo() { + const action = this.undoStack.pop(); + if (action) { + action.undo(); + this.redoStack.push(action); + this.onUpdate(); + } + } + + redo() { + const action = this.redoStack.pop(); + if (action) { + action.redo(); + this.undoStack.push(action); + this.onUpdate(); + } + } + + clear() { + this.undoStack = []; + this.redoStack = []; + this.onUpdate(); + } + + canUndo() { return this.undoStack.length > 0; } + canRedo() { return this.redoStack.length > 0; } +} + +/** + * OverlayElement - Encapsulates overlay creation and state for a single editable element + */ +class OverlayElement { + constructor(editor, original, id, type, rect, parentRect, defaults) { + this.editor = editor; + this.original = original; + this.id = id; + this.type = type; + this.defaults = defaults || {}; + this.cleanupFns = []; + this.overlay = this.createOverlay(rect, parentRect); + this.startState = this.captureState(); + } + + createOverlay(rect, parentRect) { + const emu = this.editor.emu; + const overlay = emu.createElement("div"); + overlay.classList.add("ejs_edit_overlay_element"); + overlay.dataset.id = this.id; + overlay.dataset.type = this.type; + + // Position exactly where the original is + const left = rect.left - parentRect.left; + const top = rect.top - parentRect.top; + + // Dynamic position/size (must be inline) + overlay.style.left = left + "px"; + overlay.style.top = top + "px"; + overlay.style.width = rect.width + "px"; + overlay.style.height = rect.height + "px"; + + // Copy visual styles from original (for buttons/dpads, not zones) + if (this.type !== "zone") { + const computedStyle = window.getComputedStyle(this.original); + overlay.style.borderRadius = computedStyle.borderRadius; + overlay.style.background = computedStyle.background; + overlay.style.border = computedStyle.border; + overlay.style.boxShadow = computedStyle.boxShadow; + overlay.style.color = computedStyle.color; + overlay.style.fontSize = computedStyle.fontSize; + overlay.style.fontFamily = computedStyle.fontFamily; + overlay.style.fontWeight = computedStyle.fontWeight; + } + + // Add label + const labelText = this.type === "button" ? (this.original.innerText || "") : + this.type === "dpad" ? "D-PAD" : "STICK"; + if (labelText) { + const label = emu.createElement("span"); + label.innerText = labelText; + overlay.appendChild(label); + } + + // Store starting state + overlay._startLeft = left; + overlay._startTop = top; + overlay._startWidth = rect.width; + overlay._startHeight = rect.height; + + // Store default positions + overlay._defaultLeft = this.defaults.absoluteLeft ?? left; + overlay._defaultTop = this.defaults.absoluteTop ?? top; + overlay._defaultWidth = this.defaults.absoluteWidth ?? rect.width; + overlay._defaultHeight = this.defaults.absoluteHeight ?? rect.height; + + this.setupDragHandlers(overlay); + this.setupResizeHandle(overlay); + + return overlay; + } + + setupDragHandlers(overlay) { + let startLeft, startTop, dragOldState; + + const cleanup = this.editor.setupPointerInteraction(overlay, { + onStart: ({ event }) => { + if (event.target.classList.contains("ejs_virtualGamepad_resize_handle")) return false; + startLeft = parseFloat(overlay.style.left) || 0; + startTop = parseFloat(overlay.style.top) || 0; + // Capture FULL state for proper undo + dragOldState = this.captureState(); + overlay.classList.add("ejs_dragging"); + }, + onMove: ({ deltaX, deltaY }) => { + overlay.style.left = (startLeft + deltaX) + "px"; + overlay.style.top = (startTop + deltaY) + "px"; + }, + onEnd: () => { + overlay.classList.remove("ejs_dragging"); + const newState = this.captureState(); + // Copy to local const to avoid closure capturing mutable variable + const oldState = { ...dragOldState }; + + // Check if position actually changed + if (Math.abs(oldState.left - newState.left) > 0.5 || + Math.abs(oldState.top - newState.top) > 0.5) { + this.editor.history.push({ + undo: () => this.setState(oldState), + redo: () => this.setState(newState) + }); + } + } + }); + if (cleanup) this.cleanupFns.push(cleanup); + } + + setupResizeHandle(overlay) { + const emu = this.editor.emu; + const handle = emu.createElement("div"); + handle.classList.add("ejs_virtualGamepad_resize_handle", "ejs_resize_se"); + overlay.appendChild(handle); + + let startWidth, startHeight, resizeOldState; + + const cleanup = this.editor.setupPointerInteraction(handle, { + documentEvents: true, + stopPropagation: true, + onStart: () => { + startWidth = parseFloat(overlay.style.width) || 50; + startHeight = parseFloat(overlay.style.height) || 50; + // Capture FULL state for proper undo + resizeOldState = this.captureState(); + }, + onMove: ({ deltaX, deltaY }) => { + const delta = (deltaX + deltaY) / 2; + overlay.style.width = Math.max(20, startWidth + delta) + "px"; + overlay.style.height = Math.max(20, startHeight + delta) + "px"; + }, + onEnd: () => { + const newState = this.captureState(); + // Copy to local const to avoid closure capturing mutable variable + const oldState = { ...resizeOldState }; + + // Check if size actually changed + if (Math.abs(oldState.width - newState.width) > 0.5 || + Math.abs(oldState.height - newState.height) > 0.5) { + this.editor.history.push({ + undo: () => this.setState(oldState), + redo: () => this.setState(newState) + }); + } + } + }); + if (cleanup) this.cleanupFns.push(cleanup); + } + + captureState() { + return { + left: parseFloat(this.overlay.style.left) || 0, + top: parseFloat(this.overlay.style.top) || 0, + width: parseFloat(this.overlay.style.width) || 50, + height: parseFloat(this.overlay.style.height) || 50 + }; + } + + setState(state) { + if (state.left !== undefined) this.overlay.style.left = state.left + "px"; + if (state.top !== undefined) this.overlay.style.top = state.top + "px"; + if (state.width !== undefined) this.overlay.style.width = state.width + "px"; + if (state.height !== undefined) this.overlay.style.height = state.height + "px"; + } + + setSize(width, height) { + this.overlay.style.width = width + "px"; + this.overlay.style.height = height + "px"; + } + + resetToStart() { + this.setState({ + left: this.overlay._startLeft, + top: this.overlay._startTop, + width: this.overlay._startWidth, + height: this.overlay._startHeight + }); + } + + resetToDefault() { + const oldState = this.captureState(); + const newState = { + left: this.overlay._defaultLeft, + top: this.overlay._defaultTop, + width: this.overlay._defaultWidth, + height: this.overlay._defaultHeight + }; + + // Check if actually changed + if (Math.abs(oldState.left - newState.left) < 0.5 && + Math.abs(oldState.top - newState.top) < 0.5 && + Math.abs(oldState.width - newState.width) < 0.5 && + Math.abs(oldState.height - newState.height) < 0.5) { + return false; + } + + this.setState(newState); + return { oldState, newState }; + } + + cleanup() { + this.cleanupFns.forEach(fn => fn()); + this.cleanupFns = []; + } +} + +/** + * VirtualGamepadEditor - Orchestrates edit mode for virtual gamepad + */ +class VirtualGamepadEditor { + constructor(emulator) { + this.emu = emulator; + this.elements = []; + this.history = new HistoryManager(() => this.updateToolbarState()); + this.container = null; + this.overlayContainer = null; + this.toolbar = null; + this.toolbarButtons = {}; + this.buttonCleanups = []; + this.wasPaused = false; + this.active = false; + } + + /** Check if edit mode is currently active */ + get isActive() { + return this.active; + } + + /** Setup pointer (touch + mouse) interaction with unified event handling */ + setupPointerInteraction(element, { onStart, onMove, onEnd, documentEvents = false, stopPropagation = false }) { + let isActive = false; + let startX = 0, startY = 0; + + const handleStart = (e) => { + if (isActive) return; // Prevent duplicate starts from touch+mouse + if (stopPropagation) e.stopPropagation(); + e.preventDefault(); + const touch = e.touches ? e.touches[0] : e; + const result = onStart({ x: touch.clientX, y: touch.clientY, event: e }); + if (result === false) return; + isActive = true; + startX = touch.clientX; + startY = touch.clientY; + }; + + const handleMove = (e) => { + if (!isActive) return; + e.preventDefault(); + const touch = e.touches ? e.touches[0] : e; + onMove({ x: touch.clientX, y: touch.clientY, deltaX: touch.clientX - startX, deltaY: touch.clientY - startY, event: e }); + }; + + const handleEnd = (e) => { + if (!isActive) return; + isActive = false; + onEnd({ event: e }); + }; + + element.addEventListener("touchstart", handleStart, { passive: false }); + element.addEventListener("mousedown", handleStart); + + const moveEndTarget = documentEvents ? document : element; + moveEndTarget.addEventListener("touchmove", handleMove, { passive: false }); + moveEndTarget.addEventListener("mousemove", handleMove); + moveEndTarget.addEventListener("touchend", handleEnd); + moveEndTarget.addEventListener("touchcancel", handleEnd); + moveEndTarget.addEventListener("mouseup", handleEnd); + + return () => { + element.removeEventListener("touchstart", handleStart); + element.removeEventListener("mousedown", handleStart); + moveEndTarget.removeEventListener("touchmove", handleMove); + moveEndTarget.removeEventListener("mousemove", handleMove); + moveEndTarget.removeEventListener("touchend", handleEnd); + moveEndTarget.removeEventListener("touchcancel", handleEnd); + moveEndTarget.removeEventListener("mouseup", handleEnd); + }; + } + + enter() { + if (this.active) return; + this.active = true; + this.emu.virtualGamepadEditMode = true; + + // Store pause state and pause emulator + this.wasPaused = this.emu.paused; + if (!this.emu.paused) { + if (this.emu.gameManager) { + this.emu.gameManager.toggleMainLoop(false); + } + this.emu.paused = true; + } + + // Ensure virtual gamepad is visible + this.emu.virtualGamepad.style.display = ""; + this.emu.virtualGamepad.classList.add("ejs_virtualGamepad_edit_mode"); + + // Hide bottom menu bar + if (this.emu.elements.menu) { + this.emu.elements.menu.style.display = "none"; + } + + // Create edit container + this.container = this.emu.createElement("div"); + this.container.classList.add("ejs_virtualGamepad_edit_container"); + this.emu.elements.parent.appendChild(this.container); + + // Create toolbar + this.toolbar = this.createToolbar(); + this.container.appendChild(this.toolbar); + + // Create overlay container + this.overlayContainer = this.emu.createElement("div"); + this.overlayContainer.classList.add("ejs_virtualGamepad_overlay_container"); + this.container.appendChild(this.overlayContainer); + + // Measure default positions (lazy - only when editor opens) + this.emu.measureVirtualGamepadDefaults(); + + // Setup overlay elements + this.setupOverlayElements(); + + // Dim original virtual gamepad + this.emu.virtualGamepad.classList.add("ejs_virtualGamepad_edit_mode_faded"); + } + + createToolbar() { + const toolbar = this.emu.createElement("div"); + toolbar.classList.add("ejs_virtualGamepad_edit_toolbar"); + + // Icons for toolbar buttons + const UNDO_ICON = ''; + const REDO_ICON = ''; + // Prohibited/cancel icon for clear + const CLEAR_ICON = ''; + // Repeat/cycle arrows icon for default + const DEFAULT_ICON = ''; + // Floppy disk icon for save + const SAVE_ICON = ''; + + const buttonConfigs = [ + { icon: UNDO_ICON, cls: "undo", title: "UNDO_VIRTUAL_GAMEPAD", action: () => this.history.undo(), getDisabled: () => !this.history.canUndo() }, + { icon: REDO_ICON, cls: "redo", title: "REDO_VIRTUAL_GAMEPAD", action: () => this.history.redo(), getDisabled: () => !this.history.canRedo() }, + { icon: CLEAR_ICON, cls: "clear", title: "CLEAR_VIRTUAL_GAMEPAD", action: () => this.clear(), getDisabled: () => !this.hasChangesFromStart() }, + { icon: DEFAULT_ICON, cls: "default", title: "RESET_TO_DEFAULT_VIRTUAL_GAMEPAD", action: () => this.reset() }, + { icon: SAVE_ICON, cls: "close", title: "SAVE_AND_CLOSE_VIRTUAL_GAMEPAD", action: () => this.exit(true) } + ]; + + buttonConfigs.forEach(cfg => { + const btn = this.createButton(cfg); + this.toolbarButtons[cfg.cls] = { element: btn, getDisabled: cfg.getDisabled }; + toolbar.appendChild(btn); + }); + + return toolbar; + } + + createButton(cfg) { + const btn = this.emu.createElement("button"); + if (cfg.icon) { + btn.innerHTML = cfg.icon; + btn.classList.add("ejs_edit_icon_btn"); + } else { + btn.innerText = this.emu.localization(cfg.text); + } + btn.classList.add(`ejs_edit_btn_${cfg.cls}`); + if (cfg.title) btn.title = this.emu.localization(cfg.title); + if (cfg.getDisabled) btn.disabled = cfg.getDisabled(); + + const clickHandler = (e) => { + e.preventDefault(); + e.stopPropagation(); + cfg.action(); + }; + btn.addEventListener("click", clickHandler); + this.buttonCleanups.push(() => btn.removeEventListener("click", clickHandler)); + + return btn; + } + + /** Check if any element has changed from its start position */ + hasChangesFromStart() { + return this.elements.some(el => { + const current = el.captureState(); + return Math.abs(current.left - el.overlay._startLeft) > 0.5 || + Math.abs(current.top - el.overlay._startTop) > 0.5 || + Math.abs(current.width - el.overlay._startWidth) > 0.5 || + Math.abs(current.height - el.overlay._startHeight) > 0.5; + }); + } + + updateToolbarState() { + for (const key in this.toolbarButtons) { + const { element, getDisabled } = this.toolbarButtons[key]; + if (getDisabled) element.disabled = getDisabled(); + } + } + + setupOverlayElements() { + const parentRect = this.emu.elements.parent.getBoundingClientRect(); + const virtualGamepad = this.emu.virtualGamepad; + const findId = (classList, fallback) => this.emu.findElementId(classList, fallback); + + // Process buttons + const buttons = virtualGamepad.querySelectorAll(".ejs_virtualGamepad_button"); + buttons.forEach((btn, index) => { + const id = findId(btn.classList, `b_button_${index}`); + const rect = btn.getBoundingClientRect(); + const defaults = this.emu.virtualGamepadDefaults[id]; + + const overlayEl = new OverlayElement(this, btn, id, "button", rect, parentRect, defaults); + this.overlayContainer.appendChild(overlayEl.overlay); + this.elements.push(overlayEl); + }); + + // Process dpads + const dpads = virtualGamepad.querySelectorAll(".ejs_dpad_main"); + dpads.forEach((dpadMain, index) => { + const dpadContainer = dpadMain.parentElement; + if (!dpadContainer) return; + + const id = findId(dpadContainer.classList, `b_dpad_${index}`); + const rect = dpadMain.getBoundingClientRect(); + const defaults = this.emu.virtualGamepadDefaults[id]; + + const overlayEl = new OverlayElement(this, dpadContainer, id, "dpad", rect, parentRect, defaults); + this.overlayContainer.appendChild(overlayEl.overlay); + this.elements.push(overlayEl); + }); + + // Process zones (nipplejs joysticks) + const nipples = virtualGamepad.querySelectorAll(".nipple"); + const zones = []; + nipples.forEach(nipple => { + const zone = nipple.parentElement; + if (zone && !zones.includes(zone)) zones.push(zone); + }); + + zones.forEach((zone, index) => { + const id = findId(zone.classList, `b_zone_${index}`); + + // Get dimensions from the nipple element + const nipple = zone.querySelector(".nipple"); + const back = nipple ? nipple.querySelector(".back") : null; + let rect; + if (back && back.getBoundingClientRect().width > 0) { + rect = back.getBoundingClientRect(); + } else if (nipple && nipple.getBoundingClientRect().width > 0) { + rect = nipple.getBoundingClientRect(); + } else { + const zoneRect = zone.getBoundingClientRect(); + rect = { + left: zoneRect.left + zoneRect.width / 2 - 50, + top: zoneRect.top + zoneRect.height / 2 - 50, + width: 100, + height: 100 + }; + } + + zone.style.position = "absolute"; + const defaults = this.emu.virtualGamepadDefaults[id]; + + const overlayEl = new OverlayElement(this, zone, id, "zone", rect, parentRect, defaults); + overlayEl.nippleElement = nipple; + this.overlayContainer.appendChild(overlayEl.overlay); + this.elements.push(overlayEl); + }); + } + + clear() { + // Capture current states of all elements + const oldStates = this.elements.map(el => ({ element: el, state: el.captureState() })); + + // Check if any element has changed from start + const hasChanges = this.elements.some(el => { + const current = el.captureState(); + return Math.abs(current.left - el.overlay._startLeft) > 0.5 || + Math.abs(current.top - el.overlay._startTop) > 0.5 || + Math.abs(current.width - el.overlay._startWidth) > 0.5 || + Math.abs(current.height - el.overlay._startHeight) > 0.5; + }); + + if (!hasChanges) return; + + // Reset all elements to start + this.elements.forEach(el => el.resetToStart()); + + // Capture new states (start states) + const newStates = this.elements.map(el => ({ element: el, state: el.captureState() })); + + // Push single action to history + this.history.push({ + undo: () => oldStates.forEach(({ element, state }) => element.setState(state)), + redo: () => newStates.forEach(({ element, state }) => element.setState(state)) + }); + } + + reset() { + // Capture current states of all elements + const oldStates = this.elements.map(el => ({ element: el, state: el.captureState() })); + + // Check which elements will actually change + const changes = []; + this.elements.forEach((el) => { + const result = el.resetToDefault(); + if (result) { + changes.push({ element: el, oldState: result.oldState, newState: result.newState }); + } + }); + + if (changes.length === 0) return; + + // Push single action to history for ALL changes + this.history.push({ + undo: () => changes.forEach(({ element, oldState }) => element.setState(oldState)), + redo: () => changes.forEach(({ element, newState }) => element.setState(newState)) + }); + } + + exit(save) { + if (!this.active) return; + + // Apply changes if saving + if (save) { + this.applyChangesToOriginals(); + this.emu.saveVirtualGamepadLayout(this.elements); + } + + // Cleanup + this.elements.forEach(el => el.cleanup()); + this.elements = []; + this.history.clear(); + + // Cleanup button event listeners + this.buttonCleanups.forEach(cleanup => cleanup()); + this.buttonCleanups = []; + + if (this.container) { + this.container.remove(); + this.container = null; + } + + // Restore virtual gamepad state + this.emu.virtualGamepad.classList.remove("ejs_virtualGamepad_edit_mode_faded"); + this.emu.virtualGamepad.classList.remove("ejs_virtualGamepad_edit_mode"); + + // Show menu bar + if (this.emu.elements.menu) { + this.emu.elements.menu.style.display = ""; + } + + // Resume if wasn't paused before + if (!this.wasPaused) { + if (this.emu.gameManager) { + this.emu.gameManager.toggleMainLoop(true); + } + this.emu.paused = false; + } + + this.emu.virtualGamepadEditMode = false; + this.active = false; + } + + applyChangesToOriginals() { + const parentRect = this.emu.elements.parent.getBoundingClientRect(); + + this.elements.forEach(overlayEl => { + const state = overlayEl.captureState(); + const overlay = overlayEl.overlay; + const element = overlayEl.original; + const id = overlayEl.id; + + // Get default positions + const defaultLeft = overlay._defaultLeft || 0; + const defaultTop = overlay._defaultTop || 0; + const defaultWidth = overlay._defaultWidth || state.width; + const defaultHeight = overlay._defaultHeight || state.height; + + // Check if at default position + const isAtDefault = Math.abs(state.left - defaultLeft) < 1 && + Math.abs(state.top - defaultTop) < 1 && + Math.abs(state.width - defaultWidth) < 1 && + Math.abs(state.height - defaultHeight) < 1; + + const defaults = this.emu.virtualGamepadDefaults[id]; + + if (isAtDefault && defaults) { + // Restore original CSS positioning + if (overlayEl.type === "zone" && defaults.nippleManager && defaults.zoneConfig) { + this.restoreZoneToDefault(element, defaults); + } else { + element.style.left = defaults.left; + element.style.top = defaults.top; + element.style.right = defaults.right; + element.style.transform = defaults.transform || ""; + element.style.width = defaults.width || ""; + element.style.height = defaults.height || ""; + } + return; + } + + // Calculate scale factor + const scaleX = defaultWidth > 0 ? state.width / defaultWidth : 1; + const scaleY = defaultHeight > 0 ? state.height / defaultHeight : 1; + const scale = Math.max(0.1, Math.min(10, (scaleX + scaleY) / 2)); + + if (overlayEl.type === "zone" && defaults && defaults.nippleManager && defaults.zoneConfig) { + this.applyZonePosition(element, state, scale, defaults, parentRect); + return; + } + + // Calculate offset for dpad + let offsetX = 0, offsetY = 0; + if (overlayEl.type === "dpad") { + const dpadMain = element.querySelector(".ejs_dpad_main"); + if (dpadMain) { + const containerRect = element.getBoundingClientRect(); + const innerRect = dpadMain.getBoundingClientRect(); + offsetX = innerRect.left - containerRect.left; + offsetY = innerRect.top - containerRect.top; + } + } + + // Calculate position relative to element's parent container + const elementParent = element.parentElement; + const elementParentRect = elementParent ? elementParent.getBoundingClientRect() : parentRect; + const mainParentRect = this.emu.elements.parent.getBoundingClientRect(); + const containerOffsetX = elementParentRect.left - mainParentRect.left; + const containerOffsetY = elementParentRect.top - mainParentRect.top; + + element.style.right = ""; + element.style.left = (state.left - offsetX - containerOffsetX) + "px"; + element.style.top = (state.top - offsetY - containerOffsetY) + "px"; + + // Apply scale transform + if (Math.abs(scale - 1) > 0.01) { + element.style.transform = `scale(${scale})`; + element.style.transformOrigin = "top left"; + } else { + element.style.transform = ""; + } + }); + } + + recreateZone(element, defaults, position, size) { + this.emu.unbindZoneEventHandlers(defaults.nippleManager); + defaults.nippleManager.destroy(); + const zone = defaults.zoneConfig; + const newZoneObj = nipplejs.create({ + "zone": element, + "mode": "static", + "position": position, + "size": size, + "color": zone.color || "red" + }); + this.emu.bindZoneEventHandlers(newZoneObj, zone); + defaults.nippleManager = newZoneObj; + defaults.currentSize = size; + defaults.nippleElement = element.querySelector(".nipple"); + const newBack = defaults.nippleElement ? defaults.nippleElement.querySelector(".back") : null; + defaults.element = newBack || defaults.nippleElement; + } + + restoreZoneToDefault(element, defaults) { + const originalSize = defaults.originalSize || 100; + + // Calculate position relative to zone element using stored absolute defaults + const elementRect = element.getBoundingClientRect(); + const mainParentRect = this.emu.elements.parent.getBoundingClientRect(); + + // The default position stored is the top-left of the .back element + // The center is at absoluteLeft + absoluteWidth/2 + const defaultCenterX = defaults.absoluteLeft + defaults.absoluteWidth / 2; + const defaultCenterY = defaults.absoluteTop + defaults.absoluteHeight / 2; + + // Zone element's offset from main parent + const zoneOffsetX = elementRect.left - mainParentRect.left; + const zoneOffsetY = elementRect.top - mainParentRect.top; + + // Position relative to zone element + const newLeftPx = defaultCenterX - zoneOffsetX; + const newTopPx = defaultCenterY - zoneOffsetY; + + this.recreateZone(element, defaults, { + "left": newLeftPx + "px", + "top": newTopPx + "px" + }, originalSize); + } + + applyZonePosition(element, state, scale, defaults, parentRect) { + // nipplejs positions relative to the zone element, not its parent + const elementRect = element.getBoundingClientRect(); + const mainParentRect = this.emu.elements.parent.getBoundingClientRect(); + + // Calculate zone element's offset from main parent + const zoneOffsetX = elementRect.left - mainParentRect.left; + const zoneOffsetY = elementRect.top - mainParentRect.top; + + // Overlay center position relative to main parent + const overlayCenterX = state.left + state.width / 2; + const overlayCenterY = state.top + state.height / 2; + + // Position relative to the zone element + const newLeftPx = overlayCenterX - zoneOffsetX; + const newTopPx = overlayCenterY - zoneOffsetY; + const newSize = Math.round((defaults.originalSize || 100) * scale); + + this.recreateZone(element, defaults, { + "left": newLeftPx + "px", + "top": newTopPx + "px" + }, newSize); + } +} + +window.VirtualGamepadEditor = VirtualGamepadEditor; From 5e86e29744acb0edb0e80a9336499b3cea6ed9e2 Mon Sep 17 00:00:00 2001 From: z Date: Sat, 3 Jan 2026 18:06:10 +1300 Subject: [PATCH 2/7] attempt to fix localisation issue --- data/src/emulator.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/src/emulator.js b/data/src/emulator.js index 9867465..7fe05dd 100644 --- a/data/src/emulator.js +++ b/data/src/emulator.js @@ -5368,7 +5368,7 @@ class EmulatorJS { const editVirtualGamepadRow = this.createElement("div"); editVirtualGamepadRow.classList.add("ejs_settings_main_bar"); const editVirtualGamepadSpan = this.createElement("span"); - editVirtualGamepadSpan.innerText = this.localization("EDIT_VIRTUAL_GAMEPAD"); + editVirtualGamepadSpan.innerText = this.localization("Edit Virtual Gamepad"); editVirtualGamepadRow.appendChild(editVirtualGamepadSpan); virtualGamepad.appendChild(editVirtualGamepadRow); this.addEventListener(editVirtualGamepadRow, "click", () => { From 130302eb6a449d7e057e67a7197670f95e47185a Mon Sep 17 00:00:00 2001 From: z Date: Sun, 4 Jan 2026 16:51:15 +1300 Subject: [PATCH 3/7] findElementId removed redundant fallback --- data/src/emulator.js | 19 ++++++++----------- data/src/virtualGamepadEditor.js | 2 +- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/data/src/emulator.js b/data/src/emulator.js index 7fe05dd..f5fbf39 100644 --- a/data/src/emulator.js +++ b/data/src/emulator.js @@ -89,11 +89,8 @@ class EmulatorJS { data[i].elem.removeEventListener(data[i].listener, data[i].cb); } } - findElementId(classList, fallback) { - for (const cls of classList) { - if (cls.startsWith("b_")) return cls; - } - return fallback; + findElementId(classList) { + return [...classList].find(cls => cls.startsWith("b_")); } downloadFile(path, progressCB, notWithPath, opts) { return new Promise(async cb => { @@ -4377,23 +4374,23 @@ class EmulatorJS { const dpads = this.virtualGamepad.querySelectorAll(".ejs_dpad_main"); const nipples = this.virtualGamepad.querySelectorAll(".nipple"); - buttons.forEach((btn, index) => { - const id = this.findElementId(btn.classList, `b_button_${index}`); + buttons.forEach(btn => { + const id = this.findElementId(btn.classList); applyToElement(btn, id); }); - dpads.forEach((dpad, index) => { + dpads.forEach(dpad => { const dpadContainer = dpad.parentElement; if (!dpadContainer) return; - const id = this.findElementId(dpadContainer.classList, `b_dpad_${index}`); + const id = this.findElementId(dpadContainer.classList); applyToElement(dpadContainer, id); }); // Apply to zones (joysticks) - recreate nipplejs with saved position and size - nipples.forEach((nipple, index) => { + nipples.forEach(nipple => { const zone = nipple.parentElement; if (!zone) return; - const id = this.findElementId(zone.classList, `b_zone_${index}`); + const id = this.findElementId(zone.classList); const saved = this.virtualGamepadLayout[id]; const defaults = this.virtualGamepadDefaults[id]; diff --git a/data/src/virtualGamepadEditor.js b/data/src/virtualGamepadEditor.js index ce1fe8e..5c84b8f 100644 --- a/data/src/virtualGamepadEditor.js +++ b/data/src/virtualGamepadEditor.js @@ -442,7 +442,7 @@ class VirtualGamepadEditor { setupOverlayElements() { const parentRect = this.emu.elements.parent.getBoundingClientRect(); const virtualGamepad = this.emu.virtualGamepad; - const findId = (classList, fallback) => this.emu.findElementId(classList, fallback); + const findId = (classList) => this.emu.findElementId(classList); // Process buttons const buttons = virtualGamepad.querySelectorAll(".ejs_virtualGamepad_button"); From a8aff392a44a9f40f5262e149065434ef41a234a Mon Sep 17 00:00:00 2001 From: z Date: Sun, 4 Jan 2026 17:48:58 +1300 Subject: [PATCH 4/7] fixed bug with applying default button positions --- data/src/emulator.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/data/src/emulator.js b/data/src/emulator.js index f5fbf39..fad1527 100644 --- a/data/src/emulator.js +++ b/data/src/emulator.js @@ -1075,6 +1075,8 @@ class EmulatorJS { this.loadSettings(); // Apply virtual gamepad layout after settings are loaded if (this.virtualGamepadLayout && this.virtualGamepadDefaults) { + // Measure default positions BEFORE applying saved layout + this.measureVirtualGamepadDefaults(); this.applyVirtualGamepadLayout(); } this.updateCheatUI(); From 3f3fcf8b72eaf58baed82fe26fbf8bd139aa396d Mon Sep 17 00:00:00 2001 From: z Date: Sun, 18 Jan 2026 23:53:27 +1300 Subject: [PATCH 5/7] - renamed VirtualGamepadEditor -> EJS_VirtualGamepadEditor - added jsdocs for class - added jsdocs for all functions in EJS_VirtualGamepadEditor class --- data/src/emulator.js | 4 +- data/src/virtualGamepadEditor.js | 109 ++++++++++++++++++++++++++++--- 2 files changed, 101 insertions(+), 12 deletions(-) diff --git a/data/src/emulator.js b/data/src/emulator.js index fad1527..adfe2e0 100644 --- a/data/src/emulator.js +++ b/data/src/emulator.js @@ -4190,8 +4190,8 @@ class EmulatorJS { } /** Enter virtual gamepad edit mode using the external editor class */ enterVirtualGamepadEditMode() { - if (!this.gamepadEditor && window.VirtualGamepadEditor) { - this.gamepadEditor = new window.VirtualGamepadEditor(this); + if (!this.gamepadEditor && window.EJS_VirtualGamepadEditor) { + this.gamepadEditor = new window.EJS_VirtualGamepadEditor(this); } if (!this.gamepadEditor) return; this.gamepadEditor.enter(); diff --git a/data/src/virtualGamepadEditor.js b/data/src/virtualGamepadEditor.js index 5c84b8f..a905fbe 100644 --- a/data/src/virtualGamepadEditor.js +++ b/data/src/virtualGamepadEditor.js @@ -248,13 +248,21 @@ class OverlayElement { } /** - * VirtualGamepadEditor - Orchestrates edit mode for virtual gamepad + * Orchestrates the virtual gamepad edit mode. + * Provides UI for repositioning and resizing virtual gamepad controls, + * with undo/redo support and persistent storage of layouts. + * + * @class EJS_VirtualGamepadEditor */ -class VirtualGamepadEditor { +class EJS_VirtualGamepadEditor { + /** + * Creates a new virtual gamepad editor. + * @param {Object} emulator - The EmulatorJS instance + */ constructor(emulator) { this.emu = emulator; this.elements = []; - this.history = new HistoryManager(() => this.updateToolbarState()); + this.history = new EJS_HistoryManager(() => this.updateToolbarState()); this.container = null; this.overlayContainer = null; this.toolbar = null; @@ -264,12 +272,25 @@ class VirtualGamepadEditor { this.active = false; } - /** Check if edit mode is currently active */ + /** + * Whether edit mode is currently active. + * @type {boolean} + */ get isActive() { return this.active; } - /** Setup pointer (touch + mouse) interaction with unified event handling */ + /** + * Sets up unified pointer (touch + mouse) interaction handlers. + * @param {HTMLElement} element - Element to attach handlers to + * @param {Object} options - Handler options + * @param {Function} options.onStart - Called on pointer down, receives {x, y, event} + * @param {Function} options.onMove - Called on pointer move, receives {x, y, deltaX, deltaY, event} + * @param {Function} options.onEnd - Called on pointer up, receives {event} + * @param {boolean} [options.documentEvents=false] - Attach move/end to document + * @param {boolean} [options.stopPropagation=false] - Stop event propagation + * @returns {Function} Cleanup function to remove listeners + */ setupPointerInteraction(element, { onStart, onMove, onEnd, documentEvents = false, stopPropagation = false }) { let isActive = false; let startX = 0, startY = 0; @@ -320,6 +341,9 @@ class VirtualGamepadEditor { }; } + /** + * Enters edit mode, pausing the emulator and displaying the editor UI. + */ enter() { if (this.active) return; this.active = true; @@ -367,6 +391,11 @@ class VirtualGamepadEditor { this.emu.virtualGamepad.classList.add("ejs_virtualGamepad_edit_mode_faded"); } + /** + * Creates the editor toolbar with undo/redo/reset/save buttons. + * @returns {HTMLElement} The toolbar element + * @private + */ createToolbar() { const toolbar = this.emu.createElement("div"); toolbar.classList.add("ejs_virtualGamepad_edit_toolbar"); @@ -398,6 +427,18 @@ class VirtualGamepadEditor { return toolbar; } + /** + * Creates a toolbar button from configuration. + * @param {Object} cfg - Button configuration + * @param {string} [cfg.icon] - SVG icon HTML + * @param {string} [cfg.text] - Button text (localization key) + * @param {string} cfg.cls - CSS class suffix + * @param {string} [cfg.title] - Tooltip text (localization key) + * @param {Function} cfg.action - Click handler + * @param {Function} [cfg.getDisabled] - Returns whether button should be disabled + * @returns {HTMLButtonElement} The button element + * @private + */ createButton(cfg) { const btn = this.emu.createElement("button"); if (cfg.icon) { @@ -421,7 +462,10 @@ class VirtualGamepadEditor { return btn; } - /** Check if any element has changed from its start position */ + /** + * Checks if any element has changed from its position when the editor opened. + * @returns {boolean} True if any element has moved or resized + */ hasChangesFromStart() { return this.elements.some(el => { const current = el.captureState(); @@ -432,6 +476,10 @@ class VirtualGamepadEditor { }); } + /** + * Updates toolbar button disabled states based on current conditions. + * @private + */ updateToolbarState() { for (const key in this.toolbarButtons) { const { element, getDisabled } = this.toolbarButtons[key]; @@ -439,6 +487,10 @@ class VirtualGamepadEditor { } } + /** + * Creates overlay elements for all editable virtual gamepad controls. + * @private + */ setupOverlayElements() { const parentRect = this.emu.elements.parent.getBoundingClientRect(); const virtualGamepad = this.emu.virtualGamepad; @@ -451,7 +503,7 @@ class VirtualGamepadEditor { const rect = btn.getBoundingClientRect(); const defaults = this.emu.virtualGamepadDefaults[id]; - const overlayEl = new OverlayElement(this, btn, id, "button", rect, parentRect, defaults); + const overlayEl = new EJS_OverlayElement(this, btn, id, "button", rect, parentRect, defaults); this.overlayContainer.appendChild(overlayEl.overlay); this.elements.push(overlayEl); }); @@ -466,7 +518,7 @@ class VirtualGamepadEditor { const rect = dpadMain.getBoundingClientRect(); const defaults = this.emu.virtualGamepadDefaults[id]; - const overlayEl = new OverlayElement(this, dpadContainer, id, "dpad", rect, parentRect, defaults); + const overlayEl = new EJS_OverlayElement(this, dpadContainer, id, "dpad", rect, parentRect, defaults); this.overlayContainer.appendChild(overlayEl.overlay); this.elements.push(overlayEl); }); @@ -503,13 +555,16 @@ class VirtualGamepadEditor { zone.style.position = "absolute"; const defaults = this.emu.virtualGamepadDefaults[id]; - const overlayEl = new OverlayElement(this, zone, id, "zone", rect, parentRect, defaults); + const overlayEl = new EJS_OverlayElement(this, zone, id, "zone", rect, parentRect, defaults); overlayEl.nippleElement = nipple; this.overlayContainer.appendChild(overlayEl.overlay); this.elements.push(overlayEl); }); } + /** + * Clears all changes made in the current session, reverting to session start state. + */ clear() { // Capture current states of all elements const oldStates = this.elements.map(el => ({ element: el, state: el.captureState() })); @@ -538,6 +593,9 @@ class VirtualGamepadEditor { }); } + /** + * Resets all elements to their default CSS/config positions. + */ reset() { // Capture current states of all elements const oldStates = this.elements.map(el => ({ element: el, state: el.captureState() })); @@ -560,6 +618,10 @@ class VirtualGamepadEditor { }); } + /** + * Exits edit mode, optionally saving changes. + * @param {boolean} save - Whether to save changes before exiting + */ exit(save) { if (!this.active) return; @@ -604,6 +666,10 @@ class VirtualGamepadEditor { this.active = false; } + /** + * Applies overlay positions to the original DOM elements. + * @private + */ applyChangesToOriginals() { const parentRect = this.emu.elements.parent.getBoundingClientRect(); @@ -685,6 +751,14 @@ class VirtualGamepadEditor { }); } + /** + * Recreates a nipplejs zone with new position and size. + * @param {HTMLElement} element - The zone container element + * @param {Object} defaults - Default configuration for the zone + * @param {{left: string, top: string}} position - New position (CSS values) + * @param {number} size - New size in pixels + * @private + */ recreateZone(element, defaults, position, size) { this.emu.unbindZoneEventHandlers(defaults.nippleManager); defaults.nippleManager.destroy(); @@ -704,6 +778,12 @@ class VirtualGamepadEditor { defaults.element = newBack || defaults.nippleElement; } + /** + * Restores a zone to its original default position and size. + * @param {HTMLElement} element - The zone container element + * @param {Object} defaults - Default configuration for the zone + * @private + */ restoreZoneToDefault(element, defaults) { const originalSize = defaults.originalSize || 100; @@ -730,6 +810,15 @@ class VirtualGamepadEditor { }, originalSize); } + /** + * Applies a new position and scale to a zone element. + * @param {HTMLElement} element - The zone container element + * @param {Object} state - Current overlay state {left, top, width, height} + * @param {number} scale - Scale factor to apply + * @param {Object} defaults - Default configuration for the zone + * @param {DOMRect} parentRect - Parent container bounding rectangle + * @private + */ applyZonePosition(element, state, scale, defaults, parentRect) { // nipplejs positions relative to the zone element, not its parent const elementRect = element.getBoundingClientRect(); @@ -755,4 +844,4 @@ class VirtualGamepadEditor { } } -window.VirtualGamepadEditor = VirtualGamepadEditor; +window.EJS_VirtualGamepadEditor = EJS_VirtualGamepadEditor; From d6dfe755490ec6abd8139dd1690cdf7f11e17edd Mon Sep 17 00:00:00 2001 From: z Date: Sun, 18 Jan 2026 23:54:01 +1300 Subject: [PATCH 6/7] - renamed OverlayElement -> EJS_OverlayElement - added jsdocs for class - added jsdocs for all functions in EJS_OverlayElement class --- data/src/virtualGamepadEditor.js | 58 ++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/data/src/virtualGamepadEditor.js b/data/src/virtualGamepadEditor.js index a905fbe..16a9d52 100644 --- a/data/src/virtualGamepadEditor.js +++ b/data/src/virtualGamepadEditor.js @@ -44,9 +44,23 @@ class HistoryManager { } /** - * OverlayElement - Encapsulates overlay creation and state for a single editable element + * Represents an editable overlay for a virtual gamepad control element. + * Creates a draggable/resizable overlay positioned over the original element, + * handling position state and undo/redo integration. + * + * @class EJS_OverlayElement */ -class OverlayElement { +class EJS_OverlayElement { + /** + * Creates a new overlay element. + * @param {EJS_VirtualGamepadEditor} editor - The parent editor instance + * @param {HTMLElement} original - The original DOM element being edited + * @param {string} id - Unique identifier for this element (e.g., "b_A", "b_dpad") + * @param {string} type - Element type: "button", "dpad", or "zone" + * @param {DOMRect} rect - Bounding rectangle of the original element + * @param {DOMRect} parentRect - Bounding rectangle of the parent container + * @param {Object} [defaults] - Default position/size values for reset functionality + */ constructor(editor, original, id, type, rect, parentRect, defaults) { this.editor = editor; this.original = original; @@ -58,6 +72,13 @@ class OverlayElement { this.startState = this.captureState(); } + /** + * Creates the visual overlay element with styling and event handlers. + * @param {DOMRect} rect - Bounding rectangle of the original element + * @param {DOMRect} parentRect - Bounding rectangle of the parent container + * @returns {HTMLElement} The created overlay element + * @private + */ createOverlay(rect, parentRect) { const emu = this.editor.emu; const overlay = emu.createElement("div"); @@ -115,6 +136,11 @@ class OverlayElement { return overlay; } + /** + * Sets up drag interaction handlers for moving the overlay. + * @param {HTMLElement} overlay - The overlay element to make draggable + * @private + */ setupDragHandlers(overlay) { let startLeft, startTop, dragOldState; @@ -150,6 +176,11 @@ class OverlayElement { if (cleanup) this.cleanupFns.push(cleanup); } + /** + * Creates and sets up a resize handle for the overlay. + * @param {HTMLElement} overlay - The overlay element to add resize handle to + * @private + */ setupResizeHandle(overlay) { const emu = this.editor.emu; const handle = emu.createElement("div"); @@ -190,6 +221,10 @@ class OverlayElement { if (cleanup) this.cleanupFns.push(cleanup); } + /** + * Captures the current position and size state of the overlay. + * @returns {{left: number, top: number, width: number, height: number}} Current state + */ captureState() { return { left: parseFloat(this.overlay.style.left) || 0, @@ -199,6 +234,10 @@ class OverlayElement { }; } + /** + * Applies a position/size state to the overlay. + * @param {{left?: number, top?: number, width?: number, height?: number}} state - State to apply + */ setState(state) { if (state.left !== undefined) this.overlay.style.left = state.left + "px"; if (state.top !== undefined) this.overlay.style.top = state.top + "px"; @@ -206,11 +245,19 @@ class OverlayElement { if (state.height !== undefined) this.overlay.style.height = state.height + "px"; } + /** + * Sets the overlay dimensions. + * @param {number} width - Width in pixels + * @param {number} height - Height in pixels + */ setSize(width, height) { this.overlay.style.width = width + "px"; this.overlay.style.height = height + "px"; } + /** + * Resets the overlay to its position when the editor was opened. + */ resetToStart() { this.setState({ left: this.overlay._startLeft, @@ -220,6 +267,10 @@ class OverlayElement { }); } + /** + * Resets the overlay to its default position from CSS/config. + * @returns {{oldState: Object, newState: Object}|false} State change info, or false if unchanged + */ resetToDefault() { const oldState = this.captureState(); const newState = { @@ -241,6 +292,9 @@ class OverlayElement { return { oldState, newState }; } + /** + * Removes all event listeners and cleans up resources. + */ cleanup() { this.cleanupFns.forEach(fn => fn()); this.cleanupFns = []; From d84c734315c3a9f579f8ea332c6e4c2d39157f86 Mon Sep 17 00:00:00 2001 From: z Date: Sun, 18 Jan 2026 23:54:37 +1300 Subject: [PATCH 7/7] - renamed HistoryManager -> EJS_HistoryManager - added jsdocs for class - added jsdocs for all functions in EJS_HistoryManager class --- data/src/virtualGamepadEditor.js | 33 +++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/data/src/virtualGamepadEditor.js b/data/src/virtualGamepadEditor.js index 16a9d52..8c26f0b 100644 --- a/data/src/virtualGamepadEditor.js +++ b/data/src/virtualGamepadEditor.js @@ -1,20 +1,34 @@ /** - * HistoryManager - Command pattern for undo/redo operations + * Manages undo/redo history using the Command pattern. + * Stores actions as pairs of undo/redo functions that can be executed to + * reverse or replay state changes. + * + * @class EJS_HistoryManager */ -class HistoryManager { +class EJS_HistoryManager { + /** + * Creates a new history manager. + * @param {Function} onUpdate - Callback invoked after any history change + */ constructor(onUpdate) { this.undoStack = []; this.redoStack = []; this.onUpdate = onUpdate; } + /** + * Pushes a new action onto the history stack and clears redo history. + * @param {{undo: Function, redo: Function}} action - Action with undo/redo functions + */ push(action) { - // action = { undo: Function, redo: Function } this.undoStack.push(action); this.redoStack = []; this.onUpdate(); } + /** + * Undoes the most recent action. + */ undo() { const action = this.undoStack.pop(); if (action) { @@ -24,6 +38,9 @@ class HistoryManager { } } + /** + * Redoes the most recently undone action. + */ redo() { const action = this.redoStack.pop(); if (action) { @@ -33,13 +50,23 @@ class HistoryManager { } } + /** + * Clears all history. + */ clear() { this.undoStack = []; this.redoStack = []; this.onUpdate(); } + /** + * @returns {boolean} True if there are actions to undo + */ canUndo() { return this.undoStack.length > 0; } + + /** + * @returns {boolean} True if there are actions to redo + */ canRedo() { return this.redoStack.length > 0; } }