From 0c41cb97b14e501b269f4a260e0b774eb06847ae Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 29 Sep 2026 07:08:00 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/commands/assetCommands.ts | 2 + src/core/graph/NodeContext.ts | 2 + src/core/graph/nodes/events/OnMouseWheel.ts | 15 ++ src/core/graph/nodes/index.ts | 2 + src/core/graph/propertyPorts.ts | 1 + src/core/model/Asset.ts | 6 + src/core/model/AssetTypes.ts | 7 +- src/core/model/sceneVisibility.ts | 3 +- src/core/properties/PropertySchema.ts | 6 + src/core/properties/bounds.ts | 1 + src/core/properties/clip.ts | 119 ++++++++++++ src/editor/panels/assetGroups.ts | 1 + src/editor/panels/assetIcons.ts | 2 + src/editor/panels/icons/clip.svg | 4 + src/editor/viewport/GameViewport.ts | 14 +- src/editor/viewport/SceneViewport.ts | 22 ++- src/editor/viewport/WorldViewport.ts | 115 ++++++++++- src/editor/viewport/sizeGizmoEditor.ts | 4 +- src/runtime/World.ts | 18 +- src/runtime/systems/BehaviorSystem.ts | 3 + src/viewport/items.ts | 21 +- src/viewport/symbols.ts | 23 +++ src/viewport/view.ts | 10 + tests/core/clip-areas.test.ts | 201 ++++++++++++++++++++ tests/core/node-catalog.test.ts | 1 + tests/editor/scene-visibility.test.ts | 1 + tests/helpers.ts | 1 + tools/e2e/04-assets.mjs | 6 +- tools/e2e/46-topbar.mjs | 6 +- tools/e2e/58-clip-areas.mjs | 165 ++++++++++++++++ tools/e2e/index.mjs | 2 + 32 files changed, 759 insertions(+), 27 deletions(-) create mode 100644 src/core/graph/nodes/events/OnMouseWheel.ts create mode 100644 src/core/properties/clip.ts create mode 100644 src/editor/panels/icons/clip.svg create mode 100644 tests/core/clip-areas.test.ts create mode 100644 tools/e2e/58-clip-areas.mjs diff --git a/README.md b/README.md index 84b181a9..718ad416 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1544 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1549 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 55690ff8..c7a5ab05 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -41,6 +41,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { return { ...base, type: 'scene', objects: [] }; case 'camera': return { ...base, type: 'camera' }; + case 'clip': + return { ...base, type: 'clip' }; case 'sprite': return { ...base, type: 'sprite' }; case 'text': diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index a493a533..53f68fa0 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -70,6 +70,8 @@ export interface PointerState { down: boolean; pressed: boolean; released: boolean; + /** How far the wheel turned this tick, in notches, positive downwards (see `wheelNotches`). */ + wheel: number; } /** diff --git a/src/core/graph/nodes/events/OnMouseWheel.ts b/src/core/graph/nodes/events/OnMouseWheel.ts new file mode 100644 index 00000000..04cf99d3 --- /dev/null +++ b/src/core/graph/nodes/events/OnMouseWheel.ts @@ -0,0 +1,15 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { exec, num } from '../shared'; + +export const OnMouseWheel: NodeDef = { + type: 'OnMouseWheel', + label: 'On mouse wheel', + category: 'event', + description: + 'Runs when the mouse wheel turns over the owner (or over anything parented below it) in the Game tab. ' + + 'Without an owner it runs on a turn anywhere in the game view. delta is in notches of a mouse wheel, ' + + 'positive downwards; a trackpad gives fractions.', + inputs: [], + outputs: [exec(), num('delta', 'notches, positive downwards'), num('x', 'world x'), num('y', 'world y')], + params: [], +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 2871ca4f..db2a7ea7 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -102,6 +102,7 @@ import { SubList } from './data/SubList'; import { TextureSize } from './data/TextureSize'; import { OnAnimationFinished } from './events/OnAnimationFinished'; import { OnClick } from './events/OnClick'; +import { OnMouseWheel } from './events/OnMouseWheel'; import { OnCollisionEnter } from './events/OnCollisionEnter'; import { OnCollisionExit } from './events/OnCollisionExit'; import { OnCollisionStay } from './events/OnCollisionStay'; @@ -152,6 +153,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ OnCollisionStay, OnCollisionExit, OnClick, + OnMouseWheel, OnAnimationFinished, OnStateEntered, OnStateExited, diff --git a/src/core/graph/propertyPorts.ts b/src/core/graph/propertyPorts.ts index 8068c85b..ce5d9f67 100644 --- a/src/core/graph/propertyPorts.ts +++ b/src/core/graph/propertyPorts.ts @@ -22,6 +22,7 @@ export const PROPERTY_TARGETS = [ 'rigidbody', 'camera', 'container', + 'clip', 'behavior', ] as const; export type PropertyTarget = (typeof PROPERTY_TARGETS)[number]; diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 6dc06b20..a4b12ce7 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -20,6 +20,11 @@ export interface CameraAsset extends AssetBase { type: 'camera'; } +/** A rectangle its children are drawn and hit inside only (#260); nothing of its own is drawn. */ +export interface ClipAsset extends AssetBase { + type: 'clip'; +} + export interface TextAsset extends AssetBase { type: 'text'; } @@ -488,6 +493,7 @@ export interface ParticlesAsset extends AssetBase { export type Asset = | SpriteAsset | CameraAsset + | ClipAsset | TextAsset | SceneAsset | BehaviorAsset diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index dea697de..8591355c 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -9,6 +9,7 @@ export const ASSET_TYPES = [ 'collider', 'rigidbody', 'container', + 'clip', 'texture', 'vectorart', 'pixelart', @@ -37,6 +38,7 @@ export const ASSET_TYPE_LABELS: Record = { collider: 'Collider', rigidbody: 'Rigidbody', container: 'Container', + clip: 'Clip area', texture: 'Texture', vectorart: 'Vector art', pixelart: 'Pixel art', @@ -111,7 +113,8 @@ export function isSymbolType(type: AssetType): boolean { type === 'behavior' || type === 'collider' || type === 'rigidbody' || - type === 'container' + type === 'container' || + type === 'clip' ); } @@ -121,5 +124,5 @@ export function isSymbolType(type: AssetType): boolean { * throws a thing out of the game and out of the draw order. Only the icon is editor decoration. */ export function hasSceneIcon(type: AssetType): boolean { - return isSymbolType(type) || type === 'particles'; + return (isSymbolType(type) && type !== 'clip') || type === 'particles'; } diff --git a/src/core/model/sceneVisibility.ts b/src/core/model/sceneVisibility.ts index 1df66a19..70dd03ac 100644 --- a/src/core/model/sceneVisibility.ts +++ b/src/core/model/sceneVisibility.ts @@ -15,6 +15,7 @@ export type SceneVisibility = Record; export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [ 'behavior', 'camera', + 'clip', 'collider', 'container', 'particles', @@ -31,7 +32,7 @@ export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [ */ export function visibilityParts(type: PlaceableAssetType): VisibilityPart[] { const parts: VisibilityPart[] = hasSceneIcon(type) ? ['symbol', 'label'] : ['label']; - if (type === 'collider' || type === 'tilemap') parts.push('shape'); + if (type === 'collider' || type === 'tilemap' || type === 'clip') parts.push('shape'); return parts; } diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 5055e640..fc839038 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -259,6 +259,7 @@ const TRANSFORM_TYPES: ReadonlySet = new Set([ 'text', 'collider', 'container', + 'clip', 'particles', 'tilemap', ]); @@ -626,6 +627,11 @@ const ASSET_SCHEMAS: Record = { ], // A container is pure structure: its content is the template, not a property. container: [], + // What its children are cut to (#260). Units, not pixels: it turns and grows with its parent. + clip: [ + { key: 'width', label: 'Width', kind: 'number', default: 200, min: 1, step: 1 }, + { key: 'height', label: 'Height', kind: 'number', default: 150, min: 1, step: 1 }, + ], text: [ { key: 'text', label: 'Text', kind: 'text', default: 'Text' }, // Empty is the editor's own typeface, the one every text was drawn in before fonts existed. diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index ab1539a6..aee04888 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -61,6 +61,7 @@ export function footprintOf( ): { w: number; h: number } { switch (type) { case 'sprite': + case 'clip': return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) }; case 'text': return textSize(props); diff --git a/src/core/properties/clip.ts b/src/core/properties/clip.ts new file mode 100644 index 00000000..8bcecc65 --- /dev/null +++ b/src/core/properties/clip.ts @@ -0,0 +1,119 @@ +/** + * Clip areas (#260): an object of type `clip` cuts everything below it, at any depth, to its own + * rectangle – drawn and hit alike. Areas above one another intersect. A rectangle that turns, + * grows or mirrors with its parent stays convex, so the region is always one convex polygon. + */ +import type { AssetType } from '../model/AssetTypes'; +import type { Mat } from '../model/matrix'; +import type { PropertyBag, Vec2 } from '../model/Properties'; +import { footprintCorners } from './bounds'; + +/** A convex polygon in world units; empty when nothing of the object is left to see. */ +export type ClipRegion = readonly Vec2[]; + +export interface ClipNode { + type: AssetType; + props: PropertyBag; + m: Mat; +} + +/** The area's own rectangle in world units. */ +export function clipCorners(props: PropertyBag, m: Mat): Vec2[] { + return footprintCorners('clip', props, m); +} + +function signedArea(polygon: readonly Vec2[]): number { + let area = 0; + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]!; + const b = polygon[(i + 1) % polygon.length]!; + area += a.x * b.y - b.x * a.y; + } + return area / 2; +} + +/** Which side of the edge a→b the point is on, turned so that inside is positive whatever the winding. */ +function side(a: Vec2, b: Vec2, p: Vec2, winding: number): number { + return ((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) * winding; +} + +/** Sutherland–Hodgman: `subject` cut by every edge of the convex `clip`. */ +export function intersectConvex(subject: readonly Vec2[], clip: readonly Vec2[]): Vec2[] { + const winding = Math.sign(signedArea(clip)); + if (winding === 0) return []; + let out: Vec2[] = [...subject]; + for (let i = 0; i < clip.length && out.length > 0; i++) { + const a = clip[i]!; + const b = clip[(i + 1) % clip.length]!; + const input = out; + out = []; + for (let j = 0; j < input.length; j++) { + const p = input[j]!; + const q = input[(j + 1) % input.length]!; + const sp = side(a, b, p, winding); + const sq = side(a, b, q, winding); + if (sp >= 0) out.push(p); + if (sp >= 0 !== sq >= 0) { + const t = sp / (sp - sq); + out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t }); + } + } + } + return out.length >= 3 && signedArea(out) !== 0 ? out : []; +} + +/** Whether a world point lies inside the region, edge included. */ +export function regionContains(region: ClipRegion, p: Vec2): boolean { + if (region.length < 3) return false; + const winding = Math.sign(signedArea(region)); + for (let i = 0; i < region.length; i++) + if (side(region[i]!, region[(i + 1) % region.length]!, p, winding) < 0) return false; + return true; +} + +/** + * The region each object is cut to: null for one with no clip area above it. The area itself is + * not cut by its own rectangle, only by those above it. + */ +export function clipRegions( + items: readonly T[], + parentOf: (item: T) => T | null | undefined, +): Map { + const regions = new Map(); + const regionOf = (item: T): ClipRegion | null => { + if (regions.has(item)) return regions.get(item)!; + // Set first, so a broken hierarchy that loops ends here instead of overflowing the stack. + regions.set(item, null); + const parent = parentOf(item); + let region: ClipRegion | null = null; + if (parent) { + const above = regionOf(parent); + if (parent.type !== 'clip') region = above; + else { + const own = clipCorners(parent.props, parent.m); + region = above ? intersectConvex(own, above) : own; + } + } + regions.set(item, region); + return region; + }; + for (const item of items) regionOf(item); + return regions; +} + +/** + * Whether the scene view opens the clip areas above `item` (#260): when it, or an object above it, + * is selected. Selecting the area opens all it cuts, selecting one row of a list opens that row. + */ +export function opensClip( + item: T, + selected: readonly string[], + parentOf: (item: T) => T | undefined, +): boolean { + const seen = new Set(); + for (let at: T | undefined = item; at && !seen.has(at); at = parentOf(at)) { + if (selected.includes(at.id)) return true; + seen.add(at); + } + return false; +} diff --git a/src/editor/panels/assetGroups.ts b/src/editor/panels/assetGroups.ts index a62ba4d2..f99f78f8 100644 --- a/src/editor/panels/assetGroups.ts +++ b/src/editor/panels/assetGroups.ts @@ -16,6 +16,7 @@ export const ASSET_GROUP_LABELS: Record = { collider: 'Colliders', rigidbody: 'Rigidbodies', container: 'Containers', + clip: 'Clip areas', texture: 'Textures', // Not "Vector art": that is the `+` menu entry, and a heading of the same words would take the // click meant for the menu (see the test below the labels). diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index 1ffe196d..86c8915b 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -6,6 +6,7 @@ import type { AssetType } from '../../core/model/AssetTypes'; import animation from './icons/animation.svg?raw'; import behavior from './icons/behavior.svg?raw'; import camera from './icons/camera.svg?raw'; +import clip from './icons/clip.svg?raw'; import collider from './icons/collider.svg?raw'; import container from './icons/container.svg?raw'; import curve from './icons/curve.svg?raw'; @@ -36,6 +37,7 @@ export const ASSET_ICON_SVG: Record = { animation, behavior, camera, + clip, collider, container, curve, diff --git a/src/editor/panels/icons/clip.svg b/src/editor/panels/icons/clip.svg new file mode 100644 index 00000000..2a2513fe --- /dev/null +++ b/src/editor/panels/icons/clip.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts index a1ba27b1..ddf2072a 100644 --- a/src/editor/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -2,7 +2,12 @@ * The scene seen through its active camera at the chosen scale, letterboxed: the running world while * playing, the model as a still preview otherwise. */ -import { type FederatedPointerEvent, Text } from 'pixi.js'; +import { + type FederatedMouseEvent, + type FederatedPointerEvent, + type FederatedWheelEvent, + Text, +} from 'pixi.js'; import { isSymbolType } from '../../core/model/AssetTypes'; import type { EditorState } from '../state'; import type { World } from '../../runtime/World'; @@ -16,6 +21,7 @@ import { isInside, screenToWorld, type View, + wheelNotches, } from '../../viewport/view'; import { type Frame, WorldViewport } from './WorldViewport'; @@ -49,6 +55,10 @@ export class GameViewport extends WorldViewport { this.on('globalpointermove', (e: FederatedPointerEvent) => this.reportPointer(e)); this.on('pointerup', () => this.pointerWorld()?.setPointerDown(false)); this.on('pointerupoutside', () => this.pointerWorld()?.setPointerDown(false)); + this.on('wheel', (e: FederatedWheelEvent) => { + this.reportPointer(e); + this.pointerWorld()?.turnWheel(wheelNotches(e.deltaY, e.deltaMode)); + }); } /** null = not playing. */ @@ -147,7 +157,7 @@ export class GameViewport extends WorldViewport { } /** Outside the camera frame counts as "not in view": that region is letterbox, not game. */ - private reportPointer(e: FederatedPointerEvent): void { + private reportPointer(e: FederatedMouseEvent): void { const world = this.pointerWorld(); const view = this.currentView(); if (!world || !view) return; diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index dc22a19d..70ade120 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -6,7 +6,8 @@ import { Container, type FederatedPointerEvent, Graphics, Rectangle, Text } from 'pixi.js'; import { oneStep } from '../../core/commands/CompoundCommand'; import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; -import { hasSceneIcon } from '../../core/model/AssetTypes'; +import { hasSceneIcon, isSymbolType } from '../../core/model/AssetTypes'; +import { opensClip, regionContains } from '../../core/properties/clip'; import { findObject, subtreeRoots } from '../../core/model/hierarchy'; import { objectRef, resolvedHolder } from '../../core/model/instance'; import { @@ -429,6 +430,19 @@ export class SceneViewport extends FreeViewport { return s?.kind === 'object' && s.objectIds.includes(objectId); } + /** A clip area opens for what is selected and everything below it (#260): the area itself, a row, a slot. */ + protected override opensClip(item: DrawItem): boolean { + const s = this.state.selection; + if (s?.kind !== 'object') return false; + if (this.itemsById.items !== this.lastItems) + this.itemsById = { items: this.lastItems, byId: new Map(this.lastItems.map((i) => [i.id, i])) }; + const { byId } = this.itemsById; + return opensClip(item, s.objectIds, (at) => byId.get(at.parentId)); + } + + /** `lastItems` by id, built once per redraw for `opensClip`. */ + private itemsById: { items: DrawItem[]; byId: Map } = { items: [], byId: new Map() }; + /** * Pixi only says which hit area was pressed; what is really under the pointer is worked out * here, in the order it is drawn (#203): a tilemap's grid is empty where it has no tile, and a @@ -519,6 +533,10 @@ export class SceneViewport extends FreeViewport { for (const item of this.lastItems) { const node = this.nodes.get(item.id); if (!node?.visible || node.eventMode === 'none') continue; + // What a clip area cuts off is not there to click, unless the area is open (#260). Symbols + // are drawn above everything, uncut. + if (item.clip && !isSymbolType(item.type) && !this.opensClip(item) && !regionContains(item.clip, world)) + continue; const p = node.toLocal(global); if ( !this.onIcon(item.id, global) && @@ -658,7 +676,7 @@ export class SceneViewport extends FreeViewport { /** A tilemap's shape switch hides only the outline of its solid cells, never the map itself. */ protected override hidesShape(item: DrawItem): boolean { - return item.type === 'collider' && !this.shows(item, 'shape'); + return (item.type === 'collider' || item.type === 'clip') && !this.shows(item, 'shape'); } protected override disposeDecorations(id: string): void { diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index fa1d9eaf..7c52c8ff 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -4,6 +4,7 @@ import { type Extent, scaleOf } from '../../core/model/matrix'; import type { Vec2 } from '../../core/model/Properties'; import { footprintExtent, footprintOf, placementOf } from '../../core/properties/bounds'; import { rectsIntersect } from '../../core/properties/camera'; +import type { ClipRegion } from '../../core/properties/clip'; import type { EditorState } from '../state'; import { type Size, Widget } from '../../ui'; import { fontLibrary } from '../../viewport/FontLibrary'; @@ -22,6 +23,9 @@ import { textureLibrary } from '../../viewport/TextureLibrary'; import { gameClock } from '../../core/time'; import { type View, worldBounds, worldToScreen } from '../../viewport/view'; +/** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */ +export const CLIP_GHOST_ALPHA = 0.3; + export interface Frame { items: DrawItem[]; background: number; @@ -45,6 +49,15 @@ export abstract class WorldViewport extends Widget { private readonly fields = new Map(); /** The meshes of each placed tilemap, one container per layer (#174). */ private readonly tilemaps = new Map(); + /** + * What clip areas cut off (#260), as a mask per drawn thing: Pixi lets a mask serve only one, and + * switches a shared one back on as content once any of them lets go of it. + */ + private readonly masks = new Container(); + private readonly maskOf = new Map(); + /** The cut-off part of a sprite or text, drawn faintly under it while its clip area is open. */ + private readonly ghostSprites = new Map(); + private readonly ghostTexts = new Map(); private visibleCount = 0; private animated = false; protected lastItems: DrawItem[] = []; @@ -66,7 +79,7 @@ export abstract class WorldViewport extends Widget { protected constructor(protected readonly state: EditorState) { super(); - this.world.addChild(this.objects, this.overlay); + this.world.addChild(this.masks, this.objects, this.overlay); this.content.addChild(this.world); this.addChild(this.bg, this.content, this.contentMask); this.content.mask = this.contentMask; @@ -129,6 +142,11 @@ export abstract class WorldViewport extends Widget { protected onItemPointerDown(_objectId: string, _e: FederatedPointerEvent): void {} + /** Whether the part a clip area cuts off the item is shown faintly and can be grabbed (#260). */ + protected opensClip(_item: DrawItem): boolean { + return false; + } + /** Whether the object itself is selected – a selected collider shows its area filled. */ protected isSelected(_objectId: string): boolean { return false; @@ -338,11 +356,13 @@ export abstract class WorldViewport extends Widget { this.fields.delete(id); this.tilemaps.get(id)?.destroy(); this.tilemaps.delete(id); + this.dropGhosts(id); node.destroy(); this.nodes.delete(id); this.disposeDecorations(id); } this.syncDrawOrder(items); + this.sweepMasks(); } /** @@ -352,25 +372,79 @@ export abstract class WorldViewport extends Widget { */ private syncDrawOrder(items: DrawItem[]): void { this.objects.removeChildren(); + const byId = new Map(items.map((item) => [item.id, item])); for (const { id, layerId, row } of drawSlots(items)) { + const clip = byId.get(id)?.clip ?? null; if (layerId) { const layer = this.tilemaps.get(id)?.layerContainer(layerId, row); - if (layer) this.objects.addChild(layer); + if (layer) this.objects.addChild(this.clipTo(layer, clip)); continue; } // Picture and text are siblings because Graphics may not have children: the picture right - // under its node, which only holds the hit area, the text right above its box. + // under its node, which only holds the hit area, the text right above its box. A ghost goes + // right under what it is the rest of. + const ghost = this.ghostSprites.get(id); + if (ghost) this.objects.addChild(this.clipTo(ghost, clip, true)); const sprite = this.sprites.get(id); - if (sprite) this.objects.addChild(sprite); + if (sprite) this.objects.addChild(this.clipTo(sprite, clip)); const node = this.nodes.get(id); if (node) this.objects.addChild(node); + const ghostText = this.ghostTexts.get(id); + if (ghostText) this.objects.addChild(this.clipTo(ghostText.mesh, clip, true)); const text = this.texts.get(id); - if (text) this.objects.addChild(text.mesh); + if (text) this.objects.addChild(this.clipTo(text.mesh, clip)); const field = this.fields.get(id); - if (field) this.objects.addChild(field.container); + if (field) this.objects.addChild(this.clipTo(field.container, clip)); } } + /** + * Cuts `target` to the region, or with `inverse` to everything outside it. The mask lives in + * world units like the objects, and stays with the target until the target goes. + */ + private clipTo(target: T, region: ClipRegion | null, inverse = false): T { + let mask = this.maskOf.get(target); + if (!region) { + if (mask) { + target.mask = null; + mask.destroy(); + this.maskOf.delete(target); + } + return target; + } + if (!mask) { + mask = new Graphics(); + this.masks.addChild(mask); + this.maskOf.set(target, mask); + target.setMask({ mask, inverse }); + } + mask.clear(); + if (region.length >= 3) mask.poly([...region]).fill(0xffffff); + return target; + } + + /** Masks whose target is gone: a tilemap drops the containers of layers it no longer has itself. */ + private sweepMasks(): void { + for (const [target, mask] of this.maskOf) + if (target.destroyed) { + mask.destroy(); + this.maskOf.delete(target); + } + } + + private dropGhosts(id: string): void { + const sprite = this.ghostSprites.get(id); + if (sprite) disposeSpriteMesh(sprite); + this.ghostSprites.delete(id); + this.ghostTexts.get(id)?.destroy(); + this.ghostTexts.delete(id); + } + + /** Whether the item has a cut-off part to show faintly right now. */ + private ghosted(item: DrawItem, onScreen: boolean): boolean { + return onScreen && item.clip !== null && this.opensClip(item); + } + /** * The particles of one source. Their positions are world units, like everything else under * `objects`, so the container itself carries no transform: the source's own goes into each @@ -462,6 +536,9 @@ export abstract class WorldViewport extends Widget { let sprite = this.sprites.get(item.id); if (item.type !== 'sprite' || !onScreen) { if (sprite) sprite.visible = false; + const ghost = this.ghostSprites.get(item.id); + if (ghost) disposeSpriteMesh(ghost); + this.ghostSprites.delete(item.id); return; } if (!sprite) { @@ -472,6 +549,18 @@ export abstract class WorldViewport extends Widget { sprite.visible = true; if (wornShader(item.props, shaders)?.time) this.animated = true; syncSpriteMesh(sprite, item, size, textureLibrary, shaders); + let ghost = this.ghostSprites.get(item.id); + if (!this.ghosted(item, onScreen)) { + if (ghost) disposeSpriteMesh(ghost); + this.ghostSprites.delete(item.id); + return; + } + if (!ghost) { + ghost = newSpriteMesh(); + ghost.alpha = CLIP_GHOST_ALPHA; + this.ghostSprites.set(item.id, ghost); + } + syncSpriteMesh(ghost, item, size, textureLibrary, shaders); } /** @@ -487,6 +576,8 @@ export abstract class WorldViewport extends Widget { let text = this.texts.get(item.id); if (item.type !== 'text' || !onScreen) { if (text) text.mesh.visible = false; + this.ghostTexts.get(item.id)?.destroy(); + this.ghostTexts.delete(item.id); return; } if (!text) { @@ -497,5 +588,17 @@ export abstract class WorldViewport extends Widget { text.mesh.visible = true; if (wornShader(item.props, shaders)?.time) this.animated = true; text.sync(item, scale, shaders); + let ghost = this.ghostTexts.get(item.id); + if (!this.ghosted(item, onScreen)) { + ghost?.destroy(); + this.ghostTexts.delete(item.id); + return; + } + if (!ghost) { + ghost = new TextPicture(); + ghost.mesh.alpha = CLIP_GHOST_ALPHA; + this.ghostTexts.set(item.id, ghost); + } + ghost.sync(item, scale, shaders); } } diff --git a/src/editor/viewport/sizeGizmoEditor.ts b/src/editor/viewport/sizeGizmoEditor.ts index 7469f099..eeb9ad72 100644 --- a/src/editor/viewport/sizeGizmoEditor.ts +++ b/src/editor/viewport/sizeGizmoEditor.ts @@ -340,9 +340,9 @@ export class SizeGizmoEditor { } } -/** Only sprites and colliders have a width and a height to pull at; everything else turns and scales. */ +/** Only sprites, colliders and clip areas have a width and a height to pull at; everything else turns and scales. */ function hasSizeHandles(item: DrawItem): boolean { - return item.type === 'sprite' || item.type === 'collider'; + return item.type === 'sprite' || item.type === 'collider' || item.type === 'clip'; } /** A polygon keeps its corners to itself, and a frame collider is the frame's size. */ diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 487ea317..b7f6d2ee 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -85,6 +85,7 @@ export class World { down: false, pressed: false, released: false, + wheel: 0, }; /** Objects the editor is dragging (ids): the physics puts them where the drag does, at rest. */ held: ReadonlySet = new Set(); @@ -373,6 +374,7 @@ export class World { dropPointerEdges(): void { this.pointer.pressed = false; this.pointer.released = false; + this.pointer.wheel = 0; } /** Game view: where the mouse is (world units) and whether it is over the view at all. */ @@ -382,6 +384,11 @@ export class World { this.pointer.inside = inside; } + /** Game view: the wheel turned by `notches`; what turns within one tick adds up. */ + turnWheel(notches: number): void { + this.pointer.wheel += notches; + } + /** Game view: the mouse button changed. Sets the edge flag the current tick reports. */ setPointerDown(down: boolean): void { if (down === this.pointer.down) return; @@ -404,7 +411,9 @@ export class World { */ hitTest(entity: Entity, x: number, y: number): boolean { const point = { x, y }; - const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e)); + const parts = [entity, ...this.descendantsOf(entity)].filter( + (e) => isHandleEnabled(e) && this.insideClipAreas(e, point), + ); const colliders = parts.filter((e) => e.type === 'collider'); if (colliders.length > 0) return colliders.some((c) => @@ -417,6 +426,13 @@ export class World { ); } + /** Whether the point is inside every clip area above the entity (#260): what is cut off is not hit. */ + private insideClipAreas(entity: Entity, point: Vec2): boolean { + for (let at = entity.parent; at; at = at.parent) + if (at.type === 'clip' && !footprintContains('clip', at.props, worldMatrixOf(at), point)) return false; + return true; + } + childrenOf(entity: Entity): Entity[] { return this.entities.filter((e) => e.parent === entity); } diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 2cfc67b0..5da6a38c 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -473,6 +473,9 @@ export class BehaviorSystem implements System { if (pointer.pressed && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) { i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y }); } + if (pointer.wheel !== 0 && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) { + i.fire('OnMouseWheel', ctx, { delta: pointer.wheel, x: pointer.x, y: pointer.y }); + } i.fire('OnUpdate', ctx, { dt }); }, dt, diff --git a/src/viewport/items.ts b/src/viewport/items.ts index d8d908bf..3d1b2dba 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -26,6 +26,7 @@ import { fontFileOf, fontSmoothOf } from '../core/model/font'; import { resolveTexture, type TextureRef } from '../core/model/texture'; import type { FrameOutline } from '../core/model/frameOutline'; import { liveTilemap, type TileEdits } from '../core/model/liveTilemap'; +import { type ClipRegion, clipRegions } from '../core/properties/clip'; import { colliderFrame } from '../core/properties/collider'; import { enabledAlongChain } from '../core/properties/enabled'; import { fontOf } from '../core/properties/fonts'; @@ -69,6 +70,8 @@ export interface DrawItem { emits: number; /** The frame a collider of the shape "Frame collision" wears (#193), resolved here like a sprite's picture. */ frame: FrameOutline | null; + /** Where the clip areas above it let it be seen and hit (#260), in world units; null when none is. */ + clip: ClipRegion | null; } interface DrawList { @@ -222,13 +225,16 @@ export function collectHolderItems( : null, emits: 0, frame: asset.type === 'collider' ? colliderFrame(project, props) : null, + clip: null, }); } const byId = new Map(items.map((i) => [i.id, i])); const parentOf = (i: DrawItem) => byId.get(i.parentId) ?? null; + const regions = clipRegions(items, parentOf); for (const item of items) { item.enabled = enabledAlongChain(item, (i) => i.props, parentOf); item.sortsWith = passedOnTo(parentOf(item), parentOf)?.id ?? ''; + item.clip = regions.get(item) ?? null; } return { items, background: Number(holder.props.backgroundColor ?? fallbackBackground) }; } @@ -257,12 +263,16 @@ function passedOnTo( /** The running world: scene content only, since symbols are editor-only. */ export function collectWorldItems(world: World): DrawList { - const items = world.entities - .filter((e) => !isSymbolType(e.type) && isHandleEnabled(e)) - .map((e) => { + const placed = new Map( + world.entities.map((e) => { const { m, z } = worldTransformOf(e); - return { e, m, z }; - }) + return [e, { e, m, z, type: e.type, props: e.props }]; + }), + ); + // Worked out before the symbols go: a clip area is one, and still cuts what is below it. + const regions = clipRegions([...placed.values()], (p) => (p.e.parent ? placed.get(p.e.parent) : null)); + const items = [...placed.values()] + .filter(({ e }) => !isSymbolType(e.type) && isHandleEnabled(e)) .map(({ e, m, z }) => ({ id: e.objectId, type: e.type, @@ -283,6 +293,7 @@ export function collectWorldItems(world: World): DrawList { tilemap: e.type === 'tilemap' ? resolveTilemap(world.project, e.assetId, e.props, e.tileEdits) : null, emits: e.emits ?? 0, frame: e.wornFrame?.() ?? null, + clip: regions.get(placed.get(e)!) ?? null, })); return { items, background: world.backgroundColor }; } diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index d22c0595..f645b780 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -23,6 +23,9 @@ import type { TextureLibrary } from './TextureLibrary'; /** Screen-pixel half-width of the grab band along a collider's outline. */ const COLLIDER_GRAB_BAND = 6; +/** Screen-pixel length of a dash of a clip area's outline, and of the gap after it. */ +const CLIP_DASH = 6; + /** How strongly a selected collider tints its area. */ export const COLLIDER_FILL_ALPHA = 0.3; @@ -144,6 +147,26 @@ export function drawItem( case 'container': case 'rigidbody': return; + // Only the dashed outline can be grabbed, so a click inside reaches the content it cuts (#260). + case 'clip': { + const [hw, hh] = [size.w / 2, size.h / 2]; + const corners = [ + [-hw, -hh], + [hw, -hh], + [hw, hh], + [-hw, hh], + ] as const; + corners.forEach(([x1, y1], i) => { + const [x2, y2] = corners[(i + 1) % 4]!; + dashedLine(node, x1, y1, x2, y2, CLIP_DASH / scale, CLIP_DASH / scale); + }); + node.stroke({ width: 1.5 / scale, color: Theme.colors.textMuted }); + node.hitArea = colliderOutlineHitArea( + { kind: 'box', x: 0, y: 0, w: size.w, h: size.h, angle: 0 }, + COLLIDER_GRAB_BAND / scale, + ); + return; + } case 'text': node.hitArea = new Rectangle( -size.w / 2 - (textAnchor(item.props) - 0.5) * size.w, diff --git a/src/viewport/view.ts b/src/viewport/view.ts index 48149337..258d7834 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -49,6 +49,16 @@ function clampIndex(index: number): number { return Math.max(0, Math.min(FREE_ZOOM_STEPS.length - 1, index)); } +/** + * A wheel event in notches of a mouse wheel, positive downwards like `deltaY` (#260). A notch is + * 100 pixels (see `WHEEL_STEP`) or 3 lines; a page is counted as one notch. + */ +export function wheelNotches(deltaY: number, deltaMode: number): number { + if (deltaMode === 1) return deltaY / 3; + if (deltaMode === 2) return Math.sign(deltaY); + return deltaY / 100; +} + /** Wheel travel for one zoom step: a mouse notch (100) is one step, a trackpad steps every 50 px. */ export const WHEEL_STEP = 50; diff --git a/tests/core/clip-areas.test.ts b/tests/core/clip-areas.test.ts new file mode 100644 index 00000000..019d73d1 --- /dev/null +++ b/tests/core/clip-areas.test.ts @@ -0,0 +1,201 @@ +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); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 8b8a016f..d3d27f83 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -1789,6 +1789,7 @@ const EVENT_NODES: Record = { OnCollisionStay: 'behavior-events.test.ts', OnCollisionExit: 'collider.test.ts', OnClick: 'nodes.test.ts', + OnMouseWheel: 'clip-areas.test.ts', OnAnimationFinished: 'animation.test.ts', OnStateEntered: 'state-machine.test.ts', OnStateExited: 'state-machine.test.ts', diff --git a/tests/editor/scene-visibility.test.ts b/tests/editor/scene-visibility.test.ts index 43931f01..6c31d2e6 100644 --- a/tests/editor/scene-visibility.test.ts +++ b/tests/editor/scene-visibility.test.ts @@ -25,6 +25,7 @@ describe('scene visibility', () => { expect(VISIBILITY_TYPES).toEqual([ 'behavior', 'camera', + 'clip', 'collider', 'container', 'particles', diff --git a/tests/helpers.ts b/tests/helpers.ts index 281852fd..f3e147d3 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -185,6 +185,7 @@ export const NO_POINTER: PointerState = { down: false, pressed: false, released: false, + wheel: 0, }; /** A top-level entity handle named after itself, with empty props. */ diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 3f705a28..b86cef06 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -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; diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index 6db57b68..d0f48986 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -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) => { diff --git a/tools/e2e/58-clip-areas.mjs b/tools/e2e/58-clip-areas.mjs new file mode 100644 index 00000000..d6339a65 --- /dev/null +++ b/tools/e2e/58-clip-areas.mjs @@ -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-')); + `); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index aa361592..4489cf08 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -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, ];