Files
feuerwerk/src/core/commands/sceneObjectCommands.ts
T
rootandClaude Fable 5.1 42cafa7543
game-maker / suite (push) Failing after 3m59s
Shader assets: graph-built shaders, the Shader tab and sprite rows from the graph (#62)
A shader is an asset carrying a node graph (Texture color, Output, Property,
Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL
fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite
wears a shader through its Shader picker, and the rows below it are the
graph's Property nodes. Property schema, resolution and deviation take the
project because a sprite's rows now live in another asset. The built-in
registry of the prototype is gone; "Adjust colors" ships as a graph in the
fixture and the three example games.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:41:14 +02:00

292 lines
9.5 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, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import {
captureObject,
isLiveInstance,
type ObjectMemo,
type ObjectRef,
restoreObject,
} from '../model/instance';
import { placementRejection } from '../model/placement';
import { type Position, 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 { 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(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 } = {
parentId: '',
position: { x: 0, y: 0, z: 0 },
};
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 } };
const world = worldPosition(holder, object);
object.parentId = this.parentId;
object.position = localPositionFor(holder, this.parentId, world);
}
undo(project: Project): void {
const object = requireObject(project, this.holderId, this.objectId);
object.parentId = this.previous.parentId;
object.position = { ...this.previous.position };
}
}