diff --git a/src/components/Emotions.jsx b/src/components/Emotions.jsx new file mode 100644 index 00000000..668f202c --- /dev/null +++ b/src/components/Emotions.jsx @@ -0,0 +1,77 @@ +import React, { useEffect } from "react" +import styles from "./Emotions.module.css" +import MenuTitle from "./MenuTitle" +import { SceneContext } from "../context/SceneContext"; +import Slider from "./Slider"; + +// import 'react-dropdown/style.css'; + +export default function Emotions(){ + + const { characterManager,moveCamera } = React.useContext(SceneContext) + + const [isConstant, setConstant] = React.useState(false) + const [intensity, setIntensity] = React.useState(1) + + const availableEmotions = characterManager.emotionManager.availableEmotions + + useEffect(() => { + moveCamera({ targetY:1.8, distance:2}) + }, []) + + const playEmotion = (emotion)=>{ + characterManager.emotionManager.playEmotion(emotion,undefined,isConstant,intensity) + } + + return ( + +
+
+ +
+
+ View different emotions +
+ +
+
+
+ + +
+
Constant Emotion
+ + +
+
+
+ Intensity: {parseFloat(intensity.toFixed(2))} +
+ + setIntensity(parseFloat(e.currentTarget.value.toString()))} min={0} max={1} step={0.01}/> +
+ + {availableEmotions.map((emotion, index) => { + return ( +
{ + playEmotion(emotion) + }}> +
{emotion}
+
+ ) + })} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/Emotions.module.css b/src/components/Emotions.module.css new file mode 100644 index 00000000..d75dcd17 --- /dev/null +++ b/src/components/Emotions.module.css @@ -0,0 +1,110 @@ + +.InformationContainerPos { + position: fixed; + right: 132px; + top: 98px; + width:250px; + height: -webkit-calc(100vh - 176px); + height: calc(100vh - 176px); + backdrop-filter: blur(22.5px); + background: rgba(5, 11, 14, 0.8); + z-index: 1000; + user-select: none; + } + + .scrollContainer { + height: 100%; + width: 80%; + overflow-y: scroll; + position: relative; + overflow-x: hidden !important; + margin: 30px; + height: -webkit-calc(100% - 40px); + height: calc(100% - 40px); + } + .centerAlign{ + text-align: center; + } + .traitInfoTitle { + text-align: center; + color: white; + text-transform: uppercase; + text-shadow: 1px 1px 2px black; + font-size: 14px; + word-spacing: 2px; + margin-bottom: 10px; + } + + +.traitInfoText { + color: rgb(179, 179, 179); + /* text-transform: uppercase; */ + text-shadow: 1px 1px 2px black; + font-size: 14px; + word-spacing: 2px; + margin-bottom: 6px; + display: flex; + justify-content: left; + } + +/* Hide the default checkbox */ +.custom-checkbox input[type="checkbox"] { + display: none; + } + + /* Style the custom checkbox */ + .custom-checkbox .checkbox-container { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid #284b39; /* Change border color as needed */ + border-radius: 5px; + cursor: pointer; + } + + .custom-checkbox .checkbox-container.checked { + background-color: #5eb086; /* Change background color when checked */ + } + + .custom-checkbox .checkbox-container .checkmark { + display: none; + } + + /* Style the checkmark when the checkbox is checked */ + .custom-checkbox input[type="checkbox"]:checked + .checkbox-container { + background-color: #5eb086; /* Change background color when checked */ + } + + .custom-checkbox input[type="checkbox"]:checked + .checkbox-container .checkmark { + display: block; + } + +.checkboxHolder { + display: flex; + gap: 5px; + align-items: center; + justify-content: left; + height: 40px; + } + + + + .actionButton{ + margin: 10px auto; + text-align: center; + outline-color: #3b434f; + color: #d1d7df; + outline-width: 2px; + outline-style: solid; + background-color: #1e2530; + height: 30px; + width: 80%; + font-family: "TTSC-Bold"; + text-transform: uppercase !important; + font-size:x-small; + display: flex; + justify-content: center; + align-items : center; + user-select: none; + cursor: pointer; + } \ No newline at end of file diff --git a/src/components/RightPanel.jsx b/src/components/RightPanel.jsx index ddba3d74..8c2fd19f 100644 --- a/src/components/RightPanel.jsx +++ b/src/components/RightPanel.jsx @@ -1,8 +1,9 @@ -import React, { useContext, useState, useEffect } from "react" +import React from "react" import styles from "./RightPanel.module.css" import MenuTitle from "./MenuTitle" import traitsIcon from "../images/t-shirt.png" import genSpriteIcon from "../images/users.png" +import emotionIcon from "../images/emotion.png" import genLoraIcon from "../images/paste.png" import genThumbIcon from "../images/portraits.png" import { TokenBox } from "../components/token-box/TokenBox" @@ -10,6 +11,7 @@ import TraitInformation from "../components/TraitInformation" import LoraCreation from "./LoraCreation" import SpriteCreation from "./SpriteCreation" import ThumbnailCreation from "./ThumbnailCreation" +import Emotions from "./Emotions" export default function RightPanel({selectedTrait, selectedVRM, traitGroupName}){ const [selectedOption, setSelectedOption] = React.useState("") @@ -27,6 +29,7 @@ export default function RightPanel({selectedTrait, selectedVRM, traitGroupName}) {selectedOption=="LoraCreation" && } {selectedOption=="SpriteCreation" && } {selectedOption=="ThumbnailCreation" && } + {selectedOption=="EmotionManager" && }
@@ -71,6 +74,16 @@ export default function RightPanel({selectedTrait, selectedVRM, traitGroupName}) rarity={selectedOption == "ThumbnailCreation" ? "mythic" : "none"} />
+
{setSelectedOptionString("EmotionManager")}} + > + +
diff --git a/src/components/RightPanel.module.css b/src/components/RightPanel.module.css index 9b6b7966..9cf115b0 100644 --- a/src/components/RightPanel.module.css +++ b/src/components/RightPanel.module.css @@ -4,7 +4,7 @@ right: 32px; top: 98px; width:90px; - height: 270px; + height: auto; backdrop-filter: blur(22.5px); background: rgba(5, 11, 14, 0.8); z-index: 1000; @@ -19,7 +19,7 @@ position: relative; overflow-x: hidden !important; margin: 16px; - height: 270px; + height: 300px; } .options-container { user-select: none; diff --git a/src/context/SceneContext.jsx b/src/context/SceneContext.jsx index 30fe7429..d22f7682 100644 --- a/src/context/SceneContext.jsx +++ b/src/context/SceneContext.jsx @@ -1,16 +1,30 @@ import React, { createContext, useEffect, useState } from "react" import gsap from "gsap" -import { local } from "../library/store" import { sceneInitializer } from "../library/sceneInitializer" import { LoraDataGenerator } from "../library/loraDataGenerator" import { SpriteAtlasGenerator } from "../library/spriteAtlasGenerator" import { ThumbnailGenerator } from "../library/thumbnailsGenerator" -export const SceneContext = createContext() +export const SceneContext = createContext({ + /** + * @typedef {import('../library/characterManager').CharacterManager} CharacterManager + * @type {CharacterManager} + */ + characterManager: null, + /** + * @typedef {Object} MoveCameraParam + * @property {number} targetX + * @property {number} targetY + * @property {number} targetZ + * @property {number} distance + * @param {MoveCameraParam} _value + */ + // eslint-disable-next-line no-unused-vars + moveCamera: (_value) => {}, +}) export const SceneProvider = (props) => { - const [characterManager, setCharacterManager] = useState(null) const [loraDataGenerator, setLoraDataGenerator] = useState(null) const [spriteAtlasGenerator, setSpriteAtlasGenerator] = useState(null) diff --git a/src/images/emotion.png b/src/images/emotion.png new file mode 100644 index 00000000..fabe7113 Binary files /dev/null and b/src/images/emotion.png differ diff --git a/src/library/EmotionManager.js b/src/library/EmotionManager.js new file mode 100644 index 00000000..ca5afb33 --- /dev/null +++ b/src/library/EmotionManager.js @@ -0,0 +1,267 @@ +import { VRMExpressionPresetName } from "@pixiv/three-vrm"; +import { Clock } from "three"; + +/** + * @typedef {import('@pixiv/three-vrm').VRMExpressionPresetName} VRMExpressionPresetName + * @typedef {import('@pixiv/three-vrm').VRM} VRM + * + */ + +export class EmotionManager { + /** + * @type {VRM[]} + */ + vrmEmotion + /** + * @type {'ready'|'animating'|'stopping'|'transition'} + */ + mode + /** + * @type {Clock} + */ + clock + continuous = false; + /** + * @type {VRMExpressionPresetName|null} + */ + emotionPlaying =null; + /** + * @type {number} + */ + emotionValue = 0; + /** + * @type {number} + */ + intensity = 1; + /** + * Time for the emotion to go from 0 to 1 (divide by two if you want fast in and out) + * @type {number} + */ + emotionTime = 0.1; + /** + * @type {boolean} + */ + isTakingScreenShot = false; + + /** + * For transitioning to the next emotion from the current one; + * @type {VRMExpressionPresetName|null} + */ + _nextEmotion = null; + /** + * @type {number} + */ + _nextEmotionTime = 0; + /** + * @type {number} + */ + _nextEmotionValue = 0; + /** + * @type {number} + */ + _nextIntensity = 1; + _nextIsContinuous = false; + + constructor( ) { + this.vrmEmotion = []; + this.mode = 'ready'; + + this.clock = new Clock(); + + this.isTakingScreenShot = false; + + this.update() + } + + get availableEmotions(){ + const keys = Object.keys(VRMExpressionPresetName).map((t)=>t.toLowerCase()) + const available= [] + for(const vrm of this.vrmEmotion){ + for(const key of keys){ + if(key==='blink') continue + if(available.includes(key)) continue + const express =vrm.expressionManager?.getExpression(key) + + if(express && express._binds.length > 0){ + available.push(key) + } + } + } + return available + } + + /** + * @param {VRM} vrm + */ + addVRM(vrm){ + if(!vrm.expressionManager) return + this.vrmEmotion.push(vrm) + } + /** + * + * @param {'aa'|'ee'|'ii'|'oo'|'uu'|'blink'|'joy'|'angry'|'sorrow'|'fun'|'lookUp'|'lookDown'|'lookLeft'|'lookRight'} emotion + */ + hasEmotion(emotion){ + return this.availableEmotions.some(emo => emo === emotion) + } + + /** + * @param {VRM} vrm + */ + removeVRM(vrm) { + const index = this.vrmEmotion.indexOf(vrm); + + if (index !== -1) { + this.vrmEmotion.splice(index, 1); + } + } + + enableScreenshot() { + this.isTakingScreenShot = true; + this.emotionPlaying = null; + this._updateEmotions(); + } + + disableScreenshot() { + this.isTakingScreenShot = false; + } + + _isBlink(emotion){ + return emotion === 'blink' + } + /** + * + * @param {'aa'|'ee'|'ii'|'oo'|'uu'|'blink'|'joy'|'angry'|'sorrow'|'fun'|'lookUp'|'lookDown'|'lookLeft'|'lookRight'} emotion + * @param {number} [time] + * @param {boolean} [continuous] + * @param {number} [intensity] + */ + playEmotion(emotion, time=undefined, continuous=false, intensity = 1){ + if (!this.hasEmotion(emotion)) { + console.warn(`Emotion ${emotion} not available`) + return + } + if(this._isBlink(emotion)){ + console.warn(`Blink is handled by the BlinkManager, ignoring`) + return + } + if(emotion === this.emotionPlaying){ + if(intensity === this.intensity){ + return + } + } + const intensity_ = Math.min(1,Math.max(0,intensity)) + if(this.mode === 'animating' && this.emotionPlaying){ + this.continuous = false; + // transition to the next emotion + this._nextEmotion = emotion + this._nextEmotionTime = time || this.emotionTime + this._nextEmotionValue = 0 + this._nextIntensity = intensity_ + this._nextIsContinuous = continuous || false + this.mode = 'transition' + return + } + + this.emotionPlaying = emotion + this.intensity = intensity_ + if(time){ + this.emotionTime = time + } + this.continuous = continuous || false + this.mode = 'animating' + } + + _setIsReady(){ + this.emotionValue = 0 + this.intensity = 1 + this.emotionPlaying = null + this.continuous = false + this.mode = 'ready' + } + + _removeNextEmotion(){ + this._nextEmotion = null + this._nextIntensity = 1 + this._nextEmotionValue = 0 + this._nextEmotionTime = 0 + this._nextIsContinuous = false + } + + update(){ + setInterval(() => { + if (this.isTakingScreenShot) { + return; + } + const deltaTime = this.clock.getDelta() + switch (this.mode){ + + case 'animating': + if ( this.emotionPlaying){ + if(this.emotionValue < this.intensity){ + this.emotionValue += deltaTime / this.emotionTime; + this.emotionValue = Math.min(1,this.emotionValue) + } + + if(!this.continuous && this.emotionValue >= this.intensity){ + this.mode = 'stopping' + } + + }else{ + this._setIsReady() + } + this._updateEmotions(); + break; + case 'stopping': + if ( this.emotionPlaying){ + if(this.emotionValue>0){ + this.emotionValue -= deltaTime / this.emotionTime; + this.emotionValue = Math.max(0,this.emotionValue) + } + if(this.emotionValue <= 0){ + this._setIsReady() + } + }else{ + this._setIsReady() + } + this._updateEmotions(); + break; + case 'transition': + if(this._nextEmotion){ + if(this._nextEmotionValue < this._nextIntensity){ + this._nextEmotionValue += deltaTime / this._nextEmotionTime; + this.emotionValue = Math.min(this.intensity,this.emotionValue) + } + if(this.emotionValue > 0){ + this.emotionValue -=deltaTime / this._nextEmotionTime + }else{ + this.emotionValue = this._nextEmotionValue + this.emotionTime = this._nextEmotionTime + this.emotionPlaying = this._nextEmotion + this.intensity = this._nextIntensity + this.continuous = this._nextIsContinuous + this.mode = 'animating' + this._removeNextEmotion() + } + + }else{ + if(this.emotionPlaying){ + this.mode = 'animating' + } + } + this._updateEmotions(); + } + }, 1000/30); + } + + _updateEmotions(){ + if(!this.emotionPlaying) return + this.vrmEmotion.forEach(vrm => { + if(this._nextEmotion){ + vrm.expressionManager?.setValue(this._nextEmotion, this._nextEmotionValue) + } + vrm.expressionManager?.setValue(this.emotionPlaying, this.emotionValue) + vrm.expressionManager?.update() + }); + } +} diff --git a/src/library/characterManager.js b/src/library/characterManager.js index d2e9ad12..a48a4cc7 100644 --- a/src/library/characterManager.js +++ b/src/library/characterManager.js @@ -3,7 +3,7 @@ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader" import { AnimationManager } from "./animationManager" import { ScreenshotManager } from "./screenshotManager"; import { BlinkManager } from "./blinkManager"; - +import { EmotionManager } from "./EmotionManager"; import { VRMLoaderPlugin, VRMSpringBoneCollider } from "@pixiv/three-vrm"; import { getAsArray, disposeVRM, renameVRMBones, addModelData } from "./utils"; import { downloadGLB, downloadVRMWithAvatar } from "../library/download-utils" @@ -13,12 +13,27 @@ import { LipSync } from "./lipsync"; import { LookAtManager } from "./lookatManager"; import OverlayedTextureManager from "./OverlayTextureManager"; import { CharacterManifestData } from "./CharacterManifestData"; - const mouse = new THREE.Vector2(); const raycaster = new THREE.Raycaster(); const localVector3 = new THREE.Vector3(); export class CharacterManager { + /** + * @type {EmotionManager} + */ + emotionManager = null; + /** + * @type {AnimationManager} + */ + animationManager = null; + /** + * @type {BlinkManager} + */ + blinkManager + /** + * @type {ScreenshotManager} + */ + screenshotManager constructor(options){ this._start(options); } @@ -48,7 +63,7 @@ export class CharacterManager { this.screenshotManager = new ScreenshotManager(this, parentModel || this.rootModel); this.overlayedTextureManager = new OverlayedTextureManager(this) this.blinkManager = new BlinkManager(0.1, 0.1, 0.5, 5) - + this.emotionManager = new EmotionManager(); this.rootModel.add(this.characterModel) this.renderCamera = renderCamera; @@ -1423,6 +1438,7 @@ export class CharacterManager { _applyManagers(vrm){ this.blinkManager.addVRM(vrm) + this.emotionManager.addVRM(vrm) if (this.lookAtManager) this.lookAtManager.addVRM(vrm); @@ -1474,7 +1490,8 @@ export class CharacterManager { _disposeTrait(vrm){ this.blinkManager.removeVRM(vrm) - + this.emotionManager.removeVRM(vrm) + if (this.lookAtManager) this.lookAtManager.removeVRM(vrm);