diff --git a/Aetheria.Rts.Web/Client/aetheria-rts-contract.ts b/Aetheria.Rts.Web/Client/aetheria-rts-contract.ts index 998bb3487..428cf0671 100644 --- a/Aetheria.Rts.Web/Client/aetheria-rts-contract.ts +++ b/Aetheria.Rts.Web/Client/aetheria-rts-contract.ts @@ -10,6 +10,46 @@ import type { export type Viewport = CultMeshViewportRequest; +export type AetheriaMenuSurfaceRequest = { + surfaceId?: string; + inGame?: boolean; + canOpenRuntimeInputScreen?: boolean; +}; + +export type AetheriaMenuSurfaceDocument = { + providerId: string; + providerKind: string; + title: string; + version: number; + updatedAtUtc: string; + surface: AetheriaMenuSurfaceTree; + commands: AetheriaMenuSurfaceCommand[]; +}; + +export type AetheriaMenuSurfaceTree = { + id: string; + root: AetheriaMenuSurfaceComponent; + styles: AetheriaMenuStyleToken[]; +}; + +export type AetheriaMenuSurfaceComponent = { + id: string; + kind: string; + props: Record; + children: AetheriaMenuSurfaceComponent[]; +}; + +export type AetheriaMenuStyleToken = { + name: string; + value: string; +}; + +export type AetheriaMenuSurfaceCommand = { + command: string; + label: string; + transport: string; +}; + export type AetheriaRuntimeSetMoveVectorRequest = { actorEntityKey: string; directionX: number; @@ -305,5 +345,6 @@ export type AetheriaRtsApi = { setTarget(request: AetheriaRuntimeSetTargetRequest): Promise; surfaceCatalog(): Promise; surfaceCatalogIndex(): Promise; + mainMenuSurface(request: AetheriaMenuSurfaceRequest): Promise; health(): Promise; }; diff --git a/Aetheria.Rts.Web/Client/app.ts b/Aetheria.Rts.Web/Client/app.ts index 4664b6192..33612d055 100644 --- a/Aetheria.Rts.Web/Client/app.ts +++ b/Aetheria.Rts.Web/Client/app.ts @@ -1,5 +1,7 @@ import type { AetheriaRtsApi, + AetheriaMenuSurfaceComponent, + AetheriaMenuSurfaceDocument, AuthorityStatusDocument, AetheriaRuntimeViewportFeedSnapshot, BodyView, @@ -49,6 +51,7 @@ let latestReceivedAt = 0; let latestPollMs = 0; let refreshInFlight = false; let viewportFeedUnsubscribe: (() => void) | null = null; +const mainMenuMode = window.location.hash === "#main-menu"; function requiredElement(selector: string): TElement { const element = document.querySelector(selector); @@ -87,6 +90,117 @@ function setStatus(text: string): void { statusEl.textContent = text; } +async function showMainMenu(surfaceId = "aetheria.main_menu.root"): Promise { + document.body.classList.add("main-menu-mode"); + viewportFeedUnsubscribe?.(); + const surface = await window.aetheriaRts.mainMenuSurface({ + surfaceId, + inGame: false, + canOpenRuntimeInputScreen: false, + }); + installMenuFonts(surface); + renderMainMenuSurface(surface); +} + +function installMenuFonts(surface: AetheriaMenuSurfaceDocument): void { + const googleFont = surface.surface.styles.find(token => token.name === "font.web.google")?.value; + if (!googleFont || document.querySelector(`link[data-aetheria-menu-fonts="${googleFont}"]`)) { + return; + } + + const preconnect = document.createElement("link"); + preconnect.rel = "preconnect"; + preconnect.href = "https://fonts.gstatic.com"; + preconnect.crossOrigin = "anonymous"; + document.head.append(preconnect); + + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = googleFont; + link.dataset.aetheriaMenuFonts = googleFont; + document.head.append(link); +} + +function renderMainMenuSurface(surface: AetheriaMenuSurfaceDocument): void { + let host = document.querySelector("#main-menu-host"); + if (!host) { + host = document.createElement("section"); + host.id = "main-menu-host"; + host.className = "main-menu-host"; + document.body.append(host); + } + + host.replaceChildren(lowerMenuComponent(surface.surface.root, surface)); +} + +function lowerMenuComponent( + component: AetheriaMenuSurfaceComponent, + surface: AetheriaMenuSurfaceDocument, +): HTMLElement { + const kind = component.kind || "surface"; + if (kind === "control.button") { + const button = document.createElement("button"); + button.id = component.id; + button.className = "eve-control-button"; + button.type = "button"; + button.textContent = component.props.label ?? ""; + button.addEventListener("click", () => handleMenuCommand(component.props.command ?? "", surface)); + return button; + } + + if (kind.startsWith("text")) { + const label = document.createElement("div"); + label.id = component.id; + label.className = kind === "text.title" ? "eve-text-title" : "eve-text"; + label.textContent = component.props.value ?? component.props.text ?? ""; + return label; + } + + if (kind === "metric") { + const metric = document.createElement("dl"); + metric.id = component.id; + metric.className = "eve-metric"; + const term = document.createElement("dt"); + term.textContent = component.props.label ?? ""; + const value = document.createElement("dd"); + value.textContent = component.props.value ?? ""; + metric.append(term, value); + return metric; + } + + const element = document.createElement("div"); + element.id = component.id; + element.className = [ + kind === "surface" ? "eve-surface" : "", + kind === "column" ? "eve-column" : "", + kind === "row" ? "eve-row" : "", + ].filter(Boolean).join(" "); + for (const child of component.children ?? []) { + element.append(lowerMenuComponent(child, surface)); + } + return element; +} + +function handleMenuCommand(command: string, surface: AetheriaMenuSurfaceDocument): void { + switch (command) { + case "aetheria.main_menu.root.show_settings": + void showMainMenu("aetheria.main_menu.settings"); + return; + case "aetheria.main_menu.settings.show_input_settings": + void showMainMenu("aetheria.main_menu.input_settings"); + return; + case "aetheria.main_menu.settings.back_to_settings": + void showMainMenu("aetheria.main_menu.settings"); + return; + case "aetheria.main_menu.settings.back_to_main": + void showMainMenu("aetheria.main_menu.root"); + return; + default: + setStatus(`${surface.title}: ${command || "no command"}`); + return; + } +} + function startViewportFeed(): void { const viewport = currentViewport(); viewportFeedUnsubscribe?.(); @@ -585,6 +699,10 @@ function roleText(session: StarbridgeSessionDocument): string { .join(", "); } -updateZoomLabel(); -void refreshRuntimeSurfaces(); -void refresh(); +if (mainMenuMode) { + void showMainMenu(); +} else { + updateZoomLabel(); + void refreshRuntimeSurfaces(); + void refresh(); +} diff --git a/Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts b/Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts index e3886df46..f4a5ce562 100644 --- a/Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts +++ b/Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts @@ -70,6 +70,46 @@ export type AetheriaCultMeshClientOptions = { snapshotTimeoutMs?: number; }; +export type AetheriaMenuSurfaceRequest = { + surfaceId?: string; + inGame?: boolean; + canOpenRuntimeInputScreen?: boolean; +}; + +export type AetheriaMenuSurfaceDocument = { + providerId: string; + providerKind: string; + title: string; + version: number; + updatedAtUtc: string; + surface: AetheriaMenuSurfaceTree; + commands: AetheriaMenuSurfaceCommand[]; +}; + +export type AetheriaMenuSurfaceTree = { + id: string; + root: AetheriaMenuSurfaceComponent; + styles: AetheriaMenuStyleToken[]; +}; + +export type AetheriaMenuSurfaceComponent = { + id: string; + kind: string; + props: Record; + children: AetheriaMenuSurfaceComponent[]; +}; + +export type AetheriaMenuStyleToken = { + name: string; + value: string; +}; + +export type AetheriaMenuSurfaceCommand = { + command: string; + label: string; + transport: string; +}; + export type { BodyView, EntityStatus, @@ -232,6 +272,13 @@ export class AetheriaCultMeshClient { return this.aetheria.daemon.assetManifest(); } + public async mainMenuSurface(request: AetheriaMenuSurfaceRequest = {}): Promise { + return buildMainMenuSurface( + request.surfaceId || "aetheria.main_menu.root", + request.canOpenRuntimeInputScreen === true, + request.inGame === true); + } + public queryDiagnostics(): Readonly> { return describeAetheriaRuntimeRtsQueryHandles(this.queries); } @@ -449,6 +496,141 @@ export class AetheriaCultMeshClient { } } +function buildMainMenuSurface( + surfaceId: string, + canOpenRuntimeInputScreen: boolean, + inGame: boolean, +): AetheriaMenuSurfaceDocument { + const updatedAtUtc = new Date().toISOString(); + if (surfaceId === "aetheria.main_menu.settings") { + return surfaceDocument( + surfaceId, + "Aetheria Settings", + updatedAtUtc, + [ + command("aetheria.main_menu.settings.show_player_settings", "Player Settings"), + command("aetheria.main_menu.settings.show_verse_settings", "Verse"), + command("aetheria.main_menu.settings.show_input_settings", "Input"), + command("aetheria.main_menu.settings.back_to_main", "Back"), + ], + text("aetheria.mainMenu.settings.title", "SETTINGS", "text.title"), + buttonColumn( + "aetheria.mainMenu.settings.actions", + button("aetheria.mainMenu.settings.playerSettings", "Player Settings", "aetheria.main_menu.settings.show_player_settings"), + button("aetheria.mainMenu.settings.verse", "Verse", "aetheria.main_menu.settings.show_verse_settings"), + button("aetheria.mainMenu.settings.input", "Input", "aetheria.main_menu.settings.show_input_settings"), + button("aetheria.mainMenu.settings.back", "Back", "aetheria.main_menu.settings.back_to_main"), + ), + ); + } + + if (surfaceId === "aetheria.main_menu.input_settings") { + const children = [ + text("aetheria.mainMenu.input.title", "INPUT", "text.title"), + metric("aetheria.mainMenu.input.bindingOverrides", "Binding Overrides", "0"), + metric("aetheria.mainMenu.input.actionBarInputs", "Action-Bar Inputs", "0"), + text( + "aetheria.mainMenu.input.note", + canOpenRuntimeInputScreen && inGame + ? "The runtime Eve input screen owns low-level InputSystem rebinding and action-bar input edits." + : "This title shell reports typed player-settings state. Launch a run to open the runtime Eve input screen that owns low-level InputSystem rebinding."), + buttonColumn( + "aetheria.mainMenu.input.actions", + button("aetheria.mainMenu.input.back", "Back", "aetheria.main_menu.settings.back_to_settings"), + ), + ]; + return surfaceDocument( + surfaceId, + "Aetheria Input Settings", + updatedAtUtc, + [command("aetheria.main_menu.settings.back_to_settings", "Back")], + ...children); + } + + const actionButtons = [ + ...(!inGame ? [button("aetheria.main_menu.root.continue", "Continue", "aetheria.main_menu.root.continue")] : []), + button("aetheria.main_menu.root.newGame", "New Game", "aetheria.main_menu.root.new_game"), + button("aetheria.main_menu.root.settings", "Settings", "aetheria.main_menu.root.show_settings"), + button("aetheria.main_menu.root.quit", "Quit", "aetheria.main_menu.root.quit"), + ]; + return surfaceDocument( + "aetheria.main_menu.root", + "Aetheria Terminus", + updatedAtUtc, + [ + ...(!inGame ? [command("aetheria.main_menu.root.continue", "Continue")] : []), + command("aetheria.main_menu.root.new_game", "New Game"), + command("aetheria.main_menu.root.show_settings", "Settings"), + command("aetheria.main_menu.root.quit", "Quit"), + ], + text("aetheria.main_menu.root.title", "AETHERIA", "text.title"), + text("aetheria.main_menu.root.subtitle", "TERMINUS", "text.subtitle"), + buttonColumn("aetheria.main_menu.root.actions", ...actionButtons), + ); +} + +function surfaceDocument( + surfaceId: string, + title: string, + updatedAtUtc: string, + commands: AetheriaMenuSurfaceCommand[], + ...children: AetheriaMenuSurfaceComponent[] +): AetheriaMenuSurfaceDocument { + return { + providerId: "aetheria", + providerKind: "game.menu", + title, + version: 1, + updatedAtUtc, + surface: { + id: surfaceId, + root: node(`${surfaceId}.root`, "surface", {}, ...children), + styles: [ + { name: "font.title.family", value: "Montserrat" }, + { name: "font.title.style", value: "Thin" }, + { name: "font.title.weight", value: "100" }, + { name: "font.body.family", value: "Ubuntu" }, + { name: "font.body.style", value: "Regular" }, + { name: "font.body.weight", value: "400" }, + { + name: "font.web.google", + value: "https://fonts.googleapis.com/css2?family=Montserrat:wght@100&family=Ubuntu:wght@400&display=swap", + }, + ], + }, + commands, + }; +} + +function command(commandId: string, label: string): AetheriaMenuSurfaceCommand { + return { command: commandId, label, transport: "cultmesh" }; +} + +function node( + id: string, + kind: string, + props: Record, + ...children: AetheriaMenuSurfaceComponent[] +): AetheriaMenuSurfaceComponent { + return { id, kind, props, children }; +} + +function text(id: string, value: string, kind = "text"): AetheriaMenuSurfaceComponent { + return node(id, kind, { value }); +} + +function metric(id: string, label: string, value: string): AetheriaMenuSurfaceComponent { + return node(id, "metric", { label, value }); +} + +function button(id: string, label: string, commandId: string): AetheriaMenuSurfaceComponent { + return node(id, "control.button", { label, command: commandId }); +} + +function buttonColumn(id: string, ...children: AetheriaMenuSurfaceComponent[]): AetheriaMenuSurfaceComponent { + return node(id, "column", {}, ...children); +} + function stableToken(value: string): string { return value .replace(/[^a-zA-Z0-9]+/gu, "-") diff --git a/Aetheria.Rts.Web/Electron/main.ts b/Aetheria.Rts.Web/Electron/main.ts index 9930f3ecd..6622065ba 100644 --- a/Aetheria.Rts.Web/Electron/main.ts +++ b/Aetheria.Rts.Web/Electron/main.ts @@ -27,6 +27,7 @@ const rtsCultMeshAdvertiseHost = process.env.AETHERIA_RTS_CULTMESH_ADVERTISE_HOS const peerCultMeshEndpoints = parseEndpointList(process.env.AETHERIA_RTS_PEER_CULTMESH_ENDPOINTS); const electronSmoke = process.env.AETHERIA_RTS_ELECTRON_SMOKE === "1"; const electronSmokeResultPath = process.env.AETHERIA_RTS_ELECTRON_SMOKE_RESULT; +const rendererView = process.env.AETHERIA_RTS_VIEW?.trim() ?? ""; let daemonProcess: ChildProcessWithoutNullStreams | null = null; let mainWindow: BrowserWindow | null = null; @@ -39,7 +40,10 @@ app.whenReady().then(async () => { showStartup("Preparing Aetheria RTS", "Building daemon if needed."); try { - if (launchLocalDaemon) { + if (rendererView === "main-menu") { + showStartup("Launching Aetheria Menu", "Lowering the CultUI main-menu surface."); + mkdirSync(runtimeRoot, { recursive: true }); + } else if (launchLocalDaemon) { await ensureDotnetBuild(); showStartup("Launching Aetheria RTS", "Starting the Aetheria daemon."); mkdirSync(runtimeRoot, { recursive: true }); @@ -73,9 +77,13 @@ app.whenReady().then(async () => { "aetheria-rts-electron", { publicationMode: launchLocalDaemon ? "local" : "remote" }); - showStartup("Launching Aetheria RTS", "Waiting for the daemon CultMesh frame."); - await rtsClient.waitForFrame(30000); - await mainWindow.loadFile(rendererIndex); + if (rendererView === "main-menu") { + await mainWindow.loadFile(rendererIndex, { hash: "main-menu" }); + } else { + showStartup("Launching Aetheria RTS", "Waiting for the daemon CultMesh frame."); + await rtsClient.waitForFrame(30000); + await mainWindow.loadFile(rendererIndex); + } if (electronSmoke) { const result = await runElectronSmoke(mainWindow); writeElectronSmokeResult({ ok: true, result }); @@ -291,6 +299,8 @@ function registerIpc(): void { daemonRunning: daemonProcess != null && !daemonProcess.killed, daemonMode: launchLocalDaemon ? "local" : "remote", })); + ipcMain.handle("aetheria-rts:main-menu-surface", async (_event, request) => + requireClient().mainMenuSurface(request)); } function parseEndpointList(value: string | undefined): string[] { diff --git a/Aetheria.Rts.Web/Electron/preload.cjs b/Aetheria.Rts.Web/Electron/preload.cjs index 7dc4513ca..2b095b9c9 100644 --- a/Aetheria.Rts.Web/Electron/preload.cjs +++ b/Aetheria.Rts.Web/Electron/preload.cjs @@ -20,6 +20,7 @@ const channels = { setTarget: "aetheria-rts:set-target", surfaceCatalog: "aetheria-rts:surface-catalog", surfaceCatalogIndex: "aetheria-rts:surface-catalog-index", + mainMenuSurface: "aetheria-rts:main-menu-surface", health: "aetheria-rts:health", }; @@ -51,5 +52,6 @@ contextBridge.exposeInMainWorld("aetheriaRts", { setTarget: request => ipcRenderer.invoke(channels.setTarget, request), surfaceCatalog: () => ipcRenderer.invoke(channels.surfaceCatalog), surfaceCatalogIndex: () => ipcRenderer.invoke(channels.surfaceCatalogIndex), + mainMenuSurface: request => ipcRenderer.invoke(channels.mainMenuSurface, request), health: () => ipcRenderer.invoke(channels.health), }); diff --git a/Aetheria.Rts.Web/wwwroot/index.html b/Aetheria.Rts.Web/wwwroot/index.html index d7b4ed865..af5300ba5 100644 --- a/Aetheria.Rts.Web/wwwroot/index.html +++ b/Aetheria.Rts.Web/wwwroot/index.html @@ -46,6 +46,7 @@ + diff --git a/Aetheria.Rts.Web/wwwroot/styles.css b/Aetheria.Rts.Web/wwwroot/styles.css index 412129f4d..bd69a6fd1 100644 --- a/Aetheria.Rts.Web/wwwroot/styles.css +++ b/Aetheria.Rts.Web/wwwroot/styles.css @@ -19,6 +19,111 @@ body { overflow: hidden; } +.main-menu-host { + display: none; +} + +body.main-menu-mode { + background: + radial-gradient(circle at 76% 66%, rgba(255, 213, 112, 0.74) 0, rgba(247, 121, 88, 0.36) 13%, transparent 31%), + radial-gradient(circle at 61% 37%, rgba(240, 45, 201, 0.54) 0, rgba(176, 38, 174, 0.28) 19%, transparent 45%), + radial-gradient(circle at 40% 45%, rgba(145, 21, 122, 0.42) 0, rgba(54, 8, 41, 0.4) 34%, transparent 62%), + #050407; +} + +body.main-menu-mode .shell { + display: none; +} + +body.main-menu-mode .main-menu-host { + display: flex; + align-items: flex-start; + justify-content: flex-start; + min-height: 100%; + padding: 92px 96px; + background: + radial-gradient(ellipse at 78% 64%, rgba(255, 194, 95, 0.36) 0, rgba(255, 148, 92, 0.18) 16%, transparent 35%), + radial-gradient(ellipse at 59% 38%, rgba(238, 56, 216, 0.28) 0, rgba(164, 37, 160, 0.2) 22%, transparent 52%), + linear-gradient(90deg, rgba(0, 0, 0, 0.5), transparent 48%); +} + +body.main-menu-mode .eve-surface { + width: 720px; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +body.main-menu-mode .eve-text-title { + width: 720px; + color: rgba(209, 242, 255, 0.96); + font-family: "Montserrat", sans-serif; + font-weight: 100; + line-height: 0.95; + text-align: right; +} + +body.main-menu-mode #aetheria\.main_menu\.root\.title { + margin: 0 0 -12px; + font-size: 86px; +} + +body.main-menu-mode #aetheria\.main_menu\.root\.subtitle { + margin: 0 0 36px; + font-size: 39px; +} + +body.main-menu-mode .eve-text, +body.main-menu-mode .eve-metric, +body.main-menu-mode .eve-control-button { + font-family: "Ubuntu", system-ui, sans-serif; +} + +body.main-menu-mode .eve-column { + display: flex; + flex-direction: column; +} + +body.main-menu-mode .eve-control-button { + width: 220px; + min-width: 0; + height: 32px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: rgba(219, 250, 255, 0.96); + font-size: 24px; + line-height: 32px; + text-align: left; + letter-spacing: 0; + transition: letter-spacing 160ms ease, color 160ms ease; +} + +body.main-menu-mode .eve-control-button:hover, +body.main-menu-mode .eve-control-button:focus-visible { + background: transparent; + color: white; + letter-spacing: 8px; + outline: 0; +} + +body.main-menu-mode .eve-metric { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 8px; + width: 520px; + margin: 0 0 8px; +} + +body.main-menu-mode .eve-text:not(.eve-text-title) { + width: 520px; + margin: 0 0 14px; + color: rgba(219, 250, 255, 0.9); + font-size: 16px; + line-height: 1.25; +} + button { min-width: 36px; height: 32px;