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

An inventory grid could not scroll, because nothing could cut a child
off at an edge. A game also had no way to hear the mouse wheel. Both
are needed for a scrolling list, a dialog text or a window with more
content than room.

- A new object type Clip area has width and height and turns, scales
  and mirrors with its parent. The size gizmo drags width and height.
- Everything below a clip area, on every level, is drawn only inside
  its rectangle, in the Scene view as in the game. Nested areas cut to
  their intersection. The rule works on a convex polygon, so a turned
  area cuts right as well.
- On click, Is hovered and On mouse wheel do not hit a cut-off part.
  The Scene view does not pick it either.
- In the Scene view the area is a dashed frame that is grabbed only at
  its edge, so a click inside lands on the content. The eye under
  Shape hides the frame. In the game the area is invisible.
- While an object below an area or one of its parents is selected, the
  Scene view shows the cut-off part at 30 % opacity and it can be
  grabbed there. The rule lives in core/properties/clip.ts.
- The Properties node knows the type Clip area.
- The new event node On mouse wheel fires by the rule of On click. Its
  delta counts in notches, positive is down, and a frame's turns
  arrive as one value. It also gives x and y.

Limits:

- Only sprites and texts show the faded rest. Tilemaps and particles
  are cut and can be grabbed when open, but are not shown faded.
- Masks by texture shape are out, as noted in the issue.

Tests:

- ./check.sh: 2773 tests, CHECK EXIT 0.
- Full e2e run (local): ALL PASSED, 1549 checks, measured.
- tests/core/clip-areas.test.ts: 12 tests for the cut rule, scene and
  game, save and load, hits, the wheel with and without owner, the
  notch conversion and the open rule.
- e2e 58, 5 checks with real mouse and wheel: the scene cuts (74 of 74
  red inside, 0 outside), a click on the cut part picks nothing, the
  selected rest is faded and grabbable, the game cuts, one notch
  logs 1.
- e2e 04 and 46 count the object types and the eye rows. They now
  include Clip area.
- Counter-checks: 6 in unit tests and 3 in the browser, all fail as
  expected. The originals were restored and checked with cmp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 07:08:00 +02:00
1 parent 89aa92d2e3
commit 0c41cb97b1
32 files changed
+759 -27

No files matched your search

+1 -1
View File
@@ -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
+2
View File
@@ -41,6 +41,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'scene', objects: [] };
case 'camera':
return { ...base, type: 'camera' };
case 'clip':
return { ...base, type: 'clip' };
case 'sprite':
return { ...base, type: 'sprite' };
case 'text':
+2
View File
@@ -70,6 +70,8 @@ export interface PointerState {
down: boolean;
pressed: boolean;
released: boolean;
/** How far the wheel turned this tick, in notches, positive downwards (see `wheelNotches`). */
wheel: number;
}
/**
@@ -0,0 +1,15 @@
import type { NodeDef } from '../../NodeRegistry';
import { exec, num } from '../shared';
export const OnMouseWheel: NodeDef = {
type: 'OnMouseWheel',
label: 'On mouse wheel',
category: 'event',
description:
'Runs when the mouse wheel turns over the owner (or over anything parented below it) in the Game tab. ' +
'Without an owner it runs on a turn anywhere in the game view. delta is in notches of a mouse wheel, ' +
'positive downwards; a trackpad gives fractions.',
inputs: [],
outputs: [exec(), num('delta', 'notches, positive downwards'), num('x', 'world x'), num('y', 'world y')],
params: [],
};
+2
View File
@@ -102,6 +102,7 @@ import { SubList } from './data/SubList';
import { TextureSize } from './data/TextureSize';
import { OnAnimationFinished } from './events/OnAnimationFinished';
import { OnClick } from './events/OnClick';
import { OnMouseWheel } from './events/OnMouseWheel';
import { OnCollisionEnter } from './events/OnCollisionEnter';
import { OnCollisionExit } from './events/OnCollisionExit';
import { OnCollisionStay } from './events/OnCollisionStay';
@@ -152,6 +153,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
OnCollisionStay,
OnCollisionExit,
OnClick,
OnMouseWheel,
OnAnimationFinished,
OnStateEntered,
OnStateExited,
+1
View File
@@ -22,6 +22,7 @@ export const PROPERTY_TARGETS = [
'rigidbody',
'camera',
'container',
'clip',
'behavior',
] as const;
export type PropertyTarget = (typeof PROPERTY_TARGETS)[number];
+6
View File
@@ -20,6 +20,11 @@ export interface CameraAsset extends AssetBase {
type: 'camera';
}
/** A rectangle its children are drawn and hit inside only (#260); nothing of its own is drawn. */
export interface ClipAsset extends AssetBase {
type: 'clip';
}
export interface TextAsset extends AssetBase {
type: 'text';
}
@@ -488,6 +493,7 @@ export interface ParticlesAsset extends AssetBase {
export type Asset =
| SpriteAsset
| CameraAsset
| ClipAsset
| TextAsset
| SceneAsset
| BehaviorAsset
+5 -2
View File
@@ -9,6 +9,7 @@ export const ASSET_TYPES = [
'collider',
'rigidbody',
'container',
'clip',
'texture',
'vectorart',
'pixelart',
@@ -37,6 +38,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
collider: 'Collider',
rigidbody: 'Rigidbody',
container: 'Container',
clip: 'Clip area',
texture: 'Texture',
vectorart: 'Vector art',
pixelart: 'Pixel art',
@@ -111,7 +113,8 @@ export function isSymbolType(type: AssetType): boolean {
type === 'behavior' ||
type === 'collider' ||
type === 'rigidbody' ||
type === 'container'
type === 'container' ||
type === 'clip'
);
}
@@ -121,5 +124,5 @@ export function isSymbolType(type: AssetType): boolean {
* throws a thing out of the game and out of the draw order. Only the icon is editor decoration.
*/
export function hasSceneIcon(type: AssetType): boolean {
return isSymbolType(type) || type === 'particles';
return (isSymbolType(type) && type !== 'clip') || type === 'particles';
}
+2 -1
View File
@@ -15,6 +15,7 @@ export type SceneVisibility = Record<PlaceableAssetType, TypeVisibility>;
export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [
'behavior',
'camera',
'clip',
'collider',
'container',
'particles',
@@ -31,7 +32,7 @@ export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [
*/
export function visibilityParts(type: PlaceableAssetType): VisibilityPart[] {
const parts: VisibilityPart[] = hasSceneIcon(type) ? ['symbol', 'label'] : ['label'];
if (type === 'collider' || type === 'tilemap') parts.push('shape');
if (type === 'collider' || type === 'tilemap' || type === 'clip') parts.push('shape');
return parts;
}
+6
View File
@@ -259,6 +259,7 @@ const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
'text',
'collider',
'container',
'clip',
'particles',
'tilemap',
]);
@@ -626,6 +627,11 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
// A container is pure structure: its content is the template, not a property.
container: [],
// What its children are cut to (#260). Units, not pixels: it turns and grows with its parent.
clip: [
{ key: 'width', label: 'Width', kind: 'number', default: 200, min: 1, step: 1 },
{ key: 'height', label: 'Height', kind: 'number', default: 150, min: 1, step: 1 },
],
text: [
{ key: 'text', label: 'Text', kind: 'text', default: 'Text' },
// Empty is the editor's own typeface, the one every text was drawn in before fonts existed.
+1
View File
@@ -61,6 +61,7 @@ export function footprintOf(
): { w: number; h: number } {
switch (type) {
case 'sprite':
case 'clip':
return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) };
case 'text':
return textSize(props);
+119
View File
@@ -0,0 +1,119 @@
/**
* Clip areas (#260): an object of type `clip` cuts everything below it, at any depth, to its own
* rectangle – drawn and hit alike. Areas above one another intersect. A rectangle that turns,
* grows or mirrors with its parent stays convex, so the region is always one convex polygon.
*/
import type { AssetType } from '../model/AssetTypes';
import type { Mat } from '../model/matrix';
import type { PropertyBag, Vec2 } from '../model/Properties';
import { footprintCorners } from './bounds';
/** A convex polygon in world units; empty when nothing of the object is left to see. */
export type ClipRegion = readonly Vec2[];
export interface ClipNode {
type: AssetType;
props: PropertyBag;
m: Mat;
}
/** The area's own rectangle in world units. */
export function clipCorners(props: PropertyBag, m: Mat): Vec2[] {
return footprintCorners('clip', props, m);
}
function signedArea(polygon: readonly Vec2[]): number {
let area = 0;
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i]!;
const b = polygon[(i + 1) % polygon.length]!;
area += a.x * b.y - b.x * a.y;
}
return area / 2;
}
/** Which side of the edge a→b the point is on, turned so that inside is positive whatever the winding. */
function side(a: Vec2, b: Vec2, p: Vec2, winding: number): number {
return ((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) * winding;
}
/** Sutherland–Hodgman: `subject` cut by every edge of the convex `clip`. */
export function intersectConvex(subject: readonly Vec2[], clip: readonly Vec2[]): Vec2[] {
const winding = Math.sign(signedArea(clip));
if (winding === 0) return [];
let out: Vec2[] = [...subject];
for (let i = 0; i < clip.length && out.length > 0; i++) {
const a = clip[i]!;
const b = clip[(i + 1) % clip.length]!;
const input = out;
out = [];
for (let j = 0; j < input.length; j++) {
const p = input[j]!;
const q = input[(j + 1) % input.length]!;
const sp = side(a, b, p, winding);
const sq = side(a, b, q, winding);
if (sp >= 0) out.push(p);
if (sp >= 0 !== sq >= 0) {
const t = sp / (sp - sq);
out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t });
}
}
}
return out.length >= 3 && signedArea(out) !== 0 ? out : [];
}
/** Whether a world point lies inside the region, edge included. */
export function regionContains(region: ClipRegion, p: Vec2): boolean {
if (region.length < 3) return false;
const winding = Math.sign(signedArea(region));
for (let i = 0; i < region.length; i++)
if (side(region[i]!, region[(i + 1) % region.length]!, p, winding) < 0) return false;
return true;
}
/**
* The region each object is cut to: null for one with no clip area above it. The area itself is
* not cut by its own rectangle, only by those above it.
*/
export function clipRegions<T extends ClipNode>(
items: readonly T[],
parentOf: (item: T) => T | null | undefined,
): Map<T, ClipRegion | null> {
const regions = new Map<T, ClipRegion | null>();
const regionOf = (item: T): ClipRegion | null => {
if (regions.has(item)) return regions.get(item)!;
// Set first, so a broken hierarchy that loops ends here instead of overflowing the stack.
regions.set(item, null);
const parent = parentOf(item);
let region: ClipRegion | null = null;
if (parent) {
const above = regionOf(parent);
if (parent.type !== 'clip') region = above;
else {
const own = clipCorners(parent.props, parent.m);
region = above ? intersectConvex(own, above) : own;
}
}
regions.set(item, region);
return region;
};
for (const item of items) regionOf(item);
return regions;
}
/**
* Whether the scene view opens the clip areas above `item` (#260): when it, or an object above it,
* is selected. Selecting the area opens all it cuts, selecting one row of a list opens that row.
*/
export function opensClip<T extends { id: string }>(
item: T,
selected: readonly string[],
parentOf: (item: T) => T | undefined,
): boolean {
const seen = new Set<T>();
for (let at: T | undefined = item; at && !seen.has(at); at = parentOf(at)) {
if (selected.includes(at.id)) return true;
seen.add(at);
}
return false;
}
+1
View File
@@ -16,6 +16,7 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
collider: 'Colliders',
rigidbody: 'Rigidbodies',
container: 'Containers',
clip: 'Clip areas',
texture: 'Textures',
// Not "Vector art": that is the `+` menu entry, and a heading of the same words would take the
// click meant for the menu (see the test below the labels).
+2
View File
@@ -6,6 +6,7 @@ import type { AssetType } from '../../core/model/AssetTypes';
import animation from './icons/animation.svg?raw';
import behavior from './icons/behavior.svg?raw';
import camera from './icons/camera.svg?raw';
import clip from './icons/clip.svg?raw';
import collider from './icons/collider.svg?raw';
import container from './icons/container.svg?raw';
import curve from './icons/curve.svg?raw';
@@ -36,6 +37,7 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
animation,
behavior,
camera,
clip,
collider,
container,
curve,
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-4.5 -7.5 L-4.5 4.5 L7.5 4.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M-7.5 -4.5 L4.5 -4.5 L4.5 7.5" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 289 B

+12 -2
View File
@@ -2,7 +2,12 @@
* The scene seen through its active camera at the chosen scale, letterboxed: the running world while
* playing, the model as a still preview otherwise.
*/
import { type FederatedPointerEvent, Text } from 'pixi.js';
import {
type FederatedMouseEvent,
type FederatedPointerEvent,
type FederatedWheelEvent,
Text,
} from 'pixi.js';
import { isSymbolType } from '../../core/model/AssetTypes';
import type { EditorState } from '../state';
import type { World } from '../../runtime/World';
@@ -16,6 +21,7 @@ import {
isInside,
screenToWorld,
type View,
wheelNotches,
} from '../../viewport/view';
import { type Frame, WorldViewport } from './WorldViewport';
@@ -49,6 +55,10 @@ export class GameViewport extends WorldViewport {
this.on('globalpointermove', (e: FederatedPointerEvent) => this.reportPointer(e));
this.on('pointerup', () => this.pointerWorld()?.setPointerDown(false));
this.on('pointerupoutside', () => this.pointerWorld()?.setPointerDown(false));
this.on('wheel', (e: FederatedWheelEvent) => {
this.reportPointer(e);
this.pointerWorld()?.turnWheel(wheelNotches(e.deltaY, e.deltaMode));
});
}
/** null = not playing. */
@@ -147,7 +157,7 @@ export class GameViewport extends WorldViewport {
}
/** Outside the camera frame counts as "not in view": that region is letterbox, not game. */
private reportPointer(e: FederatedPointerEvent): void {
private reportPointer(e: FederatedMouseEvent): void {
const world = this.pointerWorld();
const view = this.currentView();
if (!world || !view) return;
+20 -2
View File
@@ -6,7 +6,8 @@
import { Container, type FederatedPointerEvent, Graphics, Rectangle, Text } from 'pixi.js';
import { oneStep } from '../../core/commands/CompoundCommand';
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { hasSceneIcon } from '../../core/model/AssetTypes';
import { hasSceneIcon, isSymbolType } from '../../core/model/AssetTypes';
import { opensClip, regionContains } from '../../core/properties/clip';
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
import { objectRef, resolvedHolder } from '../../core/model/instance';
import {
@@ -429,6 +430,19 @@ export class SceneViewport extends FreeViewport {
return s?.kind === 'object' && s.objectIds.includes(objectId);
}
/** A clip area opens for what is selected and everything below it (#260): the area itself, a row, a slot. */
protected override opensClip(item: DrawItem): boolean {
const s = this.state.selection;
if (s?.kind !== 'object') return false;
if (this.itemsById.items !== this.lastItems)
this.itemsById = { items: this.lastItems, byId: new Map(this.lastItems.map((i) => [i.id, i])) };
const { byId } = this.itemsById;
return opensClip(item, s.objectIds, (at) => byId.get(at.parentId));
}
/** `lastItems` by id, built once per redraw for `opensClip`. */
private itemsById: { items: DrawItem[]; byId: Map<string, DrawItem> } = { items: [], byId: new Map() };
/**
* Pixi only says which hit area was pressed; what is really under the pointer is worked out
* here, in the order it is drawn (#203): a tilemap's grid is empty where it has no tile, and a
@@ -519,6 +533,10 @@ export class SceneViewport extends FreeViewport {
for (const item of this.lastItems) {
const node = this.nodes.get(item.id);
if (!node?.visible || node.eventMode === 'none') continue;
// What a clip area cuts off is not there to click, unless the area is open (#260). Symbols
// are drawn above everything, uncut.
if (item.clip && !isSymbolType(item.type) && !this.opensClip(item) && !regionContains(item.clip, world))
continue;
const p = node.toLocal(global);
if (
!this.onIcon(item.id, global) &&
@@ -658,7 +676,7 @@ export class SceneViewport extends FreeViewport {
/** A tilemap's shape switch hides only the outline of its solid cells, never the map itself. */
protected override hidesShape(item: DrawItem): boolean {
return item.type === 'collider' && !this.shows(item, 'shape');
return (item.type === 'collider' || item.type === 'clip') && !this.shows(item, 'shape');
}
protected override disposeDecorations(id: string): void {
+109 -6
View File
@@ -4,6 +4,7 @@ import { type Extent, scaleOf } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { footprintExtent, footprintOf, placementOf } from '../../core/properties/bounds';
import { rectsIntersect } from '../../core/properties/camera';
import type { ClipRegion } from '../../core/properties/clip';
import type { EditorState } from '../state';
import { type Size, Widget } from '../../ui';
import { fontLibrary } from '../../viewport/FontLibrary';
@@ -22,6 +23,9 @@ import { textureLibrary } from '../../viewport/TextureLibrary';
import { gameClock } from '../../core/time';
import { type View, worldBounds, worldToScreen } from '../../viewport/view';
/** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */
export const CLIP_GHOST_ALPHA = 0.3;
export interface Frame {
items: DrawItem[];
background: number;
@@ -45,6 +49,15 @@ export abstract class WorldViewport extends Widget {
private readonly fields = new Map<string, ParticleField>();
/** The meshes of each placed tilemap, one container per layer (#174). */
private readonly tilemaps = new Map<string, TilemapField>();
/**
* What clip areas cut off (#260), as a mask per drawn thing: Pixi lets a mask serve only one, and
* switches a shared one back on as content once any of them lets go of it.
*/
private readonly masks = new Container();
private readonly maskOf = new Map<Container, Graphics>();
/** The cut-off part of a sprite or text, drawn faintly under it while its clip area is open. */
private readonly ghostSprites = new Map<string, Mesh>();
private readonly ghostTexts = new Map<string, TextPicture>();
private visibleCount = 0;
private animated = false;
protected lastItems: DrawItem[] = [];
@@ -66,7 +79,7 @@ export abstract class WorldViewport extends Widget {
protected constructor(protected readonly state: EditorState) {
super();
this.world.addChild(this.objects, this.overlay);
this.world.addChild(this.masks, this.objects, this.overlay);
this.content.addChild(this.world);
this.addChild(this.bg, this.content, this.contentMask);
this.content.mask = this.contentMask;
@@ -129,6 +142,11 @@ export abstract class WorldViewport extends Widget {
protected onItemPointerDown(_objectId: string, _e: FederatedPointerEvent): void {}
/** Whether the part a clip area cuts off the item is shown faintly and can be grabbed (#260). */
protected opensClip(_item: DrawItem): boolean {
return false;
}
/** Whether the object itself is selected – a selected collider shows its area filled. */
protected isSelected(_objectId: string): boolean {
return false;
@@ -338,11 +356,13 @@ export abstract class WorldViewport extends Widget {
this.fields.delete(id);
this.tilemaps.get(id)?.destroy();
this.tilemaps.delete(id);
this.dropGhosts(id);
node.destroy();
this.nodes.delete(id);
this.disposeDecorations(id);
}
this.syncDrawOrder(items);
this.sweepMasks();
}
/**
@@ -352,25 +372,79 @@ export abstract class WorldViewport extends Widget {
*/
private syncDrawOrder(items: DrawItem[]): void {
this.objects.removeChildren();
const byId = new Map(items.map((item) => [item.id, item]));
for (const { id, layerId, row } of drawSlots(items)) {
const clip = byId.get(id)?.clip ?? null;
if (layerId) {
const layer = this.tilemaps.get(id)?.layerContainer(layerId, row);
if (layer) this.objects.addChild(layer);
if (layer) this.objects.addChild(this.clipTo(layer, clip));
continue;
}
// Picture and text are siblings because Graphics may not have children: the picture right
// under its node, which only holds the hit area, the text right above its box.
// under its node, which only holds the hit area, the text right above its box. A ghost goes
// right under what it is the rest of.
const ghost = this.ghostSprites.get(id);
if (ghost) this.objects.addChild(this.clipTo(ghost, clip, true));
const sprite = this.sprites.get(id);
if (sprite) this.objects.addChild(sprite);
if (sprite) this.objects.addChild(this.clipTo(sprite, clip));
const node = this.nodes.get(id);
if (node) this.objects.addChild(node);
const ghostText = this.ghostTexts.get(id);
if (ghostText) this.objects.addChild(this.clipTo(ghostText.mesh, clip, true));
const text = this.texts.get(id);
if (text) this.objects.addChild(text.mesh);
if (text) this.objects.addChild(this.clipTo(text.mesh, clip));
const field = this.fields.get(id);
if (field) this.objects.addChild(field.container);
if (field) this.objects.addChild(this.clipTo(field.container, clip));
}
}
/**
* Cuts `target` to the region, or with `inverse` to everything outside it. The mask lives in
* world units like the objects, and stays with the target until the target goes.
*/
private clipTo<T extends Container>(target: T, region: ClipRegion | null, inverse = false): T {
let mask = this.maskOf.get(target);
if (!region) {
if (mask) {
target.mask = null;
mask.destroy();
this.maskOf.delete(target);
}
return target;
}
if (!mask) {
mask = new Graphics();
this.masks.addChild(mask);
this.maskOf.set(target, mask);
target.setMask({ mask, inverse });
}
mask.clear();
if (region.length >= 3) mask.poly([...region]).fill(0xffffff);
return target;
}
/** Masks whose target is gone: a tilemap drops the containers of layers it no longer has itself. */
private sweepMasks(): void {
for (const [target, mask] of this.maskOf)
if (target.destroyed) {
mask.destroy();
this.maskOf.delete(target);
}
}
private dropGhosts(id: string): void {
const sprite = this.ghostSprites.get(id);
if (sprite) disposeSpriteMesh(sprite);
this.ghostSprites.delete(id);
this.ghostTexts.get(id)?.destroy();
this.ghostTexts.delete(id);
}
/** Whether the item has a cut-off part to show faintly right now. */
private ghosted(item: DrawItem, onScreen: boolean): boolean {
return onScreen && item.clip !== null && this.opensClip(item);
}
/**
* The particles of one source. Their positions are world units, like everything else under
* `objects`, so the container itself carries no transform: the source's own goes into each
@@ -462,6 +536,9 @@ export abstract class WorldViewport extends Widget {
let sprite = this.sprites.get(item.id);
if (item.type !== 'sprite' || !onScreen) {
if (sprite) sprite.visible = false;
const ghost = this.ghostSprites.get(item.id);
if (ghost) disposeSpriteMesh(ghost);
this.ghostSprites.delete(item.id);
return;
}
if (!sprite) {
@@ -472,6 +549,18 @@ export abstract class WorldViewport extends Widget {
sprite.visible = true;
if (wornShader(item.props, shaders)?.time) this.animated = true;
syncSpriteMesh(sprite, item, size, textureLibrary, shaders);
let ghost = this.ghostSprites.get(item.id);
if (!this.ghosted(item, onScreen)) {
if (ghost) disposeSpriteMesh(ghost);
this.ghostSprites.delete(item.id);
return;
}
if (!ghost) {
ghost = newSpriteMesh();
ghost.alpha = CLIP_GHOST_ALPHA;
this.ghostSprites.set(item.id, ghost);
}
syncSpriteMesh(ghost, item, size, textureLibrary, shaders);
}
/**
@@ -487,6 +576,8 @@ export abstract class WorldViewport extends Widget {
let text = this.texts.get(item.id);
if (item.type !== 'text' || !onScreen) {
if (text) text.mesh.visible = false;
this.ghostTexts.get(item.id)?.destroy();
this.ghostTexts.delete(item.id);
return;
}
if (!text) {
@@ -497,5 +588,17 @@ export abstract class WorldViewport extends Widget {
text.mesh.visible = true;
if (wornShader(item.props, shaders)?.time) this.animated = true;
text.sync(item, scale, shaders);
let ghost = this.ghostTexts.get(item.id);
if (!this.ghosted(item, onScreen)) {
ghost?.destroy();
this.ghostTexts.delete(item.id);
return;
}
if (!ghost) {
ghost = new TextPicture();
ghost.mesh.alpha = CLIP_GHOST_ALPHA;
this.ghostTexts.set(item.id, ghost);
}
ghost.sync(item, scale, shaders);
}
}
+2 -2
View File
@@ -340,9 +340,9 @@ export class SizeGizmoEditor {
}
}
/** Only sprites and colliders have a width and a height to pull at; everything else turns and scales. */
/** Only sprites, colliders and clip areas have a width and a height to pull at; everything else turns and scales. */
function hasSizeHandles(item: DrawItem): boolean {
return item.type === 'sprite' || item.type === 'collider';
return item.type === 'sprite' || item.type === 'collider' || item.type === 'clip';
}
/** A polygon keeps its corners to itself, and a frame collider is the frame's size. */
+17 -1
View File
@@ -85,6 +85,7 @@ export class World {
down: false,
pressed: false,
released: false,
wheel: 0,
};
/** Objects the editor is dragging (ids): the physics puts them where the drag does, at rest. */
held: ReadonlySet<string> = new Set();
@@ -373,6 +374,7 @@ export class World {
dropPointerEdges(): void {
this.pointer.pressed = false;
this.pointer.released = false;
this.pointer.wheel = 0;
}
/** Game view: where the mouse is (world units) and whether it is over the view at all. */
@@ -382,6 +384,11 @@ export class World {
this.pointer.inside = inside;
}
/** Game view: the wheel turned by `notches`; what turns within one tick adds up. */
turnWheel(notches: number): void {
this.pointer.wheel += notches;
}
/** Game view: the mouse button changed. Sets the edge flag the current tick reports. */
setPointerDown(down: boolean): void {
if (down === this.pointer.down) return;
@@ -404,7 +411,9 @@ export class World {
*/
hitTest(entity: Entity, x: number, y: number): boolean {
const point = { x, y };
const parts = [entity, ...this.descendantsOf(entity)].filter((e) => isHandleEnabled(e));
const parts = [entity, ...this.descendantsOf(entity)].filter(
(e) => isHandleEnabled(e) && this.insideClipAreas(e, point),
);
const colliders = parts.filter((e) => e.type === 'collider');
if (colliders.length > 0)
return colliders.some((c) =>
@@ -417,6 +426,13 @@ export class World {
);
}
/** Whether the point is inside every clip area above the entity (#260): what is cut off is not hit. */
private insideClipAreas(entity: Entity, point: Vec2): boolean {
for (let at = entity.parent; at; at = at.parent)
if (at.type === 'clip' && !footprintContains('clip', at.props, worldMatrixOf(at), point)) return false;
return true;
}
childrenOf(entity: Entity): Entity[] {
return this.entities.filter((e) => e.parent === entity);
}
+3
View File
@@ -473,6 +473,9 @@ export class BehaviorSystem implements System {
if (pointer.pressed && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) {
i.fire('OnClick', ctx, { x: pointer.x, y: pointer.y });
}
if (pointer.wheel !== 0 && (owner ? world.hitTest(owner, pointer.x, pointer.y) : pointer.inside)) {
i.fire('OnMouseWheel', ctx, { delta: pointer.wheel, x: pointer.x, y: pointer.y });
}
i.fire('OnUpdate', ctx, { dt });
},
dt,
+16 -5
View File
@@ -26,6 +26,7 @@ import { fontFileOf, fontSmoothOf } from '../core/model/font';
import { resolveTexture, type TextureRef } from '../core/model/texture';
import type { FrameOutline } from '../core/model/frameOutline';
import { liveTilemap, type TileEdits } from '../core/model/liveTilemap';
import { type ClipRegion, clipRegions } from '../core/properties/clip';
import { colliderFrame } from '../core/properties/collider';
import { enabledAlongChain } from '../core/properties/enabled';
import { fontOf } from '../core/properties/fonts';
@@ -69,6 +70,8 @@ export interface DrawItem {
emits: number;
/** The frame a collider of the shape "Frame collision" wears (#193), resolved here like a sprite's picture. */
frame: FrameOutline | null;
/** Where the clip areas above it let it be seen and hit (#260), in world units; null when none is. */
clip: ClipRegion | null;
}
interface DrawList {
@@ -222,13 +225,16 @@ export function collectHolderItems(
: null,
emits: 0,
frame: asset.type === 'collider' ? colliderFrame(project, props) : null,
clip: null,
});
}
const byId = new Map(items.map((i) => [i.id, i]));
const parentOf = (i: DrawItem) => byId.get(i.parentId) ?? null;
const regions = clipRegions(items, parentOf);
for (const item of items) {
item.enabled = enabledAlongChain(item, (i) => i.props, parentOf);
item.sortsWith = passedOnTo(parentOf(item), parentOf)?.id ?? '';
item.clip = regions.get(item) ?? null;
}
return { items, background: Number(holder.props.backgroundColor ?? fallbackBackground) };
}
@@ -257,12 +263,16 @@ function passedOnTo<T extends { type: AssetType; props: PropertyBag }>(
/** The running world: scene content only, since symbols are editor-only. */
export function collectWorldItems(world: World): DrawList {
const items = world.entities
.filter((e) => !isSymbolType(e.type) && isHandleEnabled(e))
.map((e) => {
const placed = new Map(
world.entities.map((e) => {
const { m, z } = worldTransformOf(e);
return { e, m, z };
})
return [e, { e, m, z, type: e.type, props: e.props }];
}),
);
// Worked out before the symbols go: a clip area is one, and still cuts what is below it.
const regions = clipRegions([...placed.values()], (p) => (p.e.parent ? placed.get(p.e.parent) : null));
const items = [...placed.values()]
.filter(({ e }) => !isSymbolType(e.type) && isHandleEnabled(e))
.map(({ e, m, z }) => ({
id: e.objectId,
type: e.type,
@@ -283,6 +293,7 @@ export function collectWorldItems(world: World): DrawList {
tilemap: e.type === 'tilemap' ? resolveTilemap(world.project, e.assetId, e.props, e.tileEdits) : null,
emits: e.emits ?? 0,
frame: e.wornFrame?.() ?? null,
clip: regions.get(placed.get(e)!) ?? null,
}));
return { items, background: world.backgroundColor };
}
+23
View File
@@ -23,6 +23,9 @@ import type { TextureLibrary } from './TextureLibrary';
/** Screen-pixel half-width of the grab band along a collider's outline. */
const COLLIDER_GRAB_BAND = 6;
/** Screen-pixel length of a dash of a clip area's outline, and of the gap after it. */
const CLIP_DASH = 6;
/** How strongly a selected collider tints its area. */
export const COLLIDER_FILL_ALPHA = 0.3;
@@ -144,6 +147,26 @@ export function drawItem(
case 'container':
case 'rigidbody':
return;
// Only the dashed outline can be grabbed, so a click inside reaches the content it cuts (#260).
case 'clip': {
const [hw, hh] = [size.w / 2, size.h / 2];
const corners = [
[-hw, -hh],
[hw, -hh],
[hw, hh],
[-hw, hh],
] as const;
corners.forEach(([x1, y1], i) => {
const [x2, y2] = corners[(i + 1) % 4]!;
dashedLine(node, x1, y1, x2, y2, CLIP_DASH / scale, CLIP_DASH / scale);
});
node.stroke({ width: 1.5 / scale, color: Theme.colors.textMuted });
node.hitArea = colliderOutlineHitArea(
{ kind: 'box', x: 0, y: 0, w: size.w, h: size.h, angle: 0 },
COLLIDER_GRAB_BAND / scale,
);
return;
}
case 'text':
node.hitArea = new Rectangle(
-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w,
+10
View File
@@ -49,6 +49,16 @@ function clampIndex(index: number): number {
return Math.max(0, Math.min(FREE_ZOOM_STEPS.length - 1, index));
}
/**
* A wheel event in notches of a mouse wheel, positive downwards like `deltaY` (#260). A notch is
* 100 pixels (see `WHEEL_STEP`) or 3 lines; a page is counted as one notch.
*/
export function wheelNotches(deltaY: number, deltaMode: number): number {
if (deltaMode === 1) return deltaY / 3;
if (deltaMode === 2) return Math.sign(deltaY);
return deltaY / 100;
}
/** Wheel travel for one zoom step: a mouse notch (100) is one step, a trackpad steps every 50 px. */
export const WHEEL_STEP = 50;
+201
View File
@@ -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);
});
});
+1
View File
@@ -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',
+1
View File
@@ -25,6 +25,7 @@ describe('scene visibility', () => {
expect(VISIBILITY_TYPES).toEqual([
'behavior',
'camera',
'clip',
'collider',
'container',
'particles',
+1
View File
@@ -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. */
+3 -3
View File
@@ -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;
+4 -2
View File
@@ -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) => {
+165
View File
@@ -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-'));
`);
}
+2
View File
@@ -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,
];