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:
adminandClaude Opus 5 committed 2026-09-22 19:09:29 +02:00
1 parent d9c71c2ce7
commit ad2a2d7921
6 files changed
+344 -23

No files matched your search

+2
View File
@@ -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),
+99 -21
View File
@@ -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. */