Files
feuerwerk/src/viewport/pick.ts
T
adminandClaude Opus 5 ad2a2d7921 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>
2026-09-22 19:09:29 +02:00

53 lines
2.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}