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
@@ -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':
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const PROPERTY_TARGETS = [
|
||||
'rigidbody',
|
||||
'camera',
|
||||
'container',
|
||||
'clip',
|
||||
'behavior',
|
||||
] as const;
|
||||
export type PropertyTarget = (typeof PROPERTY_TARGETS)[number];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
+17
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user