feat: tactical icons, follow-camera orbit, and ship info panel

This commit is contained in:
2026-03-18 22:45:33 -04:00
parent f98c47a8a7
commit aa4a6930ba
17 changed files with 154 additions and 118 deletions

View File

@@ -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;

View File

@@ -9,6 +9,7 @@ export const NAV_DISTANCE: Record<PovLevel, number> = {
// 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 {

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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;
}
};

View File

@@ -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()),

View File

@@ -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 = `
<p>Parent ${parent}</p>
<p>Behavior ${shipBehavior}</p>
<p>State ${shipState}</p>
${ship.commanderObjective ? `<p>Objective ${describeShipObjective(ship.commanderObjective)}</p>` : ""}
<p>Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}</p>
<p>Order ${shipOrder}</p>
<p>Task ${ship.controllerTaskKind}</p>
${shipAction ? `
<div class="detail-progress">

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;
}
}

View File

@@ -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");

View File

@@ -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),

View File

@@ -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;

View File

@@ -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();

View File

@@ -36,6 +36,7 @@ export interface ShipVisual {
systemId: string;
mesh: SceneNode;
icon: SceneNode;
iconBaseScale: number;
startPosition: THREE.Vector3;
authoritativePosition: THREE.Vector3;
targetPosition: THREE.Vector3;

View File

@@ -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));