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
+8 -1
View File
@@ -10,12 +10,12 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
## [Unreleased] ## [Unreleased]
### Fixed ### Fixed
- Main menu left-nav highlight no longer swaps when switching between Multiplayer browser and Settings modals (stale panel-close callbacks were overwriting `setSelected` after the next modal opened).
- Mouse cursor no longer trampolines back to a centered box in the custom main menu (previously only worked with a gamepad connected). In mouse mode the title-screen engine repositions/clips the OS cursor to a small centered box every frame; with a gamepad active that path never runs, so the pointer was free. Fixed by intercepting `SetCursorPos` and `ClipCursor` in the Fallout4.exe import table (IAT) and making them no-ops while the Commonwealth Online overlay owns the pointer, so the game can no longer move or box in the Windows cursor. Only the game module's imports are patched, leaving PrismaUI's own cursor rendering untouched. Also hooks the `MainMenu` `OnCursorMoveEvent`/`OnMouseMoveEvent` vtable slots and keeps the `MenuCursor` release (`forceOSCursorPos = false`, cleared parallax constraints, widened bounds) as defense-in-depth. - Mouse cursor no longer trampolines back to a centered box in the custom main menu (previously only worked with a gamepad connected). In mouse mode the title-screen engine repositions/clips the OS cursor to a small centered box every frame; with a gamepad active that path never runs, so the pointer was free. Fixed by intercepting `SetCursorPos` and `ClipCursor` in the Fallout4.exe import table (IAT) and making them no-ops while the Commonwealth Online overlay owns the pointer, so the game can no longer move or box in the Windows cursor. Only the game module's imports are patched, leaving PrismaUI's own cursor rendering untouched. Also hooks the `MainMenu` `OnCursorMoveEvent`/`OnMouseMoveEvent` vtable slots and keeps the `MenuCursor` release (`forceOSCursorPos = false`, cleared parallax constraints, widened bounds) as defense-in-depth.
### Added ### Added
- **Custom main menu overlay**: PrismaUI full-screen overlay of Commonwealth Online main menu (MULTIPLAYER, CREATIONS, SETTINGS, HELP, QUIT) auto-shows on Fallout 4 title screen, replacing the vanilla list menu. - **Custom main menu overlay**: PrismaUI full-screen overlay of Commonwealth Online main menu (MULTIPLAYER, CREATIONS, SETTINGS, HELP, QUIT) auto-shows on Fallout 4 title screen, replacing the vanilla list menu.
- **Main menu input capture**: Keyboard (↑↓ / ENTER / ESC) and gamepad (dpad / A / B) navigation of custom main menu overlay; vanilla menu input is blocked so the underlying CONTINUE/NEW/LOAD/SETTINGS rows remain unreachable. - **Main menu input capture**: Keyboard (↑↓ / ENTER / ESC) and gamepad (dpad / A / B) navigation of custom main menu overlay; vanilla menu input is blocked so the underlying CONTINUE/NEW/LOAD/SETTINGS rows remain unreachable.
- **Main menu→multiplayer bridge**: MULTIPLAYER item opens server browser overlay (hiding main menu); closing browser returns to main menu.
- **Main menu vanilla settings gateway**: SETTINGS → GAME SETTINGS → OPEN FALLOUT 4 SETTINGS calls `openGameSettings` stub (full vanilla settings sub-flow is planned for v1.1). - **Main menu vanilla settings gateway**: SETTINGS → GAME SETTINGS → OPEN FALLOUT 4 SETTINGS calls `openGameSettings` stub (full vanilla settings sub-flow is planned for v1.1).
- `F4TMainMenuBridge.cpp` and `F4TMainMenuBridge.h` for main menu↔C++ event routing (controller input logging; full JS dispatch pending). - `F4TMainMenuBridge.cpp` and `F4TMainMenuBridge.h` for main menu↔C++ event routing (controller input logging; full JS dispatch pending).
- `F4TMainMenuVanillaSettings.cpp` and `F4TMainMenuVanillaSettings.h` discovery stubs for vanilla settings panel access (ready for GFx work). - `F4TMainMenuVanillaSettings.cpp` and `F4TMainMenuVanillaSettings.h` discovery stubs for vanilla settings panel access (ready for GFx work).
@@ -23,6 +23,13 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
- `SetMainMenuActive(bool)` in F4TMenuInput to route gamepad input to main menu or server browser (active target determines recipient). - `SetMainMenuActive(bool)` in F4TMenuInput to route gamepad input to main menu or server browser (active target determines recipient).
- Unified pointer mode tracking and mouse/gamepad mode switching for both main menu and server browser overlays (single active menu at a time). - Unified pointer mode tracking and mouse/gamepad mode switching for both main menu and server browser overlays (single active menu at a time).
### Changed
- Server browser extracted into reusable `CO_Browser.create()` module (`browser/browser.js`, `browser/browser-modal.css`) matching the settings modal pattern; standalone `browser/index.html` still works for F9/dev via thin `app.js` bootstrap.
- Main menu MULTIPLAYER now opens the server browser as an embedded modal (left nav stays visible, background dims) instead of swapping to a separate PrismaUI view.
- `F4TPrismaUI` routes `ServerBrowserBridge`, `coAction`, `closeBrowser`, and hover sync through the main menu view when the embedded browser is active; all JS hooks (`dispatchCoEvent`, `onBrowserShown`, `closeBrowser`, `setConnectionStatus`, `CO_App`) are unchanged.
- `ServerBrowserBridge::SetView()` added so the C++ bridge can target the active PrismaUI view.
- **Main menu→multiplayer bridge**: MULTIPLAYER opens the server browser as a modal overlay on the main menu (main menu stays visible); closing the browser returns to the main menu list.
### Changed ### Changed
- F4TPrismaUI renamed internal globals for clarity: `g_visible``g_browserVisible`, `g_domReady``g_browserDomReady`, `g_browserMouseCursorVisible``g_menuMouseCursorVisible`, `g_browserPreferGamepad``g_menuPreferGamepad` (shared across both views). - F4TPrismaUI renamed internal globals for clarity: `g_visible``g_browserVisible`, `g_domReady``g_browserDomReady`, `g_browserMouseCursorVisible``g_menuMouseCursorVisible`, `g_browserPreferGamepad``g_menuPreferGamepad` (shared across both views).
- Multiplayer row injection is now gated behind `constexpr bool kInjectMultiplayerRow = false` in F4TMainMenuInject.cpp. The custom main menu overlay supersedes the injected row; existing code is preserved for v1.1 opt-in. - Multiplayer row injection is now gated behind `constexpr bool kInjectMultiplayerRow = false` in F4TMainMenuInject.cpp. The custom main menu overlay supersedes the injected row; existing code is preserved for v1.1 opt-in.
+31
View File
@@ -240,6 +240,37 @@ See corrected entry above for the full investigation and actual root cause. This
### Next Steps ### Next Steps
- See corrected entry above. - See corrected entry above.
## 2026-07-07 - Server Browser Main Menu Modal
### Summary
Refactored the server browser into a reusable `CO_Browser.create()` module (same pattern as settings) and embedded it in the custom main menu as a modal overlay. MULTIPLAYER now opens the browser in-place while keeping the left nav visible; B/back closes the modal and returns to the menu list. All existing C++↔JS hooks are preserved (`coAction`, `dispatchCoEvent`, `onBrowserShown`, `closeBrowser`, `setConnectionStatus`, `CO_App`).
### Files Changed
- `ui/views/CommonwealthOnline/browser/browser.js` (new reusable module)
- `ui/views/CommonwealthOnline/browser/browser-modal.css` (new embedded modal styles)
- `ui/views/CommonwealthOnline/browser/app.js` (standalone bootstrap)
- `ui/views/CommonwealthOnline/browser/index.html` (uses `CO_Browser.create()`)
- `ui/views/CommonwealthOnline/main-menu/index.html`, `script.js`, `styles.css`
- `plugin/src/F4TPrismaUI.cpp`
- `plugin/include/F4TServerBrowserBridge.h`, `plugin/src/F4TServerBrowserBridge.cpp`
- `changelog.md`
### Details
- Browser modal uses the same fade/slide animation pattern as the settings panel (`is-browser-mode` on `.main-screen`).
- `F4TPrismaUI::ShowServerBrowser()` now ensures the main menu is visible and invokes `onBrowserShown` on the main menu view instead of hiding it and showing a separate browser PrismaUI view.
- `ServerBrowserBridge::SetView()` lets the bridge dispatch events to whichever view hosts the browser UI.
- Standalone `browser/index.html` remains available for F9/dev testing with unchanged global hooks.
### Testing
- Not run in this session (requires in-game PrismaUI).
### Known Issues
- None currently known.
### Next Steps
- In-game verify MULTIPLAYER opens modal, server list populates via `dispatchCoEvent`, join/refresh actions reach C++, and B closes back to main menu.
- Confirm gamepad routing still reaches browser via `ServerBrowserBridge::ForwardControllerInput` when browser modal is open.
## 2026-07-06 - Main Menu PrismaUI Overlay + Input Capture ## 2026-07-06 - Main Menu PrismaUI Overlay + Input Capture
### Summary ### Summary
+1
View File
@@ -10,6 +10,7 @@ namespace F4T::ServerBrowser
{ {
public: public:
static void Initialize(PRISMA_UI_API::IVPrismaUI4* a_api, PrismaView a_view); static void Initialize(PRISMA_UI_API::IVPrismaUI4* a_api, PrismaView a_view);
static void SetView(PrismaView a_view);
static void OnBrowserShown(); static void OnBrowserShown();
static void OnBrowserHidden(); static void OnBrowserHidden();
static void HandleAction(const nlohmann::json& a_action); static void HandleAction(const nlohmann::json& a_action);
+103 -26
View File
@@ -66,6 +66,8 @@ namespace F4T::PrismaUI
void CreateServerBrowserView(); void CreateServerBrowserView();
void SyncBrowserHover(); void SyncBrowserHover();
void WatchMainMenuState(); void WatchMainMenuState();
[[nodiscard]] PrismaView GetActiveBrowserView();
void ActivateEmbeddedBrowser();
void InstallMainMenuCursorReleaseHook(); void InstallMainMenuCursorReleaseHook();
void RegisterMenuAdvanceSink(); void RegisterMenuAdvanceSink();
void ReleaseMenuCursorConstraints(); void ReleaseMenuCursorConstraints();
@@ -473,7 +475,8 @@ namespace F4T::PrismaUI
void SyncBrowserHover() void SyncBrowserHover()
{ {
if (!g_browserVisible || !g_browserDomReady || !g_api || !g_api->IsValid(g_serverBrowserView)) { const auto activeView = GetActiveBrowserView();
if (!g_browserVisible || !g_api || activeView == 0 || !g_api->IsValid(activeView)) {
return; return;
} }
@@ -493,12 +496,48 @@ namespace F4T::PrismaUI
::ScreenToClient(reinterpret_cast<HWND>(renderWindow->hwnd), &cursor); ::ScreenToClient(reinterpret_cast<HWND>(renderWindow->hwnd), &cursor);
const auto script = std::format( const auto script = std::format(
"if(window.CO_Hover&&window.CO_Hover.updateFromPoint){{window.CO_Hover.updateFromPoint({},{});}}", "if(window.CO_Hover&&window.CO_Hover.updateFromPoint){{window.CO_Hover.updateFromPoint({},{});}}",
cursor.x, cursor.x,
cursor.y); cursor.y);
g_api->Invoke(g_serverBrowserView, script.c_str()); g_api->Invoke(activeView, script.c_str());
} }
PrismaView GetActiveBrowserView()
{
if (!g_api) {
return 0;
}
if (g_browserVisible && g_mainMenuVisible && g_mainMenuDomReady && g_api->IsValid(g_mainMenuView)) {
return g_mainMenuView;
}
if (g_browserVisible && g_browserDomReady && g_api->IsValid(g_serverBrowserView)) {
return g_serverBrowserView;
}
return 0;
}
void ActivateEmbeddedBrowser()
{
if (g_browserVisible) {
return;
}
g_browserVisible = true;
F4T::MenuInput::SetBrowserActive(true);
F4T::MainMenuInject::SetMainMenuInputSuppressed(true);
if (g_api && g_mainMenuDomReady && g_api->IsValid(g_mainMenuView)) {
F4T::ServerBrowser::ServerBrowserBridge::SetView(g_mainMenuView);
}
InitializeBrowserPointerMode();
ApplyBrowserPointerMode(!g_menuPreferGamepad);
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserShown();
}
void DispatchMainMenuControllerInput(const char* a_button, const char* a_phase) void DispatchMainMenuControllerInput(const char* a_button, const char* a_phase)
{ {
@@ -518,16 +557,24 @@ namespace F4T::PrismaUI
const bool wasVisible = g_browserVisible; const bool wasVisible = g_browserVisible;
g_browserVisible = false; g_browserVisible = false;
F4T::MenuInput::SetBrowserActive(false); F4T::MenuInput::SetBrowserActive(false);
F4T::MainMenuInject::SetMainMenuInputSuppressed(false); F4T::MainMenuInject::SetMainMenuInputSuppressed(g_mainMenuVisible);
if (!g_api || !g_api->IsValid(g_serverBrowserView)) {
return;
}
if (wasVisible) { if (wasVisible) {
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserHidden(); F4T::ServerBrowser::ServerBrowserBridge::OnBrowserHidden();
} }
if (g_api && g_mainMenuVisible && g_mainMenuDomReady && g_api->IsValid(g_mainMenuView)) {
g_api->Invoke(
g_mainMenuView,
"if(window.closeBrowser){window.closeBrowser();}");
return;
}
if (!g_api || !g_api->IsValid(g_serverBrowserView)) {
RequestNativeCursorRestore();
return;
}
g_api->Unfocus(g_serverBrowserView); g_api->Unfocus(g_serverBrowserView);
g_api->Hide(g_serverBrowserView); g_api->Hide(g_serverBrowserView);
RequestNativeCursorRestore(); RequestNativeCursorRestore();
@@ -552,6 +599,12 @@ namespace F4T::PrismaUI
void HideMainMenuInternal() void HideMainMenuInternal()
{ {
if (g_browserVisible) {
g_browserVisible = false;
F4T::MenuInput::SetBrowserActive(false);
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserHidden();
}
const bool wasVisible = g_mainMenuVisible; const bool wasVisible = g_mainMenuVisible;
g_mainMenuVisible = false; g_mainMenuVisible = false;
F4T::MenuInput::SetMainMenuActive(false); F4T::MenuInput::SetMainMenuActive(false);
@@ -595,6 +648,9 @@ namespace F4T::PrismaUI
F4T::MainMenu::MainMenuBridge::Initialize(); F4T::MainMenu::MainMenuBridge::Initialize();
g_mainMenuBridgeReady = true; g_mainMenuBridgeReady = true;
F4T::ServerBrowser::ServerBrowserBridge::Initialize(g_api, a_view);
F4T::ServerBrowser::ServerBrowserBridge::PushUITheme();
g_api->BindUIEvent(a_view, "coAction", [](const char* a_json) { g_api->BindUIEvent(a_view, "coAction", [](const char* a_json) {
if (!a_json) { if (!a_json) {
return; return;
@@ -607,10 +663,12 @@ namespace F4T::PrismaUI
if (actionName == "openView") { if (actionName == "openView") {
const auto viewName = action.value("view", ""); const auto viewName = action.value("view", "");
if (viewName == "multiplayer") { if (viewName == "multiplayer") {
HideMainMenuInternal(); ActivateEmbeddedBrowser();
F4T::PrismaUI::ShowServerBrowser();
return; return;
} }
} else if (actionName == "closeBrowser") {
HideServerBrowserInternal();
return;
} else if (actionName == "openGameSettings") { } else if (actionName == "openGameSettings") {
LogInfo("openGameSettings requested from main menu."); LogInfo("openGameSettings requested from main menu.");
F4T::MainMenuVanillaSettings::OpenGameSettings(); F4T::MainMenuVanillaSettings::OpenGameSettings();
@@ -619,11 +677,33 @@ namespace F4T::PrismaUI
LogInfo("quit requested from main menu (not yet implemented)."); LogInfo("quit requested from main menu (not yet implemented).");
return; return;
} }
if (!actionName.empty()) {
F4T::ServerBrowser::ServerBrowserBridge::HandleAction(action);
}
} catch (const std::exception& ex) { } catch (const std::exception& ex) {
REX::WARN("{} [JS] Failed to parse coAction JSON: {}", GetLogPrefix(), ex.what()); REX::WARN("{} [JS] Failed to parse coAction JSON: {}", GetLogPrefix(), ex.what());
} }
}); });
g_api->BindUIEvent(a_view, "closeBrowser", [](const char*) {
HideServerBrowserInternal();
});
g_api->BindUIEvent(a_view, "connectServer", [](const char*) {
if (!F4T::Networking::IsConnectedToServer()) {
F4T::Networking::ConnectToLocalServer();
}
F4T::ServerBrowser::ServerBrowserBridge::PushConnectionStatus();
});
g_api->BindUIEvent(a_view, "disconnectServer", [](const char*) {
F4T::Networking::DisconnectFromLocalServer();
F4T::ServerBrowser::ServerBrowserBridge::PushConnectionStatus();
});
F4T::ServerBrowser::ServerBrowserBridge::PushConnectionStatus();
if (g_mainMenuPendingShow) { if (g_mainMenuPendingShow) {
ShowMainMenuInternal(); ShowMainMenuInternal();
} }
@@ -694,27 +774,24 @@ namespace F4T::PrismaUI
return; return;
} }
if (!g_api->IsValid(g_serverBrowserView)) { if (!g_api->IsValid(g_mainMenuView)) {
g_mainMenuPendingShow = true; g_mainMenuPendingShow = true;
CreateServerBrowserView(); CreateMainMenuView();
return; return;
} }
if (!g_browserDomReady) { if (!g_mainMenuDomReady) {
g_mainMenuPendingShow = true; g_mainMenuPendingShow = true;
return; return;
} }
g_mainMenuPendingShow = false; if (!g_mainMenuVisible) {
g_browserVisible = true; ShowMainMenuInternal();
F4T::MenuInput::SetBrowserActive(true); }
F4T::MainMenuInject::SetMainMenuInputSuppressed(true);
InitializeBrowserPointerMode(); ActivateEmbeddedBrowser();
g_api->Show(g_serverBrowserView);
ApplyBrowserPointerMode(!g_menuPreferGamepad);
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserShown();
g_api->Invoke( g_api->Invoke(
g_serverBrowserView, g_mainMenuView,
"if(window.onBrowserShown){window.onBrowserShown();}"); "if(window.onBrowserShown){window.onBrowserShown();}");
} }
+5
View File
@@ -377,6 +377,11 @@ namespace F4T::ServerBrowser
g_data.SetAllServers(ServerBrowserData::CreateMockServers()); g_data.SetAllServers(ServerBrowserData::CreateMockServers());
} }
void ServerBrowserBridge::SetView(PrismaView a_view)
{
g_view = a_view;
}
void ServerBrowserBridge::OnBrowserShown() void ServerBrowserBridge::OnBrowserShown()
{ {
PushUIThemeInternal(); PushUIThemeInternal();
+12 -838
View File
@@ -1,845 +1,19 @@
(function () { (function () {
"use strict"; "use strict";
var root = document.getElementById("coRoot"); var mount = document.body;
var panel = document.getElementById("coPanel"); if (!window.CO_Browser) {
var joinOverlay = document.getElementById("coJoinOverlay"); console.error("[CO] CO_Browser module is required before app.js");
var joinMessage = document.getElementById("coJoinMessage"); return;
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 || {});
} }
function handleActionLocal(action, payload) { var browser = window.CO_Browser.create({
switch (action) { mount: mount,
case "refreshServerList": layout: "standalone",
refreshLocalMock(); exposeGlobals: true,
break; captureKeys: true
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();
}); });
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"> <link rel="stylesheet" href="./styles.css">
</head> </head>
<body> <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/layout.js"></script>
<script src="./components/theme.js"></script> <script src="./components/theme.js"></script>
<script src="./components/mock-data.js"></script> <script src="./components/mock-data.js"></script>
@@ -103,6 +18,7 @@
<script src="./components/controller-prompts.js"></script> <script src="./components/controller-prompts.js"></script>
<script src="./components/direct-connect.js"></script> <script src="./components/direct-connect.js"></script>
<script src="./components/hover.js"></script> <script src="./components/hover.js"></script>
<script src="./browser.js"></script>
<script src="./app.js"></script> <script src="./app.js"></script>
</body> </body>
</html> </html>
@@ -5,6 +5,8 @@
<title>Commonwealth Online — Main Menu</title> <title>Commonwealth Online — Main Menu</title>
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="../settings/settings.css"> <link rel="stylesheet" href="../settings/settings.css">
<link rel="stylesheet" href="../browser/styles.css">
<link rel="stylesheet" href="../browser/browser-modal.css">
</head> </head>
<body> <body>
<div class="main-screen" id="mainScreen"> <div class="main-screen" id="mainScreen">
@@ -56,6 +58,9 @@
<!-- Settings view (content mounted by ../settings/settings.js) --> <!-- Settings view (content mounted by ../settings/settings.js) -->
<section class="settings-view" id="settingsPanel" aria-label="Settings" hidden></section> <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) --> <!-- 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">
@@ -103,6 +108,19 @@
<script src="../settings/settings-data.js"></script> <script src="../settings/settings-data.js"></script>
<script src="../settings/settings.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> <script src="script.js"></script>
</body> </body>
</html> </html>
+220 -31
View File
@@ -17,34 +17,18 @@
} }
}; };
// ── Pointer mode API for C++ invocation ───────────────────────── // ── Pointer mode API for C++ invocation (patched after browser mount) ──
window.CO_App = { window.CO_App = {};
setPointerMode: function (mode) {
console.log("[CO Main Menu] Pointer mode:", mode);
}
};
// ── Controller input handler ───────────────────────────────────── function sendCoAction(payload) {
window.handleControllerInput = function (button, phase) { var msg = JSON.stringify(payload);
if (phase === "down") { if (typeof window.coAction === "function") {
switch (button) { window.coAction(msg);
case "dpad_up": return true;
moveSelection(-1);
break;
case "dpad_down":
moveSelection(1);
break;
case "a":
activateSelected();
break;
case "b":
if (viewMode === "settings") {
exitSettingsMode();
}
break;
}
} }
}; console.warn("[CO Main Menu] coAction bridge unavailable:", payload);
return false;
}
// ── Compute UI scale ─────────────────────────────────────────── // ── Compute UI scale ───────────────────────────────────────────
function updateScale() { function updateScale() {
@@ -180,24 +164,29 @@
var mainScreen = document.getElementById("mainScreen"); var mainScreen = document.getElementById("mainScreen");
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 mainPromptBar = document.getElementById("mainPromptBar"); var mainPromptBar = document.getElementById("mainPromptBar");
var settingsPromptBar = document.getElementById("settingsPromptBar"); var settingsPromptBar = document.getElementById("settingsPromptBar");
if (!menu || !settingsPanel || !window.CO_Settings) { if (!menu || !settingsPanel || !browserPanel || !window.CO_Settings || !window.CO_Browser) {
return; return;
} }
var settingsController = null; var settingsController = 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 MULTIPLAYER_INDEX = 0;
var SETTINGS_INDEX = 2; var SETTINGS_INDEX = 2;
// viewMode: "menu" | "settings" // viewMode: "menu" | "settings" | "browser"
var viewMode = "menu"; var viewMode = "menu";
var selectedIndex = -1; var selectedIndex = -1;
var SETTINGS_TRANSITION_MS = 220; var PANEL_TRANSITION_MS = 220;
var settingsHideFallbackId = 0; var settingsHideFallbackId = 0;
var settingsHideOnTransitionEnd = null; var settingsHideOnTransitionEnd = null;
var browserHideFallbackId = 0;
var browserHideOnTransitionEnd = null;
// ── Main menu selection ──────────────────────────────────────── // ── Main menu selection ────────────────────────────────────────
@@ -252,6 +241,8 @@
if (action === "settings") { if (action === "settings") {
enterSettingsMode(); enterSettingsMode();
} else if (action === "multiplayer") {
enterBrowserMode();
} else { } else {
window.CommonwealthOnlineUI.onOpenView(action); window.CommonwealthOnlineUI.onOpenView(action);
if (action !== "quit") { if (action !== "quit") {
@@ -267,6 +258,11 @@
return; return;
} }
exitSettingsMode(); exitSettingsMode();
} else if (viewMode === "browser") {
if (index === MULTIPLAYER_INDEX) {
return;
}
exitBrowserMode();
} }
if (item.classList.contains("is-disabled")) { if (item.classList.contains("is-disabled")) {
return; return;
@@ -342,10 +338,151 @@
}; };
settingsPanel.addEventListener("transitionend", settingsHideOnTransitionEnd); 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() { 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"; viewMode = "settings";
console.log("[CO Main Menu] Entering settings mode"); console.log("[CO Main Menu] Entering settings mode");
@@ -376,7 +513,9 @@
if (mainPromptBar) { if (mainPromptBar) {
mainPromptBar.hidden = false; 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 ──────────────────────────────────────── // ── Keyboard navigation ────────────────────────────────────────
function handleMenuKeydown(event) { function handleMenuKeydown(event) {
@@ -422,7 +577,41 @@
settingsController.handleKeydown(event); settingsController.handleKeydown(event);
return; return;
} }
if (viewMode === "browser") {
browserController.handleKeydown(event);
return;
}
handleMenuKeydown(event); 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); 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);
}