Duplicate an asset from its right-click menu (#297)
game-maker / gate (push) Successful in 3m4s
game-maker / suite (push) Successful in 14m16s

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:
adminandClaude Opus 5.5 committed 2026-10-03 11:17:02 +02:00
1 parent 0dda6a012a
commit 4f4c149211
5 files changed
+128 -3

No files matched your search

+40 -1
View File
@@ -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;
+7
View File
@@ -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);