Deleting a tile left the cells painted with it in the tilemaps. They drew nothing, blocked nothing and did not show in the delete dialog, but still read as a tile for "Tile at" and stopped a fill. - DeleteAssetCommand empties the cells of a deleted tile on every layer of every tilemap in the same step. Undo puts the tile and its cells back together. - The delete dialog counts them, for example "3 cell(s) on 1 tilemap(s) are painted with it and become empty." (cellsPainted). - presentTile treats a cell naming a missing tile as empty. "Tile at" (World.tileAt) and floodFill use it, so cells already in existing files read and fill as empty. There is no migration. - The brush, the rectangle and the fill check the tile in hand. A tile deleted after it was picked no longer paints new invisible cells, the editor says "Choose a tile to paint with." instead. - Unchanged on purpose: the picker still reports such a cell as naming a tile that no longer exists, and erasing removes it. Tests: - ./check.sh: 195 files, 2823 tests, CHECK EXIT 0 (+6). - GM_STEPS=39,40: ALL PASSED, 100 checks (4 new in 40t). - Full e2e sum is now 1590 (1586 confirmed by CI on round 8, plus 4). - Counter-checks, all fail as expected: 5 in unit tests (delete without emptying, undo without cells, fill and "Tile at" without presentTile, wrong map count) and 3 in the browser (dialog without count, delete without emptying, brush without the check). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
95 lines
4.1 KiB
TypeScript
95 lines
4.1 KiB
TypeScript
/**
|
||
* "Where is this asset used" – across scenes and container templates alike. Kept out of
|
||
* `Project.ts` so that module stays free of the property schema and of container instancing:
|
||
* both of those already import it, and answering this question needs both.
|
||
*/
|
||
import { isKindWith } from '../properties/kinds';
|
||
import { schemaForAsset } from '../properties/PropertySchema';
|
||
import { referencesAny, trackDef } from './animation';
|
||
import type { AnimationAsset, Asset, TileAsset } from './Asset';
|
||
import { overrideSites } from './instance';
|
||
import { type ObjectHolder, objectHolders, type Project } from './Project';
|
||
import type { SceneObject } from './SceneObject';
|
||
|
||
export interface SceneObjectRef {
|
||
holder: ObjectHolder;
|
||
object: SceneObject;
|
||
}
|
||
|
||
function objectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] {
|
||
const result: SceneObjectRef[] = [];
|
||
for (const holder of objectHolders(project)) {
|
||
for (const object of holder.objects) if (predicate(object)) result.push({ holder, object });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** Every placed instance of an asset, in scenes and in container templates alike. */
|
||
export function findUsages(project: Project, assetId: string): SceneObjectRef[] {
|
||
return objectsWhere(project, (o) => o.assetId === assetId);
|
||
}
|
||
|
||
export type PropertyUsage =
|
||
| { on: 'asset'; asset: Asset; key: string }
|
||
/** `path` is empty for a placed object's own override, set for one a container instance keeps. */
|
||
| { on: 'object'; holder: ObjectHolder; object: SceneObject; path: string; key: string };
|
||
|
||
/**
|
||
* The other half of "where is this asset used": a texture is never placed, it is worn through a
|
||
* sprite's `texture` property, so counting uses needs this as well as `findUsages`. Several ids
|
||
* because every frame cut from a texture is a reference to the same picture (see `textureRefIds`).
|
||
*/
|
||
export function findPropertyUsages(project: Project, ids: string | readonly string[]): PropertyUsage[] {
|
||
const wanted = new Set((typeof ids === 'string' ? [ids] : ids).filter(Boolean));
|
||
if (wanted.size === 0) return [];
|
||
const result: PropertyUsage[] = [];
|
||
const refKeys = new Map<string, string[]>();
|
||
const matches = (value: unknown): boolean => typeof value === 'string' && wanted.has(value);
|
||
for (const asset of project.assets) {
|
||
const keys = schemaForAsset(project, asset)
|
||
.filter((def) => isKindWith('reference', def.kind))
|
||
.map((def) => def.key);
|
||
refKeys.set(asset.id, keys);
|
||
for (const key of keys) if (matches(asset.props[key])) result.push({ on: 'asset', asset, key });
|
||
}
|
||
for (const site of overrideSites(project, () => true)) {
|
||
for (const key of refKeys.get(site.assetId) ?? [])
|
||
if (matches(site.bag[key]))
|
||
result.push({ on: 'object', holder: site.holder, object: site.object, path: site.path, key });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** The tiles drawn from any of these pictures – intrinsic data, like an animation's keys. */
|
||
export function tilesUsing(project: Project, ids: readonly string[]): TileAsset[] {
|
||
const wanted = new Set(ids);
|
||
return project.assets.filter(
|
||
(a): a is TileAsset => a.type === 'tile' && a.pictures.some((id) => wanted.has(id)),
|
||
);
|
||
}
|
||
|
||
/** The cells a tile is painted on, and on how many tilemaps – the cells are the maps' own data. */
|
||
export function cellsPainted(project: Project, tileId: string): { tilemaps: number; cells: number } {
|
||
let tilemaps = 0;
|
||
let cells = 0;
|
||
for (const asset of project.assets) {
|
||
if (asset.type !== 'tilemap') continue;
|
||
const here = asset.layers.reduce(
|
||
(sum, layer) => sum + Object.values(layer.cells).filter((id) => id === tileId).length,
|
||
0,
|
||
);
|
||
if (here > 0) tilemaps++;
|
||
cells += here;
|
||
}
|
||
return { tilemaps, cells };
|
||
}
|
||
|
||
/** The animations keying any of these ids – intrinsic data, which `findPropertyUsages` does not see. */
|
||
export function animationsUsing(project: Project, ids: readonly string[]): AnimationAsset[] {
|
||
const wanted = new Set(ids);
|
||
return project.assets.filter(
|
||
(a): a is AnimationAsset =>
|
||
a.type === 'animation' && a.tracks.some((t) => referencesAny(t, trackDef(project, a, t)?.kind, wanted)),
|
||
);
|
||
}
|