Files
feuerwerk/src/core/commands/sceneObjectCommands.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

320 lines
10 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 { isSelfOrAncestor, removeSubtrees } from '../model/hierarchy';
import {
captureObject,
isLiveInstance,
type ObjectMemo,
type ObjectRef,
restoreObject,
} from '../model/instance';
import { placementRejection } from '../model/placement';
import {
type Position,
type PropertyBag,
type PropertyValue,
toPosition,
type Vec2,
} from '../model/Properties';
import type { Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue } from '../properties/PropertySchema';
import {
carryTransform,
localPositionFor,
parentTransform,
positionOf,
worldTransform,
} from '../properties/transform';
import { type Removed, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import {
requireAsset,
requireObject,
requireObjectRef,
requirePropDef,
requireHolder,
requireStructuralObject,
} from './require';
/**
* An edit to one placed object. The base captures the object before the edit and puts it back on
* undo, so a subclass only says what changes – and, if it wrote somewhere else too, how to take
* that back (`revert`).
*/
export abstract class ObjectEditCommand implements Command {
abstract readonly label: string;
private memo: ObjectMemo | undefined;
protected constructor(
private readonly holderId: string,
private readonly objectId: string,
) {}
protected abstract apply(ref: ObjectRef, project: Project): void;
protected revert(): void {}
execute(project: Project): void {
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
this.apply(ref, project);
}
undo(): void {
this.revert();
if (this.memo) restoreObject(this.memo);
}
}
/**
* The position is a world position; with a parent it is converted to the parent's local space.
* Placing a container adds the container object alone – its content is resolved from the template
* (see `model/instance.ts`), so it is never copied into the scene.
*/
export class PlaceAssetCommand implements Command {
readonly label = 'Place asset';
readonly objectId = newId();
constructor(
private readonly holderId: string,
private readonly assetId: string,
private readonly position: Vec2 | Position,
private readonly parentId = '',
) {}
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
const asset = requireAsset(project, this.assetId);
const rejection = placementRejection(project, holder, asset);
if (rejection) throw new CommandError(rejection);
if (this.parentId) requireStructuralObject(project, holder, this.parentId);
const local = localPositionFor(project, holder, this.parentId, toPosition(this.position));
holder.objects.push(createSceneObject(this.objectId, this.assetId, local, this.parentId));
}
undo(project: Project): void {
const holder = requireHolder(project, this.holderId);
holder.objects = holder.objects.filter((o) => o.id !== this.objectId);
}
}
/** Removes objects together with everything parented below them, as one undo step. */
export class RemoveSceneObjectsCommand implements Command {
readonly label: string;
private removed: Removed<SceneObject>[] = [];
constructor(
private readonly holderId: string,
private readonly objectIds: readonly string[],
) {
this.label = objectIds.length === 1 ? 'Remove object' : 'Remove objects';
}
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
for (const id of this.objectIds) requireStructuralObject(project, holder, id);
this.removed = removeSubtrees(holder, this.objectIds);
}
undo(project: Project): void {
restoreAt(requireHolder(project, this.holderId).objects, this.removed);
}
}
/**
* The previous position is read at execution time, so a caller that previewed the move must
* restore the model before executing.
*/
export class MoveSceneObjectCommand extends ObjectEditCommand {
readonly label = 'Move object';
constructor(
holderId: string,
objectId: string,
private readonly position: Position,
) {
super(holderId, objectId);
}
protected override apply(ref: ObjectRef): void {
ref.setPosition(this.position);
}
}
/** Editor-only: undoable like any other edit, but it changes nothing the game can see. */
export class SetEditorOffsetCommand extends ObjectEditCommand {
readonly label = 'Editor offset';
constructor(
holderId: string,
objectId: string,
private readonly offset: Vec2,
) {
super(holderId, objectId);
}
protected override apply(ref: ObjectRef): void {
ref.setEditorOffset(this.offset);
}
}
export class RenameSceneObjectCommand extends ObjectEditCommand {
readonly label = 'Rename object';
constructor(
holderId: string,
objectId: string,
private readonly name: string,
) {
super(holderId, objectId);
}
protected override apply(ref: ObjectRef): void {
ref.setName(this.name);
}
}
abstract class OverrideCommand extends ObjectEditCommand {
protected constructor(
holderId: string,
objectId: string,
protected readonly key: string,
readonly label: string,
) {
super(holderId, objectId);
}
protected abstract nextValue(object: SceneObject, project: Project): PropertyValue | undefined;
protected override apply(ref: ObjectRef, project: Project): void {
ref.setOverride(this.key, this.nextValue(ref.object, project));
}
}
export class SetOverrideCommand extends OverrideCommand {
constructor(
holderId: string,
objectId: string,
key: string,
private readonly value: PropertyValue,
) {
super(holderId, objectId, key, `Override ${key}`);
}
protected override nextValue(object: SceneObject, project: Project): PropertyValue {
return coerceValue(
requirePropDef(project, requireAsset(project, object.assetId), this.key, object),
this.value,
);
}
}
export class ClearOverrideCommand extends OverrideCommand {
constructor(holderId: string, objectId: string, key: string) {
super(holderId, objectId, key, `Reset ${key}`);
}
// No schema check, unlike setting one: a key the asset no longer declares is exactly what one
// wants to be able to clear.
protected override nextValue(): undefined {
return undefined;
}
}
/**
* Moves an object among its siblings. Sibling order is the order the stored list happens to have
* (`treeOrder`), so the move is a splice – and because objects sharing a `z` fall back to that
* order, it is also how two overlapping objects are put in front of each other.
*/
export class ReorderObjectCommand implements Command {
readonly label = 'Reorder object';
private previousIndex = -1;
/** `beforeId` empty means "behind its last sibling". */
constructor(
private readonly holderId: string,
private readonly objectId: string,
private readonly beforeId: string,
) {}
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
const object = requireStructuralObject(project, holder, this.objectId);
if (this.beforeId === this.objectId)
throw new CommandError('An object cannot be moved in front of itself');
let at = holder.objects.length;
if (this.beforeId) {
const before = requireStructuralObject(project, holder, this.beforeId);
if (before.parentId !== object.parentId)
throw new CommandError('An object can only be reordered among its own siblings');
at = holder.objects.indexOf(before);
}
this.previousIndex = holder.objects.indexOf(object);
holder.objects.splice(this.previousIndex, 1);
// The index was read before the removal, so anything behind it has moved up by one.
holder.objects.splice(at > this.previousIndex ? at - 1 : at, 0, object);
}
undo(project: Project): void {
const holder = requireHolder(project, this.holderId);
const object = requireObject(project, this.holderId, this.objectId);
holder.objects.splice(holder.objects.indexOf(object), 1);
holder.objects.splice(this.previousIndex, 0, object);
}
}
/**
* Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed
* for the new parent.
*/
export class SetParentCommand implements Command {
readonly label = 'Set parent';
private previous: { parentId: string; position: Position; overrides: PropertyBag } = {
parentId: '',
position: { x: 0, y: 0, z: 0 },
overrides: {},
};
constructor(
private readonly holderId: string,
private readonly objectId: string,
private readonly parentId: string,
) {}
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
const object = requireStructuralObject(project, holder, this.objectId);
if (this.parentId) {
if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent');
if (isLiveInstance(project, holder, requireStructuralObject(project, holder, this.parentId)))
throw new CommandError('A placed container shows its template; unpack it to put objects inside');
if (isSelfOrAncestor(holder, this.objectId, this.parentId))
throw new CommandError('An object cannot be parented to one of its own children');
}
this.previous = {
parentId: object.parentId,
position: { ...object.position },
overrides: { ...object.overrides },
};
// The object stays where it is and how it looks: the new local position and, when the parent
// chain's turn or size changed, transform rows that absorb the difference.
const world = worldTransform(project, holder, object);
const before = parentTransform(project, holder, object.parentId).m;
object.parentId = this.parentId;
object.position = localPositionFor(project, holder, this.parentId, positionOf(world));
carryTransform(
requireAsset(project, object.assetId),
object,
before,
parentTransform(project, holder, this.parentId).m,
);
}
undo(project: Project): void {
const object = requireObject(project, this.holderId, this.objectId);
object.parentId = this.previous.parentId;
object.position = { ...this.previous.position };
object.overrides = { ...this.previous.overrides };
}
}