373 lines
15 KiB
TypeScript
373 lines
15 KiB
TypeScript
import {
|
||
ApplyOverrideToAssetCommand,
|
||
ApplyOverrideToContainerCommand,
|
||
ApplyPositionToContainerCommand,
|
||
RevertOverrideToTemplateCommand,
|
||
RevertPositionToTemplateCommand,
|
||
} from '../../core/commands/applyCommands';
|
||
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
|
||
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 } from '../../core/model/Properties';
|
||
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
|
||
import type { SceneObject } from '../../core/model/SceneObject';
|
||
import {
|
||
COMMON_PROPS,
|
||
isCommonProp,
|
||
isTransformProp,
|
||
type PropertyDef,
|
||
schemaFor,
|
||
schemaForObject,
|
||
} from '../../core/properties/PropertySchema';
|
||
import {
|
||
deviatesFromAsset,
|
||
deviatesFromTemplate,
|
||
resolveSceneObjectProps,
|
||
} from '../../core/properties/resolve';
|
||
import { Button } from '../../ui';
|
||
import { createContainer, removeObjects } from '../actions';
|
||
import { aspectActions, sizeCommand } from './aspectAction';
|
||
import { defSignature } from './assetSection';
|
||
import { actionIf, createFieldRow, type FieldRow, labelledRow, 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 { buildTilemapSection, tilemapSignature } from './tilemapSection';
|
||
import { overrideValue } 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, schema 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 rows = schemaForObject(project, asset, object);
|
||
const painting = asset.type === 'tilemap' ? `|${tilemapSignature(state, asset)}` : '';
|
||
return `${object.parentId}|${kind}|` + rows.map(defSignature).join(',') + painting;
|
||
}
|
||
|
||
/** 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', 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', 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)),
|
||
),
|
||
});
|
||
}
|
||
|
||
// 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);
|
||
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 === 'container') contentSection(host, holderId, object, asset.objects.length);
|
||
if (asset.type === 'tilemap') buildTilemapSection(host, asset);
|
||
}
|
||
|
||
function assetLink(host: InspectorHost, label: string, asset: Asset): void {
|
||
const { row } = labelledRow(label);
|
||
row.add(
|
||
new Button(asset.name, {
|
||
variant: 'ghost',
|
||
onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }),
|
||
}),
|
||
1,
|
||
);
|
||
host.body.add(row);
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
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, targetOf(project, holderId, objectId));
|
||
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 = 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);
|
||
}
|
||
}
|