Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ icons/sprite.svg
/binaries/

.wrangler
.claude/

# Playwright
test-results
Expand Down
120 changes: 120 additions & 0 deletions app/.server/classes/Plugins/Bridge.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
mechatronics-studio marked this conversation as resolved.
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<string, string>; // 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<string, StationAssignment>;
viewscreens!: BridgeViewscreen[];
floors!: BridgeFloor[];
/** Default size in pixels for map elements. When undefined, defaults to 7.5% of floor width. */
elementScale?: number;
assets!: Record<string, string>;
constructor(params: Partial<BridgePlugin>, 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 || {};
}
}
3 changes: 3 additions & 0 deletions app/.server/classes/Plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -34,6 +35,7 @@ const Aspects = {
reports: ReportPlugin,
trainings: TrainingPlugin,
conversations: ConversationPlugin,
bridges: BridgePlugin,
};

export type AspectsMap = {
Expand Down Expand Up @@ -114,6 +116,7 @@ export default class BasePlugin extends DataStore {
reports: [],
trainings: [],
conversations: [],
bridges: [],
};
pluginAspects.set(this, aspects);
}
Expand Down
45 changes: 41 additions & 4 deletions app/.server/data/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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();
Expand 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.");
Expand Down
Loading
Loading