Let a child sort with its parent so its Z stays local (#232)
An animated or set Z of -1 on a weapon put it behind everything on Z 0, not just behind the player holding it. A child can now opt in to being drawn at its parent's place, so its own Z only orders it against the parent and its siblings. - New property "Sort with parent" on sprites, texts and particle emitters, shown in the inspector right under Z. It is a normal property, set on the asset and overridable per object. - With it on, the rest of the scene sees only the parent's Z and, where the layer sorts by Y, the parent's feet. Below 0 the child is drawn behind the parent, from 0 on in front. - It works across levels: a grandchild with the switch hangs on the top object of the chain. - It has no effect under a parent that is not drawn itself (container, behavior, camera, collider) or under a tilemap. - New assets start with it on, saved ones read as off, so old projects draw unchanged. For that a schema row now has "initial" next to "default". The gold "changed" mark and reset follow "initial", so a fresh sprite is not marked as changed. No load migration is needed. - parentGroups() in drawOrder.ts gives a slot only to the top of a chain and inserts the children there. Editor, play mode and picking all use it, so they agree. Tests: - 10 new unit tests in tests/viewport/sort-with-parent.test.ts. - 8 existing tests list the sprite rows or defaults and got the row. - New e2e check 5b-ii clicks the switch and checks order and undo. - Counter-checks, all originals restored by cmp: switch ignored fails 7, children not sorted by Z fails 6, no chain fails 1, "initial" ignored fails 9, row missing in the inspector fails e2e 5. - ./check.sh: 166 files, 2426 tests, CHECK EXIT 0. - e2e 5: ALL PASSED, 13 checks. Check total 1398, computed only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fae827c087
commit
8b44f97c35
13 files changed
+351
-15
No files matched your search
@@ -25,14 +25,14 @@ import {
|
||||
} from '../model/Scope';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile } from '../model/texture';
|
||||
import { coerceValue, defaultsFor } from '../properties/PropertySchema';
|
||||
import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema';
|
||||
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset, requirePropDef, requireStateMachine } from './require';
|
||||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||||
|
||||
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
const base = { id, name, props: defaultsFor(type) };
|
||||
const base = { id, name, props: initialPropsFor(type) };
|
||||
switch (type) {
|
||||
case 'texture':
|
||||
return buildTextureAsset(name, { file: '', width: 0, height: 0 }, id);
|
||||
@@ -436,7 +436,7 @@ export class ResetAssetPropertyCommand extends AssetPropertyCommand {
|
||||
}
|
||||
|
||||
protected override computeNext(asset: Asset, project: Project): PropertyValue {
|
||||
return requirePropDef(project, asset, this.key).default;
|
||||
return initialOf(requirePropDef(project, asset, this.key));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,9 +91,22 @@ type Extras<K extends FlatKind> = K extends 'number'
|
||||
* (see `isRowShown`), everything else – graph, defaults, files – always has it.
|
||||
*/
|
||||
export type FlatDef<K extends FlatKind = FlatKind> = {
|
||||
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P>; shownWith?: string } & Extras<P>;
|
||||
[P in K]: {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: P;
|
||||
default: ValueOf<P>;
|
||||
/** What a new asset starts with when it differs from `default`, the value a missing key reads as. */
|
||||
initial?: ValueOf<P>;
|
||||
shownWith?: string;
|
||||
} & Extras<P>;
|
||||
}[K];
|
||||
|
||||
/** The value a new asset starts with, which is also what Reset goes back to. */
|
||||
export function initialOf(def: PropertyDef): PropertyValue {
|
||||
return 'initial' in def && def.initial !== undefined ? def.initial : def.default;
|
||||
}
|
||||
|
||||
/** Whether the inspector shows the row, given the values of the bag it is on. */
|
||||
export function isRowShown(def: PropertyDef, values: PropertyBag): boolean {
|
||||
return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true;
|
||||
@@ -233,9 +246,30 @@ export function isTransformProp(key: string): boolean {
|
||||
return TRANSFORM_KEYS.has(key);
|
||||
}
|
||||
|
||||
const COMMON_KEYS = new Set([...COMMON_PROPS, ...TRANSFORM_PROPS].map((d) => d.key));
|
||||
export const SORT_WITH_PARENT_KEY = 'sortWithParent';
|
||||
|
||||
/** The switch and the transform rows: reserved names, shown apart from the type's own properties. */
|
||||
/**
|
||||
* Whether a child is drawn in its parent's place instead of on its own (#232, see `drawSlots`).
|
||||
* A project stored before it reads as off, so it draws as it did; a new asset starts with it on.
|
||||
*/
|
||||
export const SORT_PROPS: PropertySchema = [
|
||||
{ key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false, initial: true },
|
||||
];
|
||||
|
||||
/** A tilemap has no single place in the draw order, and the other types are not drawn at all. */
|
||||
const SORT_TYPES: ReadonlySet<AssetType> = new Set(['sprite', 'text', 'particles']);
|
||||
|
||||
export function hasSortProps(type: AssetType): boolean {
|
||||
return SORT_TYPES.has(type);
|
||||
}
|
||||
|
||||
export function isSortProp(key: string): boolean {
|
||||
return key === SORT_WITH_PARENT_KEY;
|
||||
}
|
||||
|
||||
const COMMON_KEYS = new Set([...COMMON_PROPS, ...TRANSFORM_PROPS, ...SORT_PROPS].map((d) => d.key));
|
||||
|
||||
/** The switches, the transform and sort rows: reserved names, shown apart from the type's own properties. */
|
||||
export function isCommonProp(key: string): boolean {
|
||||
return COMMON_KEYS.has(key);
|
||||
}
|
||||
@@ -585,7 +619,12 @@ const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
|
||||
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
|
||||
type,
|
||||
isPlaceableType(type)
|
||||
? [...COMMON_PROPS, ...(hasTransformProps(type) ? TRANSFORM_PROPS : []), ...ASSET_SCHEMAS[type]]
|
||||
? [
|
||||
...COMMON_PROPS,
|
||||
...(hasTransformProps(type) ? TRANSFORM_PROPS : []),
|
||||
...(hasSortProps(type) ? SORT_PROPS : []),
|
||||
...ASSET_SCHEMAS[type],
|
||||
]
|
||||
: ASSET_SCHEMAS[type],
|
||||
]),
|
||||
) as Record<AssetType, PropertySchema>;
|
||||
@@ -686,6 +725,13 @@ export function defaultsOf(schema: PropertySchema): PropertyBag {
|
||||
return bag;
|
||||
}
|
||||
|
||||
/** The bag a new asset of the type is created with. */
|
||||
export function initialPropsFor(type: AssetType): PropertyBag {
|
||||
const bag: PropertyBag = {};
|
||||
for (const def of FULL_SCHEMAS[type]) bag[def.key] = initialOf(def);
|
||||
return bag;
|
||||
}
|
||||
|
||||
export function variableDef(
|
||||
key: string,
|
||||
kind: VariableKind,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Asset } from '../model/Asset';
|
||||
import { type PropertyBag, type PropertyValue, sameValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { propDef, schemaForAsset, schemaForObject } from './PropertySchema';
|
||||
import { initialOf, propDef, schemaForAsset, schemaForObject } from './PropertySchema';
|
||||
|
||||
/**
|
||||
* Schema defaults filled with the asset's own values. Keys unknown to the schema are ignored, so
|
||||
@@ -38,12 +38,12 @@ function valuesOver(
|
||||
return bag;
|
||||
}
|
||||
|
||||
/** Whether the asset's own value differs from the schema default – the asset-level override. */
|
||||
/** Whether the asset's own value differs from what a new asset starts with – the asset-level override. */
|
||||
export function isModified(project: Project, asset: Asset, key: string): boolean {
|
||||
const def = propDef(project, asset, key);
|
||||
if (!def) return false;
|
||||
const own = asset.props[key];
|
||||
return own !== undefined && !sameValue(own, def.default);
|
||||
return own !== undefined && !sameValue(own, initialOf(def));
|
||||
}
|
||||
|
||||
/** The value the object would have without its override for `key`. */
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
COMMON_PROPS,
|
||||
isCommonProp,
|
||||
isRowShown,
|
||||
isSortProp,
|
||||
isTransformProp,
|
||||
type PropertyDef,
|
||||
schemaFor,
|
||||
@@ -180,6 +181,9 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
|
||||
),
|
||||
});
|
||||
}
|
||||
// Right under Z, which it changes the meaning of.
|
||||
for (const def of schemaForObject(project, asset, object).filter((d) => isSortProp(d.key)))
|
||||
propertyRow(host, holderId, objectId, asset, def, resolved);
|
||||
|
||||
// Turn, size and mirror sit with the position: together they are where the object is.
|
||||
const transform = schemaForObject(project, asset, object).filter((def) => isTransformProp(def.key));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import { sortByZ } from '../core/model/Properties';
|
||||
import { cellOrigin, type TileDraw, tilemapTileSize } from '../core/model/tilemap';
|
||||
import { footprintExtent } from '../core/properties/bounds';
|
||||
import { SORT_WITH_PARENT_KEY } from '../core/properties/PropertySchema';
|
||||
import type { DrawItem, TilemapDraw, TilemapLayerDraw } from './items';
|
||||
|
||||
/**
|
||||
@@ -52,11 +53,15 @@ interface Candidate extends DrawSlot {
|
||||
* (#228): an object by the bottom of its box or by its frame's sort line, a row of the layer by the
|
||||
* bottom of the row. Ties keep the order above. A layer drawn as one piece and the map's own node
|
||||
* stay behind them, in that order, since neither has a single place to stand.
|
||||
*
|
||||
* A child that sorts with its parent takes no part in any of this: it is drawn in its parent's
|
||||
* place, and its `z` only orders it against the parent and their siblings (#232).
|
||||
*/
|
||||
export function drawSlots(items: readonly DrawItem[]): DrawSlot[] {
|
||||
const groups = parentGroups(items);
|
||||
const slots: Candidate[] = [];
|
||||
for (const item of items) {
|
||||
if (isSymbolType(item.type)) continue;
|
||||
if (isSymbolType(item.type) || !groups.has(item.id)) continue;
|
||||
slots.push({ id: item.id, layerId: '', row: null, z: item.z, item });
|
||||
for (const layer of item.tilemap?.layers ?? []) {
|
||||
const z = item.z + layer.z;
|
||||
@@ -83,7 +88,43 @@ export function drawSlots(items: readonly DrawItem[]): DrawSlot[] {
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
return order.map(({ id, layerId, row, z }) => ({ id, layerId, row, z }));
|
||||
return order.flatMap(({ id, layerId, row, z }) =>
|
||||
layerId === '' && row === null
|
||||
? groups.get(id)!.map((member) => ({ id: member.id, layerId, row, z: member.z }))
|
||||
: [{ id, layerId, row, z }],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every object that is sorted into the scene on its own, with what is drawn in its place (#232):
|
||||
* itself and the children that sort with it, at any depth, back to front by their `z` above its
|
||||
* own – ties in hierarchy order, so the parent comes before a child on its `z`. An object that
|
||||
* sorts with its parent is no key here.
|
||||
*/
|
||||
function parentGroups(items: readonly DrawItem[]): Map<string, DrawItem[]> {
|
||||
const drawn = new Map(items.filter((item) => !isSymbolType(item.type)).map((item) => [item.id, item]));
|
||||
const leaderOf = (item: DrawItem): DrawItem => {
|
||||
let leader = item;
|
||||
for (let parent = drawn.get(leader.parentId); parent && joinsParent(leader, parent);) {
|
||||
leader = parent;
|
||||
parent = drawn.get(leader.parentId);
|
||||
}
|
||||
return leader;
|
||||
};
|
||||
const groups = new Map<string, DrawItem[]>();
|
||||
for (const item of drawn.values()) {
|
||||
const leader = leaderOf(item);
|
||||
const group = groups.get(leader.id);
|
||||
if (group) group.push(item);
|
||||
else groups.set(leader.id, [item]);
|
||||
}
|
||||
for (const group of groups.values()) group.sort((a, b) => a.z - b.z);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** A map has no single place to share, neither as the child nor as the parent. */
|
||||
function joinsParent(child: DrawItem, parent: DrawItem): boolean {
|
||||
return child.props[SORT_WITH_PARENT_KEY] === true && child.type !== 'tilemap' && parent.type !== 'tilemap';
|
||||
}
|
||||
|
||||
/** A row of a layer sorted by Y, or an object other than a map. */
|
||||
|
||||
Reference in new issue
Block a user