mirror of
https://github.com/moeru-ai/airi.git
synced 2026-09-21 05:20:03 +08:00
feat(stage-tamagotchi): smooth Live2D tilt keys
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
+140
-19
@@ -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)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -162,7 +275,7 @@ function handleKeyUp(event: KeyboardEvent) {
|
||||
@pointercancel="handlePointerEnd"
|
||||
@keydown="handleKeyDown"
|
||||
@keyup="handleKeyUp"
|
||||
@blur="release"
|
||||
@blur="handleBlur"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<span :class="['pointer-events-none absolute inset-6 rounded-full', 'border border-neutral-300/60 dark:border-neutral-700/60']" />
|
||||
@@ -226,6 +339,14 @@ function handleKeyUp(event: KeyboardEvent) {
|
||||
<dd :class="['text-right font-mono text-neutral-800 tabular-nums dark:text-neutral-100']">
|
||||
{{ group.y.toFixed(2) }}
|
||||
</dd>
|
||||
<template v-if="group.z !== undefined">
|
||||
<dt :class="['text-neutral-500 dark:text-neutral-400']">
|
||||
Z
|
||||
</dt>
|
||||
<dd :class="['text-right font-mono text-neutral-800 tabular-nums dark:text-neutral-100']">
|
||||
{{ group.z.toFixed(2) }}
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -17,22 +17,22 @@ describe('live2D motion recording', () => {
|
||||
now: () => now,
|
||||
})
|
||||
|
||||
controller.startRecording({ x: 0, y: 0 })
|
||||
controller.startRecording({ x: 0, y: 0, headZ: 0, bodyZ: 0 })
|
||||
now = 125
|
||||
controller.recordPose({ x: 0.5, y: -0.25 })
|
||||
controller.recordPose({ x: 0.5, y: -0.25, headZ: -0.5, bodyZ: 0.75 })
|
||||
now = 175
|
||||
controller.recordPose({ x: 0.5, y: -0.25 })
|
||||
controller.recordPose({ x: 0.5, y: -0.25, headZ: -0.5, bodyZ: 0.75 })
|
||||
now = 200
|
||||
controller.recordPose({ x: 0, y: 0 })
|
||||
controller.recordPose({ x: 0, y: 0, headZ: 0, bodyZ: 0 })
|
||||
controller.stopRecording()
|
||||
|
||||
expect(controller.recording.value).toEqual({
|
||||
format: 'airi-live2d-motion/v1',
|
||||
format: 'airi-live2d-motion/v2',
|
||||
durationMs: 100,
|
||||
samples: [
|
||||
{ atMs: 0, x: 0, y: 0 },
|
||||
{ atMs: 25, x: 0.5, y: -0.25 },
|
||||
{ atMs: 100, x: 0, y: 0 },
|
||||
{ atMs: 0, x: 0, y: 0, headZ: 0, bodyZ: 0 },
|
||||
{ atMs: 25, x: 0.5, y: -0.25, headZ: -0.5, bodyZ: 0.75 },
|
||||
{ atMs: 100, x: 0, y: 0, headZ: 0, bodyZ: 0 },
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -43,7 +43,7 @@ describe('live2D motion recording', () => {
|
||||
const appliedPoses: Live2DMotionControlPose[] = []
|
||||
const releasePose = vi.fn()
|
||||
const controller = useLive2DMotionRecording({
|
||||
applyPose: pose => appliedPoses.push({ x: pose.x, y: pose.y }),
|
||||
applyPose: pose => appliedPoses.push({ ...pose }),
|
||||
releasePose,
|
||||
now: () => now,
|
||||
requestFrame: (callback) => {
|
||||
@@ -54,24 +54,24 @@ describe('live2D motion recording', () => {
|
||||
})
|
||||
|
||||
controller.loadRecording(parseLive2DMotionRecording(JSON.stringify({
|
||||
format: 'airi-live2d-motion/v1',
|
||||
format: 'airi-live2d-motion/v2',
|
||||
durationMs: 200,
|
||||
samples: [
|
||||
{ atMs: 0, x: 0, y: 0 },
|
||||
{ atMs: 50, x: 0.5, y: 0.25 },
|
||||
{ atMs: 150, x: -1, y: 1 },
|
||||
{ atMs: 0, x: 0, y: 0, headZ: 0, bodyZ: 0 },
|
||||
{ atMs: 50, x: 0.5, y: 0.25, headZ: -0.5, bodyZ: 0.5 },
|
||||
{ atMs: 150, x: -1, y: 1, headZ: 1, bodyZ: -1 },
|
||||
],
|
||||
})))
|
||||
|
||||
controller.startPlayback()
|
||||
expect(appliedPoses).toEqual([{ x: 0, y: 0 }])
|
||||
expect(appliedPoses).toEqual([{ x: 0, y: 0, headZ: 0, bodyZ: 0 }])
|
||||
|
||||
now = 1150
|
||||
nextFrame?.(now)
|
||||
expect(appliedPoses).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0.5, y: 0.25 },
|
||||
{ x: -1, y: 1 },
|
||||
{ x: 0, y: 0, headZ: 0, bodyZ: 0 },
|
||||
{ x: 0.5, y: 0.25, headZ: -0.5, bodyZ: 0.5 },
|
||||
{ x: -1, y: 1, headZ: 1, bodyZ: -1 },
|
||||
])
|
||||
|
||||
now = 1200
|
||||
@@ -82,11 +82,11 @@ describe('live2D motion recording', () => {
|
||||
|
||||
it('round-trips the versioned JSON format', () => {
|
||||
const recording = parseLive2DMotionRecording(JSON.stringify({
|
||||
format: 'airi-live2d-motion/v1',
|
||||
format: 'airi-live2d-motion/v2',
|
||||
durationMs: 50,
|
||||
samples: [
|
||||
{ atMs: 0, x: 0, y: 0 },
|
||||
{ atMs: 50, x: 1, y: -1 },
|
||||
{ atMs: 0, x: 0, y: 0, headZ: 0, bodyZ: 0 },
|
||||
{ atMs: 50, x: 1, y: -1, headZ: -1, bodyZ: 1 },
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -95,20 +95,20 @@ describe('live2D motion recording', () => {
|
||||
|
||||
it('rejects samples outside the normalized joystick range', () => {
|
||||
expect(() => parseLive2DMotionRecording(JSON.stringify({
|
||||
format: 'airi-live2d-motion/v1',
|
||||
format: 'airi-live2d-motion/v2',
|
||||
durationMs: 10,
|
||||
samples: [{ atMs: 0, x: 1.1, y: 0 }],
|
||||
samples: [{ atMs: 0, x: 0, y: 0, headZ: 1.1, bodyZ: 0 }],
|
||||
}))).toThrow('The file is not an AIRI Live2D motion recording.')
|
||||
})
|
||||
|
||||
it('rejects samples that are not in time order', () => {
|
||||
expect(() => parseLive2DMotionRecording(JSON.stringify({
|
||||
format: 'airi-live2d-motion/v1',
|
||||
format: 'airi-live2d-motion/v2',
|
||||
durationMs: 20,
|
||||
samples: [
|
||||
{ atMs: 0, x: 0, y: 0 },
|
||||
{ atMs: 20, x: 1, y: 0 },
|
||||
{ atMs: 10, x: 0, y: 0 },
|
||||
{ atMs: 0, x: 0, y: 0, headZ: 0, bodyZ: 0 },
|
||||
{ atMs: 20, x: 1, y: 0, headZ: 0, bodyZ: 0 },
|
||||
{ atMs: 10, x: 0, y: 0, headZ: 0, bodyZ: 0 },
|
||||
],
|
||||
}))).toThrow('The motion samples must be in time order.')
|
||||
})
|
||||
|
||||
@@ -9,10 +9,12 @@ const live2dMotionSampleSchema = object({
|
||||
atMs: pipe(number(), finite(), minValue(0)),
|
||||
x: pipe(number(), finite(), minValue(-1), maxValue(1)),
|
||||
y: pipe(number(), finite(), minValue(-1), maxValue(1)),
|
||||
headZ: pipe(number(), finite(), minValue(-1), maxValue(1)),
|
||||
bodyZ: pipe(number(), finite(), minValue(-1), maxValue(1)),
|
||||
})
|
||||
|
||||
const live2dMotionRecordingSchema = object({
|
||||
format: literal('airi-live2d-motion/v1'),
|
||||
format: literal('airi-live2d-motion/v2'),
|
||||
durationMs: pipe(number(), finite(), minValue(0)),
|
||||
samples: pipe(array(live2dMotionSampleSchema), minLength(1)),
|
||||
})
|
||||
@@ -61,8 +63,8 @@ interface Live2DMotionRecordingController {
|
||||
* Parses and validates a Live2D joystick recording at the file boundary.
|
||||
*
|
||||
* @example
|
||||
* parseLive2DMotionRecording('{"format":"airi-live2d-motion/v1","durationMs":0,"samples":[{"atMs":0,"x":0,"y":0}]}')
|
||||
* // => { format: 'airi-live2d-motion/v1', durationMs: 0, samples: [{ atMs: 0, x: 0, y: 0 }] }
|
||||
* parseLive2DMotionRecording('{"format":"airi-live2d-motion/v2","durationMs":0,"samples":[{"atMs":0,"x":0,"y":0,"headZ":0,"bodyZ":0}]}')
|
||||
* // => { format: 'airi-live2d-motion/v2', durationMs: 0, samples: [{ atMs: 0, x: 0, y: 0, headZ: 0, bodyZ: 0 }] }
|
||||
*/
|
||||
export function parseLive2DMotionRecording(raw: string): Live2DMotionRecording {
|
||||
let input: unknown
|
||||
@@ -96,8 +98,8 @@ export function parseLive2DMotionRecording(raw: string): Live2DMotionRecording {
|
||||
* Serializes a Live2D joystick recording as a readable JSON file.
|
||||
*
|
||||
* @example
|
||||
* stringifyLive2DMotionRecording({ format: 'airi-live2d-motion/v1', durationMs: 0, samples: [{ atMs: 0, x: 0, y: 0 }] })
|
||||
* // => '{\n "format": "airi-live2d-motion/v1", ...\n}\n'
|
||||
* stringifyLive2DMotionRecording({ format: 'airi-live2d-motion/v2', durationMs: 0, samples: [{ atMs: 0, x: 0, y: 0, headZ: 0, bodyZ: 0 }] })
|
||||
* // => '{\n "format": "airi-live2d-motion/v2", ...\n}\n'
|
||||
*/
|
||||
export function stringifyLive2DMotionRecording(recording: ReadonlyLive2DMotionRecording): string {
|
||||
return `${JSON.stringify(recording, null, 2)}\n`
|
||||
@@ -135,7 +137,13 @@ export function useLive2DMotionRecording(
|
||||
return
|
||||
|
||||
recording.value = null
|
||||
capturedSamples = [{ atMs: 0, x: initialPose.x, y: initialPose.y }]
|
||||
capturedSamples = [{
|
||||
atMs: 0,
|
||||
x: initialPose.x,
|
||||
y: initialPose.y,
|
||||
headZ: initialPose.headZ,
|
||||
bodyZ: initialPose.bodyZ,
|
||||
}]
|
||||
status.value = { type: 'recording', startedAt: now() }
|
||||
}
|
||||
|
||||
@@ -144,10 +152,22 @@ export function useLive2DMotionRecording(
|
||||
return
|
||||
|
||||
const atMs = Math.max(0, Math.round(now() - status.value.startedAt))
|
||||
const nextSample: Live2DMotionSample = { atMs, x: pose.x, y: pose.y }
|
||||
const nextSample: Live2DMotionSample = {
|
||||
atMs,
|
||||
x: pose.x,
|
||||
y: pose.y,
|
||||
headZ: pose.headZ,
|
||||
bodyZ: pose.bodyZ,
|
||||
}
|
||||
const previousSample = capturedSamples.at(-1)
|
||||
if (previousSample?.x === pose.x && previousSample.y === pose.y)
|
||||
if (
|
||||
previousSample?.x === pose.x
|
||||
&& previousSample.y === pose.y
|
||||
&& previousSample.headZ === pose.headZ
|
||||
&& previousSample.bodyZ === pose.bodyZ
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (previousSample?.atMs === atMs) {
|
||||
capturedSamples[capturedSamples.length - 1] = nextSample
|
||||
@@ -166,7 +186,7 @@ export function useLive2DMotionRecording(
|
||||
capturedSamples.at(-1)?.atMs ?? 0,
|
||||
)
|
||||
recording.value = {
|
||||
format: 'airi-live2d-motion/v1',
|
||||
format: 'airi-live2d-motion/v2',
|
||||
durationMs,
|
||||
samples: capturedSamples,
|
||||
}
|
||||
@@ -191,7 +211,13 @@ export function useLive2DMotionRecording(
|
||||
playbackSampleIndex < recording.value.samples.length
|
||||
&& recording.value.samples[playbackSampleIndex].atMs <= elapsedMs
|
||||
) {
|
||||
options.applyPose(recording.value.samples[playbackSampleIndex])
|
||||
const sample = recording.value.samples[playbackSampleIndex]
|
||||
options.applyPose({
|
||||
x: sample.x,
|
||||
y: sample.y,
|
||||
headZ: sample.headZ,
|
||||
bodyZ: sample.bodyZ,
|
||||
})
|
||||
playbackSampleIndex++
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
const { t } = useI18n()
|
||||
const motionControl = useLive2DMotionControl()
|
||||
const ownerId = crypto.randomUUID()
|
||||
const neutralPose: Live2DMotionControlPose = Object.freeze({ x: 0, y: 0 })
|
||||
const neutralPose: Live2DMotionControlPose = Object.freeze({ x: 0, y: 0, headZ: 0, bodyZ: 0 })
|
||||
const pose = shallowRef<Live2DMotionControlPose>(neutralPose)
|
||||
const active = shallowRef(false)
|
||||
const importError = shallowRef('')
|
||||
|
||||
@@ -57,10 +57,10 @@ devtools:
|
||||
title: Lag Visualizer
|
||||
live2d-motion:
|
||||
title: Live2D Motion Control
|
||||
description: Move the joystick to control the model's eyes, head, and body on the X and Y axes.
|
||||
description: Use the joystick and keyboard to control eye, head, and body movement on the X, Y, and Z axes.
|
||||
joystick-label: Live2D motion joystick
|
||||
instructions: Drag the joystick, or use the arrow keys or W, A, S, and D. Release it to restore normal motion.
|
||||
parameter-note: The joystick maps to eye ±1, head ±30°, and body ±10° parameters.
|
||||
instructions: Drag the joystick, or use the arrow keys for X/Y. W/S also controls Y. A/D tilts the body. Q/E tilts the head. Released keys return smoothly to center.
|
||||
parameter-note: The joystick controls X/Y. A/D maps to body Z ±10°. Q/E maps to head Z ±30°.
|
||||
groups:
|
||||
eyes: Eyes
|
||||
head: Head
|
||||
|
||||
@@ -53,10 +53,10 @@ devtools:
|
||||
title: 卡頓可視化
|
||||
live2d-motion:
|
||||
title: Live2D 動作控制
|
||||
description: 移動搖桿來控制模型眼睛、頭部與身體的 X 軸和 Y 軸動作。
|
||||
description: 使用搖桿和鍵盤控制模型眼睛、頭部與身體的 X、Y、Z 軸動作。
|
||||
joystick-label: Live2D 動作搖桿
|
||||
instructions: 拖曳搖桿,或使用方向鍵或 W、A、S、D 鍵。放開後會恢復一般動作。
|
||||
parameter-note: 搖桿會分別映射至眼睛 ±1、頭部 ±30° 與身體 ±10° 的參數。
|
||||
instructions: 拖曳搖桿,或使用方向鍵控制 X/Y 軸。W/S 也能控制 Y 軸。A/D 讓身體左右傾斜。Q/E 讓頭部左右傾斜。放開按鍵後會平滑回到中央。
|
||||
parameter-note: 搖桿控制 X/Y 軸。A/D 映射至身體 Z 軸 ±10°。Q/E 映射至頭部 Z 軸 ±30°。
|
||||
groups:
|
||||
eyes: 眼睛
|
||||
head: 頭部
|
||||
|
||||
@@ -187,15 +187,17 @@ describe('live2d motion manager plugins', () => {
|
||||
useMotionUpdatePluginManualControl(ref({
|
||||
active: true,
|
||||
ownerId: 'motion-devtool',
|
||||
pose: { x: 0.5, y: -0.25 },
|
||||
pose: { x: 0.5, y: -0.25, headZ: -0.75, bodyZ: 1 },
|
||||
}))(context)
|
||||
|
||||
expect(context.model.setParameterValueById).toHaveBeenCalledWith('ParamEyeBallX', 0.5)
|
||||
expect(context.model.setParameterValueById).toHaveBeenCalledWith('ParamEyeBallY', -0.25)
|
||||
expect(context.model.setParameterValueById).toHaveBeenCalledWith('ParamAngleX', 15)
|
||||
expect(context.model.setParameterValueById).toHaveBeenCalledWith('ParamAngleY', -7.5)
|
||||
expect(context.model.setParameterValueById).toHaveBeenCalledWith('ParamAngleZ', -22.5)
|
||||
expect(context.model.setParameterValueById).toHaveBeenCalledWith('ParamBodyAngleX', 5)
|
||||
expect(context.model.setParameterValueById).toHaveBeenCalledWith('ParamBodyAngleY', -2.5)
|
||||
expect(context.model.setParameterValueById).toHaveBeenCalledWith('ParamBodyAngleZ', 10)
|
||||
})
|
||||
|
||||
it('leaves motion parameters unchanged after manual control is released', () => {
|
||||
@@ -204,7 +206,7 @@ describe('live2d motion manager plugins', () => {
|
||||
useMotionUpdatePluginManualControl(ref({
|
||||
active: false,
|
||||
ownerId: null,
|
||||
pose: { x: 0, y: 0 },
|
||||
pose: { x: 0, y: 0, headZ: 0, bodyZ: 0 },
|
||||
}))(context)
|
||||
|
||||
expect(context.model.setParameterValueById).not.toHaveBeenCalled()
|
||||
|
||||
@@ -458,7 +458,7 @@ export function useMotionUpdatePluginExpression(
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the active manual two-axis pose after normal Live2D motion updates.
|
||||
* Applies the active manual pose after normal Live2D motion updates.
|
||||
*
|
||||
* The normalized joystick range maps to each standard parameter range. Models
|
||||
* that omit one of these parameters ignore that write through the Cubism API.
|
||||
@@ -470,13 +470,15 @@ export function useMotionUpdatePluginManualControl(
|
||||
if (!control.value.active)
|
||||
return
|
||||
|
||||
const { x, y } = control.value.pose
|
||||
const { x, y, headZ, bodyZ } = control.value.pose
|
||||
ctx.model.setParameterValueById('ParamEyeBallX', x)
|
||||
ctx.model.setParameterValueById('ParamEyeBallY', y)
|
||||
ctx.model.setParameterValueById('ParamAngleX', x * 30)
|
||||
ctx.model.setParameterValueById('ParamAngleY', y * 30)
|
||||
ctx.model.setParameterValueById('ParamAngleZ', headZ * 30)
|
||||
ctx.model.setParameterValueById('ParamBodyAngleX', x * 10)
|
||||
ctx.model.setParameterValueById('ParamBodyAngleY', y * 10)
|
||||
ctx.model.setParameterValueById('ParamBodyAngleZ', bodyZ * 10)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,16 @@ import { useBroadcastChannel } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { shallowRef, watch } from 'vue'
|
||||
|
||||
/** A normalized two-axis pose for manual Live2D motion control. */
|
||||
/** A normalized pose for manual Live2D motion control. */
|
||||
export interface Live2DMotionControlPose {
|
||||
/** Horizontal position from -1 (left) to 1 (right). */
|
||||
x: number
|
||||
/** Vertical position from -1 (down) to 1 (up). */
|
||||
y: number
|
||||
/** Head roll from -1 (left) to 1 (right). */
|
||||
headZ: number
|
||||
/** Body roll from -1 (left) to 1 (right). */
|
||||
bodyZ: number
|
||||
}
|
||||
|
||||
/** The active manual control owner and its current normalized pose. */
|
||||
@@ -28,7 +32,7 @@ type Live2DMotionControlEvent
|
||||
ownerId: string
|
||||
}
|
||||
|
||||
const neutralPose: Live2DMotionControlPose = Object.freeze({ x: 0, y: 0 })
|
||||
const neutralPose: Live2DMotionControlPose = Object.freeze({ x: 0, y: 0, headZ: 0, bodyZ: 0 })
|
||||
|
||||
function clampAxis(value: number): number {
|
||||
return Math.min(1, Math.max(-1, value))
|
||||
@@ -38,6 +42,8 @@ function normalizePose(pose: Live2DMotionControlPose): Live2DMotionControlPose {
|
||||
return {
|
||||
x: clampAxis(pose.x),
|
||||
y: clampAxis(pose.y),
|
||||
headZ: clampAxis(pose.headZ),
|
||||
bodyZ: clampAxis(pose.bodyZ),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user