openvibers/source.openvibe.games Commits

Add embedded OpenVibe HTML menu

CO Copilot committed 29 Jun 2026 · 750c7affbf · 15 files, +689 −111


Changes

ddiff --git a/game/openvibe.games/cfg/autoexec.cfg b/game/openvibe.games/cfg/autoexec.cfg
iindex 30acddf..9ab1d37 100644
---- a/game/openvibe.games/cfg/autoexec.cfg
++++ b/game/openvibe.games/cfg/autoexec.cfg
@@@ -1,8 +1,4 @@
--// OpenVibe: Source client fallback aliases.
++// OpenVibe: Source client startup config.
//
--// The Linux64 SDK build provides real C++ implementations for these commands.
--// Proton/Windows clients may still load older DLLs until a Windows GameDLL is
--// built, so these aliases prevent noisy "Unknown command" console output.
--alias "openvibe_menu" "echo OpenVibe: Source menu is available through GameMenu.res or the launcher."
--alias "ov_menu" "openvibe_menu"
--alias "ov_join" "echo OpenVibe: authenticated ov_join requires the custom OpenVibe client DLL."
++// Do not define openvibe_menu, ov_menu, or ov_join aliases here. The custom
++// client DLL owns those commands, and aliases would mask the real C++ bridge.
ddiff --git a/game/openvibe.games/resource/GameMenu.res b/game/openvibe.games/resource/GameMenu.res
iindex ec80a9b..dbf0d61 100644
---- a/game/openvibe.games/resource/GameMenu.res
++++ b/game/openvibe.games/resource/GameMenu.res
@@@ -2,8 +2,8 @@
{
"1"
{
-- "label" "OpenVibe.Games"
-- "command" "engine echo OpenVibe.Games"
++ "label" "OpenVibe.Games Menu"
++ "command" "engine openvibe_menu"
"OnlyInGame" "0"
}
"2"
ddiff --git a/launcher/index.html b/launcher/index.html
iindex a4e37c6..2b675bf 100644
---- a/launcher/index.html
++++ b/launcher/index.html
@@@ -32,6 +32,7 @@
<span id="server-count">0 servers</span>
<span class="status-sep">|</span>
<span class="status-right" id="game-status-label">Game: idle</span>
++ <button class="embedded-close-btn" id="embedded-close" title="Close menu">Close</button>
</div>
<!-- ── Main layout ──────────────────────────────────────────────────── -->
ddiff --git a/launcher/main.js b/launcher/main.js
iindex 62bd1d8..11b3151 100644
---- a/launcher/main.js
++++ b/launcher/main.js
@@@ -11,9 +11,12 @@ const https = require('https');
const API_BASE = 'http://127.0.0.1:3000';
const DEV = process.env.ELECTRON_IS_DEV === '1';
++const CLIENT_UI_PORT = Number(process.env.OPENVIBE_CLIENT_UI_PORT || 5173);
let mainWindow = null;
let gameProcess = null;
++let uiServerProcess = null;
++let appIsQuitting = false;
// ── API helper ────────────────────────────────────────────────────────────────
function apiGet(urlPath) {
@@@ -58,8 +61,63 @@ function apiPost(urlPath, body) {
});
}
++function waitForHttp(url, timeoutMs = 5000) {
++ const started = Date.now();
++ return new Promise((resolve) => {
++ const tick = () => {
++ http.get(url, { timeout: 1000 }, (res) => {
++ res.resume();
++ resolve(res.statusCode >= 200 && res.statusCode < 500);
++ }).on('error', () => {
++ if (Date.now() - started >= timeoutMs) {
++ resolve(false);
++ return;
++ }
++ setTimeout(tick, 200);
++ }).on('timeout', () => {
++ if (Date.now() - started >= timeoutMs) {
++ resolve(false);
++ return;
++ }
++ setTimeout(tick, 200);
++ });
++ };
++ tick();
++ });
++}
++
++async function ensureClientUiServer(root) {
++ const healthUrl = `http://127.0.0.1:${CLIENT_UI_PORT}/health`;
++ if (await waitForHttp(healthUrl, 500)) return true;
++
++ const serverScript = path.join(root, 'tools/serve-client-ui.mjs');
++ if (!fs.existsSync(serverScript)) {
++ dialog.showErrorBox('OpenVibe Menu Server Missing', `Could not find:\n${serverScript}`);
++ return false;
++ }
++
++ uiServerProcess = spawn(process.execPath, [serverScript], {
++ cwd: root,
++ env: {
++ ...process.env,
++ OPENVIBE_ROOT: root,
++ OPENVIBE_CLIENT_UI_PORT: String(CLIENT_UI_PORT),
++ },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ });
++
++ uiServerProcess.stdout.on('data', (d) => console.log('[client-ui]', d.toString().trim()));
++ uiServerProcess.stderr.on('data', (d) => console.error('[client-ui]', d.toString().trim()));
++ uiServerProcess.on('exit', (code) => {
++ console.log('[launcher] client UI server exited with code', code);
++ uiServerProcess = null;
++ });
++
++ return waitForHttp(healthUrl, 5000);
++}
++
// ── Game launch ────────────────────────────────────────────────────────────────
--function launchGame(serverIp, serverPort) {
++async function launchGame(serverIp, serverPort) {
const root = path.resolve(__dirname, '..');
const mod = path.join(root, 'game/openvibe.games');
@@@ -79,6 +137,13 @@ function launchGame(serverIp, serverPort) {
return false;
}
++ const uiReady = await ensureClientUiServer(root);
++ if (!uiReady) {
++ dialog.showErrorBox('OpenVibe Menu Server Failed',
++ `Could not start http://127.0.0.1:${CLIENT_UI_PORT}/client for the in-game HTML menu.`);
++ return false;
++ }
++
// Build arguments to pass through the shell script
const scriptArgs = serverIp && serverPort ? [serverIp, String(serverPort)] : [];
@@@ -100,8 +165,15 @@ function launchGame(serverIp, serverPort) {
console.log('[launcher] game exited with code', code);
gameProcess = null;
mainWindow?.webContents.send('game-exited', code);
++ mainWindow?.show();
++ mainWindow?.focus();
});
++ mainWindow?.webContents.send('game-started', gameProcess.pid);
++ setTimeout(() => {
++ if (gameProcess && mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
++ }, 750);
++
// Detach so the game keeps running if Electron is closed
gameProcess.unref();
@@@ -131,11 +203,11 @@ ipcMain.handle('api:travel', async (_e, { steamId, mode }) => {
catch (e) { return { error: e.message }; }
});
--ipcMain.handle('game:launch', (_e, { ip, port }) => {
++ipcMain.handle('game:launch', async (_e, { ip, port }) => {
return launchGame(ip, port);
});
--ipcMain.handle('game:launch-direct', (_e, mode) => {
++ipcMain.handle('game:launch-direct', async (_e, mode) => {
// Look up the mode's port from local server list
const serverMap = {
hub: { ip: '127.0.0.1', port: 27015 },
@@@ -181,6 +253,13 @@ function createWindow() {
if (DEV) mainWindow.webContents.openDevTools({ mode: 'detach' });
});
++ mainWindow.on('close', (event) => {
++ if (gameProcess && !appIsQuitting) {
++ event.preventDefault();
++ mainWindow.hide();
++ }
++ });
++
mainWindow.on('closed', () => {
mainWindow = null;
});
@@@ -196,9 +275,18 @@ app.whenReady().then(() => {
});
app.on('window-all-closed', () => {
++ if (gameProcess) return;
if (process.platform !== 'darwin') app.quit();
});
++app.on('before-quit', () => {
++ appIsQuitting = true;
++ if (uiServerProcess) {
++ uiServerProcess.kill();
++ uiServerProcess = null;
++ }
++});
++
// Titlebar controls via IPC
ipcMain.on('window:minimize', () => mainWindow?.minimize());
ipcMain.on('window:maximize', () => {
ddiff --git a/launcher/package.json b/launcher/package.json
iindex 76842e4..bab9fb6 100644
---- a/launcher/package.json
++++ b/launcher/package.json
@@@ -5,7 +5,8 @@
"main": "main.js",
"scripts": {
"start": "electron .",
-- "dev": "ELECTRON_IS_DEV=1 electron ."
++ "dev": "ELECTRON_IS_DEV=1 electron .",
++ "serve": "node ../tools/serve-client-ui.mjs"
},
"keywords": [],
"author": "OpenVibe",
ddiff --git a/launcher/preload.js b/launcher/preload.js
iindex a918f86..bab9638 100644
---- a/launcher/preload.js
++++ b/launcher/preload.js
@@@ -21,5 +21,6 @@ contextBridge.exposeInMainWorld('OV', {
openUrl: (url) => ipcRenderer.send('open-url', url),
// Events from main → renderer
++ onGameStart: (cb) => ipcRenderer.on('game-started', (_e, pid) => cb(pid)),
onGameExit: (cb) => ipcRenderer.on('game-exited', (_e, code) => cb(code)),
});
ddiff --git a/launcher/renderer.js b/launcher/renderer.js
iindex 1ad3659..1964a62 100644
---- a/launcher/renderer.js
++++ b/launcher/renderer.js
@@@ -11,6 +11,13 @@ const MODES = {
traitortown: { label: 'Traitor Town', port: 27019, color: '#a855f7' },
};
++const API_BASE = 'http://127.0.0.1:3000';
++const isElectron = !!window.OV;
++const isEmbedded = new URLSearchParams(window.location.search).get('embedded') === '1' || !isElectron;
++
++document.documentElement.classList.toggle('embedded', isEmbedded);
++document.body.classList.toggle('embedded', isEmbedded);
++
const PLAYER_IDS = {
hub: 'hub-players', prophunt: 'ph-players',
deathrun: 'dr-players', fortwars: 'fw-players', traitortown: 'tt-players',
@@@ -20,6 +27,45 @@ let currentTab = 'portal';
let serverData = [];
let apiOnline = false;
++async function apiFetch(path, options = {}) {
++ const res = await fetch(`${API_BASE}${path}`, {
++ ...options,
++ headers: {
++ 'Accept': 'application/json',
++ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
++ ...(options.headers || {}),
++ },
++ });
++ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
++ return res.json();
++}
++
++const Bridge = {
++ health: () => isElectron ? window.OV.health() : apiFetch('/health'),
++ servers: async () => {
++ if (isElectron) return window.OV.servers();
++ const data = await apiFetch('/v1/servers');
++ return Array.isArray(data) ? data : (data.servers ?? []);
++ },
++ leaderboard: async (limit) => {
++ if (isElectron) return window.OV.leaderboard(limit);
++ const data = await apiFetch(`/v1/leaderboard?limit=${limit}`);
++ return Array.isArray(data) ? data : (data.players ?? []);
++ },
++ launchMode: async (mode) => {
++ if (isElectron) return window.OV.launchMode(mode);
++ window.location.href = `openvibe://join?mode=${encodeURIComponent(mode)}`;
++ return true;
++ },
++ gameStatus: () => isElectron ? window.OV.gameStatus() : Promise.resolve({ running: true, pid: null }),
++ close: () => {
++ if (isElectron) window.OV.close();
++ else window.location.href = 'openvibe://close';
++ },
++ minimize: () => { if (isElectron) window.OV.minimize(); },
++ maximize: () => { if (isElectron) window.OV.maximize(); },
++};
++
// ── Toast helper ─────────────────────────────────────────────────────────────
const toastEl = document.getElementById('toast');
let toastTimer;
@@@ -52,9 +98,10 @@ function setTab(tab) {
}
// ── Titlebar controls ────────────────────────────────────────────────────────
--document.getElementById('btn-min')?.addEventListener('click', () => window.OV.minimize());
--document.getElementById('btn-max')?.addEventListener('click', () => window.OV.maximize());
--document.getElementById('btn-close')?.addEventListener('click', () => window.OV.close());
++document.getElementById('btn-min')?.addEventListener('click', () => Bridge.minimize());
++document.getElementById('btn-max')?.addEventListener('click', () => Bridge.maximize());
++document.getElementById('btn-close')?.addEventListener('click', () => Bridge.close());
++document.getElementById('embedded-close')?.addEventListener('click', () => Bridge.close());
// ── Launch overlay ───────────────────────────────────────────────────────────
const launchOverlay = document.getElementById('launch-overlay');
@@@ -81,7 +128,7 @@ async function launchMode(mode) {
toast(`Connecting to ${info.label}…`);
try {
-- const ok = await window.OV.launchMode(mode);
++ const ok = await Bridge.launchMode(mode);
if (ok) {
toast(`✓ ${info.label} launched — port ${info.port}`);
updateGameStatus(true);
@@@ -115,11 +162,16 @@ document.getElementById('settings-launch')?.addEventListener('click', () => {
});
// Game exit callback
--window.OV.onGameExit((code) => {
-- hideLaunchOverlay();
-- updateGameStatus(false);
-- toast(`Game exited (code ${code})`);
--});
++if (isElectron) {
++ window.OV.onGameStart?.(() => {
++ updateGameStatus(true);
++ });
++ window.OV.onGameExit((code) => {
++ hideLaunchOverlay();
++ updateGameStatus(false);
++ toast(`Game exited (code ${code})`);
++ });
++}
// ── API health ────────────────────────────────────────────────────────────────
const apiDot = document.getElementById('api-dot');
@@@ -128,7 +180,7 @@ const srvCount = document.getElementById('server-count');
async function checkApiHealth() {
try {
-- const data = await window.OV.health();
++ const data = await Bridge.health();
if (data.ok) {
apiOnline = true;
apiDot.className = 'status-dot up';
@@@ -155,7 +207,7 @@ async function refreshServers() {
tbody.innerHTML = '<tr><td colspan="6" class="loading-row">Loading…</td></tr>';
try {
-- const servers = await window.OV.servers();
++ const servers = await Bridge.servers();
serverData = servers;
if (!servers || servers.length === 0) {
@@@ -220,7 +272,7 @@ async function refreshLeaderboard() {
list.innerHTML = '<li class="loading-row">Loading…</li>';
try {
-- const data = await window.OV.leaderboard(20);
++ const data = await Bridge.leaderboard(20);
if (!data || data.length === 0) {
list.innerHTML = '<li class="loading-row">No rankings yet — play some games!</li>';
@@@ -262,7 +314,7 @@ async function init() {
// Load server player counts for portal cards
try {
-- const servers = await window.OV.servers();
++ const servers = await Bridge.servers();
serverData = servers;
srvCount.textContent = `${servers.length} server${servers.length !== 1 ? 's' : ''}`;
servers.forEach((s) => {
@@@ -279,7 +331,7 @@ async function init() {
setInterval(async () => {
await checkApiHealth();
try {
-- const status = await window.OV.gameStatus();
++ const status = await Bridge.gameStatus();
updateGameStatus(status.running);
} catch {}
}, 10_000);
ddiff --git a/launcher/styles.css b/launcher/styles.css
iindex d157c5f..1f90de1 100644
---- a/launcher/styles.css
++++ b/launcher/styles.css
@@@ -120,6 +120,24 @@ html, body {
.status-sep { color: var(--text-muted); }
.status-right { margin-left: auto; }
++.embedded-close-btn {
++ display: none;
++ padding: 4px 10px;
++ background: rgba(255,255,255,.04);
++ border: 1px solid var(--border-lit);
++ border-radius: 6px;
++ color: var(--text-dim);
++ font-family: var(--font-ui);
++ font-size: 12px;
++ font-weight: 700;
++ cursor: pointer;
++}
++.embedded-close-btn:hover {
++ border-color: var(--accent);
++ color: var(--text);
++ background: rgba(0,200,255,.12);
++}
++
/* ── App layout ──────────────────────────────────────────────────── */
.app-layout {
display: flex;
@@@ -677,3 +695,61 @@ html, body {
.ping-ok { color: var(--accent3); }
.ping-mid { color: #ffaa00; }
.ping-bad { color: var(--danger); }
++
++/* ── Embedded Source browser mode ───────────────────────────────── */
++html.embedded body {
++ background:
++ linear-gradient(180deg, rgba(5,7,12,.97), rgba(7,8,16,.94)),
++ var(--bg-deep);
++}
++
++html.embedded .titlebar {
++ display: none;
++}
++
++html.embedded .statusbar {
++ height: 34px;
++ padding: 0 18px;
++ background: rgba(6, 8, 15, .9);
++ border-bottom-color: rgba(0,200,255,.22);
++}
++
++html.embedded .embedded-close-btn {
++ display: inline-flex;
++ align-items: center;
++ justify-content: center;
++}
++
++html.embedded .app-layout {
++ height: calc(100vh - 34px);
++}
++
++html.embedded .sidebar {
++ width: 210px;
++ min-width: 210px;
++ background: rgba(9, 10, 19, .84);
++ border-right-color: rgba(0,200,255,.18);
++}
++
++html.embedded .content {
++ background:
++ radial-gradient(ellipse at 18% 10%, rgba(0,200,255,.08), transparent 48%),
++ radial-gradient(ellipse at 88% 88%, rgba(6,255,165,.045), transparent 42%),
++ rgba(9, 10, 19, .76);
++}
++
++html.embedded .portal-card,
++html.embedded .shop-item,
++html.embedded .server-table,
++html.embedded .leaderboard-list li {
++ background: rgba(17, 18, 32, .82);
++ backdrop-filter: blur(10px);
++}
++
++html.embedded .portal-card {
++ border-radius: 8px;
++}
++
++html.embedded .hero-sub {
++ color: #8aa0b8;
++}
ddiff --git a/sdk/openvibe/client/hl2mp/openvibe_client.cpp b/sdk/openvibe/client/hl2mp/openvibe_client.cpp
iindex f55b4dc..33dc288 100644
---- a/sdk/openvibe/client/hl2mp/openvibe_client.cpp
++++ b/sdk/openvibe/client/hl2mp/openvibe_client.cpp
@@@ -73,10 +73,10 @@ static bool OV_IsModeAllowed( const char *pszMode )
static const char *OV_LocalConnectForMode( const char *pszMode )
{
if ( !Q_stricmp( pszMode, "hub" ) ) return "127.0.0.1:27015";
-- if ( !Q_stricmp( pszMode, "prophunt" ) ) return "127.0.0.1:27016";
-- if ( !Q_stricmp( pszMode, "deathrun" ) ) return "127.0.0.1:27017";
-- if ( !Q_stricmp( pszMode, "fortwars" ) ) return "127.0.0.1:27018";
-- if ( !Q_stricmp( pszMode, "traitortown" ) ) return "127.0.0.1:27019";
++ if ( !Q_stricmp( pszMode, "prophunt" ) ) return "127.0.0.2:27016";
++ if ( !Q_stricmp( pszMode, "deathrun" ) ) return "127.0.0.3:27017";
++ if ( !Q_stricmp( pszMode, "fortwars" ) ) return "127.0.0.4:27018";
++ if ( !Q_stricmp( pszMode, "traitortown" ) ) return "127.0.0.5:27019";
return "127.0.0.1:27015";
}
ddiff --git a/sdk/openvibe/client/hl2mp/vgui_openvibe_menu.cpp b/sdk/openvibe/client/hl2mp/vgui_openvibe_menu.cpp
iindex 0d51d07..5b5056f 100644
---- a/sdk/openvibe/client/hl2mp/vgui_openvibe_menu.cpp
++++ b/sdk/openvibe/client/hl2mp/vgui_openvibe_menu.cpp
@@@ -1,143 +1,373 @@
--// OpenVibe: Source VGUI portal panel.
++// OpenVibe: Source HTML menu host.
//
--// This is intentionally small and native. The Electron launcher is still the
--// rich desktop menu, while this panel gives the Source client an in-game
--// OpenVibe-first menu that can replace the normal server-browser workflow.
++// This is the only native menu shell the game needs. It embeds Steam's
++// CEF-backed vgui::HTML control full-screen, loads the OpenVibe web client,
++// and exposes a small allowlisted openvibe:// bridge back into Source.
#include "cbase.h"
#include "cdll_client_int.h"
#include "ienginevgui.h"
--#include <vgui/IScheme.h>
#include <vgui/ISurface.h>
--#include <vgui_controls/Button.h>
#include <vgui_controls/Frame.h>
--#include <vgui_controls/Label.h>
++#include <vgui_controls/HTML.h>
++
++#include <ctype.h>
++#include <stdlib.h>
// memdbgon must be the last include file in a .cpp file.
#include "tier0/memdbgon.h"
using namespace vgui;
--class COpenVibeMenuPanel : public Frame
++static ConVar ov_menu_url(
++ "ov_menu_url",
++ "http://127.0.0.1:5173/client/?embedded=1",
++ FCVAR_ARCHIVE,
++ "OpenVibe HTML menu URL." );
++
++static ConVar ov_menu_auto_open(
++ "ov_menu_auto_open",
++ "1",
++ FCVAR_ARCHIVE,
++ "Automatically opens the OpenVibe HTML menu when the client UI initializes." );
++
++static ConVar ov_menu_allow_remote(
++ "ov_menu_allow_remote",
++ "1",
++ FCVAR_ARCHIVE,
++ "Allow loading https://openvibe.games in the embedded menu." );
++
++static bool OV_URLStartsWith( const char *pszUrl, const char *pszPrefix )
{
-- DECLARE_CLASS_SIMPLE( COpenVibeMenuPanel, Frame );
++ return pszUrl && pszPrefix && !Q_strnicmp( pszUrl, pszPrefix, Q_strlen( pszPrefix ) );
++}
++
++static bool OV_IsAllowedWebURL( const char *pszUrl )
++{
++ if ( !pszUrl || !pszUrl[0] )
++ return false;
++
++ if ( OV_URLStartsWith( pszUrl, "about:blank" ) )
++ return true;
++
++ if ( OV_URLStartsWith( pszUrl, "http://127.0.0.1:" ) ||
++ OV_URLStartsWith( pszUrl, "http://localhost:" ) )
++ return true;
++
++ if ( ov_menu_allow_remote.GetBool() &&
++ ( OV_URLStartsWith( pszUrl, "https://openvibe.games/" ) ||
++ OV_URLStartsWith( pszUrl, "https://www.openvibe.games/" ) ) )
++ return true;
++
++ return false;
++}
++
++static bool OV_IsSafeMode( const char *pszMode )
++{
++ return pszMode &&
++ ( !Q_stricmp( pszMode, "hub" ) ||
++ !Q_stricmp( pszMode, "prophunt" ) ||
++ !Q_stricmp( pszMode, "deathrun" ) ||
++ !Q_stricmp( pszMode, "fortwars" ) ||
++ !Q_stricmp( pszMode, "traitortown" ) );
++}
++
++static void OV_URLDecodeInPlace( char *pszValue )
++{
++ if ( !pszValue )
++ return;
++
++ char *pszRead = pszValue;
++ char *pszWrite = pszValue;
++ while ( *pszRead )
++ {
++ if ( pszRead[0] == '%' && isxdigit( pszRead[1] ) && isxdigit( pszRead[2] ) )
++ {
++ char szHex[3] = { pszRead[1], pszRead[2], '\0' };
++ *pszWrite++ = (char)strtol( szHex, NULL, 16 );
++ pszRead += 3;
++ continue;
++ }
++
++ *pszWrite++ = ( pszRead[0] == '+' ) ? ' ' : pszRead[0];
++ ++pszRead;
++ }
++ *pszWrite = '\0';
++}
++
++static bool OV_ReadQueryValue( const char *pszUrl, const char *pszKey, char *pszOut, size_t nOut )
++{
++ if ( !pszUrl || !pszKey || !pszOut || nOut == 0 )
++ return false;
++
++ pszOut[0] = '\0';
++
++ char szNeedle[64];
++ Q_snprintf( szNeedle, sizeof( szNeedle ), "%s=", pszKey );
++
++ const char *pszQuery = Q_strstr( pszUrl, "?" );
++ if ( !pszQuery )
++ return false;
++
++ const char *pszCursor = pszQuery + 1;
++ while ( pszCursor && pszCursor[0] )
++ {
++ if ( !Q_strnicmp( pszCursor, szNeedle, Q_strlen( szNeedle ) ) )
++ {
++ pszCursor += Q_strlen( szNeedle );
++ const char *pszEnd = pszCursor;
++ while ( pszEnd[0] && pszEnd[0] != '&' && pszEnd[0] != '#' )
++ ++pszEnd;
++
++ const int nLen = MIN( (int)( pszEnd - pszCursor ), (int)nOut - 1 );
++ if ( nLen <= 0 )
++ return false;
++
++ Q_strncpy( pszOut, pszCursor, nLen + 1 );
++ pszOut[nLen] = '\0';
++ OV_URLDecodeInPlace( pszOut );
++ return true;
++ }
++
++ pszCursor = Q_strstr( pszCursor, "&" );
++ if ( pszCursor )
++ ++pszCursor;
++ }
++
++ return false;
++}
++
++class IOpenVibeHTMLBridge
++{
++public:
++ virtual bool OnOpenVibeHTMLStartRequest( const char *pszURL, const char *pszTarget, const char *pszPostData, bool bIsRedirect ) = 0;
++};
++
++class COpenVibeHTMLControl : public HTML
++{
++ DECLARE_CLASS_SIMPLE( COpenVibeHTMLControl, HTML );
++public:
++ COpenVibeHTMLControl( IOpenVibeHTMLBridge *pBridge, Panel *pParent, const char *pszName ) :
++ BaseClass( pParent, pszName, true ),
++ m_pBridge( pBridge )
++ {
++ }
++
++ bool OnStartRequest( const char *pszURL, const char *pszTarget, const char *pszPostData, bool bIsRedirect ) OVERRIDE
++ {
++ return m_pBridge ? m_pBridge->OnOpenVibeHTMLStartRequest( pszURL, pszTarget, pszPostData, bIsRedirect ) : false;
++ }
++
++private:
++ IOpenVibeHTMLBridge *m_pBridge;
++};
++
++class COpenVibeHTMLPanel : public Frame, public IOpenVibeHTMLBridge
++{
++ DECLARE_CLASS_SIMPLE( COpenVibeHTMLPanel, Frame );
public:
-- COpenVibeMenuPanel( VPANEL parent ) :
-- BaseClass( NULL, "OpenVibeMenuPanel" )
++ COpenVibeHTMLPanel( VPANEL parent ) :
++ BaseClass( NULL, "OpenVibeHTMLPanel" )
{
SetParent( parent );
-- SetTitle( "OpenVibe: Source", true );
-- SetSize( 760, 500 );
-- SetMinimumSize( 640, 420 );
++ SetTitle( "OpenVibe: Source", false );
++ SetTitleBarVisible( false );
++ SetMinimizeButtonVisible( false );
++ SetMaximizeButtonVisible( false );
++ SetCloseButtonVisible( false );
SetSizeable( false );
-- SetMoveable( true );
++ SetMoveable( false );
SetDeleteSelfOnClose( false );
-- SetBgColor( Color( 18, 20, 28, 245 ) );
++ SetPaintBackgroundEnabled( true );
++ SetBgColor( Color( 7, 8, 14, 255 ) );
++
++ m_pHTML = new COpenVibeHTMLControl( this, this, "OpenVibeHTML" );
++ m_pHTML->SetScrollbarsEnabled( false );
++ m_pHTML->SetContextMenuEnabled( false );
++ m_pHTML->NewWindowsOnly( false );
++ MakeFullScreen();
++ SetVisible( false );
++ }
++
++ void Open( const char *pszURL = NULL )
++ {
++ const char *pszTarget = ( pszURL && pszURL[0] ) ? pszURL : ov_menu_url.GetString();
++ MakeFullScreen();
++ SetVisible( true );
++ MoveToFront();
++ RequestFocus();
++ m_pHTML->RequestFocus();
++ m_pHTML->OpenURL( pszTarget, NULL, true );
++ engine->ClientCmd_Unrestricted( "gameui_hide\n" );
++ }
++
++ void CloseMenu()
++ {
++ SetVisible( false );
++ engine->ClientCmd_Unrestricted( "gameui_hide\n" );
++ }
++
++ void Reload()
++ {
++ if ( IsVisible() )
++ m_pHTML->Refresh();
++ }
++
++ void RunJS( const char *pszScript )
++ {
++ if ( pszScript && pszScript[0] )
++ m_pHTML->RunJavascript( pszScript );
++ }
++
++ void OnSizeChanged( int wide, int tall ) OVERRIDE
++ {
++ BaseClass::OnSizeChanged( wide, tall );
++ if ( m_pHTML )
++ m_pHTML->SetBounds( 0, 0, wide, tall );
++ }
++
++ void PerformLayout() OVERRIDE
++ {
++ BaseClass::PerformLayout();
++ MakeFullScreen();
++ }
++
++ bool OnOpenVibeHTMLStartRequest( const char *pszURL, const char *pszTarget, const char *pszPostData, bool bIsRedirect ) OVERRIDE
++ {
++ if ( OV_URLStartsWith( pszURL, "openvibe://" ) )
++ {
++ HandleBridgeURL( pszURL );
++ return false;
++ }
++
++ if ( !OV_IsAllowedWebURL( pszURL ) )
++ {
++ Warning( "[OV HTML] Blocked navigation to %s\n", pszURL ? pszURL : "<null>" );
++ return false;
++ }
++
++ return true;
++ }
++
++private:
++ void MakeFullScreen()
++ {
int sw = 0;
int sh = 0;
surface()->GetScreenSize( sw, sh );
-- SetPos( ( sw - GetWide() ) / 2, ( sh - GetTall() ) / 2 );
--
-- Label *pTitle = new Label( this, "OpenVibeTitle", "OpenVibe.Games" );
-- pTitle->SetPos( 28, 36 );
-- pTitle->SetSize( 420, 32 );
-- pTitle->SetFgColor( Color( 0, 210, 255, 255 ) );
--
-- Label *pSubtitle = new Label( this, "OpenVibeSubtitle", "Choose a hub or minigame server. Travel is routed through the OpenVibe backend." );
-- pSubtitle->SetPos( 28, 70 );
-- pSubtitle->SetSize( 680, 24 );
-- pSubtitle->SetFgColor( Color( 220, 230, 240, 255 ) );
--
-- AddModeButton( "Hub", "Social lobby, portals, NPCs, shops", "hub", 28, 120 );
-- AddModeButton( "Prop Hunt", "Props disguise and hide from hunters", "prophunt", 390, 120 );
-- AddModeButton( "Deathrun", "Runners survive traps and reach the finish", "deathrun", 28, 220 );
-- AddModeButton( "Fort Wars", "Build phase, prop forts, combat phase", "fortwars", 390, 220 );
-- AddModeButton( "Traitor Town", "Social deduction with traitors and innocents", "traitortown", 28, 320 );
--
-- Button *pWebsite = new Button( this, "OpenWebsite", "Open openvibe.games", this, "website" );
-- pWebsite->SetPos( 390, 320 );
-- pWebsite->SetSize( 320, 44 );
--
-- Button *pClose = new Button( this, "Close", "Close", this, "close" );
-- pClose->SetPos( 590, 430 );
-- pClose->SetSize( 120, 36 );
++ SetBounds( 0, 0, sw, sh );
++ if ( m_pHTML )
++ m_pHTML->SetBounds( 0, 0, sw, sh );
}
-- void OnCommand( const char *pszCommand ) OVERRIDE
++ void HandleBridgeURL( const char *pszURL )
{
-- if ( !Q_strncmp( pszCommand, "join:", 5 ) )
++ if ( OV_URLStartsWith( pszURL, "openvibe://join" ) )
{
-- engine->ClientCmd_Unrestricted( VarArgs( "gameui_hide\nov_join %s\n", pszCommand + 5 ) );
-- SetVisible( false );
++ char szMode[64];
++ if ( OV_ReadQueryValue( pszURL, "mode", szMode, sizeof( szMode ) ) && OV_IsSafeMode( szMode ) )
++ {
++ CloseMenu();
++ engine->ClientCmd_Unrestricted( VarArgs( "ov_join %s\n", szMode ) );
++ return;
++ }
++ }
++
++ if ( OV_URLStartsWith( pszURL, "openvibe://close" ) )
++ {
++ CloseMenu();
return;
}
-- if ( !Q_stricmp( pszCommand, "website" ) )
++ if ( OV_URLStartsWith( pszURL, "openvibe://reload" ) )
{
-- engine->ClientCmd_Unrestricted( "ov_open_url https://openvibe.games\n" );
++ Reload();
return;
}
-- if ( !Q_stricmp( pszCommand, "close" ) )
++ if ( OV_URLStartsWith( pszURL, "openvibe://external" ) )
{
-- SetVisible( false );
++ char szUrl[512];
++ if ( OV_ReadQueryValue( pszURL, "url", szUrl, sizeof( szUrl ) ) && OV_IsAllowedWebURL( szUrl ) )
++ engine->ClientCmd_Unrestricted( VarArgs( "ov_open_url %s\n", szUrl ) );
return;
}
-- BaseClass::OnCommand( pszCommand );
++ Warning( "[OV HTML] Ignored bridge URL: %s\n", pszURL ? pszURL : "<null>" );
}
--private:
-- void AddModeButton( const char *pszName, const char *pszDescription, const char *pszMode, int x, int y )
-- {
-- char szButtonName[64];
-- char szCommand[64];
-- char szDescriptionName[64];
-- Q_snprintf( szButtonName, sizeof( szButtonName ), "%sButton", pszMode );
-- Q_snprintf( szCommand, sizeof( szCommand ), "join:%s", pszMode );
-- Q_snprintf( szDescriptionName, sizeof( szDescriptionName ), "%sDescription", pszMode );
--
-- Button *pButton = new Button( this, szButtonName, pszName, this, szCommand );
-- pButton->SetPos( x, y );
-- pButton->SetSize( 320, 48 );
--
-- Label *pDescription = new Label( this, szDescriptionName, pszDescription );
-- pDescription->SetPos( x + 2, y + 52 );
-- pDescription->SetSize( 320, 26 );
-- pDescription->SetFgColor( Color( 185, 195, 205, 255 ) );
-- }
++ HTML *m_pHTML;
};
--static COpenVibeMenuPanel *s_pOpenVibeMenu = NULL;
++static COpenVibeHTMLPanel *s_pOpenVibeMenu = NULL;
--static void OV_OpenMenu()
++static COpenVibeHTMLPanel *OV_GetHTMLMenu()
{
if ( !s_pOpenVibeMenu )
-- {
-- s_pOpenVibeMenu = new COpenVibeMenuPanel( enginevgui->GetPanel( PANEL_GAMEUIDLL ) );
-- }
++ s_pOpenVibeMenu = new COpenVibeHTMLPanel( enginevgui->GetPanel( PANEL_GAMEUIDLL ) );
-- s_pOpenVibeMenu->SetVisible( true );
-- s_pOpenVibeMenu->MoveToFront();
-- s_pOpenVibeMenu->RequestFocus();
++ return s_pOpenVibeMenu;
}
static void OV_OpenMenu_f( const CCommand &args )
{
-- OV_OpenMenu();
++ const char *pszURL = args.ArgC() >= 2 ? args[1] : NULL;
++ OV_GetHTMLMenu()->Open( pszURL );
++}
++
++static void OV_CloseMenu_f( const CCommand &args )
++{
++ if ( s_pOpenVibeMenu )
++ s_pOpenVibeMenu->CloseMenu();
++}
++
++static void OV_ReloadMenu_f( const CCommand &args )
++{
++ if ( s_pOpenVibeMenu )
++ s_pOpenVibeMenu->Reload();
++}
++
++static void OV_MenuJS_f( const CCommand &args )
++{
++ if ( s_pOpenVibeMenu && args.ArgC() >= 2 )
++ s_pOpenVibeMenu->RunJS( args.ArgS() );
++}
++
++void OpenVibe_OnClientModeInit()
++{
++ if ( ov_menu_auto_open.GetBool() )
++ OV_GetHTMLMenu()->Open();
}
static ConCommand openvibe_menu_cmd(
"openvibe_menu",
OV_OpenMenu_f,
-- "Open the OpenVibe hub/minigame selection menu.",
++ "Open the OpenVibe embedded HTML menu.",
FCVAR_CLIENTDLL );
static ConCommand ov_menu_cmd(
"ov_menu",
OV_OpenMenu_f,
-- "Open the OpenVibe hub/minigame selection menu.",
++ "Open the OpenVibe embedded HTML menu.",
++ FCVAR_CLIENTDLL );
++
++static ConCommand ov_menu_close_cmd(
++ "ov_menu_close",
++ OV_CloseMenu_f,
++ "Close the OpenVibe embedded HTML menu.",
++ FCVAR_CLIENTDLL );
++
++static ConCommand ov_menu_reload_cmd(
++ "ov_menu_reload",
++ OV_ReloadMenu_f,
++ "Reload the OpenVibe embedded HTML menu.",
++ FCVAR_CLIENTDLL );
++
++static ConCommand ov_menu_js_cmd(
++ "ov_menu_js",
++ OV_MenuJS_f,
++ "Run JavaScript in the OpenVibe embedded HTML menu.",
FCVAR_CLIENTDLL );
ddiff --git a/tools/apply-openvibe-sdk.sh b/tools/apply-openvibe-sdk.sh
iindex d148ce0..db3848f 100755
---- a/tools/apply-openvibe-sdk.sh
++++ b/tools/apply-openvibe-sdk.sh
@@@ -27,6 +27,7 @@ copy_file "$ROOT/sdk/openvibe/server/hl2mp/openvibe_server.cpp" \
CLIENT_VPC="$SDK/src/game/client/client_hl2mp.vpc"
SERVER_VPC="$SDK/src/game/server/server_hl2mp.vpc"
HL2MP_CLIENT="$SDK/src/game/server/hl2mp/hl2mp_client.cpp"
++HL2MP_CLIENTMODE="$SDK/src/game/client/hl2mp/clientmode_hl2mpnormal.cpp"
perl -0pi -e 's/^.*hl2mp\\openvibe_client\.cpp.*\n//mg; s/^.*hl2mp\\vgui_openvibe_menu\.cpp.*\n//mg; s/(\$File\s+"hl2mp\\clientmode_hl2mpnormal\.h"\n)/$1\t\t\t\$File\t"hl2mp\\openvibe_client.cpp"\n\t\t\t\$File\t"hl2mp\\vgui_openvibe_menu.cpp"\n/s' "$CLIENT_VPC"
echo "[openvibe-sdk] patched client_hl2mp.vpc"
@@@ -42,4 +43,12 @@ fi
perl -0pi -e 's/[^\S\r\n]*\x0boid OpenVibe_OnClientActive/void OpenVibe_OnClientActive/g; s/\\tOpenVibe_OnClientActive/\tOpenVibe_OnClientActive/g; s/^[ \t]*void OpenVibe_OnClientActive/void OpenVibe_OnClientActive/m' "$HL2MP_CLIENT"
++if ! grep -q 'OpenVibe_OnClientModeInit' "$HL2MP_CLIENTMODE"; then
++ sed -i '/#include "ienginevgui.h"/a void OpenVibe_OnClientModeInit();' "$HL2MP_CLIENTMODE"
++ sed -i '/BaseClass::Init();/a \ OpenVibe_OnClientModeInit();' "$HL2MP_CLIENTMODE"
++ echo "[openvibe-sdk] patched clientmode_hl2mpnormal.cpp OpenVibe menu hook"
++fi
++
++perl -0pi -e 's/^[ \t]*tOpenVibe_OnClientModeInit\(\);/ OpenVibe_OnClientModeInit();/m' "$HL2MP_CLIENTMODE"
++
echo "[openvibe-sdk] Source SDK OpenVibe patch applied"
ddiff --git a/tools/dev-down.sh b/tools/dev-down.sh
iindex 0ebe765..c940e4b 100755
---- a/tools/dev-down.sh
++++ b/tools/dev-down.sh
@@@ -6,6 +6,7 @@ MOD="$ROOT/game/openvibe.games"
sessions=(
ov-api
++ ov-client-ui
ov-hub
ov-prophunt
ov-deathrun
@@@ -35,6 +36,7 @@ kill_matching() {
kill_matching TERM "srcds_linux64 -game $MOD"
kill_matching TERM "$ROOT/tools/ov-sidecar.mjs"
kill_matching TERM "$ROOT/tools/dev-api.sh"
++kill_matching TERM "$ROOT/tools/serve-client-ui.mjs"
kill_matching TERM "$ROOT/backend/node_modules/.bin/tsx watch src/index.ts"
sleep 1
@@@ -42,6 +44,7 @@ sleep 1
kill_matching KILL "srcds_linux64 -game $MOD"
kill_matching KILL "$ROOT/tools/ov-sidecar.mjs"
kill_matching KILL "$ROOT/tools/dev-api.sh"
++kill_matching KILL "$ROOT/tools/serve-client-ui.mjs"
kill_matching KILL "$ROOT/backend/node_modules/.bin/tsx watch src/index.ts"
"$ROOT/tools/dev-db-down.sh"
ddiff --git a/tools/dev-up.sh b/tools/dev-up.sh
iindex aa8d781..9592186 100755
---- a/tools/dev-up.sh
++++ b/tools/dev-up.sh
@@@ -13,8 +13,12 @@ fi
if tmux has-session -t ov-api 2>/dev/null; then
tmux kill-session -t ov-api
fi
++if tmux has-session -t ov-client-ui 2>/dev/null; then
++ tmux kill-session -t ov-client-ui
++fi
tmux new-session -d -s ov-api "$ROOT/tools/dev-api.sh"
++tmux new-session -d -s ov-client-ui "$ROOT/tools/run-client-ui.sh"
echo "[openvibe] waiting for api"
for _ in {1..60}; do
ddiff --git a/tools/run-client-ui.sh b/tools/run-client-ui.sh
nnew file mode 100755
iindex 0000000..f215e55
---- /dev/null
++++ b/tools/run-client-ui.sh
@@@ -0,0 +1,9 @@
++#!/usr/bin/env bash
++set -euo pipefail
++
++ROOT="${OPENVIBE_ROOT:-$HOME/src/openvibe-source}"
++export OPENVIBE_ROOT="$ROOT"
++export OPENVIBE_CLIENT_UI_HOST="${OPENVIBE_CLIENT_UI_HOST:-127.0.0.1}"
++export OPENVIBE_CLIENT_UI_PORT="${OPENVIBE_CLIENT_UI_PORT:-5173}"
++
++exec node "$ROOT/tools/serve-client-ui.mjs"
ddiff --git a/tools/serve-client-ui.mjs b/tools/serve-client-ui.mjs
nnew file mode 100755
iindex 0000000..d60fb55
---- /dev/null
++++ b/tools/serve-client-ui.mjs
@@@ -0,0 +1,108 @@
++#!/usr/bin/env node
++import { createReadStream, existsSync, statSync } from 'node:fs';
++import { createServer } from 'node:http';
++import { extname, join, normalize, resolve, sep } from 'node:path';
++
++const root = resolve(process.env.OPENVIBE_ROOT || join(process.env.HOME || '.', 'src/openvibe-source'));
++const launcherRoot = join(root, 'launcher');
++const host = process.env.OPENVIBE_CLIENT_UI_HOST || '127.0.0.1';
++const port = Number(process.env.OPENVIBE_CLIENT_UI_PORT || 5173);
++
++const mime = {
++ '.html': 'text/html; charset=utf-8',
++ '.css': 'text/css; charset=utf-8',
++ '.js': 'text/javascript; charset=utf-8',
++ '.json': 'application/json; charset=utf-8',
++ '.png': 'image/png',
++ '.jpg': 'image/jpeg',
++ '.jpeg': 'image/jpeg',
++ '.svg': 'image/svg+xml',
++ '.ico': 'image/x-icon',
++ '.woff': 'font/woff',
++ '.woff2': 'font/woff2',
++ '.ttf': 'font/ttf',
++};
++
++function send(res, status, body, type = 'text/plain; charset=utf-8') {
++ res.writeHead(status, {
++ 'Content-Type': type,
++ 'Cache-Control': 'no-store',
++ 'X-Content-Type-Options': 'nosniff',
++ });
++ res.end(body);
++}
++
++function sendHeaders(res, status, type) {
++ res.writeHead(status, {
++ 'Content-Type': type,
++ 'Cache-Control': 'no-store',
++ 'X-Content-Type-Options': 'nosniff',
++ });
++ res.end();
++}
++
++function fileForUrl(url) {
++ const parsed = new URL(url, `http://${host}:${port}`);
++ let pathname = decodeURIComponent(parsed.pathname);
++
++ if (pathname === '/' || pathname === '/client' || pathname === '/client/') {
++ return join(launcherRoot, 'index.html');
++ }
++
++ if (!pathname.startsWith('/client/')) {
++ if (pathname === '/styles.css' || pathname === '/renderer.js' || pathname === '/preload.js') {
++ return join(launcherRoot, pathname.slice(1));
++ }
++ if (pathname.startsWith('/assets/')) {
++ const asset = normalize(join(launcherRoot, pathname.slice(1)));
++ return asset.startsWith(launcherRoot + sep) ? asset : null;
++ }
++ return null;
++ }
++
++ pathname = pathname.slice('/client/'.length);
++ const file = normalize(join(launcherRoot, pathname));
++ if (!file.startsWith(launcherRoot + sep)) {
++ return null;
++ }
++
++ return file;
++}
++
++const server = createServer((req, res) => {
++ if (!req.url || (req.method !== 'GET' && req.method !== 'HEAD')) {
++ send(res, 405, 'Method not allowed');
++ return;
++ }
++
++ if (req.url === '/health') {
++ if (req.method === 'HEAD') {
++ sendHeaders(res, 200, 'application/json; charset=utf-8');
++ return;
++ }
++ send(res, 200, JSON.stringify({ ok: true, service: 'openvibe-client-ui' }), 'application/json; charset=utf-8');
++ return;
++ }
++
++ const file = fileForUrl(req.url);
++ if (!file || !existsSync(file) || !statSync(file).isFile()) {
++ send(res, 404, 'Not found');
++ return;
++ }
++
++ if (req.method === 'HEAD') {
++ sendHeaders(res, 200, mime[extname(file).toLowerCase()] || 'application/octet-stream');
++ return;
++ }
++
++ res.writeHead(200, {
++ 'Content-Type': mime[extname(file).toLowerCase()] || 'application/octet-stream',
++ 'Cache-Control': 'no-store',
++ 'X-Content-Type-Options': 'nosniff',
++ });
++ createReadStream(file).pipe(res);
++});
++
++server.listen(port, host, () => {
++ console.log(`[openvibe-ui] serving ${launcherRoot} at http://${host}:${port}/client`);
++});