Add PrismaUI HTML server browser & integration

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

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

Primary intent: provide an in-game HTML server browser (toggleable with F9 or Scaleform calls), wire up controller input, and add build/deploy workflow for PrismaUI/Ultralight so developers can build and deploy the UI and plugin together. Mock server data is used for now; network join/connect plumbing is provided for local/dev servers.
This commit is contained in:
2026-06-08 14:02:49 +12:00
parent 2ced73f7b9
commit fe620020be
38 changed files with 3565 additions and 15 deletions
+516
View File
@@ -0,0 +1,516 @@
(function () {
"use strict";
var root = document.getElementById("coRoot");
var panel = document.getElementById("coPanel");
var joinOverlay = document.getElementById("coJoinOverlay");
var joinMessage = document.getElementById("coJoinMessage");
var FOCUS_ZONES = ["tabs", "filters", "server-list", "details"];
var REPEAT_INITIAL_MS = 400;
var REPEAT_RATE_MS = 80;
var repeatTimer = null;
var repeatAction = null;
var state = {
activeTab: "server-browser",
focusZone: "server-list",
selectedTabIndex: 1,
selectedServerIndex: 0,
selectedFilterIndex: 0,
filters: {
search: "",
region: "Any Region",
mode: "Any Mode",
worldspace: "Any Worldspace",
ping: "Any",
showFull: false,
hasPassword: false,
modded: false
},
allServers: [],
servers: [],
favorites: [],
recent: [],
connectionStatus: "Online",
isRefreshing: false,
directConnect: { address: "127.0.0.1", port: "7777" },
joinOverlay: null
};
function sendAction(action, payload) {
var msg = JSON.stringify(Object.assign({ action: action }, payload || {}));
if (typeof window.coAction === "function") {
window.coAction(msg);
return;
}
console.warn("[CO] coAction bridge unavailable:", action);
handleActionLocal(action, payload || {});
}
function handleActionLocal(action, payload) {
switch (action) {
case "refreshServerList":
refreshLocalMock();
break;
case "setFilter":
state.filters[payload.filterName] = payload.value;
applyLocalFilters();
render();
break;
case "resetFilters":
resetLocalFilters();
break;
case "toggleFavorite":
toggleFavoriteLocal(payload.serverId);
break;
case "joinServer":
joinServerLocal(payload.serverId);
break;
case "openDirectConnect":
joinDirectLocal(payload.address, payload.port);
break;
case "switchTab":
switchTab(payload.tabName);
break;
case "closeBrowser":
if (typeof window.closeBrowser === "function") window.closeBrowser();
break;
default:
break;
}
}
function refreshLocalMock() {
state.isRefreshing = true;
render();
setTimeout(function () {
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
applyLocalFilters();
state.isRefreshing = false;
render();
}, 600);
}
function resetLocalFilters() {
state.filters = {
search: "",
region: "Any Region",
mode: "Any Mode",
worldspace: "Any Worldspace",
ping: "Any",
showFull: false,
hasPassword: false,
modded: false
};
sendAction("resetFilters", {});
applyLocalFilters();
render();
}
function applyLocalFilters() {
var list = state.allServers.slice();
var f = state.filters;
if (f.search) {
var q = f.search.toLowerCase();
list = list.filter(function (s) {
return s.name.toLowerCase().indexOf(q) >= 0;
});
}
if (f.region !== "Any Region") {
list = list.filter(function (s) { return s.region === f.region; });
}
if (f.mode !== "Any Mode") {
list = list.filter(function (s) { return s.mode === f.mode; });
}
if (f.worldspace !== "Any Worldspace") {
list = list.filter(function (s) { return s.worldspace === f.worldspace; });
}
if (f.ping === "< 50 ms") list = list.filter(function (s) { return s.ping < 50; });
else if (f.ping === "< 100 ms") list = list.filter(function (s) { return s.ping < 100; });
else if (f.ping === "< 150 ms") list = list.filter(function (s) { return s.ping < 150; });
if (!f.showFull) list = list.filter(function (s) { return s.status !== "Full"; });
if (f.hasPassword) list = list.filter(function (s) { return s.passworded; });
if (f.modded) list = list.filter(function (s) { return s.modded; });
state.servers = list;
state.favorites = state.allServers.filter(function (s) { return s.favorite; });
if (state.selectedServerIndex >= state.servers.length) {
state.selectedServerIndex = Math.max(0, state.servers.length - 1);
}
updateServerCount();
}
function updateServerCount() {
var el = document.getElementById("coServerCount");
if (el) {
el.textContent = " \u2014 " + state.servers.length + " servers found";
}
}
function toggleFavoriteLocal(serverId) {
var server = findServerById(serverId);
if (!server) return;
server.favorite = !server.favorite;
sendAction("toggleFavorite", { serverId: serverId });
applyLocalFilters();
render();
}
function findServerById(id) {
for (var i = 0; i < state.allServers.length; i++) {
if (state.allServers[i].id === id) return state.allServers[i];
}
return null;
}
function getSelectedServer() {
return getActiveList()[state.selectedServerIndex] || null;
}
function getActiveList() {
if (state.activeTab === "favorites") return state.favorites;
if (state.activeTab === "recent") return state.recent;
return state.servers;
}
function joinServerLocal(serverId) {
var server = findServerById(serverId);
if (!server) return;
showJoinOverlay("Connecting to " + server.name + "...");
sendAction("joinServer", { serverId: serverId });
}
function joinDirectLocal(address, port) {
showJoinOverlay("Connecting to " + address + ":" + port + "...");
sendAction("openDirectConnect", { address: address, port: port });
}
function showJoinOverlay(message, isError) {
state.joinOverlay = { message: message, error: !!isError };
joinOverlay.classList.remove("hidden");
if (isError) joinOverlay.classList.add("co-error");
else joinOverlay.classList.remove("co-error");
joinMessage.textContent = message;
var spinner = joinOverlay.querySelector(".co-spinner");
if (spinner) spinner.style.display = isError ? "none" : "block";
render();
}
function hideJoinOverlay() {
state.joinOverlay = null;
joinOverlay.classList.add("hidden");
render();
}
function switchTab(tabName) {
state.activeTab = tabName;
state.selectedTabIndex = CO_Tabs.TAB_ORDER.indexOf(tabName);
if (state.selectedTabIndex < 0) state.selectedTabIndex = 0;
if (tabName === "server-browser") state.focusZone = "server-list";
else if (tabName === "direct-connect") state.focusZone = "tabs";
sendAction("switchTab", { tabName: tabName });
render();
}
function render() {
if (!root) return;
panel.classList.toggle("co-loading", state.isRefreshing);
CO_Tabs.render(root, state);
CO_ControllerPrompts.render(root, state);
document.getElementById("coConnectionStatus").textContent = state.connectionStatus;
document.querySelectorAll(".co-tab-pane").forEach(function (pane) {
pane.classList.toggle("active", pane.getAttribute("data-pane") === state.activeTab);
});
if (state.activeTab === "direct-connect") {
CO_DirectConnect.render(root, state);
} else {
var listServers = state.servers;
if (state.activeTab === "favorites") listServers = state.favorites;
if (state.activeTab === "recent") listServers = state.recent;
var listState = Object.assign({}, state, { servers: listServers });
if (state.activeTab === "favorites" || state.activeTab === "recent") {
listState.focusZone = state.focusZone === "details" || state.focusZone === "filters" ? "server-list" : state.focusZone;
}
if (state.activeTab === "server-browser") {
CO_Filters.render(root, listState);
CO_ServerDetails.render(root, listState);
}
var activePane = root.querySelector('.co-tab-pane[data-pane="' + state.activeTab + '"]');
if (activePane) {
var listEl = activePane.querySelector(".co-server-list");
if (listEl) {
var tempRoot = { querySelector: function (sel) {
if (sel === ".co-server-list-wrap") return activePane.querySelector(".co-server-list-wrap");
if (sel === ".co-server-list") return listEl;
return root.querySelector(sel);
}};
CO_ServerList.render(tempRoot, listState);
}
}
}
updateServerCount();
}
function bindUi() {
CO_Tabs.bind(root, function (tabName) {
switchTab(tabName);
});
CO_Filters.bind(root, function (name, value) {
state.filters[name] = value;
sendAction("setFilter", { filterName: name, value: value });
applyLocalFilters();
render();
}, resetLocalFilters);
root.addEventListener("click", function (e) {
var row = e.target.closest(".co-tab-pane.active .co-server-row");
if (!row) return;
state.selectedServerIndex = parseInt(row.getAttribute("data-index"), 10);
render();
});
root.addEventListener("dblclick", function (e) {
var row = e.target.closest(".co-tab-pane.active .co-server-row");
if (!row) return;
state.selectedServerIndex = parseInt(row.getAttribute("data-index"), 10);
joinSelected();
});
CO_DirectConnect.bind(root, function (address, port) {
state.directConnect = { address: address, port: port };
joinDirectLocal(address, port);
});
document.addEventListener("keydown", onKeyDown);
}
function onKeyDown(e) {
if (state.joinOverlay && state.joinOverlay.error && e.key === "Escape") {
hideJoinOverlay();
return;
}
if (state.activeTab === "direct-connect") return;
handleControllerButton(mapKeyToButton(e.key), "down");
}
function mapKeyToButton(key) {
var map = {
ArrowUp: "dpad_up", ArrowDown: "dpad_down", ArrowLeft: "dpad_left", ArrowRight: "dpad_right",
Enter: "a", Escape: "b", " ": "a", r: "x", f: "y", q: "lb", e: "rb"
};
return map[key] || null;
}
function handleControllerButton(button, phase) {
if (!button || phase !== "down") return;
if (state.joinOverlay) {
if (button === "b") hideJoinOverlay();
return;
}
if (button === "lb" || button === "rb") {
var idx = state.selectedTabIndex;
idx = button === "lb" ? idx - 1 : idx + 1;
if (idx < 0) idx = CO_Tabs.TAB_ORDER.length - 1;
if (idx >= CO_Tabs.TAB_ORDER.length) idx = 0;
switchTab(CO_Tabs.TAB_ORDER[idx]);
return;
}
if (button === "b") {
sendAction("closeBrowser", {});
return;
}
if (state.activeTab === "direct-connect") {
if (button === "a") {
var vals = CO_DirectConnect.getValues(root);
joinDirectLocal(vals.address, vals.port);
}
return;
}
if (button === "a") {
joinSelected();
return;
}
if (button === "x") {
sendAction("refreshServerList", {});
return;
}
if (button === "y") {
var sel = getSelectedServer();
if (sel) toggleFavoriteLocal(sel.id);
return;
}
navigateFocus(button);
}
function joinSelected() {
var sel = getSelectedServer();
if (!sel) return;
joinServerLocal(sel.id);
}
function navigateFocus(button) {
var zoneIdx = FOCUS_ZONES.indexOf(state.focusZone);
var list = getActiveList();
if (button === "dpad_left") {
if (zoneIdx > 0) {
state.focusZone = FOCUS_ZONES[zoneIdx - 1];
render();
}
return;
}
if (button === "dpad_right") {
if (zoneIdx < FOCUS_ZONES.length - 1 && state.activeTab === "server-browser") {
state.focusZone = FOCUS_ZONES[zoneIdx + 1];
render();
}
return;
}
if (state.focusZone === "tabs") {
if (button === "dpad_up" || button === "dpad_down") {
var dir = button === "dpad_up" ? -1 : 1;
state.selectedTabIndex = (state.selectedTabIndex + dir + CO_Tabs.TAB_ORDER.length) % CO_Tabs.TAB_ORDER.length;
switchTab(CO_Tabs.TAB_ORDER[state.selectedTabIndex]);
}
return;
}
if (state.focusZone === "filters") {
var maxFilter = CO_Filters.FILTER_ROWS.length;
if (button === "dpad_up") state.selectedFilterIndex = Math.max(0, state.selectedFilterIndex - 1);
if (button === "dpad_down") state.selectedFilterIndex = Math.min(maxFilter, state.selectedFilterIndex + 1);
if (button === "a" && state.selectedFilterIndex === maxFilter) resetLocalFilters();
render();
return;
}
if (state.focusZone === "server-list") {
if (button === "dpad_up") state.selectedServerIndex = Math.max(0, state.selectedServerIndex - 1);
if (button === "dpad_down") state.selectedServerIndex = Math.min(list.length - 1, state.selectedServerIndex + 1);
render();
CO_ServerList.scrollToSelected(root, state.selectedServerIndex);
return;
}
}
function startRepeat(action) {
stopRepeat();
repeatAction = action;
repeatTimer = setTimeout(function tick() {
if (repeatAction) repeatAction();
repeatTimer = setTimeout(tick, REPEAT_RATE_MS);
}, REPEAT_INITIAL_MS);
}
function stopRepeat() {
if (repeatTimer) clearTimeout(repeatTimer);
repeatTimer = null;
repeatAction = null;
}
window.dispatchCoEvent = function (event) {
if (!event || !event.type) return;
switch (event.type) {
case "serverListUpdated":
state.allServers = event.servers || [];
applyLocalFilters();
break;
case "serverListRefreshStarted":
state.isRefreshing = true;
break;
case "serverListRefreshFinished":
state.isRefreshing = false;
if (!event.success && event.error) {
console.warn("[CO] Refresh failed:", event.error);
}
break;
case "joinStarted":
showJoinOverlay("Connecting...");
break;
case "joinFailed":
showJoinOverlay(event.reason || "Connection failed", true);
break;
case "joinSucceeded":
hideJoinOverlay();
addRecent(event.serverId);
break;
case "connectionStatusChanged":
state.connectionStatus = event.status || "Online";
break;
case "menuOpened":
break;
case "menuClosed":
hideJoinOverlay();
break;
case "controllerInput":
if (event.phase === "down") {
handleControllerButton(event.button, "down");
if (event.button === "dpad_up" || event.button === "dpad_down") {
startRepeat(function () { handleControllerButton(event.button, "down"); });
}
} else if (event.phase === "up") {
stopRepeat();
}
break;
case "uiSound":
// TODO: Wire to Fallout 4 menu sound via C++ when audio hooks are available.
break;
default:
break;
}
render();
};
function addRecent(serverId) {
var server = findServerById(serverId);
if (!server) return;
state.recent = state.recent.filter(function (s) { return s.id !== serverId; });
state.recent.unshift(server);
if (state.recent.length > 8) state.recent.pop();
}
window.setConnectionStatus = function (connected, logPrefix) {
state.connectionStatus = connected ? "Connected" : "Online";
render();
};
window.onBrowserShown = function () {
dispatchCoEvent({ type: "menuOpened" });
render();
};
window.coAction = window.coAction || function () {
console.warn("[CO] coAction bridge not ready");
};
window.CO_App = {
joinSelected: joinSelected,
getState: function () { return state; }
};
state.allServers = (window.CO_MOCK_SERVERS || []).slice();
applyLocalFilters();
bindUi();
render();
console.log("[CO] Server browser loaded");
})();
@@ -0,0 +1,53 @@
(function () {
"use strict";
window.CO_ControllerPrompts = {
render: function (root, state) {
var el = root.querySelector(".co-prompts");
if (!el) return;
var prompts = CO_ControllerPrompts.getPrompts(state);
el.innerHTML = prompts.map(function (p) {
return (
'<div class="co-prompt">' +
'<span class="co-prompt-key">' + p.key + "</span>" +
"<span>" + p.label + "</span>" +
"</div>"
);
}).join("");
},
getPrompts: function (state) {
if (state.joinOverlay) {
if (state.joinOverlay.error) {
return [{ key: "B", label: "Dismiss" }];
}
return [];
}
if (state.activeTab === "direct-connect") {
return [
{ key: "A", label: "Connect" },
{ key: "B", label: "Back" }
];
}
var base = [
{ key: "A", label: "Join" },
{ key: "X", label: "Refresh" },
{ key: "Y", label: "Favorite" },
{ key: "B", label: "Back" }
];
if (state.focusZone === "tabs") {
return [
{ key: "LB", label: "Prev Tab" },
{ key: "RB", label: "Next Tab" },
{ key: "B", label: "Back" }
];
}
return base;
}
};
})();
@@ -0,0 +1,55 @@
(function () {
"use strict";
window.CO_DirectConnect = {
render: function (root, state) {
var pane = root.querySelector('[data-pane="direct-connect"]');
if (!pane) return;
var address = state.directConnect.address || "127.0.0.1";
var port = state.directConnect.port || "7777";
pane.innerHTML =
'<div class="co-direct-connect">' +
"<h2>Direct Connect</h2>" +
'<div class="co-direct-row">' +
'<label for="co-dc-address">Server Address</label>' +
'<input class="co-filter-input" id="co-dc-address" type="text" value="' + escapeAttr(address) + '">' +
"</div>" +
'<div class="co-direct-row">' +
'<label for="co-dc-port">Port</label>' +
'<input class="co-filter-input" id="co-dc-port" type="text" value="' + escapeAttr(port) + '">' +
"</div>" +
'<button type="button" class="co-btn co-dc-connect">Connect</button>' +
'<p class="co-detail-desc" style="margin-top:16px">Enter a server address to connect directly without browsing the list.</p>' +
"</div>";
},
bind: function (root, onConnect) {
var pane = root.querySelector('[data-pane="direct-connect"]');
if (!pane) return;
pane.addEventListener("click", function (e) {
if (!e.target.closest(".co-dc-connect")) return;
var address = pane.querySelector("#co-dc-address");
var port = pane.querySelector("#co-dc-port");
onConnect(address ? address.value : "", port ? port.value : "");
});
},
getValues: function (root) {
var pane = root.querySelector('[data-pane="direct-connect"]');
if (!pane) return { address: "127.0.0.1", port: "7777" };
var address = pane.querySelector("#co-dc-address");
var port = pane.querySelector("#co-dc-port");
return {
address: address ? address.value : "127.0.0.1",
port: port ? port.value : "7777"
};
}
};
function escapeAttr(value) {
return String(value).replace(/"/g, "&quot;");
}
})();
@@ -0,0 +1,98 @@
(function () {
"use strict";
var FILTER_ROWS = [
{ key: "search", type: "text", label: "Search", placeholder: "Search servers..." },
{ key: "region", type: "select", label: "Region", options: ["Any Region", "Oceania", "North America", "Europe", "Local"] },
{ key: "mode", type: "select", label: "Mode", options: ["Any Mode", "Co-op", "Roleplay", "Survival"] },
{ key: "worldspace", type: "select", label: "Worldspace", options: ["Any Worldspace", "Commonwealth", "Far Harbor", "Nuka-World"] },
{ key: "ping", type: "select", label: "Ping", options: ["Any", "< 50 ms", "< 100 ms", "< 150 ms"] },
{ key: "showFull", type: "checkbox", label: "Show Full" },
{ key: "hasPassword", type: "checkbox", label: "Has Password" },
{ key: "modded", type: "checkbox", label: "Modded" }
];
window.CO_Filters = {
FILTER_ROWS: FILTER_ROWS,
render: function (root, state) {
var el = root.querySelector(".co-filters");
if (!el) return;
var focusedClass = state.focusZone === "filters" ? " focused" : "";
var html = FILTER_ROWS.map(function (row, index) {
var rowFocused = state.focusZone === "filters" && state.selectedFilterIndex === index ? " focused" : "";
if (row.type === "text") {
return (
'<div class="co-filter-row' + rowFocused + '" data-filter-index="' + index + '">' +
'<div class="co-filter-label">' + row.label + '</div>' +
'<input class="co-filter-input" type="text" data-filter="' + row.key + '" placeholder="' + row.placeholder + '" value="' + escapeAttr(state.filters[row.key] || "") + '">' +
"</div>"
);
}
if (row.type === "select") {
var opts = row.options.map(function (opt) {
var sel = (state.filters[row.key] || row.options[0]) === opt ? " selected" : "";
return '<option value="' + escapeAttr(opt) + '"' + sel + ">" + opt + "</option>";
}).join("");
return (
'<div class="co-filter-row' + rowFocused + '" data-filter-index="' + index + '">' +
'<div class="co-filter-label">' + row.label + '</div>' +
'<select class="co-filter-select" data-filter="' + row.key + '">' + opts + "</select>" +
"</div>"
);
}
var checked = state.filters[row.key] ? " checked" : "";
return (
'<label class="co-checkbox-row co-filter-row' + rowFocused + '" data-filter-index="' + index + '">' +
'<span class="co-checkbox' + checked + '" data-filter="' + row.key + '"></span>' +
row.label +
"</label>"
);
}).join("");
html += '<button type="button" class="co-btn co-reset-btn">Reset Filters</button>';
el.className = "co-filters" + focusedClass;
el.innerHTML = html;
},
bind: function (root, onFilterChange, onReset) {
var el = root.querySelector(".co-filters");
if (!el) return;
el.addEventListener("change", function (e) {
var target = e.target;
if (!target.dataset.filter) return;
onFilterChange(target.dataset.filter, target.type === "checkbox" ? target.checked : target.value);
});
el.addEventListener("input", function (e) {
var target = e.target;
if (target.classList.contains("co-filter-input")) {
onFilterChange(target.dataset.filter, target.value);
}
});
el.addEventListener("click", function (e) {
if (e.target.closest(".co-reset-btn")) {
onReset();
return;
}
var row = e.target.closest(".co-checkbox-row");
if (row) {
var box = row.querySelector(".co-checkbox");
if (box) {
onFilterChange(box.dataset.filter, !box.classList.contains("checked"));
}
}
});
}
};
function escapeAttr(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
}
})();
@@ -0,0 +1,100 @@
window.CO_MOCK_SERVERS = [
{
id: "nz-1",
name: "Commonwealth NZ #1",
region: "Oceania",
players: 6,
maxPlayers: 12,
ping: 32,
worldspace: "Commonwealth",
mode: "Co-op",
status: "Open",
modded: true,
passworded: false,
favorite: true,
description: "Relaxed co-op server for explorers and builders. No PvP. Help each other rebuild the Commonwealth.",
rules: ["No griefing or stealing", "Respect build sites", "Mic optional"]
},
{
id: "vault111",
name: "Vault 111 Co-op",
region: "North America",
players: 3,
maxPlayers: 8,
ping: 48,
worldspace: "Commonwealth",
mode: "Co-op",
status: "Open",
modded: false,
passworded: false,
favorite: false,
description: "Story-friendly co-op. Stay in sync with main quest progression.",
rules: ["No skipping major quest beats without group consent"]
},
{
id: "dc-rp",
name: "DC Wasteland RP",
region: "North America",
players: 10,
maxPlayers: 10,
ping: 72,
worldspace: "Commonwealth",
mode: "Roleplay",
status: "Full",
modded: true,
passworded: true,
favorite: false,
description: "Light roleplay server set around Diamond City trade routes.",
rules: ["Stay in character in voice", "No combat logging"]
},
{
id: "far-harbor",
name: "Far Harbor Expedition",
region: "Europe",
players: 4,
maxPlayers: 6,
ping: 118,
worldspace: "Far Harbor",
mode: "Co-op",
status: "Open",
modded: true,
passworded: false,
favorite: true,
description: "DLC-focused co-op run through Far Harbor content.",
rules: ["DLC content required", "Spoiler tags in chat for newcomers"]
},
{
id: "local-relay",
name: "Local Relay (Dev)",
region: "Local",
players: 0,
maxPlayers: 16,
ping: 1,
worldspace: "Commonwealth",
mode: "Co-op",
status: "Open",
modded: false,
passworded: false,
favorite: false,
host: "127.0.0.1",
port: 7777,
description: "Python relay used by the Commonwealth Online prototype. Start server/server.py before joining.",
rules: ["Development server only"]
},
{
id: "survival-hard",
name: "Survival Hardcore",
region: "Europe",
players: 2,
maxPlayers: 4,
ping: 95,
worldspace: "Commonwealth",
mode: "Survival",
status: "Open",
modded: true,
passworded: false,
favorite: false,
description: "Hardcore survival with limited respawns and shared resources.",
rules: ["Permadeath on disconnect during combat", "Shared stash rules apply"]
}
];
@@ -0,0 +1,60 @@
(function () {
"use strict";
window.CO_ServerDetails = {
render: function (root, state) {
var el = root.querySelector(".co-details");
if (!el) return;
el.className = "co-details" + (state.focusZone === "details" ? " focused" : "");
var server = state.servers[state.selectedServerIndex];
if (!server) {
el.innerHTML = '<div class="co-details-title">Server Details</div><div class="co-empty">Select a server</div>';
return;
}
var rulesHtml = "";
if (server.rules && server.rules.length) {
rulesHtml = "<ul class=\"co-detail-rules\">" + server.rules.map(function (r) {
return "<li>" + escapeHtml(r) + "</li>";
}).join("") + "</ul>";
}
var previewHtml = server.previewImage
? '<img src="' + escapeAttr(server.previewImage) + '" alt="Preview">'
: "Preview Unavailable";
el.innerHTML =
'<div class="co-details-title">Server Details</div>' +
'<div class="co-preview">' + previewHtml + "</div>" +
'<div class="co-detail-name">' + escapeHtml(server.name) + "</div>" +
detailRow("Region", server.region) +
detailRow("Players", server.players + "/" + server.maxPlayers) +
detailRow("Ping", server.ping + " ms") +
detailRow("Worldspace", server.worldspace) +
detailRow("Mode", server.mode) +
detailRow("Status", server.status) +
detailRow("Modded", server.modded ? "Yes" : "No") +
detailRow("Password", server.passworded ? "Required" : "None") +
'<div class="co-detail-desc">' + escapeHtml(server.description || "") + "</div>" +
rulesHtml +
'<div class="co-detail-desc" style="margin-top:8px;color:var(--co-text-dim)">Mod list: coming soon</div>';
}
};
function detailRow(label, value) {
return '<div class="co-detail-row"><span>' + label + '</span><span>' + escapeHtml(String(value)) + "</span></div>";
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeAttr(value) {
return String(value).replace(/"/g, "&quot;");
}
})();
@@ -0,0 +1,80 @@
(function () {
"use strict";
window.CO_ServerList = {
pingClass: function (ping) {
if (ping <= 50) return "co-ping-good";
if (ping <= 100) return "co-ping-mid";
return "co-ping-bad";
},
render: function (root, state) {
var wrap = root.querySelector(".co-server-list-wrap");
var list = root.querySelector(".co-server-list");
if (!wrap || !list) return;
wrap.className = "co-server-list-wrap" + (state.focusZone === "server-list" ? " focused" : "");
if (!state.servers.length) {
list.innerHTML = '<div class="co-empty">No servers found</div>';
return;
}
list.innerHTML = state.servers.map(function (server, index) {
var selected = index === state.selectedServerIndex ? " selected" : "";
var fav = server.favorite ? " on" : "";
var pingCls = CO_ServerList.pingClass(server.ping);
return (
'<div class="co-server-row' + selected + '" data-index="' + index + '" data-id="' + escapeAttr(server.id) + '">' +
'<span class="co-fav' + fav + '">' + (server.favorite ? "\u2605" : "\u2606") + "</span>" +
"<span>" + escapeHtml(server.name) + "</span>" +
"<span>" + server.players + "/" + server.maxPlayers + "</span>" +
'<span class="co-ping ' + pingCls + '">' + server.ping + " ms</span>" +
"<span>" + escapeHtml(server.worldspace) + "</span>" +
"<span>" + escapeHtml(server.mode) + "</span>" +
"<span>" + escapeHtml(server.status) + "</span>" +
"</div>"
);
}).join("");
},
bind: function (root, onSelect) {
var list = root.querySelector(".co-server-list");
if (!list) return;
list.addEventListener("click", function (e) {
var row = e.target.closest(".co-server-row");
if (!row) return;
onSelect(parseInt(row.getAttribute("data-index"), 10));
});
list.addEventListener("dblclick", function (e) {
var row = e.target.closest(".co-server-row");
if (!row) return;
onSelect(parseInt(row.getAttribute("data-index"), 10));
if (window.CO_App && window.CO_App.joinSelected) {
window.CO_App.joinSelected();
}
});
},
scrollToSelected: function (root, index) {
var list = root.querySelector(".co-server-list");
var row = list && list.querySelector('.co-server-row[data-index="' + index + '"]');
if (row) {
row.scrollIntoView({ block: "nearest" });
}
}
};
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeAttr(value) {
return String(value).replace(/"/g, "&quot;");
}
})();
@@ -0,0 +1,42 @@
(function () {
"use strict";
var TAB_ORDER = ["direct-connect", "server-browser", "favorites", "recent"];
window.CO_Tabs = {
TAB_ORDER: TAB_ORDER,
render: function (root, state) {
var tabsEl = root.querySelector(".co-tabs");
if (!tabsEl) return;
var labels = {
"direct-connect": "Direct Connect",
"server-browser": "Server Browser",
favorites: "Favorites",
recent: "Recent"
};
tabsEl.innerHTML = TAB_ORDER.map(function (tabId, index) {
var active = state.activeTab === tabId ? " active" : "";
var focused = state.focusZone === "tabs" && state.selectedTabIndex === index ? " focused" : "";
return (
'<button type="button" class="co-tab' + active + focused + '" data-tab="' + tabId + '" data-index="' + index + '">' +
labels[tabId] +
"</button>"
);
}).join("");
},
bind: function (root, onTabClick) {
var tabsEl = root.querySelector(".co-tabs");
if (!tabsEl) return;
tabsEl.addEventListener("click", function (e) {
var btn = e.target.closest(".co-tab");
if (!btn) return;
onTabClick(btn.getAttribute("data-tab"));
});
}
};
})();
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Commonwealth Online</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div class="co-root" id="coRoot">
<div class="co-panel" id="coPanel">
<header class="co-header">
<h1>Commonwealth Online</h1>
<div class="co-subtitle">Server Browser</div>
</header>
<nav class="co-tabs"></nav>
<div class="co-body">
<div class="co-tab-content">
<div class="co-tab-pane" data-pane="direct-connect"></div>
<div class="co-tab-pane active" data-pane="server-browser">
<div class="co-browser-layout">
<aside class="co-filters"></aside>
<div class="co-server-list-wrap">
<div class="co-server-list-header">
<span></span>
<span>Server Name</span>
<span>Players</span>
<span>Ping</span>
<span>Worldspace</span>
<span>Mode</span>
<span>Status</span>
</div>
<div class="co-server-list"></div>
</div>
<aside class="co-details"></aside>
</div>
</div>
<div class="co-tab-pane" data-pane="favorites">
<div class="co-browser-layout co-favorites-layout">
<div class="co-server-list-wrap">
<div class="co-server-list-header">
<span></span>
<span>Server Name</span>
<span>Players</span>
<span>Ping</span>
<span>Worldspace</span>
<span>Mode</span>
<span>Status</span>
</div>
<div class="co-server-list co-favorites-list"></div>
</div>
</div>
</div>
<div class="co-tab-pane" data-pane="recent">
<div class="co-browser-layout co-recent-layout">
<div class="co-server-list-wrap">
<div class="co-server-list-header">
<span></span>
<span>Server Name</span>
<span>Players</span>
<span>Ping</span>
<span>Worldspace</span>
<span>Mode</span>
<span>Status</span>
</div>
<div class="co-server-list co-recent-list"></div>
</div>
</div>
</div>
</div>
</div>
<footer class="co-action-bar">
<div class="co-status-text">
Status: <span id="coConnectionStatus">Online</span>
<span class="co-count" id="coServerCount"></span>
</div>
<div class="co-prompts"></div>
</footer>
<div class="co-join-overlay hidden" id="coJoinOverlay">
<div class="co-spinner"></div>
<p id="coJoinMessage">Connecting...</p>
</div>
</div>
</div>
<script src="./components/mock-data.js"></script>
<script src="./components/tabs.js"></script>
<script src="./components/filters.js"></script>
<script src="./components/server-list.js"></script>
<script src="./components/server-details.js"></script>
<script src="./components/controller-prompts.js"></script>
<script src="./components/direct-connect.js"></script>
<script src="./app.js"></script>
</body>
</html>
@@ -0,0 +1,675 @@
:root {
--co-green: #4dff3a;
--co-green-soft: rgba(77, 255, 58, 0.88);
--co-green-dim: rgba(57, 255, 34, 0.35);
--co-bg: rgba(0, 14, 0, 0.92);
--co-bg-dark: rgba(0, 8, 0, 0.96);
--co-bg-list: rgba(0, 12, 0, 0.88);
--co-selected: rgba(77, 255, 58, 0.92);
--co-selected-text: #041200;
--co-border: rgba(77, 255, 58, 0.9);
--co-text-dim: rgba(77, 255, 58, 0.68);
--co-ping-good: #4dff3a;
--co-ping-mid: #c8ff3a;
--co-ping-bad: #ffd43a;
--co-danger: #ff6a6a;
--co-font: "Consolas", "Courier New", monospace;
--co-spacing: 10px;
--co-transition: 120ms ease;
--co-text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
/*
* Fallout 4 main-menu safe zones (1280×720 UI scaled to viewport):
* Left — menu list + green selection bar (~046%)
* Top — "Fallout 4 Updates" / installed-content panel (~026% height)
* Bottom — Bethesda logo / controller hints (~08% 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);
}
}