Create a sprite asset from a texture's context menu

The sprite takes the whole image at its pixel size and the texture's name,
numbered like any other clash. CreateAssetCommand now accepts starting
properties; they go through the schema in execute, so an unknown key or a
bad value is refused before the project is touched, and one undo removes
the asset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 09:57:19 +02:00
1 parent 1e2285c545
commit 9212e61c4c
9 files changed
+137 -7

No files matched your search

+6 -1
View File
@@ -447,6 +447,11 @@ sizes up to 200 × 200, 3135 do not survive the round trip through a division, a
would leave the button standing after its own click. The pixel size is compared exactly, because
those two numbers are written as they are.
**A sprite straight from a texture.** Right-click a texture in the asset list → *Create sprite
from texture* makes a sprite asset that wears the whole picture at its pixel size, named after the
texture and selected. It is the same as creating a sprite, picking the texture and clicking *Set to
W × H px*, but in one undo step.
Rendering a frame is a texture matrix on the fill, not a Pixi sub-texture — a Graphics fill
ignores a `Texture`'s `frame` (measured, see `viewport/textureFill.ts`). One consequence is
worth having: a sheet stays **one** GPU upload however many frames of it are on screen.
@@ -619,7 +624,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
# code is lost (tail's is reported), so a red run reads as green
npm run build # writes dist/ and regenerates index.html
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
node tools/seed-e2e.mjs && node tools/e2e.mjs # 527 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 532 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
+8 -1
View File
@@ -41,12 +41,19 @@ export class CreateAssetCommand implements Command {
readonly asset: Asset;
private previousActiveScene = '';
constructor(type: AssetType, name: string) {
/** `props` replace schema defaults and pass through the schema like any other edit. */
constructor(
type: AssetType,
name: string,
private readonly props: PropertyBag = {},
) {
this.asset = buildAsset(type, name);
this.label = `Create ${type} "${name}"`;
}
execute(project: Project): void {
for (const [key, value] of Object.entries(this.props))
this.asset.props[key] = coerceValue(requirePropDef(this.asset, key), value);
project.assets.push(this.asset);
this.previousActiveScene = project.activeSceneId;
if (this.asset.type === 'scene' && !project.activeSceneId) project.activeSceneId = this.asset.id;
+7
View File
@@ -22,6 +22,13 @@ export function texturePixelSize(project: Project, props: PropertyBag): PixelSiz
return w > 0 && h > 0 ? { w, h } : null;
}
/** A sprite wearing the texture at its own pixel size – or at the default size while that is unknown. */
export function spriteFromTexture(project: Project, textureId: string): PropertyBag {
const props: PropertyBag = { texture: textureId };
const size = texturePixelSize(project, props);
return size ? { ...props, width: size.w, height: size.h } : props;
}
/**
* What `axis` has to become for the sprite to carry the picture's proportions, leaving the
* other side where it is. Null when the other side is not a usable number.
+10 -2
View File
@@ -7,7 +7,9 @@ import {
hasColorSwatch,
isPlaceableType,
} from '../../core/model/AssetTypes';
import type { PropertyBag } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { spriteFromTexture } from '../../core/properties/aspect';
import { assetDeviates } from '../../core/properties/deviation';
import type { TextureStore } from '../../core/store/TextureStore';
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer, startDrag } from '../../ui';
@@ -77,8 +79,8 @@ export class AssetPanel extends Panel {
this.list.select(s?.kind === 'asset' ? s.assetId : null, true);
}
private createAsset(type: AssetType): void {
const command = new CreateAssetCommand(type, nextAssetName(this.state.project, ASSET_TYPE_LABELS[type]));
private createAsset(type: AssetType, name = ASSET_TYPE_LABELS[type], props: PropertyBag = {}): void {
const command = new CreateAssetCommand(type, nextAssetName(this.state.project, name), props);
if (this.state.execute(command)) this.state.select({ kind: 'asset', assetId: command.asset.id });
}
@@ -128,6 +130,12 @@ export class AssetPanel extends Panel {
disabled: this.state.project.activeSceneId === id,
});
}
if (asset.type === 'texture') {
items.push({
label: 'Create sprite from texture',
onSelect: () => this.createAsset('sprite', asset.name, spriteFromTexture(this.state.project, id)),
});
}
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
const menu = new Menu(items);
menu.open(x, y);
+17
View File
@@ -6,6 +6,7 @@ import {
fitAxisToAspect,
matchesAspect,
matchesPixelSize,
spriteFromTexture,
texturePixelSize,
} from '../../src/core/properties/aspect';
import { makeSpriteScene } from '../helpers';
@@ -97,3 +98,19 @@ describe('matchesAspect', () => {
expect(matchesAspect(size, props({ width: Number.NaN, height: 10 }))).toBe(false);
});
});
describe('spriteFromTexture', () => {
it('wears the texture at its pixel size', () => {
const sheet = makeSheet();
expect(spriteFromTexture(sheet.project, sheet.textureId)).toEqual({
texture: sheet.textureId,
width: 64,
height: 48,
});
});
it('leaves the size to the defaults while the image has not been measured', () => {
const sheet = makeSheet(0, 0);
expect(spriteFromTexture(sheet.project, sheet.textureId)).toEqual({ texture: sheet.textureId });
});
});
+37 -1
View File
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest';
import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import { isTextureAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { spriteFromTexture } from '../../src/core/properties/aspect';
import { makeProject } from '../helpers';
const FILE = `${'b'.repeat(64)}.webp`;
@@ -40,3 +41,38 @@ describe('CreateTextureCommand', () => {
expect(new Set(project.assets.map((a) => a.id)).size).toBe(2);
});
});
describe('CreateAssetCommand with starting props', () => {
it('creates a sprite wearing the texture at its pixel size, and removes it again', () => {
const { project, stack } = makeProject();
const texture = new CreateTextureCommand('Hero', { file: FILE, width: 64, height: 32 });
stack.execute(texture);
const sprite = new CreateAssetCommand('sprite', 'Hero 2', spriteFromTexture(project, texture.asset.id));
stack.execute(sprite);
expect(findAsset(project, sprite.asset.id)?.props).toEqual({
enabled: true,
width: 64,
height: 32,
color: 0xffffff,
texture: texture.asset.id,
});
stack.undo();
expect(findAsset(project, sprite.asset.id)).toBeUndefined();
stack.redo();
expect(findAsset(project, sprite.asset.id)?.props.texture).toBe(texture.asset.id);
});
it('passes each value through the schema', () => {
const { project, stack } = makeProject();
const sprite = new CreateAssetCommand('sprite', 'Tiny', { width: -5 });
stack.execute(sprite);
expect(findAsset(project, sprite.asset.id)?.props.width).toBe(1);
});
it('rejects a property the type does not have, leaving the project untouched', () => {
const { project, stack } = makeProject();
expect(() => stack.execute(new CreateAssetCommand('sprite', 'Odd', { zoom: 2 }))).toThrow(CommandError);
expect(project.assets).toHaveLength(0);
});
});
@@ -0,0 +1,9 @@
rechtsklick auf texture asset => menüeintrag: create sprite from texture
selbsterklärend oder? (es soll im prinzip ein sprite asset erstellt werden wo automatisch die ausgewählte textur zugewiesen ist)
---
Umgesetzt: das Sprite bekommt die **Pixelgröße des Bildes** (Wahl des Users) und heißt wie die
Textur, nach der üblichen Regel durchnummeriert (`pasted` → `pasted 2`). Ist die Bildgröße noch
nicht bekannt, bleibt es bei 100 × 100. Die Start-Werte gehen beim Ausführen durch das Schema wie
jede andere Änderung; ein Undo nimmt das Asset in einem Schritt heraus.
-2
View File
@@ -1,2 +0,0 @@
rechtsklick auf texture asset => menüeintrag: create sprite from texture
selbsterklärend oder? (es soll im prinzip ein sprite asset erstellt werden wo automatisch die ausgewählte textur zugewiesen ist)
+43
View File
@@ -326,4 +326,47 @@ export async function textures(t) {
'undo takes the adopted asset back out',
(await json("__gm.state.project.assets.filter((a) => a.type === 'texture').length")) === 1,
);
// 19f. Right-clicking a texture asset makes a sprite wearing it at the picture's pixel size.
const entryText = 'Create sprite from texture';
const spriteRow = await json("__gm.assetRow('crate')");
await click(spriteRow.x, spriteRow.y, 'right');
await sleep(200);
check(
'a sprite asset does not offer making a sprite from it',
(await json(`__gm.locate(${JSON.stringify(entryText)}, false)`)) === null,
);
await press('Escape');
await sleep(150);
const spritesBefore = await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length");
const textureRow = await json(`__gm.assetRow(${JSON.stringify(texture.id)})`);
await click(textureRow.x, textureRow.y, 'right');
await sleep(200);
const createEntry = await json(`__gm.locate(${JSON.stringify(entryText)}, false)`);
check('a texture asset offers making a sprite from it', createEntry !== null);
if (createEntry) {
await click(createEntry.x, createEntry.y);
await sleep(400);
const made = await json(`(() => {
const s = __gm.state.selection;
const a = s && s.kind === 'asset' ? __gm.state.project.assets.find((x) => x.id === s.assetId) : null;
return a ? { type: a.type, name: a.name, props: a.props } : null;
})()`);
check(
'the new sprite is selected and wears the texture at its pixel size',
made?.type === 'sprite' &&
made.props.texture === texture.id &&
made.props.width === 64 &&
made.props.height === 48,
JSON.stringify(made),
);
check('it is named after the texture', made?.name === `${texture.name} 2`, JSON.stringify(made?.name));
await shot('35b-sprite-from-texture');
await undo();
check(
'undo takes the new sprite back out',
(await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length")) === spritesBefore,
);
}
}