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

+52
View File
@@ -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 };
}