diff --git a/README.md b/README.md index e8a1d4e1..5aec84ff 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 6346ae0e..83a8492b 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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; diff --git a/src/core/properties/aspect.ts b/src/core/properties/aspect.ts index e3aaecdc..68ca553c 100644 --- a/src/core/properties/aspect.ts +++ b/src/core/properties/aspect.ts @@ -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. diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 66095be0..1f7e8664 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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); diff --git a/tests/core/aspect.test.ts b/tests/core/aspect.test.ts index 821014e1..d08e759e 100644 --- a/tests/core/aspect.test.ts +++ b/tests/core/aspect.test.ts @@ -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 }); + }); +}); diff --git a/tests/core/texture-commands.test.ts b/tests/core/texture-commands.test.ts index 86a1f732..108d2814 100644 --- a/tests/core/texture-commands.test.ts +++ b/tests/core/texture-commands.test.ts @@ -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); + }); +}); diff --git a/todo-notes/done/texture-asset-to-sprite.md b/todo-notes/done/texture-asset-to-sprite.md new file mode 100644 index 00000000..aea67b7f --- /dev/null +++ b/todo-notes/done/texture-asset-to-sprite.md @@ -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. diff --git a/todo-notes/texture-asset-to-sprite.md b/todo-notes/texture-asset-to-sprite.md deleted file mode 100644 index 6a2c2eed..00000000 --- a/todo-notes/texture-asset-to-sprite.md +++ /dev/null @@ -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) diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs index 70fb0342..28e65c6d 100644 --- a/tools/e2e/12-textures.mjs +++ b/tools/e2e/12-textures.mjs @@ -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, + ); + } }