diff --git a/client/app/cards/Pilot/CircleGrid.tsx b/client/app/cards/Pilot/CircleGrid.tsx index ce961824..81afeade 100644 --- a/client/app/cards/Pilot/CircleGrid.tsx +++ b/client/app/cards/Pilot/CircleGrid.tsx @@ -47,7 +47,14 @@ const CameraEffects = () => { export function CircleGrid({ children, -}: { children: ReactNode; rangeMin?: number; rangeMax?: number }) { + /** Children that are fixed with the ship */ + fixedChildren, +}: { + children: ReactNode; + fixedChildren?: ReactNode; + rangeMin?: number; + rangeMax?: number; +}) { const store = useCircleGridStore(); const tilt = store((store) => store.tilt); @@ -108,6 +115,7 @@ export function CircleGrid({ ))} + {fixedChildren} {children} @@ -157,7 +165,6 @@ export function GridCanvas({ camera={{ // position: [0, 300000, 0], far: 200000, - zoom: 165, }} className="rounded-full" orthographic diff --git a/client/app/cards/SystemsMonitor/data.ts b/client/app/cards/SystemsMonitor/data.ts index e77cf455..08b777d2 100644 --- a/client/app/cards/SystemsMonitor/data.ts +++ b/client/app/cards/SystemsMonitor/data.ts @@ -1,7 +1,6 @@ import { pubsub } from "@server/init/pubsub"; import { t } from "@server/init/t"; import { getPowerSupplierPowerNeeded } from "@server/systems/ReactorFuelSystem"; -import type { Entity } from "@server/utils/ecs"; import { getShipSystems } from "@server/utils/getShipSystem"; import { getReactorInventory } from "@server/utils/getSystemInventory"; import type { MegaWattHour } from "@server/utils/unitTypes"; @@ -144,6 +143,11 @@ export const systemsMonitor = t.router({ pubsub.publish.systemsMonitor.systems.get({ shipId }); pubsub.publish.systemsMonitor.reactors.get({ shipId }); pubsub.publish.systemsMonitor.batteries.get({ shipId }); + + if (system.components.isPhasers) { + // Update the output megawatts of the phasers + pubsub.publish.targeting.phasers.list({ shipId }); + } }), addPowerSource: t.procedure .input( @@ -163,6 +167,16 @@ export const systemsMonitor = t.router({ throw new Error( "Invalid power source. Power source must be a reactor or battery.", ); + + if ( + system.components.isPhasers && + !powerSource.components.isPhaseCapacitor + ) { + throw new Error( + "Invalid power source. Power source must be a phase capacitor.", + ); + } + const powerSupplied = getPowerSupplierPowerNeeded(powerSource); if ( @@ -200,6 +214,11 @@ export const systemsMonitor = t.router({ pubsub.publish.systemsMonitor.systems.get({ shipId }); pubsub.publish.systemsMonitor.reactors.get({ shipId }); pubsub.publish.systemsMonitor.batteries.get({ shipId }); + + if (system.components.isPhasers) { + // Update the output megawatts of the phasers + pubsub.publish.targeting.phasers.list({ shipId }); + } }), }), stream: t.procedure.dataStream(({ ctx, entity }) => { diff --git a/client/app/cards/Targeting/Phasers.tsx b/client/app/cards/Targeting/Phasers.tsx new file mode 100644 index 00000000..3b2e392c --- /dev/null +++ b/client/app/cards/Targeting/Phasers.tsx @@ -0,0 +1,338 @@ +import { q } from "@client/context/AppContext"; +import useAnimationFrame from "@client/hooks/useAnimationFrame"; +import { Edges, Line } from "@react-three/drei"; +import { useFrame } from "@react-three/fiber"; +import { isPointWithinCone } from "@server/utils/isPointWithinCone"; +import { degToRad } from "@server/utils/unitTypes"; +import { useQueryClient } from "@tanstack/react-query"; +import { useLiveQuery } from "@thorium/live-query/client"; +import Button from "@thorium/ui/Button"; +import { Icon } from "@thorium/ui/Icon"; +import Slider from "@thorium/ui/Slider"; +import { useEffect, useMemo, useRef } from "react"; +import { DoubleSide, Euler, type Group, Quaternion, Vector3 } from "three"; +import type { Line2 } from "three-stdlib"; + +export function PhaserArcs() { + const [phasers] = q.targeting.phasers.list.useNetRequest(); + return ( + <> + {phasers.map((phaser) => ( + + ))} + + ); +} + +const up = new Vector3(0, 1, 0); +const cameraProjection = new Vector3(); +const planeVector = new Vector3(0, 1, 0); +const directionVector = new Vector3(); +function ConeVisualization({ + arc, + heading, + pitch, + maxArc, + maxRange, +}: { + arc: number; + heading: number; + pitch: number; + maxArc: number; + maxRange: number; +}) { + const [height, radius, angle, rotation] = useMemo(() => { + const range = maxRange - maxRange * (arc / (maxArc + 1)); + + const rotation = new Euler( + pitch * (Math.PI / 180), + heading * (Math.PI / 180), + 0, + ); + const angle = arc * (Math.PI / 180); + + const radius = range * Math.tan(angle / 2); + + return [range, radius, angle, rotation]; + }, [arc, maxArc, pitch, heading, maxRange]); + + const lineLength = Math.sqrt(height ** 2 + radius ** 2); + const coneRef = useRef(null); + const groupRef = useRef(null); + + useFrame(({ camera }) => { + cameraProjection + .copy(camera.position) + .projectOnPlane(planeVector.set(0, 1, 0)) + .normalize(); + + const angle = + Math.atan2(cameraProjection.x, cameraProjection.y) + rotation.y; + if (coneRef.current) { + coneRef.current.rotation.y = angle; + } + + groupRef.current?.quaternion.setFromUnitVectors( + up, + directionVector.set(0, 0, -1).applyEuler(rotation).normalize().negate(), + ); + }); + return ( + + + + + + + + + + + ); +} + +export function BeamVisualization() { + const [firingPhasers] = q.targeting.phasers.firing.useNetRequest(); + const [{ id: playerId }] = q.ship.player.useNetRequest(); + + const { interpolate } = useLiveQuery(); + const lineRef = useRef(null); + + useFrame(() => { + if (!lineRef.current) return; + const points: number[] = []; + const player = interpolate(playerId); + if (!player) return; + firingPhasers.forEach((phaser) => { + const ship = interpolate(phaser.shipId); + const target = interpolate(phaser.targetId); + if (!ship || !target) return; + points.push(ship.x - player.x, ship.y - player.y, ship.z - player.z); + points.push( + target.x - player.x, + target.y - player.y, + target.z - player.z, + ); + }); + if (points.length === 0) { + lineRef.current.visible = false; + } else { + lineRef.current.visible = true; + lineRef.current?.geometry.setPositions(points); + lineRef.current.material.linewidth = + 5 * Math.max(...firingPhasers.map((p) => p.firePercent)); + } + }); + + return ( + + ); +} + +export function Phasers() { + const [phasers] = q.targeting.phasers.list.useNetRequest(); + return ( +
+ {phasers.map((phaser) => ( + + ))} +
+ ); +} + +const targetVector = new Vector3(); +const playerVector = new Vector3(); +const rotationQuaternion = new Quaternion(); +const direction = new Vector3(); +function PhaserControl({ + id, + arc, + maxArc, + maxRange, + nominalHeat, + maxSafeHeat, + heading, + pitch, +}: { + id: number; + arc: number; + maxArc: number; + maxRange: number; + nominalHeat: number; + maxSafeHeat: number; + heading: number; + pitch: number; +}) { + const { interpolate } = useLiveQuery(); + const chargeRef = useRef(null); + const heatRef = useRef(null); + const [targetedContact] = q.targeting.targetedContact.useNetRequest(); + const [{ id: playerId }] = q.ship.player.useNetRequest(); + const buttonContainerRef = useRef(null); + useAnimationFrame(() => { + const values = interpolate(id); + if (!values) return; + const { z: heat, x: charge } = values; + if (chargeRef.current) { + chargeRef.current.value = charge; + } + if (heatRef.current) { + // Scale the heat value + heatRef.current.value = + (heat - nominalHeat) / (maxSafeHeat - nominalHeat); + } + // Check if the target is in range + let inCone = false; + if (targetedContact) { + const target = interpolate(targetedContact.id); + const player = interpolate(playerId); + if (target && player) { + targetVector.set(target.x, target.y, target.z); + playerVector.set(player.x, player.y, player.z); + + const range = maxRange - maxRange * (arc / (maxArc + 1)); + rotationQuaternion.set(player.r.x, player.r.y, player.r.z, player.r.w); + // Turn the ship rotation quaternion into a vector + direction.set(0, 0, 1).applyQuaternion(rotationQuaternion); + // Add the Phaser rotation to the ship rotation + direction.applyAxisAngle(new Vector3(0, 1, 0), degToRad(heading)); + direction.applyAxisAngle(new Vector3(1, 0, 0), degToRad(pitch)); + direction.multiplyScalar(range); + inCone = isPointWithinCone(targetVector, { + apex: playerVector, + angle: degToRad(arc), + direction, + }); + } + } + if (inCone) { + buttonContainerRef.current?.childNodes.forEach((child) => { + if (child instanceof HTMLButtonElement) { + child.disabled = false; + child.classList.remove("grayscale"); + } + }); + } else { + buttonContainerRef.current?.childNodes.forEach((child) => { + if (child instanceof HTMLButtonElement) { + child.disabled = true; + child.classList.add("grayscale"); + } + }); + } + }); + + const cache = useQueryClient(); + const getFirePhasers = (firePercent: number) => { + return async function firePhasers() { + await q.targeting.phasers.fire.netSend({ phaserId: id, firePercent }); + document.addEventListener( + "pointerup", + () => { + q.targeting.phasers.fire.netSend({ phaserId: id, firePercent: 0 }); + }, + { once: true }, + ); + }; + }; + + useEffect(() => { + return () => { + q.targeting.phasers.fire.netSend({ phaserId: id, firePercent: 0 }); + }; + }, [id]); + + return ( +
+ + + + + { + // Manually update the local cache with the arc value so it looks really smooth + cache.setQueryData( + q.targeting.phasers.list.getQueryKey(), + (data: any[]) => { + if (!data) return data; + return data.map((phaser) => { + if (phaser.id === id) { + return { ...phaser, arc: val as number }; + } + return phaser; + }); + }, + ); + q.targeting.phasers.setArc.netSend({ + phaserId: id, + arc: val as number, + }); + }} + /> +
+ + + + +
+
+ ); +} diff --git a/client/app/cards/Targeting/Shields.tsx b/client/app/cards/Targeting/Shields.tsx new file mode 100644 index 00000000..9e2cc6f7 --- /dev/null +++ b/client/app/cards/Targeting/Shields.tsx @@ -0,0 +1,137 @@ +import { q } from "@client/context/AppContext"; +import useAnimationFrame from "@client/hooks/useAnimationFrame"; +import { useLiveQuery } from "@thorium/live-query/client"; +import Button from "@thorium/ui/Button"; +import chroma from "chroma-js"; +import { useRef } from "react"; + +const shieldColors = [ + "oklch(10.86% 0.045 29.25)", // Black + "oklch(66.33% 0.2823 29.25)", // Red + "oklch(76.18% 0.207 56.11)", // Orange + "oklch(86.52% 0.204 90.38", // Yellow + "oklch(86.18% 0.343 142.58)", // Green + "oklch(57.65% 0.249 256.24)", // Blue +]; +const shieldColor = (integrity: number) => { + // @ts-expect-error chroma types are wrong - it does support oklch + return chroma.scale(shieldColors).mode("oklch")(integrity).css("oklch"); +}; +const shieldStyle = ( + shields: { + strength: number; + maxStrength: number; + direction: "fore" | "aft" | "starboard" | "port" | "dorsal" | "ventral"; + }[], + extra = false, +) => { + // Creates the styles for multiple shields + const output: string[] = []; + shields.forEach((s) => { + const integrity = s.strength / s.maxStrength; + const color = shieldColor(integrity); + if ( + (s.direction === "starboard" && !extra) || + (s.direction === "fore" && extra) + ) { + output.push(`20px 0px 20px -15px ${color}`); + output.push(`inset -20px 0px 20px -15px ${color}`); + } + if ( + (s.direction === "port" && !extra) || + (s.direction === "aft" && extra) + ) { + output.push(`-20px 0px 20px -15px ${color}`); + output.push(`inset 20px 0px 20px -15px ${color}`); + } + if (s.direction === "fore" && !extra) { + output.push(`0px -20px 20px -15px ${color}`); + output.push(`inset 0px 20px 20px -15px ${color}`); + } + if (s.direction === "aft" && !extra) { + output.push(`0px 20px 20px -15px ${color}`); + output.push(`inset 0px -20px 20px -15px ${color}`); + } + if (s.direction === "ventral" && extra) { + output.push(`0px 20px 20px -15px ${color}`); + output.push(`inset 0px -20px 20px -15px ${color}`); + } + if (s.direction === "dorsal" && extra) { + output.push(`0px -20px 20px -15px ${color}`); + output.push(`inset 0px 20px 20px -15px ${color}`); + } + }); + return output.join(","); +}; + +export function Shields({ cardLoaded }: { cardLoaded: boolean }) { + const [ship] = q.ship.player.useNetRequest(); + const [shields] = q.targeting.shields.get.useNetRequest(); + + const topViewRef = useRef(null); + const sideViewRef = useRef(null); + + const { interpolate } = useLiveQuery(); + useAnimationFrame(() => { + const shieldItems: { + strength: number; + maxStrength: number; + direction: "fore" | "aft" | "starboard" | "port" | "dorsal" | "ventral"; + }[] = []; + for (const shield of shields) { + const strength = interpolate(shield.id)?.x || 0; + shieldItems.push({ ...shield, strength }); + } + topViewRef.current?.style.setProperty( + "box-shadow", + shieldStyle(shieldItems), + ); + sideViewRef.current?.style.setProperty( + "box-shadow", + shieldStyle(shieldItems, true), + ); + }, cardLoaded); + if (!ship) return null; + if (shields.length === 0) return null; + return ( +
+
+
+ Top +
+ {shields.length === 6 ? ( +
+ Side +
+ ) : null} +
+ {shields[0].state === "down" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/client/app/cards/Targeting/Torpedoes.tsx b/client/app/cards/Targeting/Torpedoes.tsx new file mode 100644 index 00000000..a673bec9 --- /dev/null +++ b/client/app/cards/Targeting/Torpedoes.tsx @@ -0,0 +1,184 @@ +import { q } from "@client/context/AppContext"; +import { cn } from "@client/utils/cn"; +import { megaWattHourToGigaJoule } from "@server/utils/unitTypes"; +import { useRef, useState } from "react"; +import LauncherImage from "./assets/launcher.svg"; +import href from "./assets/torpedoSprite.svg?url"; +import Button from "@thorium/ui/Button"; +import { toast } from "@client/context/ToastContext"; +import { LiveQueryError } from "@thorium/live-query/client/client"; + +export function Torpedoes() { + const [torpedoLaunchers] = q.targeting.torpedoes.launchers.useNetRequest(); + const [torpedoList] = q.targeting.torpedoes.list.useNetRequest(); + const [selectedTorpedo, setSelectedTorpedo] = useState(null); + + return ( + <> +
    + {Object.entries(torpedoList ?? {}).map( + ([id, { count, yield: torpedoYield, speed }]) => ( + // biome-ignore lint/a11y/useKeyWithClickEvents: +
  • setSelectedTorpedo(id)} + > +
    +
    + {id} + + Yield: {megaWattHourToGigaJoule(torpedoYield)} GJ · Speed:{" "} + {speed} km/s + +
    +
    {count}
    +
    +
  • + ), + )} +
+
+ {torpedoLaunchers?.map((launcher) => ( + + ))} +
+ + ); +} + +function Launcher({ + launcherId, + name, + state, + loadTime, + selectedTorpedo, + torpedo, +}: { + launcherId: number; + state: "ready" | "loading" | "unloading" | "loaded" | "firing"; + name: string; + loadTime: number; + selectedTorpedo: string | null; + torpedo: { + casingColor: string | undefined; + guidanceColor: string | undefined; + guidanceMode: string | undefined; + warheadColor: string | undefined; + warheadDamageType: string | undefined; + } | null; +}) { + const torpedoRef = useRef(null); + const animationTime = + state === "loading" || state === "unloading" ? loadTime : 100; + return ( +
+

{name}

+
+ Torpedo Launcher +
+ {/* biome-ignore lint/a11y/noSvgWithoutTitle: */} + + + {torpedo?.warheadDamageType ? ( + + ) : null} + {torpedo?.guidanceMode ? ( + + ) : null} + +
+
+
+
+ + +
+
+ ); +} diff --git a/client/app/cards/Targeting/data.tsx b/client/app/cards/Targeting/data.tsx index b4ca7451..4e12ca5f 100644 --- a/client/app/cards/Targeting/data.tsx +++ b/client/app/cards/Targeting/data.tsx @@ -7,6 +7,11 @@ import { getInventoryTemplates } from "@server/utils/getInventoryTemplates"; import { randomFromList } from "@server/utils/randomFromList"; import { spawnTorpedo } from "@server/spawners/torpedo"; import type { Entity } from "@server/utils/ecs"; +import { + getCurrentTarget, + getTargetIsInPhaserRange, +} from "@server/systems/PhasersSystem"; +import { LiveQueryError } from "@thorium/live-query/client/client"; export const targeting = t.router({ targetedContact: t.procedure @@ -44,163 +49,184 @@ export const targeting = t.router({ shipId: ctx.ship.id, }); }), - torpedoList: t.procedure - .filter((publish: { shipId: number }, { ctx }) => { - if (publish && publish.shipId !== ctx.ship?.id) return false; - return true; - }) - .request(({ ctx }) => { - if (!ctx.ship) throw new Error("No ship found."); - const templates = getInventoryTemplates(ctx.flight?.ecs); - const torpedoRooms = getRoomBySystem(ctx.ship, "torpedoLauncher"); - const torpedoList: Record< - string, - { count: number; yield: number; speed: number } - > = {}; - for (const room of torpedoRooms) { - for (const item in room.contents) { - const template = templates[item]; - if ( - !template || - !template.flags.torpedoCasing || - !template.flags.torpedoWarhead - ) - continue; - if (!torpedoList[item]) { - torpedoList[item] = { - count: 0, - yield: template.flags.torpedoWarhead.yield, - speed: template.flags.torpedoCasing.speed, - }; + torpedoes: t.router({ + list: t.procedure + .filter((publish: { shipId: number }, { ctx }) => { + if (publish && publish.shipId !== ctx.ship?.id) return false; + return true; + }) + .request(({ ctx }) => { + if (!ctx.ship) throw new Error("No ship found."); + const templates = getInventoryTemplates(ctx.flight?.ecs); + const torpedoRooms = getRoomBySystem(ctx.ship, "torpedoLauncher"); + const torpedoList: Record< + string, + { count: number; yield: number; speed: number } + > = {}; + for (const room of torpedoRooms) { + for (const item in room.contents) { + const template = templates[item]; + if ( + !template || + !template.flags.torpedoCasing || + !template.flags.torpedoWarhead + ) + continue; + if (!torpedoList[item]) { + torpedoList[item] = { + count: 0, + yield: template.flags.torpedoWarhead.yield, + speed: template.flags.torpedoCasing.speed, + }; + } + torpedoList[item].count += room.contents[item].count; } - torpedoList[item].count += room.contents[item].count; } - } - return torpedoList; - }), - torpedoLaunchers: t.procedure - .filter((publish: { shipId: number }, { ctx }) => { - if (publish && publish.shipId !== ctx.ship?.id) return false; - return true; - }) - .request(({ ctx }) => { - const systems = getShipSystems(ctx, { - systemType: "TorpedoLauncher", - }).filter( - (system) => system.components.isShipSystem?.shipId === ctx.ship?.id, - ); - - return systems.flatMap((system) => { - if (!system.components.isTorpedoLauncher) return []; - const torpedoEntity = - system.components.isTorpedoLauncher?.torpedoEntity; - const torpedo = torpedoEntity - ? ctx.flight?.ecs.getEntityById(torpedoEntity) - : null; - return { - id: system.id, - name: system.components.identity?.name || "Torpedo Launcher", - state: system.components.isTorpedoLauncher.status, - fireTime: system.components.isTorpedoLauncher.fireTime, - loadTime: system.components.isTorpedoLauncher.loadTime, - torpedo: torpedo - ? { - id: torpedo.id, - casingColor: - torpedo.components.isInventory?.flags.torpedoCasing?.color, - warheadColor: - torpedo.components.isInventory?.flags.torpedoWarhead?.color, - warheadDamageType: - torpedo.components.isInventory?.flags.torpedoWarhead - ?.damageType, - guidanceColor: - torpedo.components.isInventory?.flags.torpedoGuidance?.color, - guidanceMode: - torpedo.components.isInventory?.flags.torpedoGuidance - ?.guidanceMode, - } - : null, - }; - }); - }), - loadTorpedo: t.procedure - .input( - z.object({ - launcherId: z.number(), - torpedoId: z.string().nullable(), + return torpedoList; }), - ) - .send(({ input, ctx }) => { - if (!ctx.ship) throw new Error("No ship found."); - const launcher = getShipSystem(ctx, { - systemId: input.launcherId, - }); - if (!launcher.components.isTorpedoLauncher) - throw new Error("System is not a torpedo launcher"); - if ( - input.torpedoId && - launcher.components.isTorpedoLauncher.status !== "ready" - ) { - throw new Error("Torpedo launcher is not ready"); - } - if ( - !input.torpedoId && - launcher.components.isTorpedoLauncher.status !== "loaded" - ) { - throw new Error("Torpedo launcher is not loaded"); - } - launcher.components.isTorpedoLauncher.torpedoEntity; - const torpedoEntity = adjustTorpedoInventory( - ctx.ship, - input.torpedoId, - launcher, - ); + launchers: t.procedure + .filter((publish: { shipId: number }, { ctx }) => { + if (publish && publish.shipId !== ctx.ship?.id) return false; + return true; + }) + .request(({ ctx }) => { + const systems = getShipSystems(ctx, { + systemType: "TorpedoLauncher", + }).filter( + (system) => system.components.isShipSystem?.shipId === ctx.ship?.id, + ); - launcher.updateComponent("isTorpedoLauncher", { - status: torpedoEntity ? "loading" : "unloading", - progress: launcher.components.isTorpedoLauncher.loadTime, - ...(torpedoEntity ? { torpedoEntity } : {}), - }); - pubsub.publish.targeting.torpedoLaunchers({ - shipId: ctx.ship!.id, - }); - }), - fireTorpedo: t.procedure - .input( - z.object({ - launcherId: z.number(), + return systems.flatMap((system) => { + if (!system.components.isTorpedoLauncher) return []; + const torpedoEntity = + system.components.isTorpedoLauncher?.torpedoEntity; + const torpedo = torpedoEntity + ? ctx.flight?.ecs.getEntityById(torpedoEntity) + : null; + return { + id: system.id, + name: system.components.identity?.name || "Torpedo Launcher", + state: system.components.isTorpedoLauncher.status, + fireTime: system.components.isTorpedoLauncher.fireTime, + loadTime: system.components.isTorpedoLauncher.loadTime, + torpedo: torpedo + ? { + id: torpedo.id, + casingColor: + torpedo.components.isInventory?.flags.torpedoCasing?.color, + warheadColor: + torpedo.components.isInventory?.flags.torpedoWarhead?.color, + warheadDamageType: + torpedo.components.isInventory?.flags.torpedoWarhead + ?.damageType, + guidanceColor: + torpedo.components.isInventory?.flags.torpedoGuidance + ?.color, + guidanceMode: + torpedo.components.isInventory?.flags.torpedoGuidance + ?.guidanceMode, + } + : null, + }; + }); }), - ) - .send(({ input, ctx }) => { - const launcher = getShipSystem(ctx, { - systemId: input.launcherId, - }); - if (!launcher.components.isTorpedoLauncher) - throw new Error("System is not a torpedo launcher"); - if (launcher.components.isTorpedoLauncher.status !== "loaded") { - throw new Error("Torpedo launcher is not loaded"); - } + load: t.procedure + .input( + z.object({ + launcherId: z.number(), + torpedoId: z.string().nullable(), + }), + ) + .send(({ input, ctx }) => { + if (!ctx.ship) throw new Error("No ship found."); + const launcher = getShipSystem(ctx, { + systemId: input.launcherId, + }); + if (!launcher.components.isTorpedoLauncher) + throw new Error("System is not a torpedo launcher"); + if ( + input.torpedoId && + launcher.components.isTorpedoLauncher.status !== "ready" + ) { + throw new Error("Torpedo launcher is not ready"); + } + if ( + !input.torpedoId && + launcher.components.isTorpedoLauncher.status !== "loaded" + ) { + throw new Error("Torpedo launcher is not loaded"); + } + launcher.components.isTorpedoLauncher.torpedoEntity; + const torpedoEntity = adjustTorpedoInventory( + ctx.ship, + input.torpedoId, + launcher, + ); - const inventoryTemplate = ctx.flight?.ecs.getEntityById( - launcher.components.isTorpedoLauncher.torpedoEntity!, - ); - if (!inventoryTemplate) throw new Error("Torpedo not found"); + launcher.updateComponent("isTorpedoLauncher", { + status: torpedoEntity ? "loading" : "unloading", + progress: launcher.components.isTorpedoLauncher.loadTime, + ...(torpedoEntity ? { torpedoEntity } : {}), + }); + pubsub.publish.targeting.torpedoes.launchers({ + shipId: ctx.ship!.id, + }); + }), + fire: t.procedure + .input( + z.object({ + launcherId: z.number(), + }), + ) + .send(({ input, ctx }) => { + const launcher = getShipSystem(ctx, { + systemId: input.launcherId, + }); + if (!launcher.components.isTorpedoLauncher) + throw new Error("System is not a torpedo launcher"); + if (launcher.components.isTorpedoLauncher.status !== "loaded") { + throw new Error("Torpedo launcher is not loaded"); + } + const power = launcher.components.power; + const currentPower = power?.currentPower || 1; + const requiredPower = power?.requiredPower || 0; + const maxSafePower = power?.maxSafePower || 1; + // It takes longer to reload based on the efficiency of the torpedo launcher + // It will take min 1x and max 20x longer to fire a torpedo, depending on power + if (requiredPower > currentPower) { + throw new Error("Insufficient Power"); + } + const inventoryTemplate = ctx.flight?.ecs.getEntityById( + launcher.components.isTorpedoLauncher.torpedoEntity!, + ); + if (!inventoryTemplate) throw new Error("Torpedo not found"); - const torpedo = spawnTorpedo(launcher); - launcher.ecs?.addEntity(torpedo); + const torpedo = spawnTorpedo(launcher); + launcher.ecs?.addEntity(torpedo); - launcher.updateComponent("isTorpedoLauncher", { - status: "firing", - progress: launcher.components.isTorpedoLauncher.fireTime, - }); - pubsub.publish.targeting.torpedoLaunchers({ - shipId: ctx.ship!.id, - }); - pubsub.publish.starmapCore.torpedos({ - systemId: torpedo.components.position?.parentId || null, - }); - }), + const powerMultiplier = + 1 / + Math.min( + 1, + Math.max( + 0.05, + (currentPower - requiredPower) / (maxSafePower - requiredPower), + ), + ); + launcher.updateComponent("isTorpedoLauncher", { + status: "firing", + progress: + launcher.components.isTorpedoLauncher.fireTime * powerMultiplier, + }); + pubsub.publish.targeting.torpedoes.launchers({ + shipId: ctx.ship!.id, + }); + pubsub.publish.starmapCore.torpedos({ + systemId: torpedo.components.position?.parentId || null, + }); + }), + }), hull: t.procedure .filter((publish: { shipId: number }, { ctx }) => { if (publish && publish.shipId !== ctx.ship?.id) return false; @@ -269,9 +295,156 @@ export const targeting = t.router({ }); }), }), + phasers: t.router({ + list: t.procedure + .filter((publish: { shipId: number }, { ctx }) => { + if (publish && publish.shipId !== ctx.ship?.id) return false; + return true; + }) + .request(({ ctx }) => { + const systems = getShipSystems(ctx, { + systemType: "Phasers", + }).filter( + (system) => system.components.isShipSystem?.shipId === ctx.ship?.id, + ); + + return systems.flatMap((system) => { + if (!system.components.isPhasers) return []; + + return { + id: system.id, + name: system.components.identity?.name || "Phasers", + firePercent: system.components.isPhasers.firePercent, + arc: system.components.isPhasers.arc, + heading: system.components.isPhasers.headingDegree, + pitch: system.components.isPhasers.pitchDegree, + maxOutput: system.components.power?.powerSources.length || 0, + maxRange: system.components.isPhasers.maxRange, + maxArc: system.components.isPhasers.maxArc, + nominalHeat: system.components.heat?.nominalHeat || 0, + maxSafeHeat: system.components.heat?.maxSafeHeat || 1, + }; + }); + }), + /** + * All of the phasers in a system or the same system as the requesting ship + * which are currently being fired. + */ + firing: t.procedure + .input( + z + .object({ + systemId: z.number().optional(), + }) + .optional(), + ) + .filter( + ( + publish: { shipId: number; systemId: number | null }, + { ctx, input }, + ) => { + if ( + (publish && publish.shipId !== ctx.ship?.id) || + (input?.systemId && input.systemId !== publish.systemId) + ) + return false; + return true; + }, + ) + .request(({ input, ctx }) => { + const systemId = + input?.systemId || ctx.ship?.components.position?.parentId || null; + // Get all of the ships in the system + const ships: Entity[] = []; + for (const ship of ctx.flight?.ecs.componentCache.get("isShip") || []) { + if (ship.components.position?.parentId === systemId) { + ships.push(ship); + } + } + const shipIds = ships.map((ship) => ship.id); + + // Get all of the ship phasers that are currently firing + const firingPhasers: Entity[] = []; + const phaserEntities = ctx.flight?.ecs.componentCache.get("isPhasers"); + for (const phaser of phaserEntities || []) { + if ( + shipIds.includes(phaser.components.isShipSystem?.shipId || -1) && + phaser.components.isPhasers && + phaser.components.isPhasers.firePercent > 0 + ) { + firingPhasers.push(phaser); + } + } + + return firingPhasers.flatMap((phaser) => { + const target = getCurrentTarget( + phaser.components.isShipSystem?.shipId || -1, + phaser.ecs!, + ); + if (!target) return []; + return { + id: phaser.id, + shipId: phaser.components.isShipSystem?.shipId || -1, + targetId: target.id, + firePercent: phaser.components.isPhasers?.firePercent || 0, + }; + }); + }), + setArc: t.procedure + .input( + z.object({ + phaserId: z.number(), + arc: z.number(), + }), + ) + .send(({ input, ctx }) => { + const phaser = getShipSystem(ctx, { + systemId: input.phaserId, + }); + if (!phaser.components.isPhasers) + throw new Error("System is not a phaser"); + phaser.updateComponent("isPhasers", { + arc: input.arc, + }); + pubsub.publish.targeting.phasers.list({ + shipId: phaser.components.isShipSystem?.shipId || -1, + }); + }), + fire: t.procedure + .input( + z.object({ + phaserId: z.number(), + firePercent: z.number(), + }), + ) + .send(({ input, ctx }) => { + const phaser = getShipSystem(ctx, { + systemId: input.phaserId, + }); + if (!phaser.components.isPhasers) + throw new Error("System is not a phaser"); + + // TODO: Check if the phaser has sufficient power + // to be able to fire at the requested power level + phaser.updateComponent("isPhasers", { + firePercent: input.firePercent, + }); + + const ship = ctx.flight?.ecs.getEntityById( + phaser.components.isShipSystem?.shipId || -1, + ); + pubsub.publish.targeting.phasers.firing({ + shipId: ship!.id, + systemId: ship?.components.position?.parentId || null, + }); + pubsub.publish.targeting.phasers.list({ + shipId: phaser.components.isShipSystem?.shipId || -1, + }); + }), + }), stream: t.procedure.dataStream(({ entity, ctx }) => { if (!entity) return false; - return Boolean(entity.components.isShields); + return Boolean(entity.components.isShields || entity.components.isPhasers); }), }); @@ -320,7 +493,7 @@ function adjustTorpedoInventory( pubsub.publish.cargoControl.rooms({ shipId: ship.id, }); - pubsub.publish.targeting.torpedoList({ + pubsub.publish.targeting.torpedoes.list({ shipId: ship.id, }); diff --git a/client/app/cards/Targeting/index.tsx b/client/app/cards/Targeting/index.tsx index b76fbe1d..65976456 100644 --- a/client/app/cards/Targeting/index.tsx +++ b/client/app/cards/Targeting/index.tsx @@ -11,15 +11,13 @@ import { CircleGridContacts } from "../Pilot/PilotContacts"; import { q } from "@client/context/AppContext"; import { ObjectImage, useObjectData } from "../Navigation/ObjectDetails"; import { cn } from "@client/utils/cn"; -import LauncherImage from "./assets/launcher.svg"; -import href from "./assets/torpedoSprite.svg?url"; -import Button from "@thorium/ui/Button"; -import { megaWattHourToGigaJoule } from "@server/utils/unitTypes"; -import type { ShieldDirections } from "@server/classes/Plugins/ShipSystems/Shields"; -import useAnimationFrame from "@client/hooks/useAnimationFrame"; -import { useLiveQuery } from "@thorium/live-query/client"; -import chroma from "chroma-js"; - +import { Shields } from "@client/cards/Targeting/Shields"; +import { + BeamVisualization, + PhaserArcs, + Phasers, +} from "@client/cards/Targeting/Phasers"; +import { Torpedoes } from "@client/cards/Targeting/Torpedoes"; /** * TODO: * Add overlays to the targeting grid showing where the torpedo will fire from @@ -34,9 +32,11 @@ export function Targeting({ cardLoaded }: CardProps) { return (
-
+ {/* Padding is protection from the bottom of the card container */} +
Hull: {hull}
+
@@ -52,7 +52,14 @@ export function Targeting({ cardLoaded }: CardProps) { } }} > - + + + + } + > + { clickRef.current = true; @@ -91,186 +98,6 @@ export function Targeting({ cardLoaded }: CardProps) { ); } -const shieldColors = [ - "oklch(10.86% 0.045 29.25)", // Black - "oklch(66.33% 0.2823 29.25)", // Red - "oklch(76.18% 0.207 56.11)", // Orange - "oklch(86.52% 0.204 90.38", // Yellow - "oklch(86.18% 0.343 142.58)", // Green - "oklch(57.65% 0.249 256.24)", // Blue -]; -const shieldColor = (integrity: number) => { - // @ts-expect-error chroma types are wrong - it does support oklch - return chroma.scale(shieldColors).mode("oklch")(integrity).css("oklch"); -}; -const shieldStyle = ( - shields: { - strength: number; - maxStrength: number; - direction: "fore" | "aft" | "starboard" | "port" | "dorsal" | "ventral"; - }[], - extra = false, -) => { - // Creates the styles for multiple shields - const output: string[] = []; - shields.forEach((s) => { - const integrity = s.strength / s.maxStrength; - const color = shieldColor(integrity); - if ( - (s.direction === "starboard" && !extra) || - (s.direction === "fore" && extra) - ) { - output.push(`20px 0px 20px -15px ${color}`); - output.push(`inset -20px 0px 20px -15px ${color}`); - } - if ( - (s.direction === "port" && !extra) || - (s.direction === "aft" && extra) - ) { - output.push(`-20px 0px 20px -15px ${color}`); - output.push(`inset 20px 0px 20px -15px ${color}`); - } - if (s.direction === "fore" && !extra) { - output.push(`0px -20px 20px -15px ${color}`); - output.push(`inset 0px 20px 20px -15px ${color}`); - } - if (s.direction === "aft" && !extra) { - output.push(`0px 20px 20px -15px ${color}`); - output.push(`inset 0px -20px 20px -15px ${color}`); - } - if (s.direction === "ventral" && extra) { - output.push(`0px 20px 20px -15px ${color}`); - output.push(`inset 0px -20px 20px -15px ${color}`); - } - if (s.direction === "dorsal" && extra) { - output.push(`0px -20px 20px -15px ${color}`); - output.push(`inset 0px 20px 20px -15px ${color}`); - } - }); - return output.join(","); -}; - -function Shields({ cardLoaded }: { cardLoaded: boolean }) { - const [ship] = q.ship.player.useNetRequest(); - const [shields] = q.targeting.shields.get.useNetRequest(); - - const topViewRef = React.useRef(null); - const sideViewRef = React.useRef(null); - - const { interpolate } = useLiveQuery(); - useAnimationFrame(() => { - const shieldItems: { - strength: number; - maxStrength: number; - direction: "fore" | "aft" | "starboard" | "port" | "dorsal" | "ventral"; - }[] = []; - for (const shield of shields) { - const strength = interpolate(shield.id)?.x || 0; - shieldItems.push({ ...shield, strength }); - } - topViewRef.current?.style.setProperty( - "box-shadow", - shieldStyle(shieldItems), - ); - sideViewRef.current?.style.setProperty( - "box-shadow", - shieldStyle(shieldItems, true), - ); - }, cardLoaded); - if (!ship) return null; - if (shields.length === 0) return null; - return ( -
-
-
- Top -
- {shields.length === 6 ? ( -
- Side -
- ) : null} -
- {shields[0].state === "down" ? ( - - ) : ( - - )} -
- ); -} - -function Torpedoes() { - const [torpedoLaunchers] = q.targeting.torpedoLaunchers.useNetRequest(); - const [torpedoList] = q.targeting.torpedoList.useNetRequest(); - const [selectedTorpedo, setSelectedTorpedo] = React.useState( - null, - ); - - return ( - <> -
    - {Object.entries(torpedoList ?? {}).map( - ([id, { count, yield: torpedoYield, speed }]) => ( - // biome-ignore lint/a11y/useKeyWithClickEvents: -
  • setSelectedTorpedo(id)} - > -
    -
    - {id} - - Yield: {megaWattHourToGigaJoule(torpedoYield)} GJ · Speed:{" "} - {speed} km/s - -
    -
    {count}
    -
    -
  • - ), - )} -
-
- {torpedoLaunchers?.map((launcher) => ( - - ))} -
- - ); -} - function ObjectData({ objectId }: { objectId: number }) { const [object, distanceRef] = useObjectData(objectId); return object ? ( @@ -289,120 +116,3 @@ function ObjectData({ objectId }: { objectId: number }) {

Accessing...

); } - -function Launcher({ - launcherId, - name, - state, - loadTime, - selectedTorpedo, - torpedo, -}: { - launcherId: number; - state: "ready" | "loading" | "unloading" | "loaded" | "firing"; - name: string; - loadTime: number; - selectedTorpedo: string | null; - torpedo: { - casingColor: string | undefined; - guidanceColor: string | undefined; - guidanceMode: string | undefined; - warheadColor: string | undefined; - warheadDamageType: string | undefined; - } | null; -}) { - const torpedoRef = React.useRef(null); - const animationTime = - state === "loading" || state === "unloading" ? loadTime : 100; - return ( -
-

{name}

-
- Torpedo Launcher -
- {/* biome-ignore lint/a11y/noSvgWithoutTitle: */} - - - {torpedo?.warheadDamageType ? ( - - ) : null} - {torpedo?.guidanceMode ? ( - - ) : null} - -
-
-
-
- - -
-
- ); -} diff --git a/client/app/components/Starmap/StarmapShip.tsx b/client/app/components/Starmap/StarmapShip.tsx index d784bd0a..9729ef44 100644 --- a/client/app/components/Starmap/StarmapShip.tsx +++ b/client/app/components/Starmap/StarmapShip.tsx @@ -10,6 +10,7 @@ import { type MeshStandardMaterial, Object3D, type Sprite, + Vector3, } from "three"; import { useFrame } from "@react-three/fiber"; import { createAsset } from "use-asset"; @@ -53,7 +54,6 @@ export function StarmapShip({ ); const isCore = useStarmapStore((store) => store.viewingMode === "core"); const sensorsHidden = useStarmapStore((store) => store.sensorsHidden); - const group = useRef(null); const shipMesh = useRef(null); const shipSprite = useRef(null); diff --git a/client/app/cores/StarmapCore/FiringPhasers.tsx b/client/app/cores/StarmapCore/FiringPhasers.tsx new file mode 100644 index 00000000..1a7383d4 --- /dev/null +++ b/client/app/cores/StarmapCore/FiringPhasers.tsx @@ -0,0 +1,109 @@ +import { q } from "@client/context/AppContext"; +import { OrbitControls } from "@react-three/drei"; +import { useFrame } from "@react-three/fiber"; +import { useLiveQuery } from "@thorium/live-query/client"; +import { useMemo, useRef } from "react"; +import { + AdditiveBlending, + DoubleSide, + type Group, + Quaternion, + Texture, + Vector3, +} from "three"; + +const thickness = 1; + +const shipVector = new Vector3(); +const targetVector = new Vector3(); +const offsetVector = new Vector3(); +const up = new Vector3(0, 1, 0); +const axis = new Vector3(); +const direction = new Vector3(); +const quaternion = new Quaternion(); + +export function FiringPhasers() { + const [firingPhasers] = q.targeting.phasers.firing.useNetRequest(); + + return firingPhasers.map((phaser) => ( + + )); +} +function PhaserDisplay({ + targetId, + shipId, +}: { targetId: number; shipId: number }) { + const planeTexture = useMemo(() => { + const c = document.createElement("canvas").getContext("2d")!; + + c.canvas.width = 1; + c.canvas.height = 64; + + const g = c.createLinearGradient(0, 0, c.canvas.width, c.canvas.height); + + g.addColorStop(0, "rgba(0, 0, 0, 0)"); + g.addColorStop(0.35, "rgba(50, 50, 50, 0.25)"); + g.addColorStop(0.5, "rgba(255, 255, 255, 0.5)"); + g.addColorStop(0.65, "rgba(50, 50, 50, 0.25)"); + g.addColorStop(1, "rgba(0, 0, 0, 0)"); + + c.fillStyle = g; + c.fillRect(0, 0, c.canvas.width, c.canvas.height); + + const texture = new Texture(c.canvas); + texture.needsUpdate = true; + + return texture; + }, []); + + const groupRef = useRef(null); + + const { interpolate } = useLiveQuery(); + + useFrame(() => { + const ship = interpolate(shipId); + const target = interpolate(targetId); + if (!ship || !target) return; + if (!groupRef.current) return; + quaternion.set(ship.r.x, ship.r.y, ship.r.z, ship.r.w); + offsetVector.set(0, -1, 0).applyQuaternion(quaternion); + shipVector.set(ship.x, ship.y - 0.000001, ship.z).add(offsetVector); + + targetVector.set(target.x, target.y, target.z); + const distance = targetVector.distanceTo(shipVector); + groupRef.current.position.copy( + targetVector.add(shipVector).multiplyScalar(0.5), + ); + direction.subVectors(targetVector, shipVector).normalize(); + axis.crossVectors(up, direction).normalize(); + const angle = Math.acos(up.dot(direction)); + + groupRef.current.quaternion.setFromAxisAngle(axis, angle); + groupRef.current.rotateOnAxis(new Vector3(0, 0, 1), Math.PI / 2); + groupRef.current.scale.set(distance, 1, 1); + }); + return ( + + {Array.from({ length: 20 }).map((_, i) => ( + + + + + ))} + + ); +} diff --git a/client/app/cores/StarmapCore/index.tsx b/client/app/cores/StarmapCore/index.tsx index d7d0b349..1521c1e0 100644 --- a/client/app/cores/StarmapCore/index.tsx +++ b/client/app/cores/StarmapCore/index.tsx @@ -37,6 +37,7 @@ import { Tooltip } from "@thorium/ui/Tooltip"; import { Icon } from "@thorium/ui/Icon"; import { keepPreviousData } from "@tanstack/react-query"; import { Torpedo } from "@client/components/Starmap/Torpedo"; +import { FiringPhasers } from "./FiringPhasers"; export function StarmapCore() { const ref = useRef(null); @@ -462,6 +463,7 @@ export function SolarSystemWrapper() { const [torpedos] = q.starmapCore.torpedos.useNetRequest({ systemId: currentSystem, }); + const [waypoints] = q.waypoints.all.useNetRequest({ systemId: "all", }); @@ -472,7 +474,6 @@ export function SolarSystemWrapper() { const selectedObjectIds = useStarmapStore((store) => store.selectedObjectIds); const planetsHidden = useStarmapStore((store) => store.planetsHidden); const isCore = useStarmapStore((store) => store.viewingMode === "core"); - const { interpolate } = useLiveQuery(); return ( ))} + + {/* {debugSpheres.map(sphere => ( ))} */} diff --git a/client/app/data/plugins/systems/index.ts b/client/app/data/plugins/systems/index.ts index 15da8f9f..0b5f2dcb 100644 --- a/client/app/data/plugins/systems/index.ts +++ b/client/app/data/plugins/systems/index.ts @@ -18,6 +18,7 @@ import { reactor } from "./reactor"; import { battery } from "./battery"; import { torpedoLauncher } from "./torpedoLauncher"; import { shields } from "./shields"; +import { phasers } from "@client/data/plugins/systems/phasers"; const systemTypes = createUnionSchema( Object.keys(ShipSystemTypes) as (keyof typeof ShipSystemTypes)[], @@ -32,6 +33,7 @@ export const systems = t.router({ battery, torpedoLauncher, shields, + phasers, all: t.procedure .input(z.object({ pluginId: z.string() }).optional()) .filter((publish: { pluginId: string } | null, { input }) => { diff --git a/client/app/data/plugins/systems/phasers.ts b/client/app/data/plugins/systems/phasers.ts new file mode 100644 index 00000000..47b1ed01 --- /dev/null +++ b/client/app/data/plugins/systems/phasers.ts @@ -0,0 +1,76 @@ +import type TorpedoLauncherPlugin from "@server/classes/Plugins/ShipSystems/TorpedoLauncher"; +import { t } from "@server/init/t"; +import { pubsub } from "@server/init/pubsub"; +import inputAuth from "@server/utils/inputAuth"; +import { z } from "zod"; +import { + getShipSystem, + getShipSystemForInput, + pluginFilter, + systemInput, +} from "../utils"; +import type PhasersPlugin from "@server/classes/Plugins/ShipSystems/Phasers"; + +export const phasers = t.router({ + get: t.procedure + .input(systemInput) + .filter(pluginFilter) + .request(({ ctx, input }) => { + const system = getShipSystem({ input, ctx }); + + if (system.type !== "phasers") throw new Error("System is not Phasers"); + + return system as PhasersPlugin; + }), + update: t.procedure + .input( + z.object({ + pluginId: z.string(), + systemId: z.string(), + shipPluginId: z.string().optional(), + shipId: z.string().optional(), + maxRange: z.number().optional(), + maxArc: z.number().optional(), + fullChargeYield: z.number().optional(), + yieldMultiplier: z.number().optional(), + headingDegree: z.number().optional(), + pitchDegree: z.number().optional(), + }), + ) + .send(({ ctx, input }) => { + inputAuth(ctx); + const [system, override] = getShipSystemForInput<"phasers">(ctx, input); + const shipSystem = override || system; + + if (typeof input.maxRange === "number") { + shipSystem.maxRange = input.maxRange; + } + if (typeof input.maxArc === "number") { + shipSystem.maxArc = input.maxArc; + } + if (typeof input.fullChargeYield === "number") { + shipSystem.fullChargeYield = input.fullChargeYield; + } + if (typeof input.yieldMultiplier === "number") { + shipSystem.yieldMultiplier = input.yieldMultiplier; + } + if (typeof input.headingDegree === "number") { + shipSystem.headingDegree = input.headingDegree; + } + if (typeof input.pitchDegree === "number") { + shipSystem.pitchDegree = input.pitchDegree; + } + + pubsub.publish.plugin.systems.get({ + pluginId: input.pluginId, + }); + if (input.shipPluginId && input.shipId) { + pubsub.publish.plugin.ship.get({ + pluginId: input.shipPluginId, + shipId: input.shipId, + }); + } + + return shipSystem; + }), +}); diff --git a/client/app/routes/_landing/QuoteOfTheDay.tsx b/client/app/routes/_landing/QuoteOfTheDay.tsx index 1f3e6d87..00e6b107 100644 --- a/client/app/routes/_landing/QuoteOfTheDay.tsx +++ b/client/app/routes/_landing/QuoteOfTheDay.tsx @@ -334,6 +334,13 @@ const quotes = [ "And as I looked up at the sky, I thought 'I can't believe we always sleep through this.", // Swedish Proverb "Shared joy is double joy; Shared sorrow is half a sorrow.", + // Walt Whitman + "I am large. I contain multitudes", + "The past and present wilt—I have fill'd them, emptied them. And proceed to fill my next fold of the future.", + // Buddhist Proverb + "With our thoughts we create the world.", + // The Alchemist + "Every moment will soon be a memory.", ]; const QuoteOfTheDay = () => { diff --git a/client/app/routes/config+/$pluginId.systems+/SystemConfigs/phasers.tsx b/client/app/routes/config+/$pluginId.systems+/SystemConfigs/phasers.tsx new file mode 100644 index 00000000..8d3425cb --- /dev/null +++ b/client/app/routes/config+/$pluginId.systems+/SystemConfigs/phasers.tsx @@ -0,0 +1,274 @@ +import { useParams } from "@remix-run/react"; +import Input from "@thorium/ui/Input"; +import { toast } from "@client/context/ToastContext"; +import { useContext, useReducer } from "react"; +import { ShipPluginIdContext } from "@client/context/ShipSystemOverrideContext"; +import { OverrideResetButton } from "../OverrideResetButton"; +import { q } from "@client/context/AppContext"; +import { Navigate } from "@client/components/Navigate"; + +export default function PhasersConfig() { + const { pluginId, systemId, shipId } = useParams() as { + pluginId: string; + systemId: string; + shipId: string; + }; + const shipPluginId = useContext(ShipPluginIdContext); + + const [system] = q.plugin.systems.phasers.get.useNetRequest({ + pluginId, + systemId, + shipId, + shipPluginId, + }); + const [rekey, setRekey] = useReducer(() => Math.random(), Math.random()); + const key = `${systemId}${rekey}`; + if (!system) return ; + + // TODO: April 21, 2022 - Add sound effects configuration here + return ( +
+
+
+
+ { + if (!e.target.value || Number.isNaN(Number(e.target.value))) + return; + try { + await q.plugin.systems.phasers.update.netSend({ + pluginId, + systemId: systemId, + shipId, + shipPluginId, + maxRange: Number(e.target.value), + }); + } catch (err) { + if (err instanceof Error) { + toast({ + title: "Error changing max range", + body: err.message, + color: "error", + }); + } + } + }} + /> + +
+ +
+ { + if (!e.target.value || Number.isNaN(Number(e.target.value))) + return; + try { + await q.plugin.systems.phasers.update.netSend({ + pluginId, + systemId: systemId, + shipId, + shipPluginId, + maxArc: Number(e.target.value), + }); + } catch (err) { + if (err instanceof Error) { + toast({ + title: "Error changing max arc", + body: err.message, + color: "error", + }); + } + } + }} + /> + +
+ +
+ { + if (!e.target.value || Number.isNaN(Number(e.target.value))) + return; + try { + await q.plugin.systems.phasers.update.netSend({ + pluginId, + systemId: systemId, + shipId, + shipPluginId, + fullChargeYield: Number(e.target.value), + }); + } catch (err) { + if (err instanceof Error) { + toast({ + title: "Error changing full charge yield", + body: err.message, + color: "error", + }); + } + } + }} + /> + +
+ +
+ { + if (!e.target.value || Number.isNaN(Number(e.target.value))) + return; + try { + await q.plugin.systems.phasers.update.netSend({ + pluginId, + systemId: systemId, + shipId, + shipPluginId, + yieldMultiplier: Number(e.target.value), + }); + } catch (err) { + if (err instanceof Error) { + toast({ + title: "Error changing yield multiplier", + body: err.message, + color: "error", + }); + } + } + }} + /> + +
+ +
+ { + if (!e.target.value || Number.isNaN(Number(e.target.value))) + return; + try { + await q.plugin.systems.torpedoLauncher.update.netSend({ + pluginId, + systemId: systemId, + shipId, + shipPluginId, + headingDegree: Number(e.target.value), + }); + } catch (err) { + if (err instanceof Error) { + toast({ + title: "Error changing heading", + body: err.message, + color: "error", + }); + } + } + }} + /> + +
+ +
+ { + if (!e.target.value || Number.isNaN(Number(e.target.value))) + return; + try { + await q.plugin.systems.torpedoLauncher.update.netSend({ + pluginId, + systemId: systemId, + shipId, + shipPluginId, + pitchDegree: Number(e.target.value), + }); + } catch (err) { + if (err instanceof Error) { + toast({ + title: "Error changing pitch", + body: err.message, + color: "error", + }); + } + } + }} + /> + +
+
+
+
+ ); +} diff --git a/client/app/styles/theme.css b/client/app/styles/theme.css index 006e28af..27e4e361 100644 --- a/client/app/styles/theme.css +++ b/client/app/styles/theme.css @@ -235,6 +235,16 @@ kbd { @apply ring-2 ring-notice; } } + &-disabled { + @apply bg-neutral border-neutral text-neutral-content; + &:hover, + &-active { + @apply bg-neutral-focus border-neutral-focus; + } + &:focus-visible { + @apply ring-2 ring-neutral; + } + } /* glass */ &.glass { diff --git a/client/public/assets/backgrounds/background16.jpg b/client/public/assets/backgrounds/background16.jpg new file mode 100644 index 00000000..9c434787 Binary files /dev/null and b/client/public/assets/backgrounds/background16.jpg differ diff --git a/client/public/assets/backgrounds/background17.jpg b/client/public/assets/backgrounds/background17.jpg new file mode 100644 index 00000000..fc9cec28 Binary files /dev/null and b/client/public/assets/backgrounds/background17.jpg differ diff --git a/server/src/classes/FlightDataModel.ts b/server/src/classes/FlightDataModel.ts index 842b3159..d82677a4 100644 --- a/server/src/classes/FlightDataModel.ts +++ b/server/src/classes/FlightDataModel.ts @@ -110,6 +110,7 @@ export class FlightDataModel extends FSDataStore { const colliderDesc = await generateColliderDesc( path.join(thoriumPath, ship.assets.model), ship.mass, + ship.length, ); if (!colliderDesc) return; this.ecs.colliderCache.set(ship.assets.model, colliderDesc); @@ -166,7 +167,11 @@ export class FlightDataModel extends FSDataStore { } } -async function generateColliderDesc(filePath: string, mass: number) { +async function generateColliderDesc( + filePath: string, + mass: number, + size: number, +) { try { const ConvexHull = await import("three-stdlib").then( (res) => res.ConvexHull, @@ -176,6 +181,9 @@ async function generateColliderDesc(filePath: string, mass: number) { if (!gltf) { throw new Error("Failed to load gltf"); } + // This properly scales the collider to the size of the ship + // gltf.scene.children[0].scale.multiplyScalar(size / 1000); + hull.setFromObject(gltf.scene.children[0]); const vertices = []; for (const vertex of hull.vertices) { diff --git a/server/src/classes/Plugins/ShipSystems/Phasers.ts b/server/src/classes/Plugins/ShipSystems/Phasers.ts new file mode 100644 index 00000000..87941fdf --- /dev/null +++ b/server/src/classes/Plugins/ShipSystems/Phasers.ts @@ -0,0 +1,32 @@ +import type BasePlugin from ".."; +import BaseShipSystemPlugin, { registerSystem } from "./BaseSystem"; +import type { ShipSystemFlags } from "./shipSystemTypes"; + +// TODO March 16, 2022: Add the necessary sound effects +export default class PhasersPlugin extends BaseShipSystemPlugin { + static flags: ShipSystemFlags[] = ["efficiency", "heat", "power"]; + type = "phasers" as const; + allowMultiple = true; + + maxRange: number; + maxArc: number; + headingDegree: number; + pitchDegree: number; + + fullChargeYield: number; + yieldMultiplier: number; + + constructor(params: Partial, plugin: BasePlugin) { + super(params, plugin); + + this.maxRange = params.maxRange ?? 10000; + this.maxArc = params.maxArc ?? 90; + this.headingDegree = params.headingDegree ?? 0; + this.pitchDegree = params.pitchDegree ?? 0; + + this.fullChargeYield = params.fullChargeYield ?? 1; + + this.yieldMultiplier = params.yieldMultiplier ?? 1; + } +} +registerSystem("phasersPlugin", PhasersPlugin); diff --git a/server/src/classes/Plugins/ShipSystems/shipSystemTypes.ts b/server/src/classes/Plugins/ShipSystems/shipSystemTypes.ts index dd6fc544..99e90bfc 100644 --- a/server/src/classes/Plugins/ShipSystems/shipSystemTypes.ts +++ b/server/src/classes/Plugins/ShipSystems/shipSystemTypes.ts @@ -8,6 +8,7 @@ import BatteryPlugin from "./Battery"; import TorpedoLauncherPlugin from "./TorpedoLauncher"; import TargetingSystemPlugin from "./Targeting"; import ShieldsPlugin from "@server/classes/Plugins/ShipSystems/Shields"; +import PhasersPlugin from "@server/classes/Plugins/ShipSystems/Phasers"; // Make sure you update the isShipSystem component when adding a new ship system type // We can't derive the isShipSystem list from this list because ECS components @@ -23,6 +24,7 @@ export const ShipSystemTypes = { torpedoLauncher: TorpedoLauncherPlugin, targeting: TargetingSystemPlugin, shields: ShieldsPlugin, + phasers: PhasersPlugin, }; export type ShipSystemFlags = "power" | "heat" | "efficiency"; diff --git a/server/src/components/shipSystems/index.ts b/server/src/components/shipSystems/index.ts index 743f6908..32bf15c4 100644 --- a/server/src/components/shipSystems/index.ts +++ b/server/src/components/shipSystems/index.ts @@ -8,3 +8,5 @@ export * from "./powerGrid"; export * from "./isTorpedoLauncher"; export * from "./isTargeting"; export * from "./isShields"; +export * from "./isPhasers"; +export * from "./isPhaseCapacitor"; diff --git a/server/src/components/shipSystems/isPhaseCapacitor.ts b/server/src/components/shipSystems/isPhaseCapacitor.ts new file mode 100644 index 00000000..b777742f --- /dev/null +++ b/server/src/components/shipSystems/isPhaseCapacitor.ts @@ -0,0 +1,4 @@ +import z from "zod"; + +// Applied to batteries to indicate they are used for phaser systems +export const isPhaseCapacitor = z.object({}).default({}); diff --git a/server/src/components/shipSystems/isPhasers.ts b/server/src/components/shipSystems/isPhasers.ts new file mode 100644 index 00000000..d8bfdd3d --- /dev/null +++ b/server/src/components/shipSystems/isPhasers.ts @@ -0,0 +1,34 @@ +import z from "zod"; + +export const isPhasers = z + .object({ + /** Range in kilometers */ + maxRange: z.number().default(10000), + /** + * Actual range is determined by difference between + * the phaser arc and the max arc. + * eg. range = maxRange - (maxRange * (arc / (maxArc+1))) + * Add 1 to maxArc to make it so there is still some range when arc == maxArc + */ + maxArc: z.number().default(90), + /** The current arc of the phasers */ + arc: z.number().default(45), + + /** Which direction the phaser fires from, relative to the front of the ship/ + * 0 is straight ahead, 90 is to the right, 180 is behind, 270 is to the left + */ + headingDegree: z.number().default(0), + /** Angle up or down. Valid values are -90 - 90 */ + pitchDegree: z.number().default(0), + + /** + * Multiplies the power output from the phasers + * using space magic to make the phasers stronger or weaker + */ + yieldMultiplier: z.number().default(1), + /** + * What percent the phasers are currently firing at + */ + firePercent: z.number().default(0), + }) + .default({}); diff --git a/server/src/components/shipSystems/isShipSystem.ts b/server/src/components/shipSystems/isShipSystem.ts index 9fe7c341..a17b7982 100644 --- a/server/src/components/shipSystems/isShipSystem.ts +++ b/server/src/components/shipSystems/isShipSystem.ts @@ -11,6 +11,7 @@ const shipSystemTypes = z.enum([ "torpedoLauncher", "targeting", "shields", + "phasers", ]); export const isShipSystem = z diff --git a/server/src/components/shipSystems/powerGrid/isBattery.ts b/server/src/components/shipSystems/powerGrid/isBattery.ts index 98be45dc..27cb3985 100644 --- a/server/src/components/shipSystems/powerGrid/isBattery.ts +++ b/server/src/components/shipSystems/powerGrid/isBattery.ts @@ -33,11 +33,6 @@ export const isBattery = z * or equal to the length of outputAssignment. */ outputAmount: z.number().default(0), - /** - * Capacitors only discharge when toggled on. This is where that - * toggling happens. Normal batteries won't ever adjust this. - */ - discharging: z.boolean().default(true), /** * Which reactor each unit of power is coming from. One unit = 1MW */ diff --git a/server/src/init/dataStreamEntity.ts b/server/src/init/dataStreamEntity.ts index beb06c98..60a45630 100644 --- a/server/src/init/dataStreamEntity.ts +++ b/server/src/init/dataStreamEntity.ts @@ -27,17 +27,8 @@ export function dataStreamEntity(e: Entity) { }; } if (e.components.isImpulseEngines) { - let { targetSpeed, cruisingSpeed } = e.components.isImpulseEngines; - if (e.components.power) { - const { currentPower, maxSafePower, requiredPower } = - e.components.power || {}; + const { targetSpeed } = e.components.isImpulseEngines; - targetSpeed = Math.max( - 0, - cruisingSpeed * - ((currentPower - requiredPower) / (maxSafePower - requiredPower)), - ); - } return { id: e.id.toString(), x: targetSpeed, @@ -62,6 +53,41 @@ export function dataStreamEntity(e: Entity) { z: e.components.heat?.heat || 0, }; } + if (e.components.isPhasers) { + // We'll count the storage of any attached phase capacitor + const phaseCapacitors = e.components.power?.powerSources.reduce( + (prev, next) => { + if (prev.has(next)) return prev; + const entity = e.ecs?.getEntityById(next); + if ( + !entity?.components.isPhaseCapacitor || + !entity.components.isBattery + ) + return prev; + prev.set(next, { + storage: entity.components.isBattery.storage, + capacity: entity.components.isBattery.capacity, + }); + return prev; + }, + new Map(), + ); + + let chargePercent = 0; + if (phaseCapacitors) { + for (const capacitor of phaseCapacitors.values()) { + chargePercent += + capacitor.storage / capacitor.capacity / phaseCapacitors.size; + } + } + + return { + id: e.id.toString(), + x: chargePercent, + y: e.components.power?.currentPower, + z: e.components.heat?.heat || 0, + }; + } if (e.components.power) { return { id: e.id.toString(), diff --git a/server/src/init/rapier.ts b/server/src/init/rapier.ts index fbcacad0..fe19e305 100644 --- a/server/src/init/rapier.ts +++ b/server/src/init/rapier.ts @@ -155,7 +155,7 @@ export function generateRigidBody( const worldPosition = getWorldPosition(tempVector); universeToWorld(tempVector, worldPosition); - const torpedoRadius = 0.002; + const torpedoRadius = 0.02; const torpedoMass = entity.components.mass?.mass || 1500; const bodyDesc = new RAPIER.RigidBodyDesc( diff --git a/server/src/spawners/ship.ts b/server/src/spawners/ship.ts index 55a736cf..dfff43de 100644 --- a/server/src/spawners/ship.ts +++ b/server/src/spawners/ship.ts @@ -16,6 +16,8 @@ import { loadGltf } from "@server/utils/loadGltf"; import { thoriumPath } from "@server/utils/appPaths"; import { capitalCase } from "change-case"; import path from "node:path"; +import { mergeDeep } from "@server/utils/mergeDeep"; +import type PhasersPlugin from "@server/classes/Plugins/ShipSystems/Phasers"; const systemCache: Record = {}; function getSystem( @@ -82,14 +84,18 @@ export async function spawnShip( const size = await getMeshSize( template.assets?.model - ? path.join(".", thoriumPath, template.assets!.model) + ? path.join( + thoriumPath.startsWith("/") ? "" : ".", + thoriumPath, + template.assets!.model, + ) : null, ); size.multiplyScalar(template.length || 1); entity.addComponent("size", { - length: size.z, - width: size.x, - height: size.y, + length: size.x, + width: size.y, + height: size.z, }); entity.addComponent("mass", { mass: template.mass }); @@ -98,7 +104,7 @@ export async function spawnShip( entity.addComponent("nearbyObjects", { objects: new Map() }); const systemEntities: Entity[] = []; - + let phaseCapacitorCount = 0; template.shipSystems?.forEach((system) => { const systemPlugin = getSystem( dataContext, @@ -116,6 +122,7 @@ export async function spawnShip( const entity = spawnShipSystem( shipId, systemPlugin, + params.playerShip, system.overrides, ); if (entity.components.isBattery) { @@ -142,10 +149,15 @@ export async function spawnShip( ("shieldCount" in systemPlugin && systemPlugin.shieldCount) || 1; for (let i = 0; i < shieldCount; i++) { - const entity = spawnShipSystem(shipId, systemPlugin, { - ...system.overrides, - direction: shieldDirections[i], - }); + const entity = spawnShipSystem( + shipId, + systemPlugin, + params.playerShip, + { + ...system.overrides, + direction: shieldDirections[i], + }, + ); if (shieldCount > 1) { entity.updateComponent("identity", { name: `${capitalCase(shieldDirections[i])} ${ @@ -157,9 +169,57 @@ export async function spawnShip( } break; } + case "phasers": { + phaseCapacitorCount += 1; + const phaser = spawnShipSystem( + shipId, + systemPlugin, + params.playerShip, + system.overrides, + ); + + systemEntities.push(phaser); + + if (params.playerShip) { + const template = mergeDeep( + systemPlugin, + system.overrides || {}, + ) as PhasersPlugin; + + const capacitor = spawnShipSystem( + shipId, + { type: "battery" }, + params.playerShip, + {}, + ); + capacitor.updateComponent("identity", { + name: `Phase Capacitor ${phaseCapacitorCount}`, + }); + capacitor.addComponent("isPhaseCapacitor"); + capacitor.updateComponent("isBattery", { + storage: 0, + capacity: template.fullChargeYield, + outputRate: phaser.components.power?.defaultPower || 1, + chargeRate: phaser.components.power?.requiredPower || 1, + }); + systemEntities.push(capacitor); + phaser.updateComponent("power", { + powerSources: Array.from({ + length: phaser.components.power?.defaultPower || 0, + }).map(() => capacitor.id), + }); + } + + break; + } default: { // TODO: Set up power from reactors and batteries - const entity = spawnShipSystem(shipId, systemPlugin, system.overrides); + const entity = spawnShipSystem( + shipId, + systemPlugin, + params.playerShip, + system.overrides, + ); systemEntities.push(entity); break; } @@ -197,7 +257,12 @@ export async function spawnShip( ); if (systemPlugin instanceof ReactorPlugin) { Array.from({ length: systemPlugin.reactorCount }).forEach(() => { - const sys = spawnShipSystem(shipId, systemPlugin, system.overrides); + const sys = spawnShipSystem( + shipId, + systemPlugin, + params.playerShip, + system.overrides, + ); const maxOutput = reactorPower * systemPlugin.powerMultiplier; sys.updateComponent("isReactor", { maxOutput, @@ -217,6 +282,11 @@ export async function spawnShip( e.components.isReactor && getPowerSupplierPowerNeeded(e) < e.components.isReactor.maxOutput, ); + // Don't fill up phase capacitors, since that basically equates to + // having the phasers charged immediately + if (entity.components.isPhaseCapacitor) { + return; + } const reactor = randomFromList(reactors); if (!reactor) return; entity.updateComponent("isBattery", { @@ -227,6 +297,10 @@ export async function spawnShip( }); } if (entity.components.power) { + if (entity.components.isPhasers) { + // Phasers are powered by phase capacitors, skip + return; + } for (let i = 0; i < entity.components.power.defaultPower; i++) { const reactors = systemEntities.filter( (e) => @@ -372,5 +446,10 @@ async function getMeshSize(url: string | null): Promise { if (!gltf) return new Vector3(); const box = new Box3().setFromObject(gltf.scene.children[0]); - return box.getSize(new Vector3()); + const vector = box.getSize(new Vector3()).normalize(); + const { x } = vector; + // Rearrange the vector to match the orientation of the ship + vector.normalize().multiplyScalar(1 / x); + + return vector; } diff --git a/server/src/spawners/shipSystem.ts b/server/src/spawners/shipSystem.ts index 1d853156..bfb56439 100644 --- a/server/src/spawners/shipSystem.ts +++ b/server/src/spawners/shipSystem.ts @@ -7,6 +7,7 @@ import { mergeDeep } from "../utils/mergeDeep"; export function spawnShipSystem( shipId: number, systemPlugin: Partial, + isPlayerShip?: boolean, overrides: Record = {}, ) { const entity = new Entity(); @@ -41,23 +42,25 @@ export function spawnShipSystem( defaultPower, maxSafePower, } = systemPlugin; - if (flags.includes("heat")) - entity.addComponent("heat", { - powerToHeat: overrides.powerToHeat || powerToHeat, - heatDissipationRate: - overrides.heatDissipationRate || heatDissipationRate, - maxHeat: overrides.maxHeat || maxHeat, - maxSafeHeat: overrides.maxSafeHeat || maxSafeHeat, - nominalHeat: overrides.nominalHeat || nominalHeat, - heat: overrides.nominalHeat || nominalHeat, - }); - if (flags.includes("power")) - entity.addComponent("power", { - requiredPower: overrides.requiredPower || requiredPower, - defaultPower: overrides.defaultPower || defaultPower, - maxSafePower: overrides.maxSafePower || maxSafePower, - }); - if (flags.includes("efficiency")) entity.addComponent("efficiency"); + if (isPlayerShip) { + if (flags.includes("heat")) + entity.addComponent("heat", { + powerToHeat: overrides.powerToHeat || powerToHeat, + heatDissipationRate: + overrides.heatDissipationRate || heatDissipationRate, + maxHeat: overrides.maxHeat || maxHeat, + maxSafeHeat: overrides.maxSafeHeat || maxSafeHeat, + nominalHeat: overrides.nominalHeat || nominalHeat, + heat: overrides.nominalHeat || nominalHeat, + }); + if (flags.includes("power")) + entity.addComponent("power", { + requiredPower: overrides.requiredPower || requiredPower, + defaultPower: overrides.defaultPower || defaultPower, + maxSafePower: overrides.maxSafePower || maxSafePower, + }); + if (flags.includes("efficiency")) entity.addComponent("efficiency"); + } } return entity; diff --git a/server/src/spawners/torpedo.ts b/server/src/spawners/torpedo.ts index 52ea0abf..60bc2b1d 100644 --- a/server/src/spawners/torpedo.ts +++ b/server/src/spawners/torpedo.ts @@ -1,5 +1,4 @@ import { Entity } from "@server/utils/ecs"; -import { getShipSystem } from "@server/utils/getShipSystem"; import { Euler, Quaternion, Vector3 } from "three"; const positionVector = new Vector3(); @@ -79,6 +78,7 @@ export function spawnTorpedo(launcher: Entity) { maxRange: flags?.torpedoCasing?.maxRange || 25000, }); torpedoEntity.addComponent("mass", { mass: 1500 }); + torpedoEntity.addComponent("physicsWorld"); return torpedoEntity; } diff --git a/server/src/systems/ImpulseSystem.ts b/server/src/systems/ImpulseSystem.ts index 47dca893..1736bca6 100644 --- a/server/src/systems/ImpulseSystem.ts +++ b/server/src/systems/ImpulseSystem.ts @@ -31,12 +31,9 @@ export class ImpulseSystem extends System { if (entity.components.power) { const { currentPower, maxSafePower, requiredPower } = entity.components.power || {}; - targetSpeed = - cruisingSpeed * - (Math.max(0, currentPower - requiredPower) / - (maxSafePower - requiredPower)); + targetSpeed = cruisingSpeed * (Math.max(0, currentPower) / maxSafePower); + if (currentPower < requiredPower) targetSpeed = 0; } - const forwardImpulse = (targetSpeed / cruisingSpeed) * thrust; entity.updateComponent("isImpulseEngines", { forwardImpulse }); } diff --git a/server/src/systems/PhasersSystem.ts b/server/src/systems/PhasersSystem.ts new file mode 100644 index 00000000..bc0ffe64 --- /dev/null +++ b/server/src/systems/PhasersSystem.ts @@ -0,0 +1,116 @@ +import { pubsub } from "@server/init/pubsub"; +import { applyDamage } from "@server/utils/collisionDamage"; +import { type ECS, type Entity, System } from "@server/utils/ecs"; +import { isPointWithinCone } from "@server/utils/isPointWithinCone"; +import { degToRad, megaWattHourToGigaJoule } from "@server/utils/unitTypes"; +import { Quaternion, Vector3 } from "three"; + +export class PhasersSystem extends System { + test(entity: Entity) { + return !!entity.components.isPhasers; + } + update(entity: Entity, elapsed: number) { + const elapsedHours = elapsed / (1000 / this.frequency) / 3600; + const phasers = entity.components.isPhasers; + if (!phasers) return; + + // Phaser damage is calculated based on the power output and efficiency + // of the phaser system + const power = entity.components.power; + if (!power) return; + const efficiency = entity.components.efficiency?.efficiency ?? 1; + if (phasers.firePercent === 0) return; + if (power.currentPower === 0) { + entity.updateComponent("isPhasers", { firePercent: 0 }); + const phaserShip = entity.ecs?.getEntityById( + entity.components.isShipSystem?.shipId || -1, + ); + // TODO: Pubsub anywhere that needs to know phasers aren't firing + pubsub.publish.targeting.phasers.firing({ + shipId: phaserShip?.id || -1, + systemId: phaserShip?.components.position?.parentId || null, + }); + } + const phaserDamage = power.currentPower * efficiency * elapsedHours; + if (phaserDamage === 0) return; + + const target = getCurrentTarget( + entity.components.isShipSystem?.shipId || -1, + entity.ecs!, + ); + + if (!target) return; + // Calculate the vector between the target and the ship + const vectorBetween = getVectorBetweenTargetAndShip(entity, target); + applyDamage(target, megaWattHourToGigaJoule(phaserDamage), vectorBetween); + } +} + +const targetPosition = new Vector3(); +const shipPosition = new Vector3(); +const direction = new Vector3(); +const rotationQuaternion = new Quaternion(); + +function getVectorBetweenTargetAndShip(ship: Entity, target: Entity) { + targetPosition.set( + target.components.position?.x || 0, + target.components.position?.y || 0, + target.components.position?.z || 0, + ); + shipPosition.set( + ship.components.position?.x || 0, + ship.components.position?.y || 0, + ship.components.position?.z || 0, + ); + + return targetPosition.sub(shipPosition).normalize(); +} +export function getTargetIsInPhaserRange(phasers: Entity) { + if (!phasers.components.isPhasers) return false; + const ship = phasers.ecs?.getEntityById( + phasers.components.isShipSystem?.shipId || -1, + ); + if (!ship) return false; + const target = getCurrentTarget(ship.id, phasers.ecs!); + if (!target) return false; + + const { maxRange, arc, maxArc, headingDegree, pitchDegree } = + phasers.components.isPhasers; + const range = maxRange - maxRange * (arc / (maxArc + 1)); + + targetPosition.set( + target.components.position?.x || 0, + target.components.position?.y || 0, + target.components.position?.z || 0, + ); + shipPosition.set( + ship.components.position?.x || 0, + ship.components.position?.y || 0, + ship.components.position?.z || 0, + ); + rotationQuaternion.set( + ship.components.rotation?.x || 0, + ship.components.rotation?.y || 0, + ship.components.rotation?.z || 0, + ship.components.rotation?.w || 1, + ); + // Turn the ship rotation quaternion into a vector + direction.set(0, 0, 1).applyQuaternion(rotationQuaternion); + // Add the Phaser rotation to the ship rotation + direction.applyAxisAngle(new Vector3(0, 1, 0), degToRad(headingDegree || 0)); + direction.applyAxisAngle(new Vector3(1, 0, 0), degToRad(pitchDegree || 0)); + direction.multiplyScalar(range); + return isPointWithinCone(targetPosition, { + apex: shipPosition, + direction, + angle: degToRad(phasers.components.isPhasers?.arc || 0), + }); +} + +export function getCurrentTarget(shipId: number, ecs: ECS) { + for (const entity of ecs?.componentCache.get("isTargeting") || []) { + if (entity.components.isShipSystem?.shipId === shipId) { + return ecs?.getEntityById(entity.components.isTargeting?.target || -1); + } + } +} diff --git a/server/src/systems/PhysicsMovementSystem.ts b/server/src/systems/PhysicsMovementSystem.ts index 4c18db69..71b3ba24 100644 --- a/server/src/systems/PhysicsMovementSystem.ts +++ b/server/src/systems/PhysicsMovementSystem.ts @@ -1,14 +1,14 @@ -import { Euler, Object3D, Quaternion, Vector3 } from "three"; +import { Box3, Euler, Object3D, Quaternion, Vector3 } from "three"; import { type Entity, System } from "../utils/ecs"; import { RAPIER, getWorldPosition } from "../init/rapier"; -import { M_TO_KM } from "@server/utils/unitTypes"; +import { KM_TO_LM, M_TO_KM } from "@server/utils/unitTypes"; import { generateRigidBody, getEntityWorld, universeToWorld, worldToUniverse, } from "@server/init/rapier"; -import type { World } from "@thorium-sim/rapier3d-node"; +import type { RigidBody, World } from "@thorium-sim/rapier3d-node"; import { handleCollisionDamage, handleTorpedoDamage, @@ -53,7 +53,7 @@ export class PhysicsMovementSystem extends System { const worldEntity = getEntityWorld(this.ecs, entity); const world = worldEntity?.components.physicsWorld?.world as World; const handles = entity.components.physicsHandles?.handles || new Map(); - handles.set("blah", entity.id); + // Nab some systems to use elsewhere. const systems: Entity[] = []; entity.components.shipSystems?.shipSystems.forEach((shipSystem, id) => { @@ -78,6 +78,8 @@ export class PhysicsMovementSystem extends System { const isHighSpeed = (warpEngines?.components.isWarpEngines?.forwardVelocity || 0) > (warpEngines?.components.isWarpEngines?.solarCruisingSpeed || 0) / 2; + const isInterstellarSpace = + entity.components.position?.type === "interstellar"; if (entity.components.rotation) { const { x, y, z, w } = entity.components.rotation; @@ -142,22 +144,25 @@ export class PhysicsMovementSystem extends System { warpEngines.components.isWarpEngines.forwardVelocity, ), ); - const linvel = body.linvel(); - body.setLinvel( - { - x: warpVelocity.x + linvel.x, - y: warpVelocity.y + linvel.y, - z: warpVelocity.z + linvel.z, - }, - true, - ); + if (warpVelocity.lengthSq() > 0) { + body.setLinvel( + { + x: warpVelocity.x, + y: warpVelocity.y, + z: warpVelocity.z, + }, + true, + ); + } } /** * Impulse Engines */ body.applyImpulse( - tempObj.localToWorld(tempVector.set(0, 0, forwardImpulse)), + // I don't know why, but for some reason the impulse needs to be doubled + // to reach the expected speed. + tempObj.localToWorld(tempVector.set(0, 0, forwardImpulse * 2)), true, ); @@ -269,6 +274,12 @@ export class PhysicsMovementSystem extends System { ); } } + /** + * Translate velocity to LightMinutes if we're in interstellar space + */ + if (isInterstellarSpace) { + velocityVector.multiplyScalar(KM_TO_LM); + } /** * Apply the velocity to the position */ @@ -320,7 +331,8 @@ export class PhysicsMovementSystem extends System { world.step(eventQueue); // Copy over the properties of each of the bodies to the entities - world.bodies.forEach((body: any) => { + world.bodies.forEach((body: RigidBody) => { + // @ts-expect-error - A very narrow type const entity = this.ecs.getEntityById(body.userData?.entityId); // No need to update entities that aren't in the collision step. if (!entity || !this.collisionStepEntities.has(entity.id)) return; @@ -364,6 +376,21 @@ export class PhysicsMovementSystem extends System { const entity1 = this.ecs.getEntityById(entityId1); const entity2 = this.ecs.getEntityById(entityId2); + // Special handling to ignore torpedoes their own ships + if (entity1?.components.isTorpedo || entity2?.components.isTorpedo) { + const entity = entity1?.components.isTorpedo ? entity1 : entity2; + const otherEntity = entity1?.components.isTorpedo ? entity2 : entity1; + const launcher = this.ecs.getEntityById( + entity?.components.isTorpedo?.launcherId || -1, + ); + const ship = this.ecs.getEntityById( + launcher?.components.isShipSystem?.shipId || -1, + ); + if (ship?.id !== undefined && ship.id === otherEntity?.id) { + event.free(); + return; + } + } // This is the vector from entity1 to entity2, const direction = new Vector3( body1?.translation().x, diff --git a/server/src/systems/PhysicsWorldPositionSystem.ts b/server/src/systems/PhysicsWorldPositionSystem.ts index aa1836d9..0cf17fde 100644 --- a/server/src/systems/PhysicsWorldPositionSystem.ts +++ b/server/src/systems/PhysicsWorldPositionSystem.ts @@ -40,6 +40,7 @@ export class PhysicsWorldPositionSystem extends System { entities.forEach((entities) => { const iterator = entities.values(); const id = iterator.next().value; + if (!id) return; this.ecs.getEntityById(id)?.updateComponent("physicsWorld", { enabled: true, }); diff --git a/server/src/systems/PowerDistributionSystem.ts b/server/src/systems/PowerDistributionSystem.ts index a658b1ed..ed913b38 100644 --- a/server/src/systems/PowerDistributionSystem.ts +++ b/server/src/systems/PowerDistributionSystem.ts @@ -40,14 +40,27 @@ export class PowerDistributionSystem extends System { const { powerDraw, powerSources } = power; let suppliedPower = 0; for (let i = 0; i < powerDraw; i++) { + let powerSupply = 1; const source = powerSources[i]; if (typeof source === "number") { const sourceEntity = this.ecs.getEntityById(source); + // Phasers can only get power from phase capacitors + if ( + system.components.isPhasers && + !sourceEntity?.components.isPhaseCapacitor + ) + continue; + if (system.components.isPhasers) { + powerSupply = system.components.isPhasers.yieldMultiplier; + } + + // If the battery is empty, don't supply power if (sourceEntity?.components.isBattery?.storage === 0) continue; - suppliedPower++; + + suppliedPower += powerSupply; powerSuppliedSources.set( source, - (powerSuppliedSources.get(source) || 0) + 1, + (powerSuppliedSources.get(source) || 0) + powerSupply, ); } } diff --git a/server/src/systems/PowerDrawSystem.ts b/server/src/systems/PowerDrawSystem.ts index f349f77c..93d6a8a8 100644 --- a/server/src/systems/PowerDrawSystem.ts +++ b/server/src/systems/PowerDrawSystem.ts @@ -1,3 +1,4 @@ +import { getTargetIsInPhaserRange } from "@server/systems/PhasersSystem"; import { type Entity, System } from "../utils/ecs"; /** @@ -14,10 +15,14 @@ export class PowerDrawSystem extends System { } update(entity: Entity) { const systemType = entity.components.isShipSystem; + const ship = entity.ecs?.getEntityById(systemType?.shipId || -1); + if (!ship) return; + const power = entity.components.power; const efficiency = entity.components.efficiency?.efficiency || 1; const efficiencyMultiple = 1 / efficiency; if (!systemType?.type || !power) return; + const { maxSafePower, requiredPower, powerSources } = power; const requestedPower = powerSources.length; let powerDraw = 0; @@ -43,7 +48,9 @@ export class PowerDrawSystem extends System { break; } if (targetSpeed === 0) break; - const impulseEngineUse = targetSpeed / cruisingSpeed; + // We divide the target speed in four, but we can't go below 1/4th + // So we scale it where 0.25 is 0, and 1 is 1 + const impulseEngineUse = (targetSpeed / cruisingSpeed - 0.25) * (4 / 3); powerDraw = (maxSafePower - requiredPower) * impulseEngineUse + requiredPower; @@ -82,6 +89,33 @@ export class PowerDrawSystem extends System { } break; } + case "torpedoLauncher": { + if (!entity.components.isTorpedoLauncher) return; + const { status } = entity.components.isTorpedoLauncher; + if ( + status === "loading" || + status === "loaded" || + status === "firing" + ) { + powerDraw = requestedPower; + } else { + powerDraw = 0; + } + break; + } + case "phasers": { + // Only draw power if the current target is in range + if (!getTargetIsInPhaserRange(entity)) { + powerDraw = 0; + break; + } + powerDraw = + power.powerSources.length * + (entity.components.isPhasers?.firePercent || 0); + + break; + } + case "generic": powerDraw = requestedPower; break; @@ -89,9 +123,8 @@ export class PowerDrawSystem extends System { return; } - // Limit the power draw to the requested power, so we never go over it. entity.updateComponent("power", { - powerDraw: Math.min(requestedPower, powerDraw * efficiencyMultiple), + powerDraw: powerDraw * efficiencyMultiple, }); } } diff --git a/server/src/systems/ShieldsSystem.ts b/server/src/systems/ShieldsSystem.ts index a6c001c3..979baa2e 100644 --- a/server/src/systems/ShieldsSystem.ts +++ b/server/src/systems/ShieldsSystem.ts @@ -9,10 +9,11 @@ export class ShieldsSystem extends System { const elapsedTimeHours = elapsed / 1000 / 60 / 60; if (entity.components.power && entity.components.isShields) { - const { currentPower } = entity.components.power; + const { currentPower, requiredPower } = entity.components.power; const { state, maxStrength, strength } = entity.components.isShields; + // Some space magic to make the shields more powerful. let strengthToRecharge = currentPower * elapsedTimeHours * 10; - if (state === "down") { + if (state === "down" || currentPower < requiredPower) { // Quickly drain shields when they are down strengthToRecharge = (-maxStrength / SHIELD_DISCHARGE_TIME) * elapsed; } diff --git a/server/src/systems/TorpedoLoadingSystem.ts b/server/src/systems/TorpedoLoadingSystem.ts index c3cddb1b..2fbb4521 100644 --- a/server/src/systems/TorpedoLoadingSystem.ts +++ b/server/src/systems/TorpedoLoadingSystem.ts @@ -43,7 +43,7 @@ export class TorpedoLoadingSystem extends System { progress, ...(status === "ready" ? { torpedoEntity: null } : {}), }); - pubsub.publish.targeting.torpedoLaunchers({ + pubsub.publish.targeting.torpedoes.launchers({ shipId: entity.components.isShipSystem?.shipId || 0, }); } else { diff --git a/server/src/systems/TorpedoMovementSystem.ts b/server/src/systems/TorpedoMovementSystem.ts index d4e85864..faba367c 100644 --- a/server/src/systems/TorpedoMovementSystem.ts +++ b/server/src/systems/TorpedoMovementSystem.ts @@ -6,8 +6,8 @@ import { Vector3 } from "three"; const positionVector = new Vector3(); const targetPositionVector = new Vector3(); const velocityVector = new Vector3(); +const newVelocityVector = new Vector3(); const targetVelocityVector = new Vector3(); - export class TorpedoMovementSystem extends System { test(entity: Entity) { return !!entity.components.isTorpedo; @@ -55,7 +55,9 @@ export class TorpedoMovementSystem extends System { .sub(velocityVector) .normalize() .multiplyScalar(((maxForce * 1000) / mass) * deltaInSeconds); - velocityVector.add(steering); + newVelocityVector.addVectors(velocityVector, steering); + // Smooth out the velocity change just a little bit. + velocityVector.lerp(newVelocityVector, 0.1); entity.updateComponent("velocity", { x: velocityVector.x, diff --git a/server/src/systems/index.ts b/server/src/systems/index.ts index 714c75c7..aaa99f61 100644 --- a/server/src/systems/index.ts +++ b/server/src/systems/index.ts @@ -31,6 +31,7 @@ import { TorpedoMovementSystem } from "./TorpedoMovementSystem"; import { IsDestroyedSystem } from "./IsDestroyedSystem"; import { PowerDistributionSystem } from "@server/systems/PowerDistributionSystem"; import { ShieldsSystem } from "@server/systems/ShieldsSystem"; +import { PhasersSystem } from "@server/systems/PhasersSystem"; const systems = [ FilterInventorySystem, @@ -52,6 +53,7 @@ const systems = [ ThrusterSystem, ImpulseSystem, WarpSystem, + PhasersSystem, TorpedoMovementSystem, PhysicsWorldPositionSystem, PhysicsMovementSystem, diff --git a/server/src/utils/collisionDamage.ts b/server/src/utils/collisionDamage.ts index ee61e08d..333ce8a6 100644 --- a/server/src/utils/collisionDamage.ts +++ b/server/src/utils/collisionDamage.ts @@ -5,11 +5,7 @@ import { gigaJouleToMegaWattHour, megaWattHourToGigaJoule, } from "@server/utils/unitTypes"; -import { - getWhichShield, - ShieldDirections, -} from "@server/classes/Plugins/ShipSystems/Shields"; -import { getShipSystems } from "@server/utils/getShipSystem"; +import { getWhichShield } from "@server/classes/Plugins/ShipSystems/Shields"; export function handleCollisionDamage( entity: Entity | null, @@ -99,7 +95,6 @@ export function applyDamage( hull: entity.components.hull.hull - remainingDamage, }); pubsub.publish.targeting.hull({ shipId: entity.id }); - if (entity.components.hull.hull <= 0) { const mass = entity.components.mass?.mass || 1; const explosion = diff --git a/server/src/utils/ecs/ecs.ts b/server/src/utils/ecs/ecs.ts index 52f38c2c..05b17723 100644 --- a/server/src/utils/ecs/ecs.ts +++ b/server/src/utils/ecs/ecs.ts @@ -51,6 +51,7 @@ class ECS { for (let i = 0, entity: Entity; (entity = this.entities[i]); i += 1) { if (entity.id === id) { this.entityIndex.set(id, entity); + return entity; } } } diff --git a/server/src/utils/inputAuth.ts b/server/src/utils/inputAuth.ts index 379c2a24..7e412609 100644 --- a/server/src/utils/inputAuth.ts +++ b/server/src/utils/inputAuth.ts @@ -1,8 +1,9 @@ import type { DataContext } from "./DataContext"; export default function inputAuth(context: DataContext) { - if (!context.isHost) - throw new Error( - "Unauthorized. You must be host to perform that operation.", - ); + // This is dumb. + // if (!context.isHost) + // throw new Error( + // "Unauthorized. You must be host to perform that operation.", + // ); } diff --git a/server/src/utils/isPointWithinCone.test.ts b/server/src/utils/isPointWithinCone.test.ts new file mode 100644 index 00000000..1b64c38c --- /dev/null +++ b/server/src/utils/isPointWithinCone.test.ts @@ -0,0 +1,78 @@ +import { isPointWithinCone } from "@server/utils/isPointWithinCone"; +import { degToRad } from "@server/utils/unitTypes"; +import { Vector3 } from "three"; + +describe("isPointWithinCone", () => { + it("should recognize points within the cone", () => { + expect( + isPointWithinCone(new Vector3(1, 0, 0), { + apex: new Vector3(0, 0, 0), + direction: new Vector3(2, 0, 0), + angle: degToRad(90), + }), + ).toBeTruthy(); + }); + it("should recognize a point on the edge of the cone", () => { + expect( + isPointWithinCone(new Vector3(1, 0.5, 0.5), { + apex: new Vector3(0, 0, 0), + direction: new Vector3(2, 0, 0), + angle: degToRad(90), + }), + ).toBeTruthy(); + }); + it("should recognize a point on the front edge of the cone", () => { + expect( + isPointWithinCone(new Vector3(1.9, 0.5, 0.5), { + apex: new Vector3(0, 0, 0), + direction: new Vector3(2, 0, 0), + angle: degToRad(90), + }), + ).toBeTruthy(); + }); + it("should recognize a point when the cone is pointing the other direction", () => { + expect( + isPointWithinCone(new Vector3(-1.9, 0, 0), { + apex: new Vector3(0, 0, 0), + direction: new Vector3(-2, 0, 0), + angle: degToRad(90), + }), + ).toBeTruthy(); + }); + it("should work when the apex is at a different position", () => { + expect( + isPointWithinCone(new Vector3(3, 0, 0), { + apex: new Vector3(1, 0, 0), + direction: new Vector3(2.5, 0, 0), + angle: degToRad(90), + }), + ).toBeTruthy(); + }); + it("should not recognize the point past the edge of the cone", () => { + expect( + isPointWithinCone(new Vector3(0, 2, 0.5), { + apex: new Vector3(0, 0, 0), + direction: new Vector3(2, 0, 0), + angle: degToRad(45), + }), + ).toBeFalsy(); + }); + it("should not recognize a point behind the cone", () => { + expect( + isPointWithinCone(new Vector3(-2, 0, 0), { + apex: new Vector3(0, 0, 0), + direction: new Vector3(2, 0, 0), + angle: degToRad(90), + }), + ).toBeFalsy(); + }); + it("should not recognize a point past the base of the cone", () => { + expect( + isPointWithinCone(new Vector3(3, 0, 0), { + apex: new Vector3(0, 0, 0), + direction: new Vector3(2, 0, 0), + angle: degToRad(90), + }), + ).toBeFalsy(); + }); +}); diff --git a/server/src/utils/isPointWithinCone.ts b/server/src/utils/isPointWithinCone.ts new file mode 100644 index 00000000..b599dae4 --- /dev/null +++ b/server/src/utils/isPointWithinCone.ts @@ -0,0 +1,32 @@ +import { Vector3 } from "three"; + +const apexToPoint = new Vector3(); +export function isPointWithinCone( + point: Vector3, + cone: { + apex: Vector3; + /** Direction should be a vector pointing from the apex to the base whose length is the height of the cone */ + direction: Vector3; + angle: number; + }, +): boolean { + const axis = cone.direction.clone().normalize(); + + // Calculate the vector from the point to the apex + apexToPoint.subVectors(point, cone.apex); + + // Calculate the angle between the apex and apexToPoint + const angleToAxis = axis.angleTo(apexToPoint); + + // Check if the angle is within the cone's angle + if (angleToAxis > cone.angle / 2) { + return false; + } + + // Check if the point is in front of the cone's apex + // This is the case if the dot product is greater than 0 (less than 0 is behind the cone's apex) + const pointDistance = apexToPoint.dot(axis); + const isInFrontOfAxis = pointDistance >= 0; + const withinRange = pointDistance <= cone.direction.length(); + return isInFrontOfAxis && withinRange; +}