import { isHandleEnabled } from '../../core/graph'; import { type AnimationPlayer, animatedDef, animatedDefOf, animationPlayheadAt, blendShare, blends, blendValue, isPositionRow, playbackOf, startersOf, targetNamePath, trackWriteAt, } from '../../core/model/animation'; import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; import { curveLookup } from '../../core/model/curve'; import { instanceChildId, splitInstanceId } from '../../core/model/instance'; import { findAsset, findAssetOf } from '../../core/model/Project'; import type { PropertyValue } from '../../core/model/Properties'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import type { Entity } from '../Entity'; import type { World } from '../World'; import type { System } from './System'; export interface AnimationFinish { /** The object the animation was played on. */ entity: Entity; animationId: string; /** The behaviors that played it; they hear it wherever `entity` stands. */ startedBy: readonly object[]; } /** A row that reaches something this tick: how to read and write it, and the kind it blends as. */ interface Binding { track: AnimationTrack; def: PropertyDef; /** Where the player keeps the starting value: ` `, or `scene `. */ base: string; read(): PropertyValue; write(value: PropertyValue): void; } /** * Plays what `Play animation` started. Players run in start order, so of two setting one property * the later start wins – or, while it blends in or fades out, mixes over what was there. Runs before the * BehaviorSystem: a behavior writing the same property writes last, and hears this frame's `finished`. */ export class AnimationSystem implements System { finished: AnimationFinish[] = []; update(world: World, dt: number): void { this.finished = []; const running: { entity: Entity; player: AnimationPlayer }[] = []; for (const entity of world.entities) for (const player of entity.animations ?? []) running.push({ entity, player }); running.sort((a, b) => a.player.order - b.player.order); // What the players before this one wrote this tick – a blend mixes from that value, live. const written = new Set(); const curves = curveLookup(world.project); for (const { entity, player } of running) { const asset = findAssetOf(world.project, player.animationId, 'animation'); if (!asset) { drop(entity, player); continue; } const bindings = bind(world, asset, entity); const blend = player.blend; if (blend && !blend.from) { blend.from = {}; for (const b of bindings) blend.from[b.base] = b.read(); } if (!player.bases) { // The first tick only takes the starting values and shows frame 0; the clock runs from the next. player.bases = {}; for (const b of bindings) player.bases[b.base] = b.read(); } else if (isHandleEnabled(entity)) { if (player.playing) { player.time += dt; if (blend) blend.elapsed += dt; } // A paused player that was stopped with a blend still fades out. if (player.fade) player.fade.elapsed += dt; } const head = animationPlayheadAt(asset, player.time); const share = blend ? blendShare(blend) : 1; const fade = player.fade; const fadeShare = fade ? blendShare(fade) : 0; for (const b of bindings) { let value = trackWriteAt(b.track, b.def, head.frame, player.bases[b.base], head.reverse, curves); if (value === undefined) continue; const below = written.has(b.base) ? b.read() : undefined; if (share < 1 && blends(b.def.kind)) { const from = below ?? blend?.from?.[b.base]; if (from !== undefined) value = blendValue(b.def.kind, from, value, share); } if (fade) { const back = below ?? player.bases[b.base]; if (back !== undefined) value = blends(b.def.kind) ? blendValue(b.def.kind, value, back, fadeShare) : back; } b.write(value); written.add(b.base); } if (blend && share >= 1) delete player.blend; if (fade) { if (fadeShare >= 1) drop(entity, player); continue; } if (!head.finished) continue; this.finished.push({ entity, animationId: asset.id, startedBy: startersOf(player) }); if (playbackOf(asset) !== 'once-reset') { drop(entity, player); continue; } if (player.blendOut) { player.fade = { seconds: player.blendOut, elapsed: 0, ended: true }; continue; } for (const b of bindings) { const base = player.bases[b.base]; if (base !== undefined) b.write(base); } drop(entity, player); } } stop(): void { this.finished = []; } } function drop(entity: Entity, player: AnimationPlayer): void { entity.animations = entity.animations?.filter((p) => p !== player); } /** * The rows that reach something on this object. A scene's rows aim at the scene itself or at its * objects by id, and only while that scene runs; everything else aims at the object itself or at * what its template placed inside it, however deep – by template id, or else by the names on the way * there, so one animation fits containers built alike. A row whose target is gone, or lacks the * property, is skipped. */ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] { const root = findAsset(world.project, asset.root); if (!root) return []; const bindings: Binding[] = []; for (const track of asset.tracks) { const key = track.property; if (root.type === 'scene' && track.target === '') { const def = world.sceneId === root.id ? animatedDef('scene', key) : undefined; if (def) bindings.push({ track, def, base: `scene ${key}`, read: () => world.sceneProps[key] ?? 0, write: (value) => { world.sceneProps[key] = value; }, }); continue; } let entity: Entity | undefined; if (root.type === 'scene') entity = world.sceneId === root.id ? world.findByObjectId(track.target) : undefined; else if (track.target === '') entity = owner; else entity = world.findByObjectId(childId(owner.objectId, track.target)) ?? byNames(world, owner, targetNamePath(world.project, root, track.target)); const def = entity && defOn(world, entity, key); if (entity && def) { const aimed = entity; bindings.push({ track, def, base: `${aimed.id} ${key}`, read: () => read(aimed, key), write: (value) => write(aimed, key, value), }); } } return bindings; } /** * The property `key` is on the entity as it is now: its bag names the shader it wears, and swapping that * shader at runtime takes the parameter rows with it (#104). */ function defOn(world: World, entity: Entity, key: string): PropertyDef | undefined { const asset = findAsset(world.project, entity.assetId); return asset && animatedDefOf(world.project, asset, entity.props, key); } /** * The id a template object gets below a placed container – nested one deeper when the container is * content itself. `path` may run through containers placed inside the template (`a/b`). */ function childId(ownerId: string, path: string): string { const split = splitInstanceId(ownerId); return split ? instanceChildId(split.instanceId, `${split.path}/${path}`) : instanceChildId(ownerId, path); } /** The object below `owner` with these names, level by level; none when a name is missing or taken twice. */ function byNames(world: World, owner: Entity, names: string[] | undefined): Entity | undefined { if (!names) return undefined; let at: Entity | undefined = owner; for (const name of names) { const found: Entity[] = world.childrenOf(at).filter((e) => e.name === name); at = found.length === 1 ? found[0] : undefined; if (!at) return undefined; } return at; } function read(entity: Entity, key: string): PropertyValue { if (isPositionRow(entity.type, key)) return entity[key]; return entity.props[key] ?? 0; } function write(entity: Entity, key: string, value: PropertyValue): void { if (isPositionRow(entity.type, key)) { const n = Number(value); if (Number.isFinite(n)) entity[key] = n; } else entity.props[key] = value; }