On a screen scaled to 125 % or 150 % the editor looked soft beside a page the browser draws itself. The layout counts in whole CSS pixels, and most of those fall between two device pixels there. The GPU averages both: text loses its edges, and a 1 px line becomes a pixel and a half of mixed colour. Now text and boxes are rounded to device pixels, and a line is one device pixel wide. - ui/pixelGrid.ts holds the rule. crispText() makes a text with Pixi's roundPixels, crisp() does the same for a Graphics of upright boxes, and hairline() is the width of one device pixel. - Every text of the interface is made with crispText() instead of new Text(). - crisp() only rounds where the scale is not a whole number. At 100 % and 200 % nothing changes. - drawControlBox, drawHeaderBar and drawSectionBand draw their lines a hairline wide and flush with the edge. The widgets that draw lines of their own do the same. - Two bands share exactly the line they draw, and a column still ends on a whole pixel. - The stage is nudged by 1/32 px where the scale is not whole. At 150 % every odd pixel is an exact half, and without the nudge one band came out a row shorter than its neighbours. - Curves, icons and everything of the game stay unrounded. Rounding their points would bend them. - The harness takes deviceScaleFactor as an option, and the hooks deviceColumn, edgeContrast and pixelGrid read the frame as the screen gets it. Not in here: the subpixel smoothing a browser gives its own text. Pixi draws a text into a transparent picture first, and there only greyscale smoothing is possible. Measured at 150 %, edge contrast before and after: top bar 31.5 to 47.8, the lists on the left 68.6 to 86.4, inspector 27.6 to 38.4. At 100 % it is 46.5, 90.1 and 37.9, and the picture there is the same as before pixel for pixel, apart from the fps display. Tests: - Gate on melektaus-atx: 206 files, 3114 tests (3104 + 10), exit 0 - Full run on melektaus-atx: 1655 of 1656 checks passed, smokes 24/19/21. The one that failed was a condition of the new step 62 itself: it scrolled an inspector that had nothing to scroll. The step now makes the window lower for that reading. - e2e 61,62 after that fix: 14 checks. The full run was not repeated, and CI is switched off for now. - Ten unit tests, nine in tests/ui/pixel-grid.test.ts - Eight e2e checks in the new step 62, which runs at 150 % - Eleven unit counter-probes fail tests - Four browser counter-probes fail their checks. They ran before the fix to step 62 and were not repeated for it. - Screenshots looked at for 125 % and 150 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
8.1 KiB
TypeScript
215 lines
8.1 KiB
TypeScript
import { Container, type FederatedPointerEvent, Graphics, Sprite, type Text } from 'pixi.js';
|
||
import { loneTileQuadrants } from '../../core/model/autotile';
|
||
import { findAssetOf } from '../../core/model/Project';
|
||
import { quadrantRef, readTile } from '../../core/model/tile';
|
||
import {
|
||
drawControlBox,
|
||
ScrollContainer,
|
||
type Size,
|
||
Stack,
|
||
Theme,
|
||
textStyle,
|
||
truncate,
|
||
Widget,
|
||
crispText,
|
||
} from '../../ui';
|
||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||
import { gridMetrics, type GridMetrics, tileAt, tileRect } from '../texture/imageGrid';
|
||
import type { EditorState } from '../state';
|
||
|
||
/** Smaller than the image picker's: a tile is 48 px, and a palette should show several in a row. */
|
||
const SIZES = { min: 56, max: 72 };
|
||
const GAP = 6;
|
||
/** Inset of the picture inside its box, so the selection frame stays visible around it. */
|
||
const PAD = 4;
|
||
|
||
interface Swatch {
|
||
box: Container;
|
||
background: Graphics;
|
||
sprites: [Sprite, Sprite, Sprite, Sprite];
|
||
/** Stands where the picture would be while the tile has none that fits. */
|
||
empty: Text;
|
||
caption: Text;
|
||
}
|
||
|
||
/**
|
||
* The tiles of the project as a grid of pictures (#139), in the painting section of a tilemap: one
|
||
* sees what one paints with, and it takes a fraction of the room a list of buttons did. Each tile is
|
||
* shown alone – for an autotile the lone tile of its block, the one RPG Maker shows as well – and
|
||
* as its first picture: the inspector has no clock, and a still picture names a tile well enough.
|
||
*
|
||
* The tiles are fixed when it is built; the section rebuilds when that list changes. A picture
|
||
* edited or a texture arriving only repaints.
|
||
*/
|
||
export class TilePalette extends Widget {
|
||
private metrics: GridMetrics = gridMetrics(0, 0, GAP, SIZES);
|
||
private readonly swatches: Swatch[] = [];
|
||
private hoveredIndex = -1;
|
||
private pressedIndex = -1;
|
||
/** The width a relayout was last asked for, so a height that will not settle cannot loop. */
|
||
private askedAt = -1;
|
||
private readonly stops: (() => void)[];
|
||
|
||
constructor(
|
||
private readonly state: EditorState,
|
||
private readonly tileIds: readonly string[],
|
||
private readonly selectedId: string,
|
||
private readonly onPick: (tileId: string) => void,
|
||
) {
|
||
super();
|
||
this.cursor = Theme.hoverCursor;
|
||
for (const _ of tileIds) this.swatches.push(this.buildSwatch());
|
||
this.stops = [
|
||
state.on('project', () => this.invalidate()),
|
||
textureLibrary.onChange(() => this.invalidate()),
|
||
];
|
||
|
||
this.on('pointermove', (e: FederatedPointerEvent) => this.hover(this.indexUnder(e)));
|
||
this.on('pointerout', () => {
|
||
this.pressedIndex = -1;
|
||
this.hover(-1);
|
||
});
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||
if (e.button === 0) this.pressedIndex = this.indexUnder(e);
|
||
});
|
||
this.on('pointerup', (e: FederatedPointerEvent) => {
|
||
const index = this.indexUnder(e);
|
||
const pressed = this.pressedIndex;
|
||
this.pressedIndex = -1;
|
||
if (e.button === 0 && index >= 0 && index === pressed) this.onPick(this.tileIds[index]!);
|
||
});
|
||
}
|
||
|
||
/** Where a tile's box and its picture sit on screen (test hook); null when it is not offered. */
|
||
swatchGlobal(
|
||
tileId: string,
|
||
): { centre: { x: number; y: number }; picture: { x: number; y: number; w: number } } | null {
|
||
const index = this.tileIds.indexOf(tileId);
|
||
if (index < 0) return null;
|
||
const rect = tileRect(index, this.metrics, GAP);
|
||
const origin = this.getGlobalPosition();
|
||
const x = origin.x + rect.x;
|
||
const y = origin.y + rect.y;
|
||
return {
|
||
centre: { x: x + rect.w / 2, y: y + this.metrics.tile / 2 },
|
||
picture: { x: x + PAD, y: y + PAD, w: this.metrics.tile - PAD * 2 },
|
||
};
|
||
}
|
||
|
||
/** How many tiles share a row – the check scripts read this. */
|
||
get columns(): number {
|
||
return this.metrics.columns;
|
||
}
|
||
|
||
override measure(): Size {
|
||
const width = this.w || this.expectedWidth();
|
||
return { w: this.minWidth, h: gridMetrics(width, this.tileIds.length, GAP, SIZES).height };
|
||
}
|
||
|
||
/**
|
||
* The height depends on the width, and the first measure comes before any width was given. So
|
||
* the palette guesses it from the scroll area it sits in, less the padding of the stacks between.
|
||
*/
|
||
private expectedWidth(): number {
|
||
let inset = 0;
|
||
for (let parent = this.parent; parent; parent = parent.parent) {
|
||
if (parent instanceof ScrollContainer) return Math.max(0, parent.w - inset);
|
||
if (parent instanceof Stack) inset += parent.padding * 2;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
override layout(): void {
|
||
this.metrics = gridMetrics(this.w, this.tileIds.length, GAP, SIZES);
|
||
this.swatches.forEach((swatch, index) => {
|
||
const rect = tileRect(index, this.metrics, GAP);
|
||
swatch.box.position.set(rect.x, rect.y);
|
||
});
|
||
// The guess was off – a scrollbar took its width, or the column was dragged narrower – so the
|
||
// grid needs another number of rows than it was given. The scroll area measures again.
|
||
if (this.metrics.height !== this.h && this.askedAt !== this.w) {
|
||
this.askedAt = this.w;
|
||
queueMicrotask(() => {
|
||
if (!this.destroyed) this.scrollArea()?.relayoutContent();
|
||
});
|
||
}
|
||
}
|
||
|
||
private scrollArea(): ScrollContainer | null {
|
||
for (let parent = this.parent; parent; parent = parent.parent)
|
||
if (parent instanceof ScrollContainer) return parent;
|
||
return null;
|
||
}
|
||
|
||
override redraw(): void {
|
||
const side = this.metrics.tile;
|
||
this.swatches.forEach((swatch, index) => this.drawSwatch(swatch, this.tileIds[index]!, index, side));
|
||
}
|
||
|
||
private drawSwatch(swatch: Swatch, tileId: string, index: number, side: number): void {
|
||
const selected = tileId === this.selectedId;
|
||
const hovered = index === this.hoveredIndex;
|
||
swatch.background.clear();
|
||
drawControlBox(swatch.background, side, side, {
|
||
fill: selected ? Theme.colors.selection : hovered ? Theme.colors.controlHover : Theme.colors.control,
|
||
border: selected ? Theme.colors.accent : Theme.colors.border,
|
||
});
|
||
|
||
const asset = findAssetOf(this.state.project, tileId, 'tile');
|
||
const info = asset ? readTile(this.state.project, asset) : null;
|
||
const picture = info?.problem === '' ? info.pictures[0] : undefined;
|
||
const quadrants = info ? loneTileQuadrants(info.kind) : null;
|
||
const half = (side - PAD * 2) / 2;
|
||
swatch.sprites.forEach((sprite, corner) => {
|
||
const texture =
|
||
picture && info && quadrants
|
||
? textureLibrary.get(quadrantRef(picture, info.quad, quadrants[corner]!))
|
||
: null;
|
||
sprite.visible = texture !== null;
|
||
if (!texture) return;
|
||
sprite.texture = texture;
|
||
sprite.position.set(PAD + (corner % 2) * half, PAD + Math.floor(corner / 2) * half);
|
||
sprite.width = half;
|
||
sprite.height = half;
|
||
});
|
||
// A tile without a usable picture still has to be pickable, and look like it is missing something.
|
||
swatch.empty.visible = !picture;
|
||
swatch.empty.position.set(
|
||
Math.round((side - swatch.empty.width) / 2),
|
||
Math.round((side - swatch.empty.height) / 2),
|
||
);
|
||
|
||
swatch.caption.style = textStyle(selected ? 'accent' : 'muted');
|
||
swatch.caption.text = truncate(asset?.name ?? '', swatch.caption.style, side);
|
||
swatch.caption.position.set(0, side + 1);
|
||
}
|
||
|
||
private buildSwatch(): Swatch {
|
||
const box = new Container();
|
||
const background = new Graphics();
|
||
const sprites = [new Sprite(), new Sprite(), new Sprite(), new Sprite()] as Swatch['sprites'];
|
||
const empty = crispText({ text: '?', style: textStyle('muted') });
|
||
const caption = crispText({ style: textStyle('small') });
|
||
box.addChild(background, ...sprites, empty, caption);
|
||
box.eventMode = 'none';
|
||
this.addChild(box);
|
||
return { box, background, sprites, empty, caption };
|
||
}
|
||
|
||
private indexUnder(e: FederatedPointerEvent): number {
|
||
const local = this.toLocal(e.global);
|
||
return tileAt(local.x, local.y, this.tileIds.length, this.metrics, GAP);
|
||
}
|
||
|
||
private hover(index: number): void {
|
||
if (index === this.hoveredIndex) return;
|
||
this.hoveredIndex = index;
|
||
this.invalidate();
|
||
}
|
||
|
||
override destroy(): void {
|
||
for (const stop of this.stops) stop();
|
||
super.destroy({ children: true });
|
||
}
|
||
}
|