Files
feuerwerk/src/editor/inspector/objectSection.ts
T
adminandClaude Opus 5.5 8b44f97c35
game-maker / gate (push) Successful in 3m7s
game-maker / suite (push) Failing after 8m51s
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>
2026-09-25 23:08:09 +02:00

427 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ApplyOverrideToAssetCommand,
ApplyOverrideToContainerCommand,
ApplyPositionToContainerCommand,
RevertOverrideToTemplateCommand,
RevertPositionToTemplateCommand,
} from '../../core/commands/applyCommands';
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands';
import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap';
import { findUsages } from '../../core/model/usages';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
RenameSceneObjectCommand,
SetEditorOffsetCommand,
SetOverrideCommand,
} from '../../core/commands/sceneObjectCommands';
import type { Asset } from '../../core/model/Asset';
import { findObject } from '../../core/model/hierarchy';
import {
type InstanceTarget,
isLiveInstance,
isTemplateContent,
type ObjectRef,
resolvedHolder,
} from '../../core/model/instance';
import { type PropertyValue, textOf } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import {
COMMON_PROPS,
isCommonProp,
isRowShown,
isSortProp,
isTransformProp,
type PropertyDef,
schemaFor,
schemaForObject,
} from '../../core/properties/PropertySchema';
import {
deviatesFromAsset,
deviatesFromTemplate,
resolveSceneObjectProps,
} from '../../core/properties/resolve';
import { Button, TextField, Theme } from '../../ui';
import { createContainer, removeObjects } from '../actions';
import { aspectActions, sizeCommand } from './aspectAction';
import { defSignature } from './assetSection';
import {
actionIf,
createFieldRow,
type FieldRow,
labelledRow,
linkLabel,
type RowActions,
} from './fieldFactory';
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
import type { EditorState, ListSlot } from '../state';
import { parentRow } from './parentRow';
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { overrideValue, tilemapSizeValue } from './valueSpecs';
import { buildVariable } from './variableRows';
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
/**
* The rows a placed object shows for what it was placed from: a behavior's declared variables, any
* other asset's schema minus the common switches.
*/
export function objectRows(project: Project, asset: Asset, object: SceneObject): PropertyDef[] {
return asset.type === 'behavior'
? asset.variables
: schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key));
}
/** Structure-defining facts of an object selection: parent, template link, the rows shown and painting. */
export function objectSignature(
state: EditorState,
holderId: string,
objectIds: readonly string[],
objectId: string,
): string {
const project = state.project;
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
const holder = findObjectHolder(project, holderId);
const view = holder && resolvedHolder(project, holder);
const object = view && findObject(view, objectId);
const asset = object && findAsset(project, object.assetId);
if (!holder || !object || !asset) return 'missing';
const kind = isTemplateContent(holder, objectId)
? 'template'
: isLiveInstance(project, holder, object)
? 'instance'
: '';
const values = resolveSceneObjectProps(project, asset, object);
const rows = schemaForObject(project, asset, object).filter((def) => isRowShown(def, values));
// A tilemap warns when it is placed more than once, so the number of placements shapes it too.
const placed = asset.type === 'tilemap' ? `|placed:${findUsages(project, asset.id).length}` : '';
return `${object.parentId}|${kind}${placed}|` + rows.map(defSignature).join(',');
}
/** Several objects: what they are, and the actions that apply to all of them at once. */
export function buildObjectsSection(host: InspectorHost, holderId: string, objectIds: string[]): void {
const view = viewOf(host.state.project, holderId);
if (!view) return;
addTitle(host, `${objectIds.length} OBJECTS SELECTED`);
for (const id of objectIds) {
const object = findObject(view, id);
const asset = object && findAsset(host.state.project, object.assetId);
if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`);
}
addGap(host);
addNote(host, 'Ctrl-click adds or removes objects.', 'small');
addNote(host, 'Dragging one in the viewport moves all of them.', 'small');
addGap(host);
const holder = findObjectHolder(host.state.project, holderId)!;
if (objectIds.some((id) => isTemplateContent(holder, id))) {
addNote(host, 'Part of the selection comes from a container', 'small');
addNote(host, 'template, so grouping and removing are off.', 'small');
return;
}
host.body.add(
new Button('Create container', { onClick: () => createContainer(host.state, holderId, objectIds) }),
);
addNote(host, 'Groups the selection into a reusable container', 'small');
addNote(host, 'asset placed at its centre (Ctrl+G).', 'small');
addGap(host);
host.body.add(
new Button('Remove', {
variant: 'danger',
onClick: () => removeObjects(host.state, holderId, objectIds),
}),
);
}
export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void {
const { project } = host.state;
const view = viewOf(project, holderId);
const object = view && findObject(view, objectId);
const asset = object && findAsset(project, object.assetId);
if (!view || !object || !asset) return;
const target = targetOf(project, holderId, objectId);
addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
addNameField(host, object.name, {
placeholder: asset.name,
onChange: (name) => host.state.execute(new RenameSceneObjectCommand(holderId, objectId, name.trim())),
});
assetLink(host, ASSET_ROW, 'Asset', asset);
host.body.add(parentRow(host, view, object, target !== undefined));
// Content of a placed container: values compare against the template, not just the asset.
if (target) assetLink(host, PART_OF_ROW, 'Part of', target.asset);
// The master switch, right where it is looked for: above everything type-specific.
const resolved = resolveSceneObjectProps(host.state.project, asset, object);
for (const def of COMMON_PROPS) propertyRow(host, holderId, objectId, asset, def, resolved);
// A behavior's own switches sit with it rather than in the section below: that one lists what
// the behavior *declares*, and `Run in editor` is not something it declared.
if (asset.type === 'behavior')
for (const def of schemaFor('behavior').filter((d) => !isCommonProp(d.key)))
propertyRow(host, holderId, objectId, asset, def, resolved);
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
for (const axis of POSITION_AXES) {
const moved = () => positionMoved(host.state, holderId, objectId, axis);
componentRow(host, holderId, objectId, object.position[axis], {
key: axis,
label: axis.toUpperCase(),
axis,
read: (ref) => ref.object.position,
write: (ref, value) => ref.setPosition(value),
command: (v) => new MoveSceneObjectCommand(holderId, objectId, v),
reset: () =>
actionIf(moved(), 'Reset to container', () =>
host.state.execute(new RevertPositionToTemplateCommand(holderId, objectId, axis)),
),
apply: () =>
actionIf(moved(), 'Apply to container', () =>
host.state.execute(new ApplyPositionToContainerCommand(holderId, objectId, axis)),
),
});
}
// 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));
if (transform.length > 0) addTitle(host, 'TRANSFORM');
for (const def of transform) propertyRow(host, holderId, objectId, asset, def, resolved);
// Where the object is *drawn* in the viewport, nothing else – for pulling stacked objects apart.
addTitle(host, 'EDITOR OFFSET (SCENE VIEW ONLY)');
for (const axis of OFFSET_AXES) {
componentRow(host, holderId, objectId, object.editorOffset[axis], {
key: offsetKey(axis),
label: `Offset ${axis.toUpperCase()}`,
axis,
read: (ref) => ref.object.editorOffset,
write: (ref, value) => ref.setEditorOffset(value),
command: (v) => new SetEditorOffsetCommand(holderId, objectId, v),
// No apply: the offset never leaves the placed object.
reset: () => {
const current = currentRef(host.state, holderId, objectId)?.object;
if (!current) return [];
return actionIf(current.editorOffset[axis] !== 0, 'Reset to 0', () =>
host.state.execute(
new SetEditorOffsetCommand(holderId, objectId, { ...current.editorOffset, [axis]: 0 }),
),
);
},
});
}
// Asset properties (or behavior variables) with overrides; the switches are shown above.
const values = resolveSceneObjectProps(host.state.project, asset, object);
const own = objectRows(host.state.project, asset, object).filter((def) => isRowShown(def, values));
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, asset.type === 'behavior');
markObjectRows(host, object, asset, target);
if (asset.type === 'tilemap') gridEdgesSection(host, holderId, object);
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
}
/**
* Grows and shrinks the grid at any edge, one column or row per click, while the painted cells stay
* where they are in the scene (#136). The cells belong to the asset, so they move in every other
* placement too – which only this one's position is corrected for, hence the note.
*/
function gridEdgesSection(host: InspectorHost, holderId: string, object: SceneObject): void {
addTitle(host, 'GRID EDGES');
for (const edge of TILEMAP_EDGES) {
const { row } = labelledRow(edge[0]!.toUpperCase() + edge.slice(1));
for (const count of [-1, 1])
row.add(
new Button(count < 0 ? '−' : '+', {
variant: 'ghost',
minWidth: Theme.controlHeight,
onClick: () => host.state.execute(new ResizeTilemapEdgeCommand(holderId, object.id, edge, count)),
}),
);
host.body.add(row);
}
const others = findUsages(host.state.project, object.assetId).length - 1;
if (others > 0) {
addNote(host, `Placed ${others} more time${others === 1 ? '' : 's'}: at the left and top`, 'warning');
addNote(host, 'edge the cells move there as well.', 'warning');
} else addNote(host, 'The painted cells stay where they are in the scene.', 'small');
}
/** Registry keys of the two asset rows; `#` keeps them clear of property keys. */
const ASSET_ROW = '#asset';
const PART_OF_ROW = '#part-of';
/**
* What the object was placed from: the label is a link like on any reference row (click pings,
* double-click opens), the name next to it only reads – changing it would mean another object.
*/
function assetLink(host: InspectorHost, key: string, text: string, asset: Asset): void {
const { row, label } = labelledRow(text);
linkLabel(host, label)(asset.id);
const name = new TextField(asset.name, { readOnly: true });
row.add(name, 1);
host.body.add(row);
host.fields.set(key, { row, setValue: (v) => name.setValue(textOf(v)), setModified: () => {} });
}
/**
* What a placed container holds. While it follows its template the content cannot be changed here –
* unpacking writes it into the scene as ordinary objects, which cuts the link for good.
*/
function contentSection(
host: InspectorHost,
holderId: string,
object: SceneObject,
templateCount: number,
): void {
const holder = findObjectHolder(host.state.project, holderId);
const live = Boolean(holder && isLiveInstance(host.state.project, holder, object));
addTitle(host, 'CONTENT');
if (!live) {
addNote(host, 'Unpacked: its content is stored in the scene.');
return;
}
addNote(
host,
templateCount === 0 ? 'The container is empty.' : `Follows a template of ${templateCount} object(s).`,
);
if (templateCount === 0) return;
addNote(host, 'Its content is listed below it in the hierarchy.', 'small');
addGap(host);
host.body.add(
new Button('Unpack', {
variant: 'ghost',
onClick: () => host.state.execute(new UnpackContainerCommand(holderId, object.id)),
}),
);
addNote(host, 'Copies the content into the scene and stops', 'small');
addNote(host, 'following the template.', 'small');
}
/** Pushes the current model values into the rows. False when the object is gone (rebuild). */
export function refreshObjectSection(host: InspectorHost, holderId: string, objectId: string): boolean {
const { project } = host.state;
const view = viewOf(project, holderId);
const object = view && findObject(view, objectId);
const asset = object && findAsset(project, object.assetId);
if (!view || !object || !asset) return false;
host.nameField?.setValue(object.name);
// A rename of the asset changes no structure, so it arrives here rather than as a rebuild.
host.fields.get(ASSET_ROW)?.setValue(asset.name);
const target = targetOf(project, holderId, objectId);
if (target) host.fields.get(PART_OF_ROW)?.setValue(target.asset.name);
for (const axis of POSITION_AXES) host.fields.get(axis)?.setValue(object.position[axis]);
for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setValue(object.editorOffset[axis]);
const values = resolveSceneObjectProps(project, asset, object);
for (const def of schemaForObject(project, asset, object))
host.fields.get(def.key)?.setValue(values[def.key] ?? def.default);
markObjectRows(host, object, asset, target);
return true;
}
/**
* One editable property of a placed object. Reset always goes to the nearest base (the template inside
* a container), while "Apply to asset" reaches past it, dropping the template's override.
*/
function propertyRow(
host: InspectorHost,
holderId: string,
objectId: string,
owner: Asset,
def: PropertyDef,
values: Record<string, PropertyValue>,
variable = false,
): FieldRow {
const bases = () => deviations(host.state, holderId, objectId, def.key);
const overrideSpec =
owner.type === 'tilemap' && (def.key === TILEMAP_COLUMNS_KEY || def.key === TILEMAP_ROWS_KEY)
? tilemapSizeValue(host.state, holderId, objectId, def.key)
: overrideValue(host.state, holderId, objectId, def.key);
const fieldSlot = (field: string): ListSlot => ({
kind: 'object',
holderId,
objectId,
key: def.key,
field,
});
const rowActions: RowActions = {
extra: aspectActions(
host,
owner,
def,
() => currentProps(host.state, holderId, objectId),
(values) =>
host.state.execute(
sizeCommand(values, (axis, value) => new SetOverrideCommand(holderId, objectId, axis, value)),
),
),
reset: () => {
const { template, asset } = bases();
return [
...actionIf(template, 'Reset to container', () =>
host.state.execute(new RevertOverrideToTemplateCommand(holderId, objectId, def.key)),
),
...actionIf(asset, 'Reset to asset', () =>
host.state.execute(new ClearOverrideCommand(holderId, objectId, def.key)),
),
];
},
apply: () => {
const { template, applyToAsset } = bases();
return [
...actionIf(template, 'Apply to container', () =>
host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)),
),
...actionIf(applyToAsset, 'Apply to asset', () =>
host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)),
),
];
},
};
const value = values[def.key] ?? def.default;
const handlers = {
...previewingHandlers<ObjectRef>(host, overrideSpec),
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
...structFields<ObjectRef>(host, def, overrideSpec, fieldSlot),
};
// A variable reads exactly like it does on its asset, which adds its own rows to the body.
const row = variable
? buildVariable(host, def, value, handlers, { actions: rowActions })
: createFieldRow(host, def, value, handlers, rowActions);
host.fields.set(def.key, row);
if (!variable) host.body.add(row.row);
return row;
}
/**
* Gold marks exactly what the ↺/↑ buttons offer: for content of a placed container a deviation
* from the template (position per axis, overrides), for any other object one from the asset. The
* editor offset has one base only – no offset at all.
*/
function markObjectRows(
host: InspectorHost,
object: SceneObject,
asset: Asset,
target: InstanceTarget | undefined,
): void {
for (const axis of POSITION_AXES)
host.fields
.get(axis)
?.setModified(Boolean(target) && object.position[axis] !== target!.base.position[axis]);
for (const axis of OFFSET_AXES)
host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
const project = host.state.project;
for (const def of schemaForObject(project, asset, object)) {
const deviates = target
? deviatesFromTemplate(object, target.base, def.key)
: deviatesFromAsset(project, object, asset, def.key);
host.fields.get(def.key)?.setModified(deviates);
}
}