A composition can now sit in a scene like a container and play there. Its items become real objects while they run, so behaviors, colliders and events work on them as on any other object. - A composition is placeable with its own symbol, transform and render layer. The scene view shows its picture at frame 0. A click on that picture selects the placement, not a single item. - CompositionSystem spawns each item as an object under the placement when it begins, with the id <placement>~<item>. On start and the other events run. The object is destroyed when the item ends. - The top track lies in front no matter which item started first. World.arrange() orders the entity list and the scene objects. - Clips and driven videos follow the same rules as compositionAt(). Loop, restart, stop and the game pause work. - New nodes: Play composition (with restart), Stop composition and the event On composition finished. They were planned for step 4 but come now, because without autoplay nothing could play otherwise. - The editor run ignores Play composition and says so in the log. It writes into the saved project, and the items would stay behind. - contentId() moved to instance.ts. The animation system uses it for nested ids too. - Compositions get no "Sort with parent" for now. Tests: the gate passed with 301 files and 4643 tests, 18 more than step 1. 13 of them are in tests/core/composition-play.test.ts. The first gate run failed in event-order.test.ts, which counts the event nodes and now finds 19. Five counter-checks each turned exactly their test red. Two order tests stayed green without the fix at first and were rebuilt. e2e 68 is new and passed with 5 own checks. Its counter-check without arrange() failed as expected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
96 lines
4.3 KiB
TypeScript
96 lines
4.3 KiB
TypeScript
import { isSymbolType } from '../core/model/AssetTypes';
|
||
import { drawSlots, slotKey } 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, and `rows` the row that tile sorts by on each of them sorted by Y (#228).
|
||
*/
|
||
export interface PointerHit {
|
||
id: string;
|
||
layers: readonly string[] | null;
|
||
rows?: ReadonlyMap<string, number>;
|
||
}
|
||
|
||
/**
|
||
* 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. What a placed composition shows answers as the placement,
|
||
* once, where its frontmost part lies (#417).
|
||
*/
|
||
export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[]): string[] {
|
||
const rank = new Map<string, number>();
|
||
drawSlots(items).forEach((slot, i) => rank.set(slotKey(slot.id, slot.layerId, slot.row), 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(slotKey(hit.id, layerId, hit.rows?.get(layerId) ?? null)) ?? -1,
|
||
);
|
||
const best = Math.max(-1, ...ranks);
|
||
if (best >= 0) ranked.push({ id: hit.id, rank: best });
|
||
}
|
||
const ids = ranked.sort((a, b) => b.rank - a.rank).map((r) => items[index.get(r.id)!]!.sealedIn ?? r.id);
|
||
return [...new Set(ids)];
|
||
}
|
||
|
||
/**
|
||
* 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 = heldIn(stack, selected, primary);
|
||
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 };
|
||
}
|
||
|
||
/**
|
||
* Where a click of the eyedropper moves its candidate (#276): the rule of a plain click, over what
|
||
* may be picked alone – a click on the candidate passes on to the next one below, round to the top
|
||
* again, a click elsewhere takes the topmost. Empty space, and a spot with nothing that may be
|
||
* picked, leave the candidate as it is; `refused` tells the second apart, so the user hears why.
|
||
*/
|
||
export function pickOn(
|
||
stack: readonly string[],
|
||
pickable: (id: string) => boolean,
|
||
candidate: string | null,
|
||
): { candidate: string | null; refused: boolean } {
|
||
const offered = stack.filter(pickable);
|
||
const press = pressOn(offered, candidate === null ? [] : [candidate], candidate);
|
||
if (!press) return { candidate, refused: stack.length > 0 };
|
||
return { candidate: press.select ?? press.onClick ?? candidate, refused: false };
|
||
}
|
||
|
||
/**
|
||
* Which object a right-click on `stack` opens the menu for (#204), by the rule a press follows:
|
||
* a selected one under the pointer keeps the selection, otherwise the topmost is selected first.
|
||
*/
|
||
export function menuTargetOf(
|
||
stack: readonly string[],
|
||
selected: readonly string[],
|
||
primary: string | null,
|
||
): { id: string; select: boolean } | null {
|
||
if (stack.length === 0) return null;
|
||
const held = heldIn(stack, selected, primary);
|
||
return held === undefined ? { id: stack[0]!, select: true } : { id: held, select: false };
|
||
}
|
||
|
||
/** The selected object under the pointer, the primary one first; undefined when none is there. */
|
||
function heldIn(stack: readonly string[], selected: readonly string[], primary: string | null) {
|
||
return primary !== null && stack.includes(primary) ? primary : stack.find((id) => selected.includes(id));
|
||
}
|