feat(ui): F8-12 — smooth planet discs + ?debug=1 overlay (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m18s

* Planet discs (and every other circle the renderer draws —
  outlines, picker hover ring, reach / bombing rings, etc.) trace
  a fixed 32-segment polygon instead of leaning on Pixi's adaptive
  bezier subdivision. PixiJS v8 picks the segment count from the
  world-space radius, which collapsed to 6-8 segments once the
  parent container's scale climbed — so the planet read as a
  visible polygon at high zoom. The custom path stays cheap (~64
  floats per disc) and gives a perceptually round silhouette at
  every zoom level.
* Opt-in dev overlay activated by `?debug=1` in the URL. A small
  bottom-left panel shows the current `scale`, the
  "whole world fits" reference scale, the current zoom ratio
  (scale / scale_ref), and the world-units rectangle visible in
  the viewport — so the owner can decide what `maxScale` to clamp
  to on the next iteration without guessing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-28 12:33:23 +02:00
parent 24d75564bb
commit 4d729c1f50
2 changed files with 141 additions and 6 deletions
+43 -6
View File
@@ -772,7 +772,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const outlineRadius = visibleRadius + paddingWorld;
for (const g of entry.graphics) {
g.clear();
g.circle(planetPrim.x, planetPrim.y, outlineRadius);
traceSmoothCircle(g, planetPrim.x, planetPrim.y, outlineRadius);
g.stroke({
color: entry.spec.color,
alpha: 0.95,
@@ -1147,7 +1147,8 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// paints in `pickHighlight` so the player sees the
// destination candidate just like a selection — only
// in the warm picker accent.
g.circle(
traceSmoothCircle(
g,
spec.hoverOutline.x,
spec.hoverOutline.y,
spec.hoverOutline.radius,
@@ -1520,6 +1521,35 @@ function rendererBackendName(r: Renderer): "webgl" | "webgpu" | "canvas" {
return "webgl";
}
/**
* SMOOTH_CIRCLE_SEGMENTS is the fixed segment count the renderer
* uses for every disc / ring. PixiJS v8's `Graphics.circle` picks
* the segment count from the world-space radius, which is fine for
* a fully-scaled scene but breaks the moment we draw the same disc
* at a small world radius inside a heavily-zoomed parent container:
* the planet renders as a visible 6-8-gon. Painting a fixed-density
* polygon ourselves keeps the silhouette round at every zoom level.
* 32 segments is the standard "perceptually round" budget; it stays
* cheap (~64 floats per disc) on a 500-planet map.
*/
const SMOOTH_CIRCLE_SEGMENTS = 32;
function traceSmoothCircle(
g: Graphics,
x: number,
y: number,
radius: number,
): void {
if (radius <= 0) return;
const step = (2 * Math.PI) / SMOOTH_CIRCLE_SEGMENTS;
g.moveTo(x + radius, y);
for (let i = 1; i < SMOOTH_CIRCLE_SEGMENTS; i++) {
const a = i * step;
g.lineTo(x + radius * Math.cos(a), y + radius * Math.sin(a));
}
g.closePath();
}
function drawPoint(
g: Graphics,
p: PointPrim,
@@ -1530,11 +1560,13 @@ function drawPoint(
const color = p.style.fillColor ?? theme.pointFill;
const alpha = p.style.fillAlpha ?? 1;
const radius = displayPointRadiusWorld(p.style, cameraScale, scaleRef);
g.circle(p.x, p.y, radius);
traceSmoothCircle(g, p.x, p.y, radius);
g.fill({ color, alpha });
if (p.style.strokeColor !== undefined && (p.style.strokeWidthPx ?? 0) > 0) {
const strokeAlpha = p.style.strokeAlpha ?? 1;
const strokeWidth = displayStrokeWidthWorld(p.style, cameraScale);
// Retrace because Pixi's `fill()` closes the current path.
traceSmoothCircle(g, p.x, p.y, radius);
g.stroke({
color: p.style.strokeColor,
alpha: strokeAlpha,
@@ -1549,9 +1581,14 @@ function drawCircle(
theme: Theme,
cameraScale: number,
): void {
g.circle(p.x, p.y, p.radius);
if (p.style.fillColor !== undefined) {
g.fill({ color: p.style.fillColor, alpha: p.style.fillAlpha ?? 1 });
const hasFill =
p.style.fillColor !== undefined && (p.style.fillAlpha ?? 1) > 0;
traceSmoothCircle(g, p.x, p.y, p.radius);
if (hasFill) {
g.fill({ color: p.style.fillColor!, alpha: p.style.fillAlpha ?? 1 });
// Pixi's `fill()` closes the current path — retrace before
// the stroke pass so the ring is actually painted on top.
traceSmoothCircle(g, p.x, p.y, p.radius);
}
const strokeColor = p.style.strokeColor ?? theme.circleStroke;
const strokeAlpha = p.style.strokeAlpha ?? 1;