From f4cc681df4383c9401ad23ae19fd47761886edc8 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 24 Sep 2026 12:23:13 +0200 Subject: [PATCH] Fill texture and tilemap collision only where it is being edited The faint fill on every frame outline and on every tilemap made the scene and the texture editor look green all over. Like the collider before, the fill now shows only on what is selected, and a bit stronger, so it marks the thing being worked on. - Texture collision: all frame outlines stay visible. Only the outlines of the selected frame are filled, at alpha 0.3 instead of 0.15 on every frame. - Tilemap: the outline still shows where the whole map blocks. The fill appears only while the tilemap alone is selected, and only for the part the layer chosen in the panel adds. Solid is 0.3 and one-way 0.15, before 0.12 and 0.06 on every map. - New query layerSolids(layerId) in items.ts. It builds the same solids for one layer and runs only for the selected map. - The small landing wedge on one-way edges stays filled. It belongs to the edge marker, not to the area. Not covered by tests: the texture canvas and the scene view wiring that picks the map and the layer. Neither was looked at in a browser. Tests: - ./check.sh: 159 files, 2337 tests, CHECK EXIT 0 (before 2334) - e2e 1-17,39-43: ALL PASSED, 668 checks - 3 new unit tests in tests/viewport/tilemap-fill.test.ts - Counter-check: with layerSolids ignoring the layer, exactly the layer test fails. Original restored, checked with cmp. Co-Authored-By: Claude Opus 5.5 --- src/editor/texture/TextureCanvas.ts | 24 ++++++--- src/editor/viewport/SceneViewport.ts | 11 +++- src/editor/viewport/tilemapSolids.ts | 20 +++---- src/viewport/items.ts | 36 +++++++++---- tests/viewport/tilemap-fill.test.ts | 78 ++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 29 deletions(-) create mode 100644 tests/viewport/tilemap-fill.test.ts diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 39d22e4a..a22e9f6b 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -31,7 +31,7 @@ import { withCornerMoved, withoutCorner, } from '../polygonEdit'; -import { dashedLine } from '../../viewport/symbols'; +import { COLLIDER_FILL_ALPHA, dashedLine } from '../../viewport/symbols'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { FramePreview } from '../framePreview'; import type { EditorState } from '../state'; @@ -604,7 +604,7 @@ 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); + for (const frame of others) this.drawOutlines(this.sheet, view, frame, false, false); this.sheetBuilds += 1; this.sheetOutlines = others.reduce((n, f) => n + (f.collision?.length ?? 0), 0); } @@ -612,7 +612,7 @@ export class TextureCanvas extends SheetCanvas { 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); + this.drawOutlines(this.overlay, view, selected, this.collision, true); if (!this.collision) { this.drawBorders(view, selected); this.drawHandles(view, selected); @@ -655,17 +655,27 @@ 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. + * 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. */ - private drawOutlines(g: Graphics, view: SheetView, frame: TextureFrame, edited: boolean): void { + private drawOutlines( + g: Graphics, + view: SheetView, + frame: TextureFrame, + edited: boolean, + filled: 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(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).fill({ color, alpha: 0.15 }).stroke({ width, color }); - else if (screen.length === 2) + if (screen.length >= 3) { + g.poly(screen, true); + if (filled) g.fill({ color, alpha: COLLIDER_FILL_ALPHA }); + 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 }); if (!edited) continue; const half = HANDLE_SIZE / 2; diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 9c43475d..d1815c7d 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -27,7 +27,7 @@ import { FramePreview } from '../framePreview'; import { type EditorState, objectSelection } from '../state'; import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel'; import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui'; -import { collectHolderItems, type DrawItem } from '../../viewport/items'; +import { collectHolderItems, type DrawItem, type TilemapSolids } from '../../viewport/items'; import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick'; import { openObjectMenu } from '../objectMenu'; import { type LayerFocus, layerAlpha } from '../../viewport/tilemap'; @@ -569,10 +569,17 @@ export class SceneViewport extends FreeViewport { _extent: Extent, ): void { this.syncIcon(item, onScreen); - this.solids.sync(item, onScreen, scale); + this.solids.sync(item, onScreen, scale, onScreen ? this.paintedSolids(item) : null); this.syncLabel(item, onScreen, scale); } + /** Where the painted layer blocks, when `item` is the tilemap selected alone – that part is tinted. */ + private paintedSolids(item: DrawItem): TilemapSolids | null { + const target = item.tilemap ? paintedTilemap(this.state) : null; + if (!target || target.objectId !== item.id) return null; + return item.tilemap!.layerSolids(paintedLayerId(this.state, target.asset)); + } + protected override disposeDecorations(id: string): void { this.labels.get(id)?.destroy(); this.labels.delete(id); diff --git a/src/editor/viewport/tilemapSolids.ts b/src/editor/viewport/tilemapSolids.ts index 11838830..d726070b 100644 --- a/src/editor/viewport/tilemapSolids.ts +++ b/src/editor/viewport/tilemapSolids.ts @@ -5,11 +5,8 @@ import type { Vec2 } from '../../core/model/Properties'; import { cellOrigin, tilemapTileSize } from '../../core/model/tilemap'; import { type CellPolygon, type CellRect, solidBoundary } from '../../core/model/tilemapCollision'; import { Theme } from '../../ui'; -import type { DrawItem } from '../../viewport/items'; -import { CONE_LENGTH, markBlockingSide, placeNode } from '../../viewport/symbols'; - -/** Where a resolved tilemap blocks: what its outline in the scene view is worked out from. */ -type TilemapSolids = Pick, 'solids' | 'outlines' | 'oneWay'>; +import type { DrawItem, TilemapSolids } from '../../viewport/items'; +import { COLLIDER_FILL_ALPHA, CONE_LENGTH, markBlockingSide, placeNode } from '../../viewport/symbols'; function sameSolids(a: TilemapSolids, b: TilemapSolids): boolean { const sameRects = (p: readonly CellRect[], q: readonly CellRect[]) => @@ -92,10 +89,11 @@ export class TilemapSolidOutlines { } /** - * The solid cells of a tilemap, drawn the way a collider is: tinted and outlined, above the - * content so a painted wall cannot hide them. + * The solid cells of a tilemap, drawn the way a collider is: outlined, above the content so a + * painted wall cannot hide them. Only `filled` is tinted – the part of the layer being painted + * while the map is selected – so the tint does not lie over every map's art. */ - sync(item: DrawItem, show: boolean, scale: number): void { + sync(item: DrawItem, show: boolean, scale: number, filled: TilemapSolids | null = null): void { let node = this.nodes.get(item.id); const rects = item.tilemap?.solids ?? []; const outlines = item.tilemap?.outlines ?? []; @@ -140,9 +138,11 @@ export class TilemapSolidOutlines { ); node.stroke({ width: lineWidth, color }); }; - fill(rects, outlines, 0.12); + if (filled) { + fill(filled.solids, filled.outlines, COLLIDER_FILL_ALPHA); + fill(filled.oneWay.solids, filled.oneWay.outlines, COLLIDER_FILL_ALPHA / 2); + } stroke(boundary.solid, width); - fill(oneWay.solids, oneWay.outlines, 0.06); stroke(boundary.oneWay, width / 2); // A platform's top is what blocks, so that is what stands out, with the cone it lands within. const length = CONE_LENGTH / (scale * scaleOf(item.m)); diff --git a/src/viewport/items.ts b/src/viewport/items.ts index ae35fa2a..63215e12 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -4,7 +4,7 @@ import { treeOrder } from '../core/model/hierarchy'; import { resolvedHolder } from '../core/model/instance'; import type { Mat } from '../core/model/matrix'; import type { PropertyBag } from '../core/model/Properties'; -import type { ParticlesAsset } from '../core/model/Asset'; +import type { ParticlesAsset, TilemapAsset } from '../core/model/Asset'; import { readTile, tileFps, type TileInfo, type TilePlayback, tilePlayback } from '../core/model/tile'; import { isLayerVisible, layerTiles, layerZ, type TileDraw } from '../core/model/tilemap'; import { @@ -90,6 +90,28 @@ export interface TilemapDraw { * its tiles land things within (#159), since tiles of two angles never merge. */ oneWay: { solids: (CellRect & { angle: number })[]; outlines: CellPolygon[] }; + /** + * Where one layer alone blocks, the same way – the scene view fills only the painted layer's + * part. Worked out when asked for, since only the selected map ever asks. + */ + layerSolids(layerId: string): TilemapSolids; +} + +/** Where a map, or one of its layers, blocks. */ +export type TilemapSolids = Pick; + +function solidsOf(project: Project, props: PropertyBag, asset: TilemapAsset): TilemapSolids { + const angles = oneWayAngles(project, asset); + return { + solids: solidRects(project, props, asset), + outlines: solidPolygons(project, props, asset), + oneWay: { + solids: angles.flatMap((angle) => + solidRects(project, props, asset, angle).map((rect) => ({ ...rect, angle })), + ), + outlines: angles.flatMap((angle) => solidPolygons(project, props, asset, angle)), + }, + }; } /** @@ -123,18 +145,12 @@ function resolveTilemap( }); } } - const angles = oneWayAngles(project, asset); return { layers, tiles: used, - solids: solidRects(project, props, asset), - outlines: solidPolygons(project, props, asset), - oneWay: { - solids: angles.flatMap((angle) => - solidRects(project, props, asset, angle).map((rect) => ({ ...rect, angle })), - ), - outlines: angles.flatMap((angle) => solidPolygons(project, props, asset, angle)), - }, + ...solidsOf(project, props, asset), + layerSolids: (layerId) => + solidsOf(project, props, { ...asset, layers: asset.layers.filter((layer) => layer.id === layerId) }), }; } diff --git a/tests/viewport/tilemap-fill.test.ts b/tests/viewport/tilemap-fill.test.ts new file mode 100644 index 00000000..8b2598e4 --- /dev/null +++ b/tests/viewport/tilemap-fill.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { Container, type Graphics } from 'pixi.js'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { AddTilemapLayerCommand, PaintTilesCommand } from '../../src/core/commands/tilemapCommands'; +import type { TilemapAsset } from '../../src/core/model/Asset'; +import { findAsset } from '../../src/core/model/Project'; +import { TILE_SOLID_KEY } from '../../src/core/model/tile'; +import { TilemapSolidOutlines } from '../../src/editor/viewport/tilemapSolids'; +import { collectHolderItems, type DrawItem } from '../../src/viewport/items'; +import { COLLIDER_FILL_ALPHA } from '../../src/viewport/symbols'; +import { makeSpriteScene, place } from '../helpers'; + +/** A map of two layers with a solid wall tile: two cells on "Ground", one on "Roofs". */ +function setup() { + const f = makeSpriteScene(); + const wall = new CreateAssetCommand('tile', 'Wall'); + const map = new CreateAssetCommand('tilemap', 'Village'); + f.stack.execute(wall); + f.stack.execute(map); + f.stack.execute(new SetAssetPropertyCommand(wall.asset.id, TILE_SOLID_KEY, true)); + const ground = (findAsset(f.project, map.asset.id) as TilemapAsset).layers[0]!.id; + const roofs = new AddTilemapLayerCommand(map.asset.id); + f.stack.execute(roofs); + const paint = (layerId: string, cells: [number, number][]) => + f.stack.execute( + new PaintTilesCommand( + map.asset.id, + layerId, + cells.map(([x, y]) => ({ x, y, tileId: wall.asset.id })), + ), + ); + paint(ground, [ + [0, 0], + [1, 0], + ]); + paint(roofs.layerId, [[4, 4]]); + const mapObject = place(f, map.asset.id, { x: 0, y: 0 }); + const item = collectHolderItems(f.project, f.sceneOf(), 0).items.find((i) => i.id === mapObject)!; + return { item, ground, roofs: roofs.layerId }; +} + +/** What the outline node of a map draws: each fill with its alpha, and how many strokes. */ +function drawn(item: DrawItem, filled: Parameters[3]) { + const layer = new Container(); + new TilemapSolidOutlines(layer).sync(item, true, 1, filled); + const node = layer.children[0] as Graphics; + const instructions = node.context.instructions; + return { + fills: instructions + .filter((i) => i.action === 'fill') + .map((i) => (i.data.style as { alpha: number }).alpha), + strokes: instructions.filter((i) => i.action === 'stroke').length, + }; +} + +describe('the tint on where a tilemap blocks in the scene view', () => { + it('knows where one layer alone blocks', () => { + const { item, ground, roofs } = setup(); + expect(item.tilemap!.solids).toEqual([ + { x: 0, y: 0, w: 2, h: 1 }, + { x: 4, y: 4, w: 1, h: 1 }, + ]); + expect(item.tilemap!.layerSolids(ground).solids).toEqual([{ x: 0, y: 0, w: 2, h: 1 }]); + expect(item.tilemap!.layerSolids(roofs).solids).toEqual([{ x: 4, y: 4, w: 1, h: 1 }]); + expect(item.tilemap!.layerSolids('gone').solids).toEqual([]); + }); + + it('is only an outline while nothing asks for a fill', () => { + const { item } = setup(); + expect(drawn(item, null)).toEqual({ fills: [], strokes: 1 }); + }); + + it('fills the painted layer at the collider alpha and still outlines the whole map', () => { + const { item, roofs } = setup(); + const layerSolids = item.tilemap!.layerSolids(roofs); + expect(drawn(item, layerSolids)).toEqual({ fills: [COLLIDER_FILL_ALPHA], strokes: 1 }); + }); +});