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"]
|
[submodule "ThirdParty/xbyak"]
|
||||||
path = ThirdParty/xbyak
|
path = ThirdParty/xbyak
|
||||||
url = https://github.com/herumi/xbyak.git
|
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;
|
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
|
## FFDec workflow
|
||||||
|
|
||||||
|
|||||||
@@ -716,6 +716,10 @@ package
|
|||||||
public function set currentState(param1:String) : *
|
public function set currentState(param1:String) : *
|
||||||
{
|
{
|
||||||
this.strCurrentState = param1;
|
this.strCurrentState = param1;
|
||||||
|
if(param1 != this.MAIN_STATE)
|
||||||
|
{
|
||||||
|
this.CloseMultiplayerBrowser();
|
||||||
|
}
|
||||||
this.UpdateButtons(param1);
|
this.UpdateButtons(param1);
|
||||||
this.UpdateStateFocus(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() : *
|
private function onMainListItemPress() : *
|
||||||
{
|
{
|
||||||
if(Boolean(this.MainPanel_mc.List_mc.selectedEntry.disabled) || Boolean(this.Spinner_mc.visible == true) || this.bSettingsChanged)
|
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);
|
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)
|
if(this.MainPanel_mc.List_mc.selectedEntry.index == this.CONTINUE_INDEX && this.PauseMode)
|
||||||
{
|
{
|
||||||
this.SaveLoadHolder_mc.visible = false;
|
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
|
namespace F4T::Networking
|
||||||
{
|
{
|
||||||
std::string GetLocalPlayerLogPrefix();
|
std::string GetLocalPlayerLogPrefix();
|
||||||
|
bool ConnectToServer(const char* a_host, std::uint16_t a_port);
|
||||||
bool ConnectToLocalServer();
|
bool ConnectToLocalServer();
|
||||||
void DisconnectFromLocalServer();
|
void DisconnectFromLocalServer();
|
||||||
bool IsConnectedToServer();
|
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
|
* Combat sync
|
||||||
* Quest 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
|
## Required Tools
|
||||||
|
|
||||||
The plugin setup is expected to require:
|
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) + "]";
|
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()) {
|
if (IsConnectedToServer()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -610,27 +614,25 @@ namespace F4T::Networking
|
|||||||
return false;
|
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);
|
const auto localSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||||
if (localSocket == INVALID_SOCKET) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SetNonBlocking(localSocket)) {
|
if (!SetNonBlocking(localSocket)) {
|
||||||
closesocket(localSocket);
|
closesocket(localSocket);
|
||||||
LogWarningWithLocalPlayerPrefix(
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
sockaddr_in serverAddress{};
|
sockaddr_in serverAddress{};
|
||||||
serverAddress.sin_family = AF_INET;
|
serverAddress.sin_family = AF_INET;
|
||||||
serverAddress.sin_port = htons(kLocalServerPort);
|
serverAddress.sin_port = htons(a_port);
|
||||||
if (inet_pton(AF_INET, kLocalServerHost, std::addressof(serverAddress.sin_addr)) != 1) {
|
if (inet_pton(AF_INET, a_host, std::addressof(serverAddress.sin_addr)) != 1) {
|
||||||
closesocket(localSocket);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,12 +643,10 @@ namespace F4T::Networking
|
|||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
closesocket(localSocket);
|
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(
|
LogWarningWithLocalPlayerPrefix(std::format(
|
||||||
"Could not connect to Commonwealth Online local server at {}:{}",
|
"Could not connect to Commonwealth Online server at {}:{}",
|
||||||
kLocalServerHost,
|
a_host,
|
||||||
kLocalServerPort));
|
a_port));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,10 +662,15 @@ namespace F4T::Networking
|
|||||||
StartReceiveThread();
|
StartReceiveThread();
|
||||||
|
|
||||||
LogInfoWithLocalPlayerPrefix(
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ConnectToLocalServer()
|
||||||
|
{
|
||||||
|
return ConnectToServer(kLocalServerHost, kLocalServerPort);
|
||||||
|
}
|
||||||
|
|
||||||
void DisconnectFromLocalServer()
|
void DisconnectFromLocalServer()
|
||||||
{
|
{
|
||||||
StopReceiveThread();
|
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 "F4TNetworking.h"
|
||||||
#include "F4TLocalAnimationGraphDebug.h"
|
#include "F4TLocalAnimationGraphDebug.h"
|
||||||
|
#include "F4TPrismaUI.h"
|
||||||
|
#include "F4TScaleformUI.h"
|
||||||
#include "F4TProxyActorController.h"
|
#include "F4TProxyActorController.h"
|
||||||
#include "F4TProxyAnimationSync.h"
|
#include "F4TProxyAnimationSync.h"
|
||||||
#include "F4TProxyPuppet.h"
|
#include "F4TProxyPuppet.h"
|
||||||
@@ -636,6 +638,9 @@ namespace
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (a_msg->type) {
|
switch (a_msg->type) {
|
||||||
|
case F4SE::MessagingInterface::kGameDataReady:
|
||||||
|
F4T::PrismaUI::OnGameDataReady();
|
||||||
|
break;
|
||||||
case F4SE::MessagingInterface::kNewGame:
|
case F4SE::MessagingInterface::kNewGame:
|
||||||
case F4SE::MessagingInterface::kPostLoadGame:
|
case F4SE::MessagingInterface::kPostLoadGame:
|
||||||
case F4SE::MessagingInterface::kGameLoaded:
|
case F4SE::MessagingInterface::kGameLoaded:
|
||||||
@@ -652,6 +657,7 @@ namespace
|
|||||||
F4SE_PLUGIN_LOAD(const F4SE::LoadInterface* a_f4se)
|
F4SE_PLUGIN_LOAD(const F4SE::LoadInterface* a_f4se)
|
||||||
{
|
{
|
||||||
F4SE::Init(a_f4se);
|
F4SE::Init(a_f4se);
|
||||||
|
F4T::ScaleformUI::RegisterPlugin();
|
||||||
|
|
||||||
LogInfoWithLocalPlayerPrefix("Commonwealth Online plugin loaded successfully.");
|
LogInfoWithLocalPlayerPrefix("Commonwealth Online plugin loaded successfully.");
|
||||||
// TODO: Consider separate per-instance log files once launcher/profile support exists.
|
// TODO: Consider separate per-instance log files once launcher/profile support exists.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ target("CommonwealthOnline")
|
|||||||
add_headerfiles("include/**.h")
|
add_headerfiles("include/**.h")
|
||||||
add_includedirs("src")
|
add_includedirs("src")
|
||||||
add_includedirs("include")
|
add_includedirs("include")
|
||||||
|
add_includedirs("../ThirdParty/framework-F4-Conversion/example-f4se-plugin/src")
|
||||||
add_packages("nlohmann_json")
|
add_packages("nlohmann_json")
|
||||||
add_syslinks("ws2_32")
|
add_syslinks("ws2_32")
|
||||||
set_pcxxheader("src/pch.h")
|
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