@@ -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 + '.' ) } ­` ;
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 + '.' ) } ­` ;
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