Two sprites with videos back to back left a blank frame at the cut. The video player only came into being once the item began, and a sprite is not drawn until its video has a first frame. So the second item was missing until its video had loaded. - compositionAhead lists the items that begin within the next second. Near the end of a looping composition it also lists the items at its start. - An item that is alive already is not loaded ahead. Two times would otherwise pull at the same player. - The frame carries these videos hidden and paused on their first picture at the item's offset. Their players get the id the item will have, so the cut reuses them instead of opening a second one. - This works the same in the tab's preview and in the game. - One second of lead (AHEAD_SECONDS) is an assumption. It can go up if large videos still flash. Tests: - Unit composition: 27 green, 2 new. Unit composition-play: 14 green, 1 new for the path in the game. - e2e 69: ALL PASSED, 12 checks. The new 69h puts two video items back to back and checks the second player is open and paused at its offset before the cut, and drawn in the same redraw as the cut. - Without loading ahead in the preview 69h fails with cut: null. Bending two spots in the model turned their unit tests red. - Gate on melektaus-atx: CHECK EXIT 0, 302 files, 4665 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
390 lines
16 KiB
TypeScript
390 lines
16 KiB
TypeScript
import { isHandleEnabled, worldTransformOf } from '../core/graph';
|
||
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
|
||
import { treeOrder } from '../core/model/hierarchy';
|
||
import { compositionAhead, compositionFrameAt, placedPicture } from '../core/model/composition';
|
||
import { contentId, placedWithContent, resolvedHolder } from '../core/model/instance';
|
||
import type { Mat } from '../core/model/matrix';
|
||
import type { PropertyBag } from '../core/model/Properties';
|
||
import type { CompositionAsset, ParticlesAsset, TilemapAsset } from '../core/model/Asset';
|
||
import {
|
||
readTile,
|
||
tileFps,
|
||
type TileInfo,
|
||
type TilePlayback,
|
||
tilePlayback,
|
||
tileSortOffset,
|
||
} from '../core/model/tile';
|
||
import { isLayerVisible, layerTiles, layerZ, type TileDraw } from '../core/model/tilemap';
|
||
import {
|
||
type CellPolygon,
|
||
type CellRect,
|
||
oneWayAngles,
|
||
solidPolygons,
|
||
solidRects,
|
||
triggerPolygons,
|
||
triggerRects,
|
||
} from '../core/model/tilemapCollision';
|
||
import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project';
|
||
import { objectName, type SceneObject } from '../core/model/SceneObject';
|
||
import { fontFileOf, fontSmoothOf } from '../core/model/font';
|
||
import { resolveTexture, type TextureRef } from '../core/model/texture';
|
||
import type { FrameOutline } from '../core/model/frameOutline';
|
||
import { liveTilemap, type TileEdits } from '../core/model/liveTilemap';
|
||
import { isVideoKey } from '../core/model/video';
|
||
import { renderLayerAlong } from '../core/model/renderLayers';
|
||
import { type ClipRegion, clipRegions } from '../core/properties/clip';
|
||
import { colliderFrame } from '../core/properties/collider';
|
||
import { enabledAlongChain } from '../core/properties/enabled';
|
||
import { fontOf } from '../core/properties/fonts';
|
||
import { videoDrivenOf } from '../core/properties/videos';
|
||
import { SORT_WITH_PARENT_KEY } from '../core/properties/PropertySchema';
|
||
import { resolveSceneObjectProps } from '../core/properties/resolve';
|
||
import { editorTransform } from '../core/properties/transform';
|
||
import type { World } from '../runtime/World';
|
||
|
||
/** What the viewport draws: the same shape whether it comes from the model or the world. */
|
||
export interface DrawItem {
|
||
id: string;
|
||
type: AssetType;
|
||
name: string;
|
||
/** World position, parents already applied, in the scene view including editor offsets. */
|
||
x: number;
|
||
y: number;
|
||
z: number;
|
||
/** The whole world transform the position is part of: how the item is turned, sized, mirrored. */
|
||
m: Mat;
|
||
props: PropertyBag;
|
||
/** Id of the parent item, empty when top-level. */
|
||
parentId: string;
|
||
/**
|
||
* The object a container between this one and it passes this one on to (#243): the container's
|
||
* switch "Sort with parent" is on, so this one is drawn in that object's place. Empty when none.
|
||
*/
|
||
sortsWith: string;
|
||
/** Effective switch: this object and all its parents are on (see `properties/enabled.ts`). */
|
||
enabled: boolean;
|
||
/** Resolved here because drawing has no access to the project. */
|
||
texture: TextureRef | null;
|
||
/** The typeface file a text wears, resolved here for the same reason; `''` is the default one. */
|
||
font: string;
|
||
/** False when that face's Filter is Nearest: rasterised a pixel per unit and magnified sharp. */
|
||
fontSmooth: boolean;
|
||
/** The asset a particle source emits from, for the same reason again: it carries the graph. */
|
||
particles: ParticlesAsset | null;
|
||
/** What a placed tilemap draws: its layers already cut into tiles, with their pictures resolved. */
|
||
tilemap: TilemapDraw | null;
|
||
/** How often an `Emit` node has restarted this source; a change is what makes it start over. */
|
||
emits: number;
|
||
/** The frame a collider of the shape "Frame collision" wears (#193), resolved here like a sprite's picture. */
|
||
frame: FrameOutline | null;
|
||
/** Where the clip areas above it let it be seen and hit (#260), in world units; null when none is. */
|
||
clip: ClipRegion | null;
|
||
/** The render layer it is on, its parents' choice included (#302): which cameras see it. */
|
||
renderLayer: number;
|
||
/** Part of what a placed composition shows (#417): the placement, which a click on it selects. */
|
||
sealedIn?: string;
|
||
}
|
||
|
||
/** A sprite not drawn yet whose driven video a view opens ahead (#417), under the id it will be drawn by. */
|
||
export type VideoAhead = Pick<DrawItem, 'id' | 'texture' | 'props'>;
|
||
|
||
interface DrawList {
|
||
items: DrawItem[];
|
||
background: number;
|
||
/** The videos of composition items about to begin, see `compositionAhead`. */
|
||
ahead?: VideoAhead[];
|
||
}
|
||
|
||
/**
|
||
* The sprites among `objects` – already laid out, container content and all – whose video is driven:
|
||
* what a view wears ahead for a composition's coming items.
|
||
*/
|
||
function drivenVideos(project: Project, objects: readonly SceneObject[]): VideoAhead[] {
|
||
return objects.flatMap((object) => {
|
||
const asset = findAsset(project, object.assetId);
|
||
if (asset?.type !== 'sprite') return [];
|
||
const props = resolveSceneObjectProps(project, asset, object);
|
||
const texture = resolveTexture(project, props);
|
||
return texture && isVideoKey(texture.file) && videoDrivenOf(props)
|
||
? [{ id: object.id, texture, props }]
|
||
: [];
|
||
});
|
||
}
|
||
|
||
/** The videos of a composition's items about to begin after `frame`, by the ids its own tab draws them by. */
|
||
export function compositionVideosAhead(
|
||
project: Project,
|
||
comp: CompositionAsset,
|
||
frame: number,
|
||
): VideoAhead[] {
|
||
return drivenVideos(project, resolvedHolder(project, compositionAhead(project, comp, frame)).objects);
|
||
}
|
||
|
||
/** The same for every composition the run plays, by the ids its items take below their placement. */
|
||
function worldVideosAhead(world: World): VideoAhead[] {
|
||
return world.entities.flatMap((placement) => {
|
||
const player = placement.composition;
|
||
const comp = player && !player.stopped && findAssetOf(world.project, placement.assetId, 'composition');
|
||
if (!comp) return [];
|
||
const frame = compositionFrameAt(comp, player.time);
|
||
const objects = compositionAhead(world.project, comp, frame).objects.flatMap((o) =>
|
||
placedWithContent(world.project, o, contentId(placement.objectId, o.id), placement.objectId),
|
||
);
|
||
return drivenVideos(world.project, objects);
|
||
});
|
||
}
|
||
|
||
/** One tile a tilemap paints with, as drawing needs it: the blocks and how they run. */
|
||
export interface TilemapTile {
|
||
info: TileInfo;
|
||
playback: TilePlayback;
|
||
fps: number;
|
||
/** Rows further down its cells are sorted on a layer sorted by Y (#228). */
|
||
sortOffset: number;
|
||
}
|
||
|
||
/**
|
||
* One layer, already worked out: which quarters each cell shows and which tile draws it, and how
|
||
* far above the map's own `z` it is sorted in among the other objects (#174) – as one piece, or
|
||
* row by row by Y (#228).
|
||
*/
|
||
export interface TilemapLayerDraw {
|
||
id: string;
|
||
z: number;
|
||
sortByY: boolean;
|
||
tiles: readonly TileDraw[];
|
||
}
|
||
|
||
export interface TilemapDraw {
|
||
layers: TilemapLayerDraw[];
|
||
tiles: Map<string, TilemapTile>;
|
||
/** Where the map blocks, in cells – the scene view outlines it like a collider (#132). */
|
||
solids: readonly CellRect[];
|
||
/** The cells that block with an outline (#141), outlined the same way. */
|
||
outlines: readonly CellPolygon[];
|
||
/**
|
||
* The same for one-way tiles (#155), which block only from above; each rectangle with the angle
|
||
* its tiles land things within (#159), since tiles of two angles never merge.
|
||
*/
|
||
oneWay: { solids: (CellRect & { angle: number })[]; outlines: CellPolygon[] };
|
||
/** Where its trigger tiles lie (#160): they block nothing and are outlined like a trigger collider. */
|
||
triggers: { solids: readonly CellRect[]; outlines: readonly CellPolygon[] };
|
||
/**
|
||
* Where one layer alone blocks, the same way – the scene view fills only the painted layer's
|
||
* part. Worked out when asked for, since only the selected map ever asks.
|
||
*/
|
||
layerSolids(layerId: string): TilemapSolids;
|
||
}
|
||
|
||
/** Where a map, or one of its layers, blocks. */
|
||
export type TilemapSolids = Pick<TilemapDraw, 'solids' | 'outlines' | 'oneWay' | 'triggers'>;
|
||
|
||
function solidsOf(project: Project, props: PropertyBag, asset: TilemapAsset): TilemapSolids {
|
||
const angles = oneWayAngles(project, asset);
|
||
return {
|
||
solids: solidRects(project, props, asset),
|
||
outlines: solidPolygons(project, props, asset),
|
||
oneWay: {
|
||
solids: angles.flatMap((angle) =>
|
||
solidRects(project, props, asset, angle).map((rect) => ({ ...rect, angle })),
|
||
),
|
||
outlines: angles.flatMap((angle) => solidPolygons(project, props, asset, angle)),
|
||
},
|
||
triggers: {
|
||
solids: triggerRects(project, props, asset),
|
||
outlines: triggerPolygons(project, props, asset),
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A placed tilemap as the viewport needs it. The cells are turned into tiles here, where the
|
||
* project is at hand, for the same reason a sprite's picture is resolved here: drawing has no
|
||
* access to it. A hidden layer is left out, in the editor and in the running game alike.
|
||
*/
|
||
function resolveTilemap(
|
||
project: Project,
|
||
assetId: string,
|
||
props: PropertyBag,
|
||
edits: TileEdits | undefined,
|
||
): TilemapDraw | null {
|
||
const stored = findAssetOf(project, assetId, 'tilemap');
|
||
if (!stored) return null;
|
||
const asset = liveTilemap(stored, edits);
|
||
const layers: TilemapLayerDraw[] = [];
|
||
const used = new Map<string, TilemapTile>();
|
||
for (const layer of asset.layers) {
|
||
if (!isLayerVisible(layer)) continue;
|
||
const tiles = layerTiles(project, props, layer);
|
||
layers.push({ id: layer.id, z: layerZ(layer), sortByY: layer.sortByY === true, tiles });
|
||
for (const tile of tiles) {
|
||
if (used.has(tile.tileId)) continue;
|
||
const source = findAssetOf(project, tile.tileId, 'tile');
|
||
if (!source) continue;
|
||
used.set(tile.tileId, {
|
||
info: readTile(project, source),
|
||
playback: tilePlayback(source),
|
||
fps: tileFps(source),
|
||
sortOffset: tileSortOffset(source),
|
||
});
|
||
}
|
||
}
|
||
return {
|
||
layers,
|
||
tiles: used,
|
||
...solidsOf(project, props, asset),
|
||
layerSolids: (layerId) =>
|
||
solidsOf(project, props, { ...asset, layers: asset.layers.filter((layer) => layer.id === layerId) }),
|
||
};
|
||
}
|
||
|
||
/** Both kinds of object that name a picture in `texture`: the sprite that wears it and the source
|
||
* that throws it. */
|
||
function wearsPicture(type: AssetType): boolean {
|
||
return type === 'sprite' || type === 'particles';
|
||
}
|
||
|
||
/**
|
||
* A scene or a container template, in tree order – the tie-break for equal `z` – at the editor
|
||
* positions, with the content of placed containers resolved in. A container has no background of
|
||
* its own, so it keeps the fallback. `run` is the editor run on that scene: the tiles its behaviors
|
||
* set are shown on the maps, though they never reach the model (#135). A placed composition shows
|
||
* its first frame (#417) unless `pictures` is off – in play mode, where its items are objects of the
|
||
* run itself.
|
||
*/
|
||
export function collectHolderItems(
|
||
project: Project,
|
||
holder: ObjectHolder | undefined,
|
||
fallbackBackground: number,
|
||
run: World | null = null,
|
||
pictures = true,
|
||
): DrawList {
|
||
if (!holder) return { items: [], background: fallbackBackground };
|
||
const resolved = resolvedHolder(project, holder);
|
||
const sealed = new Map<string, string>();
|
||
const shown = pictures
|
||
? resolved.objects.flatMap((placement) => {
|
||
const comp = findAssetOf(project, placement.assetId, 'composition');
|
||
const picture = comp ? placedPicture(project, comp, placement.id) : [];
|
||
for (const o of picture) sealed.set(o.id, placement.id);
|
||
return picture;
|
||
})
|
||
: [];
|
||
const view = shown.length > 0 ? { ...resolved, objects: [...resolved.objects, ...shown] } : resolved;
|
||
const items: DrawItem[] = [];
|
||
for (const { object } of treeOrder(view)) {
|
||
const asset = findAsset(project, object.assetId);
|
||
if (!asset) continue;
|
||
const props = resolveSceneObjectProps(project, asset, object);
|
||
const { m, z } = editorTransform(project, view, object);
|
||
items.push({
|
||
id: object.id,
|
||
type: asset.type,
|
||
name: objectName(object, asset),
|
||
x: m.tx,
|
||
y: m.ty,
|
||
z,
|
||
m,
|
||
props,
|
||
parentId: object.parentId,
|
||
sortsWith: '',
|
||
enabled: true,
|
||
texture: wearsPicture(asset.type) ? resolveTexture(project, props) : null,
|
||
font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '',
|
||
fontSmooth: fontSmoothOf(project, fontOf(props)),
|
||
particles: asset.type === 'particles' ? asset : null,
|
||
tilemap:
|
||
asset.type === 'tilemap'
|
||
? resolveTilemap(project, asset.id, props, liveEdits(run, holder, object.id))
|
||
: null,
|
||
emits: 0,
|
||
frame: asset.type === 'collider' ? colliderFrame(project, props) : null,
|
||
clip: null,
|
||
renderLayer: 0,
|
||
...(sealed.has(object.id) && { sealedIn: sealed.get(object.id) }),
|
||
});
|
||
}
|
||
const byId = new Map(items.map((i) => [i.id, i]));
|
||
const parentOf = (i: DrawItem) => byId.get(i.parentId) ?? null;
|
||
const regions = clipRegions(items, parentOf);
|
||
for (const item of items) {
|
||
item.enabled = enabledAlongChain(item, (i) => i.props, parentOf);
|
||
item.sortsWith = passedOnTo(parentOf(item), parentOf)?.id ?? '';
|
||
item.clip = regions.get(item) ?? null;
|
||
item.renderLayer = renderLayerAlong(item, (i) => i.props, parentOf);
|
||
}
|
||
return { items, background: Number(holder.props.backgroundColor ?? fallbackBackground) };
|
||
}
|
||
|
||
/** What a run changed on a placed tilemap of the holder shown, if the run is on that holder. */
|
||
function liveEdits(run: World | null, holder: ObjectHolder, objectId: string): TileEdits | undefined {
|
||
return run?.sceneId === holder.id ? run.findByObjectId(objectId)?.tileEdits : undefined;
|
||
}
|
||
|
||
/**
|
||
* Where a chain of containers with "Sort with parent" on hands their content (#243): the first
|
||
* object above them, or null when no such container is in the way – or nothing is above it.
|
||
*/
|
||
function passedOnTo<T extends { type: AssetType; props: PropertyBag }>(
|
||
parent: T | null,
|
||
up: (at: T) => T | null,
|
||
): T | null {
|
||
let at = parent;
|
||
let passed = false;
|
||
while (at?.type === 'container' && at.props[SORT_WITH_PARENT_KEY] === true) {
|
||
passed = true;
|
||
at = up(at);
|
||
}
|
||
return passed ? at : null;
|
||
}
|
||
|
||
/**
|
||
* The running world: scene content only, since symbols are editor-only. A switched-off object is
|
||
* left out, except a sprite whose video is driven (#404): the view keeps its player ready, so a
|
||
* cut to it shows the picture at once rather than its plain colour while the video loads.
|
||
*/
|
||
export function collectWorldItems(world: World): DrawList {
|
||
const placed = new Map(
|
||
world.entities.map((e) => {
|
||
const { m, z } = worldTransformOf(e);
|
||
return [e, { e, m, z, type: e.type, props: e.props }];
|
||
}),
|
||
);
|
||
// Worked out before the symbols go: a clip area is one, and still cuts what is below it.
|
||
const regions = clipRegions([...placed.values()], (p) => (p.e.parent ? placed.get(p.e.parent) : null));
|
||
const items = [...placed.values()]
|
||
.map((p) => ({ ...p, enabled: isHandleEnabled(p.e) }))
|
||
.filter(
|
||
({ e, enabled }) =>
|
||
!isSymbolType(e.type) && (enabled || (e.type === 'sprite' && videoDrivenOf(e.props))),
|
||
)
|
||
.map(({ e, m, z, enabled }) => ({
|
||
id: e.objectId,
|
||
type: e.type,
|
||
name: e.name,
|
||
x: m.tx,
|
||
y: m.ty,
|
||
z,
|
||
m,
|
||
props: e.props,
|
||
parentId: e.parent?.objectId ?? '',
|
||
sortsWith: passedOnTo(e.parent ?? null, (p) => p.parent ?? null)?.objectId ?? '',
|
||
enabled,
|
||
// Resolved per frame on purpose: a `Properties` node or an animation may swap `texture`.
|
||
texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props) : null,
|
||
font: e.type === 'text' ? fontFileOf(world.project, fontOf(e.props)) : '',
|
||
fontSmooth: fontSmoothOf(world.project, fontOf(e.props)),
|
||
particles: e.type === 'particles' ? (findAssetOf(world.project, e.assetId, 'particles') ?? null) : null,
|
||
tilemap: e.type === 'tilemap' ? resolveTilemap(world.project, e.assetId, e.props, e.tileEdits) : null,
|
||
emits: e.emits ?? 0,
|
||
frame: e.wornFrame?.() ?? null,
|
||
clip: regions.get(placed.get(e)!) ?? null,
|
||
renderLayer: renderLayerAlong(
|
||
e,
|
||
(at) => at.props,
|
||
(at) => at.parent ?? null,
|
||
),
|
||
}));
|
||
return { items, background: world.backgroundColor, ahead: worldVideosAhead(world) };
|
||
}
|