game-maker / suite (push) Successful in 9m5s
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>
158 lines
6.4 KiB
TypeScript
158 lines
6.4 KiB
TypeScript
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;
|
||
}
|
||
}
|