diff --git a/README.md b/README.md index 262dfaaa..e8cc1607 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1333 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1336 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/debug/documentHooks.ts b/src/editor/debug/documentHooks.ts index d71b36e0..864720d4 100644 --- a/src/editor/debug/documentHooks.ts +++ b/src/editor/debug/documentHooks.ts @@ -15,6 +15,8 @@ export function documentHooks({ state, editor }: HookContext) { rect: () => textureEditor.canvasRect(), /** Whether a press edits the selected frame's collision outline (#141). */ collisionMode: () => textureEditor.canvas.collisionMode, + /** Rebuilds of the unselected frames' marks, their outlines, and where marks and names sit (#220). */ + drawInfo: () => textureEditor.canvas.drawInfo, /** The open "Generate collision…" dialog (#161), or null. */ generateDialog: () => textureEditor.generateDialog.current()?.info ?? null, }, diff --git a/src/editor/texture/SheetCanvas.ts b/src/editor/texture/SheetCanvas.ts index b27e89df..6ae771ec 100644 --- a/src/editor/texture/SheetCanvas.ts +++ b/src/editor/texture/SheetCanvas.ts @@ -117,6 +117,11 @@ export abstract class SheetCanvas extends Widget { return; } this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y)); + this.panned(); + } + + /** The view moved without zooming; a tab that can shift what it drew needs no full redraw. */ + protected panned(): void { this.redraw(); } diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 45f3e3c2..39d22e4a 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -5,7 +5,7 @@ import { SetFrameRectCommand, SetFrameSliceCommand, } from '../../core/commands/frameCommands'; -import { convexPieces } from '../../core/model/polygon'; +import { polygonProblem } from '../../core/model/polygon'; import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset'; import { textOf, type Vec2 } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; @@ -54,6 +54,7 @@ import { imageToLocal, localToImage, rectBetween, + type SheetView, } from './sheetView'; /** Side of a resize handle in screen pixels – constant, so handles stay grabbable at any zoom. */ @@ -108,9 +109,20 @@ interface NewFrame { */ export class TextureCanvas extends SheetCanvas { private readonly image = new Sprite(); + /** + * Outlines and handles, drawn with the pan left out so that a pan only moves them (#220). `sheet` + * holds every frame but the selected one and is only redrawn when one of those changes; `overlay` + * holds what a gesture changes. + */ + private readonly marks = new Container(); + private readonly sheet = new Graphics(); private readonly overlay = new Graphics(); + private sheetKey: unknown[] = []; + private sheetBuilds = 0; + private sheetOutlines = 0; private readonly labels = new Map(); private readonly labelLayer = new Container(); + private readonly plates = new Graphics(); private dragging: FrameDrag | null = null; private spanning: NewFrame | null = null; private corner: CornerDrag | null = null; @@ -124,7 +136,9 @@ export class TextureCanvas extends SheetCanvas { constructor(private readonly state: EditorState) { super(); this.perFrame = new FramePreview(state, 'texture-drag'); - this.content.addChild(this.image, this.overlay, this.labelLayer); + this.marks.addChild(this.sheet, this.overlay); + // Plates under the outlines, as before the split: a corner handle must not sink under a name. + this.content.addChild(this.image, this.plates, this.marks, this.labelLayer); state.on('project', () => this.redraw()); state.on('selection', () => this.redraw()); @@ -160,6 +174,19 @@ export class TextureCanvas extends SheetCanvas { return this.collision; } + /** How the marks were drawn: the check scripts prove with it that a pan or a drag rebuilds nothing. */ + get drawInfo() { + const origin = this.marks.getGlobalPosition(); + return { + sheetBuilds: this.sheetBuilds, + sheetOutlines: this.sheetOutlines, + origin: { x: origin.x, y: origin.y }, + labels: [...this.labels.values()] + .filter((label) => label.visible) + .map((label) => ({ text: label.text, ...label.getGlobalPosition() })), + }; + } + private asset(): TextureAsset | null { const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined; return asset && asset.type === 'texture' ? asset : null; @@ -557,56 +584,69 @@ export class TextureCanvas extends SheetCanvas { drawChecker(this.checker, origin.x, origin.y, w, h, this.w, this.h); } + /** A pan leaves every outline where it is on the sheet: move them, and place the names anew. */ + protected override panned(): void { + const asset = this.asset(); + this.image.position.set(this.view.panX, this.view.panY); + this.drawChecker(asset, this.image.visible); + this.marks.position.set(this.view.panX, this.view.panY); + this.syncLabels(asset?.frames ?? [], this.selectedFrameId()); + } + private drawFrames(asset: TextureAsset | null): void { + const view: SheetView = { panX: 0, panY: 0, zoom: this.view.zoom }; + this.marks.position.set(this.view.panX, this.view.panY); + const frames = asset?.frames ?? []; + const selectedId = this.selectedFrameId(); + const key = sheetKey(frames, selectedId, view.zoom); + if (!sameKey(key, this.sheetKey)) { + this.sheetKey = key; + this.sheet.clear(); + const others = frames.filter((f) => f.id !== selectedId); + for (const frame of others) this.drawFrame(this.sheet, view, frame, false); + for (const frame of others) this.drawOutlines(this.sheet, view, frame, false); + this.sheetBuilds += 1; + this.sheetOutlines = others.reduce((n, f) => n + (f.collision?.length ?? 0), 0); + } this.overlay.clear(); - const alive = new Set(); - if (asset) { - const selectedId = this.selectedFrameId(); - for (const frame of asset.frames) { - alive.add(frame.id); - this.drawFrame(frame, frame.id === selectedId); - } - for (const frame of asset.frames) this.drawOutlines(frame, this.collision && frame.id === selectedId); - const selected = this.frame(selectedId); - if (selected && !this.collision) { - this.drawBorders(selected); - this.drawHandles(selected); + const selected = frames.find((f) => f.id === selectedId); + if (selected) { + this.drawFrame(this.overlay, view, selected, true); + this.drawOutlines(this.overlay, view, selected, this.collision); + if (!this.collision) { + this.drawBorders(view, selected); + this.drawHandles(view, selected); } } if (this.spanning?.active) { const rect = rectBetween(this.spanning.start, this.spanning.current); - const a = imageToLocal(this.view, rect.x, rect.y); + const a = imageToLocal(view, rect.x, rect.y); this.overlay - .rect(a.x, a.y, rect.w * this.view.zoom, rect.h * this.view.zoom) + .rect(a.x, a.y, rect.w * view.zoom, rect.h * view.zoom) .fill({ color: Theme.colors.marquee, alpha: 0.15 }) .stroke({ width: 1, color: Theme.colors.marquee }); } - for (const [id, label] of this.labels) { - if (alive.has(id)) continue; - label.destroy(); - this.labels.delete(id); - } + this.syncLabels(frames, selectedId); } - private drawFrame(frame: TextureFrame, selected: boolean): void { - const a = imageToLocal(this.view, frame.x, frame.y); - const w = frame.w * this.view.zoom; - const h = frame.h * this.view.zoom; + private drawFrame(g: Graphics, view: SheetView, frame: TextureFrame, selected: boolean): void { + const a = imageToLocal(view, frame.x, frame.y); + const w = frame.w * view.zoom; + const h = frame.h * view.zoom; const color = selected ? Theme.colors.accent : Theme.colors.collider; - this.overlay.rect(a.x + 0.5, a.y + 0.5, w - 1, h - 1).stroke({ width: selected ? 2 : 1, color }); - this.syncLabel(frame, a, w, h, selected); + g.rect(a.x + 0.5, a.y + 0.5, w - 1, h - 1).stroke({ width: selected ? 2 : 1, color }); } /** * The four nine-slice lines of the selected frame (#41). Drawn even when they all sit on its * edges: a frame nobody sliced still has them, and a line one cannot see is one nobody drags. */ - private drawBorders(frame: TextureFrame): void { + private drawBorders(view: SheetView, frame: TextureFrame): void { const border = frameBorders(frame); for (const edge of BORDER_EDGES) { const line = borderLine(frame, border, edge); - const a = imageToLocal(this.view, line.x1, line.y1); - const b = imageToLocal(this.view, line.x2, line.y2); + const a = imageToLocal(view, line.x1, line.y1); + const b = imageToLocal(view, line.x2, line.y2); dashedLine(this.overlay, a.x, a.y, b.x, b.y, 4, 3); } this.overlay.stroke({ width: 1, color: Theme.colors.marquee }); @@ -617,35 +657,30 @@ export class TextureCanvas extends SheetCanvas { * the edited frame the active outline is drawn heavier and every corner of every outline gets a * handle. An outline that is no polygon is drawn in red. */ - private drawOutlines(frame: TextureFrame, edited: boolean): void { - const outlines = frameCollision(frame); + private drawOutlines(g: Graphics, view: SheetView, frame: TextureFrame, edited: boolean): void { + const outlines = frame.collision ?? []; const active = edited ? this.activeOutline(frame, outlines) : -1; for (const [index, points] of outlines.entries()) { - const screen = points.map((p) => imageToLocal(this.view, frame.x + p.x, frame.y + p.y)); - const color = convexPieces(points).length > 0 ? Theme.colors.collider : Theme.colors.danger; + const screen = points.map((p) => imageToLocal(view, frame.x + p.x, frame.y + p.y)); + const color = isPolygon(points) ? Theme.colors.collider : Theme.colors.danger; const width = index === active ? 2 : 1; - if (screen.length >= 3) - this.overlay.poly(screen, true).fill({ color, alpha: 0.15 }).stroke({ width, color }); + if (screen.length >= 3) g.poly(screen, true).fill({ color, alpha: 0.15 }).stroke({ width, color }); else if (screen.length === 2) - this.overlay - .moveTo(screen[0]!.x, screen[0]!.y) - .lineTo(screen[1]!.x, screen[1]!.y) - .stroke({ width: 2, color }); + g.moveTo(screen[0]!.x, screen[0]!.y).lineTo(screen[1]!.x, screen[1]!.y).stroke({ width: 2, color }); if (!edited) continue; const half = HANDLE_SIZE / 2; for (const c of screen) - this.overlay - .rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE) + g.rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE) .fill(index === active ? Theme.colors.accentText : Theme.colors.viewport) .stroke({ width: 1, color }); } } - private drawHandles(frame: TextureFrame): void { + private drawHandles(view: SheetView, frame: TextureFrame): void { const half = HANDLE_SIZE / 2; for (const handle of frameHandles()) { const p = handlePoint(frame, handle); - const local = imageToLocal(this.view, p.x, p.y); + const local = imageToLocal(view, p.x, p.y); this.overlay .rect(local.x - half, local.y - half, HANDLE_SIZE, HANDLE_SIZE) .fill(Theme.colors.accentText) @@ -653,6 +688,22 @@ export class TextureCanvas extends SheetCanvas { } } + /** Names stay in screen space and only on frames in view, so a pan places them anew. */ + private syncLabels(frames: readonly TextureFrame[], selectedId: string): void { + this.plates.clear(); + const alive = new Set(); + for (const frame of frames) { + alive.add(frame.id); + const at = imageToLocal(this.view, frame.x, frame.y); + this.syncLabel(frame, at, frame.w * this.view.zoom, frame.h * this.view.zoom, frame.id === selectedId); + } + for (const [id, label] of this.labels) { + if (alive.has(id)) continue; + label.destroy(); + this.labels.delete(id); + } + } + private syncLabel(frame: TextureFrame, at: Vec2, w: number, h: number, selected: boolean): void { let label = this.labels.get(frame.id); // A name that does not fit inside its frame would be read as belonging to the neighbour. @@ -673,7 +724,7 @@ export class TextureCanvas extends SheetCanvas { label.position.set(x + LABEL_PAD, y + LABEL_PAD); // A dark plate, not just a text colour: the name is drawn on the user's picture, where no // fixed colour is legible against every background. - this.overlay + this.plates .roundRect(x, y, label.width + LABEL_PAD * 2, label.height + LABEL_PAD * 2, 2) .fill({ color: 0x000000, alpha: selected ? 0.75 : 0.55 }); } @@ -698,6 +749,31 @@ export class TextureCanvas extends SheetCanvas { } } +/** + * What the unselected frames are drawn from. The model replaces a frame's outline list when it + * changes rather than editing it, so the list itself stands for its corners. + */ +function sheetKey(frames: readonly TextureFrame[], selectedId: string, zoom: number): unknown[] { + const key: unknown[] = [zoom, selectedId]; + for (const f of frames) if (f.id !== selectedId) key.push(f, f.x, f.y, f.w, f.h, f.collision); + return key; +} + +function sameKey(a: readonly unknown[], b: readonly unknown[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +/** + * Whether an outline is a polygon, remembered per outline for the same reason. Working it out on + * every redraw was half of what a sheet with many outlines cost per pan (#220). + */ +const polygonOutlines = new WeakMap(); +function isPolygon(points: readonly Vec2[]): boolean { + let valid = polygonOutlines.get(points); + if (valid === undefined) polygonOutlines.set(points, (valid = polygonProblem(points) === '')); + return valid; +} + /** A corner of an outline: on whole pixels, and inside its frame. */ function inFrame(frame: TextureFrame, p: Vec2): Vec2 { return { diff --git a/tools/e2e/42-shapes.mjs b/tools/e2e/42-shapes.mjs index 9a58bd5a..bde8c7a5 100644 --- a/tools/e2e/42-shapes.mjs +++ b/tools/e2e/42-shapes.mjs @@ -298,6 +298,56 @@ export async function shapes(t) { )) === true, JSON.stringify({ two, left }), ); + + // 42c-ii. A sheet full of outlines stays smooth (#220): dragging a corner redraws only the + // edited frame, a pan moves the drawn outlines instead of drawing them anew, and a + // change to an unselected frame still reaches the picture. + const drawInfo = () => json('__gm.texture.drawInfo()'); + const beforeDrag = await drawInfo(); + const corner = await json('__gm.texture.pointAt(32, 0)'); + const aside = await json('__gm.texture.pointAt(24, 0)'); + await drag(corner.x, corner.y, aside.x, aside.y, 8); + const afterDrag = await drawInfo(); + await undo(); + check( + 'dragging a corner does not redraw the frames around it', + afterDrag.sheetBuilds === beforeDrag.sheetBuilds, + JSON.stringify({ before: beforeDrag.sheetBuilds, after: afterDrag.sheetBuilds }), + ); + await select({ kind: 'asset', assetId: texture?.id }); + await settle(); + const unselected = await drawInfo(); + await undo(); + await settle(); + const restored = await drawInfo(); + await t.redo(); + await settle(); + check( + 'undo on an unselected frame redraws its outlines', + unselected.sheetOutlines === 1 && restored.sheetOutlines === 2 && (await drawInfo()).sheetOutlines === 1, + JSON.stringify({ unselected: unselected.sheetOutlines, restored: restored.sheetOutlines }), + ); + const area = await json('__gm.texture.rect()'); + const beforePan = await drawInfo(); + const [px, py] = [Math.round(area.x + area.w / 2), Math.round(area.y + area.h / 2)]; + await drag(px, py, px + 40, py + 25, { steps: 8, button: 'middle' }); + const afterPan = await drawInfo(); + const origin = await json('__gm.texture.pointAt(0, 0)'); + const label = afterPan.labels.find((l) => l.text === 'Wedge'); + check( + 'a pan moves the drawn outlines and names along without drawing them anew', + afterPan.sheetBuilds === beforePan.sheetBuilds && + Math.abs(afterPan.origin.x - beforePan.origin.x - 40) < 0.01 && + Math.abs(afterPan.origin.y - beforePan.origin.y - 25) < 0.01 && + Math.abs(afterPan.origin.x - origin.x) < 0.01 && + Math.abs(afterPan.origin.y - origin.y) < 0.01 && + Boolean(label) && + Math.abs(label.x - origin.x - 5) < 0.01 && + Math.abs(label.y - origin.y - 5) < 0.01, + JSON.stringify({ beforePan, afterPan, origin }), + ); + await select({ kind: 'frame', assetId: texture?.id, frameId: 'sh-frame' }); + await settle(); await clickText('Collision'); await showTab('scene'); await settle();