macro-clips slice 4d: button canvas runtime + pointer handling

- ButtonInstance gains optional x/y; new instances default to stage
  center and setButtonInstancePosition updates them
- useButtonRuntime composable holds per-instance state/disabled
  overrides (runtime-only, not persisted). getState layers the
  runtime disabled override on top of the persisted initial flag.
- store.actionRuntime's setButtonState/setButtonDisabled now drive
  useButtonRuntime instead of mutating the scene, so script-triggered
  state changes stay ephemeral
- CanvasStage renders each visible (playhead >= startTime) button
  instance inside a <v-group> at (bi.x, bi.y), drawing the current
  state's layer stack with draggable/listening disabled plus a
  transparent hit-rect sized to the clip's hitArea or the bbox of
  the first rect/circle layer. The group wires mouseenter/leave/
  down/up into the state machine and fires onClick via evalAction.
- exportPlayer runtime mirrors the same thing: getEffectiveButtonState,
  per-frame buttonHits array, canvas-level mousemove/down/up handlers
  do their own hit testing in scene space. RUNTIME_VERSION bumped to 5.
- tests cover useButtonRuntime defaults/overrides and the
  store.actionRuntime → runtime-state roundtrip via label resolution.

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:40:02 +02:00
1 parent 024883943b
commit f61f90d613
6 files changed
+412 -8

No files matched your search

+123 -1
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '4'
const RUNTIME_VERSION = '5'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -212,6 +212,79 @@ const ENGINE_SOURCE = `(function(){
for(var bri=0;bri<bis.length;bri++) if(bis[bri].id===ref) return ref;
return runtime.buttonLabels[ref]||null;
}
var buttonHits=[]; // refreshed per draw()
function getEffectiveButtonState(bi){
var ovD=runtime.buttonDisabled[bi.id];
var isDis=ovD===undefined?!!bi.disabled:ovD;
if(isDis) return 'disabled';
return runtime.buttonStates[bi.id]||'normal';
}
function setButtonState(id, st){
if(st==='normal') delete runtime.buttonStates[id];
else runtime.buttonStates[id]=st;
}
function pointerToSceneXY(e){
var rc=canvas.getBoundingClientRect();
var lw=scene.canvas.width, lh=scene.canvas.height;
var cw=size.w, ch=size.h;
if(!cw||!ch||!rc.width||!rc.height) return null;
var sc=Math.min(cw/lw, ch/lh);
var ox=(cw-lw*sc)/2, oy=(ch-lh*sc)/2;
var cx=(e.clientX-rc.left)/rc.width*cw;
var cy=(e.clientY-rc.top)/rc.height*ch;
return { x:(cx-ox)/sc, y:(cy-oy)/sc };
}
function hitButtonAt(p){
for(var i=buttonHits.length-1; i>=0; i--){
var h=buttonHits[i];
var lx=p.x-(h.bi.x||0), ly=p.y-(h.bi.y||0);
if(lx>=h.hit.left && lx<=h.hit.right && ly>=h.hit.top && ly<=h.hit.bottom) return h;
}
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(hit && hit.bi.id===bh.bi.id){
var cur=getEffectiveButtonState(bh.bi);
if(cur==='normal') setButtonState(bh.bi.id,'hover');
} else {
var cur2=getEffectiveButtonState(bh.bi);
if(cur2==='hover'||cur2==='pressed') setButtonState(bh.bi.id,'normal');
}
}
}
function onStageDown(e){
var p=pointerToSceneXY(e);
if(!p) return;
var hit=hitButtonAt(p);
if(!hit) return;
if(getEffectiveButtonState(hit.bi)==='disabled') return;
setButtonState(hit.bi.id,'pressed');
}
function onStageUp(e){
var p=pointerToSceneXY(e);
if(!p) return;
for(var i=0;i<buttonHits.length;i++){
var bh=buttonHits[i];
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');
if(inside){
var acts=bh.bi.onClick||[];
for(var ai=0; ai<acts.length; ai++) evalAction(acts[ai]);
}
}
}
canvas.addEventListener('mousemove', onStageMove);
canvas.addEventListener('mousedown', onStageDown);
canvas.addEventListener('mouseup', onStageUp);
var BUTTON_STATES=['normal','hover','pressed','disabled'];
function parseScriptSource(source){
var outActs=[];
@@ -646,6 +719,55 @@ const ENGINE_SOURCE = `(function(){
ctx.drawImage(topOff,0,0);
ctx.restore();
}
// Draw button instances on top of everything else, and record their
// scene-space hit rectangles for pointer handling.
buttonHits.length=0;
var bInsts=scene.buttonInstances||[];
var bClips=scene.buttonClips||[];
var bClipById={};
for(var bcI=0;bcI<bClips.length;bcI++) bClipById[bClips[bcI].id]=bClips[bcI];
for(var biI=0;biI<bInsts.length;biI++){
var bi=bInsts[biI];
if(time<bi.startTime) continue;
var bclp=bClipById[bi.buttonClipId];
if(!bclp) continue;
var bst=getEffectiveButtonState(bi);
var bStack=(bclp.states&&bclp.states[bst])||[];
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);
ctx.restore();
// Hit rect: explicit hitArea on clip, else bbox of first rect/circle
// layer in the normal state.
var hit=null;
if(bclp.hitArea){
var ox2=bclp.hitArea.offsetX==null?(bclp.hitArea.width/2):bclp.hitArea.offsetX;
var oy2=bclp.hitArea.offsetY==null?(bclp.hitArea.height/2):bclp.hitArea.offsetY;
hit={left:-ox2, top:-oy2, right:bclp.hitArea.width-ox2, bottom:bclp.hitArea.height-oy2};
}
if(!hit){
var normalLs=(bclp.states&&bclp.states.normal)||[];
for(var nli=0; nli<normalLs.length; nli++){
var nl=normalLs[nli];
var na=assetMap[nl.assetId];
if(!na) continue;
if(na.kind==='rect'||na.kind==='image'){
var rox=nl.offsetX==null?na.width/2:nl.offsetX;
var roy=nl.offsetY==null?na.height/2:nl.offsetY;
hit={left:-rox, top:-roy, right:na.width-rox, bottom:na.height-roy};
break;
}
if(na.kind==='circle'){
var cox=nl.offsetX||0, coy=nl.offsetY||0;
hit={left:cox-na.radius, top:coy-na.radius, right:cox+na.radius, bottom:coy+na.radius};
break;
}
}
}
if(!hit) hit={left:-60, top:-20, right:60, bottom:20};
buttonHits.push({bi:bi, hit:hit});
}
ctx.restore();
}
loadImages(scene.assets).then(function(loaded){