Files
feuerwerk/src/core/commands/containerCommands.ts
T
rootandClaude Fable 5.1 016e83c9b5
game-maker / suite (push) Successful in 9m5s
Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
Four new rows on sprite, text, collider and container (block TRANSFORM):
scale, rotation in degrees clockwise, mirrorX, mirrorY. As properties they
get override/reset/apply, loading of old projects, write-back after play and
the Properties node for free; camera and behavior have none.

The hierarchy composes T*R*S per level (composeAlongChain replaces
sumAlongChain), so children rotate and scale with their parent. Reparenting
and grouping keep the look (carriedTransform). Drawing goes through
placeNode, the gizmo is a polygon, cull and objectRect use the AABB, drags
are computed in the parent's system.

Colliders are oriented boxes: SAT for box/box, box-circle in the box frame;
exitDistance replaces axisSeparation/axisCorrection, moveAndSlide moves along
the object's own axes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 17:25:06 +02:00

158 lines
6.4 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 { newId } from '../ids';
import type { ContainerAsset } from '../model/Asset';
import { findObject, removeSubtrees, subtreeIds, subtreeRoots, treeOrder } from '../model/hierarchy';
import { type InstanceEdit, isLiveInstance, materializeInstance } from '../model/instance';
import { apply, invert, multiply, translation } from '../model/matrix';
import type { Position } from '../model/Properties';
import { findAsset, findObjectHolder, type Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import {
carryTransform,
localPositionFor,
parentTransform,
worldPosition,
worldTransform,
} from '../properties/transform';
import { removeById, type Removed, restoreAt } from '../util/list';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
import { requireHolder, requireStructuralObject } from './require';
/**
* Turns the selected objects and their descendants into a container asset placed in their stead, the
* template centred on the selection's bounding box (z = 0).
*/
export class CreateContainerCommand implements Command {
readonly label = 'Create container';
readonly assetId = newId();
readonly objectId = newId();
private removed: Removed<SceneObject>[] = [];
/** Kept across a redo: the ids of the resolved content are built from them. */
private readonly templateIds = new Map<string, string>();
constructor(
private readonly holderId: string,
private readonly objectIds: readonly string[],
) {}
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
const ids = [...new Set(this.objectIds)];
const selected = ids.map((id) => requireStructuralObject(project, holder, id));
if (selected.length === 0) throw new CommandError('Select at least one object to create a container');
const worlds = selected.map((o) => worldPosition(project, holder, o));
const centre: Position = {
x: Math.round((Math.min(...worlds.map((p) => p.x)) + Math.max(...worlds.map((p) => p.x))) / 2),
y: Math.round((Math.min(...worlds.map((p) => p.y)) + Math.max(...worlds.map((p) => p.y))) / 2),
z: 0,
};
// Members are collected in tree order, so a parent is always built before its children.
const roots = new Set(
subtreeRoots(
holder,
selected.map((o) => o.id),
),
);
const memberIds = subtreeIds(holder, roots);
const members = treeOrder(holder)
.map((e) => e.object)
.filter((o) => memberIds.has(o.id));
const rootParents = new Set([...roots].map((id) => findObject(holder, id)!.parentId));
const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : '';
const asset = buildAsset(
'container',
nextAssetName(project, 'Container'),
this.assetId,
) as ContainerAsset;
const containerLocal = localPositionFor(project, holder, containerParentId, centre);
// The template is the container's frame: a root keeps its place and look inside it, which
// under a turned or sized parent means transform rows of its own (see `carryTransform`).
const containerWorld = multiply(
parentTransform(project, holder, containerParentId).m,
translation(containerLocal.x, containerLocal.y),
);
const templateIds = this.templateIds;
for (const object of members) if (!templateIds.has(object.id)) templateIds.set(object.id, newId());
for (const object of members) {
const isRoot = roots.has(object.id);
const world = worldTransform(project, holder, object);
const inside = apply(invert(containerWorld), { x: world.m.tx, y: world.m.ty });
const template: SceneObject = {
...object,
id: templateIds.get(object.id)!,
position: isRoot ? { x: inside.x, y: inside.y, z: world.z } : { ...object.position },
// A view-only offset belongs to the placed object, not to the template.
editorOffset: { x: 0, y: 0 },
overrides: { ...object.overrides },
parentId: isRoot ? '' : (templateIds.get(object.parentId) ?? ''),
instanceEdits: { ...object.instanceEdits },
};
const objectAsset = findAsset(project, object.assetId);
if (isRoot && objectAsset)
carryTransform(
objectAsset,
template,
parentTransform(project, holder, object.parentId).m,
containerWorld,
);
asset.objects.push(template);
}
const container = createSceneObject(this.objectId, this.assetId, containerLocal, containerParentId);
this.removed = removeSubtrees(holder, [...roots]);
project.assets.push(asset);
holder.objects.push(container);
}
undo(project: Project): void {
const holder = findObjectHolder(project, this.holderId);
if (holder) {
holder.objects = removeById(holder.objects, this.objectId);
restoreAt(holder.objects, this.removed);
}
project.assets = removeById(project.assets, this.assetId);
}
}
/**
* Writes the content a placed container shows into the holder as ordinary objects. It stops
* following the template from then on – which is what makes its structure editable, the one thing
* an instance does not allow.
*/
export class UnpackContainerCommand implements Command {
readonly label = 'Unpack container';
private createdIds: string[] = [];
private edits: Record<string, InstanceEdit> = {};
constructor(
private readonly holderId: string,
private readonly objectId: string,
) {}
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
const object = requireStructuralObject(project, holder, this.objectId);
if (!isLiveInstance(project, holder, object))
throw new CommandError(`${object.name || 'That object'} is not a placed container`);
const created = materializeInstance(project, holder, object);
if (created.length === 0) throw new CommandError('The container is empty, there is nothing to unpack');
this.createdIds = created.map((o) => o.id);
this.edits = object.instanceEdits;
object.instanceEdits = {};
holder.objects.push(...created);
}
undo(project: Project): void {
const holder = findObjectHolder(project, this.holderId);
if (!holder) return;
const ids = new Set(this.createdIds);
holder.objects = holder.objects.filter((o) => !ids.has(o.id));
const object = findObject(holder, this.objectId);
if (object) object.instanceEdits = this.edits;
}
}