Files
feuerwerk/src/core/commands/assetCommands.ts
T
rootandClaude Opus 5 35a1a23af8 Add a Texture tab for defining frames
A new Texture tab next to Scene/Game/Graph shows a texture sheet and lets
frames be drawn on it: drag to create, move, resize via eight handles, edit
name and rect in the inspector, Delete to remove. Every change is a command,
so one gesture is one undo step. "Slice..." cuts a grid from columns and rows
with optional offset and spacing, previewing the resulting frame count and
cell size live.

Sprites pick a frame instead of the whole texture through the same `texture`
property: it now holds either a texture id or a frame id. Frame ids come from
the same generator and are document-wide unique, so one picker, one property
and one set of instance overrides cover both cases, and every sprite written
before this still means the same thing.

A Graphics fill ignores a Pixi sub-texture's frame and draws the whole image,
because it derives its uvs from the shape bounds and the inverted style
matrix. The cutout is therefore a matrix (`viewport/textureFill.ts`), which
keeps a sheet at a single GPU upload no matter how many of its frames are on
screen at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:27:23 +02:00

235 lines
8.2 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 { createEmptyGraph } from '../graph/Graph';
import { newId } from '../ids';
import type { Asset, TextureAsset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import { removeSubtrees } from '../model/hierarchy';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { objectHolders, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { isTextureFile } from '../model/texture';
import { coerceValue, defaultsFor } from '../properties/PropertySchema';
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
import { requireAsset, requirePropDef } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
/**
* A new asset of the given type with default properties (and empty content for scenes,
* behaviors, containers). A texture starts without a picture; the upload fills it in, which
* is why `CreateTextureCommand` exists rather than a plain create followed by an edit.
*/
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
const base = { id, name, props: defaultsFor(type) };
switch (type) {
case 'texture':
return buildTextureAsset(name, { file: '', width: 0, height: 0 }, id);
case 'scene':
return { ...base, type: 'scene', objects: [] };
case 'camera':
return { ...base, type: 'camera' };
case 'sprite':
return { ...base, type: 'sprite' };
case 'text':
return { ...base, type: 'text' };
case 'behavior':
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
case 'collider':
return { ...base, type: 'collider' };
case 'container':
return { ...base, type: 'container', objects: [] };
}
}
/** Adds a new asset; the first scene of a project becomes the active scene. */
export class CreateAssetCommand implements Command {
readonly label: string;
readonly asset: Asset;
private previousActiveScene = '';
constructor(type: AssetType, name: string) {
this.asset = buildAsset(type, name);
this.label = `Create ${type} "${name}"`;
}
execute(project: Project): void {
project.assets.push(this.asset);
this.previousActiveScene = project.activeSceneId;
if (this.asset.type === 'scene' && !project.activeSceneId) project.activeSceneId = this.asset.id;
}
undo(project: Project): void {
project.assets = removeById(project.assets, this.asset.id);
project.activeSceneId = this.previousActiveScene;
}
}
/** The upload result a texture asset is built from (see `TextureStore.upload`). */
export interface TextureImage {
file: string;
width: number;
height: number;
}
/**
* A texture asset for a stored image, without frames – slicing it is a separate, undoable
* edit. Kept apart from `buildAsset` so callers that need a `TextureAsset` get one, instead
* of the `Asset` union they would have to narrow again.
*/
export function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset {
return { id, name, props: defaultsFor('texture'), type: 'texture', ...image, frames: [] };
}
/**
* Adds a texture asset for an image that is already stored on the server. Uploading is I/O and
* therefore happens before the command: this one only records the result, so undo and redo of
* an import cost nothing and never touch the network.
*/
export class CreateTextureCommand implements Command {
readonly label: string;
readonly asset: TextureAsset;
constructor(name: string, image: TextureImage) {
this.asset = buildTextureAsset(name, image);
this.label = `Import texture "${name}"`;
}
execute(project: Project): void {
// The name is the hash the server derived from the bytes; anything else never was uploaded
// and would leave a texture that silently draws nothing.
if (!isTextureFile(this.asset.file))
throw new CommandError(`"${this.asset.file}" is not a stored image file`);
project.assets.push(this.asset);
}
undo(project: Project): void {
project.assets = removeById(project.assets, this.asset.id);
}
}
/**
* Deletes an asset and, as a cascade, every scene object referencing it – together with the
* objects parented below those (a subtree is removed as a whole). Container templates
* referencing the asset lose those objects the same way.
*/
export class DeleteAssetCommand implements Command {
readonly label = 'Delete asset';
private removed?: Removed<Asset>;
/** Objects removed per scene or container asset (both hold a flat object list). */
private removedObjects: { holderId: string; removed: Removed<SceneObject>[] }[] = [];
private previousActiveScene = '';
constructor(private readonly assetId: string) {}
execute(project: Project): void {
requireAsset(project, this.assetId);
this.removedObjects = [];
// The cascade runs while the asset is still in the list: a container asset is an object
// holder itself, so removing it first would change what `objectHolders` reports.
for (const holder of objectHolders(project)) {
const roots = holder.objects.filter((o) => o.assetId === this.assetId).map((o) => o.id);
if (roots.length === 0) continue;
this.removedObjects.push({ holderId: holder.id, removed: removeSubtrees(holder, roots) });
}
this.removed = removeFirst(project.assets, (a) => a.id === this.assetId);
this.previousActiveScene = project.activeSceneId;
if (project.activeSceneId === this.assetId) {
const next = project.assets.find((a) => a.type === 'scene');
project.activeSceneId = next ? next.id : '';
}
}
undo(project: Project): void {
if (!this.removed) return;
restoreAt(project.assets, [this.removed]);
const holders = new Map(objectHolders(project).map((h) => [h.id, h]));
for (const entry of this.removedObjects) {
const holder = holders.get(entry.holderId);
if (holder) restoreAt(holder.objects, entry.removed);
}
project.activeSceneId = this.previousActiveScene;
}
}
export class RenameAssetCommand implements Command {
readonly label = 'Rename asset';
private previousName = '';
constructor(
private readonly assetId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const asset = requireAsset(project, this.assetId);
this.previousName = asset.name;
asset.name = this.name;
}
undo(project: Project): void {
requireAsset(project, this.assetId).name = this.previousName;
}
}
/** The half of `SetBagValueCommand` both asset property edits share: the bag is the asset's own. */
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
protected constructor(
private readonly assetId: string,
key: string,
label: string,
) {
super(key, label);
}
protected override resolveTarget(project: Project): Asset {
return requireAsset(project, this.assetId);
}
protected override bagOf(asset: Asset): PropertyBag {
return asset.props;
}
}
/** Sets an asset property (coerced against its schema entry). */
export class SetAssetPropertyCommand extends AssetPropertyCommand {
constructor(
assetId: string,
key: string,
private readonly value: PropertyValue,
) {
super(assetId, key, `Set ${key}`);
}
protected override computeNext(asset: Asset): PropertyValue {
return coerceValue(requirePropDef(asset, this.key), this.value);
}
}
/** Puts an asset property back to its schema default (the asset-level "clear override"). */
export class ResetAssetPropertyCommand extends AssetPropertyCommand {
constructor(assetId: string, key: string) {
super(assetId, key, `Reset ${key}`);
}
protected override computeNext(asset: Asset): PropertyValue {
return requirePropDef(asset, this.key).default;
}
}
export class SetActiveSceneCommand implements Command {
readonly label = 'Switch scene';
private previous = '';
constructor(private readonly sceneId: string) {}
execute(project: Project): void {
const asset = requireAsset(project, this.sceneId);
if (asset.type !== 'scene') throw new CommandError(`${asset.name} is not a scene`);
this.previous = project.activeSceneId;
project.activeSceneId = this.sceneId;
}
undo(project: Project): void {
project.activeSceneId = this.previous;
}
}