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:
1 parent
1e2285c545
commit
9212e61c4c
9 files changed
+137
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user