diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 0572e141..3c7e3f77 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -440,6 +440,27 @@ export function animationTargets(project: Project, root: Asset | undefined): Ani .map(({ object, depth }) => ({ id: object.id.slice(prefix.length), object, depth: depth - 1 })); } +/** + * The names from a container root down to the object a row aims at (`Sprite`, `Weapon/Hitbox`). An + * object built from another container finds its target by them when the template ids differ. + * Undefined for the root itself, and once the object is gone. + */ +export function targetNamePath( + project: Project, + root: Asset | undefined, + target: string, +): string[] | undefined { + if (root?.type !== 'container' || target === '') return undefined; + const objects = new Map(resolveObjects(project, { objects: [placedRoot(root)] }).map((o) => [o.id, o])); + const names: string[] = []; + let object = objects.get(instanceChildId(PLACED_ROOT_ID, target)); + while (object && object.id !== PLACED_ROOT_ID) { + names.unshift(objectName(object, findAsset(project, object.assetId))); + object = objects.get(object.parentId); + } + return object && names.length > 0 ? names : undefined; +} + /** What a row aims at: the asset, the object placing it (none for the root itself) and the bag it holds. */ interface Aimed { asset: Asset; diff --git a/src/runtime/systems/AnimationSystem.ts b/src/runtime/systems/AnimationSystem.ts index 8afe12c9..d855dda2 100644 --- a/src/runtime/systems/AnimationSystem.ts +++ b/src/runtime/systems/AnimationSystem.ts @@ -10,6 +10,7 @@ import { isPositionRow, playbackOf, startersOf, + targetNamePath, trackWriteAt, } from '../../core/model/animation'; import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; @@ -136,7 +137,8 @@ function drop(entity: Entity, player: AnimationPlayer): void { /** * 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. A row whose target is gone, or lacks the + * 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[] { @@ -163,7 +165,10 @@ function bind(world: World, asset: AnimationAsset, owner: Entity): Binding[] { 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)); + 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; @@ -197,6 +202,18 @@ function childId(ownerId: string, path: string): string { 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; diff --git a/tests/core/animation.test.ts b/tests/core/animation.test.ts index 94ad2889..c2eff0be 100644 --- a/tests/core/animation.test.ts +++ b/tests/core/animation.test.ts @@ -48,6 +48,7 @@ import { startersOf, stopAnimation, targetDefs, + targetNamePath, trackDef, trackLabel, trackValueAt, @@ -1100,6 +1101,51 @@ describe('the reach of a played row (#109)', () => { } }); + it('finds the object by its names on a container built alike, and skips a name missing or taken twice', () => { + /** The `x` of `Body` in a second container after half a second of an animation built over the first. */ + const played = (names: string[]) => { + const f = makeSpriteScene(); + const [slime, skeleton] = [0, 500].map((x) => { + const wrap = new CreateContainerCommand(f.sceneId, [place(f, f.spriteId, { x, y: 0 })]); + f.stack.execute(wrap); + const objects = (findAsset(f.project, wrap.assetId) as ContainerAsset).objects; + objects[0]!.name = 'Body'; + return { wrap, objects }; + }); + const body = skeleton!.objects[0]!; + skeleton!.objects.splice(0, 1, ...names.map((name, i) => ({ ...body, id: `${body.id}${i}`, name }))); + const walk = addAnimation(f, slime!.wrap.assetId, { fps: 10 }); + addRow(f, walk, 'x', slideX, slime!.objects[0]!.id); + const { world, frame } = startWorld(f, { animations: true }); + const instance = world.findByObjectId(skeleton!.wrap.objectId)!; + const target = world.childrenOf(instance)[0]!; + const before = target.x; + playAnimation(instance, walk.id); + frame(0.1); + frame(0.5); + return { before, after: target.x }; + }; + expect(played(['Body']).after).toBeCloseTo(50); + const renamed = played(['Arm']); + expect(renamed.after).toBe(renamed.before); + const twice = played(['Body', 'Body']); + expect(twice.after).toBe(twice.before); + }); + + it('names the way down to a row two containers deep', () => { + const { f, outer, path } = nested(); + const root = findAsset(f.project, outer.assetId) as ContainerAsset; + const placing = root.objects[0]!; + placing.name = 'Hand'; + const inner = findAsset(f.project, placing.assetId) as ContainerAsset; + inner.objects[0]!.name = 'Arm'; + const names = (target: string) => targetNamePath(f.project, root, target); + expect(names(path)).toEqual(['Hand', 'Arm']); + expect(names(placing.id)).toEqual(['Hand']); + expect(names('')).toBeUndefined(); + expect(names('gone')).toBeUndefined(); + }); + it("plays the scene's own background, and a once-reset pass puts it back", () => { const f = makeSpriteScene(); const box = place(f, f.spriteId, { x: 0, y: 0 });