macro-clips slice 7: button state transitions

- ButtonClip transitions are now editable in ButtonInstanceDialog
  (from/to/duration/easing) and persisted on the clip via the new
  store.setButtonClipTransitions action; transitions are shared by
  every instance of the button (clip-scoped, not instance-scoped).
- lerpProps exported from interpolate.ts so callers outside the
  sampler can blend two poses.
- useButtonRuntime tracks an active transition per instance plus a
  reactive transitionTick that pokes Vue computeds each frame.
  A self-driving RAF loop runs only while some transition is active
  (idles when none), so animations work paused or playing.
- applyButtonStateChange(id, to, transitions, persistedDisabled)
  is the one entry point for state changes — it looks up a matching
  from→to transition and starts it, or flips state instantly. Pointer
  handlers in CanvasStage and store.actionRuntime.setButtonState both
  go through it.
- CanvasStage blends the two state stacks via sampleStateLayers +
  blendStates: layers matched by name interpolate their sampled props
  with lerpProps; unmatched layers fade in/out. Renderer uses the
  blended layer list in place of the pure state stack during transit.
- Export runtime mirrors the same flow: applyButtonChange starts
  transitions, sampleButtonTransition drives per-frame progress, and
  drawButtonStatesBlended composites via drawLayer(layer, 0, override).
  drawLayer gained an optional propsOverride param for this.
  RUNTIME_VERSION bumped to 6.
- Tests cover applyButtonStateChange matching logic and
  sampleTransition's time → u mapping plus done flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 21:48:50 +02:00
1 parent f61f90d613
commit 68ea8576b6
8 files changed
+515 -32

No files matched your search

+106 -13
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '5'
const RUNTIME_VERSION = '6'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -223,6 +223,78 @@ const ENGINE_SOURCE = `(function(){
if(st==='normal') delete runtime.buttonStates[id];
else runtime.buttonStates[id]=st;
}
var buttonTransitions={}; // id -> { from, to, startedAt, duration, easing }
function startButtonTransition(id, from, to, duration, easing){
buttonTransitions[id]={from:from, to:to, startedAt:performance.now(), duration:duration, easing:easing};
}
function easingProgressRt(u, e){
if(u<=0) return 0; if(u>=1) return 1;
if(e==='easeIn') return u*u;
if(e==='easeOut') return 1-(1-u)*(1-u);
if(e==='easeInOut') return u<0.5?2*u*u:1-Math.pow(-2*u+2,2)/2;
return u;
}
function sampleButtonTransition(id){
var tr=buttonTransitions[id]; if(!tr) return null;
var dur=Math.max(0.0001, tr.duration);
var raw=(performance.now()-tr.startedAt)/1000/dur;
if(raw>=1){ delete buttonTransitions[id]; return { u:1, from:tr.from, to:tr.to, done:true }; }
return { u:easingProgressRt(raw, tr.easing), from:tr.from, to:tr.to, done:false };
}
function applyButtonChange(bi, clip, to){
var from=getEffectiveButtonState(bi);
if(from===to) return;
var trs=clip.transitions||[];
for(var ti=0; ti<trs.length; ti++){
if(trs[ti].from===from && trs[ti].to===to && trs[ti].duration>0){
startButtonTransition(bi.id, from, to, trs[ti].duration, trs[ti].easing);
break;
}
}
setButtonState(bi.id, to);
}
function lerpScalar(a,b,t){return a+(b-a)*t;}
function lerpRuntimeProps(a,b,t){
var out={
x:lerpScalar(a.x,b.x,t),
y:lerpScalar(a.y,b.y,t),
scaleX:lerpScalar(a.scaleX,b.scaleX,t),
scaleY:lerpScalar(a.scaleY,b.scaleY,t),
rotation:lerpScalar(a.rotation,b.rotation,t),
opacity:lerpScalar(a.opacity,b.opacity,t)
};
if(a.fill&&b.fill) out.fill=lerpColor(a.fill,b.fill,t);
else if(b.fill) out.fill=b.fill;
else if(a.fill) out.fill=a.fill;
return out;
}
function drawButtonStatesBlended(clip, from, to, u){
var fromLs=(clip.states&&clip.states[from])||[];
var toLs=(clip.states&&clip.states[to])||[];
var toByName={};
for(var ii=0; ii<toLs.length; ii++) toByName[toLs[ii].name]=toLs[ii];
var fromByName={};
for(var ij=0; ij<fromLs.length; ij++) fromByName[fromLs[ij].name]=fromLs[ij];
// Unmatched from-layers fade out.
for(var fi=0; fi<fromLs.length; fi++){
var fl=fromLs[fi];
if(toByName[fl.name]) continue;
var flp=sample(fl,0);
drawLayer(fl, 0, { opacity: flp.opacity*(1-u) });
}
for(var ti2=0; ti2<toLs.length; ti2++){
var tl=toLs[ti2];
var fm=fromByName[tl.name];
if(fm){
var ap=sample(fm,0);
var bp=sample(tl,0);
drawLayer(tl, 0, lerpRuntimeProps(ap,bp,u));
} else {
var tlp=sample(tl,0);
drawLayer(tl, 0, { opacity: tlp.opacity*u });
}
}
}
function pointerToSceneXY(e){
var rc=canvas.getBoundingClientRect();
var lw=scene.canvas.width, lh=scene.canvas.height;
@@ -242,20 +314,25 @@ const ENGINE_SOURCE = `(function(){
}
return null;
}
function findClipForInstance(bi){
var cs=scene.buttonClips||[];
for(var i=0;i<cs.length;i++) if(cs[i].id===bi.buttonClipId) return cs[i];
return null;
}
function onStageMove(e){
var p=pointerToSceneXY(e);
if(!p) return;
var hit=hitButtonAt(p);
for(var i=0;i<buttonHits.length;i++){
var bh=buttonHits[i];
var isDis=getEffectiveButtonState(bh.bi)==='disabled';
if(isDis) continue;
if(getEffectiveButtonState(bh.bi)==='disabled') continue;
var clip=findClipForInstance(bh.bi);
if(!clip) continue;
if(hit && hit.bi.id===bh.bi.id){
var cur=getEffectiveButtonState(bh.bi);
if(cur==='normal') setButtonState(bh.bi.id,'hover');
if(getEffectiveButtonState(bh.bi)==='normal') applyButtonChange(bh.bi, clip, 'hover');
} else {
var cur2=getEffectiveButtonState(bh.bi);
if(cur2==='hover'||cur2==='pressed') setButtonState(bh.bi.id,'normal');
var cur=getEffectiveButtonState(bh.bi);
if(cur==='hover'||cur==='pressed') applyButtonChange(bh.bi, clip, 'normal');
}
}
}
@@ -265,7 +342,8 @@ const ENGINE_SOURCE = `(function(){
var hit=hitButtonAt(p);
if(!hit) return;
if(getEffectiveButtonState(hit.bi)==='disabled') return;
setButtonState(hit.bi.id,'pressed');
var clip=findClipForInstance(hit.bi);
if(clip) applyButtonChange(hit.bi, clip, 'pressed');
}
function onStageUp(e){
var p=pointerToSceneXY(e);
@@ -275,7 +353,8 @@ const ENGINE_SOURCE = `(function(){
if(getEffectiveButtonState(bh.bi)!=='pressed') continue;
var lx=p.x-(bh.bi.x||0), ly=p.y-(bh.bi.y||0);
var inside=lx>=bh.hit.left&&lx<=bh.hit.right&&ly>=bh.hit.top&&ly<=bh.hit.bottom;
setButtonState(bh.bi.id, inside?'hover':'normal');
var clip=findClipForInstance(bh.bi);
if(clip) applyButtonChange(bh.bi, clip, inside?'hover':'normal');
if(inside){
var acts=bh.bi.onClick||[];
for(var ai=0; ai<acts.length; ai++) evalAction(acts[ai]);
@@ -356,7 +435,15 @@ const ENGINE_SOURCE = `(function(){
case 'setButtonState': {
var sid=resolveButtonRef(act.instanceId);
if(!sid){ runtime.warn('Unknown button ref: '+act.instanceId); return; }
runtime.buttonStates[sid]=act.state;
var sBi=null, sClip=null;
var sBis=scene.buttonInstances||[];
for(var sbI=0; sbI<sBis.length; sbI++) if(sBis[sbI].id===sid){ sBi=sBis[sbI]; break; }
if(sBi){
var sClips=scene.buttonClips||[];
for(var scI=0; scI<sClips.length; scI++) if(sClips[scI].id===sBi.buttonClipId){ sClip=sClips[scI]; break; }
}
if(sBi && sClip) applyButtonChange(sBi, sClip, act.state);
else setButtonState(sid, act.state);
return;
}
case 'setButtonDisabled': {
@@ -486,10 +573,11 @@ const ENGINE_SOURCE = `(function(){
}
ctxm.restore();
}
function drawLayer(layer,time){
function drawLayer(layer,time,propsOverride){
var asset=assetMap[layer.assetId];
if(!asset||!layer.visible)return;
var p=sample(layer,time);
if(propsOverride){ for(var poKey in propsOverride) p[poKey]=propsOverride[poKey]; }
ctx.save();
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
ctx.translate(p.x,p.y);
@@ -732,11 +820,16 @@ const ENGINE_SOURCE = `(function(){
var bclp=bClipById[bi.buttonClipId];
if(!bclp) continue;
var bst=getEffectiveButtonState(bi);
var bStack=(bclp.states&&bclp.states[bst])||[];
var btr=sampleButtonTransition(bi.id);
var bx=bi.x||0, by=bi.y||0;
ctx.save();
ctx.translate(bx, by);
for(var blI=0; blI<bStack.length; blI++) drawLayer(bStack[blI], 0);
if(btr && !btr.done && bst!=='disabled'){
drawButtonStatesBlended(bclp, btr.from, btr.to, btr.u);
} else {
var bStack=(bclp.states&&bclp.states[bst])||[];
for(var blI=0; blI<bStack.length; blI++) drawLayer(bStack[blI], 0);
}
ctx.restore();
// Hit rect: explicit hitArea on clip, else bbox of first rect/circle
// layer in the normal state.