Add PrismaUI HTML server browser & integration

Integrate a PrismaUI_F4-based HTML server browser and related tooling. Adds a ThirdParty submodule (framework-F4-Conversion), UI views (CommonwealthOnline browser HTML/JS/CSS), and multiple plugin headers/sources to bridge PrismaUI/Scaleform, handle menu input, and manage server browser data and actions (mock server list, join/connect flow).

Key changes:
- New build/deploy scripts: build-prismaui.bat, build-all.bat, deploy-all.bat, deploy-ui.bat and a PowerShell helper for linking the Ultralight SDK (ULTRALIGHT_SDK_PATH, default F:\\UltralightSDK).
- Plugin additions: F4TPrismaUI, F4TScaleformUI, F4TServerBrowserBridge, F4TServerBrowserData, F4TMenuInput, F4TDebugOverlay and implementations to create/show/hide the HTML view, forward controller input, register Scaleform functions (openManager/closeManager), and push UI events.
- Networking: add ConnectToServer(host,port) and keep ConnectToLocalServer() as wrapper; update connection logs and error handling.
- Main menu SWF/AS updates: updated MainMenu.swf and MainMenu.as to call closeManager() when leaving multiplayer and to integrate open/close hooks.
- Documentation: plugin/setup.md updated with PrismaUI build/deploy instructions.
- Removed Main_ModManager.swf (deleted).

Primary intent: provide an in-game HTML server browser (toggleable with F9 or Scaleform calls), wire up controller input, and add build/deploy workflow for PrismaUI/Ultralight so developers can build and deploy the UI and plugin together. Mock server data is used for now; network join/connect plumbing is provided for local/dev servers.
This commit is contained in:
2026-06-08 14:02:49 +12:00
parent 2ced73f7b9
commit fe620020be
38 changed files with 3565 additions and 15 deletions
+3
View File
@@ -4,3 +4,6 @@
[submodule "ThirdParty/xbyak"]
path = ThirdParty/xbyak
url = https://github.com/herumi/xbyak.git
[submodule "ThirdParty/framework-F4-Conversion"]
path = ThirdParty/framework-F4-Conversion
url = https://github.com/NomadsReach/framework-F4-Conversion.git
Binary file not shown.
Binary file not shown.
@@ -82,7 +82,41 @@ Add **before** `case this.HELP_INDEX:` (or anywhere in the switch):
break;
```
Requires the C++ plugin to register `commonwealthOnline.openManager()` via F4SE Scaleform (not done yet). Until then, the button will play the OK sound and do nothing else — safe for SWF-only testing.
Requires the C++ plugin to register `commonwealthOnline.openManager()` and `commonwealthOnline.closeManager()` via F4SE Scaleform.
### 4. Auto-close when leaving Multiplayer (~line 716 and ~onMainListItemPress)
Add a helper and call it when the main menu navigates to any other screen (Settings, Load, Continue, etc.):
```actionscript
private function CloseMultiplayerBrowser() : *
{
if(root.f4se != null && root.f4se.plugins != null && root.f4se.plugins.commonwealthOnline != null)
{
root.f4se.plugins.commonwealthOnline.closeManager();
}
}
```
In the `currentState` setter, after assigning `strCurrentState`:
```actionscript
if(param1 != this.MAIN_STATE)
{
this.CloseMultiplayerBrowser();
}
```
At the start of `onMainListItemPress()` (after the cancel/reset loop), before the `switch`:
```actionscript
if(this.MainPanel_mc.List_mc.selectedEntry.index != this.MULTIPLAYER_INDEX)
{
this.CloseMultiplayerBrowser();
}
```
This closes the HTML server browser when sub-menus open (Settings, Add-ons, etc.) and when any other main-menu item is selected.
## FFDec workflow
+16
View File
@@ -716,6 +716,10 @@ package
public function set currentState(param1:String) : *
{
this.strCurrentState = param1;
if(param1 != this.MAIN_STATE)
{
this.CloseMultiplayerBrowser();
}
this.UpdateButtons(param1);
this.UpdateStateFocus(param1);
}
@@ -799,6 +803,14 @@ package
}
}
private function CloseMultiplayerBrowser() : *
{
if(root.f4se != null && root.f4se.plugins != null && root.f4se.plugins.commonwealthOnline != null)
{
root.f4se.plugins.commonwealthOnline.closeManager();
}
}
private function onMainListItemPress() : *
{
if(Boolean(this.MainPanel_mc.List_mc.selectedEntry.disabled) || Boolean(this.Spinner_mc.visible == true) || this.bSettingsChanged)
@@ -811,6 +823,10 @@ package
{
this.onCancelPress(true,false);
}
if(this.MainPanel_mc.List_mc.selectedEntry.index != this.MULTIPLAYER_INDEX)
{
this.CloseMultiplayerBrowser();
}
if(this.MainPanel_mc.List_mc.selectedEntry.index == this.CONTINUE_INDEX && this.PauseMode)
{
this.SaveLoadHolder_mc.visible = false;
+24
View File
@@ -0,0 +1,24 @@
@echo off
setlocal EnableExtensions
set "SCRIPT_DIR=%~dp0"
echo Building Commonwealth Online plugin...
call "%SCRIPT_DIR%build.bat"
if errorlevel 1 exit /b 1
echo.
echo Building PrismaUI_F4...
call "%SCRIPT_DIR%build-prismaui.bat"
if errorlevel 1 exit /b 1
echo.
echo Deploying to Fallout 4...
call "%SCRIPT_DIR%deploy-all.bat" %*
if errorlevel 1 exit /b 1
echo.
echo All builds and deployment finished.
endlocal
exit /b 0
+46
View File
@@ -0,0 +1,46 @@
@echo off
setlocal EnableExtensions
set "SCRIPT_DIR=%~dp0"
set "FRAMEWORK_DIR=%SCRIPT_DIR%ThirdParty\framework-F4-Conversion"
set "UL_SDK=%ULTRALIGHT_SDK_PATH%"
if not defined UL_SDK set "UL_SDK=F:\UltralightSDK"
where xmake >nul 2>&1
if errorlevel 1 (
echo xmake was not found on PATH.
exit /b 1
)
if not exist "%FRAMEWORK_DIR%\xmake.lua" (
echo Framework submodule missing. Run:
echo git submodule update --init --recursive ThirdParty/framework-F4-Conversion
exit /b 1
)
echo Linking Ultralight SDK from:
echo %UL_SDK%
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%scripts\setup-ultralight-link.ps1" -SdkPath "%UL_SDK%"
if errorlevel 1 (
echo Failed to link Ultralight SDK.
exit /b 1
)
cd /d "%FRAMEWORK_DIR%"
echo Configuring PrismaUI_F4...
xmake f --plat=windows --arch=x64 -m release
if errorlevel 1 exit /b 1
echo Building PrismaUI_F4...
xmake build -r PrismaUI_F4
if errorlevel 1 exit /b 1
echo.
echo PrismaUI_F4 build succeeded.
for /r "%FRAMEWORK_DIR%\build" %%F in (PrismaUI_F4.dll) do (
if exist "%%F" echo %%F
)
endlocal
exit /b 0
+73
View File
@@ -0,0 +1,73 @@
@echo off
setlocal EnableExtensions
set "SCRIPT_DIR=%~dp0"
set "DATA_DIR=%~1"
set "F4_DIR=%~2"
if not defined DATA_DIR set "DATA_DIR=D:\SteamLibrary\steamapps\common\Fallout 4\Data"
if not defined F4_DIR set "F4_DIR=D:\SteamLibrary\steamapps\common\Fallout 4"
if not exist "%DATA_DIR%" (
echo Fallout 4 Data directory not found:
echo %DATA_DIR%
echo.
echo Usage: deploy-all.bat ["PATH\TO\Fallout 4\Data"] ["PATH\TO\Fallout 4"]
exit /b 1
)
set "FRAMEWORK_DIR=%SCRIPT_DIR%ThirdParty\framework-F4-Conversion"
set "PRISMA_DLL="
set "CO_DLL=%SCRIPT_DIR%plugin\build\windows\x64\release\CommonwealthOnline.dll"
set "UL_SDK=%ULTRALIGHT_SDK_PATH%"
if not defined UL_SDK set "UL_SDK=F:\UltralightSDK"
for /r "%FRAMEWORK_DIR%\build" %%F in (PrismaUI_F4.dll) do set "PRISMA_DLL=%%F"
if not defined PRISMA_DLL (
echo PrismaUI_F4.dll not found. Run build-prismaui.bat first.
exit /b 1
)
if not exist "%CO_DLL%" (
echo CommonwealthOnline.dll not found. Run build.bat first.
exit /b 1
)
set "PLUGINS_DIR=%DATA_DIR%\F4SE\Plugins"
set "PRISMA_ROOT=%DATA_DIR%\PrismaUI_F4"
set "VIEWS_DIR=%PRISMA_ROOT%\views"
set "LIBS_DIR=%PRISMA_ROOT%\libs"
set "RES_DIR=%PRISMA_ROOT%\resources"
if not exist "%PLUGINS_DIR%" mkdir "%PLUGINS_DIR%"
if not exist "%VIEWS_DIR%" mkdir "%VIEWS_DIR%"
if not exist "%LIBS_DIR%" mkdir "%LIBS_DIR%"
if not exist "%RES_DIR%" mkdir "%RES_DIR%"
echo Deploying PrismaUI_F4 runtime...
copy /Y "%PRISMA_DLL%" "%PLUGINS_DIR%\PrismaUI_F4.dll"
copy /Y "%UL_SDK%\bin\*.dll" "%LIBS_DIR%\"
xcopy /Y /E /I "%UL_SDK%\resources\*" "%RES_DIR%\"
if exist "%FRAMEWORK_DIR%\assets" (
xcopy /Y /E /I "%FRAMEWORK_DIR%\assets\*" "%PRISMA_ROOT%\"
)
echo Deploying Commonwealth Online plugin...
copy /Y "%CO_DLL%" "%PLUGINS_DIR%\CommonwealthOnline.dll"
echo Deploying HTML views...
xcopy /Y /E /I "%SCRIPT_DIR%ui\views\*" "%VIEWS_DIR%\"
echo.
echo Deployment complete.
echo %PLUGINS_DIR%\PrismaUI_F4.dll
echo %PLUGINS_DIR%\CommonwealthOnline.dll
echo %VIEWS_DIR%\CommonwealthOnline\browser\index.html
echo %LIBS_DIR%\*.dll
echo %RES_DIR%\
endlocal
exit /b 0
+56
View File
@@ -0,0 +1,56 @@
@echo off
setlocal EnableExtensions
set "SCRIPT_DIR=%~dp0"
set "UI_SOURCE=%SCRIPT_DIR%ui\views"
set "PLUGIN_DLL="
if "%~1"=="" (
echo Usage: deploy-ui.bat "PATH\TO\Fallout 4\Data"
echo.
echo Copies HTML views to Data\PrismaUI_F4\views\
echo Optionally pass a second argument with the plugin DLL path to copy CommonwealthOnline.dll too.
exit /b 1
)
set "DATA_DIR=%~1"
if not exist "%DATA_DIR%" (
echo Data directory not found:
echo "%DATA_DIR%"
exit /b 1
)
set "VIEWS_DIR=%DATA_DIR%\PrismaUI_F4\views"
if not exist "%VIEWS_DIR%" mkdir "%VIEWS_DIR%"
echo Copying PrismaUI views...
xcopy /Y /E /I "%UI_SOURCE%\*" "%VIEWS_DIR%\"
if errorlevel 1 (
echo Failed to copy HTML views.
exit /b 1
)
if not "%~2"=="" (
set "PLUGIN_DLL=%~2"
set "PLUGINS_DIR=%DATA_DIR%\F4SE\Plugins"
if not exist "%PLUGINS_DIR%" mkdir "%PLUGINS_DIR%"
echo Copying plugin DLL...
copy /Y "%PLUGIN_DLL%" "%PLUGINS_DIR%\CommonwealthOnline.dll"
if errorlevel 1 (
echo Failed to copy plugin DLL.
exit /b 1
)
)
echo.
echo UI deployment complete.
echo Installed views under:
echo %VIEWS_DIR%
echo.
echo PrismaUI_F4 itself must be installed separately:
echo Data\F4SE\Plugins\PrismaUI_F4.dll
echo Data\PrismaUI_F4\libs\*.dll
echo Data\PrismaUI_F4\resources\
endlocal
exit /b 0
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// Future developer/debug overlay (ImGui or PrismaUI inspector panel).
// Player-facing UI lives in ui/views/CommonwealthOnline/browser/.
namespace F4T::DebugOverlay
{
inline void Initialize() {}
inline void Shutdown() {}
inline void Draw() {}
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
namespace F4T::MenuInput
{
void Register();
void SetBrowserActive(bool a_active);
}
+1
View File
@@ -6,6 +6,7 @@
namespace F4T::Networking
{
std::string GetLocalPlayerLogPrefix();
bool ConnectToServer(const char* a_host, std::uint16_t a_port);
bool ConnectToLocalServer();
void DisconnectFromLocalServer();
bool IsConnectedToServer();
+9
View File
@@ -0,0 +1,9 @@
#pragma once
namespace F4T::PrismaUI
{
void OnGameDataReady();
void ShowServerBrowser();
void HideServerBrowser();
bool IsBrowserVisible();
}
+6
View File
@@ -0,0 +1,6 @@
#pragma once
namespace F4T::ScaleformUI
{
void RegisterPlugin();
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include "PrismaUI_F4_API.h"
#include <nlohmann/json_fwd.hpp>
namespace F4T::ServerBrowser
{
class ServerBrowserBridge
{
public:
static void Initialize(PRISMA_UI_API::IVPrismaUI4* a_api, PrismaView a_view);
static void OnBrowserShown();
static void OnBrowserHidden();
static void HandleAction(const nlohmann::json& a_action);
static void ForwardControllerInput(const char* a_button, const char* a_phase);
static void PushConnectionStatus();
};
}
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
namespace F4T::ServerBrowser
{
struct ServerEntry
{
std::string id;
std::string name;
std::string region;
std::string worldspace;
std::string mode;
std::string status;
std::string description;
std::string host;
int port{ 0 };
int players{ 0 };
int maxPlayers{ 0 };
int ping{ 0 };
bool modded{ false };
bool passworded{ false };
bool favorite{ false };
nlohmann::json ToJson() const;
static ServerEntry FromJson(const nlohmann::json& a_json);
};
struct BrowserFilters
{
std::string search;
std::string region{ "Any Region" };
std::string mode{ "Any Mode" };
std::string worldspace{ "Any Worldspace" };
std::string ping{ "Any" };
bool showFull{ false };
bool hasPassword{ false };
bool modded{ false };
};
class ServerBrowserData
{
public:
void SetFilters(const BrowserFilters& a_filters);
void ResetFilters();
void ApplyFilters();
void SetAllServers(std::vector<ServerEntry> a_servers);
void ToggleFavorite(const std::string& a_serverId);
const ServerEntry* FindServer(const std::string& a_serverId) const;
const std::vector<ServerEntry>& GetFilteredServers() const { return m_filteredServers; }
const BrowserFilters& GetFilters() const { return m_filters; }
std::string GetConnectionStatus() const { return m_connectionStatus; }
void SetConnectionStatus(std::string a_status) { m_connectionStatus = std::move(a_status); }
bool IsRefreshing() const { return m_isRefreshing; }
void SetRefreshing(bool a_refreshing) { m_isRefreshing = a_refreshing; }
nlohmann::json ToServerListJson() const;
static std::vector<ServerEntry> CreateMockServers();
private:
bool PassesFilters(const ServerEntry& a_entry) const;
std::vector<ServerEntry> m_allServers;
std::vector<ServerEntry> m_filteredServers;
BrowserFilters m_filters;
std::string m_connectionStatus{ "Online" };
bool m_isRefreshing{ false };
};
}
+50
View File
@@ -51,6 +51,56 @@ The plugin does not currently include:
* Combat sync
* Quest sync
## PrismaUI HTML Server Browser
Commonwealth Online renders an HTML server browser through [PrismaUI_F4](https://github.com/NomadsReach/framework-F4-Conversion), vendored as:
```text
ThirdParty/framework-F4-Conversion
```
Ultralight SDK setup:
* Default SDK path: `F:\UltralightSDK`
* Override with environment variable: `ULTRALIGHT_SDK_PATH`
Build PrismaUI_F4:
```bat
build-prismaui.bat
```
Build and deploy everything:
```bat
build.bat
build-prismaui.bat
deploy-all.bat
```
`deploy-all.bat` defaults to:
```text
D:\SteamLibrary\steamapps\common\Fallout 4\Data
```
It installs:
* `Data/F4SE/Plugins/PrismaUI_F4.dll`
* `Data/F4SE/Plugins/CommonwealthOnline.dll`
* `Data/PrismaUI_F4/libs/*.dll`
* `Data/PrismaUI_F4/resources/*`
* `Data/PrismaUI_F4/views/CommonwealthOnline/browser/index.html`
In-game controls:
* Main Menu > Multiplayer opens the server browser
* Press **F9** to toggle the browser
* Use the server list to browse, filter, and join servers
* **Local Relay (Dev)** connects to `127.0.0.1:7777` — start `server/server.py` first
If PrismaUI_F4 is missing, the plugin still loads and networking continues to work; only the HTML UI is skipped.
## Required Tools
The plugin setup is expected to require:
+109
View File
@@ -0,0 +1,109 @@
#include "pch.h"
#include "F4TMenuInput.h"
#include "F4TServerBrowserBridge.h"
#include <F4SE/InputMap.h>
namespace F4T::MenuInput
{
namespace
{
std::atomic_bool g_browserActive{ false };
std::atomic_bool g_sinkRegistered{ false };
const char* GamepadButtonName(std::uint32_t a_keyCode)
{
switch (a_keyCode) {
case F4SE::InputMap::kGamepadButtonOffset_DPAD_UP:
return "dpad_up";
case F4SE::InputMap::kGamepadButtonOffset_DPAD_DOWN:
return "dpad_down";
case F4SE::InputMap::kGamepadButtonOffset_DPAD_LEFT:
return "dpad_left";
case F4SE::InputMap::kGamepadButtonOffset_DPAD_RIGHT:
return "dpad_right";
case F4SE::InputMap::kGamepadButtonOffset_A:
return "a";
case F4SE::InputMap::kGamepadButtonOffset_B:
return "b";
case F4SE::InputMap::kGamepadButtonOffset_X:
return "x";
case F4SE::InputMap::kGamepadButtonOffset_Y:
return "y";
case F4SE::InputMap::kGamepadButtonOffset_LEFT_SHOULDER:
return "lb";
case F4SE::InputMap::kGamepadButtonOffset_RIGHT_SHOULDER:
return "rb";
default:
return nullptr;
}
}
class GamepadInputHandler :
public RE::BSInputEventUser
{
public:
static GamepadInputHandler* GetSingleton()
{
static GamepadInputHandler instance;
return std::addressof(instance);
}
bool ShouldHandleEvent(const RE::InputEvent* a_event) override
{
if (!g_browserActive.load() || !a_event) {
return false;
}
return a_event->eventType == RE::INPUT_EVENT_TYPE::kButton &&
a_event->device == RE::INPUT_DEVICE::kGamepad;
}
void OnButtonEvent(const RE::ButtonEvent* a_event) override
{
if (!a_event || !g_browserActive.load()) {
return;
}
const auto keyCode = static_cast<std::uint32_t>(a_event->GetBSButtonCode());
const auto* buttonName = GamepadButtonName(keyCode);
if (!buttonName) {
return;
}
const char* phase = nullptr;
if (a_event->QJustPressed()) {
phase = "down";
} else if (a_event->QReleased()) {
phase = "up";
}
if (phase) {
F4T::ServerBrowser::ServerBrowserBridge::ForwardControllerInput(buttonName, phase);
}
}
};
}
void Register()
{
if (g_sinkRegistered.exchange(true)) {
return;
}
auto* menuControls = RE::MenuControls::GetSingleton();
if (menuControls) {
menuControls->handlers.push_back(GamepadInputHandler::GetSingleton());
REX::INFO("[MenuInput] Registered gamepad handler for server browser.");
} else {
REX::WARN("[MenuInput] MenuControls unavailable; gamepad navigation disabled.");
g_sinkRegistered.store(false);
}
}
void SetBrowserActive(bool a_active)
{
g_browserActive.store(a_active);
}
}
+19 -14
View File
@@ -596,8 +596,12 @@ namespace F4T::Networking
return "[LocalPlayerId=" + std::to_string(*assignedPlayerId) + "]";
}
bool ConnectToLocalServer()
bool ConnectToServer(const char* a_host, std::uint16_t a_port)
{
if (!a_host || a_host[0] == '\0') {
return false;
}
if (IsConnectedToServer()) {
return true;
}
@@ -610,27 +614,25 @@ namespace F4T::Networking
return false;
}
// Keep this first networking milestone local-only so it can prove the
// packet path without exposing any gameplay state to a remote server.
const auto localSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (localSocket == INVALID_SOCKET) {
LogWarningWithLocalPlayerPrefix("Could not create socket for Commonwealth Online local server connection.");
LogWarningWithLocalPlayerPrefix("Could not create socket for Commonwealth Online server connection.");
return false;
}
if (!SetNonBlocking(localSocket)) {
closesocket(localSocket);
LogWarningWithLocalPlayerPrefix(
"Could not configure non-blocking socket for Commonwealth Online local server connection.");
"Could not configure non-blocking socket for Commonwealth Online server connection.");
return false;
}
sockaddr_in serverAddress{};
serverAddress.sin_family = AF_INET;
serverAddress.sin_port = htons(kLocalServerPort);
if (inet_pton(AF_INET, kLocalServerHost, std::addressof(serverAddress.sin_addr)) != 1) {
serverAddress.sin_port = htons(a_port);
if (inet_pton(AF_INET, a_host, std::addressof(serverAddress.sin_addr)) != 1) {
closesocket(localSocket);
LogWarningWithLocalPlayerPrefix("Could not parse Commonwealth Online local server address.");
LogWarningWithLocalPlayerPrefix(std::format("Could not parse Commonwealth Online server address '{}'.", a_host));
return false;
}
@@ -641,12 +643,10 @@ namespace F4T::Networking
if (!connected) {
closesocket(localSocket);
// The server is a developer tool, not a runtime requirement. A failed
// connection should leave Fallout 4 running and simply disable packet sends.
LogWarningWithLocalPlayerPrefix(std::format(
"Could not connect to Commonwealth Online local server at {}:{}",
kLocalServerHost,
kLocalServerPort));
"Could not connect to Commonwealth Online server at {}:{}",
a_host,
a_port));
return false;
}
@@ -662,10 +662,15 @@ namespace F4T::Networking
StartReceiveThread();
LogInfoWithLocalPlayerPrefix(
std::format("Connected to Commonwealth Online local server at {}:{}.", kLocalServerHost, kLocalServerPort));
std::format("Connected to Commonwealth Online server at {}:{}.", a_host, a_port));
return true;
}
bool ConnectToLocalServer()
{
return ConnectToServer(kLocalServerHost, kLocalServerPort);
}
void DisconnectFromLocalServer()
{
StopReceiveThread();
+313
View File
@@ -0,0 +1,313 @@
#include "pch.h"
#include "F4TMenuInput.h"
#include "F4TNetworking.h"
#include "F4TPrismaUI.h"
#include "F4TServerBrowserBridge.h"
#include "PrismaUI_F4_API.h"
#include <cstdio>
#include <nlohmann/json.hpp>
#include <string_view>
namespace F4T::PrismaUI
{
namespace
{
constexpr auto kServerBrowserHtmlPath = "CommonwealthOnline/browser/index.html";
constexpr auto kToggleVirtualKey = 0x78; // VK_F9
PRISMA_UI_API::IVPrismaUI4* g_api = nullptr;
PrismaView g_serverBrowserView = 0;
bool g_visible = false;
bool g_domReady = false;
bool g_toggleTaskStarted = false;
bool g_cursorRestoreTaskStarted = false;
bool g_menuWatchTaskStarted = false;
bool g_pendingShow = false;
int g_cursorRestoreFramesLeft = 0;
std::string GetLogPrefix()
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " [PrismaUI]";
}
void LogInfo(std::string_view a_message)
{
const auto message = GetLogPrefix() + " " + std::string(a_message);
REX::INFO(std::string_view{ message });
}
void LogWarning(std::string_view a_message)
{
const auto message = GetLogPrefix() + " " + std::string(a_message);
REX::WARN(std::string_view{ message });
}
void RestoreNativeGameCursor()
{
auto* ui = RE::UI::GetSingleton();
if (!ui) {
return;
}
if (auto cursorMenu = ui->GetMenu<RE::CursorMenu>()) {
if (cursorMenu->uiMovie) {
cursorMenu->uiMovie->SetVisible(true);
}
}
ui->RefreshCursor();
if (ui->GetMenuOpen<RE::MainMenu>()) {
if (auto* menuCursor = RE::MenuCursor::GetSingleton()) {
menuCursor->RegisterCursor();
}
}
}
void RequestNativeCursorRestore()
{
g_cursorRestoreFramesLeft = 4;
}
void HideServerBrowserInternal()
{
if (!g_visible) {
return;
}
if (!g_api || !g_api->IsValid(g_serverBrowserView)) {
g_visible = false;
F4T::MenuInput::SetBrowserActive(false);
return;
}
g_visible = false;
F4T::MenuInput::SetBrowserActive(false);
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserHidden();
g_api->Unfocus(g_serverBrowserView);
g_api->Hide(g_serverBrowserView);
RequestNativeCursorRestore();
}
void WatchMainMenuState()
{
if (!g_visible) {
return;
}
const auto* ui = RE::UI::GetSingleton();
if (!ui || !ui->GetMenuOpen<RE::MainMenu>()) {
HideServerBrowserInternal();
}
}
void CreateServerBrowserView();
void ShowServerBrowserInternal()
{
if (!g_api) {
return;
}
if (!g_api->IsValid(g_serverBrowserView)) {
g_pendingShow = true;
CreateServerBrowserView();
return;
}
if (!g_domReady) {
g_pendingShow = true;
return;
}
g_pendingShow = false;
g_visible = true;
F4T::MenuInput::SetBrowserActive(true);
g_api->Show(g_serverBrowserView);
g_api->Focus(g_serverBrowserView, false, false);
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserShown();
g_api->Invoke(
g_serverBrowserView,
"if(window.onBrowserShown){window.onBrowserShown();}");
}
void ToggleServerBrowser()
{
if (!g_api) {
return;
}
if (g_visible) {
HideServerBrowserInternal();
return;
}
ShowServerBrowserInternal();
}
void OnServerBrowserDomReady(PrismaView a_view)
{
g_domReady = true;
LogInfo("Server browser DOM ready.");
g_api->RegisterConsoleCallback(
a_view,
[](PrismaView, PRISMA_UI_API::ConsoleMessageLevel a_level, const char* a_message) {
if (!a_message) {
return;
}
const auto prefix = GetLogPrefix() + " [JS]";
switch (a_level) {
case PRISMA_UI_API::ConsoleMessageLevel::Error:
REX::CRITICAL("{} {}", prefix, a_message);
break;
case PRISMA_UI_API::ConsoleMessageLevel::Warning:
REX::WARN("{} {}", prefix, a_message);
break;
default:
REX::INFO("{} {}", prefix, a_message);
break;
}
});
F4T::ServerBrowser::ServerBrowserBridge::Initialize(g_api, a_view);
g_api->BindUIEvent(a_view, "coAction", [](const char* a_json) {
if (!a_json) {
return;
}
try {
const auto action = nlohmann::json::parse(a_json);
const auto actionName = action.value("action", "");
if (actionName == "closeBrowser") {
HideServerBrowserInternal();
return;
}
F4T::ServerBrowser::ServerBrowserBridge::HandleAction(action);
} catch (const std::exception& ex) {
REX::WARN("{} [JS] Failed to parse coAction JSON: {}", GetLogPrefix(), ex.what());
}
});
// Legacy bridge stubs for backward compatibility during transition.
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();
});
g_api->BindUIEvent(a_view, "closeBrowser", [](const char*) {
HideServerBrowserInternal();
});
F4T::ServerBrowser::ServerBrowserBridge::PushConnectionStatus();
if (g_pendingShow) {
ShowServerBrowserInternal();
}
}
void CreateServerBrowserView()
{
if (!g_api) {
return;
}
if (g_serverBrowserView != 0 && g_api->IsValid(g_serverBrowserView)) {
return;
}
g_domReady = false;
g_serverBrowserView = g_api->CreateView(kServerBrowserHtmlPath, OnServerBrowserDomReady);
if (g_serverBrowserView == 0) {
LogWarning(std::format(
"Failed to create server browser view. Is Data/PrismaUI_F4/views/{} installed?",
kServerBrowserHtmlPath));
return;
}
g_api->Hide(g_serverBrowserView);
LogInfo(std::format("Created server browser view for '{}'.", kServerBrowserHtmlPath));
}
void StartGameThreadTasks()
{
const auto* taskInterface = F4SE::GetTaskInterface();
if (!taskInterface) {
LogWarning("F4SE task interface unavailable; F9 server browser toggle disabled.");
return;
}
if (!g_toggleTaskStarted) {
taskInterface->AddTaskPermanent([]() {
static bool lastToggleDown = false;
const auto toggleDown = (GetAsyncKeyState(kToggleVirtualKey) & 0x8000) != 0;
if (toggleDown && !lastToggleDown) {
ToggleServerBrowser();
}
lastToggleDown = toggleDown;
});
g_toggleTaskStarted = true;
}
if (!g_cursorRestoreTaskStarted) {
taskInterface->AddTaskPermanent([]() {
if (g_cursorRestoreFramesLeft <= 0) {
return;
}
RestoreNativeGameCursor();
g_cursorRestoreFramesLeft--;
});
g_cursorRestoreTaskStarted = true;
}
if (!g_menuWatchTaskStarted) {
taskInterface->AddTaskPermanent(WatchMainMenuState);
g_menuWatchTaskStarted = true;
}
}
}
void OnGameDataReady()
{
g_api = PRISMA_UI_API::RequestPluginAPI<PRISMA_UI_API::IVPrismaUI4>();
if (!g_api) {
LogWarning(
"PrismaUI_F4 API not found. Install PrismaUI_F4.dll and its Ultralight libs before the HTML server browser can render.");
return;
}
LogInfo("PrismaUI_F4 API loaded.");
F4T::MenuInput::Register();
StartGameThreadTasks();
CreateServerBrowserView();
}
void ShowServerBrowser()
{
ShowServerBrowserInternal();
}
void HideServerBrowser()
{
HideServerBrowserInternal();
}
bool IsBrowserVisible()
{
return g_visible;
}
}
+96
View File
@@ -0,0 +1,96 @@
#include "pch.h"
#include "F4TNetworking.h"
#include "F4TPrismaUI.h"
#include "F4TScaleformUI.h"
#include "Scaleform/G/GFx_FunctionHandler.h"
#include "Scaleform/G/GFx_Movie.h"
#include "Scaleform/G/GFx_Value.h"
namespace F4T::ScaleformUI
{
namespace
{
class OpenManagerHandler :
public Scaleform::GFx::FunctionHandler
{
public:
void Call(const Params& a_params) override
{
(void)a_params;
const auto* taskInterface = F4SE::GetTaskInterface();
if (taskInterface) {
taskInterface->AddTask([]() {
F4T::PrismaUI::ShowServerBrowser();
});
return;
}
F4T::PrismaUI::ShowServerBrowser();
}
};
class CloseManagerHandler :
public Scaleform::GFx::FunctionHandler
{
public:
void Call(const Params& a_params) override
{
(void)a_params;
const auto* taskInterface = F4SE::GetTaskInterface();
if (taskInterface) {
taskInterface->AddTask([]() {
F4T::PrismaUI::HideServerBrowser();
});
return;
}
F4T::PrismaUI::HideServerBrowser();
}
};
OpenManagerHandler g_openManagerHandler;
CloseManagerHandler g_closeManagerHandler;
bool F4SEAPI RegisterCommonwealthOnlineScaleform(
Scaleform::GFx::Movie* a_view,
Scaleform::GFx::Value* a_plugin)
{
if (!a_view || !a_plugin) {
return false;
}
Scaleform::GFx::Value openManager;
a_view->CreateFunction(&openManager, &g_openManagerHandler);
if (!a_plugin->SetMember("openManager", openManager)) {
return false;
}
Scaleform::GFx::Value closeManager;
a_view->CreateFunction(&closeManager, &g_closeManagerHandler);
return a_plugin->SetMember("closeManager", closeManager);
}
}
void RegisterPlugin()
{
const auto* scaleform = F4SE::GetScaleformInterface();
if (!scaleform) {
REX::WARN("{} [Scaleform] Scaleform interface unavailable; Main Menu Multiplayer button will not open the server browser.",
F4T::Networking::GetLocalPlayerLogPrefix());
return;
}
if (!scaleform->Register("commonwealthOnline", RegisterCommonwealthOnlineScaleform)) {
REX::WARN("{} [Scaleform] Failed to register commonwealthOnline Scaleform API.",
F4T::Networking::GetLocalPlayerLogPrefix());
return;
}
REX::INFO("{} [Scaleform] Registered root.f4se.plugins.commonwealthOnline openManager/closeManager.",
F4T::Networking::GetLocalPlayerLogPrefix());
}
}
+282
View File
@@ -0,0 +1,282 @@
#include "pch.h"
#include "F4TNetworking.h"
#include "F4TServerBrowserBridge.h"
#include "F4TServerBrowserData.h"
#include <format>
#include <string_view>
namespace F4T::ServerBrowser
{
namespace
{
PRISMA_UI_API::IVPrismaUI4* g_api = nullptr;
PrismaView g_view = 0;
ServerBrowserData g_data;
std::string GetLogPrefix()
{
return F4T::Networking::GetLocalPlayerLogPrefix() + " [BrowserBridge]";
}
void LogInfo(std::string_view a_message)
{
REX::INFO("{}{}", GetLogPrefix(), a_message);
}
void LogWarning(std::string_view a_message)
{
REX::WARN("{}{}", GetLogPrefix(), a_message);
}
void DispatchEvent(const nlohmann::json& a_event)
{
if (!g_api || g_view == 0 || !g_api->IsValid(g_view)) {
return;
}
const auto script = std::format("if(window.dispatchCoEvent){{window.dispatchCoEvent({});}}", a_event.dump());
g_api->Invoke(g_view, script.c_str());
}
void PushServerListUpdated()
{
DispatchEvent(nlohmann::json{
{ "type", "serverListUpdated" },
{ "servers", g_data.ToServerListJson() }
});
}
void PushConnectionStatusEvent()
{
const auto connected = F4T::Networking::IsConnectedToServer();
g_data.SetConnectionStatus(connected ? "Connected" : "Online");
DispatchEvent(nlohmann::json{
{ "type", "connectionStatusChanged" },
{ "status", g_data.GetConnectionStatus() },
{ "serverCount", g_data.GetFilteredServers().size() }
});
if (g_api && g_view != 0 && g_api->IsValid(g_view)) {
const auto prefix = F4T::Networking::GetLocalPlayerLogPrefix();
const auto script = std::format(
"if(window.setConnectionStatus){{window.setConnectionStatus({},{});}}",
connected ? "true" : "false",
nlohmann::json(prefix).dump());
g_api->Invoke(g_view, script.c_str());
}
}
bool JoinServerEntry(const ServerEntry& a_entry, std::string& a_error)
{
DispatchEvent(nlohmann::json{
{ "type", "joinStarted" },
{ "serverId", a_entry.id }
});
if (a_entry.passworded) {
a_error = "Password required — password prompt not yet implemented.";
return false;
}
const auto host = a_entry.host.empty() ? "127.0.0.1" : a_entry.host;
const auto port = static_cast<std::uint16_t>(a_entry.port > 0 ? a_entry.port : 7777);
if (!F4T::Networking::ConnectToServer(host.c_str(), port)) {
if (host == "127.0.0.1" && port == 7777) {
a_error = "Could not connect to local relay. Start server/server.py first.";
} else {
a_error = std::format("Connection failed — server offline or unreachable at {}:{}", host, port);
}
return false;
}
return true;
}
void RefreshServerList()
{
g_data.SetRefreshing(true);
DispatchEvent(nlohmann::json{ { "type", "serverListRefreshStarted" } });
// TODO M6: Replace mock refresh with master-server / LAN discovery query.
g_data.SetAllServers(ServerBrowserData::CreateMockServers());
g_data.SetRefreshing(false);
PushServerListUpdated();
PushConnectionStatusEvent();
DispatchEvent(nlohmann::json{
{ "type", "serverListRefreshFinished" },
{ "success", true }
});
LogInfo(" Server list refreshed (mock data).");
}
}
void ServerBrowserBridge::Initialize(PRISMA_UI_API::IVPrismaUI4* a_api, PrismaView a_view)
{
g_api = a_api;
g_view = a_view;
g_data.SetAllServers(ServerBrowserData::CreateMockServers());
}
void ServerBrowserBridge::OnBrowserShown()
{
DispatchEvent(nlohmann::json{ { "type", "menuOpened" } });
RefreshServerList();
}
void ServerBrowserBridge::OnBrowserHidden()
{
DispatchEvent(nlohmann::json{ { "type", "menuClosed" } });
}
void ServerBrowserBridge::HandleAction(const nlohmann::json& a_action)
{
const auto action = a_action.value("action", "");
if (action.empty()) {
return;
}
if (action == "refreshServerList") {
RefreshServerList();
return;
}
if (action == "setFilter") {
auto filters = g_data.GetFilters();
const auto filterName = a_action.value("filterName", "");
if (filterName == "search") {
filters.search = a_action.value("value", "");
} else if (filterName == "region") {
filters.region = a_action.value("value", "Any Region");
} else if (filterName == "mode") {
filters.mode = a_action.value("value", "Any Mode");
} else if (filterName == "worldspace") {
filters.worldspace = a_action.value("value", "Any Worldspace");
} else if (filterName == "ping") {
filters.ping = a_action.value("value", "Any");
} else if (filterName == "showFull" || filterName == "hasPassword" || filterName == "modded") {
const auto value = a_action.value("value", false);
if (filterName == "showFull") {
filters.showFull = value;
} else if (filterName == "hasPassword") {
filters.hasPassword = value;
} else {
filters.modded = value;
}
}
g_data.SetFilters(filters);
PushServerListUpdated();
return;
}
if (action == "resetFilters") {
g_data.ResetFilters();
PushServerListUpdated();
return;
}
if (action == "toggleFavorite") {
const auto serverId = a_action.value("serverId", "");
if (!serverId.empty()) {
g_data.ToggleFavorite(serverId);
PushServerListUpdated();
}
return;
}
if (action == "switchTab") {
return;
}
if (action == "joinServer") {
const auto serverId = a_action.value("serverId", "");
const auto* entry = g_data.FindServer(serverId);
if (!entry) {
DispatchEvent(nlohmann::json{
{ "type", "joinFailed" },
{ "serverId", serverId },
{ "reason", "Server not found." }
});
return;
}
std::string error;
if (JoinServerEntry(*entry, error)) {
PushConnectionStatusEvent();
DispatchEvent(nlohmann::json{
{ "type", "joinSucceeded" },
{ "serverId", serverId }
});
DispatchEvent(nlohmann::json{
{ "type", "uiSound" },
{ "sound", "joinSuccess" }
});
LogInfo(std::format(" Joined server '{}'.", entry->name));
} else {
DispatchEvent(nlohmann::json{
{ "type", "joinFailed" },
{ "serverId", serverId },
{ "reason", error }
});
DispatchEvent(nlohmann::json{
{ "type", "uiSound" },
{ "sound", "joinFailed" }
});
LogWarning(std::format(" Join failed for '{}': {}", entry->name, error));
}
return;
}
if (action == "openDirectConnect") {
ServerEntry entry;
entry.id = "direct";
entry.name = "Direct Connect";
entry.host = a_action.value("address", "127.0.0.1");
entry.port = a_action.value("port", 7777);
std::string error;
if (JoinServerEntry(entry, error)) {
PushConnectionStatusEvent();
DispatchEvent(nlohmann::json{
{ "type", "joinSucceeded" },
{ "serverId", "direct" }
});
} else {
DispatchEvent(nlohmann::json{
{ "type", "joinFailed" },
{ "serverId", "direct" },
{ "reason", error }
});
}
return;
}
if (action == "closeBrowser") {
return;
}
}
void ServerBrowserBridge::ForwardControllerInput(const char* a_button, const char* a_phase)
{
if (!a_button || !a_phase) {
return;
}
DispatchEvent(nlohmann::json{
{ "type", "controllerInput" },
{ "button", a_button },
{ "phase", a_phase }
});
}
void ServerBrowserBridge::PushConnectionStatus()
{
PushConnectionStatusEvent();
}
}
+250
View File
@@ -0,0 +1,250 @@
#include "pch.h"
#include "F4TServerBrowserData.h"
#include <algorithm>
#include <cctype>
namespace F4T::ServerBrowser
{
namespace
{
bool ContainsIgnoreCase(std::string_view a_haystack, std::string_view a_needle)
{
if (a_needle.empty()) {
return true;
}
std::string haystackLower(a_haystack);
std::string needleLower(a_needle);
std::ranges::transform(haystackLower, haystackLower.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
std::ranges::transform(needleLower, needleLower.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return haystackLower.find(needleLower) != std::string::npos;
}
}
nlohmann::json ServerEntry::ToJson() const
{
auto json = nlohmann::json{
{ "id", id },
{ "name", name },
{ "region", region },
{ "players", players },
{ "maxPlayers", maxPlayers },
{ "ping", ping },
{ "worldspace", worldspace },
{ "mode", mode },
{ "status", status },
{ "modded", modded },
{ "passworded", passworded },
{ "favorite", favorite },
{ "description", description }
};
if (!host.empty()) {
json["host"] = host;
}
if (port > 0) {
json["port"] = port;
}
return json;
}
ServerEntry ServerEntry::FromJson(const nlohmann::json& a_json)
{
ServerEntry entry;
entry.id = a_json.value("id", "");
entry.name = a_json.value("name", "");
entry.region = a_json.value("region", "");
entry.worldspace = a_json.value("worldspace", "");
entry.mode = a_json.value("mode", "");
entry.status = a_json.value("status", "");
entry.description = a_json.value("description", "");
entry.host = a_json.value("host", "");
entry.port = a_json.value("port", 0);
entry.players = a_json.value("players", 0);
entry.maxPlayers = a_json.value("maxPlayers", 0);
entry.ping = a_json.value("ping", 0);
entry.modded = a_json.value("modded", false);
entry.passworded = a_json.value("passworded", false);
entry.favorite = a_json.value("favorite", false);
return entry;
}
void ServerBrowserData::SetFilters(const BrowserFilters& a_filters)
{
m_filters = a_filters;
ApplyFilters();
}
void ServerBrowserData::ResetFilters()
{
m_filters = BrowserFilters{};
ApplyFilters();
}
bool ServerBrowserData::PassesFilters(const ServerEntry& a_entry) const
{
if (!m_filters.search.empty() && !ContainsIgnoreCase(a_entry.name, m_filters.search)) {
return false;
}
if (m_filters.region != "Any Region" && a_entry.region != m_filters.region) {
return false;
}
if (m_filters.mode != "Any Mode" && a_entry.mode != m_filters.mode) {
return false;
}
if (m_filters.worldspace != "Any Worldspace" && a_entry.worldspace != m_filters.worldspace) {
return false;
}
if (m_filters.ping == "< 50 ms" && a_entry.ping >= 50) {
return false;
}
if (m_filters.ping == "< 100 ms" && a_entry.ping >= 100) {
return false;
}
if (m_filters.ping == "< 150 ms" && a_entry.ping >= 150) {
return false;
}
if (!m_filters.showFull && a_entry.status == "Full") {
return false;
}
if (m_filters.hasPassword && !a_entry.passworded) {
return false;
}
if (m_filters.modded && !a_entry.modded) {
return false;
}
return true;
}
void ServerBrowserData::ApplyFilters()
{
m_filteredServers.clear();
for (const auto& entry : m_allServers) {
if (PassesFilters(entry)) {
m_filteredServers.push_back(entry);
}
}
}
void ServerBrowserData::SetAllServers(std::vector<ServerEntry> a_servers)
{
m_allServers = std::move(a_servers);
ApplyFilters();
}
void ServerBrowserData::ToggleFavorite(const std::string& a_serverId)
{
for (auto& entry : m_allServers) {
if (entry.id == a_serverId) {
entry.favorite = !entry.favorite;
break;
}
}
ApplyFilters();
}
const ServerEntry* ServerBrowserData::FindServer(const std::string& a_serverId) const
{
for (const auto& entry : m_allServers) {
if (entry.id == a_serverId) {
return std::addressof(entry);
}
}
return nullptr;
}
nlohmann::json ServerBrowserData::ToServerListJson() const
{
nlohmann::json servers = nlohmann::json::array();
for (const auto& entry : m_filteredServers) {
servers.push_back(entry.ToJson());
}
return servers;
}
std::vector<ServerEntry> ServerBrowserData::CreateMockServers()
{
return {
ServerEntry{
.id = "nz-1",
.name = "Commonwealth NZ #1",
.region = "Oceania",
.worldspace = "Commonwealth",
.mode = "Co-op",
.status = "Open",
.description = "Relaxed co-op server for explorers and builders. No PvP.",
.players = 6,
.maxPlayers = 12,
.ping = 32,
.modded = true,
.favorite = true },
ServerEntry{
.id = "vault111",
.name = "Vault 111 Co-op",
.region = "North America",
.worldspace = "Commonwealth",
.mode = "Co-op",
.status = "Open",
.description = "Story-friendly co-op.",
.players = 3,
.maxPlayers = 8,
.ping = 48,
.modded = false },
ServerEntry{
.id = "dc-rp",
.name = "DC Wasteland RP",
.region = "North America",
.worldspace = "Commonwealth",
.mode = "Roleplay",
.status = "Full",
.description = "Light roleplay around Diamond City.",
.players = 10,
.maxPlayers = 10,
.ping = 72,
.modded = true,
.passworded = true },
ServerEntry{
.id = "far-harbor",
.name = "Far Harbor Expedition",
.region = "Europe",
.worldspace = "Far Harbor",
.mode = "Co-op",
.status = "Open",
.description = "DLC-focused co-op through Far Harbor.",
.players = 4,
.maxPlayers = 6,
.ping = 118,
.modded = true,
.favorite = true },
ServerEntry{
.id = "local-relay",
.name = "Local Relay (Dev)",
.region = "Local",
.worldspace = "Commonwealth",
.mode = "Co-op",
.status = "Open",
.description = "Python relay prototype. Start server/server.py before joining.",
.host = "127.0.0.1",
.port = 7777,
.players = 0,
.maxPlayers = 16,
.ping = 1,
.modded = false },
ServerEntry{
.id = "survival-hard",
.name = "Survival Hardcore",
.region = "Europe",
.worldspace = "Commonwealth",
.mode = "Survival",
.status = "Open",
.description = "Hardcore survival with limited respawns.",
.players = 2,
.maxPlayers = 4,
.ping = 95,
.modded = true }
};
}
}
+6
View File
@@ -1,5 +1,7 @@
#include "F4TNetworking.h"
#include "F4TLocalAnimationGraphDebug.h"
#include "F4TPrismaUI.h"
#include "F4TScaleformUI.h"
#include "F4TProxyActorController.h"
#include "F4TProxyAnimationSync.h"
#include "F4TProxyPuppet.h"
@@ -636,6 +638,9 @@ namespace
}
switch (a_msg->type) {
case F4SE::MessagingInterface::kGameDataReady:
F4T::PrismaUI::OnGameDataReady();
break;
case F4SE::MessagingInterface::kNewGame:
case F4SE::MessagingInterface::kPostLoadGame:
case F4SE::MessagingInterface::kGameLoaded:
@@ -652,6 +657,7 @@ namespace
F4SE_PLUGIN_LOAD(const F4SE::LoadInterface* a_f4se)
{
F4SE::Init(a_f4se);
F4T::ScaleformUI::RegisterPlugin();
LogInfoWithLocalPlayerPrefix("Commonwealth Online plugin loaded successfully.");
// TODO: Consider separate per-instance log files once launcher/profile support exists.
+1
View File
@@ -27,6 +27,7 @@ target("CommonwealthOnline")
add_headerfiles("include/**.h")
add_includedirs("src")
add_includedirs("include")
add_includedirs("../ThirdParty/framework-F4-Conversion/example-f4se-plugin/src")
add_packages("nlohmann_json")
add_syslinks("ws2_32")
set_pcxxheader("src/pch.h")
+37
View File
@@ -0,0 +1,37 @@
param(
[string]$SdkPath = "F:\UltralightSDK"
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $scriptDir
$frameworkBuild = Join-Path $repoRoot "ThirdParty\framework-F4-Conversion\build"
$linkPath = Join-Path $frameworkBuild "ultralight-1.4.0"
if (-not (Test-Path $SdkPath)) {
Write-Error "Ultralight SDK not found at: $SdkPath"
}
foreach ($required in @("include", "lib", "bin", "resources")) {
$candidate = Join-Path $SdkPath $required
if (-not (Test-Path $candidate)) {
Write-Error "Ultralight SDK is missing required folder: $candidate"
}
}
New-Item -ItemType Directory -Path $frameworkBuild -Force | Out-Null
if (Test-Path $linkPath) {
$item = Get-Item $linkPath -Force
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
Write-Host "Removing existing junction: $linkPath"
cmd /c rmdir "$linkPath" | Out-Null
} else {
Write-Error "Expected a junction at $linkPath but found a normal directory. Remove it manually and rerun."
}
}
Write-Host "Linking $linkPath -> $SdkPath"
cmd /c mklink /J "$linkPath" "$SdkPath" | Out-Null
Write-Host "Ultralight SDK link ready."
+516
View File
@@ -0,0 +1,516 @@
(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", "details"];
var REPEAT_INITIAL_MS = 400;
var REPEAT_RATE_MS = 80;
var repeatTimer = null;
var repeatAction = null;
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: [],
connectionStatus: "Online",
isRefreshing: false,
directConnect: { address: "127.0.0.1", port: "7777" },
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) {
switch (action) {
case "refreshServerList":
refreshLocalMock();
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);
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 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 el = document.getElementById("coServerCount");
if (el) {
el.textContent = " \u2014 " + state.servers.length + " servers found";
}
}
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 getSelectedServer() {
return getActiveList()[state.selectedServerIndex] || null;
}
function getActiveList() {
if (state.activeTab === "favorites") return state.favorites;
if (state.activeTab === "recent") return state.recent;
return state.servers;
}
function joinServerLocal(serverId) {
var server = findServerById(serverId);
if (!server) return;
showJoinOverlay("Connecting to " + server.name + "...");
sendAction("joinServer", { serverId: serverId });
}
function joinDirectLocal(address, port) {
showJoinOverlay("Connecting to " + address + ":" + port + "...");
sendAction("openDirectConnect", { address: address, port: port });
}
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) {
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";
sendAction("switchTab", { tabName: tabName });
render();
}
function render() {
if (!root) return;
panel.classList.toggle("co-loading", state.isRefreshing);
CO_Tabs.render(root, state);
CO_ControllerPrompts.render(root, state);
document.getElementById("coConnectionStatus").textContent = state.connectionStatus;
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;
var listState = Object.assign({}, state, { servers: listServers });
if (state.activeTab === "favorites" || state.activeTab === "recent") {
listState.focusZone = state.focusZone === "details" || state.focusZone === "filters" ? "server-list" : state.focusZone;
}
if (state.activeTab === "server-browser") {
CO_Filters.render(root, listState);
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();
}
function bindUi() {
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 && e.key === "Escape") {
hideJoinOverlay();
return;
}
if (state.activeTab === "direct-connect") 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") 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 === "a") {
var vals = CO_DirectConnect.getValues(root);
joinDirectLocal(vals.address, vals.port);
}
return;
}
if (button === "a") {
joinSelected();
return;
}
if (button === "x") {
sendAction("refreshServerList", {});
return;
}
if (button === "y") {
var sel = getSelectedServer();
if (sel) toggleFavoriteLocal(sel.id);
return;
}
navigateFocus(button);
}
function joinSelected() {
var sel = getSelectedServer();
if (!sel) return;
joinServerLocal(sel.id);
}
function navigateFocus(button) {
var zoneIdx = FOCUS_ZONES.indexOf(state.focusZone);
var list = getActiveList();
if (button === "dpad_left") {
if (zoneIdx > 0) {
state.focusZone = FOCUS_ZONES[zoneIdx - 1];
render();
}
return;
}
if (button === "dpad_right") {
if (zoneIdx < FOCUS_ZONES.length - 1 && state.activeTab === "server-browser") {
state.focusZone = FOCUS_ZONES[zoneIdx + 1];
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" && state.selectedFilterIndex === maxFilter) resetLocalFilters();
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 "joinStarted":
showJoinOverlay("Connecting...");
break;
case "joinFailed":
showJoinOverlay(event.reason || "Connection failed", true);
break;
case "joinSucceeded":
hideJoinOverlay();
addRecent(event.serverId);
break;
case "connectionStatusChanged":
state.connectionStatus = event.status || "Online";
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(serverId) {
var server = findServerById(serverId);
if (!server) return;
state.recent = state.recent.filter(function (s) { return s.id !== serverId; });
state.recent.unshift(server);
if (state.recent.length > 8) state.recent.pop();
}
window.setConnectionStatus = function (connected, logPrefix) {
state.connectionStatus = connected ? "Connected" : "Online";
render();
};
window.onBrowserShown = function () {
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; }
};
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
applyLocalFilters();
bindUi();
render();
console.log("[CO] Server browser loaded");
})();
@@ -0,0 +1,53 @@
(function () {
"use strict";
window.CO_ControllerPrompts = {
render: function (root, state) {
var el = root.querySelector(".co-prompts");
if (!el) return;
var prompts = CO_ControllerPrompts.getPrompts(state);
el.innerHTML = prompts.map(function (p) {
return (
'<div class="co-prompt">' +
'<span class="co-prompt-key">' + p.key + "</span>" +
"<span>" + p.label + "</span>" +
"</div>"
);
}).join("");
},
getPrompts: function (state) {
if (state.joinOverlay) {
if (state.joinOverlay.error) {
return [{ key: "B", label: "Dismiss" }];
}
return [];
}
if (state.activeTab === "direct-connect") {
return [
{ key: "A", label: "Connect" },
{ key: "B", label: "Back" }
];
}
var base = [
{ key: "A", label: "Join" },
{ key: "X", label: "Refresh" },
{ key: "Y", label: "Favorite" },
{ key: "B", label: "Back" }
];
if (state.focusZone === "tabs") {
return [
{ key: "LB", label: "Prev Tab" },
{ key: "RB", label: "Next Tab" },
{ key: "B", label: "Back" }
];
}
return base;
}
};
})();
@@ -0,0 +1,55 @@
(function () {
"use strict";
window.CO_DirectConnect = {
render: function (root, state) {
var pane = root.querySelector('[data-pane="direct-connect"]');
if (!pane) return;
var address = state.directConnect.address || "127.0.0.1";
var port = state.directConnect.port || "7777";
pane.innerHTML =
'<div class="co-direct-connect">' +
"<h2>Direct Connect</h2>" +
'<div class="co-direct-row">' +
'<label for="co-dc-address">Server Address</label>' +
'<input class="co-filter-input" id="co-dc-address" type="text" value="' + escapeAttr(address) + '">' +
"</div>" +
'<div class="co-direct-row">' +
'<label for="co-dc-port">Port</label>' +
'<input class="co-filter-input" id="co-dc-port" type="text" value="' + escapeAttr(port) + '">' +
"</div>" +
'<button type="button" class="co-btn co-dc-connect">Connect</button>' +
'<p class="co-detail-desc" style="margin-top:16px">Enter a server address to connect directly without browsing the list.</p>' +
"</div>";
},
bind: function (root, onConnect) {
var pane = root.querySelector('[data-pane="direct-connect"]');
if (!pane) return;
pane.addEventListener("click", function (e) {
if (!e.target.closest(".co-dc-connect")) return;
var address = pane.querySelector("#co-dc-address");
var port = pane.querySelector("#co-dc-port");
onConnect(address ? address.value : "", port ? port.value : "");
});
},
getValues: function (root) {
var pane = root.querySelector('[data-pane="direct-connect"]');
if (!pane) return { address: "127.0.0.1", port: "7777" };
var address = pane.querySelector("#co-dc-address");
var port = pane.querySelector("#co-dc-port");
return {
address: address ? address.value : "127.0.0.1",
port: port ? port.value : "7777"
};
}
};
function escapeAttr(value) {
return String(value).replace(/"/g, "&quot;");
}
})();
@@ -0,0 +1,98 @@
(function () {
"use strict";
var FILTER_ROWS = [
{ key: "search", type: "text", label: "Search", placeholder: "Search servers..." },
{ key: "region", type: "select", label: "Region", options: ["Any Region", "Oceania", "North America", "Europe", "Local"] },
{ key: "mode", type: "select", label: "Mode", options: ["Any Mode", "Co-op", "Roleplay", "Survival"] },
{ key: "worldspace", type: "select", label: "Worldspace", options: ["Any Worldspace", "Commonwealth", "Far Harbor", "Nuka-World"] },
{ key: "ping", type: "select", label: "Ping", options: ["Any", "< 50 ms", "< 100 ms", "< 150 ms"] },
{ key: "showFull", type: "checkbox", label: "Show Full" },
{ key: "hasPassword", type: "checkbox", label: "Has Password" },
{ key: "modded", type: "checkbox", label: "Modded" }
];
window.CO_Filters = {
FILTER_ROWS: FILTER_ROWS,
render: function (root, state) {
var el = root.querySelector(".co-filters");
if (!el) return;
var focusedClass = state.focusZone === "filters" ? " focused" : "";
var html = FILTER_ROWS.map(function (row, index) {
var rowFocused = state.focusZone === "filters" && state.selectedFilterIndex === index ? " focused" : "";
if (row.type === "text") {
return (
'<div class="co-filter-row' + rowFocused + '" data-filter-index="' + index + '">' +
'<div class="co-filter-label">' + row.label + '</div>' +
'<input class="co-filter-input" type="text" data-filter="' + row.key + '" placeholder="' + row.placeholder + '" value="' + escapeAttr(state.filters[row.key] || "") + '">' +
"</div>"
);
}
if (row.type === "select") {
var opts = row.options.map(function (opt) {
var sel = (state.filters[row.key] || row.options[0]) === opt ? " selected" : "";
return '<option value="' + escapeAttr(opt) + '"' + sel + ">" + opt + "</option>";
}).join("");
return (
'<div class="co-filter-row' + rowFocused + '" data-filter-index="' + index + '">' +
'<div class="co-filter-label">' + row.label + '</div>' +
'<select class="co-filter-select" data-filter="' + row.key + '">' + opts + "</select>" +
"</div>"
);
}
var checked = state.filters[row.key] ? " checked" : "";
return (
'<label class="co-checkbox-row co-filter-row' + rowFocused + '" data-filter-index="' + index + '">' +
'<span class="co-checkbox' + checked + '" data-filter="' + row.key + '"></span>' +
row.label +
"</label>"
);
}).join("");
html += '<button type="button" class="co-btn co-reset-btn">Reset Filters</button>';
el.className = "co-filters" + focusedClass;
el.innerHTML = html;
},
bind: function (root, onFilterChange, onReset) {
var el = root.querySelector(".co-filters");
if (!el) return;
el.addEventListener("change", function (e) {
var target = e.target;
if (!target.dataset.filter) return;
onFilterChange(target.dataset.filter, target.type === "checkbox" ? target.checked : target.value);
});
el.addEventListener("input", function (e) {
var target = e.target;
if (target.classList.contains("co-filter-input")) {
onFilterChange(target.dataset.filter, target.value);
}
});
el.addEventListener("click", function (e) {
if (e.target.closest(".co-reset-btn")) {
onReset();
return;
}
var row = e.target.closest(".co-checkbox-row");
if (row) {
var box = row.querySelector(".co-checkbox");
if (box) {
onFilterChange(box.dataset.filter, !box.classList.contains("checked"));
}
}
});
}
};
function escapeAttr(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
}
})();
@@ -0,0 +1,100 @@
window.CO_MOCK_SERVERS = [
{
id: "nz-1",
name: "Commonwealth NZ #1",
region: "Oceania",
players: 6,
maxPlayers: 12,
ping: 32,
worldspace: "Commonwealth",
mode: "Co-op",
status: "Open",
modded: true,
passworded: false,
favorite: true,
description: "Relaxed co-op server for explorers and builders. No PvP. Help each other rebuild the Commonwealth.",
rules: ["No griefing or stealing", "Respect build sites", "Mic optional"]
},
{
id: "vault111",
name: "Vault 111 Co-op",
region: "North America",
players: 3,
maxPlayers: 8,
ping: 48,
worldspace: "Commonwealth",
mode: "Co-op",
status: "Open",
modded: false,
passworded: false,
favorite: false,
description: "Story-friendly co-op. Stay in sync with main quest progression.",
rules: ["No skipping major quest beats without group consent"]
},
{
id: "dc-rp",
name: "DC Wasteland RP",
region: "North America",
players: 10,
maxPlayers: 10,
ping: 72,
worldspace: "Commonwealth",
mode: "Roleplay",
status: "Full",
modded: true,
passworded: true,
favorite: false,
description: "Light roleplay server set around Diamond City trade routes.",
rules: ["Stay in character in voice", "No combat logging"]
},
{
id: "far-harbor",
name: "Far Harbor Expedition",
region: "Europe",
players: 4,
maxPlayers: 6,
ping: 118,
worldspace: "Far Harbor",
mode: "Co-op",
status: "Open",
modded: true,
passworded: false,
favorite: true,
description: "DLC-focused co-op run through Far Harbor content.",
rules: ["DLC content required", "Spoiler tags in chat for newcomers"]
},
{
id: "local-relay",
name: "Local Relay (Dev)",
region: "Local",
players: 0,
maxPlayers: 16,
ping: 1,
worldspace: "Commonwealth",
mode: "Co-op",
status: "Open",
modded: false,
passworded: false,
favorite: false,
host: "127.0.0.1",
port: 7777,
description: "Python relay used by the Commonwealth Online prototype. Start server/server.py before joining.",
rules: ["Development server only"]
},
{
id: "survival-hard",
name: "Survival Hardcore",
region: "Europe",
players: 2,
maxPlayers: 4,
ping: 95,
worldspace: "Commonwealth",
mode: "Survival",
status: "Open",
modded: true,
passworded: false,
favorite: false,
description: "Hardcore survival with limited respawns and shared resources.",
rules: ["Permadeath on disconnect during combat", "Shared stash rules apply"]
}
];
@@ -0,0 +1,60 @@
(function () {
"use strict";
window.CO_ServerDetails = {
render: function (root, state) {
var el = root.querySelector(".co-details");
if (!el) return;
el.className = "co-details" + (state.focusZone === "details" ? " focused" : "");
var server = state.servers[state.selectedServerIndex];
if (!server) {
el.innerHTML = '<div class="co-details-title">Server Details</div><div class="co-empty">Select a server</div>';
return;
}
var rulesHtml = "";
if (server.rules && server.rules.length) {
rulesHtml = "<ul class=\"co-detail-rules\">" + server.rules.map(function (r) {
return "<li>" + escapeHtml(r) + "</li>";
}).join("") + "</ul>";
}
var previewHtml = server.previewImage
? '<img src="' + escapeAttr(server.previewImage) + '" alt="Preview">'
: "Preview Unavailable";
el.innerHTML =
'<div class="co-details-title">Server Details</div>' +
'<div class="co-preview">' + previewHtml + "</div>" +
'<div class="co-detail-name">' + escapeHtml(server.name) + "</div>" +
detailRow("Region", server.region) +
detailRow("Players", server.players + "/" + server.maxPlayers) +
detailRow("Ping", server.ping + " ms") +
detailRow("Worldspace", server.worldspace) +
detailRow("Mode", server.mode) +
detailRow("Status", server.status) +
detailRow("Modded", server.modded ? "Yes" : "No") +
detailRow("Password", server.passworded ? "Required" : "None") +
'<div class="co-detail-desc">' + escapeHtml(server.description || "") + "</div>" +
rulesHtml +
'<div class="co-detail-desc" style="margin-top:8px;color:var(--co-text-dim)">Mod list: coming soon</div>';
}
};
function detailRow(label, value) {
return '<div class="co-detail-row"><span>' + label + '</span><span>' + escapeHtml(String(value)) + "</span></div>";
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeAttr(value) {
return String(value).replace(/"/g, "&quot;");
}
})();
@@ -0,0 +1,80 @@
(function () {
"use strict";
window.CO_ServerList = {
pingClass: function (ping) {
if (ping <= 50) return "co-ping-good";
if (ping <= 100) return "co-ping-mid";
return "co-ping-bad";
},
render: function (root, state) {
var wrap = root.querySelector(".co-server-list-wrap");
var list = root.querySelector(".co-server-list");
if (!wrap || !list) return;
wrap.className = "co-server-list-wrap" + (state.focusZone === "server-list" ? " focused" : "");
if (!state.servers.length) {
list.innerHTML = '<div class="co-empty">No servers found</div>';
return;
}
list.innerHTML = state.servers.map(function (server, index) {
var selected = index === state.selectedServerIndex ? " selected" : "";
var fav = server.favorite ? " on" : "";
var pingCls = CO_ServerList.pingClass(server.ping);
return (
'<div class="co-server-row' + selected + '" data-index="' + index + '" data-id="' + escapeAttr(server.id) + '">' +
'<span class="co-fav' + fav + '">' + (server.favorite ? "\u2605" : "\u2606") + "</span>" +
"<span>" + escapeHtml(server.name) + "</span>" +
"<span>" + server.players + "/" + server.maxPlayers + "</span>" +
'<span class="co-ping ' + pingCls + '">' + server.ping + " ms</span>" +
"<span>" + escapeHtml(server.worldspace) + "</span>" +
"<span>" + escapeHtml(server.mode) + "</span>" +
"<span>" + escapeHtml(server.status) + "</span>" +
"</div>"
);
}).join("");
},
bind: function (root, onSelect) {
var list = root.querySelector(".co-server-list");
if (!list) return;
list.addEventListener("click", function (e) {
var row = e.target.closest(".co-server-row");
if (!row) return;
onSelect(parseInt(row.getAttribute("data-index"), 10));
});
list.addEventListener("dblclick", function (e) {
var row = e.target.closest(".co-server-row");
if (!row) return;
onSelect(parseInt(row.getAttribute("data-index"), 10));
if (window.CO_App && window.CO_App.joinSelected) {
window.CO_App.joinSelected();
}
});
},
scrollToSelected: function (root, index) {
var list = root.querySelector(".co-server-list");
var row = list && list.querySelector('.co-server-row[data-index="' + index + '"]');
if (row) {
row.scrollIntoView({ block: "nearest" });
}
}
};
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeAttr(value) {
return String(value).replace(/"/g, "&quot;");
}
})();
@@ -0,0 +1,42 @@
(function () {
"use strict";
var TAB_ORDER = ["direct-connect", "server-browser", "favorites", "recent"];
window.CO_Tabs = {
TAB_ORDER: TAB_ORDER,
render: function (root, state) {
var tabsEl = root.querySelector(".co-tabs");
if (!tabsEl) return;
var labels = {
"direct-connect": "Direct Connect",
"server-browser": "Server Browser",
favorites: "Favorites",
recent: "Recent"
};
tabsEl.innerHTML = TAB_ORDER.map(function (tabId, index) {
var active = state.activeTab === tabId ? " active" : "";
var focused = state.focusZone === "tabs" && state.selectedTabIndex === index ? " focused" : "";
return (
'<button type="button" class="co-tab' + active + focused + '" data-tab="' + tabId + '" data-index="' + index + '">' +
labels[tabId] +
"</button>"
);
}).join("");
},
bind: function (root, onTabClick) {
var tabsEl = root.querySelector(".co-tabs");
if (!tabsEl) return;
tabsEl.addEventListener("click", function (e) {
var btn = e.target.closest(".co-tab");
if (!btn) return;
onTabClick(btn.getAttribute("data-tab"));
});
}
};
})();
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Commonwealth Online</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div class="co-root" id="coRoot">
<div class="co-panel" id="coPanel">
<header class="co-header">
<h1>Commonwealth Online</h1>
<div class="co-subtitle">Server Browser</div>
</header>
<nav class="co-tabs"></nav>
<div class="co-body">
<div class="co-tab-content">
<div class="co-tab-pane" data-pane="direct-connect"></div>
<div class="co-tab-pane active" data-pane="server-browser">
<div class="co-browser-layout">
<aside class="co-filters"></aside>
<div class="co-server-list-wrap">
<div class="co-server-list-header">
<span></span>
<span>Server Name</span>
<span>Players</span>
<span>Ping</span>
<span>Worldspace</span>
<span>Mode</span>
<span>Status</span>
</div>
<div class="co-server-list"></div>
</div>
<aside class="co-details"></aside>
</div>
</div>
<div class="co-tab-pane" data-pane="favorites">
<div class="co-browser-layout co-favorites-layout">
<div class="co-server-list-wrap">
<div class="co-server-list-header">
<span></span>
<span>Server Name</span>
<span>Players</span>
<span>Ping</span>
<span>Worldspace</span>
<span>Mode</span>
<span>Status</span>
</div>
<div class="co-server-list co-favorites-list"></div>
</div>
</div>
</div>
<div class="co-tab-pane" data-pane="recent">
<div class="co-browser-layout co-recent-layout">
<div class="co-server-list-wrap">
<div class="co-server-list-header">
<span></span>
<span>Server Name</span>
<span>Players</span>
<span>Ping</span>
<span>Worldspace</span>
<span>Mode</span>
<span>Status</span>
</div>
<div class="co-server-list co-recent-list"></div>
</div>
</div>
</div>
</div>
</div>
<footer class="co-action-bar">
<div class="co-status-text">
Status: <span id="coConnectionStatus">Online</span>
<span class="co-count" id="coServerCount"></span>
</div>
<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/mock-data.js"></script>
<script src="./components/tabs.js"></script>
<script src="./components/filters.js"></script>
<script src="./components/server-list.js"></script>
<script src="./components/server-details.js"></script>
<script src="./components/controller-prompts.js"></script>
<script src="./components/direct-connect.js"></script>
<script src="./app.js"></script>
</body>
</html>
@@ -0,0 +1,675 @@
:root {
--co-green: #4dff3a;
--co-green-soft: rgba(77, 255, 58, 0.88);
--co-green-dim: rgba(57, 255, 34, 0.35);
--co-bg: rgba(0, 14, 0, 0.92);
--co-bg-dark: rgba(0, 8, 0, 0.96);
--co-bg-list: rgba(0, 12, 0, 0.88);
--co-selected: rgba(77, 255, 58, 0.92);
--co-selected-text: #041200;
--co-border: rgba(77, 255, 58, 0.9);
--co-text-dim: rgba(77, 255, 58, 0.68);
--co-ping-good: #4dff3a;
--co-ping-mid: #c8ff3a;
--co-ping-bad: #ffd43a;
--co-danger: #ff6a6a;
--co-font: "Consolas", "Courier New", monospace;
--co-spacing: 10px;
--co-transition: 120ms ease;
--co-text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
/*
* Fallout 4 main-menu safe zones (1280×720 UI scaled to viewport):
* Left — menu list + green selection bar (~0–46%)
* Top — "Fallout 4 Updates" / installed-content panel (~0–26% height)
* Bottom — Bethesda logo / controller hints (~0–8% height)
*/
--co-panel-left: max(44vw, 500px);
--co-panel-right: max(2vw, 24px);
--co-panel-top: max(26vh, 220px);
--co-panel-bottom: max(8vh, 72px);
--co-font-sm: clamp(11px, 0.95vw, 13px);
--co-font-md: clamp(13px, 1.1vw, 16px);
--co-font-lg: clamp(15px, 1.25vw, 19px);
--co-font-xl: clamp(18px, 1.5vw, 24px);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
width: 100vw;
height: 100vh;
overflow: hidden;
font-family: var(--co-font);
color: var(--co-green);
background: transparent;
user-select: none;
}
.co-root {
position: relative;
width: 100vw;
height: 100vh;
pointer-events: none;
background: transparent;
}
/* Panel is inset from all Fallout main-menu chrome so vanilla UI stays visible. */
.co-panel {
position: absolute;
left: var(--co-panel-left);
right: var(--co-panel-right);
top: var(--co-panel-top);
bottom: var(--co-panel-bottom);
width: auto;
height: auto;
min-width: 0;
min-height: 360px;
display: flex;
flex-direction: column;
border: 2px solid var(--co-border);
background: var(--co-bg);
box-shadow:
0 0 0 1px rgba(77, 255, 58, 0.12),
0 0 48px rgba(0, 0, 0, 0.55),
inset 0 0 80px rgba(0, 0, 0, 0.25);
pointer-events: auto;
transition: opacity var(--co-transition);
backdrop-filter: blur(2px);
}
.co-panel.co-loading {
opacity: 0.92;
}
.co-header {
padding: 18px 22px 14px;
border-bottom: 1px solid var(--co-green-dim);
background: rgba(0, 6, 0, 0.45);
}
.co-header h1 {
font-size: var(--co-font-xl);
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 700;
text-shadow: var(--co-text-shadow), 0 0 12px rgba(77, 255, 58, 0.2);
}
.co-header .co-subtitle {
margin-top: 6px;
font-size: var(--co-font-sm);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--co-text-dim);
}
.co-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--co-green-dim);
padding: 0 16px;
background: rgba(0, 6, 0, 0.3);
}
.co-tab {
appearance: none;
border: none;
background: transparent;
color: var(--co-text-dim);
font: inherit;
font-size: var(--co-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 12px 18px;
cursor: pointer;
border: 1px solid transparent;
border-bottom: none;
margin-bottom: -1px;
transition: color var(--co-transition), border-color var(--co-transition);
}
.co-tab:hover {
color: var(--co-green-soft);
}
.co-tab.active {
color: var(--co-green);
border-color: var(--co-border);
background: rgba(57, 255, 34, 0.06);
}
.co-tab.focused {
outline: 1px solid var(--co-green);
outline-offset: -2px;
}
.co-body {
flex: 1;
min-height: 0;
overflow: hidden;
}
.co-browser-layout {
display: grid;
grid-template-columns: 1fr minmax(240px, 30%);
grid-template-rows: auto 1fr;
height: 100%;
min-height: 0;
}
.co-filters {
grid-column: 1 / -1;
border-bottom: 1px solid var(--co-green-dim);
padding: 12px 16px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px 14px;
overflow: visible;
background: rgba(0, 8, 0, 0.55);
}
.co-filters.focused {
outline: 1px solid var(--co-green);
outline-offset: -2px;
}
.co-filter-row {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.co-filter-row[data-filter-index="0"] {
flex: 1 1 180px;
min-width: 160px;
}
.co-filter-row[data-filter-index="1"],
.co-filter-row[data-filter-index="2"],
.co-filter-row[data-filter-index="3"],
.co-filter-row[data-filter-index="4"] {
flex: 0 1 130px;
min-width: 110px;
}
.co-filter-label {
font-size: var(--co-font-sm);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--co-text-dim);
}
.co-filter-input,
.co-filter-select {
width: 100%;
background: var(--co-bg-dark);
border: 1px solid var(--co-green-dim);
color: var(--co-green);
font: inherit;
font-size: var(--co-font-sm);
padding: 8px 10px;
text-shadow: var(--co-text-shadow);
}
.co-filter-input:focus,
.co-filter-select:focus {
border-color: var(--co-border);
outline: none;
}
.co-filter-row.focused .co-filter-input,
.co-filter-row.focused .co-filter-select,
.co-filter-row.focused .co-checkbox {
outline: 1px solid var(--co-green);
}
.co-checkbox-row {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--co-font-sm);
letter-spacing: 0.05em;
text-transform: uppercase;
cursor: pointer;
padding: 8px 0 0;
flex: 0 0 auto;
align-self: center;
}
.co-checkbox {
width: 16px;
height: 16px;
border: 1px solid var(--co-border);
background: var(--co-bg-dark);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.co-checkbox.checked::after {
content: "X";
font-size: 11px;
color: var(--co-green);
}
.co-btn {
appearance: none;
border: 1px solid var(--co-border);
background: rgba(77, 255, 58, 0.1);
color: var(--co-green);
font: inherit;
font-size: var(--co-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 10px 14px;
cursor: pointer;
margin-left: auto;
align-self: flex-end;
flex-shrink: 0;
transition: background var(--co-transition);
}
.co-btn:hover {
background: rgba(57, 255, 34, 0.18);
}
.co-btn.focused {
outline: 1px solid var(--co-green);
background: rgba(57, 255, 34, 0.22);
}
.co-server-list-wrap {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--co-green-dim);
background: var(--co-bg-list);
}
.co-server-list-wrap.focused {
outline: 1px solid var(--co-green);
outline-offset: -2px;
}
.co-server-list-header {
display: grid;
grid-template-columns: 32px 2.2fr 72px 64px 1fr 88px 72px;
gap: 8px;
padding: 12px 16px;
font-size: var(--co-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--co-text-dim);
border-bottom: 1px solid var(--co-green-dim);
background: rgba(0, 6, 0, 0.5);
}
.co-server-list {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--co-green-dim) var(--co-bg-dark);
}
.co-server-list::-webkit-scrollbar {
width: 10px;
}
.co-server-list::-webkit-scrollbar-track {
background: var(--co-bg-dark);
}
.co-server-list::-webkit-scrollbar-thumb {
background: var(--co-green-dim);
border: 1px solid var(--co-border);
}
.co-server-row {
display: grid;
grid-template-columns: 32px 2.2fr 72px 64px 1fr 88px 72px;
gap: 8px;
padding: 12px 16px;
font-size: var(--co-font-md);
line-height: 1.35;
align-items: center;
cursor: pointer;
border-bottom: 1px solid rgba(77, 255, 58, 0.1);
transition: background var(--co-transition);
text-shadow: var(--co-text-shadow);
}
.co-server-row > span:nth-child(2) {
font-size: var(--co-font-lg);
font-weight: 700;
letter-spacing: 0.04em;
}
.co-server-row > span:nth-child(3) {
font-size: var(--co-font-md);
font-weight: 600;
}
.co-server-row:hover {
background: rgba(57, 255, 34, 0.06);
}
.co-server-row.selected {
background: var(--co-selected);
color: var(--co-selected-text);
}
.co-server-row.selected .co-ping,
.co-server-row.selected .co-fav {
color: var(--co-selected-text);
}
.co-fav {
text-align: center;
color: var(--co-text-dim);
font-size: 16px;
}
.co-fav.on {
color: var(--co-green);
}
.co-server-row.selected .co-fav.on {
color: var(--co-selected-text);
}
.co-ping-good { color: var(--co-ping-good); }
.co-ping-mid { color: var(--co-ping-mid); }
.co-ping-bad { color: var(--co-ping-bad); }
.co-server-row.selected .co-ping-good,
.co-server-row.selected .co-ping-mid,
.co-server-row.selected .co-ping-bad {
color: var(--co-selected-text);
}
.co-details {
padding: 14px 16px;
overflow-y: auto;
font-size: var(--co-font-md);
background: rgba(0, 8, 0, 0.45);
line-height: 1.4;
}
.co-details.focused {
outline: 1px solid var(--co-green);
outline-offset: -2px;
}
.co-details-title {
font-size: var(--co-font-sm);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--co-text-dim);
margin-bottom: 10px;
}
.co-preview {
width: 100%;
height: 96px;
border: 1px solid var(--co-green-dim);
background: var(--co-bg-dark);
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--co-text-dim);
font-size: var(--co-font-sm);
letter-spacing: 0.06em;
text-transform: uppercase;
overflow: hidden;
}
.co-preview img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.85;
}
.co-detail-name {
font-size: var(--co-font-lg);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 10px;
font-weight: 700;
text-shadow: var(--co-text-shadow);
}
.co-detail-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
border-bottom: 1px solid rgba(77, 255, 58, 0.08);
}
.co-detail-row span:first-child {
color: var(--co-text-dim);
text-transform: uppercase;
font-size: var(--co-font-sm);
letter-spacing: 0.05em;
flex-shrink: 0;
}
.co-detail-row span:last-child {
text-align: right;
font-weight: 600;
}
.co-detail-desc {
margin-top: 12px;
line-height: 1.5;
color: var(--co-green-soft);
font-size: var(--co-font-sm);
}
.co-detail-rules {
margin-top: 10px;
padding-left: 16px;
color: var(--co-text-dim);
font-size: var(--co-font-sm);
}
.co-detail-rules li {
margin-bottom: 3px;
}
.co-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
border-top: 1px solid var(--co-green-dim);
font-size: var(--co-font-sm);
letter-spacing: 0.05em;
background: rgba(0, 6, 0, 0.45);
}
.co-status-text {
text-transform: uppercase;
}
.co-status-text .co-count {
color: var(--co-text-dim);
}
.co-prompts {
display: flex;
gap: 20px;
flex-wrap: wrap;
justify-content: flex-end;
}
.co-prompt {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--co-font-sm);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.co-prompt-key {
width: 22px;
height: 22px;
border: 1px solid var(--co-border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
}
.co-direct-connect {
padding: 28px 36px;
max-width: 520px;
}
.co-direct-connect h2 {
font-size: var(--co-font-lg);
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 18px;
}
.co-direct-row {
margin-bottom: 14px;
}
.co-direct-row label {
display: block;
font-size: var(--co-font-sm);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--co-text-dim);
margin-bottom: 6px;
}
.co-empty {
padding: 32px;
text-align: center;
color: var(--co-text-dim);
font-size: var(--co-font-md);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.co-join-overlay {
position: absolute;
inset: 0;
background: rgba(0, 10, 0, 0.88);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
pointer-events: auto;
}
.co-join-overlay.hidden {
display: none;
}
.co-join-overlay p {
font-size: var(--co-font-md);
letter-spacing: 0.1em;
text-transform: uppercase;
margin-top: 14px;
}
.co-join-overlay.co-error p {
color: var(--co-danger);
}
.co-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--co-green-dim);
border-top-color: var(--co-green);
animation: co-spin 0.8s linear infinite;
}
@keyframes co-spin {
to { transform: rotate(360deg); }
}
.co-tab-content {
height: 100%;
overflow: hidden;
}
.co-tab-pane {
display: none;
height: 100%;
}
.co-tab-pane.active {
display: block;
}
.co-filters::-webkit-scrollbar,
.co-details::-webkit-scrollbar {
width: 8px;
}
.co-filters::-webkit-scrollbar-thumb,
.co-details::-webkit-scrollbar-thumb {
background: var(--co-green-dim);
}
.co-favorites-layout,
.co-recent-layout {
grid-template-columns: 1fr;
grid-template-rows: 1fr;
}
.co-favorites-layout .co-server-list-wrap,
.co-recent-layout .co-server-list-wrap {
grid-column: 1;
border-right: none;
}
@media (max-width: 1280px) {
:root {
--co-panel-left: max(46vw, 520px);
--co-panel-right: max(2vw, 16px);
--co-panel-top: max(28vh, 200px);
--co-panel-bottom: max(9vh, 64px);
}
.co-browser-layout {
grid-template-columns: 1fr minmax(200px, 34%);
}
.co-filter-row[data-filter-index="1"],
.co-filter-row[data-filter-index="2"],
.co-filter-row[data-filter-index="3"],
.co-filter-row[data-filter-index="4"] {
flex: 0 1 100px;
min-width: 90px;
}
}
@media (min-width: 2560px) {
:root {
--co-panel-left: max(44vw, 960px);
--co-panel-right: max(2.5vw, 48px);
--co-panel-top: max(24vh, 280px);
--co-panel-bottom: max(7vh, 96px);
}
}
@@ -0,0 +1,247 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Commonwealth Online</title>
<style>
:root {
--pip-green: #39ff14;
--pip-dim: #1a6610;
--pip-bg: rgba(0, 8, 0, 0.92);
--pip-border: #0f3d0a;
--pip-text-dim: #7adf66;
--pip-danger: #ff5a5a;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 100vw;
height: 100vh;
overflow: hidden;
font-family: "Consolas", "Courier New", monospace;
color: var(--pip-green);
background: transparent;
}
.backdrop {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at top, rgba(57, 255, 20, 0.08), transparent 45%),
rgba(0, 0, 0, 0.72);
}
.panel {
width: min(720px, 92vw);
border: 2px solid var(--pip-border);
background: var(--pip-bg);
box-shadow:
0 0 0 1px rgba(57, 255, 20, 0.15),
0 0 40px rgba(57, 255, 20, 0.08);
padding: 28px 32px 24px;
}
.scanlines {
position: relative;
}
.scanlines::after {
content: "";
pointer-events: none;
position: absolute;
inset: 0;
background: repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, 0.08) 3px
);
}
h1 {
font-size: 28px;
letter-spacing: 0.18em;
text-transform: uppercase;
margin-bottom: 6px;
}
.subtitle {
color: var(--pip-text-dim);
font-size: 12px;
letter-spacing: 0.08em;
margin-bottom: 24px;
}
.status-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
font-size: 13px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--pip-danger);
box-shadow: 0 0 8px rgba(255, 90, 90, 0.8);
}
.status-dot.connected {
background: var(--pip-green);
box-shadow: 0 0 8px rgba(57, 255, 20, 0.8);
}
.card {
border: 1px solid var(--pip-border);
padding: 16px 18px;
margin-bottom: 18px;
background: rgba(0, 20, 0, 0.55);
}
.card h2 {
font-size: 14px;
letter-spacing: 0.12em;
margin-bottom: 8px;
}
.card p {
font-size: 12px;
color: var(--pip-text-dim);
line-height: 1.5;
}
.server-address {
margin-top: 10px;
font-size: 16px;
letter-spacing: 0.06em;
}
.actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
button {
appearance: none;
border: 1px solid var(--pip-green);
background: rgba(57, 255, 20, 0.08);
color: var(--pip-green);
padding: 10px 18px;
font: inherit;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
}
button:hover {
background: rgba(57, 255, 20, 0.18);
}
button.secondary {
border-color: var(--pip-dim);
color: var(--pip-text-dim);
background: rgba(26, 102, 16, 0.15);
}
.hint {
margin-top: 18px;
font-size: 11px;
color: var(--pip-text-dim);
}
</style>
</head>
<body>
<div class="backdrop">
<div class="panel scanlines">
<h1>Commonwealth Online</h1>
<div class="subtitle">Relay server browser prototype</div>
<div class="status-row">
<div id="statusDot" class="status-dot"></div>
<div id="statusText">Checking connection...</div>
</div>
<div class="card">
<h2>Local Relay</h2>
<p>Connect to the Python relay used by this prototype. Start <code>server/server.py</code> before connecting.</p>
<div class="server-address">127.0.0.1:7777</div>
</div>
<div class="actions">
<button id="connectBtn" type="button">Connect</button>
<button id="disconnectBtn" type="button" class="secondary">Disconnect</button>
<button id="closeBtn" type="button" class="secondary">Close</button>
</div>
<div class="hint">Opened from Main Menu &gt; Multiplayer. Press F9 to toggle. JS console output goes to CommonwealthOnline.log.</div>
</div>
</div>
<script>
const statusDot = document.getElementById("statusDot");
const statusText = document.getElementById("statusText");
const connectBtn = document.getElementById("connectBtn");
const disconnectBtn = document.getElementById("disconnectBtn");
const closeBtn = document.getElementById("closeBtn");
function fire(eventName) {
const bridge = window[eventName];
if (typeof bridge === "function") {
bridge();
return;
}
console.warn("Prisma bridge unavailable for event:", eventName);
}
window.connectServer = window.connectServer || function() {
console.warn("connectServer bridge not ready yet");
};
window.disconnectServer = window.disconnectServer || function() {
console.warn("disconnectServer bridge not ready yet");
};
window.closeBrowser = window.closeBrowser || function() {
console.warn("closeBrowser bridge not ready yet");
};
window.setConnectionStatus = function(connected, logPrefix) {
statusDot.classList.toggle("connected", !!connected);
statusText.textContent = connected
? "Connected to relay (" + (logPrefix || "player unassigned") + ")"
: "Not connected to relay";
connectBtn.disabled = !!connected;
disconnectBtn.disabled = !connected;
};
window.onBrowserShown = function() {
console.log("Commonwealth Online server browser visible");
};
connectBtn.addEventListener("click", function() {
fire("connectServer");
});
disconnectBtn.addEventListener("click", function() {
fire("disconnectServer");
});
closeBtn.addEventListener("click", function() {
fire("closeBrowser");
});
console.log("Commonwealth Online server browser loaded");
</script>
</body>
</html>