diff --git a/.gitignore b/.gitignore index a276bf16..0b007644 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ icons/sprite.svg /binaries/ .wrangler +.claude/ # Playwright test-results diff --git a/app/.server/classes/Plugins/Bridge.ts b/app/.server/classes/Plugins/Bridge.ts new file mode 100644 index 00000000..89d8b8f8 --- /dev/null +++ b/app/.server/classes/Plugins/Bridge.ts @@ -0,0 +1,120 @@ +import type BasePlugin from "."; +import { Aspect } from "./Aspect"; +import { generateIncrementedName } from "@thorium/utils/generateIncrementedName"; + +export interface BridgeClientAssignment { + clientName: string; + stationId: string | null; + isSoundPlayer: boolean; + tags: string[]; +} + +export interface BridgeViewscreen { + id: string; + name: string; + tags: string[]; + isMainViewscreen?: boolean; + defaultPose: { poseId: string; pluginId: string } | null; + showGizmos?: boolean; + showLayout?: boolean; + brokenMode?: "fullyBroken" | "cameraBrokenOnly" | "invincible"; + /** Camera field of view in degrees (1–179). Values >= 180 yield a black screen because tan(fov/2) is mathematically undefined at 180°. Defaults to 45. */ + fov?: number; +} + +interface BridgeMapElementBase { + id: string; + /** Horizontal position in pixels relative to the floor background image. */ + x: number; + /** Vertical position in pixels relative to the floor background image. */ + y: number; + /** Rotation in degrees. For viewscreens this is the default yaw angle. */ + rotation: number; + /** Element width in pixels. */ + widthPixels?: number; + /** Element height in pixels. */ + heightPixels?: number; + label?: string; + clientName?: string; +} + +export interface BridgeMapStation extends BridgeMapElementBase { + type: "station"; + stationName?: string; +} + +export interface BridgeMapViewscreen extends BridgeMapElementBase { + type: "viewscreen"; + viewscreenId?: string; + /** Pitch angle in degrees — the default camera pitch for this viewscreen. */ + pitch?: number; +} + +export type BridgeMapElement = BridgeMapStation | BridgeMapViewscreen; +export type BridgeMapElementType = BridgeMapElement["type"]; + +export interface BridgeFloor { + id: string; + name: string; + backgroundUrl: string; + /** Background image width in pixels. */ + widthPixels: number; + /** Background image height in pixels. */ + heightPixels: number; + elements: BridgeMapElement[]; +} + +export interface StationAssignment { + clientAssignments: BridgeClientAssignment[]; + elementStations: Record; // elementId -> stationName +} + +export function complementKey(ref?: { + pluginId: string; + stationComplementId: string; +}): string | null { + return ref ? `${ref.pluginId}:${ref.stationComplementId}` : null; +} + +export default class BridgePlugin extends Aspect { + apiVersion = "bridges/v1" as const; + kind = "bridges" as const; + name!: string; + description!: string; + stationComplementRef?: { pluginId: string; stationComplementId: string }; + /** Per-complement client and element assignments, keyed by "pluginId:complementId". */ + stationAssignments!: Record; + viewscreens!: BridgeViewscreen[]; + floors!: BridgeFloor[]; + /** Default size in pixels for map elements. When undefined, defaults to 7.5% of floor width. */ + elementScale?: number; + assets!: Record; + constructor(params: Partial, plugin: BasePlugin) { + const name = generateIncrementedName( + params.name || "New Bridge", + plugin.aspects.bridges.map((b) => b.name), + ); + super({ ...params, name }, { kind: "bridges" }, plugin, {}); + + this.name = this.name || name; + this.description = this.description || params.description || ""; + this.stationComplementRef = + this.stationComplementRef || params.stationComplementRef || undefined; + this.stationAssignments = + this.stationAssignments || params.stationAssignments || {}; + this.viewscreens = this.viewscreens || params.viewscreens || []; + this.floors = this.floors || + params.floors || [ + { + id: crypto.randomUUID(), + name: "Main", + backgroundUrl: "", + widthPixels: 800, + heightPixels: 800, + elements: [], + }, + ]; + this.elementScale = this.elementScale || params.elementScale || undefined; + this.assets = this.assets || {}; + } +} diff --git a/app/.server/classes/Plugins/index.ts b/app/.server/classes/Plugins/index.ts index 46a650c5..fb528c86 100644 --- a/app/.server/classes/Plugins/index.ts +++ b/app/.server/classes/Plugins/index.ts @@ -12,6 +12,7 @@ import { MacroPlugin } from "./Macro"; import ReportPlugin from "@thorium/.server/classes/Plugins/Report"; import MissionPlugin from "./Mission"; import TrainingPlugin from "@thorium/.server/classes/Plugins/Training"; +import BridgePlugin from "./Bridge"; import ConversationPlugin from "@thorium/.server/classes/Plugins/Conversation"; import { ShipSystemTypes } from "@thorium/.server/classes/Plugins/ShipSystems/shipSystemTypes"; @@ -34,6 +35,7 @@ const Aspects = { reports: ReportPlugin, trainings: TrainingPlugin, conversations: ConversationPlugin, + bridges: BridgePlugin, }; export type AspectsMap = { @@ -114,6 +116,7 @@ export default class BasePlugin extends DataStore { reports: [], trainings: [], conversations: [], + bridges: [], }; pluginAspects.set(this, aspects); } diff --git a/app/.server/data/client.ts b/app/.server/data/client.ts index fe69902d..b9e64d7a 100644 --- a/app/.server/data/client.ts +++ b/app/.server/data/client.ts @@ -10,6 +10,7 @@ import { selectAvailableTimelines } from "@thorium/utils/.server/executeBlocks"; import type { Entity } from "@thorium/utils/ecs"; import z from "zod"; import MarkdownIt from "markdown-it"; +import { claimBridgeFlightClient } from "@thorium/.server/init/bridgeAutoAssign"; import { applyCardHighlight } from "@thorium/utils/.server/applyCardHighlight"; const md = MarkdownIt(); @@ -63,10 +64,26 @@ export const client = t.router({ return clients; }), setName: t.procedure - .input(z.object({ clientId: z.string(), name: z.string().min(2) })) + .input(z.object({ clientId: z.string(), name: z.string().min(1) })) .send(({ ctx, input }) => { const client = ctx.getClient(input.clientId); + + // Un-claim current bridge entity if exists + if (ctx.flight) { + const flightClient = ctx.getFlightClient(client.id); + if (flightClient?.components.flightClient?.bridgeAssigned) { + flightClient.updateComponent("flightClient", { clientId: "" }); + ctx.flight.flightClientIndex.delete(client.id); + } + } + client.name = input.name; + + // Try to claim a bridge entity matching the new name + if (ctx.flight) { + claimBridgeFlightClient(ctx, client.id); + } + pubsub.publish.client.all(); pubsub.publish.client.get({ clientId: client.id }); @@ -94,6 +111,7 @@ export const client = t.router({ flightClient.updateComponent("flightClient", { stationId: null, shipId: null, + bridgeAssigned: false, }); const clientId = flightClient.components.flightClient!.clientId; pubsub.publish.client.all(); @@ -107,9 +125,28 @@ export const client = t.router({ if (!ship?.components.isShip) { throw new Error("No ship with that ID exists."); } - const station = staticStations - .concat(ship.components.stationComplement?.stations || []) - .find((station) => station.name === input.stationId); + const complementStations = + ship.components.stationComplement?.stations || []; + const hasViewscreenStations = complementStations.some((s) => + s.cards.some((c) => c.component === "Viewscreen"), + ); + const filteredStatic = hasViewscreenStations + ? staticStations.filter((s) => s.name !== "Viewscreen") + : staticStations; + const stations = [...complementStations]; + for (const staticStation of filteredStatic) { + stations.push({ + cards: staticStation.cards, + description: "", + logo: "", + messageGroups: [], + name: staticStation.name, + tags: [], + theme: "", + widgets: [], + }); + } + const station = stations.find((station) => station.name === input.stationId); if (!station) { throw new Error("No station with that ID exists."); diff --git a/app/.server/data/plugins/bridge.ts b/app/.server/data/plugins/bridge.ts new file mode 100644 index 00000000..399f30d6 --- /dev/null +++ b/app/.server/data/plugins/bridge.ts @@ -0,0 +1,742 @@ +import BridgePlugin, { + complementKey, +} from "@thorium/.server/classes/Plugins/Bridge"; +import { t } from "@thorium/.server/init/t"; +import { pubsub } from "@thorium/.server/init/pubsub"; +import inputAuth from "@thorium/utils/.server/inputAuth"; +import { z } from "zod"; +import { getPlugin } from "./utils"; +import { generateIncrementedName } from "@thorium/utils/generateIncrementedName"; + +const elementTypeEnum = z.enum(["station", "viewscreen"]); + +export const bridge = t.router({ + available: t.procedure.request(({ ctx }) => { + const bridges: { pluginId: string; bridgeId: string; label: string }[] = []; + for (const plugin of ctx.server.plugins) { + if (!plugin.active) continue; + for (const b of plugin.aspects.bridges) { + bridges.push({ + pluginId: plugin.id, + bridgeId: b.name, + label: `${b.name} (${plugin.name})`, + }); + } + } + return bridges; + }), + all: t.procedure + .input(z.object({ pluginId: z.string() })) + .filter((publish: { pluginId: string } | null, { input }) => { + if (publish && input.pluginId !== publish.pluginId) return false; + return true; + }) + .request(({ ctx, input }) => { + const plugin = getPlugin(ctx, input.pluginId); + return plugin.aspects.bridges.map(({ name, description }) => ({ + name, + description, + })); + }), + get: t.procedure + .input(z.object({ pluginId: z.string(), bridgeId: z.string() })) + .filter( + (publish: { pluginId: string; bridgeId: string } | null, { input }) => { + if (publish && input.pluginId !== publish.pluginId) return false; + return true; + }, + ) + .request(({ ctx, input }) => { + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) return null; + const activeKey = complementKey(b.stationComplementRef); + return { + name: b.name, + description: b.description, + stationComplementRef: b.stationComplementRef, + clientAssignments: + (activeKey + ? b.stationAssignments[activeKey]?.clientAssignments + : null) ?? [], + viewscreens: b.viewscreens, + elementScale: b.elementScale, + floors: b.floors.map((floor) => ({ + id: floor.id, + name: floor.name, + backgroundUrl: floor.backgroundUrl, + + widthPixels: floor.widthPixels, + heightPixels: floor.heightPixels, + elements: floor.elements, + })), + }; + }), + create: t.procedure + .input(z.object({ pluginId: z.string(), name: z.string() })) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = new BridgePlugin({ name: input.name }, plugin); + plugin.aspects.bridges.push(b); + pubsub.publish.plugin.bridge.all({ pluginId: input.pluginId }); + return { bridgeId: b.name }; + }), + delete: t.procedure + .input(z.object({ pluginId: z.string(), bridgeId: z.string() })) + .send(async ({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) return; + plugin.aspects.bridges.splice(plugin.aspects.bridges.indexOf(b), 1); + await b.remove(); + pubsub.publish.plugin.bridge.all({ pluginId: input.pluginId }); + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), + update: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + name: z.string().optional(), + description: z.string().optional(), + elementScale: z.number().positive().optional(), + }), + ) + .send(async ({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) return { bridgeId: "" }; + if (typeof input.description === "string") + b.description = input.description; + if (typeof input.elementScale === "number") + b.elementScale = input.elementScale; + if (input.name !== b.name && input.name) { + await b.rename(input.name); + } + pubsub.publish.plugin.bridge.all({ pluginId: input.pluginId }); + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + return { bridgeId: b.name }; + }), + + // --- Station Complement --- + allStationComplements: t.procedure + .input(z.object({ pluginId: z.string() })) + .request(({ ctx }) => { + const groups: { + header: string; + items: { id: string; label: string }[]; + }[] = []; + for (const plugin of ctx.server.plugins) { + const items = plugin.aspects.stationComplements + .slice() + .sort((a, b) => a.stations.length - b.stations.length) + .map((sc) => ({ + id: `${plugin.id}:${sc.name}`, + label: `${sc.name} (${sc.stations.length})`, + })); + if (items.length > 0) { + groups.push({ header: plugin.name, items }); + } + } + return groups; + }), + updateStationComplement: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + stationComplementRef: z + .object({ + pluginId: z.string(), + stationComplementId: z.string(), + }) + .nullable(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + // Save current element station assignments for the old complement + const oldKey = complementKey(b.stationComplementRef); + if (oldKey) { + const elementStations: Record = {}; + for (const floor of b.floors) { + for (const el of floor.elements) { + if (el.type === "station" && el.stationName) { + elementStations[el.id] = el.stationName; + } + } + } + if (!b.stationAssignments[oldKey]) { + b.stationAssignments[oldKey] = { + clientAssignments: [], + elementStations, + }; + } else { + b.stationAssignments[oldKey].elementStations = elementStations; + } + } + + b.stationComplementRef = input.stationComplementRef ?? undefined; + + // Restore element station assignments from the new complement, or clear + const newKey = complementKey(b.stationComplementRef); + const saved = newKey ? b.stationAssignments[newKey] : null; + for (const floor of b.floors) { + for (const el of floor.elements) { + if (el.type === "station") { + const restored = saved?.elementStations[el.id] ?? ""; + el.stationName = restored; + el.label = restored; + } + } + } + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + pubsub.publish.plugin.bridge.getStationComplementStations({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), + getStationComplementStations: t.procedure + .input(z.object({ pluginId: z.string(), bridgeId: z.string() })) + .filter( + (publish: { pluginId: string; bridgeId: string } | null, { input }) => { + if (publish && input.pluginId !== publish.pluginId) return false; + return true; + }, + ) + .request(({ ctx, input }) => { + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b || !b.stationComplementRef) return []; + const complementPlugin = ctx.server.plugins.find( + (p) => p.id === b.stationComplementRef!.pluginId, + ); + if (!complementPlugin) return []; + const complement = complementPlugin.aspects.stationComplements.find( + (sc) => sc.name === b.stationComplementRef!.stationComplementId, + ); + if (!complement) return []; + return complement.stations.map((s) => s.name); + }), + + // --- Viewscreens --- + updateViewscreen: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + viewscreenId: z.string(), + name: z.string().optional(), + tags: z.string().array().optional(), + isMainViewscreen: z.boolean().optional(), + defaultPose: z + .object({ poseId: z.string(), pluginId: z.string() }) + .nullable() + .optional(), + showGizmos: z.boolean().optional(), + showLayout: z.boolean().optional(), + brokenMode: z + .enum(["fullyBroken", "cameraBrokenOnly", "invincible"]) + .optional(), + fov: z.number().min(1).max(179).optional(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const vs = b.viewscreens.find((v) => v.id === input.viewscreenId); + if (!vs) throw new Error("Viewscreen not found"); + if (typeof input.name === "string") { + const duplicate = b.viewscreens.some( + (v) => v.id !== input.viewscreenId && v.name === input.name, + ); + if (duplicate) + throw new Error( + `A viewscreen named "${input.name}" already exists on this bridge`, + ); + vs.name = input.name; + } + if (input.tags) vs.tags = input.tags; + if (typeof input.isMainViewscreen === "boolean") + vs.isMainViewscreen = input.isMainViewscreen; + if (input.defaultPose !== undefined) vs.defaultPose = input.defaultPose; + if (typeof input.showGizmos === "boolean") + vs.showGizmos = input.showGizmos; + if (typeof input.showLayout === "boolean") + vs.showLayout = input.showLayout; + if (input.brokenMode) vs.brokenMode = input.brokenMode; + if (typeof input.fov === "number") vs.fov = input.fov; + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), + removeViewscreen: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + viewscreenId: z.string(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const idx = b.viewscreens.findIndex((v) => v.id === input.viewscreenId); + if (idx >= 0) b.viewscreens.splice(idx, 1); + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), + + // --- Client Assignments --- + addClientAssignment: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + clientName: z.string(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const key = complementKey(b.stationComplementRef); + if (!key) throw new Error("No station complement selected"); + if (!b.stationAssignments[key]) { + b.stationAssignments[key] = { + clientAssignments: [], + elementStations: {}, + }; + } + b.stationAssignments[key].clientAssignments.push({ + clientName: input.clientName, + stationId: null, + isSoundPlayer: false, + tags: [], + }); + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), + updateClientAssignment: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + clientName: z.string(), + stationId: z.string().nullable().optional(), + isSoundPlayer: z.boolean().optional(), + tags: z.string().array().optional(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const key = complementKey(b.stationComplementRef); + if (!key) throw new Error("No station complement selected"); + const ca = b.stationAssignments[key]?.clientAssignments.find( + (c) => c.clientName === input.clientName, + ); + if (!ca) throw new Error("Client assignment not found"); + if (input.stationId !== undefined) ca.stationId = input.stationId; + if (typeof input.isSoundPlayer === "boolean") + ca.isSoundPlayer = input.isSoundPlayer; + if (input.tags) ca.tags = input.tags; + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), + removeClientAssignment: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + clientName: z.string(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const key = complementKey(b.stationComplementRef); + if (!key) throw new Error("No station complement selected"); + const assignments = b.stationAssignments[key]?.clientAssignments; + if (assignments) { + const idx = assignments.findIndex( + (c) => c.clientName === input.clientName, + ); + if (idx >= 0) assignments.splice(idx, 1); + } + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), + + // --- Floors --- + addFloor: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + name: z.string(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const floor = { + id: crypto.randomUUID(), + name: input.name, + backgroundUrl: "", + widthPixels: 800, + heightPixels: 800, + elements: [], + }; + b.floors.push(floor); + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + return { floorId: floor.id }; + }), + updateFloor: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + floorId: z.string(), + name: z.string().optional(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const floor = b.floors.find((f) => f.id === input.floorId); + if (!floor) throw new Error("Floor not found"); + if (typeof input.name === "string") floor.name = input.name; + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), + removeFloor: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + floorId: z.string(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const idx = b.floors.findIndex((f) => f.id === input.floorId); + if (idx >= 0) { + const floor = b.floors[idx]; + // Clean up linked viewscreens and client assignments for elements on this floor + for (const el of floor.elements) { + if (el.type === "viewscreen" && el.viewscreenId) { + const vsIdx = b.viewscreens.findIndex( + (v) => v.id === el.viewscreenId, + ); + if (vsIdx >= 0) b.viewscreens.splice(vsIdx, 1); + } + if (el.clientName) { + for (const sa of Object.values(b.stationAssignments)) { + const caIdx = sa.clientAssignments.findIndex( + (c) => c.clientName === el.clientName, + ); + if (caIdx >= 0) sa.clientAssignments.splice(caIdx, 1); + } + } + } + b.floors.splice(idx, 1); + } + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), + + // --- Floor Background --- + // Background images are stored as base64 data URIs directly in the YAML + // manifest so that bridge configs remain fully portable as single files. + uploadFloorBackground: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + floorId: z.string(), + file: z.instanceof(File), + widthPixels: z.number(), + heightPixels: z.number(), + }), + ) + .send(async ({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const floor = b.floors.find((f) => f.id === input.floorId); + if (!floor) throw new Error("Floor not found"); + + const arrayBuffer = await input.file.arrayBuffer(); + const base64 = Buffer.from(arrayBuffer).toString("base64"); + const mimeType = input.file.type || "image/png"; + floor.backgroundUrl = `data:${mimeType};base64,${base64}`; + floor.widthPixels = input.widthPixels; + floor.heightPixels = input.heightPixels; + + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), + removeFloorBackground: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + floorId: z.string(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const floor = b.floors.find((f) => f.id === input.floorId); + if (!floor) throw new Error("Floor not found"); + + floor.backgroundUrl = ""; + floor.widthPixels = 800; + floor.heightPixels = 800; + + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), + // --- Map Elements --- + addElement: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + floorId: z.string(), + type: elementTypeEnum, + x: z.number(), + y: z.number(), + widthPixels: z.number().optional(), + heightPixels: z.number().optional(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const floor = b.floors.find((f) => f.id === input.floorId); + if (!floor) throw new Error("Floor not found"); + + const element: Record = { + id: crypto.randomUUID(), + type: input.type, + x: input.x, + y: input.y, + rotation: 0, + }; + + if (input.widthPixels !== undefined) + element.widthPixels = input.widthPixels; + if (input.heightPixels !== undefined) + element.heightPixels = input.heightPixels; + + // Auto-assign a default client name + const existingClientNames = b.floors.flatMap((f) => + f.elements.filter((e) => e.clientName).map((e) => e.clientName!), + ); + element.clientName = generateIncrementedName( + "Client", + existingClientNames, + ); + + // Auto-create a BridgeViewscreen when placing a viewscreen element + if (input.type === "viewscreen") { + const vsName = generateIncrementedName( + "Viewscreen", + b.viewscreens.map((v) => v.name), + ); + const viewscreen = { + id: crypto.randomUUID(), + name: vsName, + tags: [], + defaultPose: null, + showGizmos: true, + showLayout: true, + brokenMode: "fullyBroken" as const, + }; + b.viewscreens.push(viewscreen); + element.viewscreenId = viewscreen.id; + } + + floor.elements.push(element as any); + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + return { elementId: element.id as string }; + }), + updateElement: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + floorId: z.string(), + elementId: z.string(), + x: z.number().optional(), + y: z.number().optional(), + widthPixels: z.number().nullable().optional(), + heightPixels: z.number().nullable().optional(), + rotation: z.number().optional(), + pitch: z.number().optional(), + label: z.string().optional(), + viewscreenId: z.string().optional(), + stationName: z.string().optional(), + clientName: z.string().optional(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const floor = b.floors.find((f) => f.id === input.floorId); + if (!floor) throw new Error("Floor not found"); + const el = floor.elements.find((e) => e.id === input.elementId); + if (!el) throw new Error("Element not found"); + if (typeof input.x === "number") el.x = input.x; + if (typeof input.y === "number") el.y = input.y; + if (input.widthPixels === null) el.widthPixels = undefined; + else if (typeof input.widthPixels === "number") + el.widthPixels = input.widthPixels; + if (input.heightPixels === null) el.heightPixels = undefined; + else if (typeof input.heightPixels === "number") + el.heightPixels = input.heightPixels; + if (typeof input.rotation === "number") el.rotation = input.rotation; + if (typeof input.pitch === "number" && el.type === "viewscreen") + el.pitch = input.pitch; + if (typeof input.label === "string") el.label = input.label; + if (typeof input.viewscreenId === "string" && el.type === "viewscreen") + el.viewscreenId = input.viewscreenId; + if (typeof input.stationName === "string" && el.type === "station") { + el.stationName = input.stationName; + el.label = input.stationName || ""; + // Persist element-station mapping for the active complement + const activeKey = complementKey(b.stationComplementRef); + if (activeKey) { + if (!b.stationAssignments[activeKey]) { + b.stationAssignments[activeKey] = { + clientAssignments: [], + elementStations: {}, + }; + } + if (input.stationName) { + b.stationAssignments[activeKey].elementStations[el.id] = input.stationName; + } else { + delete b.stationAssignments[activeKey].elementStations[el.id]; + } + } + } + if (typeof input.clientName === "string") { + const oldClientName = el.clientName; + el.clientName = input.clientName; + // Propagate clientName rename across all complement assignments + if (oldClientName && oldClientName !== input.clientName) { + for (const sa of Object.values(b.stationAssignments)) { + for (const ca of sa.clientAssignments) { + if (ca.clientName === oldClientName) { + ca.clientName = input.clientName; + } + } + } + } + } + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), + removeElement: t.procedure + .input( + z.object({ + pluginId: z.string(), + bridgeId: z.string(), + floorId: z.string(), + elementId: z.string(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const plugin = getPlugin(ctx, input.pluginId); + const b = plugin.aspects.bridges.find((b) => b.name === input.bridgeId); + if (!b) throw new Error("Bridge not found"); + const floor = b.floors.find((f) => f.id === input.floorId); + if (!floor) throw new Error("Floor not found"); + const idx = floor.elements.findIndex((e) => e.id === input.elementId); + if (idx >= 0) { + const removed = floor.elements[idx]; + // Auto-remove linked BridgeViewscreen when removing a viewscreen element + if (removed.type === "viewscreen" && removed.viewscreenId) { + const vsIdx = b.viewscreens.findIndex( + (v) => v.id === removed.viewscreenId, + ); + if (vsIdx >= 0) b.viewscreens.splice(vsIdx, 1); + } + // Clean up elementStations for this element across all complements + for (const sa of Object.values(b.stationAssignments)) { + delete sa.elementStations[removed.id]; + } + floor.elements.splice(idx, 1); + } + pubsub.publish.plugin.bridge.get({ + pluginId: input.pluginId, + bridgeId: b.name, + }); + }), +}); diff --git a/app/.server/data/plugins/index.ts b/app/.server/data/plugins/index.ts index fbd9ce75..796de700 100644 --- a/app/.server/data/plugins/index.ts +++ b/app/.server/data/plugins/index.ts @@ -12,6 +12,7 @@ import { theme } from "./themes"; import { inventory } from "./inventory"; import { getPlugin } from "./utils"; import { macro } from "@thorium/.server/data/plugins/macro"; +import { bridge } from "@thorium/.server/data/plugins/bridge"; export function publish(pluginId: string) { pubsub.publish.plugin.all(); @@ -26,6 +27,7 @@ export const plugin = t.router({ systems, starmap, inventory, + bridge, all: t.procedure.request(({ ctx }) => { return ctx.server.plugins; }), diff --git a/app/.server/data/stations.ts b/app/.server/data/stations.ts index b89ba320..7c1f3309 100644 --- a/app/.server/data/stations.ts +++ b/app/.server/data/stations.ts @@ -20,8 +20,15 @@ export const station = t.router({ .flightClient; const ship = ctx.getPlayerShip(input.clientId); if (flightClient?.stationOverride) return flightClient.stationOverride; - const stations = ship?.components.stationComplement?.stations || []; - for (const staticStation of staticStations) { + const complementStations = ship?.components.stationComplement?.stations || []; + const hasViewscreenStations = complementStations.some( + (s) => s.cards.some((c) => c.component === "Viewscreen"), + ); + const filteredStatic = hasViewscreenStations + ? staticStations.filter((s) => s.name !== "Viewscreen") + : staticStations; + const stations = [...complementStations]; + for (const staticStation of filteredStatic) { stations.push({ cards: staticStation.cards, description: "", diff --git a/app/.server/init/bridgeAutoAssign.ts b/app/.server/init/bridgeAutoAssign.ts new file mode 100644 index 00000000..748aba1d --- /dev/null +++ b/app/.server/init/bridgeAutoAssign.ts @@ -0,0 +1,28 @@ +import type { DataContext } from "@thorium/.server/DataContext"; + +/** Claim a pre-generated bridge flightClient entity for a connecting client. Returns true if claimed. Callers are responsible for publishing. */ +export function claimBridgeFlightClient( + ctx: DataContext, + clientId: string, +): boolean { + if (!ctx.flight) return false; + const client = ctx.server.clients[clientId]; + if (!client) return false; + const clientName = client.name.toLowerCase(); + + for (const entity of ctx.flight.ecs.componentCache.get("flightClient") || + []) { + const fc = entity.components.flightClient; + if ( + !fc || + !fc.bridgeAssigned || + fc.clientId !== "" || + fc.expectedClientName.toLowerCase() !== clientName + ) + continue; + entity.updateComponent("flightClient", { clientId }); + ctx.flight.flightClientIndex.set(clientId, entity.id); + return true; + } + return false; +} diff --git a/app/.server/init/liveQuery.ts b/app/.server/init/liveQuery.ts index b9d43ecd..18283733 100644 --- a/app/.server/init/liveQuery.ts +++ b/app/.server/init/liveQuery.ts @@ -12,6 +12,7 @@ import type { } from "@thorium/utils/live-query/.server/adapters/hono-adapter"; import { ServerClient } from "@thorium/utils/live-query/.server/ServerClient"; import { router } from "@thorium/.server/init/router"; +import { claimBridgeFlightClient } from "@thorium/.server/init/bridgeAutoAssign"; import z from "zod"; import type { ClientSettings } from "@thorium/.server/data"; @@ -155,6 +156,11 @@ export class Client extends ServerClient { return { id, name, settings }; } connectionOpened(): void { + // Auto-assign if this client's name matches a bridge assignment + const ctx = getDataContext(this.id); + if (ctx?.flight) { + claimBridgeFlightClient(ctx, this.id); + } pubsub.publish.client.get({ clientId: this.id }); pubsub.publish.client.all(); } diff --git a/app/.server/spawners/flight.ts b/app/.server/spawners/flight.ts index 4b3238d6..63916758 100644 --- a/app/.server/spawners/flight.ts +++ b/app/.server/spawners/flight.ts @@ -8,6 +8,11 @@ import { Vector3 } from "three"; import { getOrbitPosition } from "@thorium/utils/starmap/getOrbitPosition"; import { spawnShip } from "@thorium/.server/spawners/ship"; import type BasePlugin from "@thorium/.server/classes/Plugins"; +import type BridgePlugin from "@thorium/.server/classes/Plugins/Bridge"; +import { + type BridgeMapViewscreen, + complementKey, +} from "@thorium/.server/classes/Plugins/Bridge"; import type StationComplementPlugin from "@thorium/.server/classes/Plugins/StationComplement"; import { triggerAction } from "@thorium/utils/.server/triggerAction"; import { executeBlocks } from "@thorium/utils/.server/executeBlocks"; @@ -15,6 +20,7 @@ import { pubsub } from "@thorium/.server/init/pubsub"; import type { DataContext } from "@thorium/.server/DataContext"; import { calculateShipMapPath } from "@thorium/utils/.server/ship/shipMapPathfinder"; import { generateSatelliteGraph } from "@thorium/cards/LongRangeComm/data.server"; +import { claimBridgeFlightClient } from "@thorium/.server/init/bridgeAutoAssign"; const flightStartShips = z .array( @@ -22,6 +28,9 @@ const flightStartShips = z crewCount: z.number(), shipName: z.string(), theme: z.object({ pluginId: z.string(), themeId: z.string() }).optional(), + bridge: z + .object({ pluginId: z.string(), bridgeId: z.string() }) + .optional(), shipTemplate: z.object({ pluginId: z.string(), shipId: z.string(), @@ -194,7 +203,6 @@ export async function startFlight( if (theme) { shipEntity.addComponent("theme", theme); } - // First see if there is a station complement // that matches the specific one that was passed in const stationComplement = getStationComplement(mode, activePlugins, ship); @@ -204,7 +212,219 @@ export async function startFlight( }); ctx.flight.ecs.addEntity(shipEntity); + + // Create a single parent "Viewscreens" system entity that owns the shared damage + const viewscreenSystemEntity = new Entity(); + viewscreenSystemEntity.addComponent("identity", { name: "Viewscreens" }); + viewscreenSystemEntity.addComponent("isShipSystem", { + type: "generic", + shipId: shipEntity.id, + }); + viewscreenSystemEntity.addComponent("damage", { + vulnerability: "invulnerable", + }); + ctx.flight.ecs.addEntity(viewscreenSystemEntity); + shipEntity.components.shipSystems?.shipSystems.set( + viewscreenSystemEntity.id, + {}, + ); + + // Spawn viewscreen entities from bridge config, or a default if no bridge + const bridgeConfig = ship.bridge + ? activePlugins.reduce((acc: BridgePlugin | null, plugin) => { + if (acc || plugin.id !== ship.bridge!.pluginId) return acc; + return ( + plugin.aspects.bridges.find( + (b) => b.name === ship.bridge!.bridgeId, + ) || null + ); + }, null) + : null; + + if (bridgeConfig) { + // Resolve client-to-station assignments for the active complement + // using the per-complement elementStations mapping. + const key = complementKey( + stationComplement + ? { + pluginId: stationComplement.pluginName, + stationComplementId: stationComplement.name, + } + : undefined, + ); + const elementStations = key + ? bridgeConfig.stationAssignments[key]?.elementStations + : undefined; + const derived: Array<{ + clientName: string; + stationId: string; + isSoundPlayer: boolean; + }> = []; + if (elementStations) { + for (const floor of bridgeConfig.floors) { + for (const el of floor.elements) { + if (!el.clientName) continue; + let stationId: string | undefined; + if (el.type === "station") { + stationId = elementStations[el.id]; + } else if (el.type === "viewscreen" && el.viewscreenId) { + const vs = bridgeConfig.viewscreens.find( + (v) => v.id === el.viewscreenId, + ); + if (vs) stationId = vs.name; + } + if (stationId) { + derived.push({ + clientName: el.clientName, + stationId, + isSoundPlayer: false, + }); + } + } + } + } + shipEntity.addComponent("shipBridge", { + clientAssignments: derived, + }); + } + + const viewscreenStations: Array<{ + name: string; + description: string; + logo: string; + theme: string; + tags: string[]; + cards: Array<{ name: string; component: string }>; + widgets: Array<{ name: string; component: string }>; + messageGroups: string[]; + }> = []; + + if (bridgeConfig) { + // Collect viewscreen elements with their config + const viewscreenPairs: Array<{ + vs: (typeof bridgeConfig.viewscreens)[number]; + element: BridgeMapViewscreen; + }> = []; + for (const floor of bridgeConfig.floors) { + for (const element of floor.elements) { + if (element.type !== "viewscreen" || !element.viewscreenId) continue; + const vs = bridgeConfig.viewscreens.find( + (v) => v.id === element.viewscreenId, + ); + if (!vs) continue; + viewscreenPairs.push({ vs, element }); + } + } + + for (let i = 0; i < viewscreenPairs.length; i++) { + const { vs, element } = viewscreenPairs[i]; + const isMain = vs.isMainViewscreen ?? false; + const name = vs.name; + + const viewscreenEntity = new Entity(); + const brokenMode = vs.brokenMode ?? "fullyBroken"; + viewscreenEntity.addComponent("isViewscreen", { + shipId: shipEntity.id, + name, + tags: + isMain && !vs.tags.includes("main-viewscreen") + ? [...vs.tags, "main-viewscreen"] + : vs.tags, + cameraYaw: element.rotation, + cameraPitch: element.pitch ?? 0, + cameraFov: vs.fov ?? 45, + showGizmos: vs.showGizmos ?? true, + showLayout: vs.showLayout ?? true, + brokenMode, + camerasOffline: false, + viewscreenSystemId: viewscreenSystemEntity.id, + }); + viewscreenEntity.addComponent("identity", { name }); + ctx.flight.ecs.addEntity(viewscreenEntity); + + viewscreenStations.push({ + name, + description: "", + logo: "", + theme: "Default", + tags: vs.tags, + cards: [{ name: "Viewscreen", component: "Viewscreen" }], + widgets: [], + messageGroups: [], + }); + } + } else { + // No bridge configured — spawn a default forward-facing main viewscreen. + // Name must match the static "Viewscreen" station so viewscreenConfig can find it. + const defaultViewscreen = new Entity(); + defaultViewscreen.addComponent("isViewscreen", { + shipId: shipEntity.id, + name: "Viewscreen", + tags: ["main-viewscreen"], + cameraYaw: 0, + cameraPitch: 0, + cameraFov: 45, + showGizmos: true, + showLayout: true, + brokenMode: "fullyBroken", + camerasOffline: false, + viewscreenSystemId: viewscreenSystemEntity.id, + }); + defaultViewscreen.addComponent("identity", { name: "Viewscreen" }); + ctx.flight.ecs.addEntity(defaultViewscreen); + + viewscreenStations.push({ + name: "Viewscreen", + description: "", + logo: "", + theme: "Default", + tags: ["main-viewscreen"], + cards: [{ name: "Viewscreen", component: "Viewscreen" }], + widgets: [], + messageGroups: [], + }); + } + + if (viewscreenStations.length > 0) { + const existing = shipEntity.components.stationComplement?.stations || []; + shipEntity.updateComponent("stationComplement", { + stations: [...existing, ...viewscreenStations], + }); + } + } + // Pre-generate bridge flightClient entities + const playerShipCount = + ctx.flight.ecs.componentCache.get("isPlayerShip")?.size ?? 0; + for (const ship of ctx.flight.ecs.componentCache.get("shipBridge") || []) { + const bridge = ship.components.shipBridge; + if (!bridge) continue; + const shipName = ship.components.identity?.name || ""; + for (const assignment of bridge.clientAssignments) { + const expectedName = + playerShipCount > 1 + ? `${shipName}-${assignment.clientName}` + : assignment.clientName; + const entity = new Entity(); + entity.addComponent("flightClient", { + clientId: "", + expectedClientName: expectedName, + flightId: ctx.flight.name, + shipId: ship.id, + stationId: assignment.stationId, + bridgeAssigned: true, + isSoundPlayer: assignment.isSoundPlayer, + }); + ctx.flight.ecs.addEntity(entity); + } } + + // Claim pre-generated entities for already-connected clients + for (const id of Object.keys(ctx.server.clients)) { + if (ctx.server.clients[id].connected) { + claimBridgeFlightClient(ctx, id); + } + } + // Add the mission if it exists if (missionId) { triggerAction("timeline.activate", { diff --git a/app/.server/systems/DamageCheckSystem.ts b/app/.server/systems/DamageCheckSystem.ts index ab341851..7f000111 100644 --- a/app/.server/systems/DamageCheckSystem.ts +++ b/app/.server/systems/DamageCheckSystem.ts @@ -15,18 +15,19 @@ export class DamageCheckSystem extends System { // If a component is online, check if it should be taken offline if (damageComponent) { + if (damageComponent.vulnerability === "invulnerable") return; const aggregateDamage = getAggregateDamage(entity); if (damageComponent.offline) { if (damageComponent.onlineDamage < aggregateDamage) { return; } // Bring the system back online - damageComponent.offline = false; + entity.updateComponent("damage", { offline: false }, true); } else { if (damageComponent.offlineDamage > aggregateDamage) { return; } - damageComponent.offline = true; + entity.updateComponent("damage", { offline: true }, true); this.checkIfCascadeOccurs(entity, elapsed); } diff --git a/app/cards/Legacy/SensorGrid/index.tsx b/app/cards/Legacy/SensorGrid/index.tsx index 2dd557db..b6527852 100644 --- a/app/cards/Legacy/SensorGrid/index.tsx +++ b/app/cards/Legacy/SensorGrid/index.tsx @@ -18,7 +18,7 @@ export function LegacySensorGrid() { const { isWidget } = useCardContext(); const layout = - station.name === "Viewscreen" + station.cards.some((c) => c.component === "Viewscreen") ? "viewscreen" : isWidget || station.cards.some((c) => c.component === "LegacySensorScans") diff --git a/app/cards/Viewscreen/NoSignal.tsx b/app/cards/Viewscreen/NoSignal.tsx new file mode 100644 index 00000000..c490b5d5 --- /dev/null +++ b/app/cards/Viewscreen/NoSignal.tsx @@ -0,0 +1,5 @@ +export function NoSignal() { + return ( +
+ ); +} diff --git a/app/cards/Viewscreen/data.server.ts b/app/cards/Viewscreen/data.server.ts index b266a39f..a577e055 100644 --- a/app/cards/Viewscreen/data.server.ts +++ b/app/cards/Viewscreen/data.server.ts @@ -1,4 +1,5 @@ import { t } from "@thorium/.server/init/t"; +import { pubsub } from "@thorium/.server/init/pubsub"; import z from "zod"; export const viewscreen = t.router({ @@ -18,6 +19,168 @@ export const viewscreen = t.router({ skyboxKey: system.components.isSolarSystem?.skyboxKey, }; }), + viewscreenConfig: t.procedure + .input(z.object({ clientId: z.string() })) + .autoPublish(["isViewscreen", "damage"], () => null) + .request(({ ctx, input }) => { + const flightClient = ctx.getFlightClient(input.clientId)?.components + .flightClient; + if (!flightClient?.shipId || !flightClient?.stationId) return null; + + const viewscreenEntities = + ctx.flight?.ecs.componentCache.get("isViewscreen"); + if (!viewscreenEntities) return null; + + for (const entity of viewscreenEntities) { + const vs = entity.components.isViewscreen; + if ( + vs && + vs.shipId === flightClient.shipId && + vs.name === flightClient.stationId + ) { + const parentEntity = vs.viewscreenSystemId + ? ctx.flight?.ecs.getEntityById(vs.viewscreenSystemId) + : undefined; + const damageBroken = + vs.brokenMode === "invincible" + ? false + : (parentEntity?.components.damage?.offline ?? false); + + return { + cameraYaw: vs.cameraYaw, + cameraPitch: vs.cameraPitch, + cameraFov: vs.cameraFov, + showGizmos: vs.showGizmos, + showLayout: vs.showLayout, + isMainViewscreen: vs.tags.includes("main-viewscreen"), + name: vs.name, + brokenMode: vs.brokenMode, + camerasOffline: vs.camerasOffline, + damageBroken, + }; + } + } + return null; + }), + allViewscreens: t.procedure + .input(z.object({ shipId: z.number() })) + .filter((publish: { shipId: number } | null, { input }) => { + if (publish && publish.shipId !== input.shipId) return false; + return true; + }) + .request(({ ctx, input }) => { + const viewscreenEntities = + ctx.flight?.ecs.componentCache.get("isViewscreen"); + if (!viewscreenEntities) + return { viewscreens: [], viewscreenSystemOffline: false }; + + let viewscreenSystemOffline = false; + const results: Array<{ + entityId: number; + name: string; + camerasOffline: boolean; + damageBroken: boolean; + brokenMode: "fullyBroken" | "cameraBrokenOnly" | "invincible"; + }> = []; + for (const entity of viewscreenEntities) { + const vs = entity.components.isViewscreen; + if (vs && vs.shipId === input.shipId) { + if (!viewscreenSystemOffline && vs.viewscreenSystemId) { + const parentEntity = ctx.flight?.ecs.getEntityById( + vs.viewscreenSystemId, + ); + viewscreenSystemOffline = + parentEntity?.components.damage?.offline ?? false; + } + results.push({ + entityId: entity.id, + name: vs.name, + camerasOffline: vs.camerasOffline, + damageBroken: + vs.brokenMode === "invincible" ? false : viewscreenSystemOffline, + brokenMode: vs.brokenMode, + }); + } + } + return { viewscreens: results, viewscreenSystemOffline }; + }), + setCamerasOffline: t.procedure + .input( + z.object({ + entityId: z.number(), + camerasOffline: z.boolean(), + }), + ) + .send(({ ctx, input }) => { + const entity = ctx.flight?.ecs.getEntityById(input.entityId); + if (!entity?.components.isViewscreen) return; + entity.updateComponent( + "isViewscreen", + { + camerasOffline: input.camerasOffline, + }, + true, + ); + pubsub.publish.viewscreen.allViewscreens({ + shipId: entity.components.isViewscreen.shipId, + }); + }), + setAllCamerasOffline: t.procedure + .input( + z.object({ + shipId: z.number(), + camerasOffline: z.boolean(), + }), + ) + .send(({ ctx, input }) => { + const viewscreenEntities = + ctx.flight?.ecs.componentCache.get("isViewscreen"); + if (!viewscreenEntities) return; + for (const entity of viewscreenEntities) { + const vs = entity.components.isViewscreen; + if (vs && vs.shipId === input.shipId) { + entity.updateComponent( + "isViewscreen", + { + camerasOffline: input.camerasOffline, + }, + true, + ); + } + } + pubsub.publish.viewscreen.allViewscreens({ shipId: input.shipId }); + }), + // TODO: This is a temporary endpoint for testing viewscreen damage states. + // It will be removed when a comprehensive damage control dashboard is built + // that lets the FD manipulate damage on any ship system, not just viewscreens. + simulateDamage: t.procedure + .input( + z.object({ + shipId: z.number(), + offline: z.boolean(), + }), + ) + .send(({ ctx, input }) => { + const viewscreenEntities = + ctx.flight?.ecs.componentCache.get("isViewscreen"); + if (!viewscreenEntities) return; + + // Find the parent system entity from any viewscreen on this ship + let parentEntity: + | ReturnType["ecs"]["getEntityById"]> + | undefined; + for (const entity of viewscreenEntities) { + const vs = entity.components.isViewscreen; + if (vs && vs.shipId === input.shipId && vs.viewscreenSystemId) { + parentEntity = ctx.flight?.ecs.getEntityById(vs.viewscreenSystemId); + break; + } + } + if (!parentEntity?.components.damage) return; + + parentEntity.updateComponent("damage", { offline: input.offline }, true); + pubsub.publish.viewscreen.allViewscreens({ shipId: input.shipId }); + }), stream: t.procedure .input(z.object({ shipId: z.number() })) .dataStream(({ ctx, input, entity }) => { diff --git a/app/cards/Viewscreen/index.tsx b/app/cards/Viewscreen/index.tsx index 24383814..46edadd1 100644 --- a/app/cards/Viewscreen/index.tsx +++ b/app/cards/Viewscreen/index.tsx @@ -8,18 +8,21 @@ import { InterstellarWrapper, SolarSystemWrapper, } from "@thorium/cores/StarmapCore"; -import { Suspense, useEffect, useState } from "react"; -import { Quaternion } from "three"; +import { Suspense, useEffect, useMemo, useState } from "react"; +import * as THREE from "three"; +import { Quaternion, Vector3 } from "three"; import { Fuzz } from "./Fuzz"; import { WarpStars } from "./WarpStars"; import { CircleGridStoreProvider } from "@thorium/cards/Pilot/useCircleGridStore"; import { useStation } from "@thorium/routes/station/useStation"; import { Gizmos } from "./gizmos"; +import { NoSignal } from "./NoSignal"; const forwardQuaternion = new Quaternion(0, 1, 0, 0); function ViewscreenEffects({ onDone }: { onDone: () => void }) { const [viewscreenSystem] = q.viewscreen.system.useNetRequest({ clientId }); + const [vsConfig] = q.viewscreen.viewscreenConfig.useNetRequest({ clientId }); const { shipId } = useStation(); const { interpolate } = useLiveQuery(); @@ -37,9 +40,35 @@ function ViewscreenEffects({ onDone }: { onDone: () => void }) { }); }, [viewscreenSystem?.skyboxKey, useStarmapStore]); + // Precompute the camera offset quaternion; recomputes only when yaw/pitch change + const cameraOffsetQuat = useMemo(() => { + if (!vsConfig?.cameraYaw && !vsConfig?.cameraPitch) return null; + const offset = new Quaternion(); + // Negate yaw because Three.js Y-axis rotation is counterclockwise, + // but positive yaw should mean starboard (right) in the UI + if (vsConfig.cameraYaw) { + offset.multiply( + new Quaternion().setFromAxisAngle( + new Vector3(0, 1, 0), + (-vsConfig.cameraYaw * Math.PI) / 180, + ), + ); + } + if (vsConfig.cameraPitch) { + offset.multiply( + new Quaternion().setFromAxisAngle( + new Vector3(1, 0, 0), + (vsConfig.cameraPitch * Math.PI) / 180, + ), + ); + } + return offset; + }, [vsConfig?.cameraYaw, vsConfig?.cameraPitch]); + useEffect(() => { onDone(); }); + const cameraFov = vsConfig?.cameraFov ?? 45; useFrame(({ camera }) => { const position = interpolate(shipId); if (!position) return; @@ -48,6 +77,13 @@ function ViewscreenEffects({ onDone }: { onDone: () => void }) { camera.quaternion .set(position.r.x, position.r.y, position.r.z, position.r.w) .multiply(forwardQuaternion); + if (cameraOffsetQuat) { + camera.quaternion.multiply(cameraOffsetQuat); + } + if ((camera as THREE.PerspectiveCamera).fov !== cameraFov) { + (camera as THREE.PerspectiveCamera).fov = cameraFov; + (camera as THREE.PerspectiveCamera).updateProjectionMatrix(); + } }); return null; @@ -57,36 +93,49 @@ export function Viewscreen() { const useStarmapStore = useGetStarmapStore(); const currentSystem = useStarmapStore((store) => store.currentSystem); const [initialized, setInitialized] = useState(false); + const [vsConfig] = q.viewscreen.viewscreenConfig.useNetRequest({ clientId }); const { shipId } = useStation(); q.viewscreen.stream.useDataStream({ shipId }); + // FD manual override — always kills camera, never affects gizmos + const isCameraOffline = vsConfig?.camerasOffline; + // Damage system — what breaks depends on brokenMode + const damageBroken = vsConfig?.damageBroken; + const showCamera = !isCameraOffline && !damageBroken; + const showGizmos = vsConfig?.showGizmos !== false && + !(damageBroken && vsConfig?.brokenMode === "fullyBroken"); + return ( -
- - - setInitialized(true)} /> - {initialized ? ( - <> - - - - - - - - - - - {currentSystem === null ? ( - - ) : ( - - )} - - ) : null} - - - +
+ {showCamera ? ( + + + setInitialized(true)} /> + {initialized ? ( + <> + + + + + + + + + + + {currentSystem === null ? ( + + ) : ( + + )} + + ) : null} + + + ) : ( + + )} + {showGizmos && }
); } diff --git a/app/components/Station/CardArea.tsx b/app/components/Station/CardArea.tsx index 86a81a04..fb0883af 100644 --- a/app/components/Station/CardArea.tsx +++ b/app/components/Station/CardArea.tsx @@ -42,7 +42,7 @@ export const CardArea: React.FC<{ return (
c.component === "Viewscreen")} className="w-full h-full absolute card-transition" > @@ -77,7 +77,7 @@ const CardRenderer = ({ const [client] = q.client.get.useNetRequest({ clientId }); const [station] = q.station.get.useNetRequest({ clientId }); const allowCard = - (station.name === "Viewscreen" || Boolean(client.loginName)) && + (station.cards.some(c => c.component === "Viewscreen") || Boolean(client.loginName)) && !client.offlineState; const show = allowCard && currentCardId === id; const [cardLoaded, setCardLoaded] = useState(show); diff --git a/app/components/Station/StationLayout.tsx b/app/components/Station/StationLayout.tsx index ad5e89b8..5addbdc3 100644 --- a/app/components/Station/StationLayout.tsx +++ b/app/components/Station/StationLayout.tsx @@ -17,6 +17,9 @@ const StationLayout = () => { const { client, station, ship } = useStation(); const [theme] = q.theme.get.useNetRequest({ clientId }); const [card, changeCard] = useManageCard(); + const isViewscreen = station.cards.some(c => c.component === "Viewscreen"); + const [vsConfig] = q.viewscreen.viewscreenConfig.useNetRequest({ clientId }); + const showLayout = !isViewscreen || (vsConfig?.showLayout !== false); const { account } = useThoriumAccount(); if (!ship) return null; @@ -24,7 +27,7 @@ const StationLayout = () => { return (
{
-
+ {showLayout &&
{ship.name}
{ship.assets?.logo && (
@@ -93,7 +96,7 @@ const StationLayout = () => {
-
+
}
diff --git a/app/components/ui/Icon.tsx b/app/components/ui/Icon.tsx index 46e48fc1..0be0690e 100644 --- a/app/components/ui/Icon.tsx +++ b/app/components/ui/Icon.tsx @@ -36,6 +36,39 @@ const childrenSizeClassName = { * you need to wrap the icon and text in a common parent and set the parent to * display "flex" (or "inline-flex") with "items-center" and a reasonable gap. */ +/** Icon for use inside an SVG context. Renders a nested `` with a `` reference. */ +export function SvgIcon({ + name, + x, + y, + width, + height, + ...props +}: Omit, "viewBox" | "fill"> & { + name: IconName; + x: number; + y: number; + width: number; + height: number; +}) { + return ( + + + + ); +} + export function Icon({ name, size = "font", diff --git a/app/components/ui/Select.tsx b/app/components/ui/Select.tsx index 495df27e..cf4b6ce8 100644 --- a/app/components/ui/Select.tsx +++ b/app/components/ui/Select.tsx @@ -35,8 +35,8 @@ export default function Select({ labelHidden?: boolean; disabled?: boolean; items: ( - | { id: I; label: string } - | { header: string; items: { id: I; label: string }[] } + | { id: I; label: string; disabled?: boolean } + | { header: string; items: { id: I; label: string; disabled?: boolean }[] } )[]; selected: I | null; setSelected: (value: I | null) => void; @@ -70,7 +70,7 @@ export default function Select({
); diff --git a/app/routes/config/bridges/bridge.tsx b/app/routes/config/bridges/bridge.tsx new file mode 100644 index 00000000..cc2fe852 --- /dev/null +++ b/app/routes/config/bridges/bridge.tsx @@ -0,0 +1,491 @@ +import { useConfirm, usePrompt } from "@thorium/ui/AlertDialog"; +import { useParams, useNavigate, Navigate } from "react-router"; +import Button from "@thorium/ui/Button"; +import { toast } from "@thorium/context/ToastContext"; +import { useState, useRef } from "react"; +import Input from "@thorium/ui/Input"; +import Select from "@thorium/ui/Select"; +import { q } from "@thorium/context/AppContext"; +import { MapCanvas } from "./mapEditor/MapCanvas"; +import type { + BridgeClientAssignment, + BridgeViewscreen, + BridgeFloor, +} from "@thorium/.server/classes/Plugins/Bridge"; + +interface BridgeData { + name: string; + description: string; + stationComplementRef?: { pluginId: string; stationComplementId: string }; + clientAssignments: BridgeClientAssignment[]; + viewscreens: BridgeViewscreen[]; + elementScale?: number; + floors: BridgeFloor[]; +} + +type Tab = "details" | "map"; + +const tabs: { id: Tab; label: string }[] = [ + { id: "details", label: "Details" }, + { id: "map", label: "Map" }, +]; + +export default function BridgeDetail() { + const { bridgeId, pluginId } = useParams() as { + bridgeId: string; + pluginId: string; + }; + const navigate = useNavigate(); + const confirm = useConfirm(); + const [activeTab, setActiveTab] = useState("details"); + + const [rawItem] = q.plugin.bridge.get.useNetRequest({ + pluginId, + bridgeId, + }); + const item = rawItem as BridgeData | null; + + if (!bridgeId || !item) + return ; + + return ( +
+
+ {/* Tab bar */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Tab content */} +
+ {activeTab === "details" && ( + + )} + {activeTab === "map" && ( + + )} +
+ + {/* Delete */} +
+ +
+
+
+ ); +} + +function DetailsTab({ + pluginId, + bridgeId, + item, +}: { + pluginId: string; + bridgeId: string; + item: BridgeData; +}) { + const navigate = useNavigate(); + const [nameError, setNameError] = useState(false); + + return ( +
+ setNameError(false)} + onBlur={async (e: any) => { + if (!e.target.value) return setNameError(true); + try { + const result = await q.plugin.bridge.update.netSend({ + pluginId, + bridgeId, + name: e.target.value, + }); + navigate( + `/config/${pluginId}/bridges/${encodeURIComponent(result.bridgeId)}`, + ); + } catch (err) { + if (err instanceof Error) { + toast({ + title: "Error renaming bridge", + body: err.message, + color: "error", + }); + } + } + }} + /> + + q.plugin.bridge.update.netSend({ + pluginId, + bridgeId, + description: e.target.value, + }) + } + /> +
+ ); +} + +function MapTab({ + pluginId, + bridgeId, + item, +}: { + pluginId: string; + bridgeId: string; + item: BridgeData; +}) { + const prompt = usePrompt(); + const confirm = useConfirm(); + const [activeFloorId, setActiveFloorId] = useState( + item.floors[0]?.id ?? null, + ); + const activeFloor = item.floors.find((f) => f.id === activeFloorId) ?? null; + + const [complements] = q.plugin.bridge.allStationComplements.useNetRequest({ + pluginId, + }); + const complementGroups = (complements ?? []) as { + header: string; + items: { id: string; label: string }[]; + }[]; + const selectedComplement = item.stationComplementRef + ? `${item.stationComplementRef.pluginId}:${item.stationComplementRef.stationComplementId}` + : "__none__"; + + const [stationNames] = + q.plugin.bridge.getStationComplementStations.useNetRequest({ + pluginId, + bridgeId, + }); + const stations = ((stationNames ?? []) as string[]) + .slice() + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + + // Collect all station names that have been assigned to a client element across all floors + const assignedStations = new Set(); + for (const floor of item.floors) { + for (const el of floor.elements) { + if (el.type === "station" && el.stationName) { + assignedStations.add(el.stationName); + } + } + } + + const effectiveScale = + item.elementScale ?? (activeFloor?.widthPixels ?? 800) * 0.075; + + const fileInputRef = useRef(null); + + async function handleBackgroundUpload(file: File) { + if (!activeFloor) return; + + const dimensions = await getImageDimensions(file); + + await q.plugin.bridge.uploadFloorBackground.netSend({ + pluginId, + bridgeId, + floorId: activeFloor.id, + file, + widthPixels: dimensions.width, + heightPixels: dimensions.height, + }); + } + + return ( +
+ {/* Station complement selector + station list */} +
+ { + const file = e.target.files?.[0]; + if (file) handleBackgroundUpload(file); + e.target.value = ""; + }} + /> + + {activeFloor.backgroundUrl && ( + + )} +
+ )} + + {/* Element Scale */} + {activeFloor && ( +
+ + { + const val = Number(e.target.value); + if (val > 0) { + q.plugin.bridge.update.netSend({ + pluginId, + bridgeId, + elementScale: val, + }); + } + }} + className="w-16 bg-gray-800 border border-white/20 rounded px-1 py-0.5 text-xs text-white" + /> + px +
+ )} + + {/* Canvas */} + {activeFloor ? ( + + ) : ( +
+ Add a floor to start building the bridge map. +
+ )} +
+ ); +} + +/** Read image dimensions client-side using an Image element */ +function getImageDimensions( + file: File, +): Promise<{ width: number; height: number }> { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + resolve({ width: img.naturalWidth, height: img.naturalHeight }); + URL.revokeObjectURL(url); + }; + img.onerror = () => { + // Fallback to defaults if we can't read the image + resolve({ width: 800, height: 800 }); + URL.revokeObjectURL(url); + }; + img.src = url; + }); +} diff --git a/app/routes/config/bridges/layout.tsx b/app/routes/config/bridges/layout.tsx new file mode 100644 index 00000000..b7ff3655 --- /dev/null +++ b/app/routes/config/bridges/layout.tsx @@ -0,0 +1,79 @@ +import { useMenubar } from "@thorium/ui/Menubar"; +import { useParams, Outlet, useNavigate } from "react-router"; +import { usePrompt } from "@thorium/ui/AlertDialog"; +import { q } from "@thorium/context/AppContext"; +import Button from "@thorium/ui/Button"; +import { toast } from "@thorium/context/ToastContext"; +import SearchableList from "@thorium/ui/SearchableList"; +import { Fragment } from "react"; + +export default function BridgesConfig() { + const { pluginId, bridgeId } = useParams() as { + pluginId: string; + bridgeId?: string; + }; + useMenubar({ + backTo: `/config/${pluginId}/list`, + }); + const prompt = usePrompt(); + const navigate = useNavigate(); + const [data] = q.plugin.bridge.all.useNetRequest({ pluginId }); + const bridges = Array.isArray(data) ? data : []; + + const selected = bridges.find((d) => d.name === bridgeId); + + return ( +
+

Bridges

+
+
+ + + ({ + id: d.name, + name: d.name, + description: d.description, + }))} + searchKeys={["name"]} + selectedItem={bridgeId || null} + setSelectedItem={({ id }) => navigate(encodeURIComponent(id))} + renderItem={(c) => ( +
+
{c.name}
+
+ )} + /> +
+ + + +
+
+ ); +} diff --git a/app/routes/config/bridges/mapEditor/MapCanvas.tsx b/app/routes/config/bridges/mapEditor/MapCanvas.tsx new file mode 100644 index 00000000..5424bd16 --- /dev/null +++ b/app/routes/config/bridges/mapEditor/MapCanvas.tsx @@ -0,0 +1,466 @@ +import { useState, useRef, useCallback, useEffect } from "react"; +import PanZoom from "@thorium/components/ui/PanZoom"; +import type { + BridgeFloor, + BridgeViewscreen, + BridgeClientAssignment, + BridgeMapElementType, +} from "@thorium/.server/classes/Plugins/Bridge"; +import { q } from "@thorium/context/AppContext"; +import { MapToolbar, type MapTool } from "./MapToolbar"; +import { MapElementRenderer, MapElementDefs } from "./MapElement"; +import { MapElementEditor } from "./MapElementEditor"; +import { GRID_SIZE_PX, DEFAULT_CANVAS_SIZE } from "./constants"; +import type { BridgeMapElement } from "@thorium/.server/classes/Plugins/Bridge"; + +const ROTATION_HANDLE_OFFSET = 30; + +function getElementBounds( + el: BridgeMapElement, + elementScale: number, +): { w: number; h: number } { + return { + w: el.widthPixels ?? elementScale, + h: el.heightPixels ?? elementScale, + }; +} + +interface PanState { + x: number; + y: number; + scale: number; +} + +type DragMode = "move" | "rotate" | null; + +export function MapCanvas({ + pluginId, + bridgeId, + floor, + viewscreens, + stationNames, + clientAssignments, + assignedStations, + elementScale, +}: { + pluginId: string; + bridgeId: string; + floor: BridgeFloor; + viewscreens: BridgeViewscreen[]; + stationNames: string[]; + clientAssignments: BridgeClientAssignment[]; + assignedStations: Set; + elementScale: number; +}) { + const [activeTool, setActiveTool] = useState("select"); + const panState = useRef({ x: 0, y: 0, scale: 1 }); + const svgRef = useRef(null); + const activeToolRef = useRef(activeTool); + activeToolRef.current = activeTool; + + const canvasWidth = floor.backgroundUrl + ? floor.widthPixels + : DEFAULT_CANVAS_SIZE; + const canvasHeight = floor.backgroundUrl + ? floor.heightPixels + : DEFAULT_CANVAS_SIZE; + + // Intercept wheel events: stop outer scroll and force zoom behavior + const canvasWrapperRef = useRef(null); + useEffect(() => { + const el = canvasWrapperRef.current; + if (!el) return; + const handler = (e: WheelEvent) => { + e.preventDefault(); + if (e.ctrlKey || e.metaKey) return; + e.stopImmediatePropagation(); + const zoomEvent = new WheelEvent("wheel", { + deltaX: e.deltaX, + deltaY: e.deltaY, + deltaZ: e.deltaZ, + deltaMode: e.deltaMode, + clientX: e.clientX, + clientY: e.clientY, + screenX: e.screenX, + screenY: e.screenY, + ctrlKey: true, + bubbles: false, + }); + el.firstElementChild?.dispatchEvent(zoomEvent); + }; + el.addEventListener("wheel", handler, { passive: false, capture: true }); + return () => el.removeEventListener("wheel", handler, { capture: true }); + }, []); + + // Drag state + const dragMode = useRef(null); + const dragElementId = useRef(null); + const dragStart = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const dragElementStart = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const dragStartRotation = useRef(0); + // Live drag preview — never mutate floor.elements (it's React Query cache). + const [dragPreview, setDragPreview] = useState<{ + id: string; + x: number; + y: number; + rotation: number; + } | null>(null); + + const [selectedId, setSelectedId] = useState(null); + const baseSelectedElement = floor.elements.find((e) => e.id === selectedId); + const selectedElement = + baseSelectedElement && + dragPreview && + dragPreview.id === baseSelectedElement.id + ? { + ...baseSelectedElement, + x: dragPreview.x, + y: dragPreview.y, + rotation: dragPreview.rotation, + } + : baseSelectedElement; + + const getSvgPoint = useCallback((clientX: number, clientY: number) => { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const rect = svg.getBoundingClientRect(); + const x = (clientX - rect.left) / panState.current.scale; + const y = (clientY - rect.top) / panState.current.scale; + return { x, y }; + }, []); + + const handleElementMouseDown = useCallback( + (elementId: string, e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (activeTool !== "select") { + setActiveTool("select"); + } + setSelectedId(elementId); + + const el = floor.elements.find((el) => el.id === elementId); + if (!el) return; + + dragMode.current = "move"; + dragElementId.current = elementId; + dragStart.current = getSvgPoint(e.clientX, e.clientY); + dragElementStart.current = { x: el.x, y: el.y }; + setDragPreview({ + id: elementId, + x: el.x, + y: el.y, + rotation: el.rotation, + }); + }, + [activeTool, floor.elements, getSvgPoint], + ); + + const handleRotateMouseDown = useCallback( + (e: React.MouseEvent) => { + if (!selectedElement) return; + e.stopPropagation(); + e.preventDefault(); + + dragMode.current = "rotate"; + dragElementId.current = selectedElement.id; + dragStart.current = getSvgPoint(e.clientX, e.clientY); + dragStartRotation.current = selectedElement.rotation; + dragElementStart.current = { x: selectedElement.x, y: selectedElement.y }; + setDragPreview({ + id: selectedElement.id, + x: selectedElement.x, + y: selectedElement.y, + rotation: selectedElement.rotation, + }); + }, + [selectedElement, getSvgPoint], + ); + + const handleCanvasMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!dragMode.current || !dragElementId.current) return; + const pt = getSvgPoint(e.clientX, e.clientY); + const el = floor.elements.find((el) => el.id === dragElementId.current); + if (!el) return; + + if (dragMode.current === "move") { + const dx = pt.x - dragStart.current.x; + const dy = pt.y - dragStart.current.y; + setDragPreview({ + id: el.id, + x: dragElementStart.current.x + dx, + y: dragElementStart.current.y + dy, + rotation: el.rotation, + }); + } else if (dragMode.current === "rotate") { + // Rotation pivots around the element's static position. + const angle = Math.atan2(pt.y - el.y, pt.x - el.x); + const startAngle = Math.atan2( + dragStart.current.y - el.y, + dragStart.current.x - el.x, + ); + const delta = ((angle - startAngle) * 180) / Math.PI; + setDragPreview({ + id: el.id, + x: el.x, + y: el.y, + rotation: Math.round(dragStartRotation.current + delta), + }); + } + }, + [floor.elements, getSvgPoint], + ); + + const handleCanvasMouseUp = useCallback( + async (e: React.MouseEvent) => { + // Finish element drag + if (dragMode.current && dragElementId.current) { + const elementId = dragElementId.current; + const preview = dragPreview; + dragMode.current = null; + dragElementId.current = null; + setDragPreview(null); + if (preview) { + await q.plugin.bridge.updateElement.netSend({ + pluginId, + bridgeId, + floorId: floor.id, + elementId, + x: preview.x, + y: preview.y, + rotation: preview.rotation, + }); + } + return; + } + + // Place discrete element + if (activeTool !== "select") { + const pt = getSvgPoint(e.clientX, e.clientY); + await q.plugin.bridge.addElement.netSend({ + pluginId, + bridgeId, + floorId: floor.id, + type: activeTool as BridgeMapElementType, + x: pt.x, + y: pt.y, + }); + } + }, + [activeTool, pluginId, bridgeId, floor.id, getSvgPoint, dragPreview], + ); + + const handleDeleteElement = useCallback(async () => { + if (!selectedId) return; + await q.plugin.bridge.removeElement.netSend({ + pluginId, + bridgeId, + floorId: floor.id, + elementId: selectedId, + }); + setSelectedId(null); + }, [selectedId, pluginId, bridgeId, floor.id]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!selectedId) return; + if (e.key === "Delete" || e.key === "Backspace") { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; + e.preventDefault(); + handleDeleteElement(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [selectedId, handleDeleteElement]); + + return ( +
+ +
+ + activeToolRef.current !== "select" || dragMode.current !== null + } + onStateChange={(state: PanState) => { + panState.current = state; + }} + onMouseDown={() => { + if (activeTool === "select" && !dragMode.current) + setSelectedId(null); + }} + disableDoubleClickZoom + > + + + {/* Front of ship label */} + + Front of Ship + + {/* Grid */} + + {Array.from( + { length: Math.floor(canvasWidth / GRID_SIZE_PX) + 1 }, + (_, i) => { + const pos = i * GRID_SIZE_PX; + return ( + + ); + }, + )} + {Array.from( + { length: Math.floor(canvasHeight / GRID_SIZE_PX) + 1 }, + (_, i) => { + const pos = i * GRID_SIZE_PX; + return ( + + ); + }, + )} + + {/* Background image */} + {floor.backgroundUrl && ( + + )} + + {/* Elements */} + {floor.elements.map((el) => { + const linkedVs = + el.type === "viewscreen" && el.viewscreenId + ? viewscreens.find((v) => v.id === el.viewscreenId) + : null; + const renderEl = + dragPreview && dragPreview.id === el.id + ? { + ...el, + x: dragPreview.x, + y: dragPreview.y, + rotation: dragPreview.rotation, + } + : el; + return ( + handleElementMouseDown(el.id, e)} + isMainViewscreen={linkedVs?.isMainViewscreen} + viewscreenName={linkedVs?.name} + elementScale={elementScale} + /> + ); + })} + + {/* Selection overlay: bounding box + rotation handle */} + {selectedElement && + activeTool === "select" && + (() => { + const el = selectedElement; + const { w, h } = getElementBounds(el, elementScale); + const hw = w / 2; + const hh = h / 2; + return ( + + + + + + ); + })()} + + + + {/* Element property editor */} + {selectedElement && ( + + )} +
+

+ {activeTool === "select" + ? "Click elements to select and drag to move. Use the handle above to rotate." + : "Click to place."} +

+
+ ); +} diff --git a/app/routes/config/bridges/mapEditor/MapElement.tsx b/app/routes/config/bridges/mapEditor/MapElement.tsx new file mode 100644 index 00000000..960013a3 --- /dev/null +++ b/app/routes/config/bridges/mapEditor/MapElement.tsx @@ -0,0 +1,157 @@ +import type { BridgeMapElement } from "@thorium/.server/classes/Plugins/Bridge"; +import { SvgIcon } from "@thorium/ui/Icon"; + +/** Inline SVG filter for drop shadow. Render once inside the parent SVG via . */ +export function MapElementDefs() { + return ( + + + + + + ); +} + +export function MapElementRenderer({ + element, + selected, + onMouseDown, + isMainViewscreen, + viewscreenName, + elementScale, +}: { + element: BridgeMapElement; + selected: boolean; + onMouseDown: (e: React.MouseEvent) => void; + isMainViewscreen?: boolean; + viewscreenName?: string; + elementScale: number; +}) { + const strokeWidth = selected ? 2 : 1; + + switch (element.type) { + case "station": { + const assigned = Boolean(element.stationName); + const stroke = selected ? "#60a5fa" : assigned ? "#4ade80" : "#9ca3af"; + const w = element.widthPixels ?? elementScale; + const h = element.heightPixels ?? elementScale; + const fontSize = w * 0.14; + const iconSize = Math.min(w, h) * 0.7; + return ( + + + + {element.label && ( + + {element.label} + + )} + {element.clientName && ( + + {element.clientName} + + )} + + ); + } + case "viewscreen": { + const stroke = selected ? "#60a5fa" : "#9ca3af"; + const w = element.widthPixels ?? elementScale; + const h = element.heightPixels ?? elementScale; + const fontSize = w * 0.14; + const iconSize = Math.min(w, h) * 0.35; + const gap = iconSize * 0.1; + const totalH = iconSize * 2 + gap; + const topY = -totalH / 2; + return ( + + + {/* video icon on top, rotated to point behind the display */} + + {/* tv-minimal icon below */} + + {viewscreenName && ( + + {viewscreenName} + + )} + + ); + } + default: + return null; + } +} diff --git a/app/routes/config/bridges/mapEditor/MapElementEditor.tsx b/app/routes/config/bridges/mapEditor/MapElementEditor.tsx new file mode 100644 index 00000000..e11eca23 --- /dev/null +++ b/app/routes/config/bridges/mapEditor/MapElementEditor.tsx @@ -0,0 +1,539 @@ +import type { + BridgeMapElement, + BridgeViewscreen, +} from "@thorium/.server/classes/Plugins/Bridge"; +import Input from "@thorium/ui/Input"; +import Select from "@thorium/ui/Select"; +import Button from "@thorium/ui/Button"; +import { q } from "@thorium/context/AppContext"; +import { Icon } from "@thorium/ui/Icon"; +import InfoTip from "@thorium/ui/InfoTip"; +import Checkbox from "@thorium/ui/Checkbox"; + +export function MapElementEditor({ + element, + pluginId, + bridgeId, + floorId, + viewscreens, + stationNames, + assignedStations, + elementScale, + onDelete, +}: { + element: BridgeMapElement; + pluginId: string; + bridgeId: string; + floorId: string; + viewscreens: BridgeViewscreen[]; + stationNames: string[]; + assignedStations: Set; + elementScale: number; + onDelete: () => void; +}) { + function update(params: Record) { + q.plugin.bridge.updateElement.netSend({ + pluginId, + bridgeId, + floorId, + elementId: element.id, + ...params, + }); + } + + // Find linked viewscreen for viewscreen elements + const linkedViewscreen = + element.type === "viewscreen" && element.viewscreenId + ? viewscreens.find((v) => v.id === element.viewscreenId) + : null; + + const headerLabel = element.type === "station" ? "Client" : "Viewscreen"; + + return ( +
+
{headerLabel}
+ {/* Inline viewscreen editing */} + {element.type === "viewscreen" && linkedViewscreen && ( + <> + Name The name of the viewscreen in the flight lobby.} + defaultValue={linkedViewscreen.name} + onBlur={(e: any) => { + const newName = e.target.value.trim(); + if (!newName || newName === linkedViewscreen.name) { + e.target.value = linkedViewscreen.name; + return; + } + const isDuplicate = viewscreens.some( + (v) => v.id !== linkedViewscreen.id && v.name === newName, + ); + if (isDuplicate) { + e.target.value = linkedViewscreen.name; + return; + } + q.plugin.bridge.updateViewscreen.netSend({ + pluginId, + bridgeId, + viewscreenId: linkedViewscreen.id, + name: newName, + }); + }} + onKeyDown={(e: any) => { + if (e.key === "Enter") e.target.blur(); + }} + /> +
+ + Default Yaw Angle:{" "} + {((((element.rotation % 360) + 540) % 360) - 180).toFixed(0)}° + +
+ + update({ rotation: Number(e.target.value) }) + } + className="flex-1" + /> + { + if (e.key === "Enter") { + const val = Number(e.target.value); + if (Number.isFinite(val)) { + update({ rotation: val }); + } + } + }} + onBlur={(e: any) => { + e.target.value = Math.round( + (((element.rotation % 360) + 540) % 360) - 180, + ); + }} + className="w-14 bg-gray-800 border border-white/20 rounded px-1 py-0.5 text-xs text-white" + /> +
+
+
+ + Default Camera Pitch Angle: {element.pitch ?? 0}° + +
+ update({ pitch: Number(e.target.value) })} + className="flex-1" + /> + { + if (e.key === "Enter") { + const val = Number(e.target.value); + if (Number.isFinite(val)) { + update({ pitch: val }); + } + } + }} + onBlur={(e: any) => { + e.target.value = element.pitch ?? 0; + }} + className="w-14 bg-gray-800 border border-white/20 rounded px-1 py-0.5 text-xs text-white" + /> + +
+
+
+ + Camera FOV: {linkedViewscreen.fov ?? 45}° + +
+ + q.plugin.bridge.updateViewscreen.netSend({ + pluginId, + bridgeId, + viewscreenId: linkedViewscreen.id, + fov: Number(e.target.value), + }) + } + className="flex-1" + /> + { + if (e.key === "Enter") { + const val = Number(e.target.value); + if (val >= 1 && val <= 179) { + q.plugin.bridge.updateViewscreen.netSend({ + pluginId, + bridgeId, + viewscreenId: linkedViewscreen.id, + fov: val, + }); + } + } + }} + onBlur={(e: any) => { + e.target.value = linkedViewscreen.fov ?? 45; + }} + className="w-14 bg-gray-800 border border-white/20 rounded px-1 py-0.5 text-xs text-white" + /> +
+
+ v.id !== linkedViewscreen.id && v.isMainViewscreen, + ) + } + onChange={(e) => + q.plugin.bridge.updateViewscreen.netSend({ + pluginId, + bridgeId, + viewscreenId: linkedViewscreen.id, + isMainViewscreen: e.target.checked, + }) + } + /> + + q.plugin.bridge.updateViewscreen.netSend({ + pluginId, + bridgeId, + viewscreenId: linkedViewscreen.id, + showGizmos: e.target.checked, + }) + } + /> + + q.plugin.bridge.updateViewscreen.netSend({ + pluginId, + bridgeId, + viewscreenId: linkedViewscreen.id, + showLayout: e.target.checked, + }) + } + /> +
+
+ Broken Settings + +
+

+ Fully Broken: No cameras, no gizmos, no + displays when offline. +

+

+ Camera Broken Only: Cameras go offline but + gizmos and displays still work. +

+

+ Invincible: Cannot be broken by in-game + damage events. +

+
+
+
+ ({ + id: name, + label: name, + })), + ]} + selected={element.stationName ?? "__none__"} + setSelected={(val) => + update({ + stationName: val === "__none__" ? "" : val, + }) + } + /> + + )} + {/* Shared: Client Name */} + Client Name When a flight client has this name, it will be auto-assigned to this {element.type === "station" ? "client" : "viewscreen"}.} + defaultValue={element.clientName ?? ""} + onBlur={(e: any) => { + update({ clientName: e.target.value.trim() }); + }} + onKeyDown={(e: any) => { + if (e.key === "Enter") e.target.blur(); + }} + /> + {/* Position */} +
+ + +
+ {/* Rotation slider (synced with canvas handle) */} +
+
+ + Rotation: {Math.round(element.rotation)}° + +
+ update({ rotation: Number(e.target.value) })} + className="flex-1" + /> + { + if (e.key === "Enter") { + const val = Number(e.target.value); + if (Number.isFinite(val)) { + update({ rotation: val }); + } + } + }} + onBlur={(e: any) => { + e.target.value = Math.round(element.rotation); + }} + className="w-14 bg-gray-800 border border-white/20 rounded px-1 py-0.5 text-xs text-white" + /> +
+
+ {/* Size overrides */} + {(() => { + const hasOverride = + element.widthPixels != null || element.heightPixels != null; + return ( + <> + { + if (e.target.checked) { + update({ + widthPixels: Math.round(elementScale), + heightPixels: Math.round(elementScale), + }); + } else { + update({ widthPixels: null, heightPixels: null }); + } + }} + /> +
+ + Width (px) + +
+ + update({ widthPixels: Number(e.target.value) }) + } + className="flex-1" + disabled={!hasOverride} + /> + { + if (e.key === "Enter") { + const val = Number(e.target.value); + if (val > 0) { + update({ widthPixels: val }); + } else { + e.target.value = Math.round( + element.widthPixels ?? elementScale, + ); + } + } + }} + onBlur={(e: any) => { + e.target.value = Math.round( + element.widthPixels ?? elementScale, + ); + }} + className="w-14 bg-gray-800 border border-white/20 rounded px-1 py-0.5 text-xs text-white disabled:opacity-40" + disabled={!hasOverride} + /> +
+
+
+ + Height (px) + +
+ + update({ heightPixels: Number(e.target.value) }) + } + className="flex-1" + disabled={!hasOverride} + /> + { + if (e.key === "Enter") { + const val = Number(e.target.value); + if (val > 0) { + update({ heightPixels: val }); + } else { + e.target.value = Math.round( + element.heightPixels ?? elementScale, + ); + } + } + }} + onBlur={(e: any) => { + e.target.value = Math.round( + element.heightPixels ?? elementScale, + ); + }} + className="w-14 bg-gray-800 border border-white/20 rounded px-1 py-0.5 text-xs text-white disabled:opacity-40" + disabled={!hasOverride} + /> +
+
+ + ); + })()} + +
+ ); +} diff --git a/app/routes/config/bridges/mapEditor/MapToolbar.tsx b/app/routes/config/bridges/mapEditor/MapToolbar.tsx new file mode 100644 index 00000000..2638894f --- /dev/null +++ b/app/routes/config/bridges/mapEditor/MapToolbar.tsx @@ -0,0 +1,36 @@ +import type { BridgeMapElementType } from "@thorium/.server/classes/Plugins/Bridge"; + +export type MapTool = "select" | BridgeMapElementType; + +const tools: { id: MapTool; label: string }[] = [ + { id: "select", label: "Select" }, + { id: "station", label: "Add Clients" }, + { id: "viewscreen", label: "Add Viewscreens" }, +]; + +export function MapToolbar({ + activeTool, + setActiveTool, +}: { + activeTool: MapTool; + setActiveTool: (tool: MapTool) => void; +}) { + return ( +
+ {tools.map((tool) => ( + + ))} +
+ ); +} diff --git a/app/routes/config/bridges/mapEditor/constants.ts b/app/routes/config/bridges/mapEditor/constants.ts new file mode 100644 index 00000000..8a00a1e5 --- /dev/null +++ b/app/routes/config/bridges/mapEditor/constants.ts @@ -0,0 +1,5 @@ +/** Grid square size in pixels (used when no background image) */ +export const GRID_SIZE_PX = 20; + +/** Default canvas size when no background image is set */ +export const DEFAULT_CANVAS_SIZE = 800; diff --git a/app/routes/flight/HostLobby.tsx b/app/routes/flight/HostLobby.tsx index 569d70e2..ac33e605 100644 --- a/app/routes/flight/HostLobby.tsx +++ b/app/routes/flight/HostLobby.tsx @@ -182,15 +182,23 @@ function ClientAssignment() { /> ))} {flight?.hasFlightDirector - ? staticStations.map((station) => ( - - )) + ? staticStations + .filter( + (s) => + s.name !== "Viewscreen" || + !ship.stations.some((st) => + st.cards.some((c) => c.component === "Viewscreen"), + ), + ) + .map((station) => ( + + )) : null}
@@ -252,7 +260,9 @@ function HostStationItem({ className={`list-group-item list-group-item-small ${ selectedClient === client.clientId ? "selected" : "" }`} - onClick={() => setSelectedClient(client.clientId)} + onClick={() => { + setSelectedClient(client.clientId); + }} >
{client.name}
diff --git a/app/routes/flight/PlayerLobby.tsx b/app/routes/flight/PlayerLobby.tsx index 3d9cc1cb..c6aab050 100644 --- a/app/routes/flight/PlayerLobby.tsx +++ b/app/routes/flight/PlayerLobby.tsx @@ -39,13 +39,21 @@ function PlayerStationSelection() { /> ))} {/* TODO April 23, 2022 - Hide this when the ship is configured to not have a flight director */} - {staticStations.map((station) => ( - - ))} + {staticStations + .filter( + (s) => + s.name !== "Viewscreen" || + !ship.stations.some((st) => + st.cards.some((c) => c.component === "Viewscreen"), + ), + ) + .map((station) => ( + + ))}
))} diff --git a/app/routes/flight/redirect.tsx b/app/routes/flight/redirect.tsx index 01032daf..cf13ba8f 100644 --- a/app/routes/flight/redirect.tsx +++ b/app/routes/flight/redirect.tsx @@ -1,5 +1,18 @@ -import { Navigate } from "@thorium/components/Navigate"; +import { redirect } from "react-router"; +import { q, clientId } from "@thorium/context/AppContext"; + +export async function clientLoader() { + const client = await q.client.get.netRequest({ clientId }); + + if (client.stationId === "Flight Director") { + throw redirect("/flight/core"); + } + if (client.shipId && client.stationId) { + throw redirect("/flight/station"); + } + throw redirect("/flight/lobby"); +} export default function Flight() { - return ; + return null; } diff --git a/app/routes/landing/WelcomeButtons.tsx b/app/routes/landing/WelcomeButtons.tsx index 15075adc..7efd6fc6 100644 --- a/app/routes/landing/WelcomeButtons.tsx +++ b/app/routes/landing/WelcomeButtons.tsx @@ -32,9 +32,26 @@ export const WelcomeButtons = ({ className }: { className?: string }) => { function FlightButtons() { const [flight] = q.flight.active.useNetRequest(); - return flight ? ( + return flight ? : ; +} + +function ActiveFlightButtons() { + const [client] = q.client.get.useNetRequest({ clientId }); + + const hasStation = client.shipId && client.stationId; + const isFlightDirector = client.stationId === "Flight Director"; + return ( <> - + {isFlightDirector ? ( + + Go To Core + + ) : hasStation ? ( + + Go To Station + + ) : null} + Go To Flight Lobby {process.env.NODE_ENV !== "production" && ( @@ -49,8 +66,6 @@ function FlightButtons() { Stop Flight - ) : ( - ); } diff --git a/app/routes/quickStart/quickStart.tsx b/app/routes/quickStart/quickStart.tsx index 9ec6cc2f..9c02af8f 100644 --- a/app/routes/quickStart/quickStart.tsx +++ b/app/routes/quickStart/quickStart.tsx @@ -89,6 +89,7 @@ export default function FlightQuickStart() { ...ship, shipName: ship.name, shipTemplate: ship.shipId, + bridge: ship.bridgeId, })); flightStart.mutate({ flightName, diff --git a/app/routes/quickStart/quickStartContext.tsx b/app/routes/quickStart/quickStartContext.tsx index 0e961c54..ff0aae8e 100644 --- a/app/routes/quickStart/quickStartContext.tsx +++ b/app/routes/quickStart/quickStartContext.tsx @@ -20,6 +20,7 @@ export interface FlightConfigState { shipId: { pluginId: string; shipId: string }; name: string; crewCount: number; + bridgeId?: { pluginId: string; bridgeId: string }; }[]; missionId?: { pluginId: string; missionId: string }; startingPointId?: FlightStartingPoint; @@ -50,6 +51,11 @@ export type FlightConfigAction = | { type: "mode"; mode: "nova" | "legacy"; + } + | { + type: "bridgeId"; + id: string; + bridgeId: { pluginId: string; bridgeId: string } | undefined; }; function quickStartReducer( @@ -127,6 +133,13 @@ function quickStartReducer( action.mode === "legacy" ? true : state.hasFlightDirector, missionId: undefined, }; + case "bridgeId": + return produce(state, (draft) => { + const ship = draft.ships.find((ship) => ship.id === action.id); + if (ship) { + ship.bridgeId = action.bridgeId; + } + }); default: return state; } diff --git a/app/routes/quickStart/ship.tsx b/app/routes/quickStart/ship.tsx index e433ec0e..12ff0a63 100644 --- a/app/routes/quickStart/ship.tsx +++ b/app/routes/quickStart/ship.tsx @@ -8,6 +8,7 @@ import Checkbox from "@thorium/ui/Checkbox"; import { Icon } from "@thorium/ui/Icon"; import InfoTip from "@thorium/ui/InfoTip"; import Input from "@thorium/ui/Input"; +import Select from "@thorium/ui/Select"; import SearchableList from "@thorium/ui/SearchableList"; import { cn } from "@thorium/utils/cn"; import { randomNameGenerator } from "@thorium/utils/operations/randomNameGenerator"; @@ -28,6 +29,7 @@ interface Ship { name: string; shipId: { pluginId: string; shipId: string }; crewCount: number; + bridgeId?: { pluginId: string; bridgeId: string }; } const FleetConfig = () => { const [state, dispatch] = useFlightQuickStart(); @@ -184,6 +186,7 @@ function ShipConfig({ const [pickingShip, setPickingShip] = React.useState(false); const [pluginShips] = q.plugin.ship.available.useNetRequest(); const [availableStations] = q.station.available.useNetRequest(); + const [availableBridges] = q.plugin.bridge.available.useNetRequest(); const availableCrewSizes = availableStations .map((station) => station.stationCount) @@ -285,6 +288,38 @@ function ShipConfig({
)} + {availableBridges.length > 0 && ( +