diff --git a/README.md b/README.md index 2ec796fe..553c38f4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 640a3e7e..52315afa 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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 = { 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> = { + 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'); diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 792c2429..aa8edbab 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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). */ diff --git a/src/editor/state.ts b/src/editor/state.ts index 5b7acc2c..4d48e862 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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 diff --git a/src/editor/textureImport.ts b/src/editor/textureImport.ts index f0b2c6b6..94970b63 100644 --- a/src/editor/textureImport.ts +++ b/src/editor/textureImport.ts @@ -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.'), diff --git a/tests/editor/open-asset.test.ts b/tests/editor/open-asset.test.ts new file mode 100644 index 00000000..666d59a6 --- /dev/null +++ b/tests/editor/open-asset.test.ts @@ -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']); + }); +}); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 3364c9f6..e398da56 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -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.