Files
feuerwerk/tests/core/clip-areas.test.ts
adminandClaude Opus 5.5 0c41cb97b1
game-maker / gate (push) Successful in 3m43s
game-maker / suite (push) Failing after 11m7s
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>
2026-09-29 07:08:00 +02:00

202 lines
8.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { findScene } from '../../src/core/model/Project';
import { visibilityParts } from '../../src/core/model/sceneVisibility';
import type { Vec2 } from '../../src/core/model/Properties';
import {
type ClipNode,
clipRegions,
intersectConvex,
opensClip,
regionContains,
} from '../../src/core/properties/clip';
import { schemaFor } from '../../src/core/properties/PropertySchema';
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
import { wheelNotches } from '../../src/viewport/view';
import {
graphEditor,
makeBehavior,
makeSpriteScene,
place,
roundTrip,
type SceneFixture,
startWorld,
} from '../helpers';
const square = (cx: number, cy: number, half: number): Vec2[] => [
{ x: cx - half, y: cy - half },
{ x: cx + half, y: cy - half },
{ x: cx + half, y: cy + half },
{ x: cx - half, y: cy + half },
];
/** A 100×100 clip area at the origin with a 100×100 sprite below it at `(80, 0)`: x 30…50 is left. */
function clippedButton(): SceneFixture & { clipId: string; clip: string; button: string } {
const f = makeSpriteScene('Button');
const create = new CreateAssetCommand('clip', 'Clip');
f.stack.execute(create);
const clip = place(f, create.asset.id, { x: 0, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, clip, 'width', 100));
f.stack.execute(new SetOverrideCommand(f.sceneId, clip, 'height', 100));
const button = place(f, f.spriteId, { x: 80, y: 0 }, clip);
return { ...f, clipId: create.asset.id, clip, button };
}
function itemsOf(f: SceneFixture) {
return collectHolderItems(f.project, findScene(f.project, f.sceneId), 0).items;
}
describe('clip regions', () => {
it('intersects convex polygons whatever their winding', () => {
const cut = intersectConvex(square(0, 0, 50), [...square(80, 0, 50)].reverse());
expect(regionContains(cut, { x: 40, y: 0 })).toBe(true);
expect(regionContains(cut, { x: 60, y: 0 })).toBe(false);
expect(regionContains(cut, { x: 20, y: 0 })).toBe(false);
expect(intersectConvex(square(0, 0, 10), square(100, 0, 10))).toEqual([]);
});
it('cuts everything below an area, at any depth, and nested areas intersect', () => {
type Node = ClipNode & { id: string; parent: string };
const at = (x: number) => ({ a: 1, b: 0, c: 0, d: 1, tx: x, ty: 0 });
const nodes: Node[] = [
{ id: 'outer', parent: '', type: 'clip', props: { width: 100, height: 100 }, m: at(0) },
{ id: 'inner', parent: 'outer', type: 'clip', props: { width: 100, height: 100 }, m: at(80) },
{ id: 'deep', parent: 'inner', type: 'sprite', props: {}, m: at(0) },
{ id: 'free', parent: '', type: 'sprite', props: {}, m: at(0) },
];
const byId = new Map(nodes.map((n) => [n.id, n]));
const regions = clipRegions(nodes, (n) => byId.get(n.parent));
const region = (id: string) => regions.get(byId.get(id)!);
expect(region('outer')).toBeNull();
expect(region('free')).toBeNull();
// The inner area is cut by the outer one only, not by itself.
expect(regionContains(region('inner')!, { x: -40, y: 0 })).toBe(true);
expect(regionContains(region('deep')!, { x: 40, y: 0 })).toBe(true);
expect(regionContains(region('deep')!, { x: -10, y: 0 })).toBe(false);
expect(regionContains(region('deep')!, { x: 60, y: 0 })).toBe(false);
});
it('gives a placed object the region of the areas above it, turned with them', () => {
const f = clippedButton();
const child = place(f, f.spriteId, { x: 0, y: 0 }, f.button);
const clipOf = (id: string) => itemsOf(f).find((i) => i.id === id)!.clip;
expect(clipOf(f.clip)).toBeNull();
expect(regionContains(clipOf(child)!, { x: 49, y: 49 })).toBe(true);
f.stack.execute(new SetOverrideCommand(f.sceneId, f.clip, 'rotation', 45));
// A corner of the upright square is outside the turned one, the tip of the diamond is inside.
expect(regionContains(clipOf(f.button)!, { x: 49, y: 49 })).toBe(false);
expect(regionContains(clipOf(f.button)!, { x: 70, y: 0 })).toBe(true);
});
it('cuts in the running game too, where the area is not drawn itself', () => {
const f = clippedButton();
const { world } = startWorld(f);
const items = collectWorldItems(world).items;
expect(items.some((i) => i.id === f.clip)).toBe(false);
const button = items.find((i) => i.id === f.button)!;
expect(regionContains(button.clip!, { x: 40, y: 0 })).toBe(true);
expect(regionContains(button.clip!, { x: 70, y: 0 })).toBe(false);
});
it('survives saving and loading', () => {
const f = clippedButton();
const loaded = roundTrip(f.project);
const items = collectHolderItems(loaded, findScene(loaded, f.sceneId), 0).items;
expect(items.find((i) => i.id === f.clip)?.type).toBe('clip');
expect(regionContains(items.find((i) => i.id === f.button)!.clip!, { x: 70, y: 0 })).toBe(false);
});
it('is a turnable, sizable area with an outline the eye can hide', () => {
const keys = schemaFor('clip').map((d) => d.key);
expect(keys).toEqual(expect.arrayContaining(['enabled', 'rotation', 'scale', 'width', 'height']));
expect(visibilityParts('clip')).toEqual(['label', 'shape']);
});
it('opens for what is selected and everything below it', () => {
const items = [
{ id: 'area', parentId: '' },
{ id: 'row', parentId: 'area' },
{ id: 'slot', parentId: 'row' },
];
const byId = new Map(items.map((i) => [i.id, i]));
const parentOf = (i: { parentId: string }) => byId.get(i.parentId);
expect(opensClip(items[2]!, ['area'], parentOf)).toBe(true);
expect(opensClip(items[2]!, ['row'], parentOf)).toBe(true);
expect(opensClip(items[1]!, ['slot'], parentOf)).toBe(false);
expect(opensClip(items[2]!, [], parentOf)).toBe(false);
});
});
describe('clicks and the wheel on cut objects', () => {
it('does not hit the part an area cuts off', () => {
const f = clippedButton();
const { world } = startWorld(f);
const button = world.findByObjectId(f.button)!;
expect(world.hitTest(button, 40, 0)).toBe(true);
expect(world.hitTest(button, 70, 0)).toBe(false);
});
/** On mouse wheel → turned = turned + delta, on a behavior below the button. */
function wheelOnButton(owned = true) {
const f = clippedButton();
const behavior = makeBehavior(f, 'Scroll', ['turned']);
const edit = graphEditor(f, behavior);
const wheel = edit.addNode('OnMouseWheel');
const get = edit.variables(['turned']);
const add = edit.addNode('Add');
const set = edit.variables(['turned'], { write: true });
edit.wire([
[wheel, 'exec', set, 'exec'],
[get, 'turned', add, 'a'],
[wheel, 'delta', add, 'b'],
[add, 'result', set, 'turned'],
]);
const logic = place(f, behavior.id, { x: 0, y: 0 }, owned ? f.button : '');
const started = startWorld(f, { behaviors: true });
return { ...started, turned: () => started.world.findByObjectId(logic)!.props.turned };
}
it('fires On mouse wheel over the owner with what turned in the tick', () => {
const { world, turned } = wheelOnButton();
world.movePointer(40, 0, true);
world.turnWheel(1);
world.turnWheel(0.5);
world.tick(0.1);
expect(turned()).toBe(1.5);
expect(world.pointer.wheel).toBe(0);
world.tick(0.1);
expect(turned()).toBe(1.5);
});
it('does not fire beside the owner, nor on its cut-off part', () => {
const { world, turned } = wheelOnButton();
world.movePointer(500, 500, true);
world.turnWheel(1);
world.tick(0.1);
world.movePointer(70, 0, true);
world.turnWheel(1);
world.tick(0.1);
expect(turned()).toBe(0);
});
it('fires anywhere in the view without an owner', () => {
const { world, turned } = wheelOnButton(false);
world.movePointer(500, 500, true);
world.turnWheel(-2);
world.tick(0.1);
expect(turned()).toBe(-2);
world.movePointer(500, 500, false);
world.turnWheel(1);
world.tick(0.1);
expect(turned()).toBe(-2);
});
it('counts a mouse notch as one whatever the unit the browser reports in', () => {
expect(wheelNotches(100, 0)).toBe(1);
expect(wheelNotches(-3, 1)).toBe(-1);
expect(wheelNotches(-240, 2)).toBe(-1);
expect(wheelNotches(25, 0)).toBe(0.25);
});
});