An inventory grid could not scroll, because nothing could cut a child off at an edge. A game also had no way to hear the mouse wheel. Both are needed for a scrolling list, a dialog text or a window with more content than room. - A new object type Clip area has width and height and turns, scales and mirrors with its parent. The size gizmo drags width and height. - Everything below a clip area, on every level, is drawn only inside its rectangle, in the Scene view as in the game. Nested areas cut to their intersection. The rule works on a convex polygon, so a turned area cuts right as well. - On click, Is hovered and On mouse wheel do not hit a cut-off part. The Scene view does not pick it either. - In the Scene view the area is a dashed frame that is grabbed only at its edge, so a click inside lands on the content. The eye under Shape hides the frame. In the game the area is invisible. - While an object below an area or one of its parents is selected, the Scene view shows the cut-off part at 30 % opacity and it can be grabbed there. The rule lives in core/properties/clip.ts. - The Properties node knows the type Clip area. - The new event node On mouse wheel fires by the rule of On click. Its delta counts in notches, positive is down, and a frame's turns arrive as one value. It also gives x and y. Limits: - Only sprites and texts show the faded rest. Tilemaps and particles are cut and can be grabbed when open, but are not shown faded. - Masks by texture shape are out, as noted in the issue. Tests: - ./check.sh: 2773 tests, CHECK EXIT 0. - Full e2e run (local): ALL PASSED, 1549 checks, measured. - tests/core/clip-areas.test.ts: 12 tests for the cut rule, scene and game, save and load, hits, the wheel with and without owner, the notch conversion and the open rule. - e2e 58, 5 checks with real mouse and wheel: the scene cuts (74 of 74 red inside, 0 outside), a click on the cut part picks nothing, the selected rest is faded and grabbable, the game cuts, one notch logs 1. - e2e 04 and 46 count the object types and the eye rows. They now include Clip area. - Counter-checks: 6 in unit tests and 3 in the browser, all fail as expected. The originals were restored and checked with cmp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
6.9 KiB
JavaScript
166 lines
6.9 KiB
JavaScript
/**
|
||
* Clip areas (#260): a red bar below a 100×100 clip area is cut at the area's edge in the scene
|
||
* view and in the game, and the cut-off part cannot be clicked. Selected, the bar shows that part
|
||
* faintly and can be grabbed there. A turn of the real mouse wheel over the game reaches `On mouse
|
||
* wheel`.
|
||
*
|
||
* Everything it adds carries a `cl58` id and goes again at the end.
|
||
*/
|
||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||
|
||
const BAR = 'cl58-bar';
|
||
const AREA = 'cl58-clip';
|
||
|
||
export async function clipAreas(t) {
|
||
const { check, json, evaluate, mutate, click, sleep, shot, stop, wheel } = t;
|
||
|
||
// On mouse wheel → Log(delta), with no owner: it hears the whole game view.
|
||
const graph = {
|
||
nodes: [
|
||
{ id: 'cl58-wheel', type: 'OnMouseWheel', x: 0, y: 0, params: {} },
|
||
{ id: 'cl58-log', type: 'Log', x: 320, y: 0, params: {} },
|
||
],
|
||
edges: [
|
||
{ id: 'cl58-e1', from: { node: 'cl58-wheel', port: 'exec' }, to: { node: 'cl58-log', port: 'exec' } },
|
||
{
|
||
id: 'cl58-e2',
|
||
from: { node: 'cl58-wheel', port: 'delta' },
|
||
to: { node: 'cl58-log', port: 'message' },
|
||
},
|
||
],
|
||
};
|
||
// Inside the camera's frame, so the game shows it too, but off the camera's own icon and in front
|
||
// of what earlier steps left there.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
const cam = scene.objects.find((o) => o.id === 'o3').position;
|
||
p.assets.push({ id: 'cl58-area', type: 'clip', name: 'Clip 58', props: { enabled: true, width: 100, height: 100 } });
|
||
p.assets.push({ id: 'cl58-scroll', type: 'behavior', name: 'Wheel 58', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(graph)} });
|
||
${MAKE_OBJECT}
|
||
const sprite = p.assets.find((a) => a.type === 'sprite');
|
||
const area = make('${AREA}', 'cl58-area', '', 0, 'Clip 58');
|
||
Object.assign(area.position, { x: cam.x - 180, y: cam.y - 110, z: 1000 });
|
||
const bar = make('${BAR}', sprite.id, '${AREA}', 0, 'Bar 58');
|
||
Object.assign(bar.position, { x: 50, y: 0, z: 0 });
|
||
bar.overrides = { width: 160, height: 40, color: 0xff0000, texture: '' };
|
||
const logic = make('cl58-logic', 'cl58-scroll', '', 0, 'Wheel 58');
|
||
Object.assign(logic.position, { x: cam.x + 200, y: cam.y - 150 });
|
||
scene.objects.push(area, bar, logic);
|
||
`);
|
||
await evaluate('__gm.state.select(null)');
|
||
await json(`(() => {
|
||
const cam = __gm.state.activeScene.objects.find((o) => o.id === 'o3').position;
|
||
__gm.lookAt(cam.x, cam.y);
|
||
})()`);
|
||
await sleep(300);
|
||
|
||
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||
/**
|
||
* Red across the bar's middle, split at the area's right edge: full red inside, and how far the
|
||
* red goes past it. A few pixels either side of the edge are left out – they blend – and the row
|
||
* runs below the middle, clear of the handles a selection puts on the sides.
|
||
*/
|
||
const across = async (view) => {
|
||
const area = await json(`__gm.${view}(${JSON.stringify(AREA)})`);
|
||
const bar = await json(`__gm.${view}(${JSON.stringify(BAR)})`);
|
||
if (!bar) return null;
|
||
const left = Math.round(bar.x - bar.w / 2 + 3);
|
||
const edge = area ? area.x + area.w / 2 : bar.x - bar.w / 2 + (bar.w * 80) / 160;
|
||
const row = await json(
|
||
`__gm.pixelRow(${left}, ${Math.round(bar.y + bar.h / 4)}, ${Math.round(bar.w - 6)})`,
|
||
);
|
||
const inside = row.slice(0, Math.max(0, Math.round(edge - left - 3))).map(channels);
|
||
const outside = row.slice(Math.round(edge - left + 3)).map(channels);
|
||
const red = (px) => px.filter(([r, g, b]) => r > 200 && g < 60 && b < 60).length;
|
||
const tinted = (px) => px.filter(([r, g, b]) => r - Math.max(g, b) > 30).length;
|
||
return {
|
||
edge: Math.round(edge),
|
||
inside: inside.length,
|
||
redInside: red(inside),
|
||
outside: outside.length,
|
||
redOutside: red(outside),
|
||
tintedOutside: tinted(outside),
|
||
};
|
||
};
|
||
|
||
// 58a. The scene view cuts the bar at the area's edge.
|
||
const cut = await across('objectRect');
|
||
await shot('58-scene-cut');
|
||
check(
|
||
'the scene view draws a child only inside its clip area',
|
||
cut !== null && cut.redInside > 0.9 * cut.inside && cut.tintedOutside === 0 && cut.outside > 20,
|
||
JSON.stringify(cut),
|
||
);
|
||
|
||
// 58b. A click on the cut-off part does not pick the bar; one on the part left does.
|
||
const bar = await json(`__gm.objectRect(${JSON.stringify(BAR)})`);
|
||
const cutPoint = { x: Math.round(cut.edge + (bar.x + bar.w / 2 - cut.edge) / 2), y: Math.round(bar.y) };
|
||
const keptPoint = { x: Math.round(bar.x - bar.w / 2 + 10), y: Math.round(bar.y) };
|
||
const primary = () =>
|
||
json("__gm.state.selection?.kind === 'object' ? __gm.state.selection.objectId : null");
|
||
const stackCut = await json(`__gm.sceneStack(${cutPoint.x}, ${cutPoint.y})`);
|
||
await click(cutPoint.x, cutPoint.y);
|
||
await sleep(150);
|
||
const afterCut = await primary();
|
||
await evaluate('__gm.state.select(null)');
|
||
await click(keptPoint.x, keptPoint.y);
|
||
await sleep(150);
|
||
const afterKept = await primary();
|
||
check(
|
||
'the cut-off part cannot be clicked, the part left can',
|
||
!stackCut.includes(BAR) && afterCut !== BAR && afterKept === BAR,
|
||
JSON.stringify({ stackCut, afterCut, afterKept }),
|
||
);
|
||
|
||
// 58c. Selected, the cut-off part shows faintly and can be grabbed there.
|
||
const open = await across('objectRect');
|
||
const stackOpen = await json(`__gm.sceneStack(${cutPoint.x}, ${cutPoint.y})`);
|
||
await shot('58-scene-open');
|
||
check(
|
||
'a selected child shows its cut-off part faintly and can be grabbed there',
|
||
open.redInside > 0.9 * open.inside &&
|
||
open.tintedOutside > 0.9 * open.outside &&
|
||
open.redOutside === 0 &&
|
||
stackOpen.includes(BAR),
|
||
JSON.stringify({ open, stackOpen }),
|
||
);
|
||
await evaluate('__gm.state.select(null)');
|
||
|
||
// 58d. The game cuts the same way.
|
||
await t.play();
|
||
await sleep(600);
|
||
const played = await across('gameRect');
|
||
await shot('58-game');
|
||
check(
|
||
'the running game draws the child only inside its clip area',
|
||
played !== null && played.redInside > 0.9 * played.inside && played.tintedOutside === 0,
|
||
JSON.stringify(played),
|
||
);
|
||
|
||
// 58e. A notch of the real wheel over the game reaches On mouse wheel as delta 1.
|
||
const logFrom = t.consoleLog.length;
|
||
const centre = await json(`__gm.gameRect(${JSON.stringify(BAR)})`);
|
||
await wheel(Math.round(centre.x), Math.round(centre.y), 100);
|
||
await sleep(300);
|
||
const logged = t.consoleLog
|
||
.slice(logFrom)
|
||
.filter((line) => line.startsWith('[behavior Wheel 58]'))
|
||
.map((line) => line.split(']').pop().trim());
|
||
check(
|
||
'a notch of the mouse wheel over the game runs On mouse wheel with delta 1',
|
||
logged.includes('1'),
|
||
JSON.stringify(logged),
|
||
);
|
||
await stop();
|
||
|
||
// Clean up.
|
||
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('cl58-'));
|
||
p.assets = p.assets.filter((a) => !a.id.startsWith('cl58-'));
|
||
`);
|
||
}
|