/** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */ export async function assets(t) { const { check, sleep, evaluate, json, shot, click, doubleClick, clickText, mouse, mutate, press, redo, undo, select, objects, asset, showTab, } = t; // 4a. Context menu on the Crate asset. Placing left the menu when it became a drag. await clickText('Crate', 'right'); check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Delete…')`))); check('placing is no longer a menu item', !(await evaluate(`__gm.locate('Place in scene')`))); await shot('11-context-menu'); await press('Escape'); // 4b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than // with `drag()`, because the preview only exists while the button is still down. const from = await json("__gm.assetRow('crate')"); const centre = await json('__gm.viewportCenter()'); const to = { x: centre.x + 70, y: centre.y - 50 }; await mouse('mouseMoved', from.x, from.y); await mouse('mousePressed', from.x, from.y); for (let i = 1; i <= 8; i++) { const x = from.x + ((to.x - from.x) * i) / 8; const y = from.y + ((to.y - from.y) * i) / 8; await mouse('mouseMoved', x, y, 'left', { buttons: 1 }); await sleep(16); } const preview = await json('__gm.dropPreview()'); check( 'dragging an asset over the view previews where it would land', Boolean(preview) && preview.assetId === 'crate' && preview.rejection === '', JSON.stringify(preview), ); await shot('11b-drop-preview'); await mouse('mouseReleased', to.x, to.y); await sleep(200); const count = (await objects()).length; check('dropping the asset adds an object', count === 8, `objects=${count}`); const placed = await json( '(() => { const id = __gm.state.selectedObjectIds[0]; return __gm.sceneObjects().find(o => o.id === id) ?? null; })()', ); // The preview is a promise: whatever it drew has to be where the object then sits, or the // outline was decoration rather than a prediction. check( 'the object lands where the preview promised, and is selected', Boolean(placed) && placed.assetId === 'crate' && placed.position.x === preview.x && placed.position.y === preview.y, JSON.stringify({ placed: placed && placed.position, preview }), ); check('the preview is gone once the asset is placed', (await json('__gm.dropPreview()')) === null); await undo(); const afterUndo = (await objects()).length; check('one undo takes the dropped object back out', afterUndo === 7, `objects=${afterUndo}`); await redo(); check('redo puts it back', (await objects()).length === 8); // 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups – // what can be placed in a scene, a line, then what only lives in the library – and it is // tall enough to show all fifteen types without scrolling. await clickText('+'); const rows = await json('__gm.menuRows()'); const cut = rows.indexOf(null); const placeable = rows.slice(0, cut); const library = rows.slice(cut + 1); check( 'the + menu is split by exactly one line', rows.filter((r) => r === null).length === 1 && cut > 0, JSON.stringify(rows), ); check( 'every asset type is offered once, each group sorted by label', placeable.length + library.length === 15 && String(placeable) === String([...placeable].sort()) && String(library) === String([...library].sort()), JSON.stringify({ placeable, library }), ); check( 'placeable types come first, the library ones after the line', placeable.includes('Sprite') && !placeable.includes('Scene') && library.includes('Scene') && library.includes('Texture'), JSON.stringify({ placeable, library }), ); // The height cap is the canvas, not a constant: fifteen entries used to run under the mask edge, // where `locate` still finds them but a click lands on nothing (that is what took step 30 down). await shot('tmp-plus-menu'); const menuBox = await json('__gm.overlayRect()'); const lastRow = await evaluate(`__gm.locate(${JSON.stringify(library.at(-1))}, true)`); const screenH = await json('__gm.app.screen.height'); check( 'the whole menu stands on screen, last entry clickable', Boolean(lastRow) && lastRow.y < menuBox.y + menuBox.h && menuBox.y + menuBox.h <= screenH && menuBox.y >= 0, JSON.stringify({ menuBox, lastRow, screenH }), ); // A tenth of the canvas stays free below every popup. The margin used to live only in the height // cap, never in the placement, so a popup that had to be shifted up ended flush on the bottom // edge with the whole gap collected above it. Above it is a budget, not a distance: this menu // hangs off a button near the top and stays there. const above = menuBox.y; const below = screenH - (menuBox.y + menuBox.h); const margin = Math.round(screenH * 0.1); check( 'the menu keeps a tenth of the canvas free below, and stays under its button', below >= margin - 1 && above >= 0 && above < margin, JSON.stringify({ above, below, margin, screenH }), ); await clickText('Sprite'); const names = await json('__gm.state.project.assets.map(a => a.name)'); check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names)); await shot('12-new-asset'); // 4d. Double-click on a scene row: the one thing activating an asset still does since placing // became a drag. The second scene goes in through the model – the "+" menu's "Scene" entry // shares its label with the Scene tab, so a click by text could land on either. await mutate( `__gm.state.project.assets.push({ id: 'scene2', type: 'scene', name: 'Level 2', props: { backgroundColor: 0x22252c }, objects: [] })`, ); const secondScene = await json("__gm.assetRow('scene2')"); await showTab('graph'); 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( 'double-clicking the first one switches back', (await json('__gm.state.project.activeSceneId')) === 'scene1', await json('__gm.state.project.activeSceneId'), ); 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 showTab('scene'); 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); 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 showTab('scene'); await clickText('+'); const entry = await evaluate(`__gm.locate('Behavior', true)`); await click(entry.x, entry.y); 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 showTab('scene'); // 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. await select({ kind: 'asset', assetId: 'crate' }); const heightReset = await evaluate(`__gm.locate('↺')`); const colorReset = await evaluate(`__gm.locate('↺', true)`); check('asset inspector shows a reset per deviating property', Boolean(heightReset)); check( 'the two deviations have two separate resets', Boolean(colorReset) && colorReset.y > heightReset.y, JSON.stringify({ heightReset, colorReset }), ); await click(heightReset.x, heightReset.y); check( 'reset puts the asset property back to its default', (await asset('crate')).props.height === 100, `height=${(await asset('crate')).props.height}`, ); const remaining = await evaluate(`__gm.locate('↺')`); check( 'the reset of the property now at its default is gone, the other one stays', Boolean(remaining) && remaining.y === colorReset.y && remaining.y === (await evaluate(`__gm.locate('↺', true)`)).y, JSON.stringify({ remaining, colorReset }), ); await shot('15-asset-reset'); await undo(); check( 'undo restores the asset value', (await asset('crate')).props.height === 60, `height=${(await asset('crate')).props.height}`, ); check( 'the height reset is back after undo', (await evaluate(`__gm.locate('↺')`))?.y === heightReset.y, JSON.stringify(await evaluate(`__gm.locate('↺')`)), ); // 4f. The same gold in the asset list: a row is marked while the asset carries anything the // inspector marks. The Crate deviates twice, so it takes two resets to turn it plain. const GOLD = '#f2c84b'; const PLAIN = '#e6e8ee'; const assetColor = (id) => json(`__gm.assetRowColor('${id}')`); check('a deviating asset is marked gold in the list', (await assetColor('crate')) === GOLD); check('an asset at its defaults is not', (await assetColor('cam')) === PLAIN, await assetColor('cam')); // The Mover's `speed` is 200 against a declared variable default of 0. The inspector marks no // variable, so neither may the list – the two must say the same thing. check( 'a behavior is not marked for a variable value, which the inspector does not mark either', (await assetColor('mover')) === PLAIN, await assetColor('mover'), ); await click(heightReset.x, heightReset.y); check( 'the mark stays while the second deviation is left', (await assetColor('crate')) === GOLD, await assetColor('crate'), ); const colorOnly = await evaluate(`__gm.locate('↺')`); await click(colorOnly.x, colorOnly.y); check( 'resetting the last deviation clears the mark, without a reload', (await assetColor('crate')) === PLAIN, `${await assetColor('crate')} props=${JSON.stringify((await asset('crate')).props)}`, ); await shot('15b-asset-marks'); await undo(); await undo(); check( 'undo brings the mark back', (await assetColor('crate')) === GOLD, `${await assetColor('crate')} props=${JSON.stringify((await asset('crate')).props)}`, ); }