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; 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; /** 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; 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(); 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(); 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( 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) }; }