fix(ui): F8-12 — owner feedback round 3 (#55)
* 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:
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user