Clip the children of an object to a clip area (#260)
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:
1 parent
89aa92d2e3
commit
0c41cb97b1
32 files changed
+759
-27
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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-'));
|
||||
`);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
Reference in new issue
Block a user