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

+2
View File
@@ -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':
+2
View File
@@ -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;
}
/**
@@ -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: [],
};
+2
View File
@@ -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,
+1
View File
@@ -22,6 +22,7 @@ export const PROPERTY_TARGETS = [
'rigidbody',
'camera',
'container',
'clip',
'behavior',
] as const;
export type PropertyTarget = (typeof PROPERTY_TARGETS)[number];
+6
View File
@@ -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
+5 -2
View File
@@ -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<AssetType, string> = {
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';
}
+2 -1
View File
@@ -15,6 +15,7 @@ export type SceneVisibility = Record<PlaceableAssetType, TypeVisibility>;
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;
}
+6
View File
@@ -259,6 +259,7 @@ const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
'text',
'collider',
'container',
'clip',
'particles',
'tilemap',
]);
@@ -626,6 +627,11 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
// 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.
+1
View File
@@ -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);
+119
View File
@@ -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<T extends ClipNode>(
items: readonly T[],
parentOf: (item: T) => T | null | undefined,
): Map<T, ClipRegion | null> {
const regions = new Map<T, ClipRegion | null>();
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<T extends { id: string }>(
item: T,
selected: readonly string[],
parentOf: (item: T) => T | undefined,
): boolean {
const seen = new Set<T>();
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;
}
+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. */
+17 -1
View File
@@ -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<string> = 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);
}
+3
View File
@@ -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,
+16 -5
View File
@@ -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<T extends { type: AssetType; props: PropertyBag }>(
/** 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 };
}
+23
View File
@@ -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,
+10
View File
@@ -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;