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 }); + }); +});