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>
202 lines
8.3 KiB
TypeScript
202 lines
8.3 KiB
TypeScript
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);
|
||
});
|
||
});
|