Clip the children of an object to a clip area (#260)
game-maker / gate (push) Successful in 3m43s
game-maker / suite (push) Failing after 11m7s

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:
adminandClaude Opus 5.5 committed 2026-09-29 07:08:00 +02:00
1 parent 89aa92d2e3
commit 0c41cb97b1
32 files changed
+759 -27

No files matched your search

+1
View File
@@ -16,6 +16,7 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
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).
+2
View File
@@ -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<AssetType, string> = {
animation,
behavior,
camera,
clip,
collider,
container,
curve,
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-4.5 -7.5 L-4.5 4.5 L7.5 4.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M-7.5 -4.5 L4.5 -4.5 L4.5 7.5" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 289 B

+12 -2
View File
@@ -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;
+20 -2
View File
@@ -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<string, DrawItem> } = { 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 {
+109 -6
View File
@@ -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<string, ParticleField>();
/** The meshes of each placed tilemap, one container per layer (#174). */
private readonly tilemaps = new Map<string, TilemapField>();
/**
* 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<Container, Graphics>();
/** The cut-off part of a sprite or text, drawn faintly under it while its clip area is open. */
private readonly ghostSprites = new Map<string, Mesh>();
private readonly ghostTexts = new Map<string, TextPicture>();
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<T extends Container>(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);
}
}
+2 -2
View File
@@ -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. */