Duplicate an asset from its right-click menu (#297)
A new asset that starts out like an existing one (a death animation
next to "Break", a second slime) had to be built again by hand. Now
the asset panel's right-click menu has Duplicate.
- The copy lands right below the original and is named the way a new
asset would be ("Crate 2"). It is selected and its tab opens where
it has one. A copied scene does not become the active one.
- One undo step takes it out again. A redo brings back the same id.
- The copy is a deep copy: values, graph, tracks, layers and objects
are its own, and editing it leaves the original alone.
- Frames of a texture or of pixel art get fresh ids, because a sprite
names a frame across the whole document.
- Objects of a scene or container get fresh ids the way pasted ones
do. Parents and object references among them follow the copy.
- Whatever pointed at the original keeps pointing at the original: a
sprite wearing it, an animation's root, a placed object.
Tests:
- 3 new unit tests (DuplicateAssetCommand). One counter-check run:
without renaming the references the scene test fails.
- e2e 04: 2 new checks with the real menu (copy named "Crate 2" right
behind the Crate and selected, one undo removes it).
- Gate: 221 files, 3445 tests, CHECK EXIT 0.
- e2e total 1703 is computed (1701 + 2). Full suite left to CI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0dda6a012a
commit
4f4c149211
5 files changed
+128
-3
No files matched your search
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1701 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1703 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { isFontFile } from '../model/font';
|
|||||||
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
|
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
|
||||||
import { isSoundFile } from '../model/sound';
|
import { isSoundFile } from '../model/sound';
|
||||||
import { removeSubtrees } from '../model/hierarchy';
|
import { removeSubtrees } from '../model/hierarchy';
|
||||||
import { dropReferencesToGone, shownObjectIds } from '../model/objectRefs';
|
import { dropReferencesToGone, renamedReference, shownObjectIds, swapReferences } from '../model/objectRefs';
|
||||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||||
import { objectHolders, type Project } from '../model/Project';
|
import { objectHolders, type Project } from '../model/Project';
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +33,7 @@ import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../propert
|
|||||||
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
||||||
import { Recorder } from '../util/recorder';
|
import { Recorder } from '../util/recorder';
|
||||||
import { type Command, CommandError } from './Command';
|
import { type Command, CommandError } from './Command';
|
||||||
|
import { nextAssetName } from './naming';
|
||||||
import { requireAsset, requirePropDef, requireStateMachine, requireVariablesAsset } from './require';
|
import { requireAsset, requirePropDef, requireStateMachine, requireVariablesAsset } from './require';
|
||||||
import { SetBagValueCommand } from './SetBagValueCommand';
|
import { SetBagValueCommand } from './SetBagValueCommand';
|
||||||
|
|
||||||
@@ -133,6 +134,44 @@ export class CreateAssetCommand implements Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A copy of an asset right behind it (#297), named the way a new one would be. What other assets
|
||||||
|
* point at stays with the original. Ids a copy cannot share get fresh ones: frames are named across
|
||||||
|
* the document, and objects get new ids the way pasted ones do (references among them follow).
|
||||||
|
*/
|
||||||
|
export class DuplicateAssetCommand implements Command {
|
||||||
|
readonly label = 'Duplicate asset';
|
||||||
|
readonly copyId = newId();
|
||||||
|
private copy?: Asset;
|
||||||
|
|
||||||
|
constructor(private readonly assetId: string) {}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
const original = requireAsset(project, this.assetId);
|
||||||
|
this.copy ??= copyOf(original, this.copyId, nextAssetName(project, original.name));
|
||||||
|
project.assets.splice(project.assets.indexOf(original) + 1, 0, this.copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
project.assets = removeById(project.assets, this.copyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyOf(original: Asset, id: string, name: string): Asset {
|
||||||
|
const copy = { ...structuredClone(original), id, name };
|
||||||
|
if (copy.type === 'texture' || copy.type === 'pixelart')
|
||||||
|
for (const frame of copy.frames) frame.id = newId();
|
||||||
|
if (copy.type === 'scene' || copy.type === 'container') {
|
||||||
|
const ids = new Map(copy.objects.map((o) => [o.id, newId()]));
|
||||||
|
for (const object of copy.objects) {
|
||||||
|
object.id = ids.get(object.id)!;
|
||||||
|
object.parentId = ids.get(object.parentId) ?? object.parentId;
|
||||||
|
}
|
||||||
|
swapReferences(copy.objects, (ref) => renamedReference(ref, ids), new Recorder());
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/** The upload result a texture asset is built from (see `TextureStore.upload`). */
|
/** The upload result a texture asset is built from (see `TextureStore.upload`). */
|
||||||
export interface TextureImage {
|
export interface TextureImage {
|
||||||
file: string;
|
file: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '../../core/commands/animationCommands';
|
} from '../../core/commands/animationCommands';
|
||||||
import {
|
import {
|
||||||
CreateAssetCommand,
|
CreateAssetCommand,
|
||||||
|
DuplicateAssetCommand,
|
||||||
SetActiveSceneCommand,
|
SetActiveSceneCommand,
|
||||||
SetStartSceneCommand,
|
SetStartSceneCommand,
|
||||||
} from '../../core/commands/assetCommands';
|
} from '../../core/commands/assetCommands';
|
||||||
@@ -214,6 +215,11 @@ export class AssetPanel extends Panel {
|
|||||||
if (this.state.execute(command)) this.state.openAsset(command.asset.id);
|
if (this.state.execute(command)) this.state.openAsset(command.asset.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private duplicateAsset(id: string): void {
|
||||||
|
const command = new DuplicateAssetCommand(id);
|
||||||
|
if (this.state.execute(command)) this.state.openAsset(command.copyId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A sprite wearing the texture and an animation over it whose one row keys the frames in order,
|
* A sprite wearing the texture and an animation over it whose one row keys the frames in order,
|
||||||
* one per frame – what a picture sequence is on a timeline. One undo step for all of it.
|
* one per frame – what a picture sequence is on a timeline. One undo step for all of it.
|
||||||
@@ -364,6 +370,7 @@ export class AssetPanel extends Panel {
|
|||||||
if (asset.type === 'pixelart' && asset.frames.length > 1)
|
if (asset.type === 'pixelart' && asset.frames.length > 1)
|
||||||
items.push({ label: 'Create animation from frames', onSelect: () => this.createAnimation(asset) });
|
items.push({ label: 'Create animation from frames', onSelect: () => this.createAnimation(asset) });
|
||||||
}
|
}
|
||||||
|
items.push({ label: 'Duplicate', onSelect: () => this.duplicateAsset(id) });
|
||||||
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
|
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
|
||||||
const menu = new Menu(items);
|
const menu = new Menu(items);
|
||||||
menu.open(x, y);
|
menu.open(x, y);
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/** Duplicate in an asset's menu (#297): a copy right behind the original that shares nothing with it. */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CreateAssetCommand, DuplicateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||||
|
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||||
|
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||||
|
import type { BehaviorAsset, PixelArtAsset, SceneAsset } from '../../src/core/model/Asset';
|
||||||
|
import { objectReference } from '../../src/core/model/objectRefs';
|
||||||
|
import { findAsset } from '../../src/core/model/Project';
|
||||||
|
import { makeBehavior, makeSpriteScene, place } from '../helpers';
|
||||||
|
|
||||||
|
describe('duplicating an asset', () => {
|
||||||
|
it('puts a copy of a scene behind it, with fresh object ids that still point at each other', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const behavior = makeBehavior(f, 'Watcher');
|
||||||
|
f.stack.execute(new AddVariableCommand(behavior.id, 'target', 'object'));
|
||||||
|
const crate = place(f, f.spriteId, { x: 10, y: 0 });
|
||||||
|
const watcher = place(f, behavior.id, { x: 0, y: 0 }, crate);
|
||||||
|
f.stack.execute(new SetOverrideCommand(f.sceneId, watcher, 'target', objectReference(crate)));
|
||||||
|
const before = structuredClone(f.sceneOf());
|
||||||
|
|
||||||
|
const command = new DuplicateAssetCommand(f.sceneId);
|
||||||
|
f.stack.execute(command);
|
||||||
|
const copy = findAsset(f.project, command.copyId) as SceneAsset;
|
||||||
|
expect(f.project.assets.map((a) => a.name)).toEqual(['Level', 'Level 2', 'Box', 'Watcher']);
|
||||||
|
expect(copy.objects).toHaveLength(2);
|
||||||
|
const [copiedCrate, copiedWatcher] = copy.objects;
|
||||||
|
expect([crate, watcher]).not.toContain(copiedCrate!.id);
|
||||||
|
expect([crate, watcher]).not.toContain(copiedWatcher!.id);
|
||||||
|
expect(copiedWatcher!.parentId).toBe(copiedCrate!.id);
|
||||||
|
expect(copiedWatcher!.overrides.target).toEqual(objectReference(copiedCrate!.id));
|
||||||
|
expect(f.sceneOf()).toEqual(before);
|
||||||
|
|
||||||
|
f.stack.undo();
|
||||||
|
expect(findAsset(f.project, command.copyId)).toBeUndefined();
|
||||||
|
f.stack.redo();
|
||||||
|
expect(f.project.assets[1]?.id).toBe(command.copyId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the frames of pixel art new ids, since a sprite names a frame across the document', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const create = new CreateAssetCommand('pixelart', 'Slime');
|
||||||
|
f.stack.execute(create);
|
||||||
|
const original = create.asset as PixelArtAsset;
|
||||||
|
const command = new DuplicateAssetCommand(original.id);
|
||||||
|
f.stack.execute(command);
|
||||||
|
const copy = findAsset(f.project, command.copyId) as PixelArtAsset;
|
||||||
|
expect(copy.name).toBe('Slime 2');
|
||||||
|
expect(copy.frames[0]?.id).not.toBe(original.frames[0]?.id);
|
||||||
|
expect(copy.layers).toEqual(original.layers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies a graph by value, so editing the copy leaves the original alone', () => {
|
||||||
|
const f = makeSpriteScene();
|
||||||
|
const behavior = makeBehavior(f, 'Mover');
|
||||||
|
const command = new DuplicateAssetCommand(behavior.id);
|
||||||
|
f.stack.execute(command);
|
||||||
|
const copy = findAsset(f.project, command.copyId) as BehaviorAsset;
|
||||||
|
expect(copy.graph).toEqual(behavior.graph);
|
||||||
|
copy.graph.nodes.push({ ...copy.graph.nodes[0]!, id: 'extra' });
|
||||||
|
expect(behavior.graph.nodes.some((n) => n.id === 'extra')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+18
-1
@@ -31,7 +31,24 @@ export async function assets(t) {
|
|||||||
check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Delete…')`)));
|
check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Delete…')`)));
|
||||||
check('placing is no longer a menu item', !(await evaluate(`__gm.locate('Place in scene')`)));
|
check('placing is no longer a menu item', !(await evaluate(`__gm.locate('Place in scene')`)));
|
||||||
await shot('11-context-menu');
|
await shot('11-context-menu');
|
||||||
await press('Escape');
|
await clickText('Duplicate');
|
||||||
|
const copy = await json(`(() => {
|
||||||
|
const assets = __gm.state.project.assets;
|
||||||
|
const c = assets[assets.findIndex((a) => a.id === 'crate') + 1];
|
||||||
|
const s = __gm.state.selection;
|
||||||
|
return c ? { name: c.name, type: c.type, open: s?.kind === 'asset' && s.assetId === c.id } : null;
|
||||||
|
})()`);
|
||||||
|
check(
|
||||||
|
'Duplicate puts "Crate 2" right behind the Crate and opens it',
|
||||||
|
copy?.name === 'Crate 2' && copy.type === 'sprite' && copy.open,
|
||||||
|
JSON.stringify(copy),
|
||||||
|
);
|
||||||
|
await undo();
|
||||||
|
check(
|
||||||
|
'one undo takes the copy back out',
|
||||||
|
!(await json("__gm.state.project.assets.some((a) => a.name === 'Crate 2')")),
|
||||||
|
);
|
||||||
|
await showTab('scene');
|
||||||
|
|
||||||
await addMenuOnRightClick(t);
|
await addMenuOnRightClick(t);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user