Clip the children of an object to a clip area (#260)
game-maker / gate (push) Successful in 3m43s
game-maker / suite (push) Failing after 11m7s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 07:08:00 +02:00
1 parent 89aa92d2e3
commit 0c41cb97b1
32 files changed
+759 -27

No files matched your search

+3 -3
View File
@@ -81,7 +81,7 @@ export async function assets(t) {
// 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 twenty-two types without scrolling.
// tall enough to show every type without scrolling.
await clickText('+');
const rows = await json('__gm.menuRows()');
const cut = rows.indexOf(null);
@@ -94,7 +94,7 @@ export async function assets(t) {
);
check(
'every asset type is offered once, each group sorted by label',
placeable.length + library.length === 23 &&
placeable.length + library.length === 24 &&
String(placeable) === String([...placeable].sort()) &&
String(library) === String([...library].sort()),
JSON.stringify({ placeable, library }),
@@ -453,7 +453,7 @@ export async function assets(t) {
async function addMenuOnRightClick(t) {
const { check, json, shot, click, clickText, press, undo, showTab } = t;
const addRows = (rows) =>
Array.isArray(rows) && rows.filter(Boolean).length === 23 && rows.includes('Sprite');
Array.isArray(rows) && rows.filter(Boolean).length === 24 && rows.includes('Sprite');
const heading = await json("__gm.assetRowShown('group:scene')");
const sceneFolded = async () =>
!(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded;
+4 -2
View File
@@ -211,11 +211,12 @@ export async function topbar(t) {
const entry = (list, type, part) => list?.find((e) => e.type === type && e.part === part);
const types = [...new Set((popup ?? []).map((e) => e.type))];
check(
'the eye lists every placeable type, Symbol greyed out where there is none and Shape on collider and tilemap',
'the eye lists every placeable type, Symbol greyed out where there is none and Shape on collider, tilemap and clip area',
JSON.stringify(types) ===
JSON.stringify([
'behavior',
'camera',
'clip',
'collider',
'container',
'particles',
@@ -229,7 +230,8 @@ export async function topbar(t) {
entry(popup, 'sprite', 'label')?.on === false &&
entry(popup, 'collider', 'shape')?.on === true &&
entry(popup, 'tilemap', 'shape')?.on === true &&
popup.filter((e) => e.part === 'shape').length === 2,
entry(popup, 'clip', 'shape')?.on === true &&
popup.filter((e) => e.part === 'shape').length === 3,
JSON.stringify(popup),
);
const flip = async (type, part) => {
+165
View File
@@ -0,0 +1,165 @@
/**
* 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-'));
`);
}
+2
View File
@@ -60,6 +60,7 @@ import { structTypes } from './54-struct-types.mjs';
import { typedLists } from './55-typed-lists.mjs';
import { privateVariables } from './56-private-variables.mjs';
import { objectReferences } from './57-object-references.mjs';
import { clipAreas } from './58-clip-areas.mjs';
export const STEPS = [
hierarchy,
@@ -119,4 +120,5 @@ export const STEPS = [
typedLists,
privateVariables,
objectReferences,
clipAreas,
];