Animations blend out on Stop and at the end of Once (reset), and Play can restart a running animation (#106)
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m37s

Stop animation gets a blend (s) port that fades the animation back to what
lies underneath. Play animation gets blend out (s) for Once (reset) and a
restart port that starts a running, paused or fading animation from frame 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 11:50:10 +02:00
1 parent 8a377d9239
commit 195fc94e08
4 files changed
+369 -29

No files matched your search

+27 -8
View File
@@ -1,7 +1,7 @@
import { pauseAnimation, playAnimation, stopAnimation } from '../../../model/animation';
import type { NodeDef, PortDef } from '../../NodeRegistry';
import { asEntity, asNumber, asString } from '../../values';
import { exec, num, obj, withDefault } from '../shared';
import { asBoolean, asEntity, asNumber, asString } from '../../values';
import { bool, exec, num, obj, withDefault } from '../shared';
/**
* The `animation` input of the three animation nodes; unconnected it shows the animation picker, the
@@ -22,14 +22,31 @@ export const PlayAnimation: NodeDef = {
'Plays an animation on an object: its rows reach the object and what is placed inside it, so every ' +
'instance plays on its own. A paused one runs on from where it stands; one already running keeps its ' +
'frame but wins again over the others on the object. Blend fades numbers and colours over that many ' +
'seconds from what they held when it took over; everything else switches at once.',
inputs: [exec(), obj('object'), animationPort(), withDefault(num('blend', 'blend (s)'), 0)],
'seconds from what they held when it took over; everything else switches at once. Blend out is for ' +
'Once (reset): when its frames run out it fades back to where it started over that many seconds. ' +
'Restart starts one already running or paused over from its first frame – blended in from where it ' +
'stands when Blend is set.',
inputs: [
exec(),
obj('object'),
animationPort(),
withDefault(num('blend', 'blend (s)'), 0),
withDefault(num('blendOut', 'blend out (s)'), 0),
withDefault(bool('restart'), false),
],
outputs: [exec()],
params: [],
run: (_ctx, inputs) => {
const target = asEntity(inputs.object);
const animation = asString(inputs.animation);
if (target && animation) playAnimation(target, animation, Math.max(0, asNumber(inputs.blend)));
if (target && animation)
playAnimation(
target,
animation,
Math.max(0, asNumber(inputs.blend)),
Math.max(0, asNumber(inputs.blendOut)),
asBoolean(inputs.restart),
);
return { next: 'exec' };
},
};
@@ -39,13 +56,15 @@ export const StopAnimation: NodeDef = {
label: 'Stop animation',
category: 'action',
description:
'Ends an animation on an object where it stands – the values it set stay. Leave Animation at None to end every one.',
inputs: [exec(), obj('object'), animationPort()],
'Ends an animation on an object where it stands – the values it set stay. With a blend it fades out ' +
'over that many seconds instead, back to what another animation sets there or else to what the ' +
'values were when it started. Leave Animation at None to end every one.',
inputs: [exec(), obj('object'), animationPort(), withDefault(num('blend', 'blend (s)'), 0)],
outputs: [exec()],
params: [],
run: (_ctx, inputs) => {
const target = asEntity(inputs.object);
if (target) stopAnimation(target, asString(inputs.animation));
if (target) stopAnimation(target, asString(inputs.animation), Math.max(0, asNumber(inputs.blend)));
return { next: 'exec' };
},
};
+61 -8
View File
@@ -389,6 +389,9 @@ export interface AnimationPlayer {
order: number;
bases?: Record<string, PropertyValue>;
blend?: AnimationBlend;
/** Seconds a `once-reset` pass takes to blend back to its starting values when its frames run out. */
blendOut?: number;
fade?: AnimationFade;
}
/**
@@ -403,8 +406,20 @@ export interface AnimationBlend {
from?: Record<string, PropertyValue>;
}
/** How far a blend has come, 0…1. */
export function blendShare(blend: AnimationBlend): number {
/**
* A player on its way out (#106): over `seconds` a row that blends mixes from the player's value back
* to what lies below it – what an earlier player writes in the same tick, live, or else the value the
* row found at the start. Everything else goes back at once. Then the player is gone. `ended` marks
* the fade of a `once-reset` pass whose frames ran out: playing it again starts it over.
*/
export interface AnimationFade {
seconds: number;
elapsed: number;
ended?: boolean;
}
/** How far a blend or a fade has come, 0…1. */
export function blendShare(blend: { seconds: number; elapsed: number }): number {
if (!(blend.seconds > 0)) return 1;
return Math.min(1, blend.elapsed / blend.seconds);
}
@@ -421,25 +436,63 @@ let startCount = 0;
* as started last again: turning back to a walk that still runs has to show that walk.
* `blendSeconds` crossfades into it – but only when it takes over: asked for again while it already
* leads on the object, it keeps the blend it has, so a graph calling every frame does not restart it.
* One fading out turns round: it blends back in from the value it has reached, without a jump – or,
* when a `once-reset` pass already ran out, starts over. `blendOutSeconds` is the fade at the end of
* a `once-reset` pass. `restart` starts a known one over from its first frame in any case – blended in
* from where it stands when `blendSeconds` is set – and keeps its starting values, so a relative row
* does not add up.
*/
export function playAnimation(holder: PlayerHolder, animationId: string, blendSeconds = 0): void {
export function playAnimation(
holder: PlayerHolder,
animationId: string,
blendSeconds = 0,
blendOutSeconds = 0,
restart = false,
): void {
const players = (holder.animations ??= []);
const known = players.find((p) => p.animationId === animationId);
const blend = blendSeconds > 0 ? { seconds: blendSeconds, elapsed: 0 } : undefined;
const blendOut = blendOutSeconds > 0 ? blendOutSeconds : undefined;
if (known) {
const leads = players.every((p) => p.order <= known.order);
const fade = known.fade;
known.playing = true;
known.order = ++startCount;
if (!leads) known.blend = blend;
known.blendOut = blendOut;
delete known.fade;
if (restart || fade?.ended) {
known.time = 0;
known.blend = blend;
} else if (fade && blend) {
// Mixed back from the starting values it stands where the fade left it: `share` = 1 - fade share.
blend.elapsed = blendSeconds * (1 - blendShare(fade));
known.blend = { ...blend, ...(known.bases && { from: { ...known.bases } }) };
} else if (fade || !leads) known.blend = blend;
return;
}
players.push({ animationId, time: 0, playing: true, order: ++startCount, ...(blend && { blend }) });
players.push({
animationId,
time: 0,
playing: true,
order: ++startCount,
...(blend && { blend }),
...(blendOut && { blendOut }),
});
}
/** Ends it where it stands – the values stay; `''` ends every animation on the object. */
export function stopAnimation(holder: PlayerHolder, animationId: string): void {
/**
* Ends it where it stands – the values stay; `''` ends every animation on the object. With
* `blendSeconds` it fades out instead, back to what lies below it (see `AnimationFade`). A player that
* has not reached anything yet ends at once either way.
*/
export function stopAnimation(holder: PlayerHolder, animationId: string, blendSeconds = 0): void {
if (!holder.animations) return;
holder.animations = holder.animations.filter((p) => animationId !== '' && p.animationId !== animationId);
const hit = (p: AnimationPlayer) => animationId === '' || p.animationId === animationId;
const fades = blendSeconds > 0;
holder.animations = holder.animations.filter((p) => !hit(p) || (fades && p.bases !== undefined));
if (fades)
for (const player of holder.animations)
if (hit(player) && !player.fade) player.fade = { seconds: blendSeconds, elapsed: 0 };
}
/** Holds it at its frame until `Play animation` lets it run on; `''` holds every one. */
+33 -11
View File
@@ -37,7 +37,7 @@ interface Binding {
/**
* 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, mixes over what was there. Runs before the
* 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 {
@@ -67,31 +67,53 @@ export class AnimationSystem implements System {
// 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 (player.playing && isHandleEnabled(entity)) {
player.time += dt;
if (blend) blend.elapsed += dt;
} 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.kind, head.frame, player.bases[b.base], head.reverse);
if (value === undefined) continue;
const below = written.has(b.base) ? b.read() : undefined;
if (share < 1 && blends(b.kind)) {
const from = written.has(b.base) ? b.read() : blend?.from?.[b.base];
const from = below ?? blend?.from?.[b.base];
if (from !== undefined) value = blendValue(b.kind, from, value, share);
}
if (fade) {
const back = below ?? player.bases[b.base];
if (back !== undefined) value = blends(b.kind) ? blendValue(b.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;
if (playbackOf(asset) === 'once-reset')
for (const b of bindings) {
const base = player.bases[b.base];
if (base !== undefined) b.write(base);
}
drop(entity, player);
this.finished.push({ entity, animationId: asset.id });
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);
}
}