diff --git a/app/.server/classes/FlightDataModel.ts b/app/.server/classes/FlightDataModel.ts index 33fcc483..e0e1c28d 100644 --- a/app/.server/classes/FlightDataModel.ts +++ b/app/.server/classes/FlightDataModel.ts @@ -1,4 +1,4 @@ -import { ECS, type Entity } from "@thorium/utils/ecs"; +import { ECS, Entity } from "@thorium/utils/ecs"; import randomWords from "@thorium/utils/random-words"; import type { ServerDataModel } from "./ServerDataModel"; import { DataStore, type DataStoreOptions } from "@thorium/utils/.server/db-fs"; @@ -15,7 +15,10 @@ export class FlightDataModel extends DataStore { paused!: boolean; hasFlightDirector!: boolean; state!: "in-progress" | "success" | "failure"; + /** Why the flight was successful or failed */ stateReason!: string; + /** How long destroyed player ships must wait before respawning. Null means no respawns. */ + defaultRespawnTimeMs!: null | number; ecs!: ECS; pluginIds!: string[]; private entities!: Entity[]; @@ -79,7 +82,16 @@ export class FlightDataModel extends DataStore { await this.#getDataPromise; this.ecs = new ECS(server); initECS(this.ecs, this.entities, this.mode); - this.run(); + if (!this.flightEntity) { + const flight = new Entity(); + flight.addComponent("isFlight", { + respawnTimeMs: this.defaultRespawnTimeMs, + }); + this.ecs.addEntity(flight); + } + if (!this.interval) { + this.run(); + } } async initPhysics() { // Fetch and calculate all of the colliders for the ships in the plugins @@ -130,6 +142,9 @@ export class FlightDataModel extends DataStore { async getSnapshots() { return DataStore.operations.getStore()!.getFlightSnapshots(this.name); } + get flightEntity() { + return this.ecs.componentCache.get("isFlight")?.values().next().value; + } /** * Ships that are available for spawning in the universe, based on the flight's plugins. */ @@ -170,6 +185,7 @@ export class FlightDataModel extends DataStore { state: this.state, stateReason: this.stateReason, startInput: this.startInput, + defaultRespawnTimeMs: this.defaultRespawnTimeMs, }; return data; } diff --git a/app/.server/data/ship.ts b/app/.server/data/ship.ts index d532a179..24285004 100644 --- a/app/.server/data/ship.ts +++ b/app/.server/data/ship.ts @@ -10,6 +10,7 @@ import { } from "@thorium/utils/starmap/position"; import { Vector3 } from "three"; import type { DataContext } from "@thorium/.server/DataContext"; +import { destroyShip } from "@thorium/utils/.server/ship/collisionDamage"; export const ship = t.router({ get: t.procedure @@ -112,7 +113,10 @@ export const ship = t.router({ if (!ship) return { id: input?.playerShipId || -1, + name: "", + registry: "", currentSystem: null, + alertLevel: "5", systemPosition: { parentId: null, type: "interstellar", @@ -128,10 +132,13 @@ export const ship = t.router({ const assets = ship.components.isShip!.assets; return { id: ship.id, + name: ship.components.identity!.name, + registry: ship.components.isShip!.registry, + alertLevel: ship.components.isShip!.alertLevel, currentSystem: systemId || null, systemPosition, assets, - isDestroyed: !!ship.components.isDestroyed, + isDestroyed: ship.components.isDestroyed, }; }), spawn: t.procedure @@ -245,6 +252,15 @@ export const ship = t.router({ }); return { id: shipEntity.id }; }), + destroy: t.procedure + .meta({ action: true }) + .input(z.object({ shipId: z.number() })) + .send(({ ctx, input }) => { + const ship = ctx.ecs.getEntityById(input.shipId); + if (!ship) throw new Error("Ship not found"); + + destroyShip(ship); + }), }); const objectPosition = new Vector3(); diff --git a/app/.server/systems/IsDestroyedSystem.ts b/app/.server/systems/IsDestroyedSystem.ts index fb58e55a..53c97978 100644 --- a/app/.server/systems/IsDestroyedSystem.ts +++ b/app/.server/systems/IsDestroyedSystem.ts @@ -1,6 +1,10 @@ import { pubsub } from "@thorium/.server/init/pubsub"; import { type Entity, System } from "@thorium/utils/ecs"; import type { World } from "@thorium-sim/rapier3d-node"; +import { + getCompletePositionFromOrbit, + getObjectOffsetPosition, +} from "@thorium/utils/starmap/position"; export class IsDestroyedSystem extends System { static flightMode = ["nova"]; @@ -9,44 +13,112 @@ export class IsDestroyedSystem extends System { } update(entity: Entity, elapsed: number) { const { timeToDestroy, timer } = entity.components.isDestroyed!; - if (timeToDestroy > 0) { + if (typeof timeToDestroy === "number" && timeToDestroy > 0) { entity.updateComponent("isDestroyed", { timer: timer + elapsed }); - if (timer + elapsed >= timeToDestroy) { - // Remove the entity from any physics worlds it is a part of - const handles = entity.components.physicsHandles?.handles as Map< - number, - number - >; - for (const [worldEntityId, handle] of handles.entries()) { - const worldEntity = this.ecs.getEntityById(worldEntityId); - if (!worldEntity) continue; - const world = worldEntity.components.physicsWorld?.world as World; - if (!world) continue; - const body = world.getRigidBody(handle); - if (!body) continue; - world.removeRigidBody(body); + if (!entity.components.isPlayerShip) { + deleteShip(entity); + } else { + respawnShip(entity); } - const systemId = entity.components.position?.parentId || null; - this.ecs.removeEntity(entity); + } + } + } +} + +function deleteShip(entity: Entity) { + // Remove the entity from any physics worlds it is a part of + const handles = entity.components.physicsHandles?.handles as Map< + number, + number + >; + for (const [worldEntityId, handle] of handles.entries()) { + const worldEntity = entity.ecs.getEntityById(worldEntityId); + if (!worldEntity) continue; + const world = worldEntity.components.physicsWorld?.world as World; + if (!world) continue; + const body = world.getRigidBody(handle); + if (!body) continue; + world.removeRigidBody(body); + } + const systemId = entity.components.position?.parentId || null; + entity.ecs.removeEntity(entity); + + if (entity.components.isTorpedo) { + pubsub.publish.starmapCore.torpedos({ + systemId, + }); + } + if (entity.components.isShip) { + // Also remove all the ship systems and crew + // TODO April 25, 2025 - Remove Crew + for (const shipSystem of entity.components.shipSystems?.shipSystems || []) { + entity.ecs.removeEntityById(shipSystem[0]); + } + pubsub.publish.starmapCore.ships({ + systemId, + }); + } +} - if (entity.components.isTorpedo) { - pubsub.publish.starmapCore.torpedos({ - systemId, - }); +function respawnShip(entity: Entity) { + // Bring the ship back from the dead. + // Restore hull entirely, bring all + // non-vulnerable systems back to operational, + // and move the ship to a more safe location, in case of collision. + entity.removeComponent("isDestroyed"); + entity.updateComponent("hull", { + hull: entity.components.hull?.maxHull || 1, + }); + + // Repair the ship systems + for (const systemId of entity.components.shipSystems?.shipSystems.keys() || + []) { + const system = entity.ecs.getEntityById(systemId); + if (!system?.components.isShipSystem || !system.components.damage) continue; + if (system.components.damage.vulnerability === "vulnerable") continue; + // Fix the system a bunch, but not all the way. + system.updateComponent("damage", { + offline: false, + efficiency: 1, + cascadeRisk: 0, + instability: 0, + heatMultiplier: 1, + }); + } + const position = entity.components.position!; + + // Find a point 1,000km from any large object and set the position to that + let closestPlanet: Entity | null = null; + let previousDistance = Number.POSITIVE_INFINITY; + for (const e of entity.ecs.componentCache.get("position") || []) { + if ( + e.components.position?.parentId === entity.components.position?.parentId + ) { + if (e.components.isPlanet || e.components.isStarbase) { + if (!closestPlanet) { + closestPlanet = e; + continue; } - if (entity.components.isShip) { - // Also remove all the ship systems and crew - // TODO April 25, 2025 - Remove Crew - for (const shipSystem of entity.components.shipSystems?.shipSystems || - []) { - this.ecs.removeEntityById(shipSystem[0]); - } - pubsub.publish.starmapCore.ships({ - systemId, - }); + const p = getCompletePositionFromOrbit(e); + const distance = Math.hypot( + position.x - p.x, + position.y - p.y, + position.z - p.z, + ); + if (distance < previousDistance) { + previousDistance = distance; + closestPlanet = e; } } } } + + // If we can't find a close by planet, then we'll just keep the current position. How bad could it be? + if (closestPlanet) { + const { x, y, z } = getObjectOffsetPosition(closestPlanet, position, 5_000); + entity.updateComponent("position", { x, y, z }); + } + + pubsub.publish.ship.player({ shipId: entity.id }); } diff --git a/app/cards/Login/index.tsx b/app/cards/Login/index.tsx index e07ecfde..0a02932b 100644 --- a/app/cards/Login/index.tsx +++ b/app/cards/Login/index.tsx @@ -1,12 +1,13 @@ import { clientId, q } from "@thorium/context/AppContext"; +import { useStation } from "@thorium/routes/station/useStation"; import Button from "@thorium/ui/Button"; import Input from "@thorium/ui/Input"; import { useState } from "react"; const Login = () => { + const { ship } = useStation(); const [loginName, setLoginName] = useState(""); // TODO: Support logging in with a ThoriumSim account - const [ship] = q.ship.get.useNetRequest({ clientId }); const login = () => { if (loginName.trim().length > 0) { // TODO: Play a sound effect when the user logs in @@ -16,17 +17,19 @@ const Login = () => { if (!ship) throw new Error("Station is not assigned to a ship."); return (
- {ship.components.identity?.name} + {ship.assets?.logo ? ( + {ship.name} + ) : null}

- {ship.components.identity?.name} + {ship.name}

- {ship.components.isShip?.registry} + {ship.registry}

{ diff --git a/app/components/Station/StationLayout.tsx b/app/components/Station/StationLayout.tsx index 5f070bf6..6d97594f 100644 --- a/app/components/Station/StationLayout.tsx +++ b/app/components/Station/StationLayout.tsx @@ -6,19 +6,18 @@ import { CardSwitcher } from "./CardSwitcher"; import { useManageCard } from "./useManageCard"; import { Widgets } from "./widgets"; import { cn } from "@thorium/utils/cn"; +import { useStation } from "@thorium/routes/station/useStation"; +import { useRef } from "react"; +import useAnimationFrame from "@thorium/hooks/useAnimationFrame"; const StationLayout = () => { - const [flight] = q.flight.active.useNetRequest(); - const [ship] = q.ship.get.useNetRequest({ clientId }); - const [client] = q.client.get.useNetRequest({ clientId }); - const [station] = q.station.get.useNetRequest({ clientId }); + const { client, station, ship } = useStation(); const [theme] = q.theme.get.useNetRequest({ clientId }); const [card, changeCard] = useManageCard(); const { account } = useThoriumAccount(); if (!ship) return null; - const alertLevel = ship.components.isShip?.alertLevel || "5"; - + const alertLevel = ship.alertLevel; return (
{ className={`alertLevel-${alertLevel} h-full`} style={ { - "--ship-name-width": `${ship.components.identity?.name.length}ch`, + "--ship-name-width": `${ship.name.length}ch`, "--station-name-width": `${station.name.length}ch`, "--card-name-width": `${card.name.length}ch`, "--login-name-width": `${client.loginName?.length || 0}ch`, @@ -40,14 +39,12 @@ const StationLayout = () => {
-
- {ship.components.identity?.name} -
- {ship.components.isShip?.assets.logo && ( +
{ship.name}
+ {ship.assets?.logo && (
)} @@ -97,27 +94,7 @@ const StationLayout = () => {
-
-
-

- Mission {flight?.state === "success" ? "Success" : "Failure"} -

-

{flight?.stateReason}

-
-
+
@@ -127,4 +104,71 @@ const StationLayout = () => { ); }; +function FlightStatus() { + const [flight] = q.flight.active.useNetRequest(); + const { ship } = useStation(); + + return ( +
+ {flight?.state !== "in-progress" ? ( +
+

+ Mission {flight?.state === "success" ? "Success" : "Failure"} +

+

{flight?.stateReason}

+
+ ) : ship.isDestroyed ? ( +
+

Ship Destroyed

+ {ship.isDestroyed?.timeToDestroy !== null ? ( +

+ Respawn in{" "} + +

+ ) : ( +

Game Over

+ )} +
+ ) : null} +
+ ); +} + +function Countdown({ + startTime, + duration, +}: { startTime: number; duration: number }) { + const ref = useRef(null); + + useAnimationFrame(() => { + if (ref.current) { + ref.current.textContent = Math.max( + 0, + (duration - (Date.now() - startTime)) / 1000, + ).toFixed(1); + } + }); + return ( + + {((duration - (Date.now() - startTime)) / 1000).toFixed(1)} + + ); +} + export default StationLayout; diff --git a/app/cores/StarmapCore/data.server.tsx b/app/cores/StarmapCore/data.server.tsx index b23cdeeb..f1c2f587 100644 --- a/app/cores/StarmapCore/data.server.tsx +++ b/app/cores/StarmapCore/data.server.tsx @@ -200,7 +200,7 @@ export const starmapCore = t.router({ components.position?.type === "interstellar")) ) { if ( - components.isDestroyed && + typeof components.isDestroyed?.timeToDestroy === "number" && components.isDestroyed.timer > components.isDestroyed.timeToDestroy ) continue; diff --git a/app/ecs-components/isDestroyed.ts b/app/ecs-components/isDestroyed.ts index 5a4dc453..c54f22b3 100644 --- a/app/ecs-components/isDestroyed.ts +++ b/app/ecs-components/isDestroyed.ts @@ -2,7 +2,8 @@ import z from "zod"; export const isDestroyed = z .object({ - timeToDestroy: z.number().default(0), + destroyedTimestamp: z.number().default(0), + timeToDestroy: z.number().nullish().default(0), timer: z.number().default(0), explosion: z.enum(["none", "small", "medium", "large"]).default("none"), }) diff --git a/app/ecs-components/isFlight.ts b/app/ecs-components/isFlight.ts index 5b163fae..57651e4f 100644 --- a/app/ecs-components/isFlight.ts +++ b/app/ecs-components/isFlight.ts @@ -1,6 +1,7 @@ import z from "zod"; export const isFlight = z .object({ + respawnTimeMs: z.number().nullable().default(null), value: z.boolean().default(true), }) .default({}); diff --git a/app/ecs-components/isStarbase.ts b/app/ecs-components/isStarbase.ts new file mode 100644 index 00000000..ae80256b --- /dev/null +++ b/app/ecs-components/isStarbase.ts @@ -0,0 +1,7 @@ +import z from "zod"; + +export const isStarbase = z + .object({ + value: z.literal(true).default(true), + }) + .default({}); diff --git a/app/ecs-components/list.ts b/app/ecs-components/list.ts index 8aed1e15..322d3919 100644 --- a/app/ecs-components/list.ts +++ b/app/ecs-components/list.ts @@ -60,3 +60,4 @@ export * from "./legacyCoolant"; export * from "./legacySensorContact"; export * from "./isInternalMessage"; export * from "./isDocument"; +export * from "./isStarbase"; diff --git a/app/utils/.server/ship/collisionDamage.ts b/app/utils/.server/ship/collisionDamage.ts index c2e991e6..1ff5562e 100644 --- a/app/utils/.server/ship/collisionDamage.ts +++ b/app/utils/.server/ship/collisionDamage.ts @@ -12,6 +12,7 @@ import { type damageTypes as damageType, } from "@thorium/utils/flags/damageTypes"; import type z from "zod"; +import { getShipSystem } from "@thorium/utils/.server/ship/getShipSystem"; export function handleCollisionDamage( entity: Entity | null, @@ -78,6 +79,7 @@ export function handleTorpedoDamage( ? "small" : "none"; torpedo.addComponent("isDestroyed", { + destroyedTimestamp: Date.now(), timeToDestroy: explosion !== "none" ? 5000 : 0, explosion, }); @@ -160,26 +162,86 @@ export function applyDamage( }); pubsub.publish.targeting.hull({ shipId: entity.id }); if (entity.components.hull.hull <= 0) { - const mass = entity.components.mass?.mass || 1; - const explosion = - mass > 1_000_000_000 - ? "large" - : mass > 100_000_000 - ? "medium" - : "small"; - - entity.addComponent("isDestroyed", { - timeToDestroy: 5000, - explosion, - }); - - if (entity.components.isPlayerShip) { - pubsub.publish.ship.player({ shipId: entity.id }); - } + destroyShip(entity); } } } +export function destroyShip(entity: Entity) { + const mass = entity.components.mass?.mass || 1; + const explosion = + mass > 1_000_000_000 ? "large" : mass > 100_000_000 ? "medium" : "small"; + + const flightEntity = entity.ecs.componentCache + .get("isFlight") + ?.values() + .next().value; + + entity.addComponent("isDestroyed", { + destroyedTimestamp: Date.now(), + timeToDestroy: entity.components.isPlayerShip + ? flightEntity?.components.isFlight?.respawnTimeMs + : 5000, + explosion, + }); + + // Stop the engines + const impulse = getShipSystem(entity.ecs, { + systemType: "impulseEngines", + shipId: entity.id, + }); + impulse?.updateComponent("isImpulseEngines", { targetSpeed: 0 }); + const warp = getShipSystem(entity.ecs, { + systemType: "warpEngines", + shipId: entity.id, + }); + warp?.updateComponent("isWarpEngines", { currentWarpFactor: 0 }); + const thrusters = getShipSystem(entity.ecs, { + systemType: "thrusters", + shipId: entity.id, + }); + thrusters?.updateComponent("isThrusters", { + direction: { x: 0, y: 0, z: 0 }, + rotationDelta: { x: 0, y: 0, z: 0 }, + }); + + entity.updateComponent("autopilot", { + destinationWaypointId: null, + desiredCoordinates: undefined, + desiredRotation: null, + desiredSolarSystemId: undefined, + path: [], + nextCoordinates: null, + rotationAutopilot: false, + forwardAutopilot: false, + }); + pubsub.publish.pilot.impulseEngines.get({ + shipId: entity.id, + systemId: impulse.id, + }); + pubsub.publish.pilot.warpEngines.get({ + shipId: entity.id, + systemId: warp.id, + }); + pubsub.publish.pilot.autopilot.get({ shipId: entity.id }); + + const systemId = entity.components.position?.parentId || null; + + pubsub.publish.starmapCore.autopilot({ + systemId, + }); + if (entity.components.isPlayerShip) { + pubsub.publish.ship.player({ shipId: entity.id }); + } + if (entity.components.isTorpedo) { + pubsub.publish.starmapCore.torpedos({ + systemId, + }); + } + pubsub.publish.starmapCore.ships({ + systemId, + }); +} export function applySystemDamage( system: Entity, damage: number, diff --git a/app/utils/starmap/position.ts b/app/utils/starmap/position.ts index f285cb30..a45a6b2b 100644 --- a/app/utils/starmap/position.ts +++ b/app/utils/starmap/position.ts @@ -113,6 +113,13 @@ export function getCompletePositionFromOrbit(object: Entity) { }); return position; } + if (object.components.position) { + return new Vector3( + object.components.position.x, + object.components.position.y, + object.components.position.z, + ); + } return new Vector3(); }