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>
235 lines
8.2 KiB
TypeScript
235 lines
8.2 KiB
TypeScript
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;
|
||
}
|
||
}
|