From c1a6d63edb8be3ab801a21d2871dd99c2fa4a6d9 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 24 Sep 2026 13:22:45 +0200 Subject: [PATCH] Cover which tilemap layer and texture frame get a collision fill f4cc681 fills tilemap solids only for the selected map and layer, and texture collision only for the selected frame. Neither the scene view wiring nor the texture canvas had a test for that. - __gm.tilemap.solids() now reports the cells that were filled, and __gm.texture.drawInfo() counts the filled outlines. - e2e 40l-iii: a selected tile fills nothing. The map selected with the upper layer fills only cell (4,0), with the lower layer only the 3x3 block. Two selected objects fill nothing again. - e2e 42: a selected frame fills its one outline. Deselected it fills none, while the outline stays visible. - README check total goes from 1375 to 1377. CI confirmed 1375, the new total is counted, not yet measured. Tests: - ./check.sh: 159 files, 2337 tests, CHECK EXIT 0 - e2e 39-43: ALL PASSED, 147 checks (before 145) - Counter checks: a scene view that fills every map fails only the tilemap check. A texture canvas that fills every frame fails only the texture check. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/editor/texture/TextureCanvas.ts | 22 ++++++++++--- src/editor/viewport/tilemapSolids.ts | 19 ++++++++++- tools/e2e/40-tilemap.mjs | 49 ++++++++++++++++++++++++++++ tools/e2e/42-shapes.mjs | 11 +++++++ 5 files changed, 96 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 354ea921..fbe23c53 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, 1375 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1377 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/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index a22e9f6b..292e8636 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -120,6 +120,9 @@ export class TextureCanvas extends SheetCanvas { private sheetKey: unknown[] = []; private sheetBuilds = 0; private sheetOutlines = 0; + /** Outlines drawn tinted, on the sheet and on the overlay – only the selected frame's should be. */ + private sheetFilled = 0; + private overlayFilled = 0; private readonly labels = new Map(); private readonly labelLayer = new Container(); private readonly plates = new Graphics(); @@ -180,6 +183,7 @@ export class TextureCanvas extends SheetCanvas { return { sheetBuilds: this.sheetBuilds, sheetOutlines: this.sheetOutlines, + filled: { sheet: this.sheetFilled, overlay: this.overlayFilled }, origin: { x: origin.x, y: origin.y }, labels: [...this.labels.values()] .filter((label) => label.visible) @@ -604,15 +608,18 @@ export class TextureCanvas extends SheetCanvas { 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, false); + this.sheetFilled = 0; + for (const frame of others) + this.sheetFilled += this.drawOutlines(this.sheet, view, frame, false, false); this.sheetBuilds += 1; this.sheetOutlines = others.reduce((n, f) => n + (f.collision?.length ?? 0), 0); } this.overlay.clear(); + this.overlayFilled = 0; 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, true); + this.overlayFilled = this.drawOutlines(this.overlay, view, selected, this.collision, true); if (!this.collision) { this.drawBorders(view, selected); this.drawHandles(view, selected); @@ -656,7 +663,7 @@ export class TextureCanvas extends SheetCanvas { * A frame's collision outlines (#141), always shown so one sees which frames block with them; on * 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. Only the selected frame's outlines are - * `filled`, so the tint does not lie over the art of every other frame. + * `filled`, so the tint does not lie over the art of every other frame. Returns how many were tinted. */ private drawOutlines( g: Graphics, @@ -664,16 +671,20 @@ export class TextureCanvas extends SheetCanvas { frame: TextureFrame, edited: boolean, filled: boolean, - ): void { + ): number { const outlines = frame.collision ?? []; const active = edited ? this.activeOutline(frame, outlines) : -1; + let tinted = 0; for (const [index, points] of outlines.entries()) { 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) { g.poly(screen, true); - if (filled) g.fill({ color, alpha: COLLIDER_FILL_ALPHA }); + if (filled) { + g.fill({ color, alpha: COLLIDER_FILL_ALPHA }); + tinted += 1; + } g.stroke({ width, color }); } else if (screen.length === 2) g.moveTo(screen[0]!.x, screen[0]!.y).lineTo(screen[1]!.x, screen[1]!.y).stroke({ width: 2, color }); @@ -684,6 +695,7 @@ export class TextureCanvas extends SheetCanvas { .fill(index === active ? Theme.colors.accentText : Theme.colors.viewport) .stroke({ width: 1, color }); } + return tinted; } private drawHandles(view: SheetView, frame: TextureFrame): void { diff --git a/src/editor/viewport/tilemapSolids.ts b/src/editor/viewport/tilemapSolids.ts index d726070b..b86fa9e4 100644 --- a/src/editor/viewport/tilemapSolids.ts +++ b/src/editor/viewport/tilemapSolids.ts @@ -41,6 +41,8 @@ export class TilemapSolidOutlines { string, { from: TilemapSolids; solid: BoundaryLine[]; oneWay: BoundaryLine[] } >(); + /** What each map's last draw tinted, or nothing: the painted layer's part, while it is selected. */ + private readonly tinted = new Map(); constructor(private readonly layer: Container) {} @@ -48,9 +50,13 @@ export class TilemapSolidOutlines { this.nodes.get(id)?.destroy(); this.nodes.delete(id); this.boundaries.delete(id); + this.tinted.delete(id); } - /** The blocking rectangles and outlined cells of a placed tilemap, and the outer edges drawn round them, in cells (test hook). */ + /** + * The blocking rectangles and outlined cells of a placed tilemap, the outer edges drawn round + * them, and the tinted cells, in cells (test hook). + */ info(item: DrawItem | undefined): { visible: boolean; rects: CellRect[]; @@ -58,10 +64,19 @@ export class TilemapSolidOutlines { oneWay: CellRect[]; oneWayOutlines: Vec2[][]; boundary: BoundaryLine[]; + filled: { rects: CellRect[]; outlines: number; oneWay: CellRect[] } | null; } | null { const node = item && this.nodes.get(item.id); if (!node || !item.tilemap) return null; + const tinted = this.tinted.get(item.id); return { + filled: tinted + ? { + rects: tinted.solids.map((rect) => ({ ...rect })), + outlines: tinted.outlines.length, + oneWay: tinted.oneWay.solids.map((rect) => ({ ...rect })), + } + : null, visible: node.visible, rects: item.tilemap.solids.map((rect) => ({ ...rect })), outlines: item.tilemap.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))), @@ -98,6 +113,7 @@ export class TilemapSolidOutlines { const rects = item.tilemap?.solids ?? []; const outlines = item.tilemap?.outlines ?? []; const oneWay = item.tilemap?.oneWay ?? { solids: [], outlines: [] }; + this.tinted.delete(item.id); if (!show || rects.length + outlines.length + oneWay.solids.length + oneWay.outlines.length === 0) { if (node) node.visible = false; return; @@ -139,6 +155,7 @@ export class TilemapSolidOutlines { node.stroke({ width: lineWidth, color }); }; if (filled) { + this.tinted.set(item.id, filled); fill(filled.solids, filled.outlines, COLLIDER_FILL_ALPHA); fill(filled.oneWay.solids, filled.oneWay.outlines, COLLIDER_FILL_ALPHA / 2); } diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 004dbaa5..f3cb46fb 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -998,6 +998,55 @@ export async function tilemap(t) { ); await shot('40-tilemap-solid'); + // 40l-iii. The outline shows on every map, the tint only on the map being painted, and there only + // where the layer picked in the panel blocks: the block on the lower, (4, 0) on the upper. + const tinted = async () => + (await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`))?.filled ?? null; + const tintedByTile = await tinted(); + await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT }); + await settle(); + const layerIds = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`)).map((l) => l.id); + const tintOf = async (row) => { + const spot = await inPanel(t, `__gm.tilemap.layerRowAt(${row})`); + if (spot) await click(spot.name.x, spot.y); + await settle(); + // No layer chosen means the topmost is painted on, as the panel has it. + const chosen = (await json('__gm.tilemap.paint()')).layerId; + return { layer: layerIds.includes(chosen) ? chosen : layerIds.at(-1), filled: await tinted() }; + }; + const tintRows = [await tintOf(0), await tintOf(1)]; + const expectedTint = { + [layerIds[0]]: [{ x: 0, y: 0, w: 3, h: 3 }], + [layerIds[1]]: [{ x: 4, y: 0, w: 1, h: 1 }], + }; + const besideMap = await json( + `__gm.state.activeScene.objects.find((o) => o.id !== ${JSON.stringify(MAP_OBJECT)} && !o.parentId).id`, + ); + await select({ + kind: 'object', + holderId: 'scene1', + objectIds: [MAP_OBJECT, besideMap], + objectId: MAP_OBJECT, + }); + await settle(); + const tintedWithTwo = await tinted(); + await select({ kind: 'asset', assetId: 'at-grass' }); + await settle(); + check( + 'only the selected map is tinted, and only where the layer painted on blocks', + tintedByTile === null && + tintedWithTwo === null && + layerIds.length === 2 && + new Set(tintRows.map((r) => r.layer)).size === 2 && + tintRows.every( + (r) => + JSON.stringify(r.filled?.rects) === JSON.stringify(expectedTint[r.layer]) && + r.filled.outlines === 0 && + r.filled.oneWay.length === 0, + ), + JSON.stringify({ tintedByTile, tintRows, tintedWithTwo }), + ); + // In the game a crate of its own walks into the block: the seeded Crate sprite with a 100 × 60 // collider and the Mover (ArrowRight moves its owner, colliding with solids) below it. Its own // rather than the seeded red crate, which an earlier step of a full run may have removed. It diff --git a/tools/e2e/42-shapes.mjs b/tools/e2e/42-shapes.mjs index bde8c7a5..5249172c 100644 --- a/tools/e2e/42-shapes.mjs +++ b/tools/e2e/42-shapes.mjs @@ -327,6 +327,17 @@ export async function shapes(t) { unselected.sheetOutlines === 1 && restored.sheetOutlines === 2 && (await drawInfo()).sheetOutlines === 1, JSON.stringify({ unselected: unselected.sheetOutlines, restored: restored.sheetOutlines }), ); + // Every frame's outlines show, but only the selected frame's are tinted, so the tint does not lie + // over the whole sheet. + check( + "only the selected frame's collision outlines are tinted", + beforeDrag.filled.overlay === 1 && + beforeDrag.filled.sheet === 0 && + unselected.sheetOutlines === 1 && + unselected.filled.sheet === 0 && + unselected.filled.overlay === 0, + JSON.stringify({ selected: beforeDrag.filled, unselected: unselected.filled }), + ); 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)];