Files
feuerwerk/src/editor/textProbe.ts
T
adminandClaude Opus 5.5 dded62b9f9
game-maker / gate (push) Successful in 4m8s
game-maker / suite (push) Successful in 12m10s
Let the example-game smokes read texts in the game again
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>
2026-09-29 15:19:42 +02:00

125 lines
5.2 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.
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 };
}