diff --git a/README.md b/README.md index 416a64e7..2c684124 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,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, 1032 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1034 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/properties/textureRef.ts b/src/core/properties/textureRef.ts index fd78b61c..2857858e 100644 --- a/src/core/properties/textureRef.ts +++ b/src/core/properties/textureRef.ts @@ -1,4 +1,4 @@ -import type { AnimationAsset, TextureAsset, TextureFrame } from '../model/Asset'; +import type { TextureAsset, TextureFrame } from '../model/Asset'; import { findAsset, type Project } from '../model/Project'; import { findFrame } from '../model/texture'; @@ -7,38 +7,30 @@ export const NO_TEXTURE_LABEL = 'None'; const FRAME_SEPARATOR = ' / '; export interface TextureChoice { - /** Asset id for the whole picture or an animation, frame id for one cut-out of a picture. */ + /** Asset id for the whole picture, frame id for one cut-out of it. */ id: string; label: string; - asset: TextureAsset | AnimationAsset; - /** Null for the whole-picture entry and for an animation. */ + asset: TextureAsset; + /** Null for the whole-picture entry. */ frame: TextureFrame | null; } /** * The texture's name, "Sheet / Frame" for a frame, "None" for the empty id, `?` when it no * longer resolves – a dangling reference shows its id rather than passing for an empty field. + * Any other asset counts as dangling, an animation included: a texture field only draws pictures. */ export function textureRefLabel(project: Project | null, id: string): string { if (id === '') return NO_TEXTURE_LABEL; if (!project) return `${id}?`; const asset = findAsset(project, id); - if (asset) return asset.type === 'texture' || asset.type === 'animation' ? asset.name : `${id}?`; + if (asset) return asset.type === 'texture' ? asset.name : `${id}?`; const found = findFrame(project, id); return found ? `${found.asset.name}${FRAME_SEPARATOR}${found.frame.name}` : `${id}?`; } -/** Everything a sprite may wear, in menu order: the animations first, a sheet's frames would bury them. */ -export function textureRefChoices(project: Project): TextureChoice[] { - const animations = project.assets - .filter((a): a is AnimationAsset => a.type === 'animation') - .sort((a, b) => a.name.localeCompare(b.name)) - .map((asset) => ({ id: asset.id, label: `${asset.name} (animation)`, asset, frame: null })); - return [...animations, ...pictureChoices(project)]; -} - /** - * Every still picture: each texture followed by its own frames. Textures are sorted by name so the + * Every still picture, the choices of a texture field: each texture followed by its own frames. Textures are sorted by name so the * order does not depend on upload time; frames keep creation order, which for a sliced sheet is * reading order. */ diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 4b6edfaf..a26b0e2f 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -13,7 +13,7 @@ import { type PropertyDef, type StructFieldDef, } from '../../core/properties/PropertySchema'; -import { NO_TEXTURE_LABEL, textureRefChoices, textureRefLabel } from '../../core/properties/textureRef'; +import { NO_TEXTURE_LABEL, pictureChoices, textureRefLabel } from '../../core/properties/textureRef'; import { Button, Checkbox, @@ -230,7 +230,7 @@ const REFERENCES: Record = { label: assetChoiceLabel(asset), })), }, - texture: { none: NO_TEXTURE_LABEL, label: textureRefLabel, choices: textureRefChoices }, + texture: { none: NO_TEXTURE_LABEL, label: textureRefLabel, choices: pictureChoices }, statemachine: { none: NO_ASSET_LABEL, label: assetRefLabel, diff --git a/tests/core/texture.test.ts b/tests/core/texture.test.ts index 3fa0185f..199637a2 100644 --- a/tests/core/texture.test.ts +++ b/tests/core/texture.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { CreateTextureCommand } from '../../src/core/commands/assetCommands'; +import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands'; import type { TextureAsset } from '../../src/core/model/Asset'; import { findAsset } from '../../src/core/model/Project'; import { findPropertyUsages, findUsages } from '../../src/core/model/usages'; @@ -12,6 +12,7 @@ import { textureRefIds, uniqueFrameName, } from '../../src/core/model/texture'; +import { pictureChoices, textureRefLabel } from '../../src/core/properties/textureRef'; import { deserializeProject } from '../../src/core/store/serialize'; import { makeSpriteScene, place, roundTrip } from '../helpers'; @@ -262,3 +263,29 @@ describe('frame names', () => { expect(nextFrameNames(sheet('Frame 2'), 3)).toEqual(['Frame 1', 'Frame 3', 'Frame 4']); }); }); + +describe('texture field references', () => { + it('offers pictures and their frames, never an animation', () => { + const fixture = makeSpriteScene(); + const texture = addTexture(fixture); + texture.frames.push({ id: 'f1', name: 'Idle', x: 0, y: 0, w: 32, h: 24 }); + const animation = new CreateAssetCommand('animation', 'Walk'); + fixture.stack.execute(animation); + + expect(pictureChoices(fixture.project).map((c) => [c.id, c.label])).toEqual([ + [texture.id, 'Bricks'], + ['f1', 'Bricks / Idle'], + ]); + }); + + it('shows an animation id left from an older project as a dangling reference', () => { + const fixture = makeSpriteScene(); + const texture = addTexture(fixture); + const animation = new CreateAssetCommand('animation', 'Walk'); + fixture.stack.execute(animation); + + expect(textureRefLabel(fixture.project, texture.id)).toBe('Bricks'); + expect(textureRefLabel(fixture.project, animation.asset.id)).toBe(`${animation.asset.id}?`); + expect(textureRefLabel(fixture.project, '')).toBe('None'); + }); +}); diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs index e2cabfa7..d0fdb1e6 100644 --- a/tools/e2e/12-textures.mjs +++ b/tools/e2e/12-textures.mjs @@ -267,6 +267,40 @@ export async function textures(t) { ); } + // 12e-ii. A texture field offers pictures only (#143). Since #97 an animation is played, not worn, + // so the Texture row of a sprite no longer lists one, and an animation id left in the + // field by an older project reads as dangling rather than passing for a picture. + await mutate(` + __gm.state.project.assets.push( + { id: 'tex-anim', type: 'animation', name: 'Walk', props: {}, root: '', tracks: [] }); + const worn = __gm.state.project.assets.find((a) => a.id === 'tex-sprite').props; + window.__texBefore = worn.texture; + worn.texture = 'tex-anim'; + __gm.state.select({ kind: 'asset', assetId: 'tex-sprite' })`); + await sleep(400); + const textureField = await json("__gm.inspectorField('Texture', true)"); + const dangling = await json("__gm.locateAll('tex-anim?')"); + check( + 'an animation id in the Texture row reads as a dangling reference', + Boolean(textureField) && dangling.length === 1 && Math.abs(dangling[0].y - textureField.y) < 12, + JSON.stringify({ textureField, dangling }), + ); + await click(textureField.x, textureField.y); + await sleep(300); + const offered = (await json('__gm.menuRows()')) ?? []; + check( + 'the Texture row offers the picture and no animation', + offered.includes(texture.name) && !offered.some((row) => row?.includes('(animation)') || row === 'Walk'), + JSON.stringify(offered), + ); + await press('Escape'); + await mutate(` + const assets = __gm.state.project.assets; + assets.splice(assets.findIndex((a) => a.id === 'tex-anim'), 1); + assets.find((a) => a.id === 'tex-sprite').props.texture = window.__texBefore; + __gm.state.select(null)`); + await sleep(300); + // 12f. "+ → Texture" offers the pictures the server already holds, unreferenced ones included: // paste a second picture, undo the asset (the file stays), then pick that file in the dialog. await evaluate(PASTE_SPARE);