From b607dc67da7090092d29cde0312c6371005f2ba3 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 6 Aug 2026 13:46:51 +0800 Subject: [PATCH 01/10] feat(playtest): map confirmed character frames into a playable model --- .../pages/playtest/workbench/model.test.ts | 102 ++++++++++++++++++ .../src/pages/playtest/workbench/model.ts | 70 ++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 frontend/src/pages/playtest/workbench/model.test.ts create mode 100644 frontend/src/pages/playtest/workbench/model.ts diff --git a/frontend/src/pages/playtest/workbench/model.test.ts b/frontend/src/pages/playtest/workbench/model.test.ts new file mode 100644 index 00000000..377a7a5c --- /dev/null +++ b/frontend/src/pages/playtest/workbench/model.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' + +import type { Character } from '@/entities' + +import { createPlaytestModel } from './model' + +const character: Character = { + id: '51', + projectId: '42', + name: '轻装信使', + description: null, + referenceImageUrl: null, + dataVersion: 1, + status: 1, + outfits: [ + { + id: 'outfit-default', + characterId: '51', + name: '常态造型', + description: null, + previewUrl: null, + actions: [ + { + id: 'idle', + outfitId: 'outfit-default', + name: '待机', + type: 'idle', + loop: true, + fps: 8, + frameCount: 1, + frames: [{ index: 0, imageUrl: '/idle-01.png', durationMs: null }], + }, + { + id: 'walk', + outfitId: 'outfit-default', + name: '行走', + type: 'walk', + loop: true, + fps: 10, + frameCount: 3, + frames: [ + { index: 2, imageUrl: '/walk-03.png', durationMs: 100 }, + { index: 0, imageUrl: '/walk-01.png', durationMs: 100 }, + { index: 1, imageUrl: '/walk-02.png', durationMs: 100 }, + ], + }, + ], + }, + ], +} + +describe('createPlaytestModel', () => { + it('keeps only the fields needed to control and render an action', () => { + const result = createPlaytestModel(character, 'outfit-default') + + expect(result.ok && result.model.characterId).toBe('51') + expect(result.ok && result.model.outfitName).toBe('常态造型') + expect(result.ok && result.model.actions[0]).toEqual({ + id: 'idle', + name: '待机', + type: 'idle', + loop: true, + // durationMs 为 null 时按所属动作的 fps 换算,不用前端常量顶上。 + frames: [{ imageUrl: '/idle-01.png', durationMs: 125 }], + }) + }) + + it('orders frames by the backend index instead of array position', () => { + const result = createPlaytestModel(character, 'outfit-default') + + expect(result.ok && result.model.actions[1]?.frames.map((frame) => frame.imageUrl)).toEqual([ + '/walk-01.png', + '/walk-02.png', + '/walk-03.png', + ]) + }) + + it('drops actions that have no frames to play', () => { + const withEmptyAction = structuredClone(character) + withEmptyAction.outfits[0]!.actions[1]!.frames = [] + + const result = createPlaytestModel(withEmptyAction, 'outfit-default') + + expect(result.ok && result.model.actions.map((action) => action.id)).toEqual(['idle']) + }) + + it('rejects a missing outfit instead of falling back to another one', () => { + expect(createPlaytestModel(character, 'missing')).toEqual({ + ok: false, + reason: 'outfit_not_found', + }) + }) + + it('clamps an unusably short frame duration before it reaches the animation loop', () => { + const tinyDurationCharacter = structuredClone(character) + tinyDurationCharacter.outfits[0]!.actions[0]!.frames[0]!.durationMs = 0.001 + + const result = createPlaytestModel(tinyDurationCharacter, 'outfit-default') + + expect(result.ok && result.model.actions[0]?.frames[0]?.durationMs).toBe(1) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/model.ts b/frontend/src/pages/playtest/workbench/model.ts new file mode 100644 index 00000000..57d035af --- /dev/null +++ b/frontend/src/pages/playtest/workbench/model.ts @@ -0,0 +1,70 @@ +import type { ActionType, Character, Frame } from '@/entities' + +export interface PlaytestFrame { + readonly imageUrl: string + readonly durationMs: number +} + +export interface PlaytestAction { + readonly id: string + readonly name: string + readonly type: ActionType + /** 一次性动作播完停在末帧;只有循环动作才回到首帧。 */ + readonly loop: boolean + readonly frames: readonly PlaytestFrame[] +} + +export interface PlaytestModel { + readonly characterId: string + readonly outfitName: string + readonly actions: readonly PlaytestAction[] +} + +export type PlaytestModelResult = + | { readonly ok: true; readonly model: PlaytestModel } + | { readonly ok: false; readonly reason: 'outfit_not_found' } + +const DEFAULT_FRAME_DURATION_MS = 100 + +function frameDuration(durationMs: number | null, fps: number): number { + if (durationMs !== null && Number.isFinite(durationMs) && durationMs > 0) { + return Math.max(1, durationMs) + } + if (Number.isFinite(fps) && fps > 0) return Math.max(1, Math.round(1000 / fps)) + return DEFAULT_FRAME_DURATION_MS +} + +/** + * 播放顺序由 Frame.index 决定,不是数组下标。 + * 后端整棵下发资产树,数组顺序没有被契约保证;照数组播的话,顺序一变动画就乱, + * 而且乱得不报错。排序在这里做一次,运行时之后只按数组下标推进。 + */ +function orderedFrames(frames: readonly Frame[]): readonly Frame[] { + return [...frames].sort((left, right) => left.index - right.index) +} + +/** Playtest 只保留渲染和操控所需的数据;审核、生成与根位移仍属于各自原有边界。 */ +export function createPlaytestModel(character: Character, outfitId: string): PlaytestModelResult { + const outfit = character.outfits.find((candidate) => candidate.id === outfitId) + if (outfit === undefined) return { ok: false, reason: 'outfit_not_found' } + + return { + ok: true, + model: { + characterId: character.id, + outfitName: outfit.name, + actions: outfit.actions + .filter((action) => action.frames.length > 0) + .map((action) => ({ + id: action.id, + name: action.name, + type: action.type, + loop: action.loop, + frames: orderedFrames(action.frames).map((frame) => ({ + imageUrl: frame.imageUrl, + durationMs: frameDuration(frame.durationMs, action.fps), + })), + })), + }, + } +} From 5575ba900a61e325e9bfc113c19bb6eb183ae7ce Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 6 Aug 2026 13:46:51 +0800 Subject: [PATCH 02/10] feat(playtest): add the movement and playback state machine --- .../workbench/runtime/runtime.test.ts | 98 +++++++++++++ .../playtest/workbench/runtime/runtime.ts | 137 ++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 frontend/src/pages/playtest/workbench/runtime/runtime.test.ts create mode 100644 frontend/src/pages/playtest/workbench/runtime/runtime.ts diff --git a/frontend/src/pages/playtest/workbench/runtime/runtime.test.ts b/frontend/src/pages/playtest/workbench/runtime/runtime.test.ts new file mode 100644 index 00000000..07b45016 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/runtime/runtime.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' + +import type { PlaytestAction } from '../model' +import { advanceRuntime, createRuntime, selectRuntimeAction, setDirectionInput } from './runtime' + +const actions: readonly PlaytestAction[] = [ + { + id: 'idle', + name: '待机', + type: 'idle', + loop: true, + frames: [ + { imageUrl: '/idle-1.png', durationMs: 100 }, + { imageUrl: '/idle-2.png', durationMs: 100 }, + ], + }, + { + id: 'walk', + name: '行走', + type: 'walk', + loop: true, + frames: [ + { imageUrl: '/walk-1.png', durationMs: 80 }, + { imageUrl: '/walk-2.png', durationMs: 120 }, + ], + }, + { + id: 'attack', + name: '攻击', + type: 'attack', + loop: false, + frames: [ + { imageUrl: '/attack-1.png', durationMs: 150 }, + { imageUrl: '/attack-2.png', durationMs: 150 }, + ], + }, +] + +describe('playtest runtime', () => { + it('binds walk while a direction is held and returns to idle on release', () => { + const idle = createRuntime(actions, 'idle') + const walking = setDirectionInput(idle, actions, 'right', true) + const released = setDirectionInput(walking, actions, 'right', false) + + expect(walking).toMatchObject({ + actionId: 'walk', + frameIndex: 0, + facing: 1, + held: { left: false, right: true }, + }) + expect(released).toMatchObject({ + actionId: 'idle', + frameIndex: 0, + facing: 1, + held: { left: false, right: false }, + }) + }) + + it('moves continuously from elapsed time while animation uses its own frame durations', () => { + const walking = setDirectionInput(createRuntime(actions, 'idle'), actions, 'right', true) + const firstTick = advanceRuntime(walking, actions, 40, { minX: -100, maxX: 100 }, 150) + const secondTick = advanceRuntime(firstTick, actions, 40, { minX: -100, maxX: 100 }, 150) + + expect(firstTick).toMatchObject({ x: 6, frameIndex: 0, frameElapsedMs: 40 }) + expect(secondTick).toMatchObject({ x: 12, frameIndex: 1, frameElapsedMs: 0 }) + }) + + it('loops a looping action back to its first frame', () => { + const idle = createRuntime(actions, 'idle') + const wrapped = advanceRuntime(idle, actions, 50, { minX: 0, maxX: 0 }, 0) + const looped = [0, 1, 2].reduce( + (current) => advanceRuntime(current, actions, 50, { minX: 0, maxX: 0 }, 0), + wrapped, + ) + + expect(looped.frameIndex).toBe(0) + }) + + it('stops a one-shot action on its last frame instead of restarting it', () => { + const attacking = selectRuntimeAction(createRuntime(actions, null), actions, 'attack') + const played = [0, 1, 2, 3, 4, 5, 6, 7].reduce( + (current) => advanceRuntime(current, actions, 50, { minX: 0, maxX: 0 }, 0), + attacking, + ) + + expect(played.frameIndex).toBe(1) + }) + + it('keeps every bound action directly selectable without extra playback state', () => { + const selected = selectRuntimeAction(createRuntime(actions, null), actions, 'attack') + + expect(selected).toMatchObject({ + actionId: 'attack', + frameIndex: 0, + frameElapsedMs: 0, + }) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/runtime/runtime.ts b/frontend/src/pages/playtest/workbench/runtime/runtime.ts new file mode 100644 index 00000000..a5c2bec6 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/runtime/runtime.ts @@ -0,0 +1,137 @@ +import type { PlaytestAction } from '../model' + +export type Direction = 'left' | 'right' +export type Facing = -1 | 1 + +export interface StageBounds { + readonly minX: number + readonly maxX: number +} + +export interface PlaytestRuntime { + readonly actionId: string | null + readonly frameIndex: number + readonly frameElapsedMs: number + readonly x: number + readonly facing: Facing + readonly held: Readonly> +} + +const EMPTY_HELD: PlaytestRuntime['held'] = { left: false, right: false } + +function actionById( + actions: readonly PlaytestAction[], + actionId: string | null, +): PlaytestAction | undefined { + return actions.find((action) => action.id === actionId && action.frames.length > 0) +} + +function actionByType( + actions: readonly PlaytestAction[], + type: PlaytestAction['type'], +): PlaytestAction | undefined { + return actions.find((action) => action.type === type && action.frames.length > 0) +} + +function initialAction( + actions: readonly PlaytestAction[], + requestedActionId: string | null, +): PlaytestAction | undefined { + return ( + actionById(actions, requestedActionId) ?? + actionByType(actions, 'idle') ?? + actions.find((action) => action.frames.length > 0) + ) +} + +export function createRuntime( + actions: readonly PlaytestAction[], + initialActionId: string | null, +): PlaytestRuntime { + return { + actionId: initialAction(actions, initialActionId)?.id ?? null, + frameIndex: 0, + frameElapsedMs: 0, + x: 0, + facing: 1, + held: EMPTY_HELD, + } +} + +export function selectRuntimeAction( + runtime: PlaytestRuntime, + actions: readonly PlaytestAction[], + actionId: string, +): PlaytestRuntime { + const action = actionById(actions, actionId) + if (action === undefined || action.id === runtime.actionId) return runtime + + return { + ...runtime, + actionId: action.id, + frameIndex: 0, + frameElapsedMs: 0, + } +} + +function horizontalAxis(held: PlaytestRuntime['held']): -1 | 0 | 1 { + if (held.left === held.right) return 0 + return held.left ? -1 : 1 +} + +export function setDirectionInput( + runtime: PlaytestRuntime, + actions: readonly PlaytestAction[], + direction: Direction, + pressed: boolean, +): PlaytestRuntime { + if (runtime.held[direction] === pressed) return runtime + + const held = { ...runtime.held, [direction]: pressed } + const axis = horizontalAxis(held) + const action = axis === 0 ? actionByType(actions, 'idle') : actionByType(actions, 'walk') + const nextActionId = action?.id ?? runtime.actionId + + return { + ...runtime, + held, + facing: axis === 0 ? runtime.facing : axis, + actionId: nextActionId, + frameIndex: nextActionId === runtime.actionId ? runtime.frameIndex : 0, + frameElapsedMs: nextActionId === runtime.actionId ? runtime.frameElapsedMs : 0, + } +} + +export function advanceRuntime( + runtime: PlaytestRuntime, + actions: readonly PlaytestAction[], + deltaMs: number, + bounds: StageBounds, + movementSpeed: number, +): PlaytestRuntime { + if (!Number.isFinite(deltaMs) || deltaMs <= 0) return runtime + + const action = actionById(actions, runtime.actionId) + if (action === undefined) return runtime + + const axis = horizontalAxis(runtime.held) + const nextX = Math.min( + bounds.maxX, + Math.max(bounds.minX, runtime.x + (axis * movementSpeed * deltaMs) / 1000), + ) + const lastFrameIndex = action.frames.length - 1 + let frameIndex = Math.min(runtime.frameIndex, lastFrameIndex) + let frameElapsedMs = runtime.frameElapsedMs + deltaMs + + while (frameElapsedMs >= (action.frames[frameIndex]?.durationMs ?? 1)) { + // 非循环动作走到末帧就停住:攻击、跳跃这类一次性动作回到首帧会变成假的循环动画。 + if (!action.loop && frameIndex === lastFrameIndex) { + frameElapsedMs = action.frames[lastFrameIndex]?.durationMs ?? 1 + break + } + frameElapsedMs -= action.frames[frameIndex]?.durationMs ?? 1 + frameIndex = (frameIndex + 1) % action.frames.length + } + + return { ...runtime, x: nextX, frameIndex, frameElapsedMs } +} From 3043946b2a5b1eaf55b11b55ec8d891471c924d2 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 6 Aug 2026 13:46:51 +0800 Subject: [PATCH 03/10] feat(playtest): drive the runtime from keyboard and pointer input --- .../runtime/use-playtest-runtime.test.ts | 55 ++++++ .../workbench/runtime/use-playtest-runtime.ts | 165 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.test.ts create mode 100644 frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.ts diff --git a/frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.test.ts b/frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.test.ts new file mode 100644 index 00000000..ae3fc1e1 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { PlaytestAction } from '../model' +import { preloadActionFrames } from './use-playtest-runtime' + +const actions: readonly PlaytestAction[] = [ + { + id: 'idle', + name: '待机', + type: 'idle', + loop: true, + frames: [ + { imageUrl: '/idle-1.png', durationMs: 100 }, + { imageUrl: '/idle-2.png', durationMs: 100 }, + ], + }, + { + id: 'walk', + name: '行走', + type: 'walk', + loop: true, + frames: [ + { imageUrl: '/walk-1.png', durationMs: 100 }, + { imageUrl: '/idle-1.png', durationMs: 100 }, + ], + }, +] + +describe('preloadActionFrames', () => { + it('loads and decodes every unique bound frame before it is selected', () => { + const loaded: string[] = [] + const decoded: string[] = [] + const createImage = () => { + let currentUrl = '' + return { + get src() { + return currentUrl + }, + set src(url: string) { + currentUrl = url + loaded.push(url) + }, + decode: vi.fn(() => { + decoded.push(currentUrl) + return Promise.resolve() + }), + } as unknown as HTMLImageElement + } + + preloadActionFrames(actions, createImage) + + expect(loaded).toEqual(['/idle-1.png', '/idle-2.png', '/walk-1.png']) + expect(decoded).toEqual(loaded) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.ts b/frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.ts new file mode 100644 index 00000000..eaf5db91 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.ts @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import type { PlaytestAction } from '../model' +import { + advanceRuntime, + createRuntime, + selectRuntimeAction, + setDirectionInput, + type Direction, + type StageBounds, +} from './runtime' + +const MOVEMENT_SPEED = 150 +const MAX_FRAME_DELTA_MS = 50 +const INITIAL_BOUNDS: StageBounds = { minX: 0, maxX: 0 } + +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + return ( + target.isContentEditable || + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' + ) +} + +function keyDirection(key: string): Direction | null { + const normalized = key.toLowerCase() + if (normalized === 'a' || normalized === 'arrowleft') return 'left' + if (normalized === 'd' || normalized === 'arrowright') return 'right' + return null +} + +export function preloadActionFrames( + actions: readonly PlaytestAction[], + createImage?: () => HTMLImageElement, +): readonly HTMLImageElement[] { + const imageFactory = createImage ?? (typeof Image === 'undefined' ? null : () => new Image()) + if (imageFactory === null) return [] + + return [ + ...new Set(actions.flatMap((action) => action.frames.map((frame) => frame.imageUrl))), + ].map((imageUrl) => { + const image = imageFactory() + image.src = imageUrl + if (typeof image.decode === 'function') void image.decode().catch(() => undefined) + return image + }) +} + +export function usePlaytestRuntime( + actions: readonly PlaytestAction[], + initialActionId: string | null, +) { + const [runtime, setRuntime] = useState(() => createRuntime(actions, initialActionId)) + const actionsRef = useRef(actions) + const boundsRef = useRef(INITIAL_BOUNDS) + const activeInputsRef = useRef(new Map()) + const preloadedImagesRef = useRef([]) + + useEffect(() => { + actionsRef.current = actions + activeInputsRef.current.clear() + setRuntime(createRuntime(actions, initialActionId)) + }, [actions, initialActionId]) + + useEffect(() => { + preloadedImagesRef.current = preloadActionFrames(actions) + return () => { + preloadedImagesRef.current = [] + } + }, [actions]) + + useEffect(() => { + if (typeof requestAnimationFrame !== 'function') return + + let animationFrame = 0 + let previousTime: number | null = null + const tick = (time: number) => { + if (previousTime !== null) { + const deltaMs = Math.min(time - previousTime, MAX_FRAME_DELTA_MS) + setRuntime((current) => + advanceRuntime(current, actionsRef.current, deltaMs, boundsRef.current, MOVEMENT_SPEED), + ) + } + previousTime = time + animationFrame = requestAnimationFrame(tick) + } + + animationFrame = requestAnimationFrame(tick) + return () => cancelAnimationFrame(animationFrame) + }, []) + + const setDirection = useCallback( + (direction: Direction, pressed: boolean, source: string = direction) => { + if (pressed) activeInputsRef.current.set(source, direction) + else activeInputsRef.current.delete(source) + + const stillHeld = [...activeInputsRef.current.values()].includes(direction) + setRuntime((current) => setDirectionInput(current, actionsRef.current, direction, stillHeld)) + }, + [], + ) + + const clearDirections = useCallback(() => { + activeInputsRef.current.clear() + setRuntime((current) => { + const withoutLeft = setDirectionInput(current, actionsRef.current, 'left', false) + return setDirectionInput(withoutLeft, actionsRef.current, 'right', false) + }) + }, []) + + useEffect(() => { + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (isTypingTarget(event.target)) return + const direction = keyDirection(event.key) + if (direction === null) return + event.preventDefault() + if (event.repeat) return + setDirection(direction, true, `keyboard:${event.code || event.key}`) + } + const handleKeyUp = (event: globalThis.KeyboardEvent) => { + const direction = keyDirection(event.key) + if (direction === null) return + event.preventDefault() + setDirection(direction, false, `keyboard:${event.code || event.key}`) + } + + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + window.addEventListener('blur', clearDirections) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + window.removeEventListener('blur', clearDirections) + } + }, [clearDirections, setDirection]) + + const selectAction = useCallback((actionId: string) => { + setRuntime((current) => selectRuntimeAction(current, actionsRef.current, actionId)) + }, []) + + const setBounds = useCallback((bounds: StageBounds) => { + boundsRef.current = bounds + setRuntime((current) => ({ + ...current, + x: Math.min(bounds.maxX, Math.max(bounds.minX, current.x)), + })) + }, []) + + const action = useMemo( + () => actions.find((candidate) => candidate.id === runtime.actionId) ?? null, + [actions, runtime.actionId], + ) + const frame = action?.frames[runtime.frameIndex] ?? action?.frames[0] ?? null + + return { + runtime, + action, + frame, + selectAction, + setDirection, + setBounds, + } +} From 7be06ef4f5f216ee591ef9a3ad4492b8fd084694 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 6 Aug 2026 13:46:51 +0800 Subject: [PATCH 04/10] feat(playtest): render the character stage --- .../src/pages/playtest/workbench/stage.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 frontend/src/pages/playtest/workbench/stage.tsx diff --git a/frontend/src/pages/playtest/workbench/stage.tsx b/frontend/src/pages/playtest/workbench/stage.tsx new file mode 100644 index 00000000..e046caa8 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/stage.tsx @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef } from 'react' + +import type { PlaytestFrame } from './model' +import type { Facing, StageBounds } from './runtime/runtime' + +export interface PlaytestStageProps { + readonly frame: PlaytestFrame | null + readonly x: number + readonly facing: Facing + readonly onBoundsChange: (bounds: StageBounds) => void +} + +export function PlaytestStage({ frame, x, facing, onBoundsChange }: PlaytestStageProps) { + const stageRef = useRef(null) + const characterRef = useRef(null) + + const measureBounds = useCallback(() => { + const stageWidth = stageRef.current?.getBoundingClientRect().width ?? 0 + const characterWidth = characterRef.current?.getBoundingClientRect().width ?? 0 + if (stageWidth <= 0) return + + const limit = Math.max(0, (stageWidth - characterWidth) / 2 - 28) + onBoundsChange({ minX: -limit, maxX: limit }) + }, [onBoundsChange]) + + useEffect(() => { + measureBounds() + const stage = stageRef.current + if (stage === null || typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measureBounds) + return () => window.removeEventListener('resize', measureBounds) + } + + const observer = new ResizeObserver(measureBounds) + observer.observe(stage) + return () => observer.disconnect() + }, [measureBounds]) + + return ( +
+