From 4f4c149211fda0e7070847f5fba44d4e2e22e55b Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 3 Oct 2026 11:17:02 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/commands/assetCommands.ts | 41 +++++++++++++++++++- src/editor/panels/AssetPanel.ts | 7 ++++ tests/core/duplicate-asset.test.ts | 62 ++++++++++++++++++++++++++++++ tools/e2e/04-assets.mjs | 19 ++++++++- 5 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 tests/core/duplicate-asset.test.ts diff --git a/README.md b/README.md index 7dfa871c..8dfa4d70 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 31e7127b..ef348748 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -15,7 +15,7 @@ import { isFontFile } from '../model/font'; import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart'; import { isSoundFile } from '../model/sound'; 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 { objectHolders, type Project } from '../model/Project'; import { @@ -33,6 +33,7 @@ import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../propert import { type Removed, removeById, removeFirst, restoreAt } from '../util/list'; import { Recorder } from '../util/recorder'; import { type Command, CommandError } from './Command'; +import { nextAssetName } from './naming'; import { requireAsset, requirePropDef, requireStateMachine, requireVariablesAsset } from './require'; 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`). */ export interface TextureImage { file: string; diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index f1d3633e..c5067e10 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -5,6 +5,7 @@ import { } from '../../core/commands/animationCommands'; import { CreateAssetCommand, + DuplicateAssetCommand, SetActiveSceneCommand, SetStartSceneCommand, } from '../../core/commands/assetCommands'; @@ -214,6 +215,11 @@ export class AssetPanel extends Panel { 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, * 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) 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) }); const menu = new Menu(items); menu.open(x, y); diff --git a/tests/core/duplicate-asset.test.ts b/tests/core/duplicate-asset.test.ts new file mode 100644 index 00000000..37848d97 --- /dev/null +++ b/tests/core/duplicate-asset.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 0ae5ce3a..cf4360ce 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -31,7 +31,24 @@ export async function assets(t) { 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')`))); 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);