Since 89aa92d (#169) a text in the game is drawn as a picture of its
glyphs on a sprite mesh, not as a Pixi `Text`. The test hook
`__gm.locate` only looked for `Text` nodes, so every game text was
invisible to it. The smokes have been red in CI since then (tasks 385
to 395): 5 checks in jumprun, 1 in tetris. The e2e part stayed green,
because it reads editor labels only.
- `textPicture.ts` remembers, per mesh, the text a `TextPicture`
shows, and hands it out through `pictureText(node)`.
- `textProbe.eachText` reads both kinds and passes the text as a
string instead of the Pixi node. `locate`, `locateAll` and
`overlayTexts` use that string.
Tests:
- ./check.sh: 195 files, 2815 tests, CHECK EXIT 0
- smoke-jumprun: ALL PASSED, 24 checks
- smoke-tetris: ALL PASSED, 19 checks
- full e2e run (local): ALL PASSED, 1580 checks, the sum is unchanged
although `locate` now also sees texts in the scene
- counter-check: CI task 395 with the old code, same checks, 6 red
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
5.2 KiB
TypeScript
125 lines
5.2 KiB
TypeScript
import { type Application, type Container, Graphics, Rectangle, Text } from 'pixi.js';
|
||
import { Button, Widget } from '../ui';
|
||
import { pictureText } from '../viewport/textPicture';
|
||
|
||
/** The global box of a widget: where it starts, and how large it is. */
|
||
export function rectOf(widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }): {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
} {
|
||
const p = widget.getGlobalPosition();
|
||
return { x: p.x, y: p.y, w: widget.w, h: widget.h };
|
||
}
|
||
|
||
/** The overlap of two global rectangles; an empty one when they do not meet. */
|
||
function intersect(a: Rectangle, b: Rectangle): Rectangle {
|
||
const x = Math.max(a.x, b.x);
|
||
const y = Math.max(a.y, b.y);
|
||
return new Rectangle(
|
||
x,
|
||
y,
|
||
Math.max(0, Math.min(a.right, b.right) - x),
|
||
Math.max(0, Math.min(a.bottom, b.bottom) - y),
|
||
);
|
||
}
|
||
|
||
/** A picture on screen, found by the id of its file. */
|
||
export interface IconHit {
|
||
x: number;
|
||
y: number;
|
||
menu: boolean;
|
||
}
|
||
|
||
/**
|
||
* Finding widgets by the text they show – the ground floor of the test hook, and the only part of
|
||
* it the sign-in screen needs, since it exists before there is an editor to ask.
|
||
*/
|
||
export function textProbe(app: Application): {
|
||
eachText: (root: Container, visit: (text: string, point: { x: number; y: number }) => void) => void;
|
||
locateAll: (text: string) => { x: number; y: number }[];
|
||
locate: (text: string, last?: boolean) => { x: number; y: number } | null;
|
||
locateIcons: (name: string) => IconHit[];
|
||
} {
|
||
/**
|
||
* Every text under `root` that is really on screen, in drawing order (the topmost last), handed
|
||
* to `visit` with the centre a click on it would use.
|
||
*
|
||
* A row scrolled out of its list is still `visible` – only masked away – and reporting it would
|
||
* hand a check a point outside the panel, where the click lands on whatever is underneath. That
|
||
* is how a step that passes on a short list fails on a long one (#39 in CI), so a text whose
|
||
* centre falls outside the clipping it sits in counts as not being there at all.
|
||
*
|
||
* Every hook that reads text goes through here. A `locate` that refuses a scrolled-away row
|
||
* while `overlayTexts` still lists it is worse than either rule alone: a check picks a row off
|
||
* the one hook and then cannot find it with the other.
|
||
*
|
||
* The clip is the box of the *widget* that owns the mask, not the mask itself: Pixi hands a
|
||
* graphics that serves as a mask to the mask effect, and `getBounds()` on it then reports an
|
||
* empty rectangle at the origin – which would throw away every masked text in the editor.
|
||
*/
|
||
const eachShown = (
|
||
root: Container,
|
||
visit: (node: Container, clip: Rectangle | null, owner: Widget | null) => boolean,
|
||
): void => {
|
||
const walk = (node: Container, clip: Rectangle | null, owner: Widget | null): void => {
|
||
if (!node.visible) return;
|
||
const holder = node instanceof Widget ? node : owner;
|
||
let inner = clip;
|
||
if (node.mask && holder) {
|
||
const r = rectOf(holder);
|
||
const box = new Rectangle(r.x, r.y, r.w, r.h);
|
||
inner = clip ? intersect(clip, box) : box;
|
||
}
|
||
if (visit(node, inner, holder)) return;
|
||
for (const child of node.children) walk(child, inner, holder);
|
||
};
|
||
walk(root, null, null);
|
||
};
|
||
const eachText = (
|
||
root: Container,
|
||
visit: (text: string, point: { x: number; y: number }) => void,
|
||
): void => {
|
||
eachShown(root, (node, clip) => {
|
||
// A text object in the game is a picture of its glyphs (#169), not a Pixi `Text`.
|
||
const text = node instanceof Text ? node.text : pictureText(node);
|
||
if (text === undefined) return false;
|
||
// Global bounds, not `width`/`height`: those are unscaled, and inside the graph canvas
|
||
// (which is scaled by the zoom) that put the point right of the text it was meant to hit.
|
||
const box = node.getBounds();
|
||
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||
if (!clip || clip.contains(point.x, point.y)) visit(text, point);
|
||
return true;
|
||
});
|
||
};
|
||
/**
|
||
* Every picture drawn from the file with this id (`icon:<id>`, see `drawSvg`), topmost last. The
|
||
* point is the file's origin, its centre; `menu` says whether the button it sits on opens a menu.
|
||
*/
|
||
const locateIcons = (name: string): IconHit[] => {
|
||
const hits: IconHit[] = [];
|
||
eachShown(app.stage, (node, clip, owner) => {
|
||
if (!(node instanceof Graphics) || node.label !== `icon:${name}`) return false;
|
||
const p = node.getGlobalPosition();
|
||
if (!clip || clip.contains(p.x, p.y))
|
||
hits.push({ x: p.x, y: p.y, menu: owner instanceof Button && owner.opensMenu });
|
||
return true;
|
||
});
|
||
return hits;
|
||
};
|
||
/** Centres of every visible text equal to `text`, in drawing order (the topmost last). */
|
||
const locateAll = (text: string): { x: number; y: number }[] => {
|
||
const hits: { x: number; y: number }[] = [];
|
||
eachText(app.stage, (shown, point) => {
|
||
if (shown === text) hits.push(point);
|
||
});
|
||
return hits;
|
||
};
|
||
const locate = (text: string, last = false): { x: number; y: number } | null => {
|
||
const hits = locateAll(text);
|
||
return (last ? hits.at(-1) : hits[0]) ?? null;
|
||
};
|
||
return { eachText, locateAll, locate, locateIcons };
|
||
}
|