An animation over a container aims its rows at template ids. Played on another container, those ids are not there, so every row below the root was skipped without a word. "Enemy - Hit" built over the Slime flashed the slime's Sprite but never the skeleton's. - When the template id is not found below the object, the runtime takes the names on the way to the row's target in the root and walks the same names below the object, level by level. - The id still comes first, so the root's own instances bind as before. - A name missing at some level, or taken twice, skips the row, as a gone target always did. - The rule is the pure function targetNamePath in animation.ts. The AnimationSystem only walks the children. Tests: - animation.test.ts: 84 tests, all green (+2). One plays over a second container built alike, renamed and with the name twice. The other names a row two containers deep. - Counter-check: with the name fallback off, the new runtime test fails (1 of 84). - Project cbbf160bb7e8 (revision 14407), Demo scene: "Enemy - Hit" and "Enemy - Die" change Flash and scale on the skeleton's Sprite. Without the fallback the skeleton stays unchanged. - The gate did not run before this commit. CI covers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
8.3 KiB
TypeScript
228 lines
8.3 KiB
TypeScript
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: `<entity id> <property>`, or `scene <property>`. */
|
||
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<string>();
|
||
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;
|
||
}
|