feat(stage-tamagotchi): smooth Live2D tilt keys

This commit is contained in:
Rin
2026-08-26 18:26:48 +08:00
parent 0feea66aa8
commit 912d977bd6
10 changed files with 314 additions and 68 deletions
@@ -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()
})
})
@@ -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 ±1 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° 與身體 ±1 的參數
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),
}
}