diff --git a/src/DansoriEQ.App/Assets/Characters/Puppets/LeeSoriDance/rig.json b/src/DansoriEQ.App/Assets/Characters/Puppets/LeeSoriDance/rig.json index a105789..2b55e2a 100644 --- a/src/DansoriEQ.App/Assets/Characters/Puppets/LeeSoriDance/rig.json +++ b/src/DansoriEQ.App/Assets/Characters/Puppets/LeeSoriDance/rig.json @@ -1,6 +1,6 @@ { "name": "LeeSoriDance", - "status": "leesori_solo3_pose_sequence_no_overlap_2026_07_04", + "status": "leesori_solo3_motion_tween_2026_07_04", "renderMode": "poseSequence", "canvas": { "width": 1024, @@ -8,7 +8,7 @@ }, "imageBase": "./Images/", "poseBase": "./Gestures/dance/solo3/", - "note": "Solo Dance 3 inspired reusable pose-sequence gesture. Runtime shows exactly one normalized frame at a time to avoid residual image overlap; original parts are retained under partsRig for future rigging.", + "note": "Solo Dance 3 inspired reusable pose-sequence gesture. Runtime crossfades normalized frames with per-frame transform offsets so the motion reads as a continuous upper-body dance while still avoiding residual image overlap.", "gestures": [ { "id": "leesori.dance.solo3", @@ -21,7 +21,7 @@ "idle", "success" ], - "frameMs": 260, + "frameMs": 340, "loop": true, "tags": [ "upper-body", @@ -33,29 +33,222 @@ "frames": [ { "image": "frame_01_ready.png", - "label": "ready bounce" + "label": "ready bounce", + "motion": { + "target": { + "x": 0, + "y": 0, + "rotate": -0.6, + "scale": 1 + }, + "enter": { + "x": -5, + "y": 5, + "rotate": -0.8, + "scale": 0.992 + }, + "exit": { + "x": 4, + "y": -3, + "rotate": 0.8, + "scale": 1.006 + } + } + }, + { + "image": "frame_03_wrist_wave.png", + "label": "wrist wave prep", + "motion": { + "target": { + "x": -4, + "y": -2, + "rotate": 0.4, + "scale": 1.012 + }, + "enter": { + "x": -10, + "y": 4, + "rotate": -0.7, + "scale": 0.99 + }, + "exit": { + "x": 5, + "y": -3, + "rotate": 0.7, + "scale": 1.004 + } + } }, { "image": "frame_02_hand_forward.png", - "label": "hand forward" + "label": "hand forward", + "motion": { + "target": { + "x": -8, + "y": -5, + "rotate": 0.8, + "scale": 1.026 + }, + "enter": { + "x": -12, + "y": 5, + "rotate": -0.8, + "scale": 0.988 + }, + "exit": { + "x": 8, + "y": -4, + "rotate": 0.9, + "scale": 0.996 + } + } }, { "image": "frame_03_wrist_wave.png", - "label": "wrist wave" + "label": "wrist wave return", + "motion": { + "target": { + "x": 3, + "y": -2, + "rotate": -0.3, + "scale": 1.014 + }, + "enter": { + "x": 8, + "y": 4, + "rotate": 0.9, + "scale": 0.992 + }, + "exit": { + "x": -5, + "y": -4, + "rotate": -0.8, + "scale": 1.002 + } + } }, { "image": "frame_04_arms_up.png", - "label": "arms up" + "label": "arms up lift", + "motion": { + "target": { + "x": 0, + "y": -12, + "rotate": 0.2, + "scale": 1.022 + }, + "enter": { + "x": -5, + "y": 7, + "rotate": -0.7, + "scale": 0.99 + }, + "exit": { + "x": 5, + "y": -5, + "rotate": 0.6, + "scale": 1.004 + } + } }, { "image": "frame_05_hair_touch.png", - "label": "hair touch" + "label": "hair touch", + "motion": { + "target": { + "x": -5, + "y": -4, + "rotate": 1, + "scale": 1.018 + }, + "enter": { + "x": 6, + "y": 5, + "rotate": -0.6, + "scale": 0.992 + }, + "exit": { + "x": -6, + "y": -3, + "rotate": -0.8, + "scale": 1.002 + } + } + }, + { + "image": "frame_04_arms_up.png", + "label": "arms up release", + "motion": { + "target": { + "x": 4, + "y": -8, + "rotate": -0.4, + "scale": 1.018 + }, + "enter": { + "x": -7, + "y": 4, + "rotate": 0.8, + "scale": 0.992 + }, + "exit": { + "x": 6, + "y": -3, + "rotate": 0.7, + "scale": 1.004 + } + } }, { "image": "frame_03_wrist_wave.png", - "label": "return wave" + "label": "settle wave", + "motion": { + "target": { + "x": -2, + "y": -1, + "rotate": 0.2, + "scale": 1.01 + }, + "enter": { + "x": 7, + "y": 5, + "rotate": -0.7, + "scale": 0.992 + }, + "exit": { + "x": -4, + "y": -3, + "rotate": 0.7, + "scale": 1.002 + } + } + }, + { + "image": "frame_01_ready.png", + "label": "ready settle", + "motion": { + "target": { + "x": 1, + "y": 1, + "rotate": 0.3, + "scale": 1.004 + }, + "enter": { + "x": 6, + "y": 4, + "rotate": 0.6, + "scale": 0.994 + }, + "exit": { + "x": -5, + "y": -3, + "rotate": -0.6, + "scale": 1.002 + } + } } - ] + ], + "transitionMs": 190 } ], "partsRig": { diff --git a/src/DansoriEQ.App/Assets/Live2DHost/characterHost.js b/src/DansoriEQ.App/Assets/Live2DHost/characterHost.js index 30e7711..5dcab42 100644 --- a/src/DansoriEQ.App/Assets/Live2DHost/characterHost.js +++ b/src/DansoriEQ.App/Assets/Live2DHost/characterHost.js @@ -82,19 +82,85 @@ function selectGesture(rig, state) { || null; } +function motionTransform(frame, phase) { + const motion = frame?.motion || {}; + const target = motion.target || { x: 0, y: 0, rotate: 0, scale: 1 }; + const offset = phase === "target" + ? { x: 0, y: 0, rotate: 0, scale: 1 } + : (motion[phase] || { x: 0, y: 0, rotate: 0, scale: 1 }); + const x = (target.x || 0) + (offset.x || 0); + const y = (target.y || 0) + (offset.y || 0); + const rotate = (target.rotate || 0) + (offset.rotate || 0); + const scale = (target.scale || 1) * (offset.scale || 1); + return `translate(${x}px, ${y}px) rotate(${rotate}deg) scale(${scale})`; +} + +function frameForElement(element) { + const frames = loadedRig?.activeGesture?.frames || []; + return frames[Number(element.dataset.frameIndex)] || {}; +} + +function hideInactiveFrame(frame) { + frame.classList.remove("is-active", "is-entering", "is-exiting"); + frame.style.opacity = "0"; + frame.style.zIndex = "1"; +} + function setActiveGestureFrame(index) { const frames = [...puppet.querySelectorAll(".puppet-frame")]; if (!frames.length) return; + const activeIndex = ((index % frames.length) + frames.length) % frames.length; + const transitionMs = Math.max(80, Number(loadedRig?.activeGesture?.transitionMs || 170)); + const current = frames.find(frame => frame.classList.contains("is-active")); + const next = frames[activeIndex]; + if (!next || current === next) return; + for (const frame of frames) { - frame.classList.toggle("is-active", Number(frame.dataset.frameIndex) === activeIndex); + if (frame !== current && frame !== next) { + hideInactiveFrame(frame); + } } + + if (current) { + current.classList.remove("is-active", "is-entering"); + current.classList.add("is-exiting"); + current.style.transitionDuration = `${transitionMs}ms`; + current.style.zIndex = "2"; + current.style.opacity = "0"; + current.style.transform = motionTransform(frameForElement(current), "exit"); + window.setTimeout(() => { + if (current.classList.contains("is-exiting")) hideInactiveFrame(current); + }, transitionMs + 40); + } + + next.classList.remove("is-exiting"); + next.classList.add("is-entering"); + next.style.transitionDuration = `${transitionMs}ms`; + next.style.zIndex = "3"; + next.style.opacity = "0"; + next.style.transform = motionTransform(frameForElement(next), "enter"); + void next.offsetWidth; + next.classList.add("is-active"); + window.requestAnimationFrame(() => { + next.style.opacity = "1"; + next.style.transform = motionTransform(frameForElement(next), "target"); + }); } function startPoseSequence(gesture) { stopGestureTimer(); - setActiveGestureFrame(0); - const frameMs = Math.max(80, Number(gesture.frameMs || 260)); + loadedRig.activeGesture = gesture; + const first = puppet.querySelector('.puppet-frame[data-frame-index="0"]'); + if (first) { + first.style.transitionDuration = "0ms"; + first.style.opacity = "1"; + first.style.zIndex = "3"; + first.style.transform = motionTransform(frameForElement(first), "target"); + first.classList.add("is-active"); + } + + const frameMs = Math.max(120, Number(gesture.frameMs || 340)); if (gesture.loop !== false && (gesture.frames?.length || 0) > 1) { gestureTimer = window.setInterval(() => { gestureFrame += 1; @@ -123,6 +189,7 @@ function renderPoseSequence(rig, active, gesture) { img.src = `${poseBase}${frame.image}?v=${assetVersion}`; img.alt = ""; img.decoding = "async"; + img.style.transform = motionTransform(frame, "enter"); fragment.appendChild(img); } puppet.replaceChildren(fragment); @@ -256,3 +323,4 @@ window.addEventListener("DOMContentLoaded", async () => { setState("idle"); post("hostReady"); }); + diff --git a/src/DansoriEQ.App/Assets/Live2DHost/style.css b/src/DansoriEQ.App/Assets/Live2DHost/style.css index c64e169..5ca6ba8 100644 --- a/src/DansoriEQ.App/Assets/Live2DHost/style.css +++ b/src/DansoriEQ.App/Assets/Live2DHost/style.css @@ -76,13 +76,20 @@ body { .puppet-frame { opacity: 0; z-index: 1; - transform-origin: 50% 38%; + transform-origin: 50% 40%; + transition-property: opacity, transform; + transition-duration: 180ms; + transition-timing-function: cubic-bezier(.22,.84,.32,1); } .puppet-frame.is-active { opacity: 1; } +.puppet-frame.is-exiting { + opacity: 0; +} + #puppet[data-mode="pose-sequence"] .puppet-part { display: none; } @@ -456,3 +463,4 @@ body { + diff --git a/src/DansoriEQ.App/bin/Debug/net8.0-windows/Assets/Characters/Puppets/LeeSoriDance/rig.json b/src/DansoriEQ.App/bin/Debug/net8.0-windows/Assets/Characters/Puppets/LeeSoriDance/rig.json index a105789..2b55e2a 100644 --- a/src/DansoriEQ.App/bin/Debug/net8.0-windows/Assets/Characters/Puppets/LeeSoriDance/rig.json +++ b/src/DansoriEQ.App/bin/Debug/net8.0-windows/Assets/Characters/Puppets/LeeSoriDance/rig.json @@ -1,6 +1,6 @@ { "name": "LeeSoriDance", - "status": "leesori_solo3_pose_sequence_no_overlap_2026_07_04", + "status": "leesori_solo3_motion_tween_2026_07_04", "renderMode": "poseSequence", "canvas": { "width": 1024, @@ -8,7 +8,7 @@ }, "imageBase": "./Images/", "poseBase": "./Gestures/dance/solo3/", - "note": "Solo Dance 3 inspired reusable pose-sequence gesture. Runtime shows exactly one normalized frame at a time to avoid residual image overlap; original parts are retained under partsRig for future rigging.", + "note": "Solo Dance 3 inspired reusable pose-sequence gesture. Runtime crossfades normalized frames with per-frame transform offsets so the motion reads as a continuous upper-body dance while still avoiding residual image overlap.", "gestures": [ { "id": "leesori.dance.solo3", @@ -21,7 +21,7 @@ "idle", "success" ], - "frameMs": 260, + "frameMs": 340, "loop": true, "tags": [ "upper-body", @@ -33,29 +33,222 @@ "frames": [ { "image": "frame_01_ready.png", - "label": "ready bounce" + "label": "ready bounce", + "motion": { + "target": { + "x": 0, + "y": 0, + "rotate": -0.6, + "scale": 1 + }, + "enter": { + "x": -5, + "y": 5, + "rotate": -0.8, + "scale": 0.992 + }, + "exit": { + "x": 4, + "y": -3, + "rotate": 0.8, + "scale": 1.006 + } + } + }, + { + "image": "frame_03_wrist_wave.png", + "label": "wrist wave prep", + "motion": { + "target": { + "x": -4, + "y": -2, + "rotate": 0.4, + "scale": 1.012 + }, + "enter": { + "x": -10, + "y": 4, + "rotate": -0.7, + "scale": 0.99 + }, + "exit": { + "x": 5, + "y": -3, + "rotate": 0.7, + "scale": 1.004 + } + } }, { "image": "frame_02_hand_forward.png", - "label": "hand forward" + "label": "hand forward", + "motion": { + "target": { + "x": -8, + "y": -5, + "rotate": 0.8, + "scale": 1.026 + }, + "enter": { + "x": -12, + "y": 5, + "rotate": -0.8, + "scale": 0.988 + }, + "exit": { + "x": 8, + "y": -4, + "rotate": 0.9, + "scale": 0.996 + } + } }, { "image": "frame_03_wrist_wave.png", - "label": "wrist wave" + "label": "wrist wave return", + "motion": { + "target": { + "x": 3, + "y": -2, + "rotate": -0.3, + "scale": 1.014 + }, + "enter": { + "x": 8, + "y": 4, + "rotate": 0.9, + "scale": 0.992 + }, + "exit": { + "x": -5, + "y": -4, + "rotate": -0.8, + "scale": 1.002 + } + } }, { "image": "frame_04_arms_up.png", - "label": "arms up" + "label": "arms up lift", + "motion": { + "target": { + "x": 0, + "y": -12, + "rotate": 0.2, + "scale": 1.022 + }, + "enter": { + "x": -5, + "y": 7, + "rotate": -0.7, + "scale": 0.99 + }, + "exit": { + "x": 5, + "y": -5, + "rotate": 0.6, + "scale": 1.004 + } + } }, { "image": "frame_05_hair_touch.png", - "label": "hair touch" + "label": "hair touch", + "motion": { + "target": { + "x": -5, + "y": -4, + "rotate": 1, + "scale": 1.018 + }, + "enter": { + "x": 6, + "y": 5, + "rotate": -0.6, + "scale": 0.992 + }, + "exit": { + "x": -6, + "y": -3, + "rotate": -0.8, + "scale": 1.002 + } + } + }, + { + "image": "frame_04_arms_up.png", + "label": "arms up release", + "motion": { + "target": { + "x": 4, + "y": -8, + "rotate": -0.4, + "scale": 1.018 + }, + "enter": { + "x": -7, + "y": 4, + "rotate": 0.8, + "scale": 0.992 + }, + "exit": { + "x": 6, + "y": -3, + "rotate": 0.7, + "scale": 1.004 + } + } }, { "image": "frame_03_wrist_wave.png", - "label": "return wave" + "label": "settle wave", + "motion": { + "target": { + "x": -2, + "y": -1, + "rotate": 0.2, + "scale": 1.01 + }, + "enter": { + "x": 7, + "y": 5, + "rotate": -0.7, + "scale": 0.992 + }, + "exit": { + "x": -4, + "y": -3, + "rotate": 0.7, + "scale": 1.002 + } + } + }, + { + "image": "frame_01_ready.png", + "label": "ready settle", + "motion": { + "target": { + "x": 1, + "y": 1, + "rotate": 0.3, + "scale": 1.004 + }, + "enter": { + "x": 6, + "y": 4, + "rotate": 0.6, + "scale": 0.994 + }, + "exit": { + "x": -5, + "y": -3, + "rotate": -0.6, + "scale": 1.002 + } + } } - ] + ], + "transitionMs": 190 } ], "partsRig": { diff --git a/src/DansoriEQ.App/bin/Debug/net8.0-windows/Assets/Live2DHost/characterHost.js b/src/DansoriEQ.App/bin/Debug/net8.0-windows/Assets/Live2DHost/characterHost.js index 30e7711..5dcab42 100644 --- a/src/DansoriEQ.App/bin/Debug/net8.0-windows/Assets/Live2DHost/characterHost.js +++ b/src/DansoriEQ.App/bin/Debug/net8.0-windows/Assets/Live2DHost/characterHost.js @@ -82,19 +82,85 @@ function selectGesture(rig, state) { || null; } +function motionTransform(frame, phase) { + const motion = frame?.motion || {}; + const target = motion.target || { x: 0, y: 0, rotate: 0, scale: 1 }; + const offset = phase === "target" + ? { x: 0, y: 0, rotate: 0, scale: 1 } + : (motion[phase] || { x: 0, y: 0, rotate: 0, scale: 1 }); + const x = (target.x || 0) + (offset.x || 0); + const y = (target.y || 0) + (offset.y || 0); + const rotate = (target.rotate || 0) + (offset.rotate || 0); + const scale = (target.scale || 1) * (offset.scale || 1); + return `translate(${x}px, ${y}px) rotate(${rotate}deg) scale(${scale})`; +} + +function frameForElement(element) { + const frames = loadedRig?.activeGesture?.frames || []; + return frames[Number(element.dataset.frameIndex)] || {}; +} + +function hideInactiveFrame(frame) { + frame.classList.remove("is-active", "is-entering", "is-exiting"); + frame.style.opacity = "0"; + frame.style.zIndex = "1"; +} + function setActiveGestureFrame(index) { const frames = [...puppet.querySelectorAll(".puppet-frame")]; if (!frames.length) return; + const activeIndex = ((index % frames.length) + frames.length) % frames.length; + const transitionMs = Math.max(80, Number(loadedRig?.activeGesture?.transitionMs || 170)); + const current = frames.find(frame => frame.classList.contains("is-active")); + const next = frames[activeIndex]; + if (!next || current === next) return; + for (const frame of frames) { - frame.classList.toggle("is-active", Number(frame.dataset.frameIndex) === activeIndex); + if (frame !== current && frame !== next) { + hideInactiveFrame(frame); + } } + + if (current) { + current.classList.remove("is-active", "is-entering"); + current.classList.add("is-exiting"); + current.style.transitionDuration = `${transitionMs}ms`; + current.style.zIndex = "2"; + current.style.opacity = "0"; + current.style.transform = motionTransform(frameForElement(current), "exit"); + window.setTimeout(() => { + if (current.classList.contains("is-exiting")) hideInactiveFrame(current); + }, transitionMs + 40); + } + + next.classList.remove("is-exiting"); + next.classList.add("is-entering"); + next.style.transitionDuration = `${transitionMs}ms`; + next.style.zIndex = "3"; + next.style.opacity = "0"; + next.style.transform = motionTransform(frameForElement(next), "enter"); + void next.offsetWidth; + next.classList.add("is-active"); + window.requestAnimationFrame(() => { + next.style.opacity = "1"; + next.style.transform = motionTransform(frameForElement(next), "target"); + }); } function startPoseSequence(gesture) { stopGestureTimer(); - setActiveGestureFrame(0); - const frameMs = Math.max(80, Number(gesture.frameMs || 260)); + loadedRig.activeGesture = gesture; + const first = puppet.querySelector('.puppet-frame[data-frame-index="0"]'); + if (first) { + first.style.transitionDuration = "0ms"; + first.style.opacity = "1"; + first.style.zIndex = "3"; + first.style.transform = motionTransform(frameForElement(first), "target"); + first.classList.add("is-active"); + } + + const frameMs = Math.max(120, Number(gesture.frameMs || 340)); if (gesture.loop !== false && (gesture.frames?.length || 0) > 1) { gestureTimer = window.setInterval(() => { gestureFrame += 1; @@ -123,6 +189,7 @@ function renderPoseSequence(rig, active, gesture) { img.src = `${poseBase}${frame.image}?v=${assetVersion}`; img.alt = ""; img.decoding = "async"; + img.style.transform = motionTransform(frame, "enter"); fragment.appendChild(img); } puppet.replaceChildren(fragment); @@ -256,3 +323,4 @@ window.addEventListener("DOMContentLoaded", async () => { setState("idle"); post("hostReady"); }); + diff --git a/src/DansoriEQ.App/bin/Debug/net8.0-windows/Assets/Live2DHost/style.css b/src/DansoriEQ.App/bin/Debug/net8.0-windows/Assets/Live2DHost/style.css index c64e169..5ca6ba8 100644 --- a/src/DansoriEQ.App/bin/Debug/net8.0-windows/Assets/Live2DHost/style.css +++ b/src/DansoriEQ.App/bin/Debug/net8.0-windows/Assets/Live2DHost/style.css @@ -76,13 +76,20 @@ body { .puppet-frame { opacity: 0; z-index: 1; - transform-origin: 50% 38%; + transform-origin: 50% 40%; + transition-property: opacity, transform; + transition-duration: 180ms; + transition-timing-function: cubic-bezier(.22,.84,.32,1); } .puppet-frame.is-active { opacity: 1; } +.puppet-frame.is-exiting { + opacity: 0; +} + #puppet[data-mode="pose-sequence"] .puppet-part { display: none; } @@ -456,3 +463,4 @@ body { +