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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
Submodule ThirdParty/framework-F4-Conversion added at d9bf8fbb45
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
namespace F4T::MenuInput
|
||||
{
|
||||
void Register();
|
||||
void SetBrowserActive(bool a_active);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
namespace F4T::PrismaUI
|
||||
{
|
||||
void OnGameDataReady();
|
||||
void ShowServerBrowser();
|
||||
void HideServerBrowser();
|
||||
bool IsBrowserVisible();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
namespace F4T::ScaleformUI
|
||||
{
|
||||
void RegisterPlugin();
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
@@ -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, """);
|
||||
}
|
||||
})();
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<");
|
||||
}
|
||||
})();
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value).replace(/"/g, """);
|
||||
}
|
||||
})();
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value).replace(/"/g, """);
|
||||
}
|
||||
})();
|
||||
@@ -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 > 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>
|
||||
Reference in New Issue
Block a user