mirror of
https://github.com/EmulatorJS/EmulatorJS.git
synced 2026-02-06 11:17:36 +00:00
virtual gamepad editting full implementation
This commit is contained in:
parent
5680535946
commit
d25096600e
@ -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;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
(async function() {
|
||||
const scripts = [
|
||||
"virtualGamepadEditor.js",
|
||||
"emulator.js",
|
||||
"nipplejs.js",
|
||||
"shaders.js",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
758
data/src/virtualGamepadEditor.js
Normal file
758
data/src/virtualGamepadEditor.js
Normal file
@ -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 = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/></svg>';
|
||||
const REDO_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"/></svg>';
|
||||
// Prohibited/cancel icon for clear
|
||||
const CLEAR_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z"/></svg>';
|
||||
// Repeat/cycle arrows icon for default
|
||||
const DEFAULT_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>';
|
||||
// Floppy disk icon for save
|
||||
const SAVE_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/></svg>';
|
||||
|
||||
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;
|
||||
Loading…
Reference in New Issue
Block a user