Clicks in the scene view pick what is drawn under the cursor (#203)
Pixi's own hit test decided what a click in the scene view selected. A tilemap caught clicks over its whole area, even on empty cells and over sprites sorted above one of its layers. A press always grabbed the topmost object, so a hidden selected object could not be dragged, and there was no way to reach an object underneath another one. - New pure rule in src/viewport/pick.ts. It lists the objects under the cursor front to back, in the order they are drawn. - A tilemap counts only where it has a tile. Its depth there is the Z of the highest layer with a tile at that spot. An empty cell is empty space, so a click clears the selection or starts a marquee. - Pressing on any selected object, hidden ones included, drags the selection. Otherwise the press selects the topmost object and drags it, as before. - A click without drag on a selected object selects the next one underneath. After the lowest it starts again at the top. - New debug hook __gm.sceneStack(x, y) returns that list. Decisions: - With a paint tool the selected tilemap paints anywhere on its grid, even under a sprite. With empty cells as empty space an empty cell could not be painted otherwise. - Icons such as colliders and cameras stay in front. A second click now reaches the sprite under a collider. Tests: - ./check.sh: 142 files, 2090 tests, exit 0 (was 141 / 2080) - 10 new unit tests in tests/viewport/pick.test.ts - GM_STEPS=39,40: ALL PASSED, 77 checks - GM_STEPS=1-16,29,39-43: ALL PASSED, 626 checks - 4 new checks in block 40q: sprite over a lower layer, empty cell, cycling crate, map, crate, dragging the hidden selected crate. - Counter test: whole-area tilemap hits and always-topmost presses fail 4 unit tests and all 4 new checks. - Check total 1290 (1286 + 4), computed, not yet counted by CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d9c71c2ce7
commit
ad2a2d7921
6 files changed
+344
-23
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, 1286 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1290 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
|
||||
|
||||
@@ -313,6 +313,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
drawOrder: () => viewport.drawOrder(),
|
||||
objectScreen: (id: string) => viewport.objectScreen(id),
|
||||
objectRect: (id: string) => viewport.objectRect(id),
|
||||
/** What a click at a screen point picks from in the Scene tab, front to back (#203). */
|
||||
sceneStack: (x: number, y: number) => viewport.stackInfo(x, y),
|
||||
/** The same for the Game tab – through the camera, so what the player sees. */
|
||||
gameScreen: (id: string) => gameView.objectScreen(id),
|
||||
gameRect: (id: string) => gameView.objectRect(id),
|
||||
|
||||
@@ -60,7 +60,8 @@ import {
|
||||
} from '../../ui';
|
||||
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
|
||||
import { collectHolderItems, type DrawItem } from '../../viewport/items';
|
||||
import type { LayerFocus } from '../../viewport/tilemap';
|
||||
import { type PointerHit, pressOn, stackAt } from '../../viewport/pick';
|
||||
import { type LayerFocus, layerAlpha } from '../../viewport/tilemap';
|
||||
import { CONE_LENGTH, dashedLine, markBlockingSide, placeNode } from '../../viewport/symbols';
|
||||
import { drawSymbolIcon } from '../../viewport/symbolIcons';
|
||||
import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
|
||||
@@ -86,6 +87,8 @@ interface ObjectDrag {
|
||||
items: { objectId: string; start: Position; toLocal: Mat }[];
|
||||
pointer: Vec2;
|
||||
moved: boolean;
|
||||
/** Selected on release when the press did not move: the next object below (#203). */
|
||||
onClick: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,16 +286,22 @@ export class SceneViewport extends FreeViewport {
|
||||
return;
|
||||
}
|
||||
if (e.button === 0 && this.startCornerEdit(e)) return;
|
||||
if (e.button === 0 && e.target === this) {
|
||||
// Empty space: a click deselects, a drag becomes a rubber band – decided on release.
|
||||
const local = this.toLocal(e.global);
|
||||
this.marquee = {
|
||||
start: { x: local.x, y: local.y },
|
||||
current: { x: local.x, y: local.y },
|
||||
active: false,
|
||||
};
|
||||
e.stopPropagation();
|
||||
if (e.button === 0 && e.target === this) this.pressEmpty(e);
|
||||
}
|
||||
|
||||
/** Empty space: a click deselects, a drag becomes a rubber band – decided on release. */
|
||||
private pressEmpty(e: FederatedPointerEvent): void {
|
||||
if (e.shiftKey) {
|
||||
this.startPan(e);
|
||||
return;
|
||||
}
|
||||
const local = this.toLocal(e.global);
|
||||
this.marquee = {
|
||||
start: { x: local.x, y: local.y },
|
||||
current: { x: local.x, y: local.y },
|
||||
active: false,
|
||||
};
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
@@ -413,6 +422,8 @@ export class SceneViewport extends FreeViewport {
|
||||
}
|
||||
const drag = this.dragging;
|
||||
this.dragging = null;
|
||||
if (drag && !drag.moved && drag.onClick)
|
||||
this.state.select(objectSelection(drag.holderId, [drag.onClick]));
|
||||
if (!drag || !drag.moved) return;
|
||||
const holder = this.holder();
|
||||
if (!holder) return;
|
||||
@@ -684,23 +695,35 @@ export class SceneViewport extends FreeViewport {
|
||||
return this.lastCorners;
|
||||
}
|
||||
|
||||
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||||
/**
|
||||
* 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
|
||||
* layer below a sprite does not put the map in front of it.
|
||||
*/
|
||||
protected override onItemPointerDown(_objectId: string, e: FederatedPointerEvent): void {
|
||||
if (e.button !== 0) return;
|
||||
if (this.startCornerEdit(e)) return;
|
||||
const holder = this.resolved();
|
||||
const object = holder && findObject(holder, objectId);
|
||||
if (!holder || !object) return;
|
||||
e.stopPropagation();
|
||||
if (!holder) return;
|
||||
const stack = this.stackUnder(e);
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
this.state.toggleObject(holder.id, objectId);
|
||||
if (stack.length === 0) this.pressEmpty(e);
|
||||
else {
|
||||
e.stopPropagation();
|
||||
this.state.toggleObject(holder.id, stack[0]!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A painting tool on the selected tilemap: the press starts a stroke instead of a move.
|
||||
const target = this.paintTargetOf(objectId);
|
||||
e.stopPropagation();
|
||||
// A painting tool on the selected tilemap paints anywhere on its grid, empty cells included.
|
||||
const target = this.paintTargetUnder(e);
|
||||
if (target && this.usePaintTool(target, holder.id, e)) return;
|
||||
// Clicking a selected object keeps the selection so the whole group can be dragged.
|
||||
if (!this.state.selectedObjectIds.includes(objectId))
|
||||
this.state.select(objectSelection(holder.id, [objectId]));
|
||||
const press = pressOn(stack, this.state.selectedObjectIds, this.primaryObjectId());
|
||||
if (!press) {
|
||||
this.pressEmpty(e);
|
||||
return;
|
||||
}
|
||||
if (press.select) this.state.select(objectSelection(holder.id, [press.select]));
|
||||
// Only the tops of the selected subtrees move; descendants follow through the hierarchy.
|
||||
const items = subtreeRoots(holder, this.state.selectedObjectIds)
|
||||
.map((id) => findObject(holder, id))
|
||||
@@ -711,7 +734,62 @@ export class SceneViewport extends FreeViewport {
|
||||
toLocal: invert(linearOf(parentTransform(this.state.project, holder, o.parentId).m)),
|
||||
}));
|
||||
const local = this.toLocal(e.global);
|
||||
this.dragging = { holderId: holder.id, items, pointer: { x: local.x, y: local.y }, moved: false };
|
||||
this.dragging = {
|
||||
holderId: holder.id,
|
||||
items,
|
||||
pointer: { x: local.x, y: local.y },
|
||||
moved: false,
|
||||
onClick: press.onClick,
|
||||
};
|
||||
}
|
||||
|
||||
private primaryObjectId(): string | null {
|
||||
const s = this.state.selection;
|
||||
return s?.kind === 'object' ? s.objectId : null;
|
||||
}
|
||||
|
||||
/** Every object whose hit area holds the pointer, a tilemap with the layers that have a tile there. */
|
||||
private hitsUnder(global: Vec2): PointerHit[] {
|
||||
const hits: PointerHit[] = [];
|
||||
const pointer = this.toLocal(global);
|
||||
const world = screenToWorld(this.currentView(), pointer.x, pointer.y);
|
||||
for (const item of this.lastItems) {
|
||||
const node = this.nodes.get(item.id);
|
||||
if (!node?.visible || node.eventMode === 'none') continue;
|
||||
const p = node.toLocal(global);
|
||||
if (!(node.hitArea ? node.hitArea.contains(p.x, p.y) : node.containsPoint(p))) continue;
|
||||
if (item.type !== 'tilemap') {
|
||||
hits.push({ id: item.id, layers: null });
|
||||
continue;
|
||||
}
|
||||
const inMap = applyPoint(invert(item.m), world);
|
||||
const cell = cellAtPoint(item.props, inMap.x, inMap.y);
|
||||
const focus = this.tilemapFocus(item);
|
||||
const layers = (item.tilemap?.layers ?? [])
|
||||
.filter((layer) => layerAlpha(layer.id, focus) > 0)
|
||||
.filter((layer) => layer.tiles.some((t) => t.x === cell.x && t.y === cell.y))
|
||||
.map((layer) => layer.id);
|
||||
hits.push({ id: item.id, layers });
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** What is under the pointer, front to back as drawn (#203). */
|
||||
private stackUnder(e: FederatedPointerEvent): string[] {
|
||||
return stackAt(this.lastItems, this.hitsUnder(e.global));
|
||||
}
|
||||
|
||||
/** The painted tilemap when the pointer is on its grid, whatever lies above it there. */
|
||||
private paintTargetUnder(e: FederatedPointerEvent): ReturnType<SceneViewport['paintTargetOf']> {
|
||||
const target = paintedTilemap(this.state);
|
||||
if (!target) return null;
|
||||
const onGrid = this.hitsUnder(e.global).some((hit) => hit.id === target.objectId);
|
||||
return onGrid ? this.paintTargetOf(target.objectId) : null;
|
||||
}
|
||||
|
||||
/** What lies under a point of the screen, front to back – what a click there picks from (test hook). */
|
||||
stackInfo(x: number, y: number): string[] {
|
||||
return stackAt(this.lastItems, this.hitsUnder({ x, y }));
|
||||
}
|
||||
|
||||
/** Selects every object whose position – not its bounds – lies inside the band. */
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import { drawSlots } from './drawOrder';
|
||||
import type { DrawItem } from './items';
|
||||
|
||||
/** An object whose hit area is under the pointer; `layers` names a tilemap's layers with a tile there. */
|
||||
export interface PointerHit {
|
||||
id: string;
|
||||
layers: readonly string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What lies under the pointer, front to back as it is drawn (#203). A tilemap counts only where
|
||||
* one of its layers has a tile, and at the height of the highest such layer: an empty cell is
|
||||
* empty space, and a sprite drawn above those layers is in front of the map. Symbols are drawn
|
||||
* above everything, so they come first.
|
||||
*/
|
||||
export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[]): string[] {
|
||||
const rank = new Map<string, number>();
|
||||
drawSlots(items).forEach((slot, i) => rank.set(`${slot.id}#${slot.layerId}`, i));
|
||||
const symbolsFrom = rank.size;
|
||||
const index = new Map(items.map((item, i) => [item.id, i]));
|
||||
const ranked: { id: string; rank: number }[] = [];
|
||||
for (const hit of hits) {
|
||||
const at = index.get(hit.id);
|
||||
if (at === undefined) continue;
|
||||
const ranks = isSymbolType(items[at]!.type)
|
||||
? [symbolsFrom + at]
|
||||
: (hit.layers ?? ['']).map((layerId) => rank.get(`${hit.id}#${layerId}`) ?? -1);
|
||||
const best = Math.max(-1, ...ranks);
|
||||
if (best >= 0) ranked.push({ id: hit.id, rank: best });
|
||||
}
|
||||
return ranked.sort((a, b) => b.rank - a.rank).map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a plain press on `stack` does (#203). With a selected object under the pointer – hidden
|
||||
* behind others or not – the selection stays and a drag moves it; a click without moving then
|
||||
* passes on to the next object below, round to the top again. Otherwise the topmost is selected
|
||||
* and dragged at once. Null when nothing is under the pointer.
|
||||
*/
|
||||
export function pressOn(
|
||||
stack: readonly string[],
|
||||
selected: readonly string[],
|
||||
primary: string | null,
|
||||
): { select: string | null; onClick: string | null } | null {
|
||||
if (stack.length === 0) return null;
|
||||
const held =
|
||||
primary !== null && stack.includes(primary) ? primary : stack.find((id) => selected.includes(id));
|
||||
if (held === undefined) return { select: stack[0]!, onClick: null };
|
||||
const next = stack[(stack.indexOf(held) + 1) % stack.length]!;
|
||||
return { select: null, onClick: next === held ? null : next };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DrawItem } from '../../src/viewport/items';
|
||||
import { pressOn, stackAt } from '../../src/viewport/pick';
|
||||
|
||||
/** Only what the pick order reads: the type, the `z`, and a map's layers with their own `z`. */
|
||||
function item(
|
||||
id: string,
|
||||
type: DrawItem['type'],
|
||||
z: number,
|
||||
layers: { id: string; z: number }[] = [],
|
||||
): DrawItem {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
z,
|
||||
tilemap: type === 'tilemap' ? { layers: layers.map((l) => ({ ...l, tiles: [] })) } : null,
|
||||
} as unknown as DrawItem;
|
||||
}
|
||||
|
||||
describe('what lies under the pointer (#203)', () => {
|
||||
const map = item('map', 'tilemap', 0, [
|
||||
{ id: 'ground', z: -5 },
|
||||
{ id: 'roofs', z: 5 },
|
||||
]);
|
||||
const hero = item('hero', 'sprite', -1);
|
||||
|
||||
it('puts a sprite in front of a map whose only tile there is on a layer below it', () => {
|
||||
expect(
|
||||
stackAt(
|
||||
[map, hero],
|
||||
[
|
||||
{ id: 'map', layers: ['ground'] },
|
||||
{ id: 'hero', layers: null },
|
||||
],
|
||||
),
|
||||
).toEqual(['hero', 'map']);
|
||||
});
|
||||
|
||||
it('puts the map in front where a layer above the sprite has a tile', () => {
|
||||
expect(
|
||||
stackAt(
|
||||
[map, hero],
|
||||
[
|
||||
{ id: 'map', layers: ['ground', 'roofs'] },
|
||||
{ id: 'hero', layers: null },
|
||||
],
|
||||
),
|
||||
).toEqual(['map', 'hero']);
|
||||
});
|
||||
|
||||
it('leaves out a map without a tile under the pointer', () => {
|
||||
expect(
|
||||
stackAt(
|
||||
[map, hero],
|
||||
[
|
||||
{ id: 'map', layers: [] },
|
||||
{ id: 'hero', layers: null },
|
||||
],
|
||||
),
|
||||
).toEqual(['hero']);
|
||||
});
|
||||
|
||||
it('orders sprites by z, ties in hierarchy order, with symbols in front of all', () => {
|
||||
const items = [
|
||||
item('a', 'sprite', 2),
|
||||
item('b', 'sprite', 0),
|
||||
item('c', 'sprite', 2),
|
||||
item('box', 'collider', -9),
|
||||
];
|
||||
const hits = items.map((i) => ({ id: i.id, layers: null }));
|
||||
expect(stackAt(items, hits)).toEqual(['box', 'c', 'a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a press picks (#203)', () => {
|
||||
const stack = ['top', 'middle', 'bottom'];
|
||||
|
||||
it('selects the topmost when nothing selected is under the pointer', () => {
|
||||
expect(pressOn(stack, ['elsewhere'], 'elsewhere')).toEqual({ select: 'top', onClick: null });
|
||||
});
|
||||
|
||||
it('keeps a selection hidden under others, so a drag moves it', () => {
|
||||
expect(pressOn(stack, ['bottom'], 'bottom')?.select).toBeNull();
|
||||
});
|
||||
|
||||
it('passes a click on to the next one below, and round to the top again', () => {
|
||||
expect(pressOn(stack, ['top'], 'top')?.onClick).toBe('middle');
|
||||
expect(pressOn(stack, ['middle'], 'middle')?.onClick).toBe('bottom');
|
||||
expect(pressOn(stack, ['bottom'], 'bottom')?.onClick).toBe('top');
|
||||
});
|
||||
|
||||
it('goes on from the primary one of a group', () => {
|
||||
expect(pressOn(stack, ['top', 'middle'], 'middle')).toEqual({ select: null, onClick: 'bottom' });
|
||||
});
|
||||
|
||||
it('changes nothing on a click when the selected object is alone there', () => {
|
||||
expect(pressOn(['only'], ['only'], 'only')).toEqual({ select: null, onClick: null });
|
||||
});
|
||||
|
||||
it('is nothing on empty space', () => {
|
||||
expect(pressOn([], ['top'], 'top')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { colourDistance } from '../lib/harness.mjs';
|
||||
import { LANES, openTab, pickAssetType } from './lib/scaffold.mjs';
|
||||
import { LANES, MAKE_OBJECT, openTab, pickAssetType } from './lib/scaffold.mjs';
|
||||
import { quadColour } from './39-tile.mjs';
|
||||
|
||||
/**
|
||||
@@ -670,6 +670,92 @@ export async function tilemap(t) {
|
||||
await undo();
|
||||
await settle();
|
||||
|
||||
// 40q. A click picks what is drawn under the pointer (#203). A crate of its own sits on the 3 × 3
|
||||
// block at z −1, below the map's 0; with the block's layer sorted in at −5 the crate is drawn
|
||||
// in front, so it is what a click there takes, although the map's grid spans the spot.
|
||||
const CRATE = 'tm-pick-crate';
|
||||
const blockLayer = `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.find((l) => l.cells['1,1'])`;
|
||||
await mutate(`
|
||||
const scene = __gm.state.activeScene;
|
||||
${MAKE_OBJECT}
|
||||
const crate = make(${JSON.stringify(CRATE)}, 'crate', '', ${LANE - 24}, 'Pick crate');
|
||||
crate.position = { x: -72, y: ${LANE - 24}, z: -1 };
|
||||
scene.objects.push(crate);
|
||||
${blockLayer}.z = -5;`);
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await settle();
|
||||
const cellPoint = (x, y) => json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, ${x}, ${y})`);
|
||||
const stackAt = (p) => json(`__gm.sceneStack(${p.x}, ${p.y})`);
|
||||
const selected = () => json('__gm.state.selectedObjectIds');
|
||||
const onBlock = await cellPoint(1, 1);
|
||||
const stackBelow = await stackAt(onBlock);
|
||||
await click(onBlock.x, onBlock.y);
|
||||
await settle();
|
||||
const pickedBelow = await selected();
|
||||
check(
|
||||
'a sprite drawn over a map layer sorted in below it is what a click there picks',
|
||||
JSON.stringify(stackBelow) === JSON.stringify([CRATE, MAP_OBJECT]) &&
|
||||
JSON.stringify(pickedBelow) === JSON.stringify([CRATE]),
|
||||
JSON.stringify({ stackBelow, pickedBelow }),
|
||||
);
|
||||
|
||||
// An empty cell is empty space: the click there takes nothing and clears the selection.
|
||||
const onEmpty = await cellPoint(COLUMNS - 1, ROWS - 1);
|
||||
const stackEmpty = await stackAt(onEmpty);
|
||||
await click(onEmpty.x, onEmpty.y);
|
||||
await settle();
|
||||
const pickedEmpty = await selected();
|
||||
check(
|
||||
'a click on an empty cell of the map selects nothing',
|
||||
stackEmpty.length === 0 && pickedEmpty.length === 0,
|
||||
JSON.stringify({ stackEmpty, pickedEmpty }),
|
||||
);
|
||||
|
||||
// Clicking what is selected passes on to the next one below, and from the bottom round to the top.
|
||||
const cycle = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const p = await cellPoint(1, 1);
|
||||
await click(p.x, p.y);
|
||||
await settle();
|
||||
cycle.push((await selected()).join(','));
|
||||
}
|
||||
check(
|
||||
'clicking the selected object again selects the one below it, then the top one again',
|
||||
JSON.stringify(cycle) === JSON.stringify([CRATE, MAP_OBJECT, CRATE]),
|
||||
JSON.stringify(cycle),
|
||||
);
|
||||
|
||||
// With the layer sorted in above the crate the map is in front – and a drag on the selected crate
|
||||
// still moves the crate, not the map on top of it.
|
||||
await mutate(`${blockLayer}.z = 5;`);
|
||||
await select({ kind: 'object', holderId: 'scene1', objectIds: [CRATE], objectId: CRATE });
|
||||
await settle();
|
||||
const onTop = await cellPoint(1, 1);
|
||||
const stackAbove = await stackAt(onTop);
|
||||
const crateBefore = (await t.object(CRATE)).position;
|
||||
const mapUnder = (await t.object(MAP_OBJECT)).position;
|
||||
await drag(onTop.x, onTop.y, onTop.x + 40, onTop.y);
|
||||
await settle();
|
||||
const crateAfter = (await t.object(CRATE)).position;
|
||||
const mapUnderAfter = (await t.object(MAP_OBJECT)).position;
|
||||
const pickedDrag = await selected();
|
||||
check(
|
||||
'a drag on a selected sprite hidden under the map moves the sprite and leaves the map',
|
||||
JSON.stringify(stackAbove) === JSON.stringify([MAP_OBJECT, CRATE]) &&
|
||||
Math.round(crateAfter.x - crateBefore.x) === 40 &&
|
||||
JSON.stringify(mapUnderAfter) === JSON.stringify(mapUnder) &&
|
||||
JSON.stringify(pickedDrag) === JSON.stringify([CRATE]),
|
||||
JSON.stringify({ stackAbove, crateBefore, crateAfter, mapUnder, mapUnderAfter, pickedDrag }),
|
||||
);
|
||||
await undo();
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await mutate(`
|
||||
const scene = __gm.state.activeScene;
|
||||
scene.objects = scene.objects.filter((o) => o.id !== ${JSON.stringify(CRATE)});
|
||||
delete ${blockLayer}.z;`);
|
||||
await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT });
|
||||
await settle();
|
||||
|
||||
// 40o. The grid grows at the left edge (#136): the cells of both layers move one column in, the
|
||||
// origin moves half a cell to the left, and on screen the painted cells stay where they were.
|
||||
const allCells = () =>
|
||||
|
||||
Reference in new issue
Block a user