From f61f90d613969c570a18e44b670bde4e57f05467 Mon Sep 17 00:00:00 2001 From: th Date: Mon, 20 Apr 2026 21:40:02 +0200 Subject: [PATCH] macro-clips slice 4d: button canvas runtime + pointer handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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) --- src/components/CanvasStage.vue | 178 +++++++++++++++++++++++++++- src/composables/useButtonRuntime.ts | 48 ++++++++ src/devtools/tests.ts | 40 +++++++ src/lib/exportPlayer.ts | 124 ++++++++++++++++++- src/stores/scene.ts | 27 ++++- src/types/scene.ts | 3 + 6 files changed, 412 insertions(+), 8 deletions(-) create mode 100644 src/composables/useButtonRuntime.ts diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index f1ff55f..f59a411 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -7,7 +7,19 @@ import { sampleLayer } from '@/lib/interpolate' import { importImageFile, loadImage } from '@/lib/fileLoader' import { useClipartStore } from '@/stores/clipart' import { drawSceneFrame, loadSceneImages } from '@/lib/render' -import type { AnimatableProps, Asset, Keyframe, Layer, Point } from '@/types/scene' +import type { + Action, + AnimatableProps, + Asset, + ButtonClip, + ButtonInstance, + ButtonState, + Keyframe, + Layer, + Point, +} from '@/types/scene' +import { useButtonRuntime } from '@/composables/useButtonRuntime' +import { evalAction } from '@/lib/actions' import { t } from '@/i18n' import { useKfSelection } from '@/composables/useKfSelection' import { showLocked } from '@/composables/useShowLocked' @@ -181,6 +193,134 @@ const canvasBorderConfig = computed(() => ({ const zoomPercent = computed(() => Math.round(zoom.value * 100)) +const buttonRt = useButtonRuntime() + +interface ButtonRenderItem { + bi: ButtonInstance + clip: ButtonClip + state: ButtonState + disabled: boolean + layers: Array<{ + layer: Layer + asset: Asset + props: AnimatableProps + }> + hit: { width: number; height: number; offsetX: number; offsetY: number } +} + +const activeButtonInstances = computed(() => { + const out: ButtonRenderItem[] = [] + const instances = store.scene.buttonInstances ?? [] + const clips = store.scene.buttonClips ?? [] + const clipById = new Map(clips.map((c) => [c.id, c])) + for (const bi of instances) { + if (store.playhead < bi.startTime) continue + const clip = clipById.get(bi.buttonClipId) + if (!clip) continue + const disabled = buttonRt.isDisabled(bi.id, bi.disabled) + const state = buttonRt.getState(bi.id, bi.disabled) + const layers = clip.states[state] ?? [] + const rendered: ButtonRenderItem['layers'] = [] + for (const layer of layers) { + const asset = store.scene.assets.find((a) => a.id === layer.assetId) + if (!asset) continue + rendered.push({ layer, asset, props: sampleLayer(layer, 0) }) + } + // Hit area: explicit hitArea on the clip, or fall back to the bbox of + // the first rect-like asset in the normal state. + let hit = clip.hitArea + ? { + width: clip.hitArea.width, + height: clip.hitArea.height, + offsetX: clip.hitArea.offsetX ?? clip.hitArea.width / 2, + offsetY: clip.hitArea.offsetY ?? clip.hitArea.height / 2, + } + : null + if (!hit) { + for (const l of clip.states.normal) { + const a = store.scene.assets.find((x) => x.id === l.assetId) + if (!a) continue + if (a.kind === 'rect' || a.kind === 'image') { + hit = { + width: a.width, + height: a.height, + offsetX: l.offsetX ?? a.width / 2, + offsetY: l.offsetY ?? a.height / 2, + } + break + } + if (a.kind === 'circle') { + hit = { + width: a.radius * 2, + height: a.radius * 2, + offsetX: (l.offsetX ?? 0) + a.radius, + offsetY: (l.offsetY ?? 0) + a.radius, + } + break + } + } + } + if (!hit) hit = { width: 120, height: 40, offsetX: 60, offsetY: 20 } + out.push({ bi, clip, state, disabled, layers: rendered, hit }) + } + return out +}) + +function buttonGroupConfig(bi: ButtonInstance) { + return { + id: `btn:${bi.id}`, + x: bi.x ?? 0, + y: bi.y ?? 0, + listening: true, + } +} + +function buttonHitRectConfig(item: ButtonRenderItem) { + return { + x: 0, + y: 0, + width: item.hit.width, + height: item.hit.height, + offsetX: item.hit.offsetX, + offsetY: item.hit.offsetY, + fill: 'rgba(0,0,0,0.001)', // nearly transparent but hittable + listening: !item.disabled, + name: 'button-hit', + } +} + +function buttonChildConfig>(cfg: T): T { + return { ...cfg, draggable: false, listening: false } +} + +function onButtonMouseEnter(bi: ButtonInstance) { + const cur = buttonRt.getState(bi.id, bi.disabled) + if (cur === 'normal') buttonRt.setState(bi.id, 'hover') +} + +function onButtonMouseLeave(bi: ButtonInstance) { + const cur = buttonRt.getState(bi.id, bi.disabled) + if (cur === 'hover' || cur === 'pressed') buttonRt.setState(bi.id, 'normal') +} + +function onButtonMouseDown(bi: ButtonInstance) { + const cur = buttonRt.getState(bi.id, bi.disabled) + if (cur === 'hover') buttonRt.setState(bi.id, 'pressed') +} + +function onButtonMouseUp(bi: ButtonInstance) { + const cur = buttonRt.getState(bi.id, bi.disabled) + if (cur !== 'pressed') return + buttonRt.setState(bi.id, 'hover') + const actions = bi.onClick + if (actions && actions.length) runButtonActions(actions) +} + +function runButtonActions(actions: Action[]) { + const rt = store.actionRuntime() + for (const a of actions) evalAction(a, rt) +} + const assetMap = computed(() => { const m = new Map() for (const a of store.scene.assets) m.set(a.id, a) @@ -2346,6 +2486,42 @@ onBeforeUnmount(() => { /> + diff --git a/src/composables/useButtonRuntime.ts b/src/composables/useButtonRuntime.ts new file mode 100644 index 0000000..c7f7cb3 --- /dev/null +++ b/src/composables/useButtonRuntime.ts @@ -0,0 +1,48 @@ +import { reactive } from 'vue' +import type { ButtonState } from '@/types/scene' + +// Runtime-only per-instance state. Not persisted; resets on page reload. +interface RuntimeState { + // Which state the button is currently showing. Undefined = 'normal'. + state: Map + // Runtime disabled flag — overrides (and can be overridden by) the + // instance's persisted `disabled` field. Undefined = use persisted. + disabled: Map +} + +const runtime: RuntimeState = reactive({ + state: new Map(), + disabled: new Map(), +}) + +export function useButtonRuntime() { + return { + state: runtime.state, + disabled: runtime.disabled, + getState(id: string, persistedDisabled?: boolean): ButtonState { + const override = runtime.disabled.get(id) + const effectiveDisabled = + override !== undefined ? override : !!persistedDisabled + if (effectiveDisabled) return 'disabled' + return runtime.state.get(id) ?? 'normal' + }, + isDisabled(id: string, persistedDisabled?: boolean): boolean { + const override = runtime.disabled.get(id) + return override !== undefined ? override : !!persistedDisabled + }, + setState(id: string, next: ButtonState) { + if (next === 'normal') runtime.state.delete(id) + else runtime.state.set(id, next) + }, + setDisabled(id: string, next: boolean) { + runtime.disabled.set(id, next) + }, + clearDisabled(id: string) { + runtime.disabled.delete(id) + }, + reset() { + runtime.state.clear() + runtime.disabled.clear() + }, + } +} diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index ddf8e8f..ce1ed6d 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -1,5 +1,6 @@ import { createApp, h, nextTick, type Component, type Slot } from 'vue' import { createBlankScene, useSceneStore } from '@/stores/scene' +import { useButtonRuntime } from '@/composables/useButtonRuntime' import { buildMarkerMap, evalAction, @@ -3521,6 +3522,45 @@ export function registerAllTests(): void { }) }) + suite('button runtime — state machine + action wiring', () => { + test('default state is normal; set/clear round-trip works', () => { + const rt = useButtonRuntime() + rt.reset() + expect(rt.getState('b1')).toBe('normal') + rt.setState('b1', 'pressed') + expect(rt.getState('b1')).toBe('pressed') + rt.setState('b1', 'normal') // normal uses delete internally + expect(rt.getState('b1')).toBe('normal') + }) + + test('persisted disabled surfaces as state=disabled until runtime overrides', () => { + const rt = useButtonRuntime() + rt.reset() + expect(rt.getState('b1', true)).toBe('disabled') + rt.setDisabled('b1', false) // runtime enables + expect(rt.getState('b1', true)).toBe('normal') + rt.setDisabled('b1', true) + expect(rt.getState('b1', false)).toBe('disabled') + }) + + test('store.actionRuntime resolves labels and drives useButtonRuntime', () => { + const s = makeFreshStore() + const rt = useButtonRuntime() + rt.reset() + const bc = s.addButtonClip('B') + const ct = s.addClipTrack() + const inst = s.addButtonInstance(bc.id, ct.id, 0)! + s.setButtonInstanceLabel(inst.id, 'play') + const ar = s.actionRuntime() + ar.setButtonState('play', 'pressed') + expect(rt.getState(inst.id)).toBe('pressed') + ar.setButtonDisabled('play', true) + expect(rt.getState(inst.id)).toBe('disabled') + ar.setButtonDisabled('play', false) + expect(rt.getState(inst.id, inst.disabled)).toBe('pressed') + }) + }) + suite('scene store — macro model normalization', () => { test('loadScene initializes new arrays when missing', () => { const s = makeFreshStore() diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts index 31d7a42..2a63d1e 100644 --- a/src/lib/exportPlayer.ts +++ b/src/lib/exportPlayer.ts @@ -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=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=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 Math.random().toString(36).slice(2, 10) @@ -1215,10 +1216,18 @@ export const useSceneStore = defineStore('scene', { buttonClipId, clipTrackId, startTime: Math.max(0, Math.min(this.scene.duration, startTime)), + x: this.scene.canvas.width / 2, + y: this.scene.canvas.height / 2, } ;(this.scene.buttonInstances ||= []).push(inst) return inst }, + setButtonInstancePosition(id: string, x: number, y: number) { + const target = this.scene.buttonInstances?.find((bi) => bi.id === id) + if (!target) return + target.x = x + target.y = y + }, removeButtonInstance(id: string) { if (!this.scene.buttonInstances) return this.scene.buttonInstances = this.scene.buttonInstances.filter((bi) => bi.id !== id) @@ -1545,6 +1554,7 @@ export const useSceneStore = defineStore('scene', { if (byId.has(ref)) return ref return byLabel.get(ref) ?? null } + const btnRt = useButtonRuntime() return { fps: self.scene.fps, playbackIn: self.playbackIn, @@ -1560,18 +1570,23 @@ export const useSceneStore = defineStore('scene', { pause() { self.pause() }, - setButtonState() { - // No-op in editor for now — runtime-state management lands in - // the export-runtime slice together with pointer handling. - }, - setButtonDisabled(ref: string, disabled: boolean) { + setButtonState(ref, state) { const id = resolve(ref) if (!id) { // eslint-disable-next-line no-console console.warn('[animatr] unknown button ref:', ref) return } - self.setButtonInstanceDisabled(id, disabled) + btnRt.setState(id, state) + }, + setButtonDisabled(ref, disabled) { + const id = resolve(ref) + if (!id) { + // eslint-disable-next-line no-console + console.warn('[animatr] unknown button ref:', ref) + return + } + btnRt.setDisabled(id, disabled) }, warn(msg: string) { // eslint-disable-next-line no-console diff --git a/src/types/scene.ts b/src/types/scene.ts index b2609ba..9cef66a 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -198,6 +198,9 @@ export interface ButtonInstance { buttonClipId: string clipTrackId: string startTime: number + /** Stage-space position of the button's origin. */ + x?: number + y?: number /** Unique, user-facing handle used in dropdowns and script DSL. */ label?: string onClick?: Action[]