Asset panel: double-click opens the tab, single click only selects (#71)
game-maker / suite (push) Failing after 8m11s
game-maker / suite (push) Failing after 8m11s
One rule for every asset type: a click on an asset row selects it, a double-click (or Enter, or creating it via +) opens its tab. Selection alone still switches tabs only for nodes, frames and template objects. - state.openAsset(id) = select + 'open' event; the editor maps the asset type to its tab via TAB_OF_ASSET. The two special clauses for state machine and shader assets in the selection handler are gone. - Creating via + and importing a texture from the picker open the tab; paste/drop of an image only selects. - 3 unit tests (event order, re-open of a selected asset, selection never fires open), 14 e2e checks in step 4. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
3da0ef1be2
commit
4d9c4982eb
7 files changed
+160
-15
No files matched your search
@@ -37,7 +37,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, 703 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 717 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
|
||||
|
||||
+28
-9
@@ -1,4 +1,5 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import type { TextureStore } from '../core/store/TextureStore';
|
||||
@@ -52,6 +53,17 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
||||
list: 'List',
|
||||
};
|
||||
|
||||
/** Where an opened asset goes; a placeable one (sprite, camera, text, collider) has no tab of its own. */
|
||||
const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
||||
scene: 'scene',
|
||||
behavior: 'graph',
|
||||
statemachine: 'statemachine',
|
||||
shader: 'shader',
|
||||
texture: 'texture',
|
||||
animation: 'animation',
|
||||
container: 'container',
|
||||
};
|
||||
|
||||
const MIN_LEFT_WIDTH = 160;
|
||||
const MAX_LEFT_WIDTH = 500;
|
||||
const MIN_RIGHT_WIDTH = 200;
|
||||
@@ -222,17 +234,18 @@ export class Editor implements PlayHost {
|
||||
});
|
||||
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
|
||||
// A node, a frame and a template object exist only in their own tab, so selecting one
|
||||
// switches to it; selecting the behavior, texture or container asset itself does not. A state
|
||||
// machine or shader asset does switch (user's choice): its tab is the only place it shows anything.
|
||||
// switches to it. Selecting an asset never does: that is what opening it (double-click,
|
||||
// Enter, creating it) is for – see the `open` event below.
|
||||
state.on('selection', () => {
|
||||
const s = state.selection;
|
||||
const machine = state.selectedStateMachineId !== null;
|
||||
const shader = state.selectedShaderId !== null;
|
||||
if (s?.kind === 'node' && !machine && !shader && this.tab !== 'graph') this.showTab('graph');
|
||||
if ((s?.kind === 'node' || s?.kind === 'asset') && machine && this.tab !== 'statemachine')
|
||||
this.showTab('statemachine');
|
||||
if ((s?.kind === 'node' || s?.kind === 'asset') && shader && this.tab !== 'shader')
|
||||
this.showTab('shader');
|
||||
if (s?.kind === 'node') {
|
||||
const tab = state.selectedStateMachineId
|
||||
? 'statemachine'
|
||||
: state.selectedShaderId
|
||||
? 'shader'
|
||||
: 'graph';
|
||||
if (this.tab !== tab) this.showTab(tab);
|
||||
}
|
||||
if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
|
||||
if (
|
||||
s?.kind === 'object' &&
|
||||
@@ -241,6 +254,12 @@ export class Editor implements PlayHost {
|
||||
)
|
||||
this.showTab('container');
|
||||
});
|
||||
state.on('open', () => {
|
||||
const s = state.selection;
|
||||
const tab =
|
||||
s?.kind === 'asset' ? TAB_OF_ASSET[findAsset(state.project, s.assetId)?.type ?? 'sprite'] : undefined;
|
||||
if (tab) this.showTab(tab);
|
||||
});
|
||||
// Opening a list is a request to look at it, so this switches even when the slot is unchanged.
|
||||
state.on('list', () => {
|
||||
if (state.listSlot) this.showTab('list');
|
||||
|
||||
@@ -85,15 +85,14 @@ export class AssetPanel extends Panel {
|
||||
|
||||
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 });
|
||||
if (this.state.execute(command)) this.state.openAsset(command.asset.id);
|
||||
}
|
||||
|
||||
/** One undo step for the asset and its frames. */
|
||||
private createAnimation(texture: TextureAsset): void {
|
||||
const create = new CreateAssetCommand('animation', nextAssetName(this.state.project, texture.name));
|
||||
const frames = new AddAnimationFramesCommand(create.asset.id, framesOfTexture(texture));
|
||||
if (this.state.execute(oneStep(create.label, [create, frames])))
|
||||
this.state.select({ kind: 'asset', assetId: create.asset.id });
|
||||
if (this.state.execute(oneStep(create.label, [create, frames]))) this.state.openAsset(create.asset.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,10 +107,11 @@ export class AssetPanel extends Panel {
|
||||
openImagePicker({ state: this.state, textures: this.textures });
|
||||
}
|
||||
|
||||
/** Double-click / Enter on a scene makes it the active one. Placing is a drag, not a click. */
|
||||
/** Double-click / Enter opens the asset's tab; a scene also becomes the active one. Placing is a drag. */
|
||||
private activate(id: string): void {
|
||||
const asset = findAsset(this.state.project, id);
|
||||
if (asset?.type === 'scene') this.state.execute(new SetActiveSceneCommand(id));
|
||||
this.state.openAsset(id);
|
||||
}
|
||||
|
||||
/** Where an asset's row sits on screen, so a check can pick it up by id (test hook). */
|
||||
|
||||
@@ -83,6 +83,8 @@ export type EditorEvent =
|
||||
| 'selection'
|
||||
/** The List tab was pointed at another list (or at none). */
|
||||
| 'list'
|
||||
/** An asset was opened (double-click, Enter, or just created): its tab should show. */
|
||||
| 'open'
|
||||
| 'mode'
|
||||
| 'save'
|
||||
| 'notice'
|
||||
@@ -267,6 +269,16 @@ export class EditorState {
|
||||
this.emit('list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the asset and asks for its tab. Selecting alone never switches tabs (a click in the
|
||||
* Assets panel is a look at the inspector); this is the way a double-click or a fresh asset gets
|
||||
* there, and like `openList` it works without knowing the `Editor`.
|
||||
*/
|
||||
openAsset(assetId: string): void {
|
||||
this.select({ kind: 'asset', assetId });
|
||||
this.emit('open');
|
||||
}
|
||||
|
||||
select(selection: Selection): void {
|
||||
// Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
|
||||
@@ -156,7 +156,9 @@ function adopt(
|
||||
*/
|
||||
export function importStoredImage(image: UploadedTexture, deps: TextureImportDeps): ImportOutcome {
|
||||
const outcome = adopt(image, 'Texture', image.file, deps);
|
||||
selectLast([outcome], deps);
|
||||
// Chosen in the picker, so this is "+ → Texture": the tab opens like for any created asset. A
|
||||
// pasted or dropped picture (`importImages`) only selects – it usually goes into the scene.
|
||||
if (outcome.assetId) deps.state.openAsset(outcome.assetId);
|
||||
deps.state.setNotice(
|
||||
outcome.error ??
|
||||
(outcome.reused ? 'That image is already in the project.' : 'Added the image to the project.'),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Opening an asset (double-click, Enter, creating one) is selecting it plus a request for its tab.
|
||||
* The editor switches on `open`, never on `selection` alone – so the order and the "already
|
||||
* selected" case are what matter here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { createEmptyProject } from '../../src/core/model/Project';
|
||||
import { EditorState } from '../../src/editor/state';
|
||||
|
||||
function setup(): { state: EditorState; assetId: string; events: string[] } {
|
||||
const state = new EditorState();
|
||||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||||
const create = new CreateAssetCommand('behavior', 'B');
|
||||
expect(state.execute(create)).toBe(true);
|
||||
const events: string[] = [];
|
||||
state.on('selection', () => events.push('selection'));
|
||||
state.on('open', () => events.push('open'));
|
||||
return { state, assetId: create.asset.id, events };
|
||||
}
|
||||
|
||||
describe('opening an asset', () => {
|
||||
it('selects it first and asks for the tab after, so the tab finds the selection in place', () => {
|
||||
const { state, assetId, events } = setup();
|
||||
state.openAsset(assetId);
|
||||
expect(state.selection).toEqual({ kind: 'asset', assetId });
|
||||
expect(events).toEqual(['selection', 'open']);
|
||||
});
|
||||
|
||||
it('still asks for the tab when the asset was selected already – a click did not open it', () => {
|
||||
const { state, assetId, events } = setup();
|
||||
state.select({ kind: 'asset', assetId });
|
||||
events.length = 0;
|
||||
state.openAsset(assetId);
|
||||
expect(events).toEqual(['open']);
|
||||
});
|
||||
|
||||
it('never fires on a plain selection', () => {
|
||||
const { state, assetId, events } = setup();
|
||||
state.select({ kind: 'asset', assetId });
|
||||
expect(events).toEqual(['selection']);
|
||||
});
|
||||
});
|
||||
@@ -88,12 +88,15 @@ export async function assets(t) {
|
||||
`__gm.state.project.assets.push({ id: 'scene2', type: 'scene', name: 'Level 2', props: { backgroundColor: 0x22252c }, objects: [] })`,
|
||||
);
|
||||
const secondScene = await json("__gm.assetRow('scene2')");
|
||||
await evaluate("__gm.showTab('graph')");
|
||||
await sleep(150);
|
||||
await doubleClick(secondScene.x, secondScene.y);
|
||||
check(
|
||||
'double-clicking a scene asset makes it the active scene',
|
||||
(await json('__gm.state.project.activeSceneId')) === 'scene2',
|
||||
await json('__gm.state.project.activeSceneId'),
|
||||
);
|
||||
check('and opens the Scene tab', (await json('__gm.tab()')) === 'scene', await json('__gm.tab()'));
|
||||
const firstScene = await json("__gm.assetRow('scene1')");
|
||||
await doubleClick(firstScene.x, firstScene.y);
|
||||
check(
|
||||
@@ -103,6 +106,72 @@ export async function assets(t) {
|
||||
);
|
||||
await mutate(`__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'scene2')`);
|
||||
|
||||
// 4d′. Opening an asset: a click on its row only selects, a double-click opens the tab it
|
||||
// lives in – every type that has one. The fixture lacks four of them; they go in through
|
||||
// the model, empty.
|
||||
await mutate(`__gm.state.project.assets.push(
|
||||
{ id: 'sm-open', type: 'statemachine', name: 'Machine', props: {}, graph: { nodes: [], edges: [] } },
|
||||
{ id: 'tex-open', type: 'texture', name: 'Sheet', props: {}, file: '', width: 0, height: 0, frames: [] },
|
||||
{ id: 'anim-open', type: 'animation', name: 'Walk', props: {}, frames: [] },
|
||||
{ id: 'box-open', type: 'container', name: 'Box', props: {}, objects: [] },
|
||||
)`);
|
||||
const openable = [
|
||||
['mover', 'graph', '__gm.graph.current()'],
|
||||
['sm-open', 'statemachine', '__gm.stateMachine.current()'],
|
||||
['adjust-colors', 'shader', '__gm.shader.current()'],
|
||||
['tex-open', 'texture', '__gm.texture.current()'],
|
||||
['anim-open', 'animation', '__gm.animation.current().assetId'],
|
||||
['box-open', 'container', '__gm.container.current()'],
|
||||
];
|
||||
for (const [id, tab, shown] of openable) {
|
||||
await evaluate("__gm.showTab('scene')");
|
||||
await sleep(150);
|
||||
const row = await json(`__gm.assetRow(${JSON.stringify(id)})`);
|
||||
await click(row.x, row.y);
|
||||
await sleep(500); // past the double-click window, so the next press starts a fresh pair
|
||||
const clicked = {
|
||||
tab: await json('__gm.tab()'),
|
||||
selected: (await json('__gm.state.selection'))?.assetId,
|
||||
};
|
||||
check(
|
||||
`a click on the ${tab} asset selects it and leaves the Scene tab alone`,
|
||||
clicked.tab === 'scene' && clicked.selected === id,
|
||||
JSON.stringify(clicked),
|
||||
);
|
||||
await doubleClick(row.x, row.y);
|
||||
await sleep(300);
|
||||
const opened = { tab: await json('__gm.tab()'), shown: await json(shown) };
|
||||
check(
|
||||
`a double-click opens the ${tab} tab on it`,
|
||||
opened.tab === tab && opened.shown === id,
|
||||
JSON.stringify(opened),
|
||||
);
|
||||
}
|
||||
// Enter goes the same way (`onActivate`), but only once the list holds keyboard focus, which a
|
||||
// click does not give it – so it is not driven here.
|
||||
await evaluate("__gm.showTab('scene')");
|
||||
await sleep(150);
|
||||
await clickText('+');
|
||||
await sleep(200);
|
||||
const entry = await evaluate(`__gm.locate('Behavior', true)`);
|
||||
await click(entry.x, entry.y);
|
||||
await sleep(400);
|
||||
const created = await json(
|
||||
"(__gm.state.project.assets.find((a) => a.type === 'behavior' && !['mover', 'spawner'].includes(a.id)) ?? {}).id ?? ''",
|
||||
);
|
||||
const fresh = { created, tab: await json('__gm.tab()'), shown: await json('__gm.graph.current()') };
|
||||
check(
|
||||
'creating an asset through + opens its tab on the new one',
|
||||
Boolean(created) && fresh.tab === 'graph' && fresh.shown === created,
|
||||
JSON.stringify(fresh),
|
||||
);
|
||||
await undo();
|
||||
await mutate(
|
||||
`__gm.state.project.assets = __gm.state.project.assets.filter((a) => !['sm-open', 'tex-open', 'anim-open', 'box-open'].includes(a.id))`,
|
||||
);
|
||||
await evaluate("__gm.showTab('scene')");
|
||||
await sleep(150);
|
||||
|
||||
// 4e. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour,
|
||||
// so two rows carry a reset. Rows run Enabled, Width, Height, Color, Texture, so height's
|
||||
// reset is the first on screen and colour's the last.
|
||||
|
||||
Reference in new issue
Block a user