diff --git a/README.md b/README.md index 73883ebe..50f5c8d3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 0249511f..b010a78d 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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), diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index afb28c2f..85617a46 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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 { + 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. */ diff --git a/src/viewport/pick.ts b/src/viewport/pick.ts new file mode 100644 index 00000000..467be0cd --- /dev/null +++ b/src/viewport/pick.ts @@ -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(); + 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 }; +} diff --git a/tests/viewport/pick.test.ts b/tests/viewport/pick.test.ts new file mode 100644 index 00000000..e08871d5 --- /dev/null +++ b/tests/viewport/pick.test.ts @@ -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(); + }); +}); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 61b1109e..82077a30 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -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 = () =>