Let a container sort its content with its parent (#243)

A container is not drawn, so it had no place in the draw order. The
chain from its content to the parent of the container broke there, and
a container under a character could not be drawn together with it.

- A container gets the switch Sort with parent under Z. It defaults to
  off, also for new containers, so projects draw as they did.
- With the switch on, the whole content is drawn in the slot of the
  container's parent. The content's own switch does not count there.
- Containers in a row pass the content on as long as each has the
  switch on. On the top level or under a tilemap it has no effect.
- drawSlots follows the new sortsWith field of a draw item. Scene view,
  game and picking all use drawSlots, so all three agree.

Found on the way: a scene object hung directly under a container
spells the container out, and its template content is dropped. That is
the existing spelledOut rule, the tests now keep both cases apart.

Tests:
- ./check.sh: 178 files, 2573 tests, CHECK EXIT 0
- 7 new unit tests in sort-with-parent.test.ts: the switch, template
  content, override per container, a chain, top level and tilemap,
  game and picking
- e2e 5b-iii: a real click on the switch in the inspector, with undo
- 4 counter-probes over #242 and #243 bite (2 unit, 2 browser)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 12:12:40 +02:00
1 parent 074bda2247
commit 3abb6894a7
5 files changed
+188 -15

No files matched your search

+15 -2
View File
@@ -274,13 +274,26 @@ export const SORT_PROPS: PropertySchema = [
{ key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false, initial: true },
];
/**
* A container is not drawn, but with the switch on it passes its content on to its own parent
* (#243). It starts off, so content placed before draws as it did.
*/
const CONTAINER_SORT_PROPS: PropertySchema = [
{ key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false },
];
/** 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']);
const SORT_TYPES: ReadonlySet<AssetType> = new Set(['sprite', 'text', 'particles', 'container']);
export function hasSortProps(type: AssetType): boolean {
return SORT_TYPES.has(type);
}
function sortPropsOf(type: AssetType): PropertySchema {
if (!hasSortProps(type)) return [];
return type === 'container' ? CONTAINER_SORT_PROPS : SORT_PROPS;
}
export function isSortProp(key: string): boolean {
return key === SORT_WITH_PARENT_KEY;
}
@@ -640,7 +653,7 @@ const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
? [
...COMMON_PROPS,
...(hasTransformProps(type) ? TRANSFORM_PROPS : []),
...(hasSortProps(type) ? SORT_PROPS : []),
...sortPropsOf(type),
...ASSET_SCHEMAS[type],
]
: ASSET_SCHEMAS[type],
+11 -5
View File
@@ -55,7 +55,8 @@ interface Candidate extends DrawSlot {
* 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).
* place, and its `z` only orders it against the parent and their siblings (#232). The content of a
* container that sorts with its parent is such a child of the container's parent (#243).
*/
export function drawSlots(items: readonly DrawItem[]): DrawSlot[] {
const groups = parentGroups(items);
@@ -103,11 +104,12 @@ export function drawSlots(items: readonly DrawItem[]): DrawSlot[] {
*/
function parentGroups(items: readonly DrawItem[]): Map<string, DrawItem[]> {
const drawn = new Map(items.filter((item) => !isSymbolType(item.type)).map((item) => [item.id, item]));
const parentOf = (item: DrawItem) => drawn.get(item.sortsWith || item.parentId);
const leaderOf = (item: DrawItem): DrawItem => {
let leader = item;
for (let parent = drawn.get(leader.parentId); parent && joinsParent(leader, parent);) {
for (let parent = parentOf(leader); parent && joinsParent(leader, parent);) {
leader = parent;
parent = drawn.get(leader.parentId);
parent = parentOf(leader);
}
return leader;
};
@@ -122,9 +124,13 @@ function parentGroups(items: readonly DrawItem[]): Map<string, DrawItem[]> {
return groups;
}
/** A map has no single place to share, neither as the child nor as the parent. */
/**
* A map has no single place to share, neither as the child nor as the parent. Content a container
* passes on joins by the container's switch, its own has no say (#243).
*/
function joinsParent(child: DrawItem, parent: DrawItem): boolean {
return child.props[SORT_WITH_PARENT_KEY] === true && child.type !== 'tilemap' && parent.type !== 'tilemap';
const joins = child.sortsWith !== '' || child.props[SORT_WITH_PARENT_KEY] === true;
return joins && child.type !== 'tilemap' && parent.type !== 'tilemap';
}
/** A row of a layer sorted by Y, or an object other than a map. */
+30 -6
View File
@@ -29,6 +29,7 @@ import { liveTilemap, type TileEdits } from '../core/model/liveTilemap';
import { colliderFrame } from '../core/properties/collider';
import { enabledAlongChain } from '../core/properties/enabled';
import { fontOf } from '../core/properties/fonts';
import { SORT_WITH_PARENT_KEY } from '../core/properties/PropertySchema';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import { editorTransform } from '../core/properties/transform';
import type { World } from '../runtime/World';
@@ -47,6 +48,11 @@ export interface DrawItem {
props: PropertyBag;
/** Id of the parent item, empty when top-level. */
parentId: string;
/**
* The object a container between this one and it passes this one on to (#243): the container's
* switch "Sort with parent" is on, so this one is drawn in that object's place. Empty when none.
*/
sortsWith: string;
/** Effective switch: this object and all its parents are on (see `properties/enabled.ts`). */
enabled: boolean;
/** Resolved here because drawing has no access to the project. */
@@ -202,6 +208,7 @@ export function collectHolderItems(
m,
props,
parentId: object.parentId,
sortsWith: '',
enabled: true,
texture: wearsPicture(asset.type) ? resolveTexture(project, props) : null,
font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '',
@@ -215,12 +222,11 @@ export function collectHolderItems(
});
}
const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items)
item.enabled = enabledAlongChain(
item,
(i) => i.props,
(i) => byId.get(i.parentId) ?? null,
);
const parentOf = (i: DrawItem) => byId.get(i.parentId) ?? null;
for (const item of items) {
item.enabled = enabledAlongChain(item, (i) => i.props, parentOf);
item.sortsWith = passedOnTo(parentOf(item), parentOf)?.id ?? '';
}
return { items, background: Number(holder.props.backgroundColor ?? fallbackBackground) };
}
@@ -229,6 +235,23 @@ function liveEdits(run: World | null, holder: ObjectHolder, objectId: string): T
return run?.sceneId === holder.id ? run.findByObjectId(objectId)?.tileEdits : undefined;
}
/**
* Where a chain of containers with "Sort with parent" on hands their content (#243): the first
* object above them, or null when no such container is in the way – or nothing is above it.
*/
function passedOnTo<T extends { type: AssetType; props: PropertyBag }>(
parent: T | null,
up: (at: T) => T | null,
): T | null {
let at = parent;
let passed = false;
while (at?.type === 'container' && at.props[SORT_WITH_PARENT_KEY] === true) {
passed = true;
at = up(at);
}
return passed ? at : null;
}
/** The running world: scene content only, since symbols are editor-only. */
export function collectWorldItems(world: World): DrawList {
const items = world.entities
@@ -247,6 +270,7 @@ export function collectWorldItems(world: World): DrawList {
m,
props: e.props,
parentId: e.parent?.objectId ?? '',
sortsWith: passedOnTo(e.parent ?? null, (p) => p.parent ?? null)?.objectId ?? '',
enabled: true,
// Resolved per frame on purpose: a `Properties` node or an animation may swap `texture`.
texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props) : null,