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); }); });