fix(ui): F8-12 — owner feedback round 3 (#55)
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m28s

* Hit-test: a click inside a planet's visible disc always picks the
  planet, regardless of overlapping route shafts or battle X-crosses
  with higher base `priority`. Closes the #1, #2, #4 reports
  (picker hover would only catch the circumference, planet+routes
  swallowed disc clicks, label click on a battled planet routed to
  the battle viewer). Slop-only hits (cursor near a line but not on
  any disc) still use the existing priority order.
* Labels and planet outlines render in all nine torus copies again
  so they follow the player into wrap tiles — closes #3 (labels
  vanished on the wrong half of the viewport whenever the camera
  was panned past the wrap seam). The fingerprint guard keeps the
  per-toggle / per-selection rebuild cheap.
* Pixi.Text gets a few px of `padding` so the rasteriser no longer
  clips the last letter on a half-pixel measurement — closes #5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-28 10:17:08 +02:00
parent eb5018342e
commit 24d75564bb
2 changed files with 126 additions and 69 deletions
+24 -1
View File
@@ -32,6 +32,16 @@ import {
export interface Hit { export interface Hit {
primitive: Primitive; primitive: Primitive;
distSq: number; // in world units squared distSq: number; // in world units squared
/**
* insideDisc is `true` when the cursor sits *inside* a planet's
* visible disc (point primitive, distance ≤ `visibleRadius`, no
* slop required). F8-12 / #4 follow-up uses it to break tie
* against overlapping route shafts or battle X-crosses: a click
* inside the disc always picks the planet, even though those
* other primitives carry a higher base `priority` (so they still
* win when the cursor is "near the line, outside any planet").
*/
insideDisc: boolean;
} }
// hitTest returns the best-matching primitive under the cursor, or // hitTest returns the best-matching primitive under the cursor, or
@@ -62,6 +72,7 @@ export function hitTest(
const slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT_HIT_SLOP_PX[p.kind]; const slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT_HIT_SLOP_PX[p.kind];
const slopWorld = slopPx / camera.scale; const slopWorld = slopPx / camera.scale;
let result: number | null; let result: number | null;
let insideDisc = false;
if (p.kind === "point") { if (p.kind === "point") {
const visibleRadius = displayPointRadiusWorld( const visibleRadius = displayPointRadiusWorld(
p.style, p.style,
@@ -75,6 +86,9 @@ export function hitTest(
slopWorld, slopWorld,
mode === "torus" ? world : null, mode === "torus" ? world : null,
); );
if (result !== null) {
insideDisc = result <= visibleRadius * visibleRadius;
}
} else if (p.kind === "circle") { } else if (p.kind === "circle") {
result = matchCircle(p, cursor, slopWorld, mode === "torus" ? world : null); result = matchCircle(p, cursor, slopWorld, mode === "torus" ? world : null);
} else { } else {
@@ -88,7 +102,7 @@ export function hitTest(
); );
} }
if (result !== null) { if (result !== null) {
candidates.push({ primitive: p, distSq: result }); candidates.push({ primitive: p, distSq: result, insideDisc });
} }
} }
@@ -98,6 +112,15 @@ export function hitTest(
} }
function compareHits(a: Hit, b: Hit): number { function compareHits(a: Hit, b: Hit): number {
// F8-12 / #4 follow-up: a click that sits *inside* a planet disc
// always picks the planet, even when a route shaft or a battle
// X-cross with a higher `priority` overlaps it. The base priority
// tie-break still rules every "near a line, outside any disc"
// case so battle markers / cargo arrows remain clickable in the
// gap between planets.
if (a.insideDisc !== b.insideDisc) {
return a.insideDisc ? -1 : 1;
}
if (a.primitive.priority !== b.primitive.priority) { if (a.primitive.priority !== b.primitive.priority) {
return b.primitive.priority - a.primitive.priority; return b.primitive.priority - a.primitive.priority;
} }
+102 -68
View File
@@ -465,19 +465,23 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
return c; return c;
}); });
// Outline + label layers (F8-12 / #29 + #30). Both live in the // Outline + label layers (F8-12 / #29 + #30). One layer per torus
// origin copy only — replicating Pixi.Text / Graphics across all // copy so the textual legend / selection frame / bombing outline
// nine torus copies is the dominant cost on a 100+ planet map, // stay glued to whichever wrap tile the player is panned over.
// and the player almost never sees a label "wrap" out of the // Performance is reasonable because `setPlanetLabels` /
// central tile because the camera-wrap listener snaps the centre // `setPlanetOutlines` skip the rebuild on no-data-change via the
// back into `[0, W) × [0, H)` whenever it walks past the seam. // fingerprint guard, and the per-zoom transform update is just an
// Outlines sit between the primitive disc and the labels so the // `x/y/scale` assignment per entry.
// stroke reads against the planet fill while staying below the const outlineLayers: Container[] = copies.map((c) => {
// textual layer. const layer = new Container();
const outlineLayer = new Container(); c.addChild(layer);
const labelLayer = new Container(); return layer;
copies[ORIGIN_COPY_INDEX].addChild(outlineLayer); });
copies[ORIGIN_COPY_INDEX].addChild(labelLayer); const labelLayers: Container[] = copies.map((c) => {
const layer = new Container();
c.addChild(layer);
return layer;
});
// Per-id `Graphics` lookup. Each primitive lives in nine copies // Per-id `Graphics` lookup. Each primitive lives in nine copies
// (one per torus tile); pick-mode dims them by id, so the lookup // (one per torus tile); pick-mode dims them by id, so the lookup
@@ -566,17 +570,17 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
} }
// Planet label state (F8-12 / #29 + #30). One Container per // Planet label state (F8-12 / #29 + #30). One Container per
// planet, anchored at the planet's `(x, y)` in the origin copy. // planet per torus copy, anchored at the planet's `(x, y)` in
// `currentLabels` mirrors the dataset last passed into // each tile. `currentLabels` mirrors the dataset last passed
// `setPlanetLabels` so a zoom-driven transform update does not // into `setPlanetLabels` so a zoom-driven transform update does
// need a fresh report. // not need a fresh report.
interface LabelGfx { interface LabelGfx {
readonly container: Container; readonly container: Container;
readonly frame: Graphics; readonly frame: Graphics;
readonly nameText: Text | null; readonly nameText: Text | null;
readonly numberText: Text; readonly numberText: Text;
} }
const planetLabelInstances = new Map<number, LabelGfx>(); const planetLabelInstances = new Map<number, LabelGfx[]>();
let currentLabels: ReadonlyArray<PlanetLabelData> = []; let currentLabels: ReadonlyArray<PlanetLabelData> = [];
let currentLabelsFingerprint: string | null = null; let currentLabelsFingerprint: string | null = null;
let currentLabelsSelectedId: number | null = null; let currentLabelsSelectedId: number | null = null;
@@ -606,6 +610,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
fontSize: LABEL_FONT_SIZE_PX, fontSize: LABEL_FONT_SIZE_PX,
fill: fillColor, fill: fillColor,
align: "center", align: "center",
// F8-12 / #5 fix: Pixi's Text rasteriser sometimes clips
// the last glyph by a subpixel when the texture bounds
// are computed from the un-padded measurement. A few
// pixels of `padding` give the rasteriser enough room
// to render the right edge cleanly without changing
// the text's anchor or position.
padding: 2,
}, },
}); });
t.anchor.set(0.5, 0); t.anchor.set(0.5, 0);
@@ -621,8 +632,8 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}; };
const clearAllLabels = (): void => { const clearAllLabels = (): void => {
for (const entry of planetLabelInstances.values()) { for (const list of planetLabelInstances.values()) {
disposeLabelGfx(entry); for (const entry of list) disposeLabelGfx(entry);
} }
planetLabelInstances.clear(); planetLabelInstances.clear();
currentLabelsFingerprint = null; currentLabelsFingerprint = null;
@@ -686,8 +697,8 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const labelScale = 1 / cameraScale; const labelScale = 1 / cameraScale;
const gapWorld = LABEL_OFFSET_PX / cameraScale; const gapWorld = LABEL_OFFSET_PX / cameraScale;
for (const data of currentLabels) { for (const data of currentLabels) {
const entry = planetLabelInstances.get(data.planetNumber); const list = planetLabelInstances.get(data.planetNumber);
if (entry === undefined) continue; if (list === undefined) continue;
const planetPrim = pointPrimitivesById.get(data.planetNumber); const planetPrim = pointPrimitivesById.get(data.planetNumber);
const visibleRadius = const visibleRadius =
planetPrim === undefined planetPrim === undefined
@@ -697,9 +708,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
cameraScale, cameraScale,
currentScaleRef, currentScaleRef,
); );
entry.container.x = data.x; const anchorY = data.y + visibleRadius + gapWorld;
entry.container.y = data.y + visibleRadius + gapWorld; for (const entry of list) {
entry.container.scale.set(labelScale); entry.container.x = data.x;
entry.container.y = anchorY;
entry.container.scale.set(labelScale);
}
} }
}; };
@@ -711,7 +725,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// (`pointRadiusBasePx` softening) or stay constant // (`pointRadiusBasePx` softening) or stay constant
// (`pointRadiusPx` pixel-space). // (`pointRadiusPx` pixel-space).
interface PlanetOutlineGfx { interface PlanetOutlineGfx {
readonly graphics: Graphics; readonly graphics: Graphics[];
readonly spec: PlanetOutlineSpec; readonly spec: PlanetOutlineSpec;
} }
const planetOutlineInstances = new Map<number, PlanetOutlineGfx>(); const planetOutlineInstances = new Map<number, PlanetOutlineGfx>();
@@ -730,8 +744,10 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const clearAllOutlines = (): void => { const clearAllOutlines = (): void => {
for (const entry of planetOutlineInstances.values()) { for (const entry of planetOutlineInstances.values()) {
entry.graphics.parent?.removeChild(entry.graphics); for (const g of entry.graphics) {
entry.graphics.destroy(); g.parent?.removeChild(g);
g.destroy();
}
} }
planetOutlineInstances.clear(); planetOutlineInstances.clear();
currentOutlinesFingerprint = null; currentOutlinesFingerprint = null;
@@ -741,8 +757,10 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const cameraScale = viewport.scaled; const cameraScale = viewport.scaled;
if (cameraScale <= 0) return; if (cameraScale <= 0) return;
const planetPrim = pointPrimitivesById.get(entry.spec.planetNumber); const planetPrim = pointPrimitivesById.get(entry.spec.planetNumber);
entry.graphics.clear(); if (planetPrim === undefined) {
if (planetPrim === undefined) return; for (const g of entry.graphics) g.clear();
return;
}
const visibleRadius = displayPointRadiusWorld( const visibleRadius = displayPointRadiusWorld(
planetPrim.style, planetPrim.style,
cameraScale, cameraScale,
@@ -752,12 +770,15 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const widthWorld = const widthWorld =
(entry.spec.widthPx ?? OUTLINE_DEFAULT_WIDTH_PX) / cameraScale; (entry.spec.widthPx ?? OUTLINE_DEFAULT_WIDTH_PX) / cameraScale;
const outlineRadius = visibleRadius + paddingWorld; const outlineRadius = visibleRadius + paddingWorld;
entry.graphics.circle(planetPrim.x, planetPrim.y, outlineRadius); for (const g of entry.graphics) {
entry.graphics.stroke({ g.clear();
color: entry.spec.color, g.circle(planetPrim.x, planetPrim.y, outlineRadius);
alpha: 0.95, g.stroke({
width: widthWorld, color: entry.spec.color,
}); alpha: 0.95,
width: widthWorld,
});
}
}; };
const updateOutlineTransforms = (): void => { const updateOutlineTransforms = (): void => {
@@ -782,9 +803,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
clearAllOutlines(); clearAllOutlines();
currentOutlinesFingerprint = fp; currentOutlinesFingerprint = fp;
for (const spec of outlines) { for (const spec of outlines) {
const g = new Graphics(); const list: Graphics[] = [];
outlineLayer.addChild(g); for (const layer of outlineLayers) {
const entry: PlanetOutlineGfx = { graphics: g, spec }; const g = new Graphics();
layer.addChild(g);
list.push(g);
}
const entry: PlanetOutlineGfx = { graphics: list, spec };
planetOutlineInstances.set(spec.planetNumber, entry); planetOutlineInstances.set(spec.planetNumber, entry);
paintOutlineEntry(entry); paintOutlineEntry(entry);
} }
@@ -810,9 +835,11 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// flips. Repaint the affected entries instead of rebuilding. // flips. Repaint the affected entries instead of rebuilding.
currentLabels = labels.slice(); currentLabels = labels.slice();
for (const data of labels) { for (const data of labels) {
const entry = planetLabelInstances.get(data.planetNumber); const list = planetLabelInstances.get(data.planetNumber);
if (entry === undefined) continue; if (list === undefined) continue;
paintLabelEntry(entry, data.planetNumber === selectedPlanetId); for (const entry of list) {
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
}
} }
currentLabelsSelectedId = selectedPlanetId; currentLabelsSelectedId = selectedPlanetId;
updateLabelTransforms(); updateLabelTransforms();
@@ -824,33 +851,40 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
currentLabelsFingerprint = fp; currentLabelsFingerprint = fp;
currentLabelsSelectedId = selectedPlanetId; currentLabelsSelectedId = selectedPlanetId;
for (const data of labels) { for (const data of labels) {
const container = new Container();
const frame = new Graphics();
frame.visible = false;
container.addChild(frame);
const nameText =
data.name === null
? null
: buildLabelText(data.name, theme.labelText);
if (nameText !== null) container.addChild(nameText);
const numberText = buildLabelText(data.numberLabel, theme.labelMuted);
container.addChild(numberText);
labelLayer.addChild(container);
const entry: LabelGfx = {
container,
frame,
nameText,
numberText,
};
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
planetLabelInstances.set(data.planetNumber, entry);
// F8-12 / #3 follow-up: a click on the label routes through
// the same hit-test path as a click on the disc so selection
// and pick-mode both fire the right callback.
const targetPlanet = data.planetNumber; const targetPlanet = data.planetNumber;
container.eventMode = "static"; const list: LabelGfx[] = [];
container.cursor = "pointer"; for (const layer of labelLayers) {
container.on("pointertap", () => simulatePlanetClick(targetPlanet)); const container = new Container();
const frame = new Graphics();
frame.visible = false;
container.addChild(frame);
const nameText =
data.name === null
? null
: buildLabelText(data.name, theme.labelText);
if (nameText !== null) container.addChild(nameText);
const numberText = buildLabelText(data.numberLabel, theme.labelMuted);
container.addChild(numberText);
layer.addChild(container);
const entry: LabelGfx = {
container,
frame,
nameText,
numberText,
};
paintLabelEntry(entry, data.planetNumber === selectedPlanetId);
// F8-12 / #3 follow-up: a click on the label routes
// through the same hit-test path as a click on the
// disc so selection and pick-mode both fire the right
// callback.
container.eventMode = "static";
container.cursor = "pointer";
container.on("pointertap", () =>
simulatePlanetClick(targetPlanet),
);
list.push(entry);
}
planetLabelInstances.set(targetPlanet, list);
} }
updateLabelTransforms(); updateLabelTransforms();
requestRender(); requestRender();