Play sprite animations from an animation asset worn through texture
An animation asset is a list of pictures (whole textures or frames) with frames per second and Loop/Once/Ping-pong playback. A sprite wears it through its texture property, so the picker, instance overrides and the Properties node work unchanged; there is no dedicated play node. A different animation starts from its first frame, the same one set again runs on, so a graph may choose Run or Idle every frame. The clock lives on the entity, not in props, which the world writes back as overrides. The AnimationSystem ticks before behaviors, and On animation finished fires once when a Once animation reaches its end. The Animation tab plays a preview above a frame strip: reorder by dragging, Delete, duplicate, + Frames, pause/resume, and wheel zoom about the pointer with pan and Fit. Scene and Container tabs show the first frame; edit mode has no clock. Right-clicking a texture creates an animation from its frames. Button now measures its label with the bold font it draws in, which cut the Animation tab and red dialog buttons short. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bf4191956c
commit
1135bf4562
39 files changed
+1935
-87
No files matched your search
@@ -13,9 +13,11 @@ import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import { type PropertyBag, textOf } from '../../core/model/Properties';
|
||||
import { findAssetOf, type Project } from '../../core/model/Project';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import { bodyOf, type CollisionPair, CollisionSystem } from './CollisionSystem';
|
||||
import { AnimationSystem } from './AnimationSystem';
|
||||
import type { Input } from './Input';
|
||||
import type { System } from './System';
|
||||
|
||||
@@ -47,7 +49,7 @@ interface Instance {
|
||||
*/
|
||||
const ERROR_PREFIX = 'Error in behavior';
|
||||
|
||||
/** Collisions are detected first, so behaviors see this frame's enter/stay/exit events. */
|
||||
/** Collisions and animations are ticked first, so behaviors see this frame's events. */
|
||||
export function addGameSystems(
|
||||
world: World,
|
||||
project: Project,
|
||||
@@ -55,12 +57,20 @@ export function addGameSystems(
|
||||
shouldRun?: (entity: Entity) => boolean,
|
||||
): BehaviorSystem {
|
||||
const collisions = new CollisionSystem();
|
||||
const behaviors = new BehaviorSystem(project, input, collisions, shouldRun);
|
||||
const animations = new AnimationSystem();
|
||||
const behaviors = new BehaviorSystem(project, input, { collisions, animations }, shouldRun);
|
||||
world.addSystem(collisions);
|
||||
world.addSystem(animations);
|
||||
world.addSystem(behaviors);
|
||||
return behaviors;
|
||||
}
|
||||
|
||||
/** The systems whose per-frame results become events; a missing one means no such events. */
|
||||
export interface EventSources {
|
||||
collisions?: CollisionSystem | null;
|
||||
animations?: AnimationSystem | null;
|
||||
}
|
||||
|
||||
/** The behavior failure that stopped a run, if that is why it stopped. */
|
||||
export function behaviorError(world: World): string | undefined {
|
||||
return world.log.find((line) => line.startsWith(ERROR_PREFIX));
|
||||
@@ -69,18 +79,22 @@ export function behaviorError(world: World): string | undefined {
|
||||
export class BehaviorSystem implements System {
|
||||
private instances: Instance[] = [];
|
||||
private world: World | null = null;
|
||||
private readonly collisions: CollisionSystem | null;
|
||||
private readonly animations: AnimationSystem | null;
|
||||
|
||||
constructor(
|
||||
private readonly project: Project,
|
||||
private readonly input: Input,
|
||||
/** Source of this frame's collision events; null = no collision detection. */
|
||||
private readonly collisions: CollisionSystem | null = null,
|
||||
sources: EventSources = {},
|
||||
/**
|
||||
* Which behaviors this system is responsible for. Playing runs them all; the editor run
|
||||
* takes only those marked "run in editor" (see `properties/editorRun.ts`).
|
||||
*/
|
||||
private readonly shouldRun: (entity: Entity) => boolean = () => true,
|
||||
) {}
|
||||
) {
|
||||
this.collisions = sources.collisions ?? null;
|
||||
this.animations = sources.animations ?? null;
|
||||
}
|
||||
|
||||
start(world: World): void {
|
||||
this.world = world;
|
||||
@@ -151,6 +165,7 @@ export class BehaviorSystem implements System {
|
||||
}
|
||||
}
|
||||
this.fireCollisions(instance, i, ctx);
|
||||
this.fireAnimationsFinished(instance, i, ctx);
|
||||
// With an owner the click has to land on it; a behavior without one has no shape to
|
||||
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
|
||||
const pointer = world.pointer;
|
||||
@@ -209,6 +224,21 @@ export class BehaviorSystem implements System {
|
||||
fire('OnCollisionExit', this.collisions.exits);
|
||||
}
|
||||
|
||||
/**
|
||||
* A behavior hears the sprites of its owner's subtree – the owner itself, or a sprite placed below
|
||||
* it next to the behavior. Without an owner it hears every sprite, like `On click` hears the view.
|
||||
*/
|
||||
private fireAnimationsFinished(instance: Instance, interpreter: GraphInterpreter, ctx: NodeContext): void {
|
||||
const owner = instance.entity.parent;
|
||||
for (const { entity, animationId } of this.animations?.finished ?? []) {
|
||||
if (owner && ![...parentChain(entity, (e) => e.parent)].includes(owner)) continue;
|
||||
interpreter.fire('OnAnimationFinished', ctx, { object: entity }, (node) => {
|
||||
const wanted = textOf(node.params.animation ?? '');
|
||||
return wanted === '' || wanted === animationId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
runningIds(): string[] {
|
||||
return this.instances.map((i) => i.entity.objectId);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user