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,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { PhTrash } from '@phosphor-icons/vue'
|
import { PhPlus, PhTrash } from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { Modal } from '@/components/ui'
|
import { Modal } from '@/components/ui'
|
||||||
import ActionEditor from './ActionEditor.vue'
|
import ActionEditor from './ActionEditor.vue'
|
||||||
import type { Action } from '@/types/scene'
|
import type { Action, ButtonState, Easing, StateTransition } from '@/types/scene'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -32,8 +32,12 @@ const buttonClip = computed(() => {
|
|||||||
const localLabel = ref('')
|
const localLabel = ref('')
|
||||||
const localDisabled = ref(false)
|
const localDisabled = ref(false)
|
||||||
const localActions = ref<Action[]>([])
|
const localActions = ref<Action[]>([])
|
||||||
|
const localTransitions = ref<StateTransition[]>([])
|
||||||
const labelError = ref<string | null>(null)
|
const labelError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const BUTTON_STATES: ButtonState[] = ['normal', 'hover', 'pressed', 'disabled']
|
||||||
|
const EASINGS: Easing[] = ['linear', 'easeIn', 'easeOut', 'easeInOut']
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => props.modelValue, () => props.instanceId],
|
[() => props.modelValue, () => props.instanceId],
|
||||||
([open, iid]) => {
|
([open, iid]) => {
|
||||||
@@ -43,6 +47,8 @@ watch(
|
|||||||
localLabel.value = bi.label ?? ''
|
localLabel.value = bi.label ?? ''
|
||||||
localDisabled.value = !!bi.disabled
|
localDisabled.value = !!bi.disabled
|
||||||
localActions.value = (bi.onClick ?? []).map((a) => ({ ...a }))
|
localActions.value = (bi.onClick ?? []).map((a) => ({ ...a }))
|
||||||
|
const clip = store.scene.buttonClips?.find((c) => c.id === bi.buttonClipId)
|
||||||
|
localTransitions.value = (clip?.transitions ?? []).map((t) => ({ ...t }))
|
||||||
labelError.value = null
|
labelError.value = null
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -59,9 +65,30 @@ function commit(): boolean {
|
|||||||
}
|
}
|
||||||
store.setButtonInstanceOnClick(props.instanceId, localActions.value)
|
store.setButtonInstanceOnClick(props.instanceId, localActions.value)
|
||||||
store.setButtonInstanceDisabled(props.instanceId, localDisabled.value)
|
store.setButtonInstanceDisabled(props.instanceId, localDisabled.value)
|
||||||
|
const bi = store.scene.buttonInstances?.find((x) => x.id === props.instanceId)
|
||||||
|
if (bi) store.setButtonClipTransitions(bi.buttonClipId, localTransitions.value)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addTransition() {
|
||||||
|
localTransitions.value = [
|
||||||
|
...localTransitions.value,
|
||||||
|
{ from: 'normal', to: 'hover', duration: 0.15, easing: 'easeOut' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTransition(index: number) {
|
||||||
|
const next = [...localTransitions.value]
|
||||||
|
next.splice(index, 1)
|
||||||
|
localTransitions.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTransition(index: number, patch: Partial<StateTransition>) {
|
||||||
|
const next = [...localTransitions.value]
|
||||||
|
next[index] = { ...next[index], ...patch }
|
||||||
|
localTransitions.value = next
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
if (commit()) emit('update:modelValue', false)
|
if (commit()) emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
@@ -110,6 +137,50 @@ function removeInstance() {
|
|||||||
@update:actions="localActions = $event"
|
@update:actions="localActions = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">
|
||||||
|
{{ t('buttonInstanceDialog.transitions') }}
|
||||||
|
<small class="field-hint">{{ t('buttonInstanceDialog.transitionsHint') }}</small>
|
||||||
|
</span>
|
||||||
|
<ol v-if="localTransitions.length" class="trans-list">
|
||||||
|
<li v-for="(tr, i) in localTransitions" :key="i" class="trans-row">
|
||||||
|
<select
|
||||||
|
:value="tr.from"
|
||||||
|
@change="updateTransition(i, { from: ($event.target as HTMLSelectElement).value as ButtonState })"
|
||||||
|
>
|
||||||
|
<option v-for="s in BUTTON_STATES" :key="s" :value="s">{{ s }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<select
|
||||||
|
:value="tr.to"
|
||||||
|
@change="updateTransition(i, { to: ($event.target as HTMLSelectElement).value as ButtonState })"
|
||||||
|
>
|
||||||
|
<option v-for="s in BUTTON_STATES" :key="s" :value="s">{{ s }}</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
:value="tr.duration"
|
||||||
|
@input="updateTransition(i, { duration: Number(($event.target as HTMLInputElement).value) })"
|
||||||
|
class="dur"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
:value="tr.easing"
|
||||||
|
@change="updateTransition(i, { easing: ($event.target as HTMLSelectElement).value as Easing })"
|
||||||
|
>
|
||||||
|
<option v-for="e in EASINGS" :key="e" :value="e">{{ e }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="icon-btn danger" :title="t('actions.remove')" @click="removeTransition(i)">
|
||||||
|
<PhTrash :size="13" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<button class="add-btn" @click="addTransition">
|
||||||
|
<PhPlus :size="12" weight="bold" />
|
||||||
|
{{ t('buttonInstanceDialog.addTransition') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<button class="btn danger" @click="removeInstance">
|
<button class="btn danger" @click="removeInstance">
|
||||||
@@ -215,4 +286,83 @@ function removeInstance() {
|
|||||||
.spacer {
|
.spacer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trans-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trans-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trans-row select,
|
||||||
|
.trans-row input.dur {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 3px 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trans-row input.dur {
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trans-row .arrow {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.danger:hover {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px dashed var(--border-strong);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
align-self: flex-start;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover {
|
||||||
|
background: var(--panel-hover);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -18,8 +18,12 @@ import type {
|
|||||||
Layer,
|
Layer,
|
||||||
Point,
|
Point,
|
||||||
} from '@/types/scene'
|
} from '@/types/scene'
|
||||||
import { useButtonRuntime } from '@/composables/useButtonRuntime'
|
import {
|
||||||
|
applyButtonStateChange,
|
||||||
|
useButtonRuntime,
|
||||||
|
} from '@/composables/useButtonRuntime'
|
||||||
import { evalAction } from '@/lib/actions'
|
import { evalAction } from '@/lib/actions'
|
||||||
|
import { lerpProps } from '@/lib/interpolate'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { useKfSelection } from '@/composables/useKfSelection'
|
import { useKfSelection } from '@/composables/useKfSelection'
|
||||||
import { showLocked } from '@/composables/useShowLocked'
|
import { showLocked } from '@/composables/useShowLocked'
|
||||||
@@ -208,23 +212,86 @@ interface ButtonRenderItem {
|
|||||||
hit: { width: number; height: number; offsetX: number; offsetY: number }
|
hit: { width: number; height: number; offsetX: number; offsetY: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sampleStateLayers(
|
||||||
|
clip: ButtonClip,
|
||||||
|
state: ButtonState,
|
||||||
|
): ButtonRenderItem['layers'] {
|
||||||
|
const layers = clip.states[state] ?? []
|
||||||
|
const out: ButtonRenderItem['layers'] = []
|
||||||
|
for (const layer of layers) {
|
||||||
|
const asset = store.scene.assets.find((a) => a.id === layer.assetId)
|
||||||
|
if (!asset) continue
|
||||||
|
out.push({ layer, asset, props: sampleLayer(layer, 0) })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blend the "from" state's layers into the "to" state's layers by layer
|
||||||
|
* name. Matched pairs lerp their props; unmatched from-layers fade out,
|
||||||
|
* unmatched to-layers fade in.
|
||||||
|
*/
|
||||||
|
function blendStates(
|
||||||
|
clip: ButtonClip,
|
||||||
|
from: ButtonState,
|
||||||
|
to: ButtonState,
|
||||||
|
u: number,
|
||||||
|
): ButtonRenderItem['layers'] {
|
||||||
|
const fromLayers = clip.states[from] ?? []
|
||||||
|
const toLayers = clip.states[to] ?? []
|
||||||
|
const fromByName = new Map<string, Layer>()
|
||||||
|
const toByName = new Map<string, Layer>()
|
||||||
|
for (const l of fromLayers) fromByName.set(l.name, l)
|
||||||
|
for (const l of toLayers) toByName.set(l.name, l)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: ButtonRenderItem['layers'] = []
|
||||||
|
// Preserve to-state order primarily, with unmatched from-layers pinned
|
||||||
|
// underneath so the new state visually rises into place.
|
||||||
|
for (const l of fromLayers) {
|
||||||
|
if (toByName.has(l.name)) continue
|
||||||
|
const asset = store.scene.assets.find((a) => a.id === l.assetId)
|
||||||
|
if (!asset) continue
|
||||||
|
const base = sampleLayer(l, 0)
|
||||||
|
out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * (1 - u) } })
|
||||||
|
}
|
||||||
|
for (const l of toLayers) {
|
||||||
|
const asset = store.scene.assets.find((a) => a.id === l.assetId)
|
||||||
|
if (!asset) continue
|
||||||
|
seen.add(l.name)
|
||||||
|
const fromLayer = fromByName.get(l.name)
|
||||||
|
if (fromLayer) {
|
||||||
|
const a = sampleLayer(fromLayer, 0)
|
||||||
|
const b = sampleLayer(l, 0)
|
||||||
|
out.push({ layer: l, asset, props: lerpProps(a, b, u) })
|
||||||
|
} else {
|
||||||
|
const base = sampleLayer(l, 0)
|
||||||
|
out.push({ layer: l, asset, props: { ...base, opacity: base.opacity * u } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void seen
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
const activeButtonInstances = computed<ButtonRenderItem[]>(() => {
|
const activeButtonInstances = computed<ButtonRenderItem[]>(() => {
|
||||||
|
// Force re-evaluation on every transition RAF tick.
|
||||||
|
void buttonRt.transitionTick.value
|
||||||
const out: ButtonRenderItem[] = []
|
const out: ButtonRenderItem[] = []
|
||||||
const instances = store.scene.buttonInstances ?? []
|
const instances = store.scene.buttonInstances ?? []
|
||||||
const clips = store.scene.buttonClips ?? []
|
const clips = store.scene.buttonClips ?? []
|
||||||
const clipById = new Map(clips.map((c) => [c.id, c]))
|
const clipById = new Map(clips.map((c) => [c.id, c]))
|
||||||
|
const now = performance.now()
|
||||||
for (const bi of instances) {
|
for (const bi of instances) {
|
||||||
if (store.playhead < bi.startTime) continue
|
if (store.playhead < bi.startTime) continue
|
||||||
const clip = clipById.get(bi.buttonClipId)
|
const clip = clipById.get(bi.buttonClipId)
|
||||||
if (!clip) continue
|
if (!clip) continue
|
||||||
const disabled = buttonRt.isDisabled(bi.id, bi.disabled)
|
const disabled = buttonRt.isDisabled(bi.id, bi.disabled)
|
||||||
const state = buttonRt.getState(bi.id, bi.disabled)
|
const state = buttonRt.getState(bi.id, bi.disabled)
|
||||||
const layers = clip.states[state] ?? []
|
const tr = buttonRt.sampleTransition(bi.id, now)
|
||||||
const rendered: ButtonRenderItem['layers'] = []
|
let rendered: ButtonRenderItem['layers']
|
||||||
for (const layer of layers) {
|
if (tr && !tr.done && !disabled) {
|
||||||
const asset = store.scene.assets.find((a) => a.id === layer.assetId)
|
rendered = blendStates(clip, tr.from, tr.to, tr.u)
|
||||||
if (!asset) continue
|
} else {
|
||||||
rendered.push({ layer, asset, props: sampleLayer(layer, 0) })
|
rendered = sampleStateLayers(clip, state)
|
||||||
}
|
}
|
||||||
// Hit area: explicit hitArea on the clip, or fall back to the bbox of
|
// Hit area: explicit hitArea on the clip, or fall back to the bbox of
|
||||||
// the first rect-like asset in the normal state.
|
// the first rect-like asset in the normal state.
|
||||||
@@ -293,25 +360,30 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
|
|||||||
return { ...cfg, draggable: false, listening: false }
|
return { ...cfg, draggable: false, listening: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transitionsFor(bi: ButtonInstance) {
|
||||||
|
return store.scene.buttonClips?.find((c) => c.id === bi.buttonClipId)?.transitions
|
||||||
|
}
|
||||||
|
|
||||||
function onButtonMouseEnter(bi: ButtonInstance) {
|
function onButtonMouseEnter(bi: ButtonInstance) {
|
||||||
const cur = buttonRt.getState(bi.id, bi.disabled)
|
const cur = buttonRt.getState(bi.id, bi.disabled)
|
||||||
if (cur === 'normal') buttonRt.setState(bi.id, 'hover')
|
if (cur === 'normal') applyButtonStateChange(bi.id, 'hover', transitionsFor(bi), bi.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onButtonMouseLeave(bi: ButtonInstance) {
|
function onButtonMouseLeave(bi: ButtonInstance) {
|
||||||
const cur = buttonRt.getState(bi.id, bi.disabled)
|
const cur = buttonRt.getState(bi.id, bi.disabled)
|
||||||
if (cur === 'hover' || cur === 'pressed') buttonRt.setState(bi.id, 'normal')
|
if (cur === 'hover' || cur === 'pressed')
|
||||||
|
applyButtonStateChange(bi.id, 'normal', transitionsFor(bi), bi.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onButtonMouseDown(bi: ButtonInstance) {
|
function onButtonMouseDown(bi: ButtonInstance) {
|
||||||
const cur = buttonRt.getState(bi.id, bi.disabled)
|
const cur = buttonRt.getState(bi.id, bi.disabled)
|
||||||
if (cur === 'hover') buttonRt.setState(bi.id, 'pressed')
|
if (cur === 'hover') applyButtonStateChange(bi.id, 'pressed', transitionsFor(bi), bi.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onButtonMouseUp(bi: ButtonInstance) {
|
function onButtonMouseUp(bi: ButtonInstance) {
|
||||||
const cur = buttonRt.getState(bi.id, bi.disabled)
|
const cur = buttonRt.getState(bi.id, bi.disabled)
|
||||||
if (cur !== 'pressed') return
|
if (cur !== 'pressed') return
|
||||||
buttonRt.setState(bi.id, 'hover')
|
applyButtonStateChange(bi.id, 'hover', transitionsFor(bi), bi.disabled)
|
||||||
const actions = bi.onClick
|
const actions = bi.onClick
|
||||||
if (actions && actions.length) runButtonActions(actions)
|
if (actions && actions.length) runButtonActions(actions)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { reactive } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import type { ButtonState } from '@/types/scene'
|
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.
|
// Runtime-only per-instance state. Not persisted; resets on page reload.
|
||||||
interface RuntimeState {
|
interface RuntimeState {
|
||||||
@@ -8,17 +16,81 @@ interface RuntimeState {
|
|||||||
// Runtime disabled flag — overrides (and can be overridden by) the
|
// Runtime disabled flag — overrides (and can be overridden by) the
|
||||||
// instance's persisted `disabled` field. Undefined = use persisted.
|
// instance's persisted `disabled` field. Undefined = use persisted.
|
||||||
disabled: Map<string, boolean>
|
disabled: Map<string, boolean>
|
||||||
|
// Currently-running transition per instance.
|
||||||
|
transition: Map<string, ActiveTransition>
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtime: RuntimeState = reactive({
|
const runtime: RuntimeState = reactive({
|
||||||
state: new Map<string, ButtonState>(),
|
state: new Map<string, ButtonState>(),
|
||||||
disabled: new Map<string, boolean>(),
|
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() {
|
export function useButtonRuntime() {
|
||||||
return {
|
return {
|
||||||
state: runtime.state,
|
state: runtime.state,
|
||||||
disabled: runtime.disabled,
|
disabled: runtime.disabled,
|
||||||
|
transition: runtime.transition,
|
||||||
|
transitionTick,
|
||||||
getState(id: string, persistedDisabled?: boolean): ButtonState {
|
getState(id: string, persistedDisabled?: boolean): ButtonState {
|
||||||
const override = runtime.disabled.get(id)
|
const override = runtime.disabled.get(id)
|
||||||
const effectiveDisabled =
|
const effectiveDisabled =
|
||||||
@@ -40,9 +112,48 @@ export function useButtonRuntime() {
|
|||||||
clearDisabled(id: string) {
|
clearDisabled(id: string) {
|
||||||
runtime.disabled.delete(id)
|
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() {
|
reset() {
|
||||||
runtime.state.clear()
|
runtime.state.clear()
|
||||||
runtime.disabled.clear()
|
runtime.disabled.clear()
|
||||||
|
runtime.transition.clear()
|
||||||
|
transitionTick.value++
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+39
-1
@@ -1,6 +1,9 @@
|
|||||||
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
|
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
|
||||||
import { createBlankScene, useSceneStore } from '@/stores/scene'
|
import { createBlankScene, useSceneStore } from '@/stores/scene'
|
||||||
import { useButtonRuntime } from '@/composables/useButtonRuntime'
|
import {
|
||||||
|
applyButtonStateChange,
|
||||||
|
useButtonRuntime,
|
||||||
|
} from '@/composables/useButtonRuntime'
|
||||||
import {
|
import {
|
||||||
buildMarkerMap,
|
buildMarkerMap,
|
||||||
evalAction,
|
evalAction,
|
||||||
@@ -3543,6 +3546,41 @@ export function registerAllTests(): void {
|
|||||||
expect(rt.getState('b1', false)).toBe('disabled')
|
expect(rt.getState('b1', false)).toBe('disabled')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('applyButtonStateChange starts a transition only when one matches', () => {
|
||||||
|
const rt = useButtonRuntime()
|
||||||
|
rt.reset()
|
||||||
|
// No transitions defined: state flips instantly, no transition entry.
|
||||||
|
applyButtonStateChange('b1', 'hover', undefined, undefined)
|
||||||
|
expect(rt.getState('b1')).toBe('hover')
|
||||||
|
expect(rt.transition.has('b1')).toBe(false)
|
||||||
|
// With a matching transition: flip state AND start transition.
|
||||||
|
rt.reset()
|
||||||
|
applyButtonStateChange(
|
||||||
|
'b1',
|
||||||
|
'hover',
|
||||||
|
[{ from: 'normal', to: 'hover', duration: 0.2, easing: 'easeOut' }],
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(rt.getState('b1')).toBe('hover')
|
||||||
|
expect(rt.transition.has('b1')).toBe(true)
|
||||||
|
rt.endTransition('b1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sampleTransition returns null outside of transitions, u∈[0..1] within', () => {
|
||||||
|
const rt = useButtonRuntime()
|
||||||
|
rt.reset()
|
||||||
|
expect(rt.sampleTransition('b1', performance.now())).toBe(null)
|
||||||
|
const t0 = performance.now()
|
||||||
|
rt.startTransition('b1', 'normal', 'hover', 1, 'linear', t0)
|
||||||
|
const mid = rt.sampleTransition('b1', t0 + 500)!
|
||||||
|
expect(mid.done).toBe(false)
|
||||||
|
expect(mid.u > 0.4 && mid.u < 0.6).toBe(true)
|
||||||
|
const end = rt.sampleTransition('b1', t0 + 1100)!
|
||||||
|
expect(end.done).toBe(true)
|
||||||
|
expect(end.u).toBe(1)
|
||||||
|
rt.endTransition('b1')
|
||||||
|
})
|
||||||
|
|
||||||
test('store.actionRuntime resolves labels and drives useButtonRuntime', () => {
|
test('store.actionRuntime resolves labels and drives useButtonRuntime', () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
const rt = useButtonRuntime()
|
const rt = useButtonRuntime()
|
||||||
|
|||||||
@@ -423,6 +423,9 @@ export const en = {
|
|||||||
labelCollision: 'Label "{name}" is already used by another button instance.',
|
labelCollision: 'Label "{name}" is already used by another button instance.',
|
||||||
disabled: 'Start disabled',
|
disabled: 'Start disabled',
|
||||||
onClick: 'On click',
|
onClick: 'On click',
|
||||||
|
transitions: 'Transitions',
|
||||||
|
transitionsHint: '(shared by all instances of this button)',
|
||||||
|
addTransition: 'Add transition',
|
||||||
remove: 'Delete instance',
|
remove: 'Delete instance',
|
||||||
done: 'Done',
|
done: 'Done',
|
||||||
},
|
},
|
||||||
|
|||||||
+106
-13
@@ -1,6 +1,6 @@
|
|||||||
import type { Scene } from '@/types/scene'
|
import type { Scene } from '@/types/scene'
|
||||||
|
|
||||||
const RUNTIME_VERSION = '5'
|
const RUNTIME_VERSION = '6'
|
||||||
|
|
||||||
const ENGINE_SOURCE = `(function(){
|
const ENGINE_SOURCE = `(function(){
|
||||||
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
||||||
@@ -223,6 +223,78 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
if(st==='normal') delete runtime.buttonStates[id];
|
if(st==='normal') delete runtime.buttonStates[id];
|
||||||
else runtime.buttonStates[id]=st;
|
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){
|
function pointerToSceneXY(e){
|
||||||
var rc=canvas.getBoundingClientRect();
|
var rc=canvas.getBoundingClientRect();
|
||||||
var lw=scene.canvas.width, lh=scene.canvas.height;
|
var lw=scene.canvas.width, lh=scene.canvas.height;
|
||||||
@@ -242,20 +314,25 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
}
|
}
|
||||||
return null;
|
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){
|
function onStageMove(e){
|
||||||
var p=pointerToSceneXY(e);
|
var p=pointerToSceneXY(e);
|
||||||
if(!p) return;
|
if(!p) return;
|
||||||
var hit=hitButtonAt(p);
|
var hit=hitButtonAt(p);
|
||||||
for(var i=0;i<buttonHits.length;i++){
|
for(var i=0;i<buttonHits.length;i++){
|
||||||
var bh=buttonHits[i];
|
var bh=buttonHits[i];
|
||||||
var isDis=getEffectiveButtonState(bh.bi)==='disabled';
|
if(getEffectiveButtonState(bh.bi)==='disabled') continue;
|
||||||
if(isDis) continue;
|
var clip=findClipForInstance(bh.bi);
|
||||||
|
if(!clip) continue;
|
||||||
if(hit && hit.bi.id===bh.bi.id){
|
if(hit && hit.bi.id===bh.bi.id){
|
||||||
var cur=getEffectiveButtonState(bh.bi);
|
if(getEffectiveButtonState(bh.bi)==='normal') applyButtonChange(bh.bi, clip, 'hover');
|
||||||
if(cur==='normal') setButtonState(bh.bi.id,'hover');
|
|
||||||
} else {
|
} else {
|
||||||
var cur2=getEffectiveButtonState(bh.bi);
|
var cur=getEffectiveButtonState(bh.bi);
|
||||||
if(cur2==='hover'||cur2==='pressed') setButtonState(bh.bi.id,'normal');
|
if(cur==='hover'||cur==='pressed') applyButtonChange(bh.bi, clip, 'normal');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +342,8 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
var hit=hitButtonAt(p);
|
var hit=hitButtonAt(p);
|
||||||
if(!hit) return;
|
if(!hit) return;
|
||||||
if(getEffectiveButtonState(hit.bi)==='disabled') 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){
|
function onStageUp(e){
|
||||||
var p=pointerToSceneXY(e);
|
var p=pointerToSceneXY(e);
|
||||||
@@ -275,7 +353,8 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
if(getEffectiveButtonState(bh.bi)!=='pressed') continue;
|
if(getEffectiveButtonState(bh.bi)!=='pressed') continue;
|
||||||
var lx=p.x-(bh.bi.x||0), ly=p.y-(bh.bi.y||0);
|
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;
|
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){
|
if(inside){
|
||||||
var acts=bh.bi.onClick||[];
|
var acts=bh.bi.onClick||[];
|
||||||
for(var ai=0; ai<acts.length; ai++) evalAction(acts[ai]);
|
for(var ai=0; ai<acts.length; ai++) evalAction(acts[ai]);
|
||||||
@@ -356,7 +435,15 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
case 'setButtonState': {
|
case 'setButtonState': {
|
||||||
var sid=resolveButtonRef(act.instanceId);
|
var sid=resolveButtonRef(act.instanceId);
|
||||||
if(!sid){ runtime.warn('Unknown button ref: '+act.instanceId); return; }
|
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;
|
return;
|
||||||
}
|
}
|
||||||
case 'setButtonDisabled': {
|
case 'setButtonDisabled': {
|
||||||
@@ -486,10 +573,11 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
}
|
}
|
||||||
ctxm.restore();
|
ctxm.restore();
|
||||||
}
|
}
|
||||||
function drawLayer(layer,time){
|
function drawLayer(layer,time,propsOverride){
|
||||||
var asset=assetMap[layer.assetId];
|
var asset=assetMap[layer.assetId];
|
||||||
if(!asset||!layer.visible)return;
|
if(!asset||!layer.visible)return;
|
||||||
var p=sample(layer,time);
|
var p=sample(layer,time);
|
||||||
|
if(propsOverride){ for(var poKey in propsOverride) p[poKey]=propsOverride[poKey]; }
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
||||||
ctx.translate(p.x,p.y);
|
ctx.translate(p.x,p.y);
|
||||||
@@ -732,11 +820,16 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
var bclp=bClipById[bi.buttonClipId];
|
var bclp=bClipById[bi.buttonClipId];
|
||||||
if(!bclp) continue;
|
if(!bclp) continue;
|
||||||
var bst=getEffectiveButtonState(bi);
|
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;
|
var bx=bi.x||0, by=bi.y||0;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(bx, by);
|
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();
|
ctx.restore();
|
||||||
// Hit rect: explicit hitArea on clip, else bbox of first rect/circle
|
// Hit rect: explicit hitArea on clip, else bbox of first rect/circle
|
||||||
// layer in the normal state.
|
// layer in the normal state.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function lerpColor(a: string, b: string, t: number): string {
|
|||||||
return formatHex(lerp(pa[0], pb[0], t), lerp(pa[1], pb[1], t), lerp(pa[2], pb[2], t))
|
return formatHex(lerp(pa[0], pb[0], t), lerp(pa[1], pb[1], t), lerp(pa[2], pb[2], t))
|
||||||
}
|
}
|
||||||
|
|
||||||
function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): AnimatableProps {
|
export function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): AnimatableProps {
|
||||||
const out: AnimatableProps = {
|
const out: AnimatableProps = {
|
||||||
x: lerp(a.x, b.x, t),
|
x: lerp(a.x, b.x, t),
|
||||||
y: lerp(a.y, b.y, t),
|
y: lerp(a.y, b.y, t),
|
||||||
|
|||||||
+18
-2
@@ -27,7 +27,10 @@ import {
|
|||||||
scriptFramesInRange,
|
scriptFramesInRange,
|
||||||
type ActionRuntime,
|
type ActionRuntime,
|
||||||
} from '@/lib/actions'
|
} from '@/lib/actions'
|
||||||
import { useButtonRuntime } from '@/composables/useButtonRuntime'
|
import {
|
||||||
|
applyButtonStateChange,
|
||||||
|
useButtonRuntime,
|
||||||
|
} from '@/composables/useButtonRuntime'
|
||||||
|
|
||||||
const uid = () => Math.random().toString(36).slice(2, 10)
|
const uid = () => Math.random().toString(36).slice(2, 10)
|
||||||
|
|
||||||
@@ -1204,6 +1207,15 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
const clip = this.scene.buttonClips?.find((c) => c.id === id)
|
const clip = this.scene.buttonClips?.find((c) => c.id === id)
|
||||||
if (clip) clip.name = name.trim() || clip.name
|
if (clip) clip.name = name.trim() || clip.name
|
||||||
},
|
},
|
||||||
|
setButtonClipTransitions(
|
||||||
|
id: string,
|
||||||
|
transitions: NonNullable<ButtonClip['transitions']>,
|
||||||
|
) {
|
||||||
|
const clip = this.scene.buttonClips?.find((c) => c.id === id)
|
||||||
|
if (!clip) return
|
||||||
|
if (transitions.length) clip.transitions = [...transitions]
|
||||||
|
else delete clip.transitions
|
||||||
|
},
|
||||||
addButtonInstance(
|
addButtonInstance(
|
||||||
buttonClipId: string,
|
buttonClipId: string,
|
||||||
clipTrackId: string,
|
clipTrackId: string,
|
||||||
@@ -1577,7 +1589,11 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
console.warn('[animatr] unknown button ref:', ref)
|
console.warn('[animatr] unknown button ref:', ref)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
btnRt.setState(id, state)
|
const inst = instances.find((x) => x.id === id)
|
||||||
|
const clip = self.scene.buttonClips?.find(
|
||||||
|
(c) => c.id === inst?.buttonClipId,
|
||||||
|
)
|
||||||
|
applyButtonStateChange(id, state, clip?.transitions, inst?.disabled)
|
||||||
},
|
},
|
||||||
setButtonDisabled(ref, disabled) {
|
setButtonDisabled(ref, disabled) {
|
||||||
const id = resolve(ref)
|
const id = resolve(ref)
|
||||||
|
|||||||
Reference in new issue
Block a user