Clip the children of an object to a clip area (#260)
An inventory grid could not scroll, because nothing could cut a child off at an edge. A game also had no way to hear the mouse wheel. Both are needed for a scrolling list, a dialog text or a window with more content than room. - A new object type Clip area has width and height and turns, scales and mirrors with its parent. The size gizmo drags width and height. - Everything below a clip area, on every level, is drawn only inside its rectangle, in the Scene view as in the game. Nested areas cut to their intersection. The rule works on a convex polygon, so a turned area cuts right as well. - On click, Is hovered and On mouse wheel do not hit a cut-off part. The Scene view does not pick it either. - In the Scene view the area is a dashed frame that is grabbed only at its edge, so a click inside lands on the content. The eye under Shape hides the frame. In the game the area is invisible. - While an object below an area or one of its parents is selected, the Scene view shows the cut-off part at 30 % opacity and it can be grabbed there. The rule lives in core/properties/clip.ts. - The Properties node knows the type Clip area. - The new event node On mouse wheel fires by the rule of On click. Its delta counts in notches, positive is down, and a frame's turns arrive as one value. It also gives x and y. Limits: - Only sprites and texts show the faded rest. Tilemaps and particles are cut and can be grabbed when open, but are not shown faded. - Masks by texture shape are out, as noted in the issue. Tests: - ./check.sh: 2773 tests, CHECK EXIT 0. - Full e2e run (local): ALL PASSED, 1549 checks, measured. - tests/core/clip-areas.test.ts: 12 tests for the cut rule, scene and game, save and load, hits, the wheel with and without owner, the notch conversion and the open rule. - e2e 58, 5 checks with real mouse and wheel: the scene cuts (74 of 74 red inside, 0 outside), a click on the cut part picks nothing, the selected rest is faded and grabbable, the game cuts, one notch logs 1. - e2e 04 and 46 count the object types and the eye rows. They now include Clip area. - Counter-checks: 6 in unit tests and 3 in the browser, all fail as expected. The originals were restored and checked with cmp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
89aa92d2e3
commit
0c41cb97b1
32 files changed
+759
-27
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user