The texture field only offers pictures: each texture with its frames, no animations any more; an animation id left in an older project shows as a missing reference (#143)
game-maker / gate (push) Successful in 1m40s
game-maker / suite (push) Successful in 6m8s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 12:14:01 +02:00
1 parent 5046ddb810
commit c2fd536aaf
5 files changed
+72 -19

No files matched your search

+1 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```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-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+7 -15
View File
@@ -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 { findAsset, type Project } from '../model/Project';
import { findFrame } from '../model/texture'; import { findFrame } from '../model/texture';
@@ -7,38 +7,30 @@ export const NO_TEXTURE_LABEL = 'None';
const FRAME_SEPARATOR = ' / '; const FRAME_SEPARATOR = ' / ';
export interface TextureChoice { 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; id: string;
label: string; label: string;
asset: TextureAsset | AnimationAsset; asset: TextureAsset;
/** Null for the whole-picture entry and for an animation. */ /** Null for the whole-picture entry. */
frame: TextureFrame | null; frame: TextureFrame | null;
} }
/** /**
* The texture's name, "Sheet / Frame" for a frame, "None" for the empty id, `<id>?` when it no * The texture's name, "Sheet / Frame" for a frame, "None" for the empty id, `<id>?` when it no
* longer resolves – a dangling reference shows its id rather than passing for an empty field. * 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 { export function textureRefLabel(project: Project | null, id: string): string {
if (id === '') return NO_TEXTURE_LABEL; if (id === '') return NO_TEXTURE_LABEL;
if (!project) return `${id}?`; if (!project) return `${id}?`;
const asset = findAsset(project, 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); const found = findFrame(project, id);
return found ? `${found.asset.name}${FRAME_SEPARATOR}${found.frame.name}` : `${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 * order does not depend on upload time; frames keep creation order, which for a sliced sheet is
* reading order. * reading order.
*/ */
+2 -2
View File
@@ -13,7 +13,7 @@ import {
type PropertyDef, type PropertyDef,
type StructFieldDef, type StructFieldDef,
} from '../../core/properties/PropertySchema'; } 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 { import {
Button, Button,
Checkbox, Checkbox,
@@ -230,7 +230,7 @@ const REFERENCES: Record<ReferenceKind, Reference> = {
label: assetChoiceLabel(asset), label: assetChoiceLabel(asset),
})), })),
}, },
texture: { none: NO_TEXTURE_LABEL, label: textureRefLabel, choices: textureRefChoices }, texture: { none: NO_TEXTURE_LABEL, label: textureRefLabel, choices: pictureChoices },
statemachine: { statemachine: {
none: NO_ASSET_LABEL, none: NO_ASSET_LABEL,
label: assetRefLabel, label: assetRefLabel,
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; 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 type { TextureAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project'; import { findAsset } from '../../src/core/model/Project';
import { findPropertyUsages, findUsages } from '../../src/core/model/usages'; import { findPropertyUsages, findUsages } from '../../src/core/model/usages';
@@ -12,6 +12,7 @@ import {
textureRefIds, textureRefIds,
uniqueFrameName, uniqueFrameName,
} from '../../src/core/model/texture'; } from '../../src/core/model/texture';
import { pictureChoices, textureRefLabel } from '../../src/core/properties/textureRef';
import { deserializeProject } from '../../src/core/store/serialize'; import { deserializeProject } from '../../src/core/store/serialize';
import { makeSpriteScene, place, roundTrip } from '../helpers'; 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']); 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');
});
});
+34
View File
@@ -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: // 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. // paste a second picture, undo the asset (the file stays), then pick that file in the dialog.
await evaluate(PASTE_SPARE); await evaluate(PASTE_SPARE);