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
@@ -61,6 +61,23 @@
<!-- Server browser view (content mounted by ../browser/browser.js) -->
<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) -->
<footer class="prompt-bar" id="mainPromptBar" aria-label="Controller prompts">
<div class="prompt">
+190 -6
View File
@@ -165,10 +165,13 @@
var menu = document.getElementById("menu");
var settingsPanel = document.getElementById("settingsPanel");
var browserPanel = document.getElementById("browserPanel");
var quitConfirmPanel = document.getElementById("quitConfirmPanel");
var quitConfirmOptions = document.getElementById("quitConfirmOptions");
var mainPromptBar = document.getElementById("mainPromptBar");
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;
}
@@ -176,12 +179,17 @@
var browserController = null;
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 SETTINGS_INDEX = 2;
var QUIT_INDEX = 4;
// viewMode: "menu" | "settings" | "browser"
// viewMode: "menu" | "settings" | "browser" | "quitConfirm"
var viewMode = "menu";
var selectedIndex = -1;
var quitConfirmSelectedIndex = 0;
var PANEL_TRANSITION_MS = 220;
var settingsHideFallbackId = 0;
var settingsHideOnTransitionEnd = null;
@@ -243,11 +251,11 @@
enterSettingsMode();
} else if (action === "multiplayer") {
enterBrowserMode();
} else if (action === "quit") {
enterQuitConfirmMode();
} else {
window.CommonwealthOnlineUI.onOpenView(action);
if (action !== "quit") {
setSelected(-1);
}
setSelected(-1);
}
}
@@ -263,6 +271,11 @@
return;
}
exitBrowserMode();
} else if (viewMode === "quitConfirm") {
if (index === QUIT_INDEX) {
return;
}
exitQuitConfirmMode();
}
if (item.classList.contains("is-disabled")) {
return;
@@ -472,6 +485,129 @@
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 () {
if (mainScreen) {
mainScreen.classList.remove("is-session-loading");
@@ -489,11 +625,14 @@
settingsController.hide();
settingsPanel.classList.remove("is-open");
settingsPanel.hidden = true;
} else if (viewMode === "quitConfirm") {
quitConfirmPanel.classList.remove("is-open");
quitConfirmPanel.hidden = true;
}
viewMode = "menu";
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");
}
if (mainPromptBar) {
@@ -580,6 +719,30 @@
// ── 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) {
switch (event.key) {
case "ArrowUp":
@@ -614,6 +777,10 @@
browserController.handleKeydown(event);
return;
}
if (viewMode === "quitConfirm") {
handleQuitConfirmKeydown(event);
return;
}
handleMenuKeydown(event);
});
@@ -626,6 +793,23 @@
if (viewMode === "settings") {
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) {
case "dpad_up":
moveSelection(-1);
@@ -441,3 +441,89 @@ body {
),
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;
}
}