From 7b1796baac58664a41ce82c10cd2c76387267395 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 5 Oct 2026 11:16:42 +0200 Subject: [PATCH] Let a container animation reach alike-named objects in another one 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 --- src/core/model/animation.ts | 21 ++++++++++++ src/runtime/systems/AnimationSystem.ts | 21 ++++++++++-- tests/core/animation.test.ts | 46 ++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) 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 });