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>
53 lines
2.3 KiB
TypeScript
53 lines
2.3 KiB
TypeScript
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 };
|
||
}
|