/** * Picking an object of the same scene for an object variable (#258), with the real mouse: the * placed behavior offers None and the scene's objects, the pick is stored as the object's id and a * run reads the object itself. Removing that object drops the reference; undo brings it back. On the * behavior asset there is no scene, so the row still says "(null)". * * The eyedropper beside the dropdown (#276) picks in the scene view instead: over two stacked * objects a click takes the one in front and a second click the one behind, Enter writes it, Escape, * a right-click and a second press of the eyedropper leave it as it was. From a list item in a * Variables tab it goes to the scene view and comes back with Take. * * Removing an object in the Container tab empties a reference a placement of it holds (#269). * Inside a container the Aim can pick content of a placement nested there, and the container's * placement in the scene reaches it (#285). Deleting that nested container's asset empties the * reference again (#286). * * Everything it adds carries an `or57` id and goes again at the end. */ import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; export async function objectReferences(t) { const { check, json, evaluate, mutate, click, press, typeText, sleep, shot, select, undo, stop } = t; const press_ = async (at, what) => { if (!at) throw new Error(`nothing to click: ${what}`); await click(at.x, at.y); await sleep(150); }; const target = () => json("__gm.state.activeScene.objects.find((o) => o.id === 'or57-obj')?.overrides.target ?? null"); // On start the behavior logs the object its `target` names. const graph = { nodes: [ { id: 'or57-start', type: 'OnStart', x: 0, y: 0, params: {} }, { id: 'or57-vars', type: 'Variables', x: 0, y: 200, params: {}, ports: [{ key: 'target', kind: 'object' }], }, { id: 'or57-log', type: 'Log', x: 320, y: 0, params: {} }, ], edges: [ { id: 'or57-e1', from: { node: 'or57-start', port: 'exec' }, to: { node: 'or57-log', port: 'exec' } }, { id: 'or57-e2', from: { node: 'or57-vars', port: 'target' }, to: { node: 'or57-log', port: 'message' }, }, ], }; await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; p.assets.push({ id: 'or57-aim', type: 'behavior', name: 'Aim 57', props: { enabled: true, runInEditor: false, target: { $object: '' } }, variables: [{ key: 'target', label: 'target', kind: 'object', default: { $object: '' } }], graph: ${JSON.stringify(graph)}, }); ${MAKE_OBJECT} const sprite = p.assets.find((a) => a.type === 'sprite'); scene.objects.push(make('or57-obj', 'or57-aim', '', 400, 'Aim 57')); scene.objects.push(make('or57-crate', sprite.id, '', 450, 'Crate 57')); `); const sceneId = await json('__gm.state.activeScene.id'); // 57a. The behavior asset has no scene: its object variable only says "(null)". await select({ kind: 'asset', assetId: 'or57-aim' }); await sleep(200); check( 'on the behavior asset the object variable says (null)', (await json("__gm.locate('(null)')")) !== null, ); // 57b. On the placed object the row is a dropdown: None, the object itself and the Crate. A click // on the Crate stores its id. await select({ kind: 'object', holderId: sceneId, objectId: 'or57-obj' }); await sleep(200); await press_(await json("__gm.inspectorField('target')"), 'the target field'); const offered = (await json('__gm.overlayTexts()')) ?? []; // In the full suite the scene holds more objects than the menu shows at once, so the two new ones // at its end are only reached through the search box. await typeText('57'); const found = (await json('__gm.overlayTexts()')) ?? []; await shot('01-menu'); check( 'the placed object offers None, itself and the other objects of the scene', offered.includes('None') && ['Aim 57', 'Crate 57'].every((label) => found.includes(label)), JSON.stringify({ offered, found }), ); await press_(await json("__gm.locate('Crate 57', true)"), 'Crate 57 in the menu'); const picked = await target(); check( 'picking the Crate stores its id and the row shows its name', JSON.stringify(picked) === JSON.stringify({ $object: 'or57-crate' }) && (await json("__gm.locate('Crate 57')")) !== null, JSON.stringify(picked), ); await shot('02-picked'); // 57c. A run reads the object itself. await evaluate('__gm.state.select(null)'); const logFrom = t.consoleLog.length; await t.play(); await sleep(600); await stop(); const logged = t.consoleLog .slice(logFrom) .filter((line) => line.startsWith('[behavior Aim 57]')) .map((line) => line.split(']').pop().trim()); check('in a run the variable is the picked object', logged.includes('Crate 57'), JSON.stringify(logged)); // 57d. Removing the Crate drops the reference with it; one undo brings both back. await select({ kind: 'object', holderId: sceneId, objectId: 'or57-crate' }); await sleep(150); await press('Delete'); await sleep(150); const dropped = await target(); await undo(); const back = await target(); check( 'removing the picked object drops the reference, undo brings it back', JSON.stringify(dropped) === JSON.stringify({ $object: '' }) && JSON.stringify(back) === JSON.stringify({ $object: 'or57-crate' }), JSON.stringify({ dropped, back }), ); // 57e. The eyedropper (#276): two objects stacked on a lane of their own, the target back to // None, and a list of one object item for 57g. const Y = LANES.eyedropper; await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; ${MAKE_OBJECT} const sprite = p.assets.find((a) => a.type === 'sprite'); scene.objects.push(make('or57-back', sprite.id, '', ${Y}, 'Back 57')); scene.objects.push(make('or57-front', sprite.id, '', ${Y}, 'Front 57')); const aim = p.assets.find((a) => a.id === 'or57-aim'); aim.variables.push({ key: 'crowd', label: 'crowd', kind: 'list', default: [] }); aim.props.crowd = []; const obj = scene.objects.find((o) => o.id === 'or57-obj'); obj.overrides.target = { $object: '' }; obj.overrides.crowd = [{ $object: '' }]; `); await select({ kind: 'object', holderId: sceneId, objectId: 'or57-obj' }); await evaluate(`__gm.lookAt(0, ${Y})`); await sleep(200); const pick = () => json('__gm.objectPick()'); const eyedropper = () => json("__gm.inspectorEyedropper('target')"); const inInspector = async (text) => { const box = await json('__gm.inspectorRect()'); const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? []; return hits.some((p) => p.x >= box.x && p.x <= box.x + box.w && p.y >= box.y && p.y <= box.y + box.h); }; const spot = await json("__gm.objectScreen('or57-front')"); const stack = await json(`__gm.sceneStack(${spot.x}, ${spot.y})`); await press_(await eyedropper(), 'the eyedropper of target'); const started = await pick(); const lit = (await eyedropper())?.lit; check( 'the eyedropper starts in the scene view, lit, and the bar over it names the field', started.picking && started.tab === 'scene' && lit === true && started.candidate === null && started.strip?.text === 'Target for Aim 57 · target: click an object', JSON.stringify({ started, lit }), ); await click(spot.x, spot.y); const first = await pick(); await click(spot.x, spot.y); const second = await pick(); await shot('03-eyedropper'); const empty = await json('__gm.emptySpot()'); await click(empty.x, empty.y); const afterEmpty = await pick(); check( 'a click takes the object in front, a second one the object behind, empty space keeps it, nothing is written', JSON.stringify(stack) === JSON.stringify(['or57-front', 'or57-back']) && first.candidate === 'or57-front' && second.candidate === 'or57-back' && second.outline === 'or57-back' && second.strip?.text === 'Target for Aim 57 · target: Back 57' && afterEmpty.candidate === 'or57-back' && afterEmpty.picking && JSON.stringify(await target()) === JSON.stringify({ $object: '' }), JSON.stringify({ stack, first, second, afterEmpty }), ); await press('Enter'); const taken = await pick(); const written = await target(); const shown = await inInspector('Back 57'); const litAfter = (await eyedropper())?.lit; await undo(); const undone = await target(); check( 'Enter writes the candidate, the row shows it, the eyedropper goes off, one undo takes it back', !taken.picking && taken.ended === 'take' && taken.strip === null && JSON.stringify(written) === JSON.stringify({ $object: 'or57-back' }) && shown && litAfter === false && JSON.stringify(undone) === JSON.stringify({ $object: '' }), JSON.stringify({ taken, written, shown, litAfter, undone }), ); // 57f. Escape, a right-click in the view and a second press of the eyedropper leave it as it was. const cancels = []; for (const [how, leave] of [ ['Escape', () => press('Escape')], ['right-click', () => click(spot.x, spot.y, 'right')], ['eyedropper', async () => press_(await eyedropper(), 'the eyedropper, again')], ]) { await press_(await eyedropper(), 'the eyedropper of target'); await click(spot.x, spot.y); const before = (await pick()).candidate; await leave(); const after = await pick(); const menu = (await json('__gm.overlayTexts()')) ?? []; cancels.push({ how, before, picking: after.picking, ended: after.ended, menu, target: await target() }); } check( 'Escape, a right-click and a second press of the eyedropper end it without writing', cancels.every( (c) => c.before === 'or57-front' && !c.picking && c.ended === 'cancel' && c.menu.length === 0 && JSON.stringify(c.target) === JSON.stringify({ $object: '' }), ), JSON.stringify(cancels), ); // 57g. A list item in a Variables tab: the eyedropper goes to the scene view, Take writes the item // and brings the tab back. await evaluate( `__gm.state.openValues({ kind: 'list', slot: { kind: 'object', holderId: ${JSON.stringify(sceneId)}, objectId: 'or57-obj', key: 'crowd' } })`, ); await sleep(200); const tabShown = (await pick()).tab; const inspectorLeft = (await json('__gm.inspectorRect()')).x; const inTab = ((await json("__gm.locateIcons('eyedropper')")) ?? []).filter((p) => p.x < inspectorLeft); await press_(inTab[0], 'the eyedropper of the list item'); const fromList = await pick(); await click(spot.x, spot.y); const listCandidate = (await pick()).candidate; const take = (await pick()).strip?.take; await press_(take, 'Take'); const backIn = await pick(); const crowd = await json( "__gm.state.activeScene.objects.find((o) => o.id === 'or57-obj')?.overrides.crowd ?? null", ); check( 'from a list item in a Variables tab it picks in the scene view, and Take writes it and goes back', tabShown === 'variables' && inTab.length === 1 && fromList.tab === 'scene' && fromList.strip?.text === 'Target for Aim 57 · crowd · [0]: click an object' && listCandidate === 'or57-front' && backIn.ended === 'take' && backIn.tab === 'variables' && JSON.stringify(crowd) === JSON.stringify([{ $object: 'or57-front' }]), JSON.stringify({ tabShown, inTab, fromList, listCandidate, backIn, crowd }), ); // 57h. A container placed in the scene, its Aim pointed at its own Crate there (#269). Removing // the Crate in the Container tab empties that reference too: the row says None, not // (missing). One undo brings both back. await t.showTab('scene'); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; ${MAKE_OBJECT} const sprite = p.assets.find((a) => a.type === 'sprite'); p.assets.push({ id: 'or57-box', type: 'container', name: 'Box 57', props: {}, objects: [make('or57-t-aim', 'or57-aim', '', 0, 'Aim 57'), make('or57-t-crate', sprite.id, '', 50, 'Crate 57')], }); const placed = make('or57-placed', 'or57-box', '', ${LANES.containerReference}, 'Box 57'); placed.instanceEdits = { 'or57-t-aim': { position: {}, overrides: { target: { $object: 'or57-t-crate' } } }, }; scene.objects.push(placed); `); const placedTarget = () => json( "__gm.state.activeScene.objects.find((o) => o.id === 'or57-placed')?.instanceEdits['or57-t-aim']?.overrides.target ?? null", ); const targetRow = async () => { const field = await json("__gm.inspectorField('target')"); const hits = (await json("__gm.locateAll('None')")) ?? []; return { none: hits.some((p) => field && p.x >= field.left && Math.abs(p.y - field.y) < 12), missing: await inInspector('(missing)'), crate: await inInspector('Crate 57'), }; }; const placedAim = { kind: 'object', holderId: sceneId, objectId: 'or57-placed~or57-t-aim' }; await select(placedAim); await sleep(200); const rowBefore = await targetRow(); await select({ kind: 'object', holderId: 'or57-box', objectId: 'or57-t-crate' }); await sleep(200); const tabThen = await json('__gm.tab()'); await press('Delete'); await sleep(150); const templateLeft = await json( "__gm.state.project.assets.find((a) => a.id === 'or57-box').objects.map((o) => o.id)", ); const emptied = await placedTarget(); await t.showTab('scene'); await select(placedAim); await sleep(200); const rowAfter = await targetRow(); await shot('04-container-reference'); await undo(); await sleep(150); const restored = await placedTarget(); check( 'removing an object in the Container tab empties the placement pointing at it, undo brings it back', rowBefore.crate && !rowBefore.missing && tabThen === 'container' && JSON.stringify(templateLeft) === JSON.stringify(['or57-t-aim']) && JSON.stringify(emptied) === JSON.stringify({ $object: '' }) && rowAfter.none && !rowAfter.missing && JSON.stringify(restored) === JSON.stringify({ $object: 'or57-t-crate' }), JSON.stringify({ rowBefore, tabThen, templateLeft, emptied, rowAfter, restored }), ); // 57i. Outer 57 holds an Aim and a placement of Inner 57 (#285). In the Container tab the Aim // picks Inner's Nest 57, stored as Outer's own view names it. Outer's placement in the scene // shows that name on its Aim, and a run reads the copy of Nest 57 there. await t.showTab('scene'); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; ${MAKE_OBJECT} const sprite = p.assets.find((a) => a.type === 'sprite'); p.assets.push({ id: 'or57-inner', type: 'container', name: 'Inner 57', props: {}, objects: [make('or57-i-nest', sprite.id, '', 0, 'Nest 57')], }); p.assets.push({ id: 'or57-outer', type: 'container', name: 'Outer 57', props: {}, objects: [ make('or57-o-aim', 'or57-aim', '', 0, 'Aim 57'), make('or57-o-inner', 'or57-inner', '', 50, 'Inner 57'), ], }); scene.objects.push(make('or57-outer-placed', 'or57-outer', '', ${LANES.nestedReference}, 'Outer 57')); `); const outerTarget = () => json( "__gm.state.project.assets.find((a) => a.id === 'or57-outer').objects.find((o) => o.id === 'or57-o-aim')?.overrides.target ?? null", ); const nestName = JSON.stringify({ $object: 'or57-o-inner~or57-i-nest' }); await select({ kind: 'object', holderId: 'or57-outer', objectId: 'or57-o-aim' }); await sleep(200); const outerTab = await json('__gm.tab()'); await press_(await json("__gm.inspectorField('target')"), 'the target field in the Container tab'); const inOuter = (await json('__gm.overlayTexts()')) ?? []; // Nest 57 sits one level below Inner 57, so the menu indents it. const nestEntry = inOuter.find((text) => text.trim() === 'Nest 57'); await press_(await json(`__gm.locate(${JSON.stringify(nestEntry)}, true)`), 'Nest 57 in the menu'); const stored = await outerTarget(); await t.showTab('scene'); const nestedAim = { kind: 'object', holderId: sceneId, objectId: 'or57-outer-placed~or57-o-aim' }; await select(nestedAim); await sleep(200); const nestedRow = { nest: await inInspector('Nest 57'), missing: await inInspector('(missing)') }; await shot('05-nested-reference'); await evaluate('__gm.state.select(null)'); const runFrom = t.consoleLog.length; await t.play(); await sleep(600); await stop(); const nestedLogged = t.consoleLog .slice(runFrom) .filter((line) => line.startsWith('[behavior Aim 57]')) .map((line) => line.split(']').pop().trim()); check( 'a pick in the Container tab reaches into a nested placement, and the placement in the scene shows and reads it', outerTab === 'container' && nestEntry !== undefined && JSON.stringify(stored) === nestName && nestedRow.nest && !nestedRow.missing && nestedLogged.includes('Nest 57'), JSON.stringify({ outerTab, inOuter, stored, nestedRow, nestedLogged }), ); // 57j. Deleting Inner 57 in the asset list (#286) takes its placement out of Outer 57 and empties // the Aim's reference to Nest 57 with it: the row says None, not (missing). One undo brings // both back. const innerRow = await json("__gm.assetRow('or57-inner')"); if (!innerRow) throw new Error('Inner 57 is not in the asset list'); await click(innerRow.x, innerRow.y, 'right'); await sleep(150); await press_(await json("__gm.locate('Delete…', true)"), 'Delete… in the row menu'); await press_(await json("__gm.locate('Delete', true)"), 'Delete in the dialog'); const innerLeft = await json("__gm.state.project.assets.some((a) => a.id === 'or57-inner')"); const cleared = await outerTarget(); await select(nestedAim); await sleep(200); const clearedRow = await targetRow(); await shot('06-asset-deleted'); await undo(); await sleep(150); const reinstated = await outerTarget(); check( 'deleting an asset empties a reference to an object it took along, undo brings it back', !innerLeft && JSON.stringify(cleared) === JSON.stringify({ $object: '' }) && clearedRow.none && !clearedRow.missing && JSON.stringify(reinstated) === nestName, JSON.stringify({ innerLeft, cleared, clearedRow, reinstated }), ); // Clean up. Back to the scene view first: removing Aim 57 closes 57g's Variables tab, and the // editor then shows whatever document earlier steps left open, not the scene view. await t.showTab('scene'); await evaluate('__gm.state.select(null)'); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('or57-')); p.assets = p.assets.filter((a) => !a.id.startsWith('or57-')); `); }