Embed server browser as main menu modal

Refactors the browser UI into a reusable `CO_Browser.create()` module and adds embedded modal styling so MULTIPLAYER opens inside the main menu instead of swapping to a separate view. Updates main-menu JS/CSS to host, animate, and route input for the browser panel while keeping nav and prompts in sync. On the C++ side, PrismaUI now drives browser show/hide and event dispatch through the main menu view, and `ServerBrowserBridge::SetView()` was added so bridge events target the active host view.
This commit is contained in:
2026-07-07 17:35:15 +12:00
parent 2961eb6512
commit 9447bc308f
12 changed files with 1514 additions and 982 deletions
+12 -838
View File
@@ -1,845 +1,19 @@
(function () {
"use strict";
var root = document.getElementById("coRoot");
var panel = document.getElementById("coPanel");
var joinOverlay = document.getElementById("coJoinOverlay");
var joinMessage = document.getElementById("coJoinMessage");
var FOCUS_ZONES = ["tabs", "filters", "server-list"];
var REPEAT_INITIAL_MS = 400;
var REPEAT_RATE_MS = 80;
var repeatTimer = null;
var repeatAction = null;
var RECENT_STORAGE_KEY = "co_recent_servers";
var MAX_RECENT = 8;
var state = {
activeTab: "server-browser",
focusZone: "server-list",
selectedTabIndex: 1,
selectedServerIndex: 0,
selectedFilterIndex: 0,
filters: {
search: "",
region: "Any Region",
mode: "Any Mode",
worldspace: "Any Worldspace",
ping: "Any",
showFull: false,
hasPassword: false,
modded: false
},
allServers: [],
servers: [],
favorites: [],
recent: [],
localServers: [],
connectionStatus: "Online",
isRefreshing: false,
isScanningLocal: false,
directConnect: { address: "127.0.0.1", port: "7777" },
directConnectFieldIndex: 0,
joinOverlay: null
};
function sendAction(action, payload) {
var msg = JSON.stringify(Object.assign({ action: action }, payload || {}));
if (typeof window.coAction === "function") {
window.coAction(msg);
return;
}
console.warn("[CO] coAction bridge unavailable:", action);
handleActionLocal(action, payload || {});
var mount = document.body;
if (!window.CO_Browser) {
console.error("[CO] CO_Browser module is required before app.js");
return;
}
function handleActionLocal(action, payload) {
switch (action) {
case "refreshServerList":
refreshLocalMock();
break;
case "scanLocalServers":
scanLocalMock();
break;
case "setFilter":
state.filters[payload.filterName] = payload.value;
applyLocalFilters();
render();
break;
case "resetFilters":
resetLocalFilters();
break;
case "toggleFavorite":
toggleFavoriteLocal(payload.serverId);
break;
case "joinServer":
joinServerLocal(payload.serverId);
break;
case "openDirectConnect":
joinDirectLocal(payload.address, payload.port);
break;
case "switchTab":
switchTab(payload.tabName, { skipAction: true });
if (payload.tabName === "local") {
scanLocalMock();
}
break;
case "closeBrowser":
if (typeof window.closeBrowser === "function") window.closeBrowser();
break;
default:
break;
}
}
function refreshLocalMock() {
state.isRefreshing = true;
render();
setTimeout(function () {
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
applyLocalFilters();
state.isRefreshing = false;
render();
}, 600);
}
function scanLocalMock() {
state.isScanningLocal = true;
render();
setTimeout(function () {
state.localServers = (window.CO_MOCK_SERVERS || []).filter(function (s) {
return s.region === "Local" ||
(s.host && window.CO_ServerList && CO_ServerList.isLocalHost(s.host));
}).map(function (s) {
return {
id: s.id || ("local:" + (s.host || "127.0.0.1") + ":" + portNumFrom(s.port)),
name: s.name || "",
host: s.host || "127.0.0.1",
port: portNumFrom(s.port),
region: "Local",
players: s.players || 0,
maxPlayers: s.maxPlayers || 16,
ping: s.ping || 1,
worldspace: s.worldspace || "",
mode: s.mode || ""
};
});
state.isScanningLocal = false;
if (state.activeTab === "local" && state.selectedServerIndex >= state.localServers.length) {
state.selectedServerIndex = Math.max(0, state.localServers.length - 1);
}
render();
}, 800);
}
function resetLocalFilters() {
state.filters = {
search: "",
region: "Any Region",
mode: "Any Mode",
worldspace: "Any Worldspace",
ping: "Any",
showFull: false,
hasPassword: false,
modded: false
};
sendAction("resetFilters", {});
applyLocalFilters();
render();
}
function applyLocalFilters() {
var list = state.allServers.slice();
var f = state.filters;
if (f.search) {
var q = f.search.toLowerCase();
list = list.filter(function (s) {
return s.name.toLowerCase().indexOf(q) >= 0;
});
}
if (f.region !== "Any Region") {
list = list.filter(function (s) { return s.region === f.region; });
}
if (f.mode !== "Any Mode") {
list = list.filter(function (s) { return s.mode === f.mode; });
}
if (f.worldspace !== "Any Worldspace") {
list = list.filter(function (s) { return s.worldspace === f.worldspace; });
}
if (f.ping === "< 50 ms") list = list.filter(function (s) { return s.ping < 50; });
else if (f.ping === "< 100 ms") list = list.filter(function (s) { return s.ping < 100; });
else if (f.ping === "< 150 ms") list = list.filter(function (s) { return s.ping < 150; });
if (!f.showFull) list = list.filter(function (s) { return s.status !== "Full"; });
if (f.hasPassword) list = list.filter(function (s) { return s.passworded; });
if (f.modded) list = list.filter(function (s) { return s.modded; });
state.servers = list;
state.favorites = state.allServers.filter(function (s) { return s.favorite; });
if (state.selectedServerIndex >= state.servers.length) {
state.selectedServerIndex = Math.max(0, state.servers.length - 1);
}
updateServerCount();
}
function updateServerCount() {
var countEl = document.getElementById("coServerCount");
var titleEl = document.getElementById("coSubpanelTitle");
var count = state.servers.length;
if (countEl) {
countEl.textContent = count + " Found";
}
if (titleEl && state.activeTab === "server-browser") {
titleEl.textContent = count === 1 ? "1 Server" : count + " Servers";
}
}
function updateLocalCount() {
var countEl = document.getElementById("coLocalCount");
var titleEl = document.getElementById("coLocalTitle");
if (!countEl && !titleEl) return;
var count = state.localServers.length;
if (countEl) {
if (state.isScanningLocal) {
countEl.textContent = "Scanning";
} else {
countEl.textContent = count + " Found";
}
}
if (titleEl && state.activeTab === "local") {
if (state.isScanningLocal) {
titleEl.textContent = "Scanning...";
} else {
titleEl.textContent = count === 1 ? "1 Local Server" : count + " Local Servers";
}
}
}
function toggleFavoriteLocal(serverId) {
var server = findServerById(serverId);
if (!server) return;
server.favorite = !server.favorite;
sendAction("toggleFavorite", { serverId: serverId });
applyLocalFilters();
render();
}
function findServerById(id) {
for (var i = 0; i < state.allServers.length; i++) {
if (state.allServers[i].id === id) return state.allServers[i];
}
return null;
}
function findLocalServerById(id) {
for (var i = 0; i < state.localServers.length; i++) {
if (state.localServers[i].id === id) return state.localServers[i];
}
return null;
}
function getSelectedServer() {
return getActiveList()[state.selectedServerIndex] || null;
}
function getActiveList() {
if (state.activeTab === "favorites") return state.favorites;
if (state.activeTab === "recent") return state.recent;
if (state.activeTab === "local") return state.localServers;
return state.servers;
}
function recentEntryKey(server) {
if (!server) return "";
var host = server.host || server.address || "";
if (host) {
return host + ":" + portNumFrom(server.port);
}
return server.id || "";
}
function snapshotRecentEntry(server) {
return {
id: server.id || recentEntryKey(server),
name: server.name || "",
host: server.host || server.address || "",
port: portNumFrom(server.port),
region: server.region || "",
players: server.players || 0,
maxPlayers: server.maxPlayers || 0,
ping: server.ping || 0,
worldspace: server.worldspace || "",
mode: server.mode || "",
favorite: !!server.favorite
};
}
function loadRecent() {
try {
var raw = localStorage.getItem(RECENT_STORAGE_KEY);
if (!raw) return;
var parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
state.recent = parsed.slice(0, MAX_RECENT);
}
} catch (err) {
console.warn("[CO] Failed to load recent servers:", err);
}
}
function saveRecent() {
try {
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(state.recent));
} catch (err) {
console.warn("[CO] Failed to save recent servers:", err);
}
}
function joinServerEntry(server) {
if (!server) return;
var label = window.CO_ServerList
? CO_ServerList.displayName(server)
: (server.name || server.host || "server");
showJoinOverlay("Connecting to " + label + "...");
var browserEntry = server.id ? (findServerById(server.id) || findLocalServerById(server.id)) : null;
if (browserEntry) {
sendAction("joinServer", { serverId: server.id });
return;
}
var host = server.host || server.address;
if (host) {
sendAction("openDirectConnect", {
address: host,
port: String(portNumFrom(server.port))
});
return;
}
if (server.id) {
sendAction("joinServer", { serverId: server.id });
return;
}
showJoinOverlay("Server connection details unavailable.", true);
}
function findServerInActiveList(serverId) {
var list = getActiveList();
for (var i = 0; i < list.length; i++) {
if (list[i].id === serverId) return list[i];
}
return null;
}
function joinServerLocal(serverId) {
var server = findServerById(serverId) || findServerInActiveList(serverId);
if (!server) return;
joinServerEntry(server);
}
function validateDirectConnect(address, port) {
if (!address || !String(address).trim()) {
return "Server address is required.";
}
var portNum = parseInt(port, 10);
if (!port || isNaN(portNum) || portNum < 1 || portNum > 65535) {
return "Port must be a number between 1 and 65535.";
}
return null;
}
function joinDirectLocal(address, port) {
var validationError = validateDirectConnect(address, port);
if (validationError) {
showJoinOverlay(validationError, true);
return;
}
showJoinOverlay("Connecting to " + address + ":" + port + "...");
sendAction("openDirectConnect", { address: address, port: String(portNumFrom(port)) });
}
function portNumFrom(port) {
var portNum = parseInt(port, 10);
return isNaN(portNum) ? 7777 : portNum;
}
function showJoinOverlay(message, isError) {
state.joinOverlay = { message: message, error: !!isError };
joinOverlay.classList.remove("hidden");
if (isError) joinOverlay.classList.add("co-error");
else joinOverlay.classList.remove("co-error");
joinMessage.textContent = message;
var spinner = joinOverlay.querySelector(".co-spinner");
if (spinner) spinner.style.display = isError ? "none" : "block";
render();
}
function hideJoinOverlay() {
state.joinOverlay = null;
joinOverlay.classList.add("hidden");
render();
}
function switchTab(tabName, options) {
options = options || {};
state.activeTab = tabName;
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";
state.directConnectFieldIndex = 0;
} else if (tabName === "favorites" || tabName === "recent" || tabName === "local") {
var list = state.servers;
if (tabName === "favorites") list = state.favorites;
else if (tabName === "recent") list = state.recent;
else if (tabName === "local") list = state.localServers;
if (state.selectedServerIndex >= list.length) {
state.selectedServerIndex = Math.max(0, list.length - 1);
}
if (tabName === "local") {
state.focusZone = "server-list";
}
}
if (!options.skipAction) {
sendAction("switchTab", { tabName: tabName });
}
render();
}
function render() {
if (!root) return;
panel.classList.toggle("co-loading", state.isRefreshing || state.isScanningLocal);
CO_Tabs.render(root, state);
CO_ControllerPrompts.render(root, state);
var statusEl = document.getElementById("coConnectionStatus");
if (statusEl) {
statusEl.textContent = String(state.connectionStatus || "Online").toUpperCase();
}
document.querySelectorAll(".co-tab-pane").forEach(function (pane) {
pane.classList.toggle("active", pane.getAttribute("data-pane") === state.activeTab);
});
if (state.activeTab === "direct-connect") {
CO_DirectConnect.render(root, state);
} else {
var listServers = state.servers;
if (state.activeTab === "favorites") listServers = state.favorites;
if (state.activeTab === "recent") listServers = state.recent;
if (state.activeTab === "local") listServers = state.localServers;
var listState = Object.assign({}, state, { servers: listServers });
if (state.activeTab === "favorites" || state.activeTab === "recent" || state.activeTab === "local") {
listState.focusZone = state.focusZone === "filters" ? "server-list" : state.focusZone;
}
if (state.activeTab === "server-browser") {
CO_Filters.render(root, listState);
}
if (state.activeTab !== "direct-connect") {
CO_ServerDetails.render(root, listState);
}
var activePane = root.querySelector('.co-tab-pane[data-pane="' + state.activeTab + '"]');
if (activePane) {
var listEl = activePane.querySelector(".co-server-list");
if (listEl) {
var tempRoot = { querySelector: function (sel) {
if (sel === ".co-server-list-wrap") return activePane.querySelector(".co-server-list-wrap");
if (sel === ".co-server-list") return listEl;
return root.querySelector(sel);
}};
CO_ServerList.render(tempRoot, listState);
}
}
}
updateServerCount();
updateLocalCount();
}
function bindUi() {
if (window.CO_Hover) {
CO_Hover.bind(root);
}
CO_Tabs.bind(root, function (tabName) {
switchTab(tabName);
});
CO_Filters.bind(root, function (name, value) {
state.filters[name] = value;
sendAction("setFilter", { filterName: name, value: value });
applyLocalFilters();
render();
}, resetLocalFilters);
root.addEventListener("click", function (e) {
var row = e.target.closest(".co-tab-pane.active .co-server-row");
if (!row) return;
state.selectedServerIndex = parseInt(row.getAttribute("data-index"), 10);
render();
});
root.addEventListener("dblclick", function (e) {
var row = e.target.closest(".co-tab-pane.active .co-server-row");
if (!row) return;
state.selectedServerIndex = parseInt(row.getAttribute("data-index"), 10);
joinSelected();
});
CO_DirectConnect.bind(root, function (address, port) {
state.directConnect = { address: address, port: port };
joinDirectLocal(address, port);
});
document.addEventListener("keydown", onKeyDown);
}
function onKeyDown(e) {
if (state.joinOverlay && state.joinOverlay.error) {
if (e.key === "Escape" || e.key === "Enter" || e.key === "b" || e.key === "B") {
hideJoinOverlay();
return;
}
}
if (e.repeat) return;
handleControllerButton(mapKeyToButton(e.key), "down");
}
function mapKeyToButton(key) {
var map = {
ArrowUp: "dpad_up", ArrowDown: "dpad_down", ArrowLeft: "dpad_left", ArrowRight: "dpad_right",
Enter: "a", Escape: "b", " ": "a", r: "x", f: "y", q: "lb", e: "rb"
};
return map[key] || null;
}
function handleControllerButton(button, phase) {
if (!button || phase !== "down") return;
if (state.joinOverlay) {
if (button === "b" || (state.joinOverlay.error && button === "a")) {
hideJoinOverlay();
}
return;
}
if (button === "lb" || button === "rb") {
var idx = state.selectedTabIndex;
idx = button === "lb" ? idx - 1 : idx + 1;
if (idx < 0) idx = CO_Tabs.TAB_ORDER.length - 1;
if (idx >= CO_Tabs.TAB_ORDER.length) idx = 0;
switchTab(CO_Tabs.TAB_ORDER[idx]);
return;
}
if (button === "b") {
sendAction("closeBrowser", {});
return;
}
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);
}
return;
}
if (button === "a") {
joinSelected();
return;
}
if (button === "x") {
if (state.activeTab === "local") {
sendAction("scanLocalServers", {});
} else {
sendAction("refreshServerList", {});
}
return;
}
if (button === "y") {
var sel = getSelectedServer();
if (sel) toggleFavoriteLocal(sel.id);
return;
}
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;
joinServerEntry(sel);
}
function nextFocusZone(fromIdx, direction) {
var zones = FOCUS_ZONES.slice();
if (state.activeTab !== "server-browser") {
zones = zones.filter(function (z) { return z !== "filters"; });
}
var current = zones.indexOf(state.focusZone);
if (current < 0) current = fromIdx;
var next = current + direction;
if (next < 0 || next >= zones.length) return null;
return zones[next];
}
function navigateFocus(button) {
var list = getActiveList();
if (button === "dpad_left") {
var prevZone = nextFocusZone(FOCUS_ZONES.indexOf(state.focusZone), -1);
if (prevZone) {
state.focusZone = prevZone;
render();
}
return;
}
if (button === "dpad_right") {
if (state.activeTab === "direct-connect") return;
var nextZone = nextFocusZone(FOCUS_ZONES.indexOf(state.focusZone), 1);
if (nextZone) {
state.focusZone = nextZone;
render();
}
return;
}
if (state.focusZone === "tabs") {
if (button === "dpad_up" || button === "dpad_down") {
var dir = button === "dpad_up" ? -1 : 1;
state.selectedTabIndex = (state.selectedTabIndex + dir + CO_Tabs.TAB_ORDER.length) % CO_Tabs.TAB_ORDER.length;
switchTab(CO_Tabs.TAB_ORDER[state.selectedTabIndex]);
}
return;
}
if (state.focusZone === "filters") {
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") activateFocusedFilter();
render();
return;
}
if (state.focusZone === "server-list") {
if (button === "dpad_up") state.selectedServerIndex = Math.max(0, state.selectedServerIndex - 1);
if (button === "dpad_down") state.selectedServerIndex = Math.min(list.length - 1, state.selectedServerIndex + 1);
render();
CO_ServerList.scrollToSelected(root, state.selectedServerIndex);
return;
}
}
function startRepeat(action) {
stopRepeat();
repeatAction = action;
repeatTimer = setTimeout(function tick() {
if (repeatAction) repeatAction();
repeatTimer = setTimeout(tick, REPEAT_RATE_MS);
}, REPEAT_INITIAL_MS);
}
function stopRepeat() {
if (repeatTimer) clearTimeout(repeatTimer);
repeatTimer = null;
repeatAction = null;
}
window.dispatchCoEvent = function (event) {
if (!event || !event.type) return;
switch (event.type) {
case "serverListUpdated":
state.allServers = event.servers || [];
applyLocalFilters();
break;
case "serverListRefreshStarted":
state.isRefreshing = true;
break;
case "serverListRefreshFinished":
state.isRefreshing = false;
if (!event.success && event.error) {
console.warn("[CO] Refresh failed:", event.error);
}
break;
case "localScanStarted":
state.isScanningLocal = true;
break;
case "localServersUpdated":
state.localServers = event.servers || [];
if (state.activeTab === "local" && state.selectedServerIndex >= state.localServers.length) {
state.selectedServerIndex = Math.max(0, state.localServers.length - 1);
}
break;
case "localScanFinished":
state.isScanningLocal = false;
if (!event.success) {
console.warn("[CO] Local scan found no servers.", event.error || "");
}
break;
case "joinStarted":
showJoinOverlay("Connecting...");
break;
case "joinLoading":
showJoinOverlay(event.message || "Loading...");
break;
case "joinFailed":
showJoinOverlay(event.reason || "Connection failed", true);
break;
case "joinSucceeded":
hideJoinOverlay();
addRecent(event.server || event.serverId);
break;
case "connectionStatusChanged":
state.connectionStatus = event.status || "Online";
break;
case "uiTheme":
if (typeof window.applyCoTheme === "function") {
window.applyCoTheme(event);
}
break;
case "menuOpened":
break;
case "menuClosed":
hideJoinOverlay();
break;
case "controllerInput":
if (event.phase === "down") {
handleControllerButton(event.button, "down");
if (event.button === "dpad_up" || event.button === "dpad_down") {
startRepeat(function () { handleControllerButton(event.button, "down"); });
}
} else if (event.phase === "up") {
stopRepeat();
}
break;
case "uiSound":
// TODO: Wire to Fallout 4 menu sound via C++ when audio hooks are available.
break;
default:
break;
}
render();
};
function addRecent(serverOrId, serverSnapshot) {
var server = serverSnapshot;
if (!server) {
if (serverOrId && typeof serverOrId === "object") {
server = serverOrId;
} else if (serverOrId) {
server = findServerById(serverOrId);
}
}
if (!server) return;
var entry = snapshotRecentEntry(server);
var key = recentEntryKey(entry);
state.recent = state.recent.filter(function (s) {
return recentEntryKey(s) !== key && s.id !== entry.id;
});
state.recent.unshift(entry);
if (state.recent.length > MAX_RECENT) {
state.recent.length = MAX_RECENT;
}
saveRecent();
}
window.setConnectionStatus = function (connected, logPrefix) {
state.connectionStatus = connected ? "Connected" : "Online";
render();
};
window.onBrowserShown = function () {
if (window.CO_Layout && CO_Layout.update) {
CO_Layout.update();
}
dispatchCoEvent({ type: "menuOpened" });
render();
};
window.coAction = window.coAction || function () {
console.warn("[CO] coAction bridge not ready");
};
window.CO_App = {
joinSelected: joinSelected,
getState: function () { return state; },
setPointerMode: function (mode) {
if (!root) return;
var gamepad = mode === "gamepad";
root.classList.toggle("co-gamepad-mode", gamepad);
if (window.CO_Hover && CO_Hover.clear) {
CO_Hover.clear();
}
}
};
loadRecent();
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
applyLocalFilters();
bindUi();
window.addEventListener("resize", function () {
if (window.CO_Layout && CO_Layout.update) {
CO_Layout.update();
}
render();
var browser = window.CO_Browser.create({
mount: mount,
layout: "standalone",
exposeGlobals: true,
captureKeys: true
});
render();
console.log("[CO] Server browser loaded");
browser.show();
console.log("[CO] Standalone server browser loaded");
})();
@@ -0,0 +1,67 @@
/* Commonwealth Online — embedded server browser modal (host provides .browser-view) */
.browser-view {
position: absolute;
top: 50%;
left: calc(50% + 48px * var(--co-ui-scale));
transform: translate(-50%, calc(-50% + 10px * var(--co-ui-scale)));
z-index: 11;
width: min(calc(1080px * var(--co-ui-scale)), calc(100vw - 80px * var(--co-ui-scale)));
height: min(calc(720px * var(--co-ui-scale)), calc(100vh - 120px * var(--co-ui-scale)));
display: flex;
align-items: stretch;
min-width: 0;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 220ms ease-out,
transform 220ms ease-out,
visibility 0ms linear 220ms;
}
.browser-view.is-open {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translate(-50%, -50%);
transition:
opacity 220ms ease-out,
transform 220ms ease-out,
visibility 0ms linear 0ms;
}
.browser-view[hidden] {
display: none;
}
.browser-view .co-root {
position: relative;
width: 100%;
height: 100%;
pointer-events: auto;
}
.browser-view .co-root.co-browser--embedded {
background: transparent;
}
.browser-view .co-panel {
left: 0;
right: 0;
top: 0;
bottom: 0;
width: auto;
height: auto;
}
@media (prefers-reduced-motion: reduce) {
.browser-view {
transform: translate(-50%, -50%);
transition: opacity 120ms ease-out, visibility 0ms linear 120ms;
}
.browser-view.is-open {
transition: opacity 120ms ease-out, visibility 0ms linear 0ms;
}
}
File diff suppressed because it is too large Load Diff
+1 -85
View File
@@ -6,91 +6,6 @@
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div class="co-root" id="coRoot">
<div class="co-panel" id="coPanel">
<svg class="co-panel-border co-panel-border-top" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1075.97 11" preserveAspectRatio="none" aria-hidden="true">
<polyline class="co-panel-border-line" points="0 10.97 0 0 1075.97 0 1075.97 10.5"/>
</svg>
<svg class="co-panel-border co-panel-border-bottom" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1075.97 11" preserveAspectRatio="none" aria-hidden="true">
<polyline class="co-panel-border-line" points="0 0.47 0 8.91 1075.97 8.97 1075.97 0.47"/>
</svg>
<div class="co-subpanel">
<nav class="co-nav-list"></nav>
<div class="co-subpanel-content">
<div class="co-tab-content">
<div class="co-tab-pane" data-pane="direct-connect">
<header class="co-subpanel-header">
<h2 class="co-subpanel-title">Direct Connect</h2>
</header>
<div class="co-direct-connect"></div>
</div>
<div class="co-tab-pane active" data-pane="server-browser">
<header class="co-subpanel-header">
<h2 class="co-subpanel-title" id="coSubpanelTitle">Servers</h2>
<div class="co-subpanel-meta">
<span id="coConnectionStatus">Online</span>
<span class="co-count" id="coServerCount"></span>
</div>
</header>
<div class="co-filters"></div>
<div class="co-server-list-wrap">
<div class="co-server-list"></div>
<div class="co-scroll-indicator" aria-hidden="true">&raquo;&raquo;</div>
</div>
</div>
<div class="co-tab-pane" data-pane="local">
<header class="co-subpanel-header">
<h2 class="co-subpanel-title" id="coLocalTitle">Local Servers</h2>
<div class="co-subpanel-meta">
<span class="co-count" id="coLocalCount"></span>
</div>
</header>
<div class="co-server-list-wrap">
<div class="co-server-list co-local-list"></div>
<div class="co-scroll-indicator" aria-hidden="true">&raquo;&raquo;</div>
</div>
</div>
<div class="co-tab-pane" data-pane="favorites">
<header class="co-subpanel-header">
<h2 class="co-subpanel-title">Favorites</h2>
</header>
<div class="co-preview-area"></div>
<div class="co-server-list-wrap">
<div class="co-server-list co-favorites-list"></div>
<div class="co-scroll-indicator" aria-hidden="true">&raquo;&raquo;</div>
</div>
</div>
<div class="co-tab-pane" data-pane="recent">
<header class="co-subpanel-header">
<h2 class="co-subpanel-title">Recent</h2>
</header>
<div class="co-preview-area"></div>
<div class="co-server-list-wrap">
<div class="co-server-list co-recent-list"></div>
<div class="co-scroll-indicator" aria-hidden="true">&raquo;&raquo;</div>
</div>
</div>
</div>
</div>
</div>
<footer class="co-action-bar">
<div class="co-prompts"></div>
</footer>
<div class="co-join-overlay hidden" id="coJoinOverlay">
<div class="co-spinner"></div>
<p id="coJoinMessage">Connecting...</p>
</div>
</div>
</div>
<script src="./components/layout.js"></script>
<script src="./components/theme.js"></script>
<script src="./components/mock-data.js"></script>
@@ -103,6 +18,7 @@
<script src="./components/controller-prompts.js"></script>
<script src="./components/direct-connect.js"></script>
<script src="./components/hover.js"></script>
<script src="./browser.js"></script>
<script src="./app.js"></script>
</body>
</html>
@@ -5,6 +5,8 @@
<title>Commonwealth Online — Main Menu</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="../settings/settings.css">
<link rel="stylesheet" href="../browser/styles.css">
<link rel="stylesheet" href="../browser/browser-modal.css">
</head>
<body>
<div class="main-screen" id="mainScreen">
@@ -56,6 +58,9 @@
<!-- Settings view (content mounted by ../settings/settings.js) -->
<section class="settings-view" id="settingsPanel" aria-label="Settings" hidden></section>
<!-- Server browser view (content mounted by ../browser/browser.js) -->
<section class="browser-view" id="browserPanel" aria-label="Server browser" hidden></section>
<!-- Bottom controller prompts (main menu) -->
<footer class="prompt-bar" id="mainPromptBar" aria-label="Controller prompts">
<div class="prompt">
@@ -103,6 +108,19 @@
<script src="../settings/settings-data.js"></script>
<script src="../settings/settings.js"></script>
<script src="../browser/components/layout.js"></script>
<script src="../browser/components/theme.js"></script>
<script src="../browser/components/mock-data.js"></script>
<script src="../browser/components/tabs.js"></script>
<script src="../browser/components/filters.js"></script>
<script src="../browser/components/server-list.js"></script>
<script src="../browser/components/server-details.js"></script>
<script src="../browser/components/controller-glyphs.js"></script>
<script src="../browser/components/controller-icons.js"></script>
<script src="../browser/components/controller-prompts.js"></script>
<script src="../browser/components/direct-connect.js"></script>
<script src="../browser/components/hover.js"></script>
<script src="../browser/browser.js"></script>
<script src="script.js"></script>
</body>
</html>
+220 -31
View File
@@ -17,34 +17,18 @@
}
};
// ── Pointer mode API for C++ invocation ─────────────────────────
window.CO_App = {
setPointerMode: function (mode) {
console.log("[CO Main Menu] Pointer mode:", mode);
}
};
// ── Pointer mode API for C++ invocation (patched after browser mount) ──
window.CO_App = {};
// ── Controller input handler ─────────────────────────────────────
window.handleControllerInput = function (button, phase) {
if (phase === "down") {
switch (button) {
case "dpad_up":
moveSelection(-1);
break;
case "dpad_down":
moveSelection(1);
break;
case "a":
activateSelected();
break;
case "b":
if (viewMode === "settings") {
exitSettingsMode();
}
break;
}
function sendCoAction(payload) {
var msg = JSON.stringify(payload);
if (typeof window.coAction === "function") {
window.coAction(msg);
return true;
}
};
console.warn("[CO Main Menu] coAction bridge unavailable:", payload);
return false;
}
// ── Compute UI scale ───────────────────────────────────────────
function updateScale() {
@@ -180,24 +164,29 @@
var mainScreen = document.getElementById("mainScreen");
var menu = document.getElementById("menu");
var settingsPanel = document.getElementById("settingsPanel");
var browserPanel = document.getElementById("browserPanel");
var mainPromptBar = document.getElementById("mainPromptBar");
var settingsPromptBar = document.getElementById("settingsPromptBar");
if (!menu || !settingsPanel || !window.CO_Settings) {
if (!menu || !settingsPanel || !browserPanel || !window.CO_Settings || !window.CO_Browser) {
return;
}
var settingsController = null;
var browserController = null;
var items = Array.prototype.slice.call(menu.querySelectorAll(".menu-item"));
var MULTIPLAYER_INDEX = 0;
var SETTINGS_INDEX = 2;
// viewMode: "menu" | "settings"
// viewMode: "menu" | "settings" | "browser"
var viewMode = "menu";
var selectedIndex = -1;
var SETTINGS_TRANSITION_MS = 220;
var PANEL_TRANSITION_MS = 220;
var settingsHideFallbackId = 0;
var settingsHideOnTransitionEnd = null;
var browserHideFallbackId = 0;
var browserHideOnTransitionEnd = null;
// ── Main menu selection ────────────────────────────────────────
@@ -252,6 +241,8 @@
if (action === "settings") {
enterSettingsMode();
} else if (action === "multiplayer") {
enterBrowserMode();
} else {
window.CommonwealthOnlineUI.onOpenView(action);
if (action !== "quit") {
@@ -267,6 +258,11 @@
return;
}
exitSettingsMode();
} else if (viewMode === "browser") {
if (index === MULTIPLAYER_INDEX) {
return;
}
exitBrowserMode();
}
if (item.classList.contains("is-disabled")) {
return;
@@ -342,10 +338,151 @@
};
settingsPanel.addEventListener("transitionend", settingsHideOnTransitionEnd);
settingsHideFallbackId = window.setTimeout(finishHide, SETTINGS_TRANSITION_MS + 80);
settingsHideFallbackId = window.setTimeout(finishHide, PANEL_TRANSITION_MS + 80);
}
function cancelBrowserPanelHide() {
if (browserHideFallbackId) {
window.clearTimeout(browserHideFallbackId);
browserHideFallbackId = 0;
}
if (browserHideOnTransitionEnd) {
browserPanel.removeEventListener("transitionend", browserHideOnTransitionEnd);
browserHideOnTransitionEnd = null;
}
}
function showBrowserPanelAnimated() {
cancelBrowserPanelHide();
if (mainScreen) {
mainScreen.classList.add("is-browser-mode");
}
browserPanel.hidden = false;
browserPanel.classList.remove("is-open");
void browserPanel.offsetWidth;
browserPanel.classList.add("is-open");
}
function hideBrowserPanelAnimated(done) {
if (browserPanel.hidden) {
if (done) {
done();
}
return;
}
cancelBrowserPanelHide();
browserPanel.classList.remove("is-open");
var completed = false;
function finishHide() {
if (completed) {
return;
}
completed = true;
cancelBrowserPanelHide();
if (viewMode === "browser" || browserPanel.classList.contains("is-open")) {
if (done) {
done();
}
return;
}
if (mainScreen) {
mainScreen.classList.remove("is-browser-mode");
}
browserPanel.hidden = true;
if (done) {
done();
}
}
browserHideOnTransitionEnd = function (event) {
if (event.target !== browserPanel || event.propertyName !== "opacity") {
return;
}
finishHide();
};
browserPanel.addEventListener("transitionend", browserHideOnTransitionEnd);
browserHideFallbackId = window.setTimeout(finishHide, PANEL_TRANSITION_MS + 80);
}
function enterBrowserMode(fromNative) {
if (viewMode === "browser") {
if (fromNative) {
browserController.onShown();
}
return;
}
if (viewMode === "settings") {
exitSettingsMode();
}
viewMode = "browser";
console.log("[CO Main Menu] Entering browser mode");
window.CommonwealthOnlineUI.onOpenView("multiplayer");
setSelected(MULTIPLAYER_INDEX);
showBrowserPanelAnimated();
if (mainPromptBar) {
mainPromptBar.hidden = true;
}
browserController.show();
browserController.onShown();
if (!fromNative) {
sendCoAction({ action: "openView", view: "multiplayer" });
}
}
function exitBrowserMode(fromNative) {
if (viewMode !== "browser") {
return;
}
viewMode = "menu";
console.log("[CO Main Menu] Exiting browser mode");
window.CommonwealthOnlineUI.onBack();
browserController.hide();
hideBrowserPanelAnimated(function () {
if (mainPromptBar) {
mainPromptBar.hidden = false;
}
if (viewMode === "menu") {
setSelected(MULTIPLAYER_INDEX);
}
});
if (!fromNative) {
sendCoAction({ action: "closeBrowser" });
}
}
window.onBrowserShown = function () {
enterBrowserMode(true);
};
window.closeBrowser = function () {
exitBrowserMode(true);
};
function enterSettingsMode() {
if (viewMode === "browser") {
cancelBrowserPanelHide();
browserController.hide();
browserPanel.classList.remove("is-open");
browserPanel.hidden = true;
if (mainScreen) {
mainScreen.classList.remove("is-browser-mode");
}
}
viewMode = "settings";
console.log("[CO Main Menu] Entering settings mode");
@@ -376,7 +513,9 @@
if (mainPromptBar) {
mainPromptBar.hidden = false;
}
setSelected(SETTINGS_INDEX);
if (viewMode === "menu") {
setSelected(SETTINGS_INDEX);
}
});
}
@@ -390,6 +529,22 @@
}
});
browserController = window.CO_Browser.create({
mount: browserPanel,
layout: "embedded",
exposeGlobals: true,
captureKeys: false,
onBack: function () {
exitBrowserMode();
}
});
window.CO_App.setPointerMode = function (mode) {
if (viewMode === "browser") {
browserController.setPointerMode(mode);
}
};
// ── Keyboard navigation ────────────────────────────────────────
function handleMenuKeydown(event) {
@@ -422,7 +577,41 @@
settingsController.handleKeydown(event);
return;
}
if (viewMode === "browser") {
browserController.handleKeydown(event);
return;
}
handleMenuKeydown(event);
});
window.handleControllerInput = function (button, phase) {
if (phase === "down") {
if (viewMode === "browser") {
browserController.handleControllerInput(button, phase);
return;
}
if (viewMode === "settings") {
return;
}
switch (button) {
case "dpad_up":
moveSelection(-1);
break;
case "dpad_down":
moveSelection(1);
break;
case "a":
activateSelected();
break;
case "b":
if (viewMode === "browser") {
exitBrowserMode();
} else if (viewMode === "settings") {
exitSettingsMode();
}
break;
}
}
};
})();
@@ -406,3 +406,32 @@ body {
),
rgba(4, 8, 6, 0.32);
}
/* Browser mode: keep main nav, dim background more */
.main-screen.is-browser-mode .left-panel {
overflow: hidden;
background:
linear-gradient(
90deg,
rgba(8, 14, 10, 0.9) 0%,
rgba(8, 14, 10, 0.78) 65%,
rgba(8, 14, 10, 0.15) 100%
);
}
.main-screen.is-browser-mode .menu-item__bar {
width: calc(100% + 28px * var(--co-ui-scale));
}
.main-screen.is-browser-mode .scene-overlay {
background:
linear-gradient(
90deg,
rgba(6, 10, 8, 0.88) 0%,
rgba(6, 10, 8, 0.55) 24%,
rgba(6, 10, 8, 0.4) 55%,
rgba(6, 10, 8, 0.48) 100%
),
rgba(4, 8, 6, 0.32);
}