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
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1544 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1549 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { findScene } from '../../src/core/model/Project';
|
||||
import { visibilityParts } from '../../src/core/model/sceneVisibility';
|
||||
import type { Vec2 } from '../../src/core/model/Properties';
|
||||
import {
|
||||
type ClipNode,
|
||||
clipRegions,
|
||||
intersectConvex,
|
||||
opensClip,
|
||||
regionContains,
|
||||
} from '../../src/core/properties/clip';
|
||||
import { schemaFor } from '../../src/core/properties/PropertySchema';
|
||||
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
|
||||
import { wheelNotches } from '../../src/viewport/view';
|
||||
import {
|
||||
graphEditor,
|
||||
makeBehavior,
|
||||
makeSpriteScene,
|
||||
place,
|
||||
roundTrip,
|
||||
type SceneFixture,
|
||||
startWorld,
|
||||
} from '../helpers';
|
||||
|
||||
const square = (cx: number, cy: number, half: number): Vec2[] => [
|
||||
{ x: cx - half, y: cy - half },
|
||||
{ x: cx + half, y: cy - half },
|
||||
{ x: cx + half, y: cy + half },
|
||||
{ x: cx - half, y: cy + half },
|
||||
];
|
||||
|
||||
/** A 100×100 clip area at the origin with a 100×100 sprite below it at `(80, 0)`: x 30…50 is left. */
|
||||
function clippedButton(): SceneFixture & { clipId: string; clip: string; button: string } {
|
||||
const f = makeSpriteScene('Button');
|
||||
const create = new CreateAssetCommand('clip', 'Clip');
|
||||
f.stack.execute(create);
|
||||
const clip = place(f, create.asset.id, { x: 0, y: 0 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, clip, 'width', 100));
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, clip, 'height', 100));
|
||||
const button = place(f, f.spriteId, { x: 80, y: 0 }, clip);
|
||||
return { ...f, clipId: create.asset.id, clip, button };
|
||||
}
|
||||
|
||||
function itemsOf(f: SceneFixture) {
|
||||
return collectHolderItems(f.project, findScene(f.project, f.sceneId), 0).items;
|
||||
}
|
||||
|
||||
describe('clip regions', () => {
|
||||
it('intersects convex polygons whatever their winding', () => {
|
||||
const cut = intersectConvex(square(0, 0, 50), [...square(80, 0, 50)].reverse());
|
||||
expect(regionContains(cut, { x: 40, y: 0 })).toBe(true);
|
||||
expect(regionContains(cut, { x: 60, y: 0 })).toBe(false);
|
||||
expect(regionContains(cut, { x: 20, y: 0 })).toBe(false);
|
||||
expect(intersectConvex(square(0, 0, 10), square(100, 0, 10))).toEqual([]);
|
||||
});
|
||||
|
||||
it('cuts everything below an area, at any depth, and nested areas intersect', () => {
|
||||
type Node = ClipNode & { id: string; parent: string };
|
||||
const at = (x: number) => ({ a: 1, b: 0, c: 0, d: 1, tx: x, ty: 0 });
|
||||
const nodes: Node[] = [
|
||||
{ id: 'outer', parent: '', type: 'clip', props: { width: 100, height: 100 }, m: at(0) },
|
||||
{ id: 'inner', parent: 'outer', type: 'clip', props: { width: 100, height: 100 }, m: at(80) },
|
||||
{ id: 'deep', parent: 'inner', type: 'sprite', props: {}, m: at(0) },
|
||||
{ id: 'free', parent: '', type: 'sprite', props: {}, m: at(0) },
|
||||
];
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const regions = clipRegions(nodes, (n) => byId.get(n.parent));
|
||||
const region = (id: string) => regions.get(byId.get(id)!);
|
||||
expect(region('outer')).toBeNull();
|
||||
expect(region('free')).toBeNull();
|
||||
// The inner area is cut by the outer one only, not by itself.
|
||||
expect(regionContains(region('inner')!, { x: -40, y: 0 })).toBe(true);
|
||||
expect(regionContains(region('deep')!, { x: 40, y: 0 })).toBe(true);
|
||||
expect(regionContains(region('deep')!, { x: -10, y: 0 })).toBe(false);
|
||||
expect(regionContains(region('deep')!, { x: 60, y: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('gives a placed object the region of the areas above it, turned with them', () => {
|
||||
const f = clippedButton();
|
||||
const child = place(f, f.spriteId, { x: 0, y: 0 }, f.button);
|
||||
const clipOf = (id: string) => itemsOf(f).find((i) => i.id === id)!.clip;
|
||||
expect(clipOf(f.clip)).toBeNull();
|
||||
expect(regionContains(clipOf(child)!, { x: 49, y: 49 })).toBe(true);
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, f.clip, 'rotation', 45));
|
||||
// A corner of the upright square is outside the turned one, the tip of the diamond is inside.
|
||||
expect(regionContains(clipOf(f.button)!, { x: 49, y: 49 })).toBe(false);
|
||||
expect(regionContains(clipOf(f.button)!, { x: 70, y: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it('cuts in the running game too, where the area is not drawn itself', () => {
|
||||
const f = clippedButton();
|
||||
const { world } = startWorld(f);
|
||||
const items = collectWorldItems(world).items;
|
||||
expect(items.some((i) => i.id === f.clip)).toBe(false);
|
||||
const button = items.find((i) => i.id === f.button)!;
|
||||
expect(regionContains(button.clip!, { x: 40, y: 0 })).toBe(true);
|
||||
expect(regionContains(button.clip!, { x: 70, y: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('survives saving and loading', () => {
|
||||
const f = clippedButton();
|
||||
const loaded = roundTrip(f.project);
|
||||
const items = collectHolderItems(loaded, findScene(loaded, f.sceneId), 0).items;
|
||||
expect(items.find((i) => i.id === f.clip)?.type).toBe('clip');
|
||||
expect(regionContains(items.find((i) => i.id === f.button)!.clip!, { x: 70, y: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('is a turnable, sizable area with an outline the eye can hide', () => {
|
||||
const keys = schemaFor('clip').map((d) => d.key);
|
||||
expect(keys).toEqual(expect.arrayContaining(['enabled', 'rotation', 'scale', 'width', 'height']));
|
||||
expect(visibilityParts('clip')).toEqual(['label', 'shape']);
|
||||
});
|
||||
|
||||
it('opens for what is selected and everything below it', () => {
|
||||
const items = [
|
||||
{ id: 'area', parentId: '' },
|
||||
{ id: 'row', parentId: 'area' },
|
||||
{ id: 'slot', parentId: 'row' },
|
||||
];
|
||||
const byId = new Map(items.map((i) => [i.id, i]));
|
||||
const parentOf = (i: { parentId: string }) => byId.get(i.parentId);
|
||||
expect(opensClip(items[2]!, ['area'], parentOf)).toBe(true);
|
||||
expect(opensClip(items[2]!, ['row'], parentOf)).toBe(true);
|
||||
expect(opensClip(items[1]!, ['slot'], parentOf)).toBe(false);
|
||||
expect(opensClip(items[2]!, [], parentOf)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clicks and the wheel on cut objects', () => {
|
||||
it('does not hit the part an area cuts off', () => {
|
||||
const f = clippedButton();
|
||||
const { world } = startWorld(f);
|
||||
const button = world.findByObjectId(f.button)!;
|
||||
expect(world.hitTest(button, 40, 0)).toBe(true);
|
||||
expect(world.hitTest(button, 70, 0)).toBe(false);
|
||||
});
|
||||
|
||||
/** On mouse wheel → turned = turned + delta, on a behavior below the button. */
|
||||
function wheelOnButton(owned = true) {
|
||||
const f = clippedButton();
|
||||
const behavior = makeBehavior(f, 'Scroll', ['turned']);
|
||||
const edit = graphEditor(f, behavior);
|
||||
const wheel = edit.addNode('OnMouseWheel');
|
||||
const get = edit.variables(['turned']);
|
||||
const add = edit.addNode('Add');
|
||||
const set = edit.variables(['turned'], { write: true });
|
||||
edit.wire([
|
||||
[wheel, 'exec', set, 'exec'],
|
||||
[get, 'turned', add, 'a'],
|
||||
[wheel, 'delta', add, 'b'],
|
||||
[add, 'result', set, 'turned'],
|
||||
]);
|
||||
const logic = place(f, behavior.id, { x: 0, y: 0 }, owned ? f.button : '');
|
||||
const started = startWorld(f, { behaviors: true });
|
||||
return { ...started, turned: () => started.world.findByObjectId(logic)!.props.turned };
|
||||
}
|
||||
|
||||
it('fires On mouse wheel over the owner with what turned in the tick', () => {
|
||||
const { world, turned } = wheelOnButton();
|
||||
world.movePointer(40, 0, true);
|
||||
world.turnWheel(1);
|
||||
world.turnWheel(0.5);
|
||||
world.tick(0.1);
|
||||
expect(turned()).toBe(1.5);
|
||||
expect(world.pointer.wheel).toBe(0);
|
||||
world.tick(0.1);
|
||||
expect(turned()).toBe(1.5);
|
||||
});
|
||||
|
||||
it('does not fire beside the owner, nor on its cut-off part', () => {
|
||||
const { world, turned } = wheelOnButton();
|
||||
world.movePointer(500, 500, true);
|
||||
world.turnWheel(1);
|
||||
world.tick(0.1);
|
||||
world.movePointer(70, 0, true);
|
||||
world.turnWheel(1);
|
||||
world.tick(0.1);
|
||||
expect(turned()).toBe(0);
|
||||
});
|
||||
|
||||
it('fires anywhere in the view without an owner', () => {
|
||||
const { world, turned } = wheelOnButton(false);
|
||||
world.movePointer(500, 500, true);
|
||||
world.turnWheel(-2);
|
||||
world.tick(0.1);
|
||||
expect(turned()).toBe(-2);
|
||||
world.movePointer(500, 500, false);
|
||||
world.turnWheel(1);
|
||||
world.tick(0.1);
|
||||
expect(turned()).toBe(-2);
|
||||
});
|
||||
|
||||
it('counts a mouse notch as one whatever the unit the browser reports in', () => {
|
||||
expect(wheelNotches(100, 0)).toBe(1);
|
||||
expect(wheelNotches(-3, 1)).toBe(-1);
|
||||
expect(wheelNotches(-240, 2)).toBe(-1);
|
||||
expect(wheelNotches(25, 0)).toBe(0.25);
|
||||
});
|
||||
});
|
||||
@@ -1789,6 +1789,7 @@ const EVENT_NODES: Record<string, string> = {
|
||||
OnCollisionStay: 'behavior-events.test.ts',
|
||||
OnCollisionExit: 'collider.test.ts',
|
||||
OnClick: 'nodes.test.ts',
|
||||
OnMouseWheel: 'clip-areas.test.ts',
|
||||
OnAnimationFinished: 'animation.test.ts',
|
||||
OnStateEntered: 'state-machine.test.ts',
|
||||
OnStateExited: 'state-machine.test.ts',
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('scene visibility', () => {
|
||||
expect(VISIBILITY_TYPES).toEqual([
|
||||
'behavior',
|
||||
'camera',
|
||||
'clip',
|
||||
'collider',
|
||||
'container',
|
||||
'particles',
|
||||
|
||||
@@ -185,6 +185,7 @@ export const NO_POINTER: PointerState = {
|
||||
down: false,
|
||||
pressed: false,
|
||||
released: false,
|
||||
wheel: 0,
|
||||
};
|
||||
|
||||
/** A top-level entity handle named after itself, with empty props. */
|
||||
|
||||
@@ -81,7 +81,7 @@ export async function assets(t) {
|
||||
|
||||
// 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups –
|
||||
// what can be placed in a scene, a line, then what only lives in the library – and it is
|
||||
// tall enough to show all twenty-two types without scrolling.
|
||||
// tall enough to show every type without scrolling.
|
||||
await clickText('+');
|
||||
const rows = await json('__gm.menuRows()');
|
||||
const cut = rows.indexOf(null);
|
||||
@@ -94,7 +94,7 @@ export async function assets(t) {
|
||||
);
|
||||
check(
|
||||
'every asset type is offered once, each group sorted by label',
|
||||
placeable.length + library.length === 23 &&
|
||||
placeable.length + library.length === 24 &&
|
||||
String(placeable) === String([...placeable].sort()) &&
|
||||
String(library) === String([...library].sort()),
|
||||
JSON.stringify({ placeable, library }),
|
||||
@@ -453,7 +453,7 @@ export async function assets(t) {
|
||||
async function addMenuOnRightClick(t) {
|
||||
const { check, json, shot, click, clickText, press, undo, showTab } = t;
|
||||
const addRows = (rows) =>
|
||||
Array.isArray(rows) && rows.filter(Boolean).length === 23 && rows.includes('Sprite');
|
||||
Array.isArray(rows) && rows.filter(Boolean).length === 24 && rows.includes('Sprite');
|
||||
const heading = await json("__gm.assetRowShown('group:scene')");
|
||||
const sceneFolded = async () =>
|
||||
!(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded;
|
||||
|
||||
@@ -211,11 +211,12 @@ export async function topbar(t) {
|
||||
const entry = (list, type, part) => list?.find((e) => e.type === type && e.part === part);
|
||||
const types = [...new Set((popup ?? []).map((e) => e.type))];
|
||||
check(
|
||||
'the eye lists every placeable type, Symbol greyed out where there is none and Shape on collider and tilemap',
|
||||
'the eye lists every placeable type, Symbol greyed out where there is none and Shape on collider, tilemap and clip area',
|
||||
JSON.stringify(types) ===
|
||||
JSON.stringify([
|
||||
'behavior',
|
||||
'camera',
|
||||
'clip',
|
||||
'collider',
|
||||
'container',
|
||||
'particles',
|
||||
@@ -229,7 +230,8 @@ export async function topbar(t) {
|
||||
entry(popup, 'sprite', 'label')?.on === false &&
|
||||
entry(popup, 'collider', 'shape')?.on === true &&
|
||||
entry(popup, 'tilemap', 'shape')?.on === true &&
|
||||
popup.filter((e) => e.part === 'shape').length === 2,
|
||||
entry(popup, 'clip', 'shape')?.on === true &&
|
||||
popup.filter((e) => e.part === 'shape').length === 3,
|
||||
JSON.stringify(popup),
|
||||
);
|
||||
const flip = async (type, part) => {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Clip areas (#260): a red bar below a 100×100 clip area is cut at the area's edge in the scene
|
||||
* view and in the game, and the cut-off part cannot be clicked. Selected, the bar shows that part
|
||||
* faintly and can be grabbed there. A turn of the real mouse wheel over the game reaches `On mouse
|
||||
* wheel`.
|
||||
*
|
||||
* Everything it adds carries a `cl58` id and goes again at the end.
|
||||
*/
|
||||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
const BAR = 'cl58-bar';
|
||||
const AREA = 'cl58-clip';
|
||||
|
||||
export async function clipAreas(t) {
|
||||
const { check, json, evaluate, mutate, click, sleep, shot, stop, wheel } = t;
|
||||
|
||||
// On mouse wheel → Log(delta), with no owner: it hears the whole game view.
|
||||
const graph = {
|
||||
nodes: [
|
||||
{ id: 'cl58-wheel', type: 'OnMouseWheel', x: 0, y: 0, params: {} },
|
||||
{ id: 'cl58-log', type: 'Log', x: 320, y: 0, params: {} },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'cl58-e1', from: { node: 'cl58-wheel', port: 'exec' }, to: { node: 'cl58-log', port: 'exec' } },
|
||||
{
|
||||
id: 'cl58-e2',
|
||||
from: { node: 'cl58-wheel', port: 'delta' },
|
||||
to: { node: 'cl58-log', port: 'message' },
|
||||
},
|
||||
],
|
||||
};
|
||||
// Inside the camera's frame, so the game shows it too, but off the camera's own icon and in front
|
||||
// of what earlier steps left there.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const cam = scene.objects.find((o) => o.id === 'o3').position;
|
||||
p.assets.push({ id: 'cl58-area', type: 'clip', name: 'Clip 58', props: { enabled: true, width: 100, height: 100 } });
|
||||
p.assets.push({ id: 'cl58-scroll', type: 'behavior', name: 'Wheel 58', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(graph)} });
|
||||
${MAKE_OBJECT}
|
||||
const sprite = p.assets.find((a) => a.type === 'sprite');
|
||||
const area = make('${AREA}', 'cl58-area', '', 0, 'Clip 58');
|
||||
Object.assign(area.position, { x: cam.x - 180, y: cam.y - 110, z: 1000 });
|
||||
const bar = make('${BAR}', sprite.id, '${AREA}', 0, 'Bar 58');
|
||||
Object.assign(bar.position, { x: 50, y: 0, z: 0 });
|
||||
bar.overrides = { width: 160, height: 40, color: 0xff0000, texture: '' };
|
||||
const logic = make('cl58-logic', 'cl58-scroll', '', 0, 'Wheel 58');
|
||||
Object.assign(logic.position, { x: cam.x + 200, y: cam.y - 150 });
|
||||
scene.objects.push(area, bar, logic);
|
||||
`);
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await json(`(() => {
|
||||
const cam = __gm.state.activeScene.objects.find((o) => o.id === 'o3').position;
|
||||
__gm.lookAt(cam.x, cam.y);
|
||||
})()`);
|
||||
await sleep(300);
|
||||
|
||||
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
/**
|
||||
* Red across the bar's middle, split at the area's right edge: full red inside, and how far the
|
||||
* red goes past it. A few pixels either side of the edge are left out – they blend – and the row
|
||||
* runs below the middle, clear of the handles a selection puts on the sides.
|
||||
*/
|
||||
const across = async (view) => {
|
||||
const area = await json(`__gm.${view}(${JSON.stringify(AREA)})`);
|
||||
const bar = await json(`__gm.${view}(${JSON.stringify(BAR)})`);
|
||||
if (!bar) return null;
|
||||
const left = Math.round(bar.x - bar.w / 2 + 3);
|
||||
const edge = area ? area.x + area.w / 2 : bar.x - bar.w / 2 + (bar.w * 80) / 160;
|
||||
const row = await json(
|
||||
`__gm.pixelRow(${left}, ${Math.round(bar.y + bar.h / 4)}, ${Math.round(bar.w - 6)})`,
|
||||
);
|
||||
const inside = row.slice(0, Math.max(0, Math.round(edge - left - 3))).map(channels);
|
||||
const outside = row.slice(Math.round(edge - left + 3)).map(channels);
|
||||
const red = (px) => px.filter(([r, g, b]) => r > 200 && g < 60 && b < 60).length;
|
||||
const tinted = (px) => px.filter(([r, g, b]) => r - Math.max(g, b) > 30).length;
|
||||
return {
|
||||
edge: Math.round(edge),
|
||||
inside: inside.length,
|
||||
redInside: red(inside),
|
||||
outside: outside.length,
|
||||
redOutside: red(outside),
|
||||
tintedOutside: tinted(outside),
|
||||
};
|
||||
};
|
||||
|
||||
// 58a. The scene view cuts the bar at the area's edge.
|
||||
const cut = await across('objectRect');
|
||||
await shot('58-scene-cut');
|
||||
check(
|
||||
'the scene view draws a child only inside its clip area',
|
||||
cut !== null && cut.redInside > 0.9 * cut.inside && cut.tintedOutside === 0 && cut.outside > 20,
|
||||
JSON.stringify(cut),
|
||||
);
|
||||
|
||||
// 58b. A click on the cut-off part does not pick the bar; one on the part left does.
|
||||
const bar = await json(`__gm.objectRect(${JSON.stringify(BAR)})`);
|
||||
const cutPoint = { x: Math.round(cut.edge + (bar.x + bar.w / 2 - cut.edge) / 2), y: Math.round(bar.y) };
|
||||
const keptPoint = { x: Math.round(bar.x - bar.w / 2 + 10), y: Math.round(bar.y) };
|
||||
const primary = () =>
|
||||
json("__gm.state.selection?.kind === 'object' ? __gm.state.selection.objectId : null");
|
||||
const stackCut = await json(`__gm.sceneStack(${cutPoint.x}, ${cutPoint.y})`);
|
||||
await click(cutPoint.x, cutPoint.y);
|
||||
await sleep(150);
|
||||
const afterCut = await primary();
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await click(keptPoint.x, keptPoint.y);
|
||||
await sleep(150);
|
||||
const afterKept = await primary();
|
||||
check(
|
||||
'the cut-off part cannot be clicked, the part left can',
|
||||
!stackCut.includes(BAR) && afterCut !== BAR && afterKept === BAR,
|
||||
JSON.stringify({ stackCut, afterCut, afterKept }),
|
||||
);
|
||||
|
||||
// 58c. Selected, the cut-off part shows faintly and can be grabbed there.
|
||||
const open = await across('objectRect');
|
||||
const stackOpen = await json(`__gm.sceneStack(${cutPoint.x}, ${cutPoint.y})`);
|
||||
await shot('58-scene-open');
|
||||
check(
|
||||
'a selected child shows its cut-off part faintly and can be grabbed there',
|
||||
open.redInside > 0.9 * open.inside &&
|
||||
open.tintedOutside > 0.9 * open.outside &&
|
||||
open.redOutside === 0 &&
|
||||
stackOpen.includes(BAR),
|
||||
JSON.stringify({ open, stackOpen }),
|
||||
);
|
||||
await evaluate('__gm.state.select(null)');
|
||||
|
||||
// 58d. The game cuts the same way.
|
||||
await t.play();
|
||||
await sleep(600);
|
||||
const played = await across('gameRect');
|
||||
await shot('58-game');
|
||||
check(
|
||||
'the running game draws the child only inside its clip area',
|
||||
played !== null && played.redInside > 0.9 * played.inside && played.tintedOutside === 0,
|
||||
JSON.stringify(played),
|
||||
);
|
||||
|
||||
// 58e. A notch of the real wheel over the game reaches On mouse wheel as delta 1.
|
||||
const logFrom = t.consoleLog.length;
|
||||
const centre = await json(`__gm.gameRect(${JSON.stringify(BAR)})`);
|
||||
await wheel(Math.round(centre.x), Math.round(centre.y), 100);
|
||||
await sleep(300);
|
||||
const logged = t.consoleLog
|
||||
.slice(logFrom)
|
||||
.filter((line) => line.startsWith('[behavior Wheel 58]'))
|
||||
.map((line) => line.split(']').pop().trim());
|
||||
check(
|
||||
'a notch of the mouse wheel over the game runs On mouse wheel with delta 1',
|
||||
logged.includes('1'),
|
||||
JSON.stringify(logged),
|
||||
);
|
||||
await stop();
|
||||
|
||||
// Clean up.
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('cl58-'));
|
||||
p.assets = p.assets.filter((a) => !a.id.startsWith('cl58-'));
|
||||
`);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import { structTypes } from './54-struct-types.mjs';
|
||||
import { typedLists } from './55-typed-lists.mjs';
|
||||
import { privateVariables } from './56-private-variables.mjs';
|
||||
import { objectReferences } from './57-object-references.mjs';
|
||||
import { clipAreas } from './58-clip-areas.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -119,4 +120,5 @@ export const STEPS = [
|
||||
typedLists,
|
||||
privateVariables,
|
||||
objectReferences,
|
||||
clipAreas,
|
||||
];
|
||||
Reference in new issue
Block a user