Add controller support and safe main menu hooks

Enable gamepad navigation for the PrismaUI server browser and install MainMenu input hooks safely. Poll XInput each game-thread tick and forward button/stick changes to the browser (ForwardButtonEvent / ForwardThumbstickEvent), allocate an input-enable layer to suppress vanilla kMenu events while the browser is open, and register polling via the F4SE task interface. Replace direct vtable writes that caused a loading-screen crash with REL::Relocation::write_vfunc on the MainMenu BSInputEventUser secondary vtable and install OnButton/OnThumbstick hooks once at plugin Install(). Add MainMenu API to suppress inputEventHandling and call it when showing/hiding the browser. Also update PrismaUI browser JS to support controller focus for direct-connect, filter activation/cycling, improved controller prompts, and add CSS for focused direct-connect fields. Files touched include plugin/src/F4TMenuInput.cpp, plugin/src/F4TMainMenuInject.cpp, plugin/include headers, PrismaUI view JS/components, styles, and documentation.
This commit is contained in:
2026-06-18 14:26:33 +12:00
parent 3d14918ee8
commit f720d35d2a
11 changed files with 626 additions and 102 deletions
+58 -7
View File
@@ -36,6 +36,7 @@
connectionStatus: "Online",
isRefreshing: false,
directConnect: { address: "127.0.0.1", port: "7777" },
directConnectFieldIndex: 0,
joinOverlay: null
};
@@ -236,7 +237,10 @@
state.selectedTabIndex = CO_Tabs.TAB_ORDER.indexOf(tabName);
if (state.selectedTabIndex < 0) state.selectedTabIndex = 0;
if (tabName === "server-browser") state.focusZone = "server-list";
else if (tabName === "direct-connect") state.focusZone = "tabs";
else if (tabName === "direct-connect") {
state.focusZone = "tabs";
state.directConnectFieldIndex = 0;
}
sendAction("switchTab", { tabName: tabName });
render();
}
@@ -334,11 +338,13 @@
}
function onKeyDown(e) {
if (state.joinOverlay && state.joinOverlay.error && e.key === "Escape") {
hideJoinOverlay();
return;
if (state.joinOverlay && state.joinOverlay.error) {
if (e.key === "Escape" || e.key === "Enter" || e.key === "b" || e.key === "B") {
hideJoinOverlay();
return;
}
}
if (state.activeTab === "direct-connect") return;
if (e.repeat) return;
handleControllerButton(mapKeyToButton(e.key), "down");
}
@@ -353,7 +359,9 @@
function handleControllerButton(button, phase) {
if (!button || phase !== "down") return;
if (state.joinOverlay) {
if (button === "b") hideJoinOverlay();
if (button === "b" || (state.joinOverlay.error && button === "a")) {
hideJoinOverlay();
}
return;
}
@@ -372,6 +380,20 @@
}
if (state.activeTab === "direct-connect") {
if (button === "b") {
sendAction("closeBrowser", {});
return;
}
if (button === "dpad_up") {
state.directConnectFieldIndex = Math.max(0, state.directConnectFieldIndex - 1);
render();
return;
}
if (button === "dpad_down") {
state.directConnectFieldIndex = Math.min(1, state.directConnectFieldIndex + 1);
render();
return;
}
if (button === "a") {
var vals = CO_DirectConnect.getValues(root);
joinDirectLocal(vals.address, vals.port);
@@ -396,6 +418,35 @@
navigateFocus(button);
}
function cycleFilterValue(filterKey, options) {
var current = state.filters[filterKey];
var index = options.indexOf(current);
if (index < 0) index = 0;
var next = options[(index + 1) % options.length];
state.filters[filterKey] = next;
sendAction("setFilter", { filterName: filterKey, value: next });
applyLocalFilters();
}
function activateFocusedFilter() {
var maxFilter = CO_Filters.FILTER_ROWS.length;
if (state.selectedFilterIndex === maxFilter) {
resetLocalFilters();
return;
}
var row = CO_Filters.FILTER_ROWS[state.selectedFilterIndex];
if (!row) return;
if (row.type === "select") {
cycleFilterValue(row.key, row.options);
} else if (row.type === "checkbox") {
state.filters[row.key] = !state.filters[row.key];
sendAction("setFilter", { filterName: row.key, value: state.filters[row.key] });
applyLocalFilters();
}
}
function joinSelected() {
var sel = getSelectedServer();
if (!sel) return;
@@ -448,7 +499,7 @@
var maxFilter = CO_Filters.FILTER_ROWS.length;
if (button === "dpad_up") state.selectedFilterIndex = Math.max(0, state.selectedFilterIndex - 1);
if (button === "dpad_down") state.selectedFilterIndex = Math.min(maxFilter, state.selectedFilterIndex + 1);
if (button === "a" && state.selectedFilterIndex === maxFilter) resetLocalFilters();
if (button === "a") activateFocusedFilter();
render();
return;
}
@@ -20,7 +20,10 @@
getPrompts: function (state) {
if (state.joinOverlay) {
if (state.joinOverlay.error) {
return [{ key: "B", label: "Dismiss" }];
return [
{ key: "B", label: "Dismiss" },
{ key: "A", label: "Dismiss" }
];
}
return [];
}
@@ -32,12 +35,13 @@
];
}
var base = [
{ key: "A", label: "Join" },
{ key: "X", label: "Refresh" },
{ key: "Y", label: "Favorite" },
{ key: "B", label: "Back" }
];
if (state.focusZone === "filters") {
var isReset = state.selectedFilterIndex === CO_Filters.FILTER_ROWS.length;
return [
{ key: "A", label: isReset ? "Reset" : "Change" },
{ key: "B", label: "Back" }
];
}
if (state.focusZone === "tabs") {
return [
@@ -47,7 +51,12 @@
];
}
return base;
return [
{ key: "A", label: "Join" },
{ key: "X", label: "Refresh" },
{ key: "Y", label: "Favorite" },
{ key: "B", label: "Back" }
];
}
};
})();
@@ -11,13 +11,15 @@
var address = state.directConnect.address || "127.0.0.1";
var port = state.directConnect.port || "7777";
var addressFocused = state.directConnectFieldIndex === 0 ? " focused" : "";
var portFocused = state.directConnectFieldIndex === 1 ? " focused" : "";
container.innerHTML =
'<div class="co-direct-row">' +
'<div class="co-direct-row' + addressFocused + '">' +
'<label for="co-dc-address">Server Address</label>' +
'<input class="co-filter-input" id="co-dc-address" type="text" value="' + escapeAttr(address) + '">' +
"</div>" +
'<div class="co-direct-row">' +
'<div class="co-direct-row' + portFocused + '">' +
'<label for="co-dc-port">Port</label>' +
'<input class="co-filter-input" id="co-dc-port" type="text" value="' + escapeAttr(port) + '">' +
"</div>" +
@@ -630,6 +630,11 @@ body {
margin-bottom: 12px;
}
.co-direct-row.focused .co-filter-input {
outline: 1px solid var(--co-green);
background: rgba(var(--co-r), var(--co-g), var(--co-b), 0.08);
}
.co-direct-row label {
display: block;
font-size: var(--co-font-xs);