Skip to content

Commit ddbd9fb

Browse files
committed
Added pure/impure function registration and integrated it with continuous easing
1 parent 62f3067 commit ddbd9fb

3 files changed

Lines changed: 137 additions & 22 deletions

File tree

Sequence/README.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Leave blank for linear. Type a curve name for ease-in, or `~name` for ease-out.
9191
| `power(3)` | Parametric ease-in |
9292
| `bezier(0.42,0,0.58,1)` | CSS ease-in-out |
9393
| `step` | Instant jump at end of segment |
94+
| `continuous` | Re-evaluate expression every tick (no lerp). Required for `t`-based animations. |
9495

9596
For ease-in-out: add an empty row at the midpoint with `~curve` as the easing — this switches the curve for subsequent lerps without moving the token.
9697

@@ -105,21 +106,50 @@ Available in value cell expressions:
105106
| `orig` / `original` | Attribute value at start of playback |
106107
| `prev` / `previous` | Accumulated value at the previous keyframe |
107108
| `curr` / `current` | Current live value on the token (lazy-fetched) |
109+
| `t` | Normalized time (0–1) within the current playback cycle. Requires `continuous` easing. |
108110

109111
Available in time cell expressions:
110112

111113
| Variable | Meaning |
112114
|----------|---------|
113115
| `prev` | Previous resolved timestamp (ms) |
114116

117+
## Constants
118+
119+
| Constant | Value |
120+
|----------|-------|
121+
| `PI` | π (3.14159…) |
122+
| `TAU` | 2π (6.28318…) — one full rotation in radians |
123+
124+
Color constants (`color.red`, `color.blue`, etc.) are also available — run `!sequence man vars` for the full list.
125+
115126
## Built-in Functions
116127

117-
`rand(min, max)`, `randInt(min, max)`, `pick(a, b, ...)`, `clamp(v, lo, hi)`, `abs`, `round`, `floor`, `ceil`, `min`, `max`, `sqrt`, `pow`, `sin`, `cos`, `tan`, `log`, `exp`
128+
`rand(min, max)`, `randInt(min, max)`, `pick(a, b, ...)`, `freeze(value)`, `clamp(v, lo, hi)`, `abs`, `round`, `floor`, `ceil`, `min`, `max`, `sqrt`, `pow`, `sin`, `cos`, `tan`, `log`, `exp`
129+
130+
**`freeze(value)`** — memoizes its result for the duration of the segment. Use in `continuous` easing to stabilize impure values:
131+
```
132+
=orig + freeze(rand(-50,50)) + cos(t * TAU) * 140
133+
```
134+
This gives a stable random offset with continuous oscillation. The frozen value resets each loop cycle.
135+
136+
**Impure functions** (`rand`, `randInt`, `pick`) are automatically memoized in non-continuous segments (stable for the segment). In `continuous` segments they re-evaluate every tick — wrap in `freeze()` to stabilize.
118137

119138
Color functions (value context only): `color.rgb(r,g,b)`, `color.hsl(h,s,l)`, `color.mix(a,b,t)`, `color.rotateHue(c,deg)`, `color.darken(c,amt)`, `color.lighten(c,amt)`, `color.saturate(c,amt)`
120139

121140
Run `!sequence man func` in chat for the full list with descriptions and examples.
122141

142+
## Continuous Animations
143+
144+
For animations driven by mathematical functions rather than lerp between keyframes, use `continuous` easing with the `t` variable:
145+
146+
| Time | left | left:easing |
147+
|------|------|-------------|
148+
| `=0` | | `continuous` |
149+
| `=3000` | `=orig + cos(t * TAU) * 140` | |
150+
151+
Play with `--loop` for endless orbiting. `t` goes from 0→1 over the segment duration and resets each loop.
152+
123153
## Supported Attributes
124154

125155
All standard graphic token properties: `left`, `top`, `rotation`, `width`, `height`, `bar1_value``bar3_value`, `bar1_max``bar3_max`, `aura1_radius`, `aura2_radius`, `light_radius`, `light_dimradius`, `light_angle`, `light_losangle`, `tint_color`, `aura1_color`, `aura2_color`, `light_color`, `flipv`, `fliph`, `showname`, `layer`, and more.

Sequence/Sequence.js

Lines changed: 89 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -969,21 +969,48 @@ var Sequence = Sequence || (() => {
969969
// receives ctx as its final argument regardless of how it's called.
970970
// Core functions live as flat properties; namespaced functions live
971971
// under their namespace chain for natural dot-access in eval.
972-
const _wrapNode = function(node) {
972+
//
973+
// Memoization: discrete functions are memoized per call-site within a
974+
// segment. In a non-continuous segment, ALL functions are memoized.
975+
// In a continuous segment, only discrete functions are memoized.
976+
const _memoCache = context && context.memo ? context.memo : null;
977+
const _isContinuousSeg = !!(context && context.isContinuousSegment);
978+
var _callCounter = 0;
979+
980+
const _wrapNode = function(node, regKey) {
973981
if (typeof node === 'function') {
982+
var reg = FN_REGISTRY[regKey] || null;
983+
var isImpure = !!(reg && !reg.pure);
984+
var isFreeze = regKey === 'core/freeze';
985+
// Memoize if: freeze (always), OR impure function in non-continuous segment
986+
var shouldMemo = _memoCache && (isFreeze || (isImpure && !_isContinuousSeg));
987+
988+
if (shouldMemo) {
989+
return function() {
990+
var idx = _callCounter++;
991+
var key = (regKey || 'fn') + ':' + idx;
992+
if (key in _memoCache) return _memoCache[key];
993+
var args = Array.prototype.slice.call(arguments);
994+
var result = node.apply(null, [_ctx].concat(args));
995+
_memoCache[key] = result;
996+
return result;
997+
};
998+
}
974999
return function() {
1000+
_callCounter++;
9751001
var args = Array.prototype.slice.call(arguments);
9761002
return node.apply(null, [_ctx].concat(args));
9771003
};
9781004
}
9791005
if (node === null || typeof node !== 'object') return node;
9801006
var wrapped = {};
9811007
Object.keys(node).forEach(function(k) {
982-
wrapped[k] = _wrapNode(node[k]);
1008+
var childKey = regKey ? regKey + '/' + k : 'core/' + k;
1009+
wrapped[k] = _wrapNode(node[k], childKey);
9831010
});
9841011
return wrapped;
9851012
};
986-
var _wrapped = _wrapNode(EXPR_SCOPE);
1013+
var _wrapped = _wrapNode(EXPR_SCOPE, '');
9871014

9881015
// Declare each top-level name as a local var so eval can access it.
9891016
// Core functions become flat locals (rand, clamp, etc.).
@@ -1112,6 +1139,7 @@ var Sequence = Sequence || (() => {
11121139
reg.args = reg.args || [];
11131140
reg.returns = reg.returns || 'number';
11141141
reg.examples = reg.examples || [];
1142+
reg.pure = reg.pure !== undefined ? reg.pure : true;
11151143
reg.source = src;
11161144

11171145
insertIntoScope(scope, namespace, name, reg.fn);
@@ -1203,6 +1231,7 @@ var Sequence = Sequence || (() => {
12031231
{
12041232
name: 'rand', namespace: 'core',
12051233
description: 'Returns a uniformly distributed random number between min (inclusive) and max (exclusive).',
1234+
pure: false,
12061235
args: [
12071236
{ name: 'min', type: 'number', description: 'Lower bound (inclusive)' },
12081237
{ name: 'max', type: 'number', description: 'Upper bound (exclusive)' },
@@ -1214,6 +1243,7 @@ var Sequence = Sequence || (() => {
12141243
{
12151244
name: 'randInt', namespace: 'core',
12161245
description: 'Returns a random integer between min and max (both inclusive).',
1246+
pure: false,
12171247
args: [
12181248
{ name: 'min', type: 'number', description: 'Lower bound (inclusive)' },
12191249
{ name: 'max', type: 'number', description: 'Upper bound (inclusive)' },
@@ -1225,11 +1255,20 @@ var Sequence = Sequence || (() => {
12251255
{
12261256
name: 'pick', namespace: 'core',
12271257
description: 'Returns one of the provided values chosen uniformly at random.',
1258+
pure: false,
12281259
args: [{ name: '...values', type: 'any', description: 'Values to pick from' }],
12291260
returns: 'any',
12301261
examples: ['=pick(0,90,180,270) random cardinal rotation'],
12311262
fn: (ctx, ...args) => args[Math.floor(Math.random() * args.length)],
12321263
},
1264+
{
1265+
name: 'freeze', namespace: 'core',
1266+
description: 'Memoizes its argument — evaluates once per segment, returns the cached value on subsequent ticks. Use in continuous easing to stabilize non-deterministic values.',
1267+
args: [{ name: 'value', type: 'any', description: 'Value to freeze' }],
1268+
returns: 'any',
1269+
examples: ['=orig + freeze(rand(-50,50)) + cos(t * TAU) * 140 stable random offset with continuous orbit'],
1270+
fn: (ctx, val) => val,
1271+
},
12331272
{
12341273
name: 'clamp', namespace: 'core',
12351274
description: 'Clamps value to the range [lo, hi].',
@@ -2573,7 +2612,7 @@ var Sequence = Sequence || (() => {
25732612
* @param {object} prevState - running state before this keyframe (prev)
25742613
* @param {object} liveObj - Roll20 graphic object (curr)
25752614
*/
2576-
const resolveDeltas = (deltas, initialState, prevState, liveObj, cumulative, t) => {
2615+
const resolveDeltas = (deltas, initialState, prevState, liveObj, cumulative, t, memo) => {
25772616
const resolved = {};
25782617
Object.entries(deltas || {}).forEach(([attrName, parsed]) => {
25792618
if (!parsed || !parsed.expr) { resolved[attrName] = parsed; return; }
@@ -2586,7 +2625,7 @@ var Sequence = Sequence || (() => {
25862625

25872626
try {
25882627
const val = evalExpr(parsed.expr, orig, prev, curr,
2589-
{ obj: liveObj, t: t || 0, cumulative: cumulative || {} });
2628+
{ obj: liveObj, t: t || 0, memo: memo || null, isContinuousSegment: false, cumulative: cumulative || {} });
25902629
if (parsed.mode === 'abs') {
25912630
resolved[attrName] = { abs: val };
25922631
} else if (parsed.mode === 'mul') {
@@ -2755,7 +2794,7 @@ var Sequence = Sequence || (() => {
27552794
}
27562795
pb.startTime = Date.now();
27572796
pb.lastKfIndex = -1;
2758-
pb.resolvedExprs = {}; // re-evaluate value expressions on next loop
2797+
pb.memoCache = {}; // reset function memo on next loop
27592798
pb.cumulative = {}; // reset scratchpad each loop cycle
27602799
pb.currentEasings = {}; // reset easing switches each loop cycle
27612800
pb.rotNudgeDir = 1; // reset nudge direction each loop
@@ -2834,7 +2873,7 @@ var Sequence = Sequence || (() => {
28342873
const prevState = stateAt(i - 1);
28352874
const state = stateAt(i);
28362875
// Resolve any expression deltas before applying
2837-
const resolvedDeltas = resolveDeltas(kf.deltas, pb.initialState, prevState, obj, pb.cumulative, tNorm);
2876+
const resolvedDeltas = resolveDeltas(kf.deltas, pb.initialState, prevState, obj, pb.cumulative, tNorm, {});
28382877
// Re-apply with resolved deltas via shadow
28392878
const shadow = makeShadow(prevState);
28402879
Object.entries(resolvedDeltas || {}).forEach(([attrName, parsed]) => {
@@ -2893,24 +2932,29 @@ var Sequence = Sequence || (() => {
28932932
const isContinuous = srcEasing === 'continuous';
28942933
const orig = pb.initialState[attrName] !== undefined ? pb.initialState[attrName] : 0;
28952934
const prev = prevAbsState[attrName] !== undefined ? prevAbsState[attrName] : orig;
2935+
2936+
// Per-segment memo cache for function memoization
2937+
const memoKey = `${nextIdx}:${attrName}`;
2938+
if (!pb.memoCache) pb.memoCache = {};
2939+
if (!pb.memoCache[memoKey]) pb.memoCache[memoKey] = {};
2940+
const memo = pb.memoCache[memoKey];
2941+
28962942
try {
28972943
const val = evalExpr(nextParsed.expr, orig, prev, undefined,
2898-
{ obj, reg, t: tNorm, cumulative: pb.cumulative || {} });
2944+
{ obj, reg, t: tNorm, memo, isContinuousSegment: isContinuous, cumulative: pb.cumulative || {} });
28992945
let resolved = nextParsed.mode === 'abs' ? { abs: val }
29002946
: nextParsed.mode === 'mul' ? { delta: val }
29012947
: { delta: (nextParsed.sign || 1) * val };
2948+
// In continuous segments, use resolved value directly (no lerp)
29022949
if (isContinuous) {
2903-
// Continuous: use resolved value directly, no lerp, no cache
29042950
const shadow = makeShadow(prevAbsState);
29052951
if ('abs' in resolved) reg.set(shadow, resolved.abs);
29062952
else if ('delta' in resolved) reg.apply(shadow, resolved.delta);
29072953
interpolated = shadow._state[attrName];
29082954
} else {
2909-
// Cache the resolved value so all ticks in this segment agree
2910-
if (!pb.resolvedExprs) pb.resolvedExprs = {};
2911-
const key = `${nextIdx}:${attrName}`;
2912-
if (!(key in pb.resolvedExprs)) pb.resolvedExprs[key] = resolved;
2913-
nextParsed = pb.resolvedExprs[key];
2955+
// Non-continuous: memoization ensures same result each tick,
2956+
// so we can use it as the lerp target
2957+
nextParsed = resolved;
29142958
}
29152959
} catch(e) {
29162960
log(`${SCRIPT_NAME}: lerp expr error for ${attrName}: ${e.message}`);
@@ -3943,7 +3987,8 @@ var Sequence = Sequence || (() => {
39433987
const argList = (r.args || []).map(a =>
39443988
a.optional ? `[${escHtml(a.name)}]` : escHtml(a.name)
39453989
).join(', ');
3946-
let html = `<b>${ns}${escHtml(r.name)}(${argList})</b> → <i>${escHtml(r.returns || 'number')}</i>${fmtContexts(r)}<br>`;
3990+
const unstable = r.pure === false ? ' [unstable]' : '';
3991+
let html = `<b>${ns}${escHtml(r.name)}(${argList})</b> → <i>${escHtml(r.returns || 'number')}</i>${unstable}${fmtContexts(r)}<br>`;
39473992
if (r.description) html += `${escHtml(r.description)}<br>`;
39483993
if (r.args && r.args.length) {
39493994
html += (r.args.map(a =>
@@ -4668,7 +4713,13 @@ var Sequence = Sequence || (() => {
46684713
li(`${c('ctx.curr')} — current live value (lazy-fetched)`),
46694714
li(`${c('ctx.cumulative')} — per-loop scratchpad, reset each cycle`),
46704715
);
4671-
html += p(b('Struct fields: ') + c('name') + ', ' + c('namespace') + ', ' + c('fn') + ', ' + c('description') + ', ' + c('args') + ', ' + c('returns') + ', ' + c('examples') + '.');
4716+
html += p(b('Struct fields: ') + c('name') + ', ' + c('namespace') + ', ' + c('fn') + ', ' + c('description') + ', ' + c('args') + ', ' + c('returns') + ', ' + c('examples') + ', ' + c('pure') + '.');
4717+
html += p(b('pure') + ' (boolean, default ' + c('true') + '): '
4718+
+ 'Set to ' + c('false') + ' for functions that may return different values on repeated calls with the same arguments '
4719+
+ '(e.g. random, stateful, or time-dependent functions). '
4720+
+ 'Impure functions (' + c('pure: false') + ') are automatically memoized per call-site in non-continuous easing segments, '
4721+
+ 'ensuring stable values for the duration of the segment. In ' + c('continuous') + ' segments, impure functions re-evaluate every tick. '
4722+
+ 'Users can wrap impure calls in ' + c('freeze()') + ' to force memoization in continuous segments.');
46724723
html += pre(
46734724
`Sequence.registerValueFunction('MyScript', {
46744725
name: 'wave', namespace: 'anim',
@@ -4899,7 +4950,8 @@ if (opacityReg) opacityReg.set(obj, 0.5);`
48994950
const ns = r.namespace === 'core' ? '' : `${b(r.namespace + '.')}&shy;`;
49004951
const argList = (r.args || []).map(a =>
49014952
a.optional ? `[${a.name}]` : a.name).join(', ');
4902-
let s = `${b(ns + r.name + `(${argList})`)}${i(r.returns || 'number')}${fmtContexts(r)}<br>`;
4953+
const unstable = r.pure === false ? ' [unstable]' : '';
4954+
let s = `${b(ns + r.name + `(${argList})`)}${i(r.returns || 'number')}${unstable}${fmtContexts(r)}<br>`;
49034955
if (r.description) s += `${r.description}<br>`;
49044956
if (r.args && r.args.length)
49054957
s += ul(r.args.map(a =>
@@ -4952,6 +5004,7 @@ if (opacityReg) opacityReg.set(obj, 0.5);`
49525004
['orig / original', 'Attribute value at start of playback. Stable for entire session.'],
49535005
['prev / previous', 'Accumulated value at the previous keyframe.'],
49545006
['curr / current', 'Current live value on the token (fetched lazily).'],
5007+
['t', 'Normalized time (0–1) within the current playback cycle. Requires continuous easing.'],
49555008
];
49565009
const timeVars = [
49575010
['prev', 'Previous resolved timestamp (ms) — the only available variable in time expressions'],
@@ -4987,13 +5040,30 @@ if (opacityReg) opacityReg.set(obj, 0.5);`
49875040
li(`${c('power(3)')} — parametric curve with arguments`),
49885041
li(`${c('~bezier(0.42,0,0.58,1)')} — reversed parametric`),
49895042
li(`${c('step')} — instant jump at end of segment`),
5043+
li(`${c('continuous')} — re-evaluate expression every tick (no lerp). Required for ${c('t')}-based animations.`),
49905044
].join(''));
49915045
html += p(`Available curves: ${c(EASING_NAMES().join(', '))}.`);
49925046
html += p('For ease-in-out, add an empty row at the midpoint with a '
49935047
+ c('~curve') + ' easing. An empty-row easing cell (no delta for that attribute) '
49945048
+ 'switches the easing for future lerps of that attribute at that timestamp, '
49955049
+ 'without moving the token.');
49965050

5051+
html += h(2, 'Continuous Animations');
5052+
html += p('For animations driven by mathematical functions (orbit, oscillation, etc.), '
5053+
+ 'use ' + c('continuous') + ' easing with the ' + c('t') + ' variable. '
5054+
+ 'The expression re-evaluates every tick instead of lerping between two values.');
5055+
html += p(i('Example: orbit at radius 140px over 3 seconds, looping:'));
5056+
html += ul([
5057+
li('Row 1: time ' + c('=0') + ', easing ' + c('continuous') + ', no delta'),
5058+
li('Row 2: time ' + c('=3000') + ', left ' + c('=orig + cos(t * TAU) * 140') + ', top ' + c('=orig + sin(t * TAU) * 140')),
5059+
].join(''));
5060+
html += p(b('freeze(value)') + ' — memoizes its result for the segment. '
5061+
+ 'Use to stabilize impure functions in continuous easing: '
5062+
+ c('=orig + freeze(rand(-50,50)) + cos(t * TAU) * 140'));
5063+
html += p('Impure functions (' + c('rand') + ', ' + c('randInt') + ', ' + c('pick')
5064+
+ ') are automatically memoized in non-continuous segments. '
5065+
+ 'In ' + c('continuous') + ' segments they re-evaluate every tick.');
5066+
49975067
html += h(2, 'Expression Variables (Value Context)');
49985068
html += ul(vars.map(([name, desc]) => li(`${b(name)}${desc}`)).join(''));
49995069

@@ -5067,7 +5137,8 @@ if (opacityReg) opacityReg.set(obj, 0.5);`
50675137
const ns2 = `${b(r.namespace + '.')}&shy;`;
50685138
const argList = (r.args || []).map(a =>
50695139
a.optional ? `[${a.name}]` : a.name).join(', ');
5070-
let s = `${b(ns2 + r.name + `(${argList})`)}${i(r.returns || 'number')}${fmtContexts(r)}<br>`;
5140+
const unstable = r.pure === false ? ' [unstable]' : '';
5141+
let s = `${b(ns2 + r.name + `(${argList})`)}${i(r.returns || 'number')}${unstable}${fmtContexts(r)}<br>`;
50715142
if (r.description) s += `${r.description}<br>`;
50725143
if (r.args && r.args.length)
50735144
s += ul(r.args.map(a =>

0 commit comments

Comments
 (0)