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:
1 parent
f61f90d613
commit
68ea8576b6
8 files changed
+515
-32
No files matched your search
@@ -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++
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user