From aa4a6930ba34cd054c47d38d4d230e24b9007bc8 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Wed, 18 Mar 2026 22:45:33 -0400 Subject: [PATCH] feat: tactical icons, follow-camera orbit, and ship info panel --- .../SimulationEngine.MovementSystem.cs | 8 ++ apps/viewer/src/ViewerAppController.ts | 2 + apps/viewer/src/viewerConstants.ts | 5 +- apps/viewer/src/viewerControllerFactory.ts | 11 +- apps/viewer/src/viewerControls.ts | 25 +++- .../viewer/src/viewerInteractionController.ts | 13 +- apps/viewer/src/viewerNavigationController.ts | 4 + apps/viewer/src/viewerPanels.ts | 8 +- apps/viewer/src/viewerPresentation.ts | 4 +- .../src/viewerPresentationController.ts | 5 +- apps/viewer/src/viewerSceneAppearance.ts | 10 +- apps/viewer/src/viewerSceneFactory.ts | 121 +++++------------- apps/viewer/src/viewerSceneSync.ts | 5 +- apps/viewer/src/viewerSelection.ts | 34 ++++- apps/viewer/src/viewerSystemLayer.ts | 2 +- apps/viewer/src/viewerTypes.ts | 1 + apps/viewer/src/viewerWorldPresentation.ts | 14 +- 17 files changed, 154 insertions(+), 118 deletions(-) diff --git a/apps/backend/Simulation/SimulationEngine.MovementSystem.cs b/apps/backend/Simulation/SimulationEngine.MovementSystem.cs index f92afab..4d5b251 100644 --- a/apps/backend/Simulation/SimulationEngine.MovementSystem.cs +++ b/apps/backend/Simulation/SimulationEngine.MovementSystem.cs @@ -54,6 +54,7 @@ public sealed partial class SimulationEngine // Resolve live position each frame — entities like stations orbit celestials and move every tick var targetPosition = ResolveCurrentTargetPosition(world, task); var targetCelestial = ResolveTravelTargetCelestial(world, task, targetPosition); + var distance = ship.Position.DistanceTo(targetPosition); ship.TargetPosition = targetPosition; if (ship.SystemId != task.TargetSystemId) @@ -80,6 +81,13 @@ public sealed partial class SimulationEngine return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial); } + if (targetCelestial is not null + && distance > WarpEngageDistanceKilometers + && HasShipCapabilities(ship.Definition, "warp")) + { + return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial); + } + return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold); } diff --git a/apps/viewer/src/ViewerAppController.ts b/apps/viewer/src/ViewerAppController.ts index f9204ab..44d2bac 100644 --- a/apps/viewer/src/ViewerAppController.ts +++ b/apps/viewer/src/ViewerAppController.ts @@ -111,6 +111,8 @@ export class ViewerAppController { private readonly followCameraDirection = new THREE.Vector3(0, 0.16, 1); private readonly followCameraDesiredDirection = new THREE.Vector3(0, 0.16, 1); private readonly followCameraOffset = new THREE.Vector3(); + private followOrbitYaw = 0; + private followOrbitPitch = 0.2; private readonly historyWindows: HistoryWindowState[] = []; private historyWindowCounter = 0; private historyWindowZCounter = 10; diff --git a/apps/viewer/src/viewerConstants.ts b/apps/viewer/src/viewerConstants.ts index 199fa07..df65ad9 100644 --- a/apps/viewer/src/viewerConstants.ts +++ b/apps/viewer/src/viewerConstants.ts @@ -9,6 +9,7 @@ export const NAV_DISTANCE: Record = { // Close-orbit distance when double-clicking a planet (display units). // 0.005 units = ~333 km from planet center in system space. export const NAV_DISTANCE_PLANET_ORBIT = 0.005; +export const NAV_DISTANCE_SHIP_HULL = 0.0004; export const ACTIVE_SYSTEM_DETAIL_SCALE = 10; export const GALAXY_PARALLAX_FACTOR = 0.025; @@ -17,8 +18,8 @@ export const PROJECTED_GALAXY_RADIUS = 65000; export const STAR_RENDER_SCALE = 0.18; export const PLANET_RENDER_SCALE = 0.95; export const MOON_RENDER_SCALE = 1.1; -// 0.002 units = ~133 km — allows scrolling into low orbit around planets. -export const MIN_CAMERA_DISTANCE = 0.002; +// 0.00005 units = ~3 km — allows scrolling very close to ships and structures. +export const MIN_CAMERA_DISTANCE = 0.00005; export const MAX_CAMERA_DISTANCE = 150000; export interface ZoomBlend { diff --git a/apps/viewer/src/viewerControllerFactory.ts b/apps/viewer/src/viewerControllerFactory.ts index f9c4cdb..96cc906 100644 --- a/apps/viewer/src/viewerControllerFactory.ts +++ b/apps/viewer/src/viewerControllerFactory.ts @@ -57,6 +57,8 @@ export function createViewerControllers(host: any) { getPovLevel: () => host.povLevel, getSelectedItems: () => host.selectedItems, getOrbitYaw: () => host.orbitYaw, + getFollowOrbitYaw: () => host.followOrbitYaw, + getFollowOrbitPitch: () => host.followOrbitPitch, galaxyAnchor: host.galaxyAnchor, systemAnchor: host.systemAnchor, galaxyCamera: host.galaxyLayer.camera, @@ -240,8 +242,13 @@ export function createViewerControllers(host: any) { getFollowCameraFocus: () => host.followCameraFocus, screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y), applyOrbitDelta: (delta: THREE.Vector2) => { - host.orbitYaw += delta.x * 0.008; - host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3); + if (host.cameraMode === "follow") { + host.followOrbitYaw += delta.x * 0.008; + host.followOrbitPitch = THREE.MathUtils.clamp(host.followOrbitPitch + delta.y * 0.004, 0.02, 1.45); + } else { + host.orbitYaw += delta.x * 0.008; + host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3); + } }, syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(), updatePanels: () => host.updatePanels(), diff --git a/apps/viewer/src/viewerControls.ts b/apps/viewer/src/viewerControls.ts index 65bce1d..00e8b36 100644 --- a/apps/viewer/src/viewerControls.ts +++ b/apps/viewer/src/viewerControls.ts @@ -72,7 +72,7 @@ export function toggleCameraMode(params: { return { cameraMode: "follow" as const, cameraTargetShipId: nextTargetShipId, - desiredDistance: Math.min(desiredDistance, 1800), + desiredDistance: Math.min(desiredDistance, 0.0012), }; } @@ -90,6 +90,8 @@ export function updateFollowCamera(params: { followCameraOffset: THREE.Vector3; systemAnchor: THREE.Vector3; delta: number; + followOrbitYaw: number; + followOrbitPitch: number; getAnimatedShipLocalPosition: (visual: ShipVisual) => THREE.Vector3; toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; resolveShipHeading: (visual: ShipVisual, worldPosition: THREE.Vector3) => THREE.Vector3; @@ -107,6 +109,8 @@ export function updateFollowCamera(params: { followCameraOffset, systemAnchor, delta, + followOrbitYaw, + followOrbitPitch, getAnimatedShipLocalPosition, toDisplayLocalPosition, resolveShipHeading, @@ -160,14 +164,23 @@ export function updateFollowCamera(params: { followCameraDirection.lerp(followCameraDesiredDirection, 1 - Math.exp(-delta * 5)); followCameraDirection.normalize(); - const distance = THREE.MathUtils.clamp(currentDistance * 0.72, 320, 6800); - const height = THREE.MathUtils.clamp(distance * 0.18, 70, 1100); - const lookAhead = THREE.MathUtils.clamp(distance * 0.9, 220, 2400); - followCameraOffset.copy(followCameraDirection).multiplyScalar(-distance); + const distance = THREE.MathUtils.clamp(currentDistance * 0.72, 0.00018, 0.012); + const height = THREE.MathUtils.clamp(distance * 0.14, 0.00002, 0.0012); + const lookAhead = THREE.MathUtils.clamp(distance * 2.6, 0.0006, 0.028); + + // Orbit the camera around the ship using followOrbitYaw/Pitch. + // Base direction is "behind ship" (negate heading). Yaw rotates left/right, pitch elevates. + const baseBack = followCameraDirection.clone().negate(); + const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), followOrbitYaw); + const orbitBack = baseBack.clone().applyQuaternion(yawQuat); + const cosP = Math.cos(followOrbitPitch), sinP = Math.sin(followOrbitPitch); + followCameraOffset.set(orbitBack.x * cosP, sinP, orbitBack.z * cosP).normalize().multiplyScalar(distance); followCameraOffset.y += height; const desiredPosition = shipWorldPosition.clone().add(followCameraOffset); - const desiredFocus = shipWorldPosition.clone().addScaledVector(followCameraDirection, lookAhead); + // Blend look-ahead based on how far off-axis the orbit is (full ahead when behind, ship center when in front) + const lookBlend = Math.max(0, Math.cos(followOrbitYaw)); + const desiredFocus = shipWorldPosition.clone().addScaledVector(followCameraDirection, lookAhead * lookBlend); desiredFocus.y += height * 0.28; const positionLerp = 1 - Math.exp(-delta * 6); diff --git a/apps/viewer/src/viewerInteractionController.ts b/apps/viewer/src/viewerInteractionController.ts index ce55655..b4c7191 100644 --- a/apps/viewer/src/viewerInteractionController.ts +++ b/apps/viewer/src/viewerInteractionController.ts @@ -12,7 +12,7 @@ import { toggleCameraMode, navigateFromWheel, } from "./viewerControls"; -import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT } from "./viewerConstants"; +import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants"; import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; import type { CameraMode, @@ -202,6 +202,7 @@ export class ViewerInteractionController { this.context.syncFollowStateFromSelection(); this.context.focusOnSelection({ kind: "ship", id: shipId }); this.toggleCameraMode("follow"); + this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL); this.context.updatePanels(); this.context.updateGamePanel("Live"); return; @@ -238,6 +239,16 @@ export class ViewerInteractionController { this.context.syncFollowStateFromSelection(); if (selection.kind === "planet") { this.context.setDesiredDistance(NAV_DISTANCE_PLANET_ORBIT); + this.context.updateGamePanel("Live"); + return; + } + + if (selection.kind === "ship") { + this.toggleCameraMode("follow"); + this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL); + this.context.updatePanels(); + this.context.updateGamePanel("Live"); + return; } }; diff --git a/apps/viewer/src/viewerNavigationController.ts b/apps/viewer/src/viewerNavigationController.ts index dd6c6a3..53e4544 100644 --- a/apps/viewer/src/viewerNavigationController.ts +++ b/apps/viewer/src/viewerNavigationController.ts @@ -39,6 +39,8 @@ export interface ViewerNavigationContext { getPovLevel: () => PovLevel; getSelectedItems: () => Selectable[]; getOrbitYaw: () => number; + getFollowOrbitYaw: () => number; + getFollowOrbitPitch: () => number; galaxyAnchor: THREE.Vector3; systemAnchor: THREE.Vector3; galaxyCamera: THREE.PerspectiveCamera; @@ -126,6 +128,8 @@ export class ViewerNavigationController { followCameraOffset: this.context.followCameraOffset, systemAnchor: this.context.systemAnchor, delta, + followOrbitYaw: this.context.getFollowOrbitYaw(), + followOrbitPitch: this.context.getFollowOrbitPitch(), getAnimatedShipLocalPosition, toDisplayLocalPosition: (localPosition) => toDisplayLocalPosition(localPosition), resolveShipHeading: (visual, worldPosition) => resolveShipHeading(visual, worldPosition, this.context.getOrbitYaw()), diff --git a/apps/viewer/src/viewerPanels.ts b/apps/viewer/src/viewerPanels.ts index 66a48c5..55078a9 100644 --- a/apps/viewer/src/viewerPanels.ts +++ b/apps/viewer/src/viewerPanels.ts @@ -5,7 +5,7 @@ import { formatSystemDistance, inventoryAmount, } from "./viewerMath"; -import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipObjective, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection"; +import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection"; import type { CameraMode, HistoryWindowState, @@ -197,13 +197,15 @@ export function updateDetailPanel( const parent = describeSelectionParent(selected); const cargoUsed = ship.inventory.reduce((sum, e) => sum + e.amount, 0); const shipState = describeShipState(world, ship); + const shipBehavior = describeShipBehavior(ship); + const shipOrder = describeShipOrder(ship); const shipAction = describeShipCurrentAction(ship); detailTitleEl.textContent = ship.label; detailBodyEl.innerHTML = `

Parent ${parent}

+

Behavior ${shipBehavior}

State ${shipState}

- ${ship.commanderObjective ? `

Objective ${describeShipObjective(ship.commanderObjective)}

` : ""} -

Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}

+

Order ${shipOrder}

Task ${ship.controllerTaskKind}

${shipAction ? `
diff --git a/apps/viewer/src/viewerPresentation.ts b/apps/viewer/src/viewerPresentation.ts index 98ff27f..c62e2e6 100644 --- a/apps/viewer/src/viewerPresentation.ts +++ b/apps/viewer/src/viewerPresentation.ts @@ -4,8 +4,8 @@ import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeS import type { PlanetVisual, ShipVisual, SystemVisual, WorldState } from "./viewerTypes"; import { rawObject } from "./viewerScenePrimitives"; -const MIN_ICON_PIXELS = 25; -const MAX_ICON_PIXELS = 50; +export const MIN_ICON_PIXELS = 25; +export const MAX_ICON_PIXELS = 50; export function iconWorldScale(distToCamera: number, camera: THREE.PerspectiveCamera, pixels: number): number { return pixels * distToCamera * 2 * Math.tan((camera.fov * Math.PI / 180) / 2) / window.innerHeight; diff --git a/apps/viewer/src/viewerPresentationController.ts b/apps/viewer/src/viewerPresentationController.ts index c6b500a..6b42dcd 100644 --- a/apps/viewer/src/viewerPresentationController.ts +++ b/apps/viewer/src/viewerPresentationController.ts @@ -9,7 +9,7 @@ import { import { updatePlanetPresentation } from "./viewerPresentation"; import { renderRecentEvents, updateGameStatus, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation"; import { updateSystemPanel } from "./viewerPanels"; -import { createBackdropStars, createMilkyWayBand, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory"; +import { createBackdropStars, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory"; import type { Selectable } from "./viewerTypes"; export interface ViewerPresentationContext { @@ -45,13 +45,12 @@ export interface ViewerPresentationContext { } export class ViewerPresentationController { - constructor(private readonly context: ViewerPresentationContext) {} + constructor(private readonly context: ViewerPresentationContext) { } initializeAmbience() { this.context.ambienceGroup.renderOrder = -10; this.context.ambienceGroup.add(createBackdropStars(document)); this.context.ambienceGroup.add(...createNebulaClouds(createNebulaTexture(document))); - this.context.ambienceGroup.add(createMilkyWayBand(document)); } updateAmbience(_delta: number) { diff --git a/apps/viewer/src/viewerSceneAppearance.ts b/apps/viewer/src/viewerSceneAppearance.ts index e1e3f5a..fa1e042 100644 --- a/apps/viewer/src/viewerSceneAppearance.ts +++ b/apps/viewer/src/viewerSceneAppearance.ts @@ -4,15 +4,15 @@ import type { ShipSnapshot } from "./contracts"; export function shipSize(ship: ShipSnapshot) { switch (ship.class) { case "capital": - return 18; + return 0.018; case "cruiser": - return 13; + return 0.012; case "destroyer": - return 10; + return 0.009; case "industrial": - return 11; + return 0.01; default: - return 8; + return 0.007; } } diff --git a/apps/viewer/src/viewerSceneFactory.ts b/apps/viewer/src/viewerSceneFactory.ts index 61dd25d..e3d4097 100644 --- a/apps/viewer/src/viewerSceneFactory.ts +++ b/apps/viewer/src/viewerSceneFactory.ts @@ -293,62 +293,6 @@ function createStarSparkleTexture(documentRef: Document): THREE.CanvasTexture { return texture; } -function createMilkyWayTexture(documentRef: Document): THREE.CanvasTexture { - const canvas = documentRef.createElement("canvas"); - canvas.width = 1024; - canvas.height = 256; - const context = canvas.getContext("2d"); - if (!context) { - throw new Error("Unable to create milky way texture"); - } - - const background = context.createLinearGradient(0, 0, 1024, 0); - background.addColorStop(0, "rgba(0,0,0,0)"); - background.addColorStop(0.1, "rgba(150,110,255,0.08)"); - background.addColorStop(0.32, "rgba(120,210,255,0.14)"); - background.addColorStop(0.5, "rgba(255,240,220,0.28)"); - background.addColorStop(0.68, "rgba(255,165,210,0.16)"); - background.addColorStop(0.88, "rgba(115,155,255,0.08)"); - background.addColorStop(1, "rgba(0,0,0,0)"); - context.fillStyle = background; - context.fillRect(0, 0, 1024, 256); - - for (let index = 0; index < 220; index += 1) { - const x = THREE.MathUtils.randFloat(0, 1024); - const y = 128 + THREE.MathUtils.randFloatSpread(78); - const radiusX = THREE.MathUtils.randFloat(40, 180); - const radiusY = THREE.MathUtils.randFloat(8, 28); - const alpha = THREE.MathUtils.randFloat(0.025, 0.09); - const hue = THREE.MathUtils.randFloat(0.52, 0.76); - const color = new THREE.Color().setHSL(hue, THREE.MathUtils.randFloat(0.25, 0.6), THREE.MathUtils.randFloat(0.72, 0.9)); - const puff = context.createRadialGradient(x, y, 0, x, y, radiusX); - puff.addColorStop(0, `rgba(${Math.round(color.r * 255)},${Math.round(color.g * 255)},${Math.round(color.b * 255)},${alpha})`); - puff.addColorStop(0.55, `rgba(${Math.round(color.r * 255)},${Math.round(color.g * 255)},${Math.round(color.b * 255)},${alpha * 0.45})`); - puff.addColorStop(1, "rgba(0,0,0,0)"); - context.save(); - context.translate(x, y); - context.scale(1, radiusY / radiusX); - context.fillStyle = puff; - context.beginPath(); - context.arc(0, 0, radiusX, 0, Math.PI * 2); - context.fill(); - context.restore(); - } - - for (let index = 0; index < 540; index += 1) { - const x = THREE.MathUtils.randFloat(0, 1024); - const y = 128 + THREE.MathUtils.randFloatSpread(54); - const alpha = THREE.MathUtils.randFloat(0.12, 0.65); - const size = THREE.MathUtils.randFloat(0.8, 2.4); - context.fillStyle = `rgba(255,255,255,${alpha})`; - context.fillRect(x, y, size, size); - } - - const texture = new THREE.CanvasTexture(canvas); - texture.needsUpdate = true; - return texture; -} - function sampleBackdropStarColor(): THREE.Color { const roll = Math.random(); if (roll < 0.1) { @@ -595,39 +539,6 @@ export function createNebulaClouds(texture: THREE.Texture): THREE.Sprite[] { }); } -export function createMilkyWayBand(documentRef: Document): THREE.Group { - const radius = 33800; - const texture = createMilkyWayTexture(documentRef); - const root = new THREE.Group(); - const planeNormal = new THREE.Vector3(0.24, 0.92, -0.3).normalize(); - const tangent = new THREE.Vector3().crossVectors(planeNormal, new THREE.Vector3(0, 0, 1)); - if (tangent.lengthSq() < 1e-6) { - tangent.set(1, 0, 0); - } - tangent.normalize(); - const bitangent = new THREE.Vector3().crossVectors(planeNormal, tangent).normalize(); - - for (let index = 0; index < 8; index += 1) { - const angle = (index / 8) * Math.PI * 2; - const direction = tangent.clone().multiplyScalar(Math.cos(angle)).add(bitangent.clone().multiplyScalar(Math.sin(angle))); - const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ - map: texture, - transparent: true, - opacity: index % 2 === 0 ? 0.22 : 0.15, - depthWrite: false, - blending: THREE.AdditiveBlending, - color: index % 3 === 0 ? "#ffd3f1" : index % 3 === 1 ? "#c8d8ff" : "#ffffff", - fog: false, - })); - sprite.position.copy(direction.multiplyScalar(radius)); - sprite.scale.set(16500, 4300 + (index % 3) * 800, 1); - sprite.material.rotation = angle + Math.PI / 2; - root.add(sprite); - } - - return root; -} - export function createTacticalIcon(documentRef: Document, color: string, size: number): SceneNode { const canvas = documentRef.createElement("canvas"); canvas.width = 64; @@ -657,6 +568,38 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n return createSceneNode(sprite); } +export function createShipTacticalIcon(documentRef: Document, color: string, size: number): SceneNode { + const canvas = documentRef.createElement("canvas"); + canvas.width = 128; + canvas.height = 96; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to create ship tactical icon"); + } + + context.clearRect(0, 0, canvas.width, canvas.height); + context.strokeStyle = color; + context.fillStyle = "rgba(7, 16, 30, 0.7)"; + context.lineWidth = 5; + + context.beginPath(); + context.arc(34, 48, 18, 0, Math.PI * 2); + context.stroke(); + + const texture = new THREE.CanvasTexture(canvas); + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthWrite: false, + depthTest: false, + color: "#ffffff", + })); + sprite.center.set(0.28, 0.5); + sprite.scale.set(size * 1.7, size * 1.275, 1); + sprite.visible = false; + return createSceneNode(sprite); +} + export function createStarDot(documentRef: Document, color: string): SceneNode { const canvas = documentRef.createElement("canvas"); diff --git a/apps/viewer/src/viewerSceneSync.ts b/apps/viewer/src/viewerSceneSync.ts index cfdc391..8b1ed2e 100644 --- a/apps/viewer/src/viewerSceneSync.ts +++ b/apps/viewer/src/viewerSceneSync.ts @@ -50,6 +50,7 @@ import { createPlanetTexture, createShellReticle, createShipMesh, + createShipTacticalIcon, createCelestialMesh, createStarCluster, createStarDot, @@ -370,7 +371,8 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick for (const ship of ships) { const mesh = createShipMesh(ship, context.shipSize(ship), context.shipLength(ship), context.shipPresentationColor(ship)); const shipColor = context.shipPresentationColor(ship); - const icon = createTacticalIcon(context.documentRef, shipColor, 90); + const iconBaseScale = 78; + const icon = createShipTacticalIcon(context.documentRef, shipColor, iconBaseScale); const localPosition = toThreeVector(ship.localPosition); const displayPos = toSystemPos(localPosition); mesh.setPosition(displayPos); @@ -386,6 +388,7 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick systemId: ship.systemId, mesh, icon, + iconBaseScale, startPosition: localPosition.clone(), authoritativePosition: localPosition.clone(), targetPosition: toThreeVector(ship.targetLocalPosition), diff --git a/apps/viewer/src/viewerSelection.ts b/apps/viewer/src/viewerSelection.ts index 114e577..6c40d6b 100644 --- a/apps/viewer/src/viewerSelection.ts +++ b/apps/viewer/src/viewerSelection.ts @@ -46,7 +46,18 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab } if (item.kind === "ship") { - return world.ships.get(item.id)?.label ?? item.id; + const ship = world.ships.get(item.id); + if (!ship) { + return item.id; + } + + const lines = [ + ship.label, + `Behavior ${describeShipBehavior(ship)}`, + `State ${describeShipState(world, ship)}`, + `Order ${describeShipOrder(ship)}`, + ]; + return lines.join("\n"); } if (item.kind === "station") { @@ -373,6 +384,27 @@ export function describeShipObjective(objective: string): string { } } +export function describeShipBehavior(ship: ShipSnapshot): string { + return ship.behaviorPhase + ? `${ship.defaultBehaviorKind} · ${ship.behaviorPhase}` + : ship.defaultBehaviorKind; +} + +export function describeShipOrder(ship: ShipSnapshot): string { + const orderParts: string[] = []; + if (ship.orderKind) { + orderParts.push(ship.orderKind); + } + if (ship.commanderObjective) { + orderParts.push(describeShipObjective(ship.commanderObjective)); + } + if (orderParts.length > 0) { + return orderParts.join(" · "); + } + + return describeControllerTask(ship.controllerTaskKind); +} + export function describeShipCurrentAction(ship: ShipSnapshot): { label: string; progress: number } | undefined { if (!ship.currentAction) { return undefined; diff --git a/apps/viewer/src/viewerSystemLayer.ts b/apps/viewer/src/viewerSystemLayer.ts index e905328..079aaad 100644 --- a/apps/viewer/src/viewerSystemLayer.ts +++ b/apps/viewer/src/viewerSystemLayer.ts @@ -18,7 +18,7 @@ import type { */ export class SystemLayer { readonly scene = new THREE.Scene(); - readonly camera = new THREE.PerspectiveCamera(50, 1, 0.0001, 300000); + readonly camera = new THREE.PerspectiveCamera(50, 1, 0.000005, 300000); readonly celestialGroup = new THREE.Group(); readonly nodeGroup = new THREE.Group(); diff --git a/apps/viewer/src/viewerTypes.ts b/apps/viewer/src/viewerTypes.ts index 66ea6b0..e85e9c7 100644 --- a/apps/viewer/src/viewerTypes.ts +++ b/apps/viewer/src/viewerTypes.ts @@ -36,6 +36,7 @@ export interface ShipVisual { systemId: string; mesh: SceneNode; icon: SceneNode; + iconBaseScale: number; startPosition: THREE.Vector3; authoritativePosition: THREE.Vector3; targetPosition: THREE.Vector3; diff --git a/apps/viewer/src/viewerWorldPresentation.ts b/apps/viewer/src/viewerWorldPresentation.ts index cfb39b9..437bf39 100644 --- a/apps/viewer/src/viewerWorldPresentation.ts +++ b/apps/viewer/src/viewerWorldPresentation.ts @@ -16,6 +16,8 @@ import { updateSystemStarPresentation, getAnimatedShipLocalPosition, iconWorldScale, + MIN_ICON_PIXELS, + MAX_ICON_PIXELS, } from "./viewerPresentation"; import { rawObject } from "./viewerScenePrimitives"; import type { @@ -94,8 +96,16 @@ export function updateWorldPresentation(context: WorldPresentationContext) { visual.mesh.setPosition(displayPosition); visual.icon.setPosition(rawObject(visual.mesh).position.clone()); const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship); - visual.mesh.setVisible(shipVisible); - visual.icon.setVisible(shipVisible && rawObject(visual.icon).visible); + const distToShip = context.camera.position.distanceTo(displayPosition); + const useTacticalIcon = renderMode !== "local" || distToShip > 0.012; + const iconScale = THREE.MathUtils.clamp( + visual.iconBaseScale, + iconWorldScale(distToShip, context.camera, MIN_ICON_PIXELS), + iconWorldScale(distToShip, context.camera, MAX_ICON_PIXELS + 10), + ); + visual.icon.setScaleScalar(iconScale); + visual.mesh.setVisible(shipVisible && !useTacticalIcon); + visual.icon.setVisible(shipVisible && useTacticalIcon); const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw); if (desiredHeading.lengthSq() > 0.01) { visual.mesh.lookAt(rawObject(visual.mesh).position.clone().add(desiredHeading));