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

+113 -2
View File
@@ -1,5 +1,13 @@
import { reactive } from 'vue'
import type { ButtonState } from '@/types/scene'
import { reactive, ref } from 'vue'
import type { ButtonState, Easing, StateTransition } from '@/types/scene'
export interface ActiveTransition {
from: ButtonState
to: ButtonState
startedAt: number
duration: number
easing: Easing
}
// Runtime-only per-instance state. Not persisted; resets on page reload.
interface RuntimeState {
@@ -8,17 +16,81 @@ interface RuntimeState {
// Runtime disabled flag — overrides (and can be overridden by) the
// instance's persisted `disabled` field. Undefined = use persisted.
disabled: Map<string, boolean>
// Currently-running transition per instance.
transition: Map<string, ActiveTransition>
}
const runtime: RuntimeState = reactive({
state: new Map<string, ButtonState>(),
disabled: new Map<string, boolean>(),
transition: new Map<string, ActiveTransition>(),
})
// Bumps whenever a transition is started/ended so Vue computeds tied to
// transition progress can re-evaluate on the next frame.
const transitionTick = ref(0)
export function pokeTransitionTick() {
transitionTick.value++
}
// Self-driving RAF loop so transitions animate even while playback is
// paused. Idles when no transitions are active.
let rafId = 0
function ensureTransitionLoop() {
if (rafId) return
const step = () => {
const now = performance.now()
let anyActive = false
for (const [id, tr] of runtime.transition) {
const elapsedSec = (now - tr.startedAt) / 1000
if (elapsedSec >= tr.duration) {
runtime.transition.delete(id)
} else {
anyActive = true
}
}
transitionTick.value++
rafId = anyActive ? requestAnimationFrame(step) : 0
}
rafId = requestAnimationFrame(step)
}
/**
* Change a button's state. If a matching from→to transition is defined
* on the clip, start the animated transition. Otherwise switch instantly.
*/
export function applyButtonStateChange(
id: string,
to: ButtonState,
transitions: StateTransition[] | undefined,
persistedDisabled: boolean | undefined,
): void {
const rt = useButtonRuntime()
const from = rt.getState(id, persistedDisabled)
if (from === to) return
const match = transitions?.find((t) => t.from === from && t.to === to)
if (match && match.duration > 0) {
rt.startTransition(id, from, to, match.duration, match.easing, performance.now())
}
rt.setState(id, to)
}
function easingProgress(u: number, e: Easing): number {
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
}
export function useButtonRuntime() {
return {
state: runtime.state,
disabled: runtime.disabled,
transition: runtime.transition,
transitionTick,
getState(id: string, persistedDisabled?: boolean): ButtonState {
const override = runtime.disabled.get(id)
const effectiveDisabled =
@@ -40,9 +112,48 @@ export function useButtonRuntime() {
clearDisabled(id: string) {
runtime.disabled.delete(id)
},
/**
* Start a transition between two states; caller supplies performance.now()
* as `startedAt` so the runtime can interpolate without re-reading time.
*/
startTransition(
id: string,
from: ButtonState,
to: ButtonState,
duration: number,
easing: Easing,
startedAt: number,
) {
runtime.transition.set(id, { from, to, startedAt, duration, easing })
transitionTick.value++
if (duration > 0) ensureTransitionLoop()
},
/**
* Return interpolated progress u ∈ [0..1] (easing applied) + raw for the
* given instance's transition at `now`. If the transition has ended,
* returns { done: true, u: 1 }. Returns null if no active transition.
*/
sampleTransition(id: string, now: number): {
u: number
from: ButtonState
to: ButtonState
done: boolean
} | null {
const t = runtime.transition.get(id)
if (!t) return null
const dur = Math.max(0.0001, t.duration)
const raw = (now - t.startedAt) / 1000 / dur
if (raw >= 1) return { u: 1, from: t.from, to: t.to, done: true }
return { u: easingProgress(raw, t.easing), from: t.from, to: t.to, done: false }
},
endTransition(id: string) {
if (runtime.transition.delete(id)) transitionTick.value++
},
reset() {
runtime.state.clear()
runtime.disabled.clear()
runtime.transition.clear()
transitionTick.value++
},
}
}