diff --git a/apps/stage-tamagotchi/src/renderer/components/devtools/live2d-motion-joystick.test.ts b/apps/stage-tamagotchi/src/renderer/components/devtools/live2d-motion-joystick.test.ts new file mode 100644 index 000000000..3208d1d3c --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/components/devtools/live2d-motion-joystick.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment jsdom + +import type { Live2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores' + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createApp, h } from 'vue' + +import Live2DMotionJoystick from './live2d-motion-joystick.vue' + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})) + +const neutralPose: Live2DMotionControlPose = { + x: 0, + y: 0, + headZ: 0, + bodyZ: 0, +} + +describe('live2DMotionJoystick', () => { + afterEach(() => { + vi.useRealTimers() + }) + + function mountJoystick(move: (pose: Live2DMotionControlPose) => void, release: () => void) { + const host = document.createElement('div') + document.body.appendChild(host) + const app = createApp({ + render: () => h(Live2DMotionJoystick, { + pose: neutralPose, + active: false, + onMove: move, + onRelease: release, + }), + }) + app.mount(host) + + return { + app, + button: host.querySelector('button')!, + host, + } + } + + it('smooths A and Q into left body and head roll', () => { + vi.useFakeTimers() + const move = vi.fn<(pose: Live2DMotionControlPose) => void>() + const release = vi.fn() + const mounted = mountJoystick(move, release) + + mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })) + mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'q', bubbles: true })) + vi.advanceTimersByTime(17) + + expect(move).toHaveBeenCalled() + expect(move.mock.lastCall?.[0].bodyZ).toBeGreaterThan(-1) + expect(move.mock.lastCall?.[0].bodyZ).toBeLessThan(0) + expect(move.mock.lastCall?.[0].headZ).toBeGreaterThan(-1) + expect(move.mock.lastCall?.[0].headZ).toBeLessThan(0) + + vi.advanceTimersByTime(300) + expect(move.mock.lastCall?.[0]).toEqual({ x: 0, y: 0, headZ: -1, bodyZ: -1 }) + + mounted.button.dispatchEvent(new KeyboardEvent('keyup', { key: 'a', bubbles: true })) + mounted.button.dispatchEvent(new KeyboardEvent('keyup', { key: 'q', bubbles: true })) + vi.advanceTimersByTime(300) + + expect(release).toHaveBeenCalledOnce() + mounted.app.unmount() + mounted.host.remove() + }) + + it('maps D and E to right body and head roll', () => { + vi.useFakeTimers() + const move = vi.fn<(pose: Live2DMotionControlPose) => void>() + const mounted = mountJoystick(move, vi.fn()) + + mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'D', bubbles: true })) + mounted.button.dispatchEvent(new KeyboardEvent('keydown', { key: 'E', bubbles: true })) + vi.advanceTimersByTime(300) + + expect(move.mock.lastCall?.[0]).toEqual({ x: 0, y: 0, headZ: 1, bodyZ: 1 }) + mounted.app.unmount() + mounted.host.remove() + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/components/devtools/live2d-motion-joystick.vue b/apps/stage-tamagotchi/src/renderer/components/devtools/live2d-motion-joystick.vue index 516639860..cbd2c6483 100644 --- a/apps/stage-tamagotchi/src/renderer/components/devtools/live2d-motion-joystick.vue +++ b/apps/stage-tamagotchi/src/renderer/components/devtools/live2d-motion-joystick.vue @@ -2,7 +2,7 @@ import type { Live2DMotionControlPose } from '@proj-airi/stage-ui-live2d/stores' import { BasicButton } from '@proj-airi/ui' -import { computed, shallowRef } from 'vue' +import { computed, onUnmounted, shallowRef, watch } from 'vue' import { useI18n } from 'vue-i18n' const props = defineProps<{ @@ -26,9 +26,17 @@ const movementKeys = new Set([ 'ArrowUp', 'a', 'd', + 'e', + 'q', 's', 'w', ]) +const neutralPose: Live2DMotionControlPose = Object.freeze({ x: 0, y: 0, headZ: 0, bodyZ: 0 }) + +let keyboardFrame: number | undefined +let keyboardFrameTime: number | undefined +let keyboardOwnsInput = false +let keyboardPose = neutralPose const knobStyle = computed(() => ({ transform: `translate(calc(-50% + ${props.pose.x * 5.75}rem), calc(-50% - ${props.pose.y * 5.75}rem))`, @@ -44,36 +52,54 @@ const parameterGroups = computed(() => [ label: t('tamagotchi.settings.devtools.pages.live2d-motion.groups.head'), x: props.pose.x * 30, y: props.pose.y * 30, + z: props.pose.headZ * 30, }, { label: t('tamagotchi.settings.devtools.pages.live2d-motion.groups.body'), x: props.pose.x * 10, y: props.pose.y * 10, + z: props.pose.bodyZ * 10, }, ]) -function setPosition(pose: Live2DMotionControlPose) { - const magnitude = Math.hypot(pose.x, pose.y) +function setPosition(x: number, y: number) { + const magnitude = Math.hypot(x, y) const scale = magnitude > 1 ? 1 / magnitude : 1 inputActive.value = true - emit('move', { x: pose.x * scale, y: pose.y * scale }) + emit('move', { + x: x * scale, + y: y * scale, + headZ: props.pose.headZ, + bodyZ: props.pose.bodyZ, + }) } function setPositionFromPointer(event: PointerEvent) { const target = event.currentTarget as HTMLElement const bounds = target.getBoundingClientRect() const radius = Math.min(bounds.width, bounds.height) / 2 - setPosition({ - x: (event.clientX - (bounds.left + bounds.width / 2)) / radius, - y: ((bounds.top + bounds.height / 2) - event.clientY) / radius, - }) + setPosition( + (event.clientX - (bounds.left + bounds.width / 2)) / radius, + ((bounds.top + bounds.height / 2) - event.clientY) / radius, + ) } -function release() { +function cancelKeyboardFrame() { + if (keyboardFrame !== undefined) + cancelAnimationFrame(keyboardFrame) + + keyboardFrame = undefined + keyboardFrameTime = undefined +} + +function releaseImmediately() { if (!inputActive.value) return + cancelKeyboardFrame() pressedKeys.clear() + keyboardOwnsInput = false + keyboardPose = neutralPose inputActive.value = false emit('release') } @@ -84,7 +110,9 @@ function handlePointerDown(event: PointerEvent) { const target = event.currentTarget as HTMLElement target.setPointerCapture(event.pointerId) + cancelKeyboardFrame() pressedKeys.clear() + keyboardOwnsInput = false setPositionFromPointer(event) } @@ -100,28 +128,95 @@ function handlePointerEnd(event: PointerEvent) { const target = event.currentTarget as HTMLElement if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId) - release() + releaseImmediately() } function keyboardPosition(): Live2DMotionControlPose { - const left = pressedKeys.has('ArrowLeft') || pressedKeys.has('a') - const right = pressedKeys.has('ArrowRight') || pressedKeys.has('d') + const left = pressedKeys.has('ArrowLeft') + const right = pressedKeys.has('ArrowRight') const down = pressedKeys.has('ArrowDown') || pressedKeys.has('s') const up = pressedKeys.has('ArrowUp') || pressedKeys.has('w') + const x = Number(right) - Number(left) + const y = Number(up) - Number(down) + const magnitude = Math.hypot(x, y) + const scale = magnitude > 1 ? 1 / magnitude : 1 + return { - x: Number(right) - Number(left), - y: Number(up) - Number(down), + x: x * scale, + y: y * scale, + headZ: Number(pressedKeys.has('e')) - Number(pressedKeys.has('q')), + bodyZ: Number(pressedKeys.has('d')) - Number(pressedKeys.has('a')), } } +function moveAxisTowards(current: number, target: number, maximumStep: number): number { + if (Math.abs(target - current) <= maximumStep) + return target + + return current + Math.sign(target - current) * maximumStep +} + +function posesMatch(first: Live2DMotionControlPose, second: Live2DMotionControlPose): boolean { + return first.x === second.x + && first.y === second.y + && first.headZ === second.headZ + && first.bodyZ === second.bodyZ +} + +function updateKeyboardPose(timestamp: number) { + keyboardFrame = undefined + const elapsedSeconds = keyboardFrameTime === undefined + ? 1 / 60 + : Math.min((timestamp - keyboardFrameTime) / 1000, 0.1) + keyboardFrameTime = timestamp + + // Four normalized units per second gives each key a 250 ms full-range ramp. + const maximumStep = elapsedSeconds * 4 + const target = keyboardPosition() + keyboardPose = { + x: moveAxisTowards(keyboardPose.x, target.x, maximumStep), + y: moveAxisTowards(keyboardPose.y, target.y, maximumStep), + headZ: moveAxisTowards(keyboardPose.headZ, target.headZ, maximumStep), + bodyZ: moveAxisTowards(keyboardPose.bodyZ, target.bodyZ, maximumStep), + } + inputActive.value = true + emit('move', keyboardPose) + + if (!posesMatch(keyboardPose, target)) { + keyboardFrame = requestAnimationFrame(updateKeyboardPose) + return + } + + keyboardFrameTime = undefined + if (pressedKeys.size > 0) + return + + keyboardOwnsInput = false + keyboardPose = neutralPose + inputActive.value = false + emit('release') +} + +function scheduleKeyboardUpdate() { + if (keyboardFrame === undefined) + keyboardFrame = requestAnimationFrame(updateKeyboardPose) +} + function handleKeyDown(event: KeyboardEvent) { const key = event.key.length === 1 ? event.key.toLowerCase() : event.key if (!movementKeys.has(key)) return event.preventDefault() + if (pressedKeys.has(key)) + return + + if (!keyboardOwnsInput) + keyboardPose = props.pose + + keyboardOwnsInput = true pressedKeys.add(key) - setPosition(keyboardPosition()) + scheduleKeyboardUpdate() } function handleKeyUp(event: KeyboardEvent) { @@ -131,13 +226,31 @@ function handleKeyUp(event: KeyboardEvent) { event.preventDefault() pressedKeys.delete(key) - if (pressedKeys.size === 0) { - release() + scheduleKeyboardUpdate() +} + +function handleBlur() { + if (!keyboardOwnsInput) { + releaseImmediately() return } - setPosition(keyboardPosition()) + pressedKeys.clear() + scheduleKeyboardUpdate() } + +watch(() => props.disabled, (disabled) => { + if (!disabled) + return + + cancelKeyboardFrame() + pressedKeys.clear() + keyboardOwnsInput = false + keyboardPose = neutralPose + inputActive.value = false +}) + +onUnmounted(cancelKeyboardFrame)