Files
galaxy-game/ui/frontend/src/map/reach-circles.ts
T
Ilia Denisov 9ae7b88b89
Tests · UI / test (push) Successful in 2m14s
Tests · Go / test (push) Successful in 2m25s
feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet.

pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm.

Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store.

Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:04:07 +02:00

82 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Phase 30 reach circles. When the ship-class calculator has a planet
// selected and a valid design, it publishes the design's loaded speed and
// the planet origin to `lib/calculator/reach.svelte`; the map view reads
// that store and feeds it through `computeReachCircles` to draw 13 thin
// concentric rings showing how far the ship reaches in 1, 2, and 3 turns.
//
// The ring count is bounded by how soon a ring reaches the meaningful
// extent of the map: half the shorter side on a torus (beyond that a
// ring wraps onto itself), or the farthest corner on a bounded no-wrap
// plane (beyond that the ring is entirely off-map). A fast ship that
// clears the map in one turn therefore shows a single ring; a slow ship
// shows all three.
import type { CirclePrim } from "./world";
export const REACH_CIRCLE_COLOR = 0x6d8cff;
/** High-bit prefix so reach-circle ids never collide with planet
* numbers, cargo-route lines, or battle/bombing markers. */
export const REACH_CIRCLE_ID_PREFIX = 0xb0000000;
const MAX_TURNS = 3;
/** Reach rings sit below every interactive primitive so they never win
* a click against a planet or ship group. */
const REACH_CIRCLE_PRIORITY = 0;
/**
* reachBound returns the largest ring radius worth drawing for the map.
* On a torus it is half the shorter side (a larger ring overlaps itself);
* on a bounded plane it is the distance from the origin to the farthest
* corner (a larger ring is wholly off-map).
*/
export function reachBound(
origin: { x: number; y: number },
mapWidth: number,
mapHeight: number,
mode: "torus" | "no-wrap",
): number {
if (mode === "torus") {
return Math.min(mapWidth, mapHeight) / 2;
}
const dx = Math.max(origin.x, mapWidth - origin.x);
const dy = Math.max(origin.y, mapHeight - origin.y);
return Math.hypot(dx, dy);
}
/**
* computeReachCircles produces up to three concentric ring primitives
* centred on `origin`, with radii speedPerTurn × {1, 2, 3}. A ring for
* turn `t` is included only when the previous ring still fits inside the
* map's reach bound, so the count shrinks as the per-turn speed grows.
* Returns an empty list when the speed is non-positive.
*/
export function computeReachCircles(
origin: { x: number; y: number },
speedPerTurn: number,
mapWidth: number,
mapHeight: number,
mode: "torus" | "no-wrap",
): CirclePrim[] {
if (speedPerTurn <= 0) return [];
const bound = reachBound(origin, mapWidth, mapHeight, mode);
const circles: CirclePrim[] = [];
for (let turn = 1; turn <= MAX_TURNS; turn++) {
// Stop once the previous ring already reached the bound.
if (turn > 1 && speedPerTurn * (turn - 1) >= bound) break;
circles.push({
kind: "circle",
id: REACH_CIRCLE_ID_PREFIX + turn,
priority: REACH_CIRCLE_PRIORITY,
hitSlopPx: 0,
x: origin.x,
y: origin.y,
radius: speedPerTurn * turn,
style: {
strokeColor: REACH_CIRCLE_COLOR,
strokeAlpha: 0.55 - (turn - 1) * 0.12,
strokeWidthPx: 1,
},
});
}
return circles;
}