Add quit confirmation to main menu

Implement a quit confirmation dialog that appears when the QUIT button is selected on the main menu. The dialog displays 'EXIT GAME?' with YES/NO options. YES sends a coAction quit request to the plugin, which hides the overlay, disconnects any active server session, and sets RE::Main::quitGame to exit the game. NO returns to the main menu. Includes full keyboard (arrow keys, Enter, Escape), gamepad (dpad, A/B buttons), and mouse support. Default selection is NO.
This commit is contained in:
2026-07-07 22:33:33 +12:00
parent 549c117818
commit 2a3e614a47
7 changed files with 379 additions and 7 deletions
+4
View File
@@ -9,6 +9,10 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
## [Unreleased] ## [Unreleased]
### Added
- Main menu QUIT button now exits Fallout 4 via the engine `quitGame` flag (JS `coAction` bridge + C++ game-thread handler).
- Main menu quit confirmation dialog (`EXIT GAME?` with YES to quit and NO to return).
### Changed ### Changed
- Server browser UI restyled to match the main menu and settings panels: Futura PT Demibold typography, amber/green palette, yellow selection bars, and shared panel chrome (replaces the old bright-green HUD terminal look and SVG border strips). - Server browser UI restyled to match the main menu and settings panels: Futura PT Demibold typography, amber/green palette, yellow selection bars, and shared panel chrome (replaces the old bright-green HUD terminal look and SVG border strips).
+56
View File
@@ -9,6 +9,62 @@ failed experiments, successful tests, and next steps.
--- ---
## 2026-07-07 - Main Menu Quit Confirmation
### Summary
Selecting QUIT on the custom main menu opens an `EXIT GAME?` confirmation dialog. YES quits to desktop; NO returns to the menu.
### Files Changed
- `ui/views/CommonwealthOnline/main-menu/index.html`
- `ui/views/CommonwealthOnline/main-menu/styles.css`
- `ui/views/CommonwealthOnline/main-menu/script.js`
- `changelog.md`
- `docs/dev-log.md`
### Details
- Added centered quit-confirm modal with YES/NO options and shared menu selection styling.
- New `quitConfirm` view mode with keyboard, gamepad, and mouse support; B/Escape cancels like vanilla.
- YES sends the `coAction` quit request to the plugin; NO cancels. Default selection is NO.
### Testing
- Not run locally (requires Fallout 4 + built plugin).
- Expected: QUIT → `EXIT GAME?` appears; NO/B returns to menu; YES exits game.
### Known Issues
- None currently known.
### Next Steps
- None currently known.
---
## 2026-07-07 - Main Menu Quit Button
### Summary
The custom main menu QUIT item now sends a `coAction` to the plugin, which hides the overlay, disconnects any active server session, and sets `RE::Main::quitGame` on the game thread so Fallout 4 exits to desktop.
### Files Changed
- `ui/views/CommonwealthOnline/main-menu/script.js`
- `plugin/src/F4TPrismaUI.cpp`
- `changelog.md`
- `docs/dev-log.md`
### Details
- JS `activateSelected()` routes `quit` through `sendCoAction({ action: "quit" })` instead of only logging via `onOpenView`.
- C++ `RequestQuitFromMainMenu()` hides the PrismaUI overlay, calls `DisconnectFromLocalServer()`, and queues `main->quitGame = true` via `F4SE::GetTaskInterface()->AddTask`.
### Testing
- Not run locally (requires Fallout 4 + built plugin).
- Expected: select QUIT on title screen → game closes to desktop; no stuck overlay; server disconnects if connected.
### Known Issues
- None currently known.
### Next Steps
- None currently known (quit confirmation added in follow-up entry).
---
## 2026-07-07 - Fix: Hide Main Menu Overlay During Server Join Load ## 2026-07-07 - Fix: Hide Main Menu Overlay During Server Join Load
### Summary ### Summary
+26 -1
View File
@@ -60,6 +60,7 @@ namespace F4T::PrismaUI
// several functions call each other out of definition order). // several functions call each other out of definition order).
void HideMainMenuInternal(); void HideMainMenuInternal();
void ShowMainMenuInternal(); void ShowMainMenuInternal();
void RequestQuitFromMainMenu();
void HideServerBrowserInternal(); void HideServerBrowserInternal();
void ShowServerBrowserInternal(); void ShowServerBrowserInternal();
void CreateMainMenuView(); void CreateMainMenuView();
@@ -619,6 +620,30 @@ namespace F4T::PrismaUI
RequestNativeCursorRestore(); RequestNativeCursorRestore();
} }
void RequestQuitFromMainMenu()
{
LogInfo("Quit requested from main menu.");
HideMainMenuInternal();
F4T::Networking::DisconnectFromLocalServer();
const auto* taskInterface = F4SE::GetTaskInterface();
if (!taskInterface) {
LogWarning("Game task interface unavailable; cannot queue quit.");
return;
}
taskInterface->AddTask([]() {
auto* main = RE::Main::GetSingleton();
if (!main) {
REX::WARN("{} Main singleton unavailable; cannot quit game.", GetLogPrefix());
return;
}
main->quitGame = true;
});
}
void OnMainMenuDomReady(PrismaView a_view) void OnMainMenuDomReady(PrismaView a_view)
{ {
g_mainMenuDomReady = true; g_mainMenuDomReady = true;
@@ -674,7 +699,7 @@ namespace F4T::PrismaUI
F4T::MainMenuVanillaSettings::OpenGameSettings(); F4T::MainMenuVanillaSettings::OpenGameSettings();
return; return;
} else if (actionName == "quit") { } else if (actionName == "quit") {
LogInfo("quit requested from main menu (not yet implemented)."); RequestQuitFromMainMenu();
return; return;
} }
@@ -61,6 +61,23 @@
<!-- Server browser view (content mounted by ../browser/browser.js) --> <!-- Server browser view (content mounted by ../browser/browser.js) -->
<section class="browser-view" id="browserPanel" aria-label="Server browser" hidden></section> <section class="browser-view" id="browserPanel" aria-label="Server browser" hidden></section>
<!-- Quit confirmation -->
<section class="quit-confirm" id="quitConfirmPanel" aria-label="Quit confirmation" hidden>
<div class="quit-confirm__dialog" role="dialog" aria-modal="true" aria-labelledby="quitConfirmTitle">
<h2 class="quit-confirm__title" id="quitConfirmTitle">EXIT GAME?</h2>
<nav class="quit-confirm__options" id="quitConfirmOptions" aria-label="Quit confirmation options">
<button type="button" class="menu-item quit-confirm__option" data-index="0" data-choice="yes">
<span class="menu-item__label">YES</span>
<span class="menu-item__bar" aria-hidden="true"></span>
</button>
<button type="button" class="menu-item quit-confirm__option" data-index="1" data-choice="no">
<span class="menu-item__label">NO</span>
<span class="menu-item__bar" aria-hidden="true"></span>
</button>
</nav>
</div>
</section>
<!-- Bottom controller prompts (main menu) --> <!-- Bottom controller prompts (main menu) -->
<footer class="prompt-bar" id="mainPromptBar" aria-label="Controller prompts"> <footer class="prompt-bar" id="mainPromptBar" aria-label="Controller prompts">
<div class="prompt"> <div class="prompt">
+189 -5
View File
@@ -165,10 +165,13 @@
var menu = document.getElementById("menu"); var menu = document.getElementById("menu");
var settingsPanel = document.getElementById("settingsPanel"); var settingsPanel = document.getElementById("settingsPanel");
var browserPanel = document.getElementById("browserPanel"); var browserPanel = document.getElementById("browserPanel");
var quitConfirmPanel = document.getElementById("quitConfirmPanel");
var quitConfirmOptions = document.getElementById("quitConfirmOptions");
var mainPromptBar = document.getElementById("mainPromptBar"); var mainPromptBar = document.getElementById("mainPromptBar");
var settingsPromptBar = document.getElementById("settingsPromptBar"); var settingsPromptBar = document.getElementById("settingsPromptBar");
if (!menu || !settingsPanel || !browserPanel || !window.CO_Settings || !window.CO_Browser) { if (!menu || !settingsPanel || !browserPanel || !quitConfirmPanel || !quitConfirmOptions ||
!window.CO_Settings || !window.CO_Browser) {
return; return;
} }
@@ -176,12 +179,17 @@
var browserController = null; var browserController = null;
var items = Array.prototype.slice.call(menu.querySelectorAll(".menu-item")); var items = Array.prototype.slice.call(menu.querySelectorAll(".menu-item"));
var quitConfirmItems = Array.prototype.slice.call(
quitConfirmOptions.querySelectorAll(".quit-confirm__option")
);
var MULTIPLAYER_INDEX = 0; var MULTIPLAYER_INDEX = 0;
var SETTINGS_INDEX = 2; var SETTINGS_INDEX = 2;
var QUIT_INDEX = 4;
// viewMode: "menu" | "settings" | "browser" // viewMode: "menu" | "settings" | "browser" | "quitConfirm"
var viewMode = "menu"; var viewMode = "menu";
var selectedIndex = -1; var selectedIndex = -1;
var quitConfirmSelectedIndex = 0;
var PANEL_TRANSITION_MS = 220; var PANEL_TRANSITION_MS = 220;
var settingsHideFallbackId = 0; var settingsHideFallbackId = 0;
var settingsHideOnTransitionEnd = null; var settingsHideOnTransitionEnd = null;
@@ -243,13 +251,13 @@
enterSettingsMode(); enterSettingsMode();
} else if (action === "multiplayer") { } else if (action === "multiplayer") {
enterBrowserMode(); enterBrowserMode();
} else if (action === "quit") {
enterQuitConfirmMode();
} else { } else {
window.CommonwealthOnlineUI.onOpenView(action); window.CommonwealthOnlineUI.onOpenView(action);
if (action !== "quit") {
setSelected(-1); setSelected(-1);
} }
} }
}
items.forEach(function (item, index) { items.forEach(function (item, index) {
item.addEventListener("click", function () { item.addEventListener("click", function () {
@@ -263,6 +271,11 @@
return; return;
} }
exitBrowserMode(); exitBrowserMode();
} else if (viewMode === "quitConfirm") {
if (index === QUIT_INDEX) {
return;
}
exitQuitConfirmMode();
} }
if (item.classList.contains("is-disabled")) { if (item.classList.contains("is-disabled")) {
return; return;
@@ -472,6 +485,129 @@
exitBrowserMode(true); exitBrowserMode(true);
}; };
function setQuitConfirmSelected(index) {
if (index >= quitConfirmItems.length) {
return;
}
quitConfirmSelectedIndex = index < 0 ? 0 : index;
quitConfirmItems.forEach(function (item, i) {
var isSelected = i === quitConfirmSelectedIndex;
item.classList.toggle("is-selected", isSelected);
item.setAttribute("aria-selected", isSelected ? "true" : "false");
item.tabIndex = isSelected ? 0 : -1;
});
}
function moveQuitConfirmSelection(delta) {
var next = quitConfirmSelectedIndex + delta;
if (next < 0) {
next = quitConfirmItems.length - 1;
} else if (next >= quitConfirmItems.length) {
next = 0;
}
setQuitConfirmSelected(next);
}
function showQuitConfirmPanelAnimated() {
if (mainScreen) {
mainScreen.classList.add("is-quit-confirm-mode");
}
quitConfirmPanel.hidden = false;
quitConfirmPanel.classList.remove("is-open");
void quitConfirmPanel.offsetWidth;
quitConfirmPanel.classList.add("is-open");
}
function hideQuitConfirmPanelAnimated(done) {
if (quitConfirmPanel.hidden) {
if (done) {
done();
}
return;
}
quitConfirmPanel.classList.remove("is-open");
window.setTimeout(function finishHide() {
if (viewMode === "quitConfirm" || quitConfirmPanel.classList.contains("is-open")) {
if (done) {
done();
}
return;
}
if (mainScreen) {
mainScreen.classList.remove("is-quit-confirm-mode");
}
quitConfirmPanel.hidden = true;
if (done) {
done();
}
}, PANEL_TRANSITION_MS + 80);
}
function enterQuitConfirmMode() {
if (viewMode === "quitConfirm") {
return;
}
if (viewMode === "settings") {
exitSettingsMode();
} else if (viewMode === "browser") {
exitBrowserMode();
}
viewMode = "quitConfirm";
console.log("[CO Main Menu] Entering quit confirmation");
setSelected(QUIT_INDEX);
setQuitConfirmSelected(1);
showQuitConfirmPanelAnimated();
}
function exitQuitConfirmMode() {
if (viewMode !== "quitConfirm") {
return;
}
viewMode = "menu";
console.log("[CO Main Menu] Exiting quit confirmation");
hideQuitConfirmPanelAnimated(function () {
if (viewMode === "menu") {
setSelected(QUIT_INDEX);
}
});
}
function confirmQuit() {
console.log("[CO Main Menu] Quit confirmed");
sendCoAction({ action: "quit" });
}
function activateQuitConfirmSelected() {
var item = quitConfirmItems[quitConfirmSelectedIndex];
if (!item) {
return;
}
var choice = item.getAttribute("data-choice");
if (choice === "yes") {
confirmQuit();
return;
}
exitQuitConfirmMode();
}
quitConfirmItems.forEach(function (item, index) {
item.addEventListener("click", function () {
setQuitConfirmSelected(index);
activateQuitConfirmSelected();
});
});
window.onMainMenuShown = function () { window.onMainMenuShown = function () {
if (mainScreen) { if (mainScreen) {
mainScreen.classList.remove("is-session-loading"); mainScreen.classList.remove("is-session-loading");
@@ -489,11 +625,14 @@
settingsController.hide(); settingsController.hide();
settingsPanel.classList.remove("is-open"); settingsPanel.classList.remove("is-open");
settingsPanel.hidden = true; settingsPanel.hidden = true;
} else if (viewMode === "quitConfirm") {
quitConfirmPanel.classList.remove("is-open");
quitConfirmPanel.hidden = true;
} }
viewMode = "menu"; viewMode = "menu";
if (mainScreen) { if (mainScreen) {
mainScreen.classList.remove("is-browser-mode", "is-settings-mode"); mainScreen.classList.remove("is-browser-mode", "is-settings-mode", "is-quit-confirm-mode");
mainScreen.classList.add("is-session-loading"); mainScreen.classList.add("is-session-loading");
} }
if (mainPromptBar) { if (mainPromptBar) {
@@ -580,6 +719,30 @@
// ── Keyboard navigation ──────────────────────────────────────── // ── Keyboard navigation ────────────────────────────────────────
function handleQuitConfirmKeydown(event) {
switch (event.key) {
case "ArrowUp":
event.preventDefault();
moveQuitConfirmSelection(-1);
break;
case "ArrowDown":
event.preventDefault();
moveQuitConfirmSelection(1);
break;
case "Enter":
case " ":
event.preventDefault();
activateQuitConfirmSelected();
break;
case "Escape":
event.preventDefault();
exitQuitConfirmMode();
break;
default:
break;
}
}
function handleMenuKeydown(event) { function handleMenuKeydown(event) {
switch (event.key) { switch (event.key) {
case "ArrowUp": case "ArrowUp":
@@ -614,6 +777,10 @@
browserController.handleKeydown(event); browserController.handleKeydown(event);
return; return;
} }
if (viewMode === "quitConfirm") {
handleQuitConfirmKeydown(event);
return;
}
handleMenuKeydown(event); handleMenuKeydown(event);
}); });
@@ -626,6 +793,23 @@
if (viewMode === "settings") { if (viewMode === "settings") {
return; return;
} }
if (viewMode === "quitConfirm") {
switch (button) {
case "dpad_up":
moveQuitConfirmSelection(-1);
break;
case "dpad_down":
moveQuitConfirmSelection(1);
break;
case "a":
activateQuitConfirmSelected();
break;
case "b":
exitQuitConfirmMode();
break;
}
return;
}
switch (button) { switch (button) {
case "dpad_up": case "dpad_up":
moveSelection(-1); moveSelection(-1);
@@ -441,3 +441,89 @@ body {
), ),
rgba(4, 8, 6, 0.32); rgba(4, 8, 6, 0.32);
} }
/* Quit confirmation modal */
.quit-confirm {
position: absolute;
inset: 0;
z-index: 12;
display: flex;
align-items: center;
justify-content: center;
padding: calc(24px * var(--co-ui-scale));
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 220ms ease-out,
visibility 0ms linear 220ms;
}
.quit-confirm.is-open {
opacity: 1;
visibility: visible;
pointer-events: auto;
transition:
opacity 220ms ease-out,
visibility 0ms linear 0ms;
}
.quit-confirm[hidden] {
display: none;
}
.quit-confirm__dialog {
width: min(calc(420px * var(--co-ui-scale)), calc(100vw - 80px * var(--co-ui-scale)));
padding:
calc(32px * var(--co-ui-scale))
calc(36px * var(--co-ui-scale))
calc(28px * var(--co-ui-scale));
background: rgba(6, 12, 8, 0.9);
border: 1px solid rgba(201, 168, 74, 0.35);
box-shadow:
inset 0 0 28px rgba(0, 0, 0, 0.35),
0 8px 32px rgba(0, 0, 0, 0.45);
}
.quit-confirm__title {
margin-bottom: calc(24px * var(--co-ui-scale));
font-size: calc(28px * var(--co-ui-scale));
font-weight: 700;
letter-spacing: 0.22em;
text-align: center;
color: var(--co-amber);
}
.quit-confirm__options {
display: flex;
flex-direction: column;
gap: var(--co-menu-gap);
}
.quit-confirm__option .menu-item__bar {
left: 0;
width: 100%;
}
.main-screen.is-quit-confirm-mode .scene-overlay {
background:
linear-gradient(
90deg,
rgba(6, 10, 8, 0.92) 0%,
rgba(6, 10, 8, 0.72) 35%,
rgba(6, 10, 8, 0.55) 60%,
rgba(6, 10, 8, 0.65) 100%
),
rgba(4, 8, 6, 0.42);
}
@media (prefers-reduced-motion: reduce) {
.quit-confirm {
transition: opacity 120ms ease-out, visibility 0ms linear 120ms;
}
.quit-confirm.is-open {
transition: opacity 120ms ease-out, visibility 0ms linear 0ms;
}
}