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);