From 4d539b56c9d655a86c1f32af4a114a3c7d6ac304 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 31 May 2026 03:17:39 +0900 Subject: [PATCH] =?UTF-8?q?Fix=20:=20=EB=A6=AC=ED=83=80=EA=B2=9F=ED=8C=85?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Resources/Settings/PropDatabase.asset | 4 +- .../Prop/무선핸드마이크/Thumbnail.meta | 8 + .../Thumbnail/핸드마이크_thumbnail.png | 3 + .../Thumbnail/핸드마이크_thumbnail.png.meta | 117 ++ .../Prop/바람개비/Thumbnail.meta | 8 + .../바람개비/Thumbnail/바람개비_thumbnail.png | 3 + .../Thumbnail/바람개비_thumbnail.png.meta | 117 ++ .../Prop/핸드마이크 스텐드/Thumbnail.meta | 8 + .../Thumbnail/핸드마이크 스텐드_thumbnail.png | 3 + .../Thumbnail/핸드마이크 스텐드_thumbnail.png.meta | 117 ++ .../CustomRetargetingScript.cs | 193 +-- .../Editor/CustomRetargetingScriptEditor.cs | 86 +- .../Editor/RetargetingControlWindow.cs | 146 ++- .../KindRetargeting/FingerShapedController.cs | 56 +- .../KindRetargeting/LimbWeightController.cs | 146 ++- Assets/Scripts/KindRetargeting/README.md | 4 +- .../Remote/RetargetingRemoteController.cs | 103 +- .../KindRetargeting/SimplePoseTransfer.cs | 17 +- .../KindRetargeting/TwoBoneIKSolver.cs | 128 +- .../Scripts/WefLab/WefLabWebSocketClient.cs | 1136 +++++++++++++---- 20 files changed, 1900 insertions(+), 503 deletions(-) create mode 100644 Assets/ResourcesData/Prop/무선핸드마이크/Thumbnail.meta create mode 100644 Assets/ResourcesData/Prop/무선핸드마이크/Thumbnail/핸드마이크_thumbnail.png create mode 100644 Assets/ResourcesData/Prop/무선핸드마이크/Thumbnail/핸드마이크_thumbnail.png.meta create mode 100644 Assets/ResourcesData/Prop/바람개비/Thumbnail.meta create mode 100644 Assets/ResourcesData/Prop/바람개비/Thumbnail/바람개비_thumbnail.png create mode 100644 Assets/ResourcesData/Prop/바람개비/Thumbnail/바람개비_thumbnail.png.meta create mode 100644 Assets/ResourcesData/Prop/핸드마이크 스텐드/Thumbnail.meta create mode 100644 Assets/ResourcesData/Prop/핸드마이크 스텐드/Thumbnail/핸드마이크 스텐드_thumbnail.png create mode 100644 Assets/ResourcesData/Prop/핸드마이크 스텐드/Thumbnail/핸드마이크 스텐드_thumbnail.png.meta diff --git a/Assets/Resources/Settings/PropDatabase.asset b/Assets/Resources/Settings/PropDatabase.asset index 7797d682f..a5bbe32ca 100644 --- a/Assets/Resources/Settings/PropDatabase.asset +++ b/Assets/Resources/Settings/PropDatabase.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b02f91a3e96820db7647405ea6ef155e58a15441b5a5c6bae5ea7e503cbb1550 -size 18615 +oid sha256:15748b4f5b67f0b5e2388d9c25befa6f4026d85ea2f0a35898ebd94d0541b833 +size 19698 diff --git a/Assets/ResourcesData/Prop/무선핸드마이크/Thumbnail.meta b/Assets/ResourcesData/Prop/무선핸드마이크/Thumbnail.meta new file mode 100644 index 000000000..ef498d191 --- /dev/null +++ b/Assets/ResourcesData/Prop/무선핸드마이크/Thumbnail.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 32e5e53ddf8eec04997e01474d080f0b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ResourcesData/Prop/무선핸드마이크/Thumbnail/핸드마이크_thumbnail.png b/Assets/ResourcesData/Prop/무선핸드마이크/Thumbnail/핸드마이크_thumbnail.png new file mode 100644 index 000000000..7bba1c0b9 --- /dev/null +++ b/Assets/ResourcesData/Prop/무선핸드마이크/Thumbnail/핸드마이크_thumbnail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b0e17ef17821f2952cdbb852c12b5a05a9a58a2788e892a5b64542941213505 +size 49678 diff --git a/Assets/ResourcesData/Prop/무선핸드마이크/Thumbnail/핸드마이크_thumbnail.png.meta b/Assets/ResourcesData/Prop/무선핸드마이크/Thumbnail/핸드마이크_thumbnail.png.meta new file mode 100644 index 000000000..ec8f90bec --- /dev/null +++ b/Assets/ResourcesData/Prop/무선핸드마이크/Thumbnail/핸드마이크_thumbnail.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 2c3e74ea3c1c06d4f8e65793a53014cb +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ResourcesData/Prop/바람개비/Thumbnail.meta b/Assets/ResourcesData/Prop/바람개비/Thumbnail.meta new file mode 100644 index 000000000..15e0b261a --- /dev/null +++ b/Assets/ResourcesData/Prop/바람개비/Thumbnail.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ee74e2afa9c7ebc4aa8d7262101231e0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ResourcesData/Prop/바람개비/Thumbnail/바람개비_thumbnail.png b/Assets/ResourcesData/Prop/바람개비/Thumbnail/바람개비_thumbnail.png new file mode 100644 index 000000000..1acad7a02 --- /dev/null +++ b/Assets/ResourcesData/Prop/바람개비/Thumbnail/바람개비_thumbnail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9157b4f17a62ec8c8ebd739c82cc6e4dff5425c35afb59dbc0d6734cef27b6a1 +size 29951 diff --git a/Assets/ResourcesData/Prop/바람개비/Thumbnail/바람개비_thumbnail.png.meta b/Assets/ResourcesData/Prop/바람개비/Thumbnail/바람개비_thumbnail.png.meta new file mode 100644 index 000000000..53248ead7 --- /dev/null +++ b/Assets/ResourcesData/Prop/바람개비/Thumbnail/바람개비_thumbnail.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 2a6a709c17aef7143aaec294254d25e1 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ResourcesData/Prop/핸드마이크 스텐드/Thumbnail.meta b/Assets/ResourcesData/Prop/핸드마이크 스텐드/Thumbnail.meta new file mode 100644 index 000000000..5024f42ad --- /dev/null +++ b/Assets/ResourcesData/Prop/핸드마이크 스텐드/Thumbnail.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 04a7ecefb26843c4c8692dffcb280df7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ResourcesData/Prop/핸드마이크 스텐드/Thumbnail/핸드마이크 스텐드_thumbnail.png b/Assets/ResourcesData/Prop/핸드마이크 스텐드/Thumbnail/핸드마이크 스텐드_thumbnail.png new file mode 100644 index 000000000..66bc8a476 --- /dev/null +++ b/Assets/ResourcesData/Prop/핸드마이크 스텐드/Thumbnail/핸드마이크 스텐드_thumbnail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a933e0c15184b33f374e9ce540278ad33d0696f52cd96ab0b319d61536e7c8cc +size 13684 diff --git a/Assets/ResourcesData/Prop/핸드마이크 스텐드/Thumbnail/핸드마이크 스텐드_thumbnail.png.meta b/Assets/ResourcesData/Prop/핸드마이크 스텐드/Thumbnail/핸드마이크 스텐드_thumbnail.png.meta new file mode 100644 index 000000000..a5b905d58 --- /dev/null +++ b/Assets/ResourcesData/Prop/핸드마이크 스텐드/Thumbnail/핸드마이크 스텐드_thumbnail.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 990174238f48eda40bbf1f02fe5d5773 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/KindRetargeting/CustomRetargetingScript.cs b/Assets/Scripts/KindRetargeting/CustomRetargetingScript.cs index 89fb1ed48..0aa17a9f1 100644 --- a/Assets/Scripts/KindRetargeting/CustomRetargetingScript.cs +++ b/Assets/Scripts/KindRetargeting/CustomRetargetingScript.cs @@ -22,12 +22,23 @@ namespace KindRetargeting // IK 컴포넌트 참조 [SerializeField] public TwoBoneIKSolver ikSolver = new TwoBoneIKSolver(); + [Header("힙 위치 보정 (로컬 좌표계 기반)")] + [SerializeField, Range(-1, 1)] + private float hipsOffsetX = 0f; // 캐릭터 기준 좌우 (항상 Right 방향) + [SerializeField, Range(-1, 1)] + private float hipsOffsetY = 0f; // 캐릭터 기준 상하 (항상 Up 방향) + [SerializeField, Range(-1, 1)] + private float hipsOffsetZ = 0f; // 캐릭터 기준 앞뒤 (항상 Forward 방향) + [HideInInspector] public float HipsWeightOffset = 1f; [HideInInspector] public float ChairSeatHeightOffset = 0f; // 의자 좌석 높이 오프셋 (월드 Y 기준) - // 캐릭터 기준 "위(Up)" 방향을 담당하는 로컬 축 (자동 힙 보정에서 사용) - // 예: (0,1,0) 이면 로컬 Y축이 월드 Up. 부호는 방향(0,-1,0이면 로컬 -Y가 Up) - private Vector3 localAxisForWorldUp = Vector3.up; + // 축 매핑: 월드 방향(Right/Up/Forward)을 담당하는 로컬 축을 저장 + // 예: localAxisForWorldRight = (0, 0, 1) 이면 로컬 Z축이 월드 Right 방향을 담당 + // 부호는 방향을 나타냄: (0, 0, -1)이면 로컬 -Z가 월드 Right를 담당 + private Vector3 localAxisForWorldRight = Vector3.right; // 월드 좌우(Right)를 담당하는 로컬 축 + private Vector3 localAxisForWorldUp = Vector3.up; // 월드 상하(Up)를 담당하는 로컬 축 + private Vector3 localAxisForWorldForward = Vector3.forward; // 월드 앞뒤(Forward)를 담당하는 로컬 축 [Header("축 정규화 정보 (읽기 전용)")] [SerializeField] @@ -62,8 +73,15 @@ namespace KindRetargeting // 본별 회전 오프셋을 저장하는 딕셔너리 private Dictionary rotationOffsets = new Dictionary(); - // HumanBodyBones 본 순회 범위 (0~54: 몸체 + 손가락 전부) - private const int lastBoneIndex = 55; + // HumanBodyBones.LastBone을 이용한 본 순회 범위 + private int lastBoneIndex = 55; // 0~54: 몸체 + 손가락 전부 + + [Header("무릎 안/밖 조정")] + [SerializeField, Range(-1f, 1f)] + private float kneeInOutWeight = 0f; // 무릎 안/밖 위치 조정 가중치 + [Header("무릎 앞/뒤 조정")] + [SerializeField, Range(-1f, 1f)] + private float kneeFrontBackWeight = 0.4f; // 무릎 앞/뒤 위치 조정 가중치 [Header("발 IK 위치 조정")] [SerializeField, Range(-1f, 1f)] @@ -135,6 +153,11 @@ namespace KindRetargeting [System.Serializable] private class RetargetingSettings { + public float hipsOffsetX; // 변경: hipsWeight → hipsOffsetX/Y/Z + public float hipsOffsetY; + public float hipsOffsetZ; + public float kneeInOutWeight; + public float kneeFrontBackWeight; public float footFrontBackOffset; // 발 앞뒤 오프셋 public float footInOutOffset; // 발 안쪽/바깥쪽 오프셋 public float floorHeight; @@ -152,9 +175,11 @@ namespace KindRetargeting } - // IK 조인트 캐시 구조체 (팔꿈치 bendGoal 위치 결정용) + // IK 조인트 싱을 위한 구조체 private struct IKJoints { + public Transform leftLowerLeg; + public Transform rightLowerLeg; public Transform leftLowerArm; public Transform rightLowerArm; } @@ -259,7 +284,7 @@ namespace KindRetargeting /// - 아바타 B: 로컬 Z가 월드 Up을 가리킴 → localAxisForWorldUp = (0, 0, 1) /// - 아바타 C: 로컬 -Z가 월드 Up을 가리킴 → localAxisForWorldUp = (0, 0, -1) /// - /// 이를 통해 자동 힙 보정은 항상 캐릭터 기준 "위/아래" 방향으로 작동합니다. + /// 이를 통해 hipsOffsetY는 항상 "위/아래" 방향으로 작동합니다. /// private void CalculateAxisNormalizer() { @@ -273,12 +298,19 @@ namespace KindRetargeting Vector3 localYInWorld = hips.TransformDirection(Vector3.up).normalized; Vector3 localZInWorld = hips.TransformDirection(Vector3.forward).normalized; - // 월드 Right/Up/Forward 에 가장 가까운 로컬 축을 분석 (디버그 표시 + Up 매핑용) - Vector3 localAxisForWorldRight = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.right, out string rightAxisName); - localAxisForWorldUp = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.up, out string upAxisName); - Vector3 localAxisForWorldForward = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.forward, out string forwardAxisName); + // 월드 Right(X)에 가장 가까운 로컬 축 찾기 + localAxisForWorldRight = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.right, out string rightAxisName); - // 디버그용: X=좌우, Y=상하, Z=앞뒤 매핑 (1=X, 2=Y, 3=Z, 부호=방향) + // 월드 Up(Y)에 가장 가까운 로컬 축 찾기 + localAxisForWorldUp = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.up, out string upAxisName); + + // 월드 Forward(Z)에 가장 가까운 로컬 축 찾기 + localAxisForWorldForward = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.forward, out string forwardAxisName); + + // 디버그용: 각 오프셋이 어느 로컬 축에 매핑되는지 표시 + // X: 좌우 오프셋이 사용하는 로컬 축 (1=X, 2=Y, 3=Z, 부호는 방향) + // Y: 상하 오프셋이 사용하는 로컬 축 + // Z: 앞뒤 오프셋이 사용하는 로컬 축 debugAxisNormalizer = new Vector3( GetAxisIndex(localAxisForWorldRight), GetAxisIndex(localAxisForWorldUp), @@ -425,6 +457,11 @@ namespace KindRetargeting var settings = new RetargetingSettings { + hipsOffsetX = hipsOffsetX, + hipsOffsetY = hipsOffsetY, + hipsOffsetZ = hipsOffsetZ, + kneeInOutWeight = kneeInOutWeight, + kneeFrontBackWeight = kneeFrontBackWeight, footFrontBackOffset = footFrontBackOffset, footInOutOffset = footInOutOffset, floorHeight = floorHeight, @@ -478,6 +515,11 @@ namespace KindRetargeting var settings = JsonUtility.FromJson(json); // 설정 적용 + hipsOffsetX = settings.hipsOffsetX; + hipsOffsetY = settings.hipsOffsetY; + hipsOffsetZ = settings.hipsOffsetZ; + kneeInOutWeight = settings.kneeInOutWeight; + kneeFrontBackWeight = settings.kneeFrontBackWeight; footFrontBackOffset = settings.footFrontBackOffset; footInOutOffset = settings.footInOutOffset; floorHeight = settings.floorHeight; @@ -663,9 +705,11 @@ namespace KindRetargeting return; } - // IK 조인트 캐싱 (팔꿈치 bendGoal 용) + // IK 조인트들 캐싱 sourceIKJoints = new IKJoints { + leftLowerLeg = sourceIKRoot.Find("LeftLowerLeg"), + rightLowerLeg = sourceIKRoot.Find("RightLowerLeg"), leftLowerArm = sourceIKRoot.Find("LeftLowerArm"), rightLowerArm = sourceIKRoot.Find("RightLowerArm") }; @@ -735,10 +779,14 @@ namespace KindRetargeting } /// - /// 머리 본에 회전 오프셋을 적용합니다. headBone 멤버 캐시 재사용 (ApplyHeadScale과 일관). + /// 머리 본에 회전 오프셋을 적용합니다. /// private void ApplyHeadRotationOffset() { + if (targetAnimator == null) return; + + // 머리 본 가져오기 + Transform headBone = targetAnimator.GetBoneTransform(HumanBodyBones.Head); if (headBone == null) return; // 오프셋이 모두 0이면 스킵 @@ -888,12 +936,30 @@ namespace KindRetargeting targetHips.rotation = finalHipsRotation; } - // 2. 다리 길이 차 + Hips↔UpperLeg 갭으로 힙 상하 자동 보정 (캐릭터 Up 방향) - Vector3 characterUp = finalHipsRotation * localAxisForWorldUp; - float autoHipsOffsetY = ComputeAutoHipsOffsetY(); - Vector3 characterOffset = characterUp * (autoHipsOffsetY * HipsWeightOffset); + // 2. 캐릭터 기준 로컬 오프셋 계산 (축 정규화 적용) + // + // 문제: 아바타마다 힙의 로컬 축 방향이 다름 + // - 아바타 A: 로컬 Y가 "위", 로컬 Z가 "앞" + // - 아바타 B: 로컬 Z가 "위", 로컬 X가 "앞" + // + // 해결: T-포즈에서 계산한 축 매핑을 사용 + // - localAxisForWorldRight: 실제로 "오른쪽"을 가리키는 로컬 축 + // - localAxisForWorldUp: 실제로 "위"를 가리키는 로컬 축 + // - localAxisForWorldForward: 실제로 "앞"을 가리키는 로컬 축 + // + // 이렇게 하면 모든 아바타에서 동일하게 작동합니다. - // 3. 힙 위치 동기화 + 자동 보정 오프셋 적용 + // 힙의 현재 회전을 기준으로, 정규화된 방향 벡터 계산 + Vector3 characterRight = finalHipsRotation * localAxisForWorldRight; + Vector3 characterUp = finalHipsRotation * localAxisForWorldUp; + Vector3 characterForward = finalHipsRotation * localAxisForWorldForward; + + Vector3 characterOffset = + characterRight * (hipsOffsetX * HipsWeightOffset) + // 캐릭터 기준 좌우 + characterUp * (hipsOffsetY * HipsWeightOffset) + // 캐릭터 기준 상하 + characterForward * (hipsOffsetZ * HipsWeightOffset); // 캐릭터 기준 앞뒤 + + // 3. 힙 위치 동기화 + 캐릭터 기준 오프셋 적용 Vector3 adjustedPosition = sourceHips.position + characterOffset; // 4. 바닥 높이 추가 (월드 Y축 - 바닥은 항상 월드 기준) @@ -912,51 +978,6 @@ namespace KindRetargeting SyncBoneRotations(skipBone: HumanBodyBones.Hips); } - // 자동 힙 보정 캐시 (다리 본 길이는 스케일 변경 시에만 갱신됨) - private float cachedAutoHipsOffsetY = 0f; - private bool autoHipsOffsetCacheValid = false; - - /// - /// 매 프레임 호출되는 자동 힙 보정값. 본 길이는 변하지 않으므로 캐시된 값을 반환. - /// avatarScale 변경 시 ApplyScale → RefreshAutoHipsOffsetCache 로 자동 갱신. - /// - private float ComputeAutoHipsOffsetY() - { - if (!autoHipsOffsetCacheValid) RefreshAutoHipsOffsetCache(); - return cachedAutoHipsOffsetY; - } - - /// - /// 자동 힙 보정 캐시를 재계산합니다 (Initialize / avatarScale 변경 시 호출). - /// = (타겟 다리길이 - 소스 다리길이) + (타겟 Hips↔UpperLeg 갭) × avatarScale - /// - private void RefreshAutoHipsOffsetCache() - { - cachedAutoHipsOffsetY = 0f; - autoHipsOffsetCacheValid = true; - - if (optitrackSource == null || targetAnimator == null) return; - - Transform sUp = optitrackSource.GetBoneTransform(HumanBodyBones.LeftUpperLeg); - Transform sLo = optitrackSource.GetBoneTransform(HumanBodyBones.LeftLowerLeg); - Transform sFt = optitrackSource.GetBoneTransform(HumanBodyBones.LeftFoot); - Transform tUp = targetAnimator.GetBoneTransform(HumanBodyBones.LeftUpperLeg); - Transform tLo = targetAnimator.GetBoneTransform(HumanBodyBones.LeftLowerLeg); - Transform tFt = targetAnimator.GetBoneTransform(HumanBodyBones.LeftFoot); - Transform tHi = targetAnimator.GetBoneTransform(HumanBodyBones.Hips); - - if (sUp == null || sLo == null || sFt == null - || tUp == null || tLo == null || tFt == null || tHi == null) - return; - - float sourceLeg = Vector3.Distance(sUp.position, sLo.position) + Vector3.Distance(sLo.position, sFt.position); - float targetLeg = Vector3.Distance(tUp.position, tLo.position) + Vector3.Distance(tLo.position, tFt.position); - if (sourceLeg < 0.01f || targetLeg < 0.01f) return; - - float hipsToLegGap = tHi.position.y - tUp.position.y; - cachedAutoHipsOffsetY = (targetLeg - sourceLeg) + hipsToLegGap * avatarScale; - } - /// /// 힙을 제외한 모든 본의 회전을 오프셋을 적용하여 동기화합니다. /// @@ -1218,9 +1239,11 @@ namespace KindRetargeting UpdateEndTarget(ikSolver.leftLeg.target, HumanBodyBones.LeftFoot); UpdateEndTarget(ikSolver.rightLeg.target, HumanBodyBones.RightFoot); - // 팔 bendGoal만 갱신 (다리는 ComputeKneePosFromSource가 소스 무릎으로 풀이하므로 bendGoal 사용 안 함) updatejointTarget(ikSolver.leftArm.bendGoal, HumanBodyBones.LeftLowerArm); updatejointTarget(ikSolver.rightArm.bendGoal, HumanBodyBones.RightLowerArm); + updatejointTarget(ikSolver.leftLeg.bendGoal, HumanBodyBones.LeftLowerLeg); + updatejointTarget(ikSolver.rightLeg.bendGoal, HumanBodyBones.RightLowerLeg); + } /// @@ -1285,10 +1308,21 @@ namespace KindRetargeting Vector3 targetBone = targetAnimator.GetBoneTransform(bone).position; Vector3 sourceIKpoint; float zOffset = 0f; + float xOffset = 0f; + float yOffset = 0f; // bone의 이름에 따라 적절한 IK 조인트 오프셋 선택 switch (bone) { + case HumanBodyBones.LeftLowerLeg: + case HumanBodyBones.RightLowerLeg: + sourceIKpoint = bone == HumanBodyBones.LeftLowerLeg ? + sourceIKJoints.leftLowerLeg.position : + sourceIKJoints.rightLowerLeg.position; + zOffset = kneeFrontBackWeight; // 무릎 앞/뒤 조정 + yOffset = floorHeight; + xOffset = kneeInOutWeight * (bone == HumanBodyBones.LeftLowerLeg ? -1f : 1f); // 무릎 안/밖 조정 + break; case HumanBodyBones.LeftLowerArm: case HumanBodyBones.RightLowerArm: sourceIKpoint = bone == HumanBodyBones.LeftLowerArm ? @@ -1306,8 +1340,11 @@ namespace KindRetargeting target.position = targetBone; target.rotation = LookatIK; - // LookatIK 기준으로 z축 오프셋 적용 (팔꿈치 뒤쪽) - target.position += LookatIK * new Vector3(0f, 0f, zOffset); + // LookatIK 기준으로 프셋 적용 + Vector3 offset = LookatIK * new Vector3(xOffset, 0, zOffset); + Vector3 offset2 = new Vector3(0, yOffset, 0); + target.position += offset; + target.position += offset2; } #endregion @@ -1393,6 +1430,18 @@ namespace KindRetargeting return File.Exists(filePath); } + // 무릎 앞/뒤뒤 위치 조정을 위한 public 메서드 추가 + public void SetKneeFrontBackOffset(float offset) + { + kneeFrontBackWeight = offset; + } + + // 무릎 조정을 위한 public 메서드들 + public void SetKneeInOutOffset(float offset) + { + kneeInOutWeight = offset; + } + public void ResetPoseAndCache() { // 캐시 파일 삭제 @@ -1496,10 +1545,6 @@ namespace KindRetargeting } } } - - // 스케일 변경으로 본 길이가 바뀌었으니 IK 캐시 + 자동 힙 보정 캐시 갱신 - ikSolver?.RefreshLimbLengths(); - RefreshAutoHipsOffsetCache(); } // 리셋 기능 추가 @@ -1528,6 +1573,12 @@ namespace KindRetargeting avatarScale = Mathf.Clamp(scale, 0.1f, 3f); } + // 현재 스케일을 가져오는 메서드 + public float GetAvatarScale() + { + return avatarScale; + } + // 외부에서 머리 크기를 설정할 수 있는 public 메서드 public void SetHeadScale(float scale) { diff --git a/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.cs b/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.cs index faea64f23..249550aa4 100644 --- a/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.cs +++ b/Assets/Scripts/KindRetargeting/Editor/CustomRetargetingScriptEditor.cs @@ -34,6 +34,16 @@ namespace KindRetargeting // ── 힙 위치 보정 ── root.Add(BuildHipsSection()); + // ── 무릎 위치 조정 ── + var kneeFoldout = new Foldout { text = "무릎 위치 조정", value = false }; + var kneeFB = new Slider("무릎 앞/뒤", -1f, 1f) { showInputField = true }; + kneeFB.BindProperty(serializedObject.FindProperty("kneeFrontBackWeight")); + kneeFoldout.Add(kneeFB); + var kneeIO = new Slider("무릎 안/밖", -1f, 1f) { showInputField = true }; + kneeIO.BindProperty(serializedObject.FindProperty("kneeInOutWeight")); + kneeFoldout.Add(kneeIO); + root.Add(kneeFoldout); + // ── 발 IK 위치 조정 ── var footFoldout = new Foldout { text = "발 IK 위치 조정", value = false }; var footFB = new Slider("발 앞/뒤", -1f, 1f) { showInputField = true }; @@ -74,17 +84,54 @@ namespace KindRetargeting } // ========== 힙 위치 보정 ========== - // 힙 상하 보정은 매 프레임 자동 계산됨 (CustomRetargetingScript.ComputeAutoHipsOffsetY). - // 의자 앉기 높이만 수동 조정 가능. private VisualElement BuildHipsSection() { - var foldout = new Foldout { text = "힙 위치 보정", value = true }; + var foldout = new Foldout { text = "힙 위치 보정 (로컬 좌표계)", value = true }; + var axisInfo = new HelpBox("플레이 모드에서 축 매핑 정보가 표시됩니다.", HelpBoxMessageType.Info); + foldout.Add(axisInfo); + + foldout.schedule.Execute(() => + { + if (target == null) return; + serializedObject.Update(); + var axisProp = serializedObject.FindProperty("debugAxisNormalizer"); + if (axisProp == null || !Application.isPlaying) { axisInfo.text = "플레이 모드에서 축 매핑 정보가 표시됩니다."; return; } + Vector3 m = axisProp.vector3Value; + if (m == Vector3.one) { axisInfo.text = "플레이 모드에서 축 매핑 정보가 표시됩니다."; return; } + string A(float v) => Mathf.RoundToInt(Mathf.Abs(v)) switch { 1 => (v > 0 ? "+X" : "-X"), 2 => (v > 0 ? "+Y" : "-Y"), 3 => (v > 0 ? "+Z" : "-Z"), _ => "?" }; + axisInfo.text = $"축 매핑: 좌우→{A(m.x)} 상하→{A(m.y)} 앞뒤→{A(m.z)}"; + }).Every(500); + + var hx = new Slider("← 좌우 →", -1f, 1f) { showInputField = true }; + hx.BindProperty(serializedObject.FindProperty("hipsOffsetX")); + foldout.Add(hx); + var hy = new Slider("↓ 상하 ↑", -1f, 1f) { showInputField = true }; + hy.BindProperty(serializedObject.FindProperty("hipsOffsetY")); + foldout.Add(hy); + var hz = new Slider("← 앞뒤 →", -1f, 1f) { showInputField = true }; + hz.BindProperty(serializedObject.FindProperty("hipsOffsetZ")); + foldout.Add(hz); + + // 의자 앉기 높이 var chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정" }; chairSlider.BindProperty(serializedObject.FindProperty("limbWeight.chairSeatHeightOffset")); foldout.Add(chairSlider); + // 다리 길이 자동 보정 버튼 + var autoHipsBtn = new Button(() => + { + if (!Application.isPlaying) { Debug.LogWarning("플레이 모드에서만 사용 가능합니다."); return; } + var script = (CustomRetargetingScript)target; + float offset = CalculateHipsOffsetFromLegDifference(script); + serializedObject.FindProperty("hipsOffsetY").floatValue = offset; + serializedObject.ApplyModifiedProperties(); + script.SaveSettings(); + }) { text = "다리 길이 자동 보정", tooltip = "소스/타겟 다리 길이 차이로 힙 상하 오프셋을 자동 계산합니다." }; + autoHipsBtn.style.marginTop = 4; autoHipsBtn.style.height = 25; + foldout.Add(autoHipsBtn); + return foldout; } @@ -372,6 +419,7 @@ namespace KindRetargeting script.ResetScale(); so.FindProperty("avatarScale").floatValue = 1f; + so.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script); so.ApplyModifiedProperties(); EditorApplication.delayCall += () => @@ -392,6 +440,7 @@ namespace KindRetargeting { if (script == null) return; var so3 = new SerializedObject(script); + so3.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script); var xP = so3.FindProperty("headRotationOffsetX"); var yP = so3.FindProperty("headRotationOffsetY"); @@ -408,6 +457,37 @@ namespace KindRetargeting // ========== 유틸리티 ========== + private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script) + { + var source = script.optitrackSource; + Animator targetAnim = script.targetAnimator; + if (source == null || targetAnim == null) return 0f; + + float sourceLeg = GetSourceLegLength(source); + float targetLeg = GetLegLength(targetAnim); + if (sourceLeg < 0.01f || targetLeg < 0.01f) return 0f; + + return targetLeg - sourceLeg; + } + + private float GetLegLength(Animator animator) + { + Transform upper = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg); + Transform lower = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg); + Transform foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot); + if (upper == null || lower == null || foot == null) return 0f; + return Vector3.Distance(upper.position, lower.position) + Vector3.Distance(lower.position, foot.position); + } + + private float GetSourceLegLength(OptitrackSkeletonAnimator_Mingle source) + { + Transform upper = source.GetBoneTransform(HumanBodyBones.LeftUpperLeg); + Transform lower = source.GetBoneTransform(HumanBodyBones.LeftLowerLeg); + Transform foot = source.GetBoneTransform(HumanBodyBones.LeftFoot); + if (upper == null || lower == null || foot == null) return 0f; + return Vector3.Distance(upper.position, lower.position) + Vector3.Distance(lower.position, foot.position); + } + private void CalibrateHeadToForward(SerializedObject so, SerializedProperty xProp, SerializedProperty yProp, SerializedProperty zProp) { CustomRetargetingScript script = so.targetObject as CustomRetargetingScript; diff --git a/Assets/Scripts/KindRetargeting/Editor/RetargetingControlWindow.cs b/Assets/Scripts/KindRetargeting/Editor/RetargetingControlWindow.cs index da02f9bab..70f61aaca 100644 --- a/Assets/Scripts/KindRetargeting/Editor/RetargetingControlWindow.cs +++ b/Assets/Scripts/KindRetargeting/Editor/RetargetingControlWindow.cs @@ -198,6 +198,19 @@ public class RetargetingControlWindow : EditorWindow // 힙 위치 보정 panel.Add(BuildHipsSection(script, so)); + // 무릎 위치 조정 + var kneeFoldout = new Foldout { text = "무릎 위치 조정", value = false }; + var kneeContainer = new VisualElement(); + var kneeFB = new Slider("무릎 앞/뒤 가중치", -1f, 1f) { showInputField = true }; + kneeFB.BindProperty(so.FindProperty("kneeFrontBackWeight")); + kneeContainer.Add(kneeFB); + var kneeIO = new Slider("무릎 안/밖 가중치", -1f, 1f) { showInputField = true }; + kneeIO.BindProperty(so.FindProperty("kneeInOutWeight")); + kneeContainer.Add(kneeIO); + kneeFoldout.Add(kneeContainer); + kneeContainer.Bind(so); + panel.Add(kneeFoldout); + // 발 IK 위치 조정 var footFoldout = new Foldout { text = "발 IK 위치 조정", value = false }; var footContainer = new VisualElement(); @@ -232,6 +245,16 @@ public class RetargetingControlWindow : EditorWindow if (headScaleProp != null) scaleContainer.Add(new PropertyField(headScaleProp, "머리 크기")); + // 아바타 크기 변경 시 다리 길이 자동 보정 (실시간) + scaleContainer.TrackPropertyValue(so.FindProperty("avatarScale"), _ => + { + if (!Application.isPlaying || script == null) return; + var sox = new SerializedObject(script); + sox.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script); + sox.ApplyModifiedProperties(); + sox.Dispose(); + }); + scaleContainer.Bind(so); scaleFoldout.Add(scaleContainer); panel.Add(scaleFoldout); @@ -313,18 +336,65 @@ public class RetargetingControlWindow : EditorWindow } // ========== Hips Settings ========== - // 힙 상하 보정은 매 프레임 자동 (CustomRetargetingScript.ComputeAutoHipsOffsetY). - // 의자 앉기 높이만 수동 조정. private VisualElement BuildHipsSection(CustomRetargetingScript script, SerializedObject so) { - var foldout = new Foldout { text = "힙 위치 보정", value = false }; + var foldout = new Foldout { text = "힙 위치 보정 (로컬)", value = false }; var container = new VisualElement(); + // 축 매핑 정보 + var axisLabel = new Label { style = { fontSize = 10, color = new Color(0.6f, 0.6f, 0.6f) } }; + container.Add(axisLabel); + + container.schedule.Execute(() => + { + try { if (so == null || so.targetObject == null) return; } + catch (System.Exception) { return; } + so.Update(); + var normProp = so.FindProperty("debugAxisNormalizer"); + if (normProp == null || !Application.isPlaying) { axisLabel.text = ""; return; } + Vector3 m = normProp.vector3Value; + if (m == Vector3.one) { axisLabel.text = ""; return; } + string A(float v) => Mathf.RoundToInt(Mathf.Abs(v)) switch { 1 => (v > 0 ? "+X" : "-X"), 2 => (v > 0 ? "+Y" : "-Y"), 3 => (v > 0 ? "+Z" : "-Z"), _ => "?" }; + axisLabel.text = $"축 매핑: 좌우→{A(m.x)} 상하→{A(m.y)} 앞뒤→{A(m.z)}"; + }).Every(500); + + var hx = new Slider("← 좌우 →", -1f, 1f) { showInputField = true }; + hx.BindProperty(so.FindProperty("hipsOffsetX")); + container.Add(hx); + + var hy = new Slider("↓ 상하 ↑", -1f, 1f) { showInputField = true }; + hy.BindProperty(so.FindProperty("hipsOffsetY")); + container.Add(hy); + + var hz = new Slider("← 앞뒤 →", -1f, 1f) { showInputField = true }; + hz.BindProperty(so.FindProperty("hipsOffsetZ")); + container.Add(hz); + + // 의자 앉기 높이 var chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정 (월드 Y 기준)" }; chairSlider.BindProperty(so.FindProperty("limbWeight.chairSeatHeightOffset")); container.Add(chairSlider); + // 다리 길이 자동 보정 버튼 + var autoHipsBtn = new Button(() => + { + if (!Application.isPlaying) + { + Debug.LogWarning("다리 길이 자동 보정은 플레이 모드에서만 사용 가능합니다."); + return; + } + float offset = CalculateHipsOffsetFromLegDifference(script); + var hipsProp = so.FindProperty("hipsOffsetY"); + hipsProp.floatValue = offset; + so.ApplyModifiedProperties(); + script.SaveSettings(); + Debug.Log($"자동 보정 완료: hipsOffsetY = {offset:F4}"); + }) { text = "다리 길이 자동 보정", tooltip = "소스/타겟 다리 길이 차이로 힙 상하 오프셋을 자동 계산합니다. (플레이 모드 전용)" }; + autoHipsBtn.style.marginTop = 4; + autoHipsBtn.style.height = 25; + container.Add(autoHipsBtn); + container.Bind(so); foldout.Add(container); return foldout; @@ -766,8 +836,7 @@ public class RetargetingControlWindow : EditorWindow // ========== Auto Full Calibration ========== /// - /// 소스/타겟 목 높이 비율로 avatarScale을 맞추고, 머리 정면을 캘리브레이션합니다. - /// (힙 상하 보정은 매 프레임 자동 처리됨) + /// 소스/타겟 목 높이 비율로 avatarScale을 맞추고, 다리 길이 차이로 hipsOffsetY를 보정합니다. /// private void AutoCalibrateAll(CustomRetargetingScript script, SerializedObject so) { @@ -780,10 +849,11 @@ public class RetargetingControlWindow : EditorWindow return; } - // ── 프레임 1: 스케일 리셋 ── + // ── 프레임 1: 스케일 리셋 + 다리 보정 ── script.ResetScale(); var scaleProp = so.FindProperty("avatarScale"); scaleProp.floatValue = 1f; + so.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script); so.ApplyModifiedProperties(); // ── 프레임 2: 리타게팅 반영 후 목 높이 측정 → avatarScale 설정 ── @@ -809,12 +879,14 @@ public class RetargetingControlWindow : EditorWindow Debug.Log($"크기 보정: 소스 목 Y={sourceNeckY:F4}, 타겟 목 Y={targetNeckY:F4} → avatarScale = {scaleRatio:F3}"); - // ── 프레임 3: 머리 정면 캘리 + 저장 ── + // ── 프레임 3: 스케일 반영 후 다리 재보정 + 머리 정면 캘리 + 저장 ── EditorApplication.delayCall += () => { if (script == null) return; var so3 = new SerializedObject(script); + float finalHipsOffset = CalculateHipsOffsetFromLegDifference(script); + so3.FindProperty("hipsOffsetY").floatValue = finalHipsOffset; var xProp = so3.FindProperty("headRotationOffsetX"); var yProp = so3.FindProperty("headRotationOffsetY"); @@ -826,11 +898,69 @@ public class RetargetingControlWindow : EditorWindow so3.Dispose(); script.SaveSettings(); - Debug.Log($"전체 자동 보정 완료: avatarScale = {scaleRatio:F3}"); + Debug.Log($"전체 자동 보정 완료: avatarScale = {scaleRatio:F3}, hipsOffsetY = {finalHipsOffset:F4}m"); }; }; } + // ========== Auto Hips Offset ========== + + /// + /// 소스/타겟 다리 길이 차이로 힙 상하 오프셋을 계산합니다. + /// 타겟 다리가 소스보다 짧으면 → 양수 (힙을 올려서 다리를 펴줌) + /// 타겟 다리가 소스보다 길면 → 음수 (힙을 내려서 다리를 펴줌) + /// + private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script) + { + var source = script.optitrackSource; + Animator target = script.targetAnimator; + + if (source == null || target == null || !target.isHuman) + { + Debug.LogWarning("소스 OptiTrack 또는 타겟 Animator가 설정되지 않았습니다."); + return 0f; + } + + float sourceLeg = GetSourceLegLength(source); + float targetLeg = GetLegLength(target); + + if (sourceLeg < 0.01f || targetLeg < 0.01f) + { + Debug.LogWarning("다리 길이를 계산할 수 없습니다. 본이 올바르게 설정되어 있는지 확인해주세요."); + return 0f; + } + + // 소스 다리가 더 길면 타겟이 뜨므로 힙을 내려야 함 (음수) + // 소스 다리가 더 짧으면 타겟 다리가 구부러지므로 힙을 올려야 함 (양수) + float diff = targetLeg - sourceLeg; + Debug.Log($"소스 다리 길이: {sourceLeg:F4}, 타겟 다리 길이: {targetLeg:F4}, 힙 오프셋: {diff:F4}m"); + return diff; + } + + private float GetLegLength(Animator animator) + { + Transform upperLeg = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg); + Transform lowerLeg = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg); + Transform foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot); + + if (upperLeg == null || lowerLeg == null || foot == null) return 0f; + + return Vector3.Distance(upperLeg.position, lowerLeg.position) + + Vector3.Distance(lowerLeg.position, foot.position); + } + + private float GetSourceLegLength(OptitrackSkeletonAnimator_Mingle source) + { + Transform upperLeg = source.GetBoneTransform(HumanBodyBones.LeftUpperLeg); + Transform lowerLeg = source.GetBoneTransform(HumanBodyBones.LeftLowerLeg); + Transform foot = source.GetBoneTransform(HumanBodyBones.LeftFoot); + + if (upperLeg == null || lowerLeg == null || foot == null) return 0f; + + return Vector3.Distance(upperLeg.position, lowerLeg.position) + + Vector3.Distance(lowerLeg.position, foot.position); + } + private void CalibrateHeadToForward(SerializedObject so, SerializedProperty xProp, SerializedProperty yProp, SerializedProperty zProp) { CustomRetargetingScript script = so.targetObject as CustomRetargetingScript; diff --git a/Assets/Scripts/KindRetargeting/FingerShapedController.cs b/Assets/Scripts/KindRetargeting/FingerShapedController.cs index 335c84c54..533caaa49 100644 --- a/Assets/Scripts/KindRetargeting/FingerShapedController.cs +++ b/Assets/Scripts/KindRetargeting/FingerShapedController.cs @@ -1,4 +1,5 @@ using UnityEngine; +using System.Collections.Generic; namespace KindRetargeting { @@ -8,10 +9,8 @@ namespace KindRetargeting private Animator animator; private HumanPoseHandler humanPoseHandler; - // 매 프레임 재사용되는 캐시 (GC 압박 제거) - private Transform[] cachedNonFingerBones; - private Quaternion[] savedBoneRotations; - private HumanPose cachedHumanPose; + // 손가락을 제외한 모든 본의 로컬 회전 저장용 (SetHumanPose 호출 시 몸 복원용) + private Dictionary savedBoneLocalRotations = new Dictionary(); // 손가락을 제외한 모든 휴먼본 목록 private static readonly HumanBodyBones[] nonFingerBones = new HumanBodyBones[] @@ -72,18 +71,6 @@ namespace KindRetargeting if (animator == null || !animator.isHuman) return; humanPoseHandler = new HumanPoseHandler(animator.avatar, animator.transform); - - // Transform 배열 + 회전 배열 사전 캐싱 (매 프레임 GetBoneTransform 50회 → 0회) - cachedNonFingerBones = new Transform[nonFingerBones.Length]; - savedBoneRotations = new Quaternion[nonFingerBones.Length]; - for (int i = 0; i < nonFingerBones.Length; i++) - { - cachedNonFingerBones[i] = animator.GetBoneTransform(nonFingerBones[i]); - } - - // HumanPose 사전 할당 (muscles 배열 95개 float, GC 회피) - humanPoseHandler.GetHumanPose(ref cachedHumanPose); - isInitialized = true; } @@ -105,29 +92,40 @@ namespace KindRetargeting private void UpdateMuscleValues() { - // 1. 비손가락 본의 로컬 회전을 배열에 저장 (캐시된 Transform 재사용) - for (int i = 0; i < cachedNonFingerBones.Length; i++) + // 1. 손가락을 제외한 모든 본의 로컬 회전 저장 (SetHumanPose 호출 전) + savedBoneLocalRotations.Clear(); + for (int i = 0; i < nonFingerBones.Length; i++) { - Transform bone = cachedNonFingerBones[i]; - if (bone != null) savedBoneRotations[i] = bone.localRotation; + Transform bone = animator.GetBoneTransform(nonFingerBones[i]); + if (bone != null) + { + savedBoneLocalRotations[nonFingerBones[i]] = bone.localRotation; + } } - // 2. HumanPose 가져오기 (재사용 필드, GC 없음) + 손가락 머슬 설정 - humanPoseHandler.GetHumanPose(ref cachedHumanPose); + // 2. HumanPose 가져오기 및 손가락 머슬 설정 + HumanPose humanPose = new HumanPose(); + humanPoseHandler.GetHumanPose(ref humanPose); + // 왼손 제어 SetHandMuscles(true, leftThumbCurl, leftIndexCurl, leftMiddleCurl, leftRingCurl, - leftPinkyCurl, leftSpreadFingers, ref cachedHumanPose); + leftPinkyCurl, leftSpreadFingers, ref humanPose); + + // 오른손 제어 SetHandMuscles(false, rightThumbCurl, rightIndexCurl, rightMiddleCurl, rightRingCurl, - rightPinkyCurl, rightSpreadFingers, ref cachedHumanPose); + rightPinkyCurl, rightSpreadFingers, ref humanPose); // 3. 머슬 포즈 적용 (손가락 포함 전체 본에 영향) - humanPoseHandler.SetHumanPose(ref cachedHumanPose); + humanPoseHandler.SetHumanPose(ref humanPose); - // 4. 비손가락 본 로컬 회전 복원 (본 길이 변형 방지) - for (int i = 0; i < cachedNonFingerBones.Length; i++) + // 4. 손가락을 제외한 모든 본의 로컬 회전 복원 (본 길이 변형 방지) + foreach (var kvp in savedBoneLocalRotations) { - Transform bone = cachedNonFingerBones[i]; - if (bone != null) bone.localRotation = savedBoneRotations[i]; + Transform bone = animator.GetBoneTransform(kvp.Key); + if (bone != null) + { + bone.localRotation = kvp.Value; + } } } diff --git a/Assets/Scripts/KindRetargeting/LimbWeightController.cs b/Assets/Scripts/KindRetargeting/LimbWeightController.cs index e721c3ce2..6d434913f 100644 --- a/Assets/Scripts/KindRetargeting/LimbWeightController.cs +++ b/Assets/Scripts/KindRetargeting/LimbWeightController.cs @@ -39,14 +39,13 @@ namespace KindRetargeting private CustomRetargetingScript crs; private Transform characterRoot; - // 가중치 배열: 인덱스 의미는 Initialize 주석 참조 (크기 고정 → float[] 사용으로 GC/인덱싱 비용 절감) - readonly float[] leftArmEndWeights = new float[2]; // [0] 양손거리 [1] 프랍거리 - readonly float[] rightArmEndWeights = new float[2]; - readonly float[] leftLegEndWeights = new float[2]; // [0] 앉기 수평거리 [1] 발 높이 - readonly float[] rightLegEndWeights = new float[2]; + List leftArmEndWeights = new List(); + List rightArmEndWeights = new List(); + List leftLegEndWeights = new List(); + List rightLegEndWeights = new List(); - readonly float[] leftLegBendWeights = new float[1] { 1f }; - readonly float[] rightLegBendWeights = new float[1] { 1f }; + List leftLegBendWeights = new List(); + List rightLegBendWeights = new List(); private float MasterleftArmEndWeights = 0f; private float MasterrightArmEndWeights = 0f; @@ -56,11 +55,9 @@ namespace KindRetargeting private float MasterrightLegBendWeights = 0f; public List props = new List(); - // SitChairDistances 매 프레임 GetComponent 비용 회피용 (Initialize 시점 한 번 캐싱) - private List chairProps = new List(); - // 힙스 가중치: [0] 의자 거리 [1] 지면 높이 - readonly float[] hipsWeights = new float[2] { 1f, 1f }; + // 힙스 가중치 리스트 추가 + List hipsWeights = new List(); private float MasterHipsWeight = 1f; // 의자 좌석 높이 오프셋 (월드 Y 기준) @@ -79,26 +76,44 @@ namespace KindRetargeting InitWeightLayers(); - //프랍 오브젝트 찾기 + 의자 타입 별도 캐싱 - var allPropControllers = Object.FindObjectsByType(FindObjectsSortMode.None); - props = allPropControllers.Select(c => c.transform).ToList(); - chairProps = allPropControllers - .Where(c => c.propType == EnumsList.PropType.Chair) - .Select(c => c.transform) - .ToList(); + //프랍 오브젝트 찾기 + props = Object.FindObjectsByType(FindObjectsSortMode.None).Select(controller => controller.transform).ToList(); // 다른 캐릭터의 손을 props에 추가 GetHand(); - // 가중치 배열은 필드 선언 시 고정 크기로 초기화됨 (다리 EndWeight[1]만 1f 기본값) - leftLegEndWeights[1] = 1f; - rightLegEndWeights[1] = 1f; + //HandDistances()에서 사용을 위한 리스트 추가 + //손 거리에 따른 웨이트 업데이트 인덱스 0번 + leftArmEndWeights.Add(0); + rightArmEndWeights.Add(0); + + // 프랍과의 거리에 따른 웨이트 업데이트 인덱스 1번 + leftArmEndWeights.Add(0); + rightArmEndWeights.Add(0); + + // 앉아있을 때 다리와의 거리에 따른 가중치 적용 인덱스 0번 + leftLegEndWeights.Add(0); + rightLegEndWeights.Add(0); + + // 다리 골 가중치 초기화 + leftLegBendWeights.Add(1f); // 기본 가중치 + rightLegBendWeights.Add(1f); // 기본 가중치 if (this.characterRoot == null) { this.characterRoot = crs.transform; } + // 힙스 가중치 초기화 인덱스 0번 + hipsWeights.Add(1f); // 의자 거리 기반 가중치 + + // 지면 높이 기반 가중치 초기화 인덱스 1번 + hipsWeights.Add(1f); // 지면 높이 기반 가중치 + + // 발 높이 기반 가중치 초기화 인덱스 1번 + leftLegEndWeights.Add(1f); + rightLegEndWeights.Add(1f); + isInitialized = true; } @@ -222,7 +237,7 @@ namespace KindRetargeting } } - private void ProcessSitLegWeight(Transform hips, Transform footTarget, float[] weightArr, int weightIndex) + private void ProcessSitLegWeight(Transform hips, Transform footTarget, List weightList, int weightIndex) { if (footTarget == null) return; @@ -241,7 +256,7 @@ namespace KindRetargeting float weight = 1f - Mathf.Clamp01((horizontalDistance - MIN_LEG_DISTANCE_RATIO) / (MAX_LEG_DISTANCE_RATIO - MIN_LEG_DISTANCE_RATIO)); - weightArr[weightIndex] = weight; + weightList[weightIndex] = weight; } void PropDistances() @@ -283,29 +298,40 @@ namespace KindRetargeting { if (crs == null) return; - Transform hipsTransform = crs?.optitrackSource?.GetBoneTransform(HumanBodyBones.Hips); - if (hipsTransform == null || chairProps.Count == 0) + Transform hipsTransform = crs.optitrackSource.GetBoneTransform(HumanBodyBones.Hips); + if (hipsTransform != null && props != null) { - hipsWeights[0] = 1f; - targetChairSeatOffset = 0f; - return; - } + float minDistance = float.MaxValue; + bool foundChair = false; - // chairProps는 Initialize에서 미리 필터링됨 (매 프레임 GetComponent 호출 회피) - float minDistance = float.MaxValue; - for (int i = 0; i < chairProps.Count; i++) - { - Transform chair = chairProps[i]; - if (chair == null) continue; - Vector3 chairPos = chair.childCount > 0 ? chair.GetChild(0).position : chair.position; - float distance = Vector3.Distance(hipsTransform.position, chairPos); - if (distance < minDistance) minDistance = distance; - } + foreach (Transform prop in props) + { + PropTypeController ptc = prop.GetComponent(); + if (ptc != null && ptc.propType == EnumsList.PropType.Chair) + { + float distance = Vector3.Distance(hipsTransform.position, prop.childCount > 0 ? prop.GetChild(0).position : prop.position); + if (distance < minDistance) + { + minDistance = distance; + foundChair = true; + } + } + } - float t = Mathf.Clamp01((minDistance - hipsMinDistance) / (hipsMaxDistance - hipsMinDistance)); - hipsWeights[0] = t; - // t가 0에 가까울수록 의자에 가까움 → 좌석 오프셋 더 적용 - targetChairSeatOffset = chairSeatHeightOffset * (1f - t); + float t = Mathf.Clamp01((minDistance - hipsMinDistance) / (hipsMaxDistance - hipsMinDistance)); + hipsWeights[0] = t; // 직접 HipsWeightOffset 수정 대신 배열에 저장 + + // 의자 좌석 높이 오프셋 계산 (가까울수록 더 적용) - 캐릭터별 설정 사용 + if (foundChair) + { + // t가 0에 가까울수록 의자에 가까움 → 좌석 오프셋 더 적용 + targetChairSeatOffset = chairSeatHeightOffset * (1f - t); + } + else + { + targetChairSeatOffset = 0f; + } + } } /// @@ -374,18 +400,18 @@ namespace KindRetargeting } /// - /// 배열에서 최대값을 찾습니다. + /// 리스트에서 최대값을 찾습니다. /// - private float GetMaxValue(float[] arr) + private float GetMaxValue(List list) { - if (arr.Length == 0) return 0f; + if (list.Count == 0) return 0f; - float max = arr[0]; - for (int i = 1; i < arr.Length; i++) + float max = list[0]; + for (int i = 1; i < list.Count; i++) { - if (arr[i] > max) + if (list[i] > max) { - max = arr[i]; + max = list[i]; } } return max; @@ -393,18 +419,18 @@ namespace KindRetargeting /// - /// 배열에서 최소값을 찾습니다. + /// 리스트에서 최소값을 찾습니다. /// - private float GetMinValue(float[] arr) + private float GetMinValue(List list) { - if (arr.Length == 0) return 0f; + if (list.Count == 0) return 0f; - float min = arr[0]; - for (int i = 1; i < arr.Length; i++) + float min = list[0]; + for (int i = 1; i < list.Count; i++) { - if (arr[i] < min) + if (list[i] < min) { - min = arr[i]; + min = list[i]; } } return min; @@ -447,7 +473,7 @@ namespace KindRetargeting { if (crs == null || ikSolver == null) return; - Transform hipsTransform = crs?.optitrackSource?.GetBoneTransform(HumanBodyBones.Hips); + Transform hipsTransform = crs.optitrackSource.GetBoneTransform(HumanBodyBones.Hips); if (hipsTransform != null) { float groundHeight = characterRoot.position.y; @@ -480,7 +506,7 @@ namespace KindRetargeting ); } - private void ProcessFootHeightWeight(Transform footTarget, float[] weightArr, int weightIndex) + private void ProcessFootHeightWeight(Transform footTarget, List weightList, int weightIndex) { if (footTarget == null) return; @@ -492,7 +518,7 @@ namespace KindRetargeting (footHeightMaxThreshold - footHeightMinThreshold)); // 계산된 가중치 설정 - weightArr[weightIndex] = weight; + weightList[weightIndex] = weight; } } } diff --git a/Assets/Scripts/KindRetargeting/README.md b/Assets/Scripts/KindRetargeting/README.md index a98d4eb11..131cb3bd5 100644 --- a/Assets/Scripts/KindRetargeting/README.md +++ b/Assets/Scripts/KindRetargeting/README.md @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c355d545f14147235fee1cf7f165e7e45e9f77b57271a1606c975e2f7f000084 -size 13403 +oid sha256:567b19df1dd4fa2f6eea49fcc8e5c6287318a3caf51cdeb8a692a4e2c2733019 +size 13245 diff --git a/Assets/Scripts/KindRetargeting/Remote/RetargetingRemoteController.cs b/Assets/Scripts/KindRetargeting/Remote/RetargetingRemoteController.cs index bf4fa3e69..fef4d3414 100644 --- a/Assets/Scripts/KindRetargeting/Remote/RetargetingRemoteController.cs +++ b/Assets/Scripts/KindRetargeting/Remote/RetargetingRemoteController.cs @@ -166,6 +166,13 @@ namespace KindRetargeting.Remote } break; + case "autoHipsOffset": + { + string charId = json["characterId"]?.ToString(); + AutoHipsOffset(charId); + } + break; + case "autoCalibrateAll": { string charId = json["characterId"]?.ToString(); @@ -226,6 +233,15 @@ namespace KindRetargeting.Remote var data = new Dictionary { + // 힙 위치 보정 (로컬) + { "hipsVertical", GetPrivateField(script, "hipsOffsetY") }, + { "hipsForward", GetPrivateField(script, "hipsOffsetZ") }, + { "hipsHorizontal", GetPrivateField(script, "hipsOffsetX") }, + + // 무릎 위치 조정 + { "kneeFrontBackWeight", GetPrivateField(script, "kneeFrontBackWeight") }, + { "kneeInOutWeight", GetPrivateField(script, "kneeInOutWeight") }, + // 발 IK 위치 조정 { "feetForwardBackward", GetPrivateField(script, "footFrontBackOffset") }, { "feetNarrow", GetPrivateField(script, "footInOutOffset") }, @@ -300,6 +316,25 @@ namespace KindRetargeting.Remote switch (property) { + // 힙 위치 보정 + case "hipsVertical": + SetPrivateField(script, "hipsOffsetY", value); + break; + case "hipsForward": + SetPrivateField(script, "hipsOffsetZ", value); + break; + case "hipsHorizontal": + SetPrivateField(script, "hipsOffsetX", value); + break; + + // 무릎 위치 조정 + case "kneeFrontBackWeight": + SetPrivateField(script, "kneeFrontBackWeight", value); + break; + case "kneeInOutWeight": + SetPrivateField(script, "kneeInOutWeight", value); + break; + // 발 IK 위치 조정 case "feetForwardBackward": SetPrivateField(script, "footFrontBackOffset", value); @@ -535,6 +570,19 @@ namespace KindRetargeting.Remote SendStatus(true, "정면 캘리브레이션 완료"); } + private void AutoHipsOffset(string characterId) + { + var script = FindCharacter(characterId); + if (script == null) return; + + float offset = CalculateHipsOffsetFromLegDifference(script); + SetPrivateField(script, "hipsOffsetY", offset); + script.SaveSettings(); + + SendCharacterData(characterId); + SendStatus(true, $"다리 길이 자동 보정 완료: hipsOffsetY={offset:F4}"); + } + private void AutoCalibrateAll(string characterId) { var script = FindCharacter(characterId); @@ -548,9 +596,10 @@ namespace KindRetargeting.Remote return; } - // Step 1: 크기 초기화 + // Step 1: 크기 초기화 + 힙 오프셋 계산 script.ResetScale(); SetPrivateField(script, "avatarScale", 1f); + SetPrivateField(script, "hipsOffsetY", CalculateHipsOffsetFromLegDifference(script)); // Step 2: 1프레임 후 목 높이 비율로 크기 조정 StartCoroutine(AutoCalibrateCoroutine(script, characterId)); @@ -577,7 +626,8 @@ namespace KindRetargeting.Remote yield return null; // 1프레임 대기 - // Step 3: 머리 정면 캘리브레이션 + 저장 (힙 보정은 매 프레임 자동) + // Step 3: 힙 오프셋 재계산 + 머리 정면 캘리브레이션 + SetPrivateField(script, "hipsOffsetY", CalculateHipsOffsetFromLegDifference(script)); script.CalibrateHeadToForward(); script.SaveSettings(); @@ -585,6 +635,37 @@ namespace KindRetargeting.Remote SendStatus(true, $"전체 자동 보정 완료: avatarScale={scaleRatio:F3}"); } + private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script) + { + var source = script.optitrackSource; + Animator targetAnim = script.targetAnimator; + if (source == null || targetAnim == null) return 0f; + + float sourceLeg = GetSourceLegLength(source); + float targetLeg = GetLegLength(targetAnim); + if (sourceLeg < 0.01f || targetLeg < 0.01f) return 0f; + + return targetLeg - sourceLeg; + } + + private float GetSourceLegLength(OptitrackSkeletonAnimator_Mingle source) + { + Transform upper = source.GetBoneTransform(HumanBodyBones.LeftUpperLeg); + Transform lower = source.GetBoneTransform(HumanBodyBones.LeftLowerLeg); + Transform foot = source.GetBoneTransform(HumanBodyBones.LeftFoot); + if (upper == null || lower == null || foot == null) return 0f; + return Vector3.Distance(upper.position, lower.position) + Vector3.Distance(lower.position, foot.position); + } + + private float GetLegLength(Animator animator) + { + Transform upper = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg); + Transform lower = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg); + Transform foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot); + if (upper == null || lower == null || foot == null) return 0f; + return Vector3.Distance(upper.position, lower.position) + Vector3.Distance(lower.position, foot.position); + } + private CustomRetargetingScript FindCharacter(string characterId) { foreach (var script in registeredCharacters) @@ -624,25 +705,11 @@ namespace KindRetargeting.Remote #region Reflection Helpers - // 슬라이더 드래그(60fps) 시 매번 GetField 호출되는 것을 방지. - // (Type, fieldName) → FieldInfo 1회 lookup 후 캐싱. - private static readonly Dictionary<(System.Type, string), FieldInfo> _fieldCache = new Dictionary<(System.Type, string), FieldInfo>(); - private const BindingFlags FIELD_FLAGS = BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public; - - private static FieldInfo ResolveField(System.Type type, string fieldName) - { - var key = (type, fieldName); - if (_fieldCache.TryGetValue(key, out FieldInfo cached)) return cached; - FieldInfo field = type.GetField(fieldName, FIELD_FLAGS); - _fieldCache[key] = field; - return field; - } - private T GetPrivateField(object obj, string fieldName) { try { - var field = ResolveField(obj.GetType(), fieldName); + var field = obj.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); if (field != null) return (T)field.GetValue(obj); @@ -660,7 +727,7 @@ namespace KindRetargeting.Remote { try { - var field = ResolveField(obj.GetType(), fieldName); + var field = obj.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public); if (field != null) { field.SetValue(obj, value); diff --git a/Assets/Scripts/KindRetargeting/SimplePoseTransfer.cs b/Assets/Scripts/KindRetargeting/SimplePoseTransfer.cs index 3a8e62fe9..7bed289b4 100644 --- a/Assets/Scripts/KindRetargeting/SimplePoseTransfer.cs +++ b/Assets/Scripts/KindRetargeting/SimplePoseTransfer.cs @@ -5,9 +5,6 @@ using KindRetargeting; [DefaultExecutionOrder(16001)] public class SimplePoseTransfer : MonoBehaviour { - // 0~54: 휴머노이드 본 (몸체 + 손가락). HumanBodyBones.LastBone 직전까지. - private const int BoneCount = 55; - [System.Serializable] public class TargetEntry { @@ -124,7 +121,7 @@ public class SimplePoseTransfer : MonoBehaviour private void InitializeTargetBones() { - boneRotationDifferences = new Quaternion[targets.Count, BoneCount]; + boneRotationDifferences = new Quaternion[targets.Count, 55]; for (int i = 0; i < targets.Count; i++) { @@ -136,7 +133,7 @@ public class SimplePoseTransfer : MonoBehaviour } // 55개의 휴머노이드 본에 대해 회전 차이 계산 - for (int j = 0; j < BoneCount; j++) + for (int j = 0; j < 55; j++) { Transform sourceBoneTransform = sourceBone.GetBoneTransform((HumanBodyBones)j); Transform targetBoneTransform = animator.GetBoneTransform((HumanBodyBones)j); @@ -152,19 +149,19 @@ public class SimplePoseTransfer : MonoBehaviour private void CacheAllBoneTransforms() { // 소스 본 캐싱 - cachedSourceBones = new Transform[BoneCount]; - for (int i = 0; i < BoneCount; i++) + cachedSourceBones = new Transform[55]; + for (int i = 0; i < 55; i++) { cachedSourceBones[i] = sourceBone.GetBoneTransform((HumanBodyBones)i); } // 타겟 본 캐싱 - cachedTargetBones = new Transform[targets.Count, BoneCount]; + cachedTargetBones = new Transform[targets.Count, 55]; for (int t = 0; t < targets.Count; t++) { Animator animator = targets[t].animator; if (animator == null) continue; - for (int i = 0; i < BoneCount; i++) + for (int i = 0; i < 55; i++) { cachedTargetBones[t, i] = animator.GetBoneTransform((HumanBodyBones)i); } @@ -208,7 +205,7 @@ public class SimplePoseTransfer : MonoBehaviour } // 모든 본에 대해 포즈 전송 - for (int i = 0; i < BoneCount; i++) + for (int i = 0; i < 55; i++) { Transform targetBoneTransform = cachedTargetBones[targetIndex, i]; Transform sourceBoneTransform = cachedSourceBones[i]; diff --git a/Assets/Scripts/KindRetargeting/TwoBoneIKSolver.cs b/Assets/Scripts/KindRetargeting/TwoBoneIKSolver.cs index 8322a8b4d..f3b240ce3 100644 --- a/Assets/Scripts/KindRetargeting/TwoBoneIKSolver.cs +++ b/Assets/Scripts/KindRetargeting/TwoBoneIKSolver.cs @@ -45,8 +45,6 @@ namespace KindRetargeting public LimbIK leftLeg = new LimbIK(); public LimbIK rightLeg = new LimbIK(); - [HideInInspector] public int fabrikIterations = 6; - private bool isInitialized; public void Initialize(Animator targetAnimator) @@ -88,25 +86,6 @@ namespace KindRetargeting limb.localBendNormal = Quaternion.Inverse(limb.upper.rotation) * bendNormal; } - /// - /// avatarScale 등으로 아바타 크기가 바뀌었을 때 호출하여 본 길이 캐시를 재계산. - /// - public void RefreshLimbLengths() - { - if (!isInitialized) return; - RecacheLength(leftArm); - RecacheLength(rightArm); - RecacheLength(leftLeg); - RecacheLength(rightLeg); - } - - private void RecacheLength(LimbIK limb) - { - if (limb.upper == null || limb.lower == null || limb.end == null) return; - limb.upperLength = Vector3.Distance(limb.upper.position, limb.lower.position); - limb.lowerLength = Vector3.Distance(limb.lower.position, limb.end.position); - } - public void OnUpdate() { if (!isInitialized) return; @@ -130,9 +109,8 @@ namespace KindRetargeting Quaternion fkLowerRot = limb.lower.rotation; Quaternion fkEndRot = limb.end.rotation; - // 본 길이는 Initialize/RefreshLimbLengths에서 캐싱됨 (avatarScale 변경 시 갱신) - float upperLen = limb.upperLength; - float lowerLen = limb.lowerLength; + float upperLen = Vector3.Distance(limb.upper.position, limb.lower.position); + float lowerLen = Vector3.Distance(limb.lower.position, limb.end.position); Vector3 targetPos = limb.target.position; @@ -171,61 +149,54 @@ namespace KindRetargeting } /// - /// 소스 무릎 위치를 타겟 프레임으로 옮긴 raw 위치를 초기값으로 두고, - /// FABRIK 2회 반복으로 hip↔knee=upperLen, knee↔target=lowerLen 을 모두 만족시킵니다. - /// - /// 정규화(normalize)에 의한 부호 증폭이 없어 입력 노이즈가 출력에 그대로 비례 전달됩니다. - /// → 다리 거의 펴진 상태에서 모캡 노이즈로 굽힘 부호가 흔들려도 점프 없이 연속적. - /// → 의도된 역관절도 자연스럽게 따라감 (소스 무릎이 어느 쪽이든 raw로 받음). + /// 소스 무릎 위치를 hip→foot 직선 기준으로 분해(투영+수직)한 뒤 + /// 타겟 비율로 스케일하여 타겟 무릎 위치를 결정합니다. + /// 소스가 역관절이면 수직 성분이 반대쪽 → 타겟도 자연스럽게 역관절. /// private Vector3 ComputeKneePosFromSource(LimbIK limb, float upperLen, float lowerLen, Vector3 targetPos) { - float chainLength = upperLen + lowerLen; - - // 소스 hip→foot 방향 - Vector3 sourceHipToFoot = limb.sourceEnd.position - limb.sourceUpper.position; - float sourceHipToFootMag = sourceHipToFoot.magnitude; - if (sourceHipToFootMag < 0.001f) return limb.lower.position; - Vector3 sourceHipToFootDir = sourceHipToFoot / sourceHipToFootMag; - - // 소스 hip→knee (굽힘 정보 raw, 정규화 X → 노이즈 증폭 없음) - Vector3 sourceHipToKnee = limb.sourceLower.position - limb.sourceUpper.position; - float sourceUpperLen = sourceHipToKnee.magnitude; + // 소스 체인 길이 + float sourceUpperLen = Vector3.Distance(limb.sourceUpper.position, limb.sourceLower.position); float sourceLowerLen = Vector3.Distance(limb.sourceLower.position, limb.sourceEnd.position); float sourceChain = sourceUpperLen + sourceLowerLen; - if (sourceChain < 0.001f) return limb.lower.position; + float targetChain = upperLen + lowerLen; - // 타겟 hip→target 방향 / 거리 - Vector3 toTarget = targetPos - limb.upper.position; - float targetDist = toTarget.magnitude; - if (targetDist < 0.001f) return limb.lower.position; - Vector3 toTargetDir = toTarget / targetDist; + if (sourceChain < 0.001f || targetChain < 0.001f) + return limb.lower.position; - // 도달 불가 거리는 클램프 (FABRIK 진동 방지) - // |upper-lower| 보다 가깝거나 chainLength 보다 멀면 본 길이 제약을 만족하는 무릎 위치 없음 - float effectiveDist = Mathf.Clamp(targetDist, Mathf.Abs(upperLen - lowerLen) + 0.001f, chainLength - 0.001f); - Vector3 effectiveTarget = limb.upper.position + toTargetDir * effectiveDist; + // 소스 hip → foot 방향 + Vector3 sourceHipToFoot = limb.sourceEnd.position - limb.sourceUpper.position; + float sourceHipToFootMag = sourceHipToFoot.magnitude; + if (sourceHipToFootMag < 0.001f) + return limb.lower.position; + Vector3 sourceHipToFootDir = sourceHipToFoot / sourceHipToFootMag; - // 소스 → 타겟 프레임 회전 + 다리 길이 비율 스케일 → 초기 무릎 위치 - Quaternion frameRotation = Quaternion.FromToRotation(sourceHipToFootDir, toTargetDir); - float scale = (upperLen + lowerLen) / sourceChain; - Vector3 kneePos = limb.upper.position + frameRotation * (sourceHipToKnee * scale); + // 소스 hip → knee 벡터를 투영(직선 성분)과 수직(오프셋 성분)으로 분해 + Vector3 sourceHipToKnee = limb.sourceLower.position - limb.sourceUpper.position; + float projection = Vector3.Dot(sourceHipToKnee, sourceHipToFootDir); + Vector3 rejection = sourceHipToKnee - projection * sourceHipToFootDir; - // FABRIK: knee 를 lowerLen 구면(target 중심) ↔ upperLen 구면(hip 중심) 사이에서 번갈아 투영 - for (int i = 0; i < fabrikIterations; i++) - { - // 1) target 중심 lowerLen 구면에 투영 → knee↔target = lowerLen 보장 - Vector3 fromTarget = kneePos - effectiveTarget; - float distFromTarget = fromTarget.magnitude; - if (distFromTarget > 0.001f) - kneePos = effectiveTarget + (fromTarget / distFromTarget) * lowerLen; + // 소스 체인 길이 대비 비율로 정규화 → 타겟 체인 길이로 스케일 + float scale = targetChain / sourceChain; + float scaledProjection = projection * scale; + Vector3 scaledRejection = rejection * scale; - // 2) hip 중심 upperLen 구면에 투영 → hip↔knee = upperLen 보장 - Vector3 fromHip = kneePos - limb.upper.position; - float distFromHip = fromHip.magnitude; - if (distFromHip > 0.001f) - kneePos = limb.upper.position + (fromHip / distFromHip) * upperLen; - } + // 타겟 hip → foot 방향 + Vector3 targetHipToFoot = targetPos - limb.upper.position; + float targetHipToFootMag = targetHipToFoot.magnitude; + if (targetHipToFootMag < 0.001f) + return limb.lower.position; + Vector3 targetHipToFootDir = targetHipToFoot / targetHipToFootMag; + + // 소스 프레임 → 타겟 프레임으로 수직 성분 회전 + // (소스와 타겟의 사지 방향이 다를 때 팔꿈치/무릎 오프셋 방향 보정) + Quaternion frameRotation = Quaternion.FromToRotation(sourceHipToFootDir, targetHipToFootDir); + Vector3 rotatedRejection = frameRotation * scaledRejection; + + // 타겟 관절 위치: 타겟 upper에서 투영 + 회전된 수직 성분 적용 + Vector3 kneePos = limb.upper.position + + targetHipToFootDir * scaledProjection + + rotatedRejection; return kneePos; } @@ -292,5 +263,24 @@ namespace KindRetargeting bendDir = Vector3.Cross(Vector3.up, toTargetDir); return bendDir.normalized; } + + public float CalculateAutoFloorHeight(float comfortRatio = 0.98f) + { + if (animator == null || leftLeg.upper == null || leftLeg.lower == null || leftLeg.end == null) return 0f; + + float upperLen = Vector3.Distance(leftLeg.upper.position, leftLeg.lower.position); + float lowerLen = Vector3.Distance(leftLeg.lower.position, leftLeg.end.position); + float totalLegLength = upperLen + lowerLen; + float comfortHeight = totalLegLength * comfortRatio; + + Transform hips = animator.GetBoneTransform(HumanBodyBones.Hips); + Transform foot = leftLeg.end; + if (hips == null || foot == null) return 0f; + + float currentHipToFoot = hips.position.y - foot.position.y; + float heightDiff = currentHipToFoot - comfortHeight; + + return -heightDiff; + } } } diff --git a/Assets/Scripts/WefLab/WefLabWebSocketClient.cs b/Assets/Scripts/WefLab/WefLabWebSocketClient.cs index 545c0bb18..88ff1d464 100644 --- a/Assets/Scripts/WefLab/WefLabWebSocketClient.cs +++ b/Assets/Scripts/WefLab/WefLabWebSocketClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Text.RegularExpressions; @@ -17,11 +17,56 @@ namespace WefLab [System.Serializable] public class DonationData { - public string platform; - public string donationType; - public int amount; + public string platform; // afreeca / naver(chzzk) / youtube / twitch / ... + public string donationType; // weflab subtype (SENDBALLOON / chzzk / superchat / bits ...) + public string subtype; // same as donationType, explicit name + public int amount; // normalized amount in KRW + public int rawValue; // raw platform value before KRW conversion (balloon count, cheese raw, bits, ...) public string message; public string donorName; + public string donorId; + public long timestamp; + } + + /// + /// A single emoticon/sticker referenced in a chat message. + /// + [System.Serializable] + public class ChatEmoticon + { + public string name; // emoticon key/name as it appears in the message + public string imageUrl; // image URL to render + } + + /// + /// Chat message data structure. Shares weflab's flat envelope with donations + /// (type/data) but carries chat-specific fields: color, badges, emoticons, warnings. + /// + [System.Serializable] + public class ChatData + { + public string platform; + public string type; // "chat" (normal) or "warn" (connection warning) + public string subtype; // platform-specific subtype (e.g. "emoticon") + public string nickname; // display name (data.name, falls back to data.id) + public string userId; // data.id + public string message; // data.msg (may contain emoticon name placeholders) + public string nickColor; // data.color (hex, may be empty) + + // Viewer grade flags (from data.grade) + public bool isStreamer; + public bool isManager; + public bool isFan; + public bool isSubscriber; + public bool isVip; + + public string[] badges; // data.grade.badge (platform badge ids) + public List emoticons = new List(); // data.emoticon + + // Connection warning (type == "warn"): not a real chat message + public bool isWarn; + public string warnType; // subtype when isWarn (e.g. "password", "subonly", "donationoff") + public long timestamp; } @@ -31,6 +76,12 @@ namespace WefLab [System.Serializable] public class DonationEvent : UnityEvent { } + /// + /// Unity event with chat data parameter + /// + [System.Serializable] + public class ChatEvent : UnityEvent { } + /// /// Donation event trigger based on amount range /// @@ -123,15 +174,36 @@ namespace WefLab [Tooltip("WefLab page URL (e.g., https://weflab.com/page/guPK2ODAmmRvYG4)")] public string pageUrl = "https://weflab.com/page/guPK2ODAmmRvYG4"; + [Tooltip("Also connect to the main control socket (ssmain). Donations arrive on the per-platform sockets, so this is usually not needed.")] + public bool connectMainSocket = false; + + [Tooltip("Drop duplicate donations with an identical signature within this window in seconds (guards against the same alert arriving on more than one socket). 0 disables.")] + [Min(0f)] + public float duplicateWindow = 3f; + [Header("Donation Event Triggers")] [Tooltip("Donation event triggers based on amount ranges")] public DonationEventTrigger[] donationTriggers = Array.Empty(); + [Header("Chat (optional - structured for later use)")] + [Tooltip("Receive chat messages. Off by default; turn on when you want to consume chat via onChatReceived.")] + public bool enableChat = false; + + [Tooltip("Fired for every incoming chat message (when enableChat is on)")] + public ChatEvent onChatReceived; + [Header("Queue Settings")] - [Tooltip("Enable sequential processing of donations")] + [Tooltip("Enable sequential processing of donations (one alert at a time, like the weflab overlay)")] public bool enableQueue = true; - [Tooltip("Delay between each donation alert (seconds)")] + [Tooltip("Lead-in delay before the donation event fires after it leaves the queue (seconds). " + + "Approximates the weflab overlay's intro/slide-in animation so the Unity reaction lands when the alert is revealed. " + + "Tune this to match your weflab alert's appearance timing.")] + [Min(0f)] + public float alertLeadTime = 0.8f; + + [Tooltip("On-screen display time for each alert before the next one is dequeued (seconds). " + + "Set this close to your weflab alert display duration so spacing between alerts matches the overlay.")] [Min(0.1f)] public float alertDelay = 3f; @@ -139,6 +211,28 @@ namespace WefLab [Min(0)] public int maxQueueSize = 50; + [Tooltip("Fired immediately when a donation is received from the socket (before the queue/lead-in). " + + "Use for instant side effects like running totals; the amount-range triggers fire later, synced to the alert reveal.")] + public DonationEvent onDonationReceived; + + [Header("Precise Timing Sync (match the weflab overlay reveal)")] + [Tooltip("Replicate the overlay timing: when an alert reaches the front of the queue, fetch the SAME TTS audio weflab uses, " + + "measure its length, fire the donation event when the fetch completes (= overlay reveal moment), then hold for the audio length. " + + "Automatically tracks the overlay's synthesis latency. Falls back to alertLeadTime/alertDelay if settings or audio are unavailable.")] + public bool syncWithAlertAudio = true; + + [Tooltip("Small constant added on top of the auto-measured TTS fetch time, to compensate server TTS cache skew + audio decode. " + + "The bulk of the reveal offset is measured automatically; fine-tune this against your live overlay (seconds).")] + [Min(0f)] + public float audioStartOffset = 0.15f; + + [Tooltip("Gap between one alert's audio ending and the next alert appearing (seconds). Matches the overlay's inter-alert pause (~1s).")] + [Min(0f)] + public float interAlertGap = 1f; + + [Tooltip("TTS language code sent to weflab's voice endpoint (affects the measured audio length).")] + public string ttsLang = "ko"; + // Queue state (visible in Inspector for debugging) [Header("Queue Status (Read Only)")] [SerializeField] private int queueCount = 0; @@ -148,20 +242,53 @@ namespace WefLab private Queue donationQueue = new Queue(); private Coroutine queueProcessorCoroutine = null; + // ---- Precise timing: meta extracted from loginData + settings fetched from /api/ ---- + private string userDir = ""; + private string userPreset = "0"; + private string pageType = "page"; // loginData.type + private string pageId = "alert"; // loginData.pageid + private string verServer = ""; + private string verSocket = ""; + private string baseDomain = "https://weflab.com"; + private string voiceUrlBase = ""; // absolute TTS endpoint (config.url.voiceurl) + + private bool settingsLoaded = false; + private float settingPopupTimeCap = 60f; // item_alert_popuptime (seconds) - display upper bound + private float ttsSpeed = 1f; // item_alert_popupsoundspeed + private string ttsTemplate = "{닉네임}님 {종류} {개수}{개} 감사합니다!"; // setup_alert_text + // Hidden connection info [HideInInspector] public bool isConnected = false; [HideInInspector] public string currentSid = ""; [HideInInspector] public string extractedUserIdx = ""; - // WebSocket connection - private WebSocket ws; - private string userIdx = ""; // Will be extracted from page - private string socketSid = ""; + /// + /// One WebSocket connection to a single weflab socket server (one per linked platform). + /// + private class PlatformConnection + { + public string platform; // "afreeca", "naver", "youtube", "main", ... + public string page; // subscription page: "alert" (donations) or "chat" + public string wsUrl; // wss://ssafreeca.weflab.com/socket.io/?... + public WebSocket ws; + public string engineSid = ""; + public string socketSid = ""; + public bool connected = false; + + public string Label => $"{platform}/{page}"; + } + + // Active connections (one per linked platform socket) + private readonly List connections = new List(); + + private string userIdx = ""; // Will be extracted from page (shared by every socket) private bool isExtracting = false; - // Ping/Pong settings + // Duplicate-donation guard: signature -> last seen Time.time + private readonly Dictionary recentDonations = new Dictionary(); + + // Ping/Pong settings (informational, reported by server handshake) private float pingInterval = 30f; - private float lastPingTime = 0f; // Thread-safe action queue for main thread private Queue mainThreadActions = new Queue(); @@ -236,6 +363,12 @@ namespace WefLab { extractedUserIdx = userIdx; Debug.Log($"[WefLab] Successfully extracted userIdx: {userIdx}"); + + // Capture meta needed for the precise-timing settings fetch + ParseLoginMeta(loginData); + + // Build the per-platform socket connection list from the same loginData + BuildConnections(loginData); } else { @@ -255,112 +388,325 @@ namespace WefLab isExtracting = false; - // Connect if we successfully extracted the userIdx - if (!string.IsNullOrEmpty(userIdx)) + // Connect if we successfully built at least one socket connection + if (connections.Count > 0) { - Connect(); + ConnectAll(); + + // Fetch alert design settings (popuptime/tts template/speed) for precise timing + if (syncWithAlertAudio) + { + yield return StartCoroutine(FetchAlertSettings()); + } } else { - Debug.LogError("[WefLab] Cannot connect - userIdx extraction failed"); + Debug.LogError("[WefLab] Cannot connect - no socket connections were prepared (userIdx/config extraction failed)"); } } /// - /// Connect to WefLab WebSocket server + /// Capture the loginData fields needed to call the /api/ settings endpoint and the TTS endpoint. /// - public void Connect() + private void ParseLoginMeta(JObject loginData) { - if (ws != null && ws.IsAlive) + userDir = loginData["dir"]?.ToString() ?? ""; + userPreset = loginData["preset"]?.ToString() ?? "0"; + pageType = loginData["type"]?.ToString() ?? "page"; + pageId = loginData["pageid"]?.ToString() ?? "alert"; + + var config = loginData["config"] as JObject; + verServer = config?["ver"]?["server"]?.ToString() ?? ""; + verSocket = config?["ver"]?["socket"]?.ToString() ?? ""; + + string domain = config?["domain"]?.ToString(); + if (!string.IsNullOrEmpty(domain)) + baseDomain = "https://" + domain; + + string voiceUrl = config?["url"]?["voiceurl"]?.ToString(); + if (!string.IsNullOrEmpty(voiceUrl)) + voiceUrlBase = voiceUrl.StartsWith("http") ? voiceUrl : baseDomain + voiceUrl; + } + + /// + /// Fetch the streamer's alert design settings from POST /api/ (type=page_load). + /// Reads the display-time cap, TTS speed and TTS text template used for precise timing. + /// + private IEnumerator FetchAlertSettings() + { + if (string.IsNullOrEmpty(userIdx) || string.IsNullOrEmpty(userDir)) { - Debug.LogWarning("[WefLab] Already connected"); + Debug.LogWarning("[WefLab] Cannot fetch alert settings - missing idx/dir; precise timing will fall back to alertDelay"); + yield break; + } + + WWWForm form = new WWWForm(); + form.AddField("type", pageType + "_load"); + form.AddField("pagetype", pageType); + form.AddField("idx", userIdx); + form.AddField("pageid", pageId); + form.AddField("preset", userPreset); + form.AddField("dir", userDir); + form.AddField("ver[server]", verServer); + form.AddField("ver[socket]", verSocket); + + using (UnityWebRequest request = UnityWebRequest.Post(baseDomain + "/api/", form)) + { + request.SetRequestHeader("Referer", pageUrl); + yield return request.SendWebRequest(); + + if (request.result != UnityWebRequest.Result.Success) + { + Debug.LogWarning($"[WefLab] Alert settings fetch failed ({request.error}); precise timing falls back to alertDelay"); + yield break; + } + + try + { + var root = JsonConvert.DeserializeObject(request.downloadHandler.text); + var data = root?["data"] as JObject; + if (data == null) + { + Debug.LogWarning("[WefLab] Alert settings response had no data; falling back to alertDelay"); + yield break; + } + + string popup = FindSetting(data, "item_alert_popuptime"); + if (!string.IsNullOrEmpty(popup) && float.TryParse(popup, out float pt) && pt > 0f) + settingPopupTimeCap = pt; + + string speed = FindSetting(data, "item_alert_popupsoundspeed"); + if (!string.IsNullOrEmpty(speed) && float.TryParse(speed, out float sp) && sp > 0f) + ttsSpeed = sp; + + string template = FindSetting(data, "setup_alert_text"); + if (!string.IsNullOrEmpty(template)) + ttsTemplate = template; + + settingsLoaded = true; + Debug.Log($"[WefLab] Alert settings loaded - popuptimeCap:{settingPopupTimeCap}s, ttsSpeed:{ttsSpeed}, template:\"{ttsTemplate}\", voiceUrl:{voiceUrlBase}"); + } + catch (Exception ex) + { + Debug.LogWarning($"[WefLab] Error parsing alert settings: {ex.Message}; falling back to alertDelay"); + } + } + } + + /// + /// Recursively find a setting value by key within the nested settings object (radio/checkbox/text/...). + /// + private static string FindSetting(JObject node, string key) + { + var direct = node[key]; + if (direct != null && direct.Type != JTokenType.Object && direct.Type != JTokenType.Array) + return direct.ToString(); + + foreach (var prop in node.Properties()) + { + if (prop.Value is JObject child) + { + string found = FindSetting(child, key); + if (found != null) + return found; + } + } + return null; + } + + /// + /// Build one PlatformConnection per linked platform from loginData.config.url. + /// Mirrors the weflab web client which opens socket.iop[platform] for each linked platform. + /// + private void BuildConnections(JObject loginData) + { + connections.Clear(); + + var urlConfig = loginData["config"]?["url"] as JObject; + var platforms = loginData["platform"] as JObject; + + if (urlConfig == null || platforms == null) + { + Debug.LogError("[WefLab] config.url or platform missing in loginData - cannot build connections"); return; } - // Build WebSocket URL - string wsUrl = $"wss://ssmain.weflab.com/socket.io/?idx={userIdx}&type=page&page=alert&EIO=4&transport=websocket"; + // One donation socket per linked platform (afreeca/naver/youtube/...). + // When chat is enabled, also open a second connection per platform on the chat page, + // since weflab subscribes alert and chat on separate pages. + foreach (var prop in platforms.Properties()) + { + string platform = prop.Name; + string host = urlConfig["socket_" + platform]?.ToString(); - Debug.Log($"[WefLab] Connecting to: {wsUrl}"); + if (string.IsNullOrEmpty(host)) + { + Debug.LogWarning($"[WefLab] No socket server for platform '{platform}' (socket_{platform} not in config.url) - skipping"); + continue; + } - ws = new WebSocket(wsUrl); + connections.Add(new PlatformConnection + { + platform = platform, + page = "alert", + wsUrl = BuildSocketUrl(host, "alert") + }); - // Event handlers - ws.OnOpen += OnWebSocketOpen; - ws.OnMessage += OnWebSocketMessage; - ws.OnError += OnWebSocketError; - ws.OnClose += OnWebSocketClose; + if (enableChat) + { + connections.Add(new PlatformConnection + { + platform = platform, + page = "chat", + wsUrl = BuildSocketUrl(host, "chat") + }); + } + } - // Connect - ws.ConnectAsync(); + // Optionally include the main control socket (ssmain) + if (connectMainSocket) + { + string mainHost = urlConfig["socket"]?.ToString(); + if (!string.IsNullOrEmpty(mainHost)) + { + connections.Add(new PlatformConnection + { + platform = "main", + page = "alert", + wsUrl = BuildSocketUrl(mainHost, "alert") + }); + } + } + + Debug.Log($"[WefLab] Prepared {connections.Count} socket connection(s): {string.Join(", ", connections.ConvertAll(c => c.Label))}"); } /// - /// Disconnect from WebSocket + /// Convert a config host (https://ssafreeca.weflab.com) into a full Socket.IO websocket URL + /// subscribed to the given page ("alert" or "chat"). + /// + private string BuildSocketUrl(string httpHost, string page) + { + string baseUrl = httpHost + .Replace("https://", "wss://") + .Replace("http://", "ws://") + .TrimEnd('/'); + + return $"{baseUrl}/socket.io/?idx={userIdx}&type=page&page={page}&EIO=4&transport=websocket"; + } + + /// + /// Open every prepared platform connection. + /// + public void ConnectAll() + { + foreach (var conn in connections) + { + ConnectOne(conn); + } + } + + /// + /// Backwards-compatible alias - connects to all platform sockets. + /// + public void Connect() + { + ConnectAll(); + } + + /// + /// Open a single platform connection and wire its event handlers. + /// + private void ConnectOne(PlatformConnection conn) + { + if (conn.ws != null && conn.ws.IsAlive) + { + Debug.LogWarning($"[WefLab] ({conn.Label}) Already connected"); + return; + } + + Debug.Log($"[WefLab] ({conn.Label}) Connecting to: {conn.wsUrl}"); + + conn.ws = new WebSocket(conn.wsUrl); + + // Capture conn in the handlers so each message knows which socket it came from + conn.ws.OnOpen += (sender, e) => + EnqueueMainThreadAction(() => Debug.Log($"[WefLab] ({conn.Label}) WebSocket connection opened")); + + conn.ws.OnMessage += (sender, e) => + { + string data = e.Data; + EnqueueMainThreadAction(() => ProcessMessage(conn, data)); + }; + + conn.ws.OnError += (sender, e) => + EnqueueMainThreadAction(() => + { + Debug.LogError($"[WefLab] ({conn.Label}) WebSocket error: {e.Message}"); + if (e.Exception != null) + Debug.LogError($"[WefLab] ({conn.Label}) Exception: {e.Exception}"); + }); + + conn.ws.OnClose += (sender, e) => + EnqueueMainThreadAction(() => + { + Debug.Log($"[WefLab] ({conn.Label}) WebSocket closed. Code: {e.Code}, Reason: {e.Reason}"); + conn.connected = false; + conn.engineSid = ""; + conn.socketSid = ""; + UpdateAggregateState(); + }); + + conn.ws.ConnectAsync(); + } + + /// + /// Disconnect every platform connection. /// public void Disconnect() { - if (ws != null) + foreach (var conn in connections) { - ws.Close(); - ws = null; + if (conn.ws != null) + { + conn.ws.Close(); + conn.ws = null; + } + + conn.connected = false; + conn.engineSid = ""; + conn.socketSid = ""; } - isConnected = false; - socketSid = ""; - currentSid = ""; + UpdateAggregateState(); } - #region WebSocket Event Handlers - - private void OnWebSocketOpen(object sender, EventArgs e) + /// + /// Refresh the aggregate inspector fields (isConnected / currentSid) from all connections. + /// + private void UpdateAggregateState() { - EnqueueMainThreadAction(() => + bool any = false; + string firstSid = ""; + + foreach (var c in connections) { - Debug.Log("[WefLab] WebSocket connection opened"); - }); + if (c.connected) + any = true; + if (string.IsNullOrEmpty(firstSid) && !string.IsNullOrEmpty(c.engineSid)) + firstSid = c.engineSid; + } + + isConnected = any; + currentSid = firstSid; } - private void OnWebSocketMessage(object sender, MessageEventArgs e) - { - string data = e.Data; - - EnqueueMainThreadAction(() => - { - ProcessMessage(data); - }); - } - - private void OnWebSocketError(object sender, ErrorEventArgs e) - { - EnqueueMainThreadAction(() => - { - Debug.LogError($"[WefLab] WebSocket error: {e.Message}"); - if (e.Exception != null) - { - Debug.LogError($"[WefLab] Exception: {e.Exception}"); - } - }); - } - - private void OnWebSocketClose(object sender, CloseEventArgs e) - { - EnqueueMainThreadAction(() => - { - Debug.Log($"[WefLab] WebSocket closed. Code: {e.Code}, Reason: {e.Reason}"); - isConnected = false; - socketSid = ""; - currentSid = ""; - }); - } - - #endregion - #region Message Processing /// - /// Process incoming WebSocket message + /// Process incoming WebSocket message for a given platform connection /// - private void ProcessMessage(string data) + private void ProcessMessage(PlatformConnection conn, string data) { if (string.IsNullOrEmpty(data)) return; @@ -375,27 +721,27 @@ namespace WefLab switch (messageType) { case "0": // Engine.IO OPEN - HandleEngineOpen(payload); + HandleEngineOpen(conn, payload); break; case "2": // Engine.IO PING - HandlePing(); + HandlePing(conn); break; case "3": // Engine.IO PONG - Debug.Log("[WefLab] Received PONG"); + Debug.Log($"[WefLab] ({conn.Label}) Received PONG"); break; case "40": // Socket.IO CONNECT - HandleSocketConnect(payload); + HandleSocketConnect(conn, payload); break; case "42": // Socket.IO EVENT - HandleSocketEvent(payload); + HandleSocketEvent(conn, payload); break; default: - Debug.Log($"[WefLab] Unknown message type: {messageType} | Data: {data}"); + Debug.Log($"[WefLab] ({conn.Label}) Unknown message type: {messageType} | Data: {data}"); break; } } @@ -403,69 +749,80 @@ namespace WefLab /// /// Handle Engine.IO OPEN message (handshake) /// - private void HandleEngineOpen(string payload) + private void HandleEngineOpen(PlatformConnection conn, string payload) { try { var openData = JsonConvert.DeserializeObject(payload); - currentSid = openData["sid"]?.ToString() ?? ""; + conn.engineSid = openData["sid"]?.ToString() ?? ""; if (openData["pingInterval"] != null) { pingInterval = openData["pingInterval"].Value() / 1000f; // Convert to seconds } - Debug.Log($"[WefLab] Engine.IO OPEN - SID: {currentSid}, PingInterval: {pingInterval}s"); - Debug.Log($"[WefLab] Full handshake data: {payload}"); + UpdateAggregateState(); + + Debug.Log($"[WefLab] ({conn.Label}) Engine.IO OPEN - SID: {conn.engineSid}, PingInterval: {pingInterval}s"); + Debug.Log($"[WefLab] ({conn.Label}) Full handshake data: {payload}"); // Send Socket.IO CONNECT - SendSocketConnect(); + SendSocketConnect(conn); } catch (Exception ex) { - Debug.LogError($"[WefLab] Error parsing OPEN message: {ex.Message}"); + Debug.LogError($"[WefLab] ({conn.Label}) Error parsing OPEN message: {ex.Message}"); } } /// /// Handle Engine.IO PING /// - private void HandlePing() + private void HandlePing(PlatformConnection conn) { - Debug.Log("[WefLab] Received PING, sending PONG"); - SendMessage("3"); // Send PONG + Debug.Log($"[WefLab] ({conn.Label}) Received PING, sending PONG"); + SendMessage(conn, "3"); // Send PONG } /// /// Handle Socket.IO CONNECT response /// - private void HandleSocketConnect(string payload) + private void HandleSocketConnect(PlatformConnection conn, string payload) { try { if (!string.IsNullOrEmpty(payload)) { var connectData = JsonConvert.DeserializeObject(payload); - socketSid = connectData["sid"]?.ToString() ?? ""; - Debug.Log($"[WefLab] Socket.IO CONNECT - SID: {socketSid}"); - Debug.Log($"[WefLab] Full connect data: {payload}"); + conn.socketSid = connectData["sid"]?.ToString() ?? ""; + Debug.Log($"[WefLab] ({conn.Label}) Socket.IO CONNECT - SID: {conn.socketSid}"); + Debug.Log($"[WefLab] ({conn.Label}) Full connect data: {payload}"); } - isConnected = true; + conn.connected = true; + UpdateAggregateState(); // Send join message - SendJoinMessage(); + SendJoinMessage(conn); } catch (Exception ex) { - Debug.LogError($"[WefLab] Error parsing Socket.IO CONNECT: {ex.Message}"); + Debug.LogError($"[WefLab] ({conn.Label}) Error parsing Socket.IO CONNECT: {ex.Message}"); } } + // Subtypes that are NOT monetary donations (subscribe/membership/follow/emoticon). + // value here is a month count or nothing, so they must not trigger amount-based events. + private static readonly HashSet NonMonetarySubtypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "subscribe", "vip", "follow", "follow_item", "follow_item_effect", "up", "emoticon", "chat" + }; + /// - /// Handle Socket.IO EVENT message (donation data) + /// Handle Socket.IO EVENT message. The weflab envelope is ["msg", { type, data, ... }] + /// where `type` is "alert" (donation), "chat", "info", etc. and `data` is the flat payload. /// - private void HandleSocketEvent(string payload) + private void HandleSocketEvent(PlatformConnection conn, string payload) { try { @@ -473,181 +830,285 @@ namespace WefLab if (eventArray == null || eventArray.Count < 2) { - Debug.LogWarning($"[WefLab] Invalid event format: {payload}"); + Debug.LogWarning($"[WefLab] ({conn.Label}) Invalid event format: {payload}"); return; } string eventName = eventArray[0].ToString(); - var eventData = eventArray[1] as JObject; + var envelope = eventArray[1] as JObject; - Debug.Log($"[WefLab] ========== EVENT RECEIVED =========="); - Debug.Log($"[WefLab] Event Name: {eventName}"); - Debug.Log($"[WefLab] Event Data: {eventData?.ToString(Formatting.Indented)}"); - Debug.Log($"[WefLab] ==================================="); + Debug.Log($"[WefLab] ===== EVENT ({conn.Label}) name={eventName} type={envelope?["type"]} ====="); if (eventName == "msg") { - HandleMessageEvent(eventData); + HandleMessageEnvelope(conn.platform, envelope); } } catch (Exception ex) { - Debug.LogError($"[WefLab] Error parsing Socket.IO EVENT: {ex.Message}"); + Debug.LogError($"[WefLab] ({conn.Label}) Error parsing Socket.IO EVENT: {ex.Message}"); Debug.LogError($"[WefLab] Payload: {payload}"); } } /// - /// Handle "msg" event (donation notifications) + /// Route a "msg" envelope by its outer type. fallbackPlatform is the socket's platform, + /// used when the flat payload itself does not carry a platform field. /// - private void HandleMessageEvent(JObject msgData) + private void HandleMessageEnvelope(string fallbackPlatform, JObject envelope) { - if (msgData == null) + if (envelope == null) return; - string msgType = msgData["type"]?.ToString() ?? ""; + string envType = envelope["type"]?.ToString() ?? ""; - switch (msgType) + // The real donation/chat payload is the flat `data` object. + // (Older/legacy flat messages fall back to the envelope itself.) + JObject data = envelope["data"] as JObject ?? envelope; + + switch (envType) { - case "test_donation": - HandleTestDonation(msgData); + case "alert": // donation (all platforms/subtypes) + case "test_donation": // legacy flat test donation + HandleAlert(fallbackPlatform, data); break; - case "donation": - case "SENDBALLOON": - case "cheese": - case "superchat": - case "bits": - HandleDonation(msgData); + case "chat": // normal chat message + case "warn": // connection warning (password/subonly/donationoff ...) + HandleChat(fallbackPlatform, envType, data); break; default: - Debug.Log($"[WefLab] Unhandled message type: {msgType}"); - Debug.Log($"[WefLab] Data: {msgData.ToString(Formatting.Indented)}"); + Debug.Log($"[WefLab] ({fallbackPlatform}) Ignored envelope type: '{envType}'"); break; } } /// - /// Normalize donation amount to unified currency (Chzzk standard: 1 = 1 KRW) - /// SOOP (Afreeca): 1 balloon = 100 KRW → multiply by 100 - /// Chzzk: 1 cheese = 1 KRW → no conversion - /// YouTube: Keep as-is (already in KRW equivalent) - /// Twitch: Keep as-is + /// Convert a platform's raw donation value to KRW. + /// Prefers the server-provided currency.real (already KRW); otherwise applies the + /// per-platform conversion derived from weflab's client (comm.donation.currency). /// - private static int NormalizeDonationAmount(string platform, int rawAmount) + private static int ConvertToKrw(string platform, JObject data, out int rawValue) { - platform = platform.ToLower(); + rawValue = 0; + if (data["value"] != null) + int.TryParse(data["value"].ToString(), out rawValue); - if (platform == "afreeca" || platform == "soop") + // 1) Trust server-provided KRW when present (naver/youtube/twitch send this) + if (data["currency"] is JObject currency && currency["real"] != null + && int.TryParse(currency["real"].ToString(), out int realKrw)) { - // SOOP: 1 balloon = 100 KRW - return rawAmount * 100; + return realKrw; } - // Chzzk, YouTube, Twitch: no conversion needed - return rawAmount; + // 2) Per-platform fallback conversion + switch ((platform ?? "").ToLowerInvariant()) + { + case "afreeca": + case "soop": + case "soopg": + return rawValue * 100; // 1 balloon / gem = 100 KRW + + case "twitch": + return rawValue / 10; // bits ??KRW (approx; currency.real preferred) + + case "naver": // chzzk cheese + case "chzzk": + case "youtube": // superchat + case "cime": + case "extdona": + return rawValue / 100; // raw value is in 1/100 KRW units + + default: + return rawValue; + } } /// - /// Handle test donation event + /// Handle an "alert" payload (a monetary donation on any platform). /// - private void HandleTestDonation(JObject msgData) + private void HandleAlert(string fallbackPlatform, JObject data) { - if (msgData["data"] is not JObject donationDataJson) + if (data == null) return; - string platform = donationDataJson["platform"]?.ToString() ?? "unknown"; - string type = donationDataJson["type"]?.ToString() ?? "unknown"; - int rawAmount = 0; + string platform = data["platform"]?.ToString() ?? fallbackPlatform; + string subtype = data["subtype"]?.ToString() ?? ""; - // Parse amount - if (int.TryParse(donationDataJson["value"]?.ToString(), out int parsedAmount)) + int amount = ConvertToKrw(platform, data, out int rawValue); + + // Skip non-monetary alerts (subscribe / membership / follow / emoticon, value = 0, ...) + if (NonMonetarySubtypes.Contains(subtype) || amount <= 0) { - rawAmount = parsedAmount; + Debug.Log($"[WefLab] ({platform}) Non-monetary alert ignored (subtype:'{subtype}', value:{rawValue}, krw:{amount})"); + return; } - // Normalize amount based on platform - int amount = NormalizeDonationAmount(platform, rawAmount); + string message = data["msg"]?.ToString() ?? data["message"]?.ToString() ?? ""; + string donorName = data["name"]?.ToString() ?? data["id"]?.ToString() ?? "Anonymous"; + string donorId = data["id"]?.ToString() ?? ""; + long timestamp = data["time"]?.Value() ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - string message = donationDataJson["msg"]?.ToString() ?? ""; - long timestamp = donationDataJson["time"]?.Value() ?? 0; - - Debug.Log($"[WefLab] *** TEST DONATION ***"); - Debug.Log($"[WefLab] Platform: {platform}"); - Debug.Log($"[WefLab] Type: {type}"); - Debug.Log($"[WefLab] Raw Amount: {rawAmount} → Normalized: {amount} KRW"); - Debug.Log($"[WefLab] Message: {message}"); - Debug.Log($"[WefLab] Timestamp: {timestamp}"); - - // Create donation data and trigger events DonationData donation = new() { platform = platform, - donationType = type, - amount = amount, - message = message, - donorName = "Test Donor", - timestamp = timestamp - }; - - EnqueueDonation(donation); - } - - /// - /// Handle real donation event - /// - private void HandleDonation(JObject msgData) - { - Debug.Log($"[WefLab] *** REAL DONATION ***"); - Debug.Log($"[WefLab] Full donation data: {msgData.ToString(Formatting.Indented)}"); - - if (msgData["data"] is not JObject donationDataJson) - return; - - string platform = msgData["platform"]?.ToString() ?? "unknown"; - string type = msgData["type"]?.ToString() ?? "unknown"; - int rawAmount = 0; - - // Parse amount from different possible fields - if (donationDataJson["value"] != null && int.TryParse(donationDataJson["value"].ToString(), out int parsedValue)) - { - rawAmount = parsedValue; - } - else if (donationDataJson["amount"] != null && int.TryParse(donationDataJson["amount"].ToString(), out int parsedAmount)) - { - rawAmount = parsedAmount; - } - - // Normalize amount based on platform - int amount = NormalizeDonationAmount(platform, rawAmount); - - string message = donationDataJson["msg"]?.ToString() ?? donationDataJson["message"]?.ToString() ?? ""; - string donorName = donationDataJson["nickname"]?.ToString() ?? donationDataJson["name"]?.ToString() ?? "Anonymous"; - long timestamp = donationDataJson["time"]?.Value() ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - // Create donation data - DonationData donation = new() - { - platform = platform, - donationType = type, + donationType = subtype, + subtype = subtype, amount = amount, + rawValue = rawValue, message = message, donorName = donorName, + donorId = donorId, timestamp = timestamp }; - Debug.Log($"[WefLab] Donation: {donorName} - {rawAmount} → {amount} KRW ({platform})"); + Debug.Log($"[WefLab] DONATION ({platform}/{subtype}): {donorName} | raw {rawValue} ??{amount} KRW | {message}"); - // Enqueue donation for sequential processing EnqueueDonation(donation); } + /// + /// Handle a "chat" payload. Structured and wired through onChatReceived for later use; + /// no-op unless enableChat is turned on. + /// + private void HandleChat(string fallbackPlatform, string envType, JObject data) + { + if (!enableChat || data == null) + return; + + string platform = data["platform"]?.ToString() ?? fallbackPlatform; + string type = data["type"]?.ToString() ?? envType; + string subtype = data["subtype"]?.ToString() ?? ""; + bool isWarn = envType == "warn" || type == "warn"; + var grade = data["grade"] as JObject; + + ChatData chat = new() + { + platform = platform, + type = type, + subtype = subtype, + nickname = data["name"]?.ToString() ?? data["id"]?.ToString() ?? "", + userId = data["id"]?.ToString() ?? "", + message = data["msg"]?.ToString() ?? "", + nickColor = data["color"]?.ToString() ?? "", + isStreamer = HasGrade(grade, "streamer"), + isManager = HasGrade(grade, "manager"), + isFan = HasGrade(grade, "fan"), + isSubscriber = HasGrade(grade, "subscribe"), + isVip = HasGrade(grade, "vip"), + badges = ParseBadges(grade), + emoticons = ParseEmoticons(data["emoticon"]), + isWarn = isWarn, + warnType = isWarn ? subtype : "", + timestamp = data["time"]?.Value() ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + + if (isWarn) + Debug.Log($"[WefLab] CHAT WARN ({platform}): {chat.warnType}"); + else + Debug.Log($"[WefLab] CHAT ({platform}): {chat.nickname}: {chat.message}" + + (chat.emoticons.Count > 0 ? $" [+{chat.emoticons.Count} emoticon]" : "")); + + onChatReceived?.Invoke(chat); + } + + /// + /// True if a grade flag is set. weflab marks grades as booleans (streamer/manager/fan/subscribe) + /// or as a value such as an image URL (vip) - presence of the key counts as set. + /// + private static bool HasGrade(JObject grade, string key) + { + if (grade == null) + return false; + + var token = grade[key]; + if (token == null || token.Type == JTokenType.Null) + return false; + + if (token.Type == JTokenType.Boolean) + return token.Value(); + + // Non-null, non-bool (e.g. a url string) => present/true + return true; + } + + /// + /// Extract platform badge ids from data.grade.badge (an array of strings). + /// + private static string[] ParseBadges(JObject grade) + { + if (grade?["badge"] is not JArray badgeArray || badgeArray.Count == 0) + return Array.Empty(); + + var list = new List(); + foreach (var b in badgeArray) + { + string s = b?.ToString(); + if (!string.IsNullOrEmpty(s)) + list.Add(s); + } + return list.ToArray(); + } + + /// + /// Parse data.emoticon into a flat list. weflab uses a map { name: [count, imageUrl, type], ... }; + /// an array form [ [name?, imageUrl], ... ] is also handled defensively. + /// + private static List ParseEmoticons(JToken token) + { + var result = new List(); + if (token == null) + return result; + + if (token is JObject map) + { + // { "name": [count, img, type], ... } + foreach (var prop in map.Properties()) + { + string img = null; + if (prop.Value is JArray arr && arr.Count >= 2) + img = arr[1]?.ToString(); + else if (prop.Value is JObject obj) + img = obj["img"]?.ToString() ?? obj["imageUrl"]?.ToString(); + + if (!string.IsNullOrEmpty(img)) + result.Add(new ChatEmoticon { name = prop.Name, imageUrl = img }); + } + } + else if (token is JArray list) + { + foreach (var item in list) + { + if (item is JArray arr && arr.Count >= 2) + result.Add(new ChatEmoticon { name = arr[0]?.ToString(), imageUrl = arr[1]?.ToString() }); + else if (item is JObject obj) + { + string img = obj["img"]?.ToString() ?? obj["imageUrl"]?.ToString(); + if (!string.IsNullOrEmpty(img)) + result.Add(new ChatEmoticon { name = obj["name"]?.ToString(), imageUrl = img }); + } + } + } + + return result; + } + /// /// Enqueue donation for sequential processing /// private void EnqueueDonation(DonationData donation) { + // Drop duplicates that arrive on more than one socket within the window + if (IsDuplicateDonation(donation)) + { + Debug.Log($"[WefLab] Duplicate donation ignored: {donation.donorName} {donation.amount} ({donation.platform})"); + return; + } + + // Immediate notification (before queue/lead-in) for instant side effects (e.g. running totals) + onDonationReceived?.Invoke(donation); + if (enableQueue) { // Check queue size limit @@ -675,7 +1136,42 @@ namespace WefLab } /// - /// Process donation queue sequentially with fixed delay + /// Returns true if a donation with an identical signature was already seen within duplicateWindow. + /// Guards against the same alert being delivered on more than one socket. + /// + private bool IsDuplicateDonation(DonationData donation) + { + if (duplicateWindow <= 0f) + return false; + + float now = Time.time; + string signature = $"{donation.platform}|{donation.donationType}|{donation.amount}|{donation.donorName}|{donation.timestamp}"; + + // Purge stale entries so the dictionary does not grow unbounded + if (recentDonations.Count > 0) + { + var stale = new List(); + foreach (var kvp in recentDonations) + { + if (now - kvp.Value > duplicateWindow) + stale.Add(kvp.Key); + } + foreach (var key in stale) + recentDonations.Remove(key); + } + + if (recentDonations.TryGetValue(signature, out float lastSeen) && now - lastSeen < duplicateWindow) + return true; + + recentDonations[signature] = now; + return false; + } + + /// + /// Process donation queue sequentially, mirroring the weflab overlay's one-at-a-time pacing. + /// Precise mode (syncWithAlertAudio): at the front of the queue, fetch the same TTS audio weflab + /// synthesizes, fire the event when the fetch completes (= overlay reveal), then hold for the + /// measured audio length. Fallback mode: alertLeadTime -> trigger -> alertDelay. /// private IEnumerator ProcessDonationQueue() { @@ -686,19 +1182,191 @@ namespace WefLab var donation = donationQueue.Dequeue(); queueCount = donationQueue.Count; - // Trigger events - TriggerDonationEvents(donation); + bool handled = false; - Debug.Log($"[WefLab] Alert triggered. Waiting {alertDelay:F1}s. Remaining in queue: {donationQueue.Count}"); + if (syncWithAlertAudio && settingsLoaded && !string.IsNullOrEmpty(voiceUrlBase)) + { + // Fetch the TTS audio (this also consumes the same synthesis latency the overlay sees, + // so completion time ~= the overlay's popupshow/reveal moment). + var audio = new AudioFetchResult(); + yield return StartCoroutine(FetchTtsDuration(ComposeTtsText(donation), audio)); - // Wait for fixed delay - yield return new WaitForSeconds(alertDelay); + if (audio.success) + { + // Small constant trim for TTS cache skew / decode + if (audioStartOffset > 0f) + yield return new WaitForSeconds(audioStartOffset); + + // Reveal moment + TriggerDonationEvents(donation); + + float hold = Mathf.Min(audio.length, settingPopupTimeCap); + Debug.Log($"[WefLab] Alert revealed (auto offset {audio.fetchSeconds:F2}s +{audioStartOffset:F2}). Audio {audio.length:F2}s, holding {hold:F2}s. Remaining: {donationQueue.Count}"); + + // Hold for the audio length; gap before next alert appears (overlay's inter-alert pause) + yield return new WaitForSeconds(hold + interAlertGap); + handled = true; + } + else + { + Debug.LogWarning($"[WefLab] TTS fetch failed ({audio.error}); using fallback timing for this alert"); + } + } + + if (!handled) + { + // Fallback: fixed lead-in + display time + if (alertLeadTime > 0f) + yield return new WaitForSeconds(alertLeadTime); + + TriggerDonationEvents(donation); + + Debug.Log($"[WefLab] Alert triggered (fallback, lead {alertLeadTime:F1}s, hold {alertDelay:F1}s). Remaining: {donationQueue.Count}"); + yield return new WaitForSeconds(alertDelay); + } } isProcessingQueue = false; queueProcessorCoroutine = null; } + /// Result holder for the async TTS fetch. + private class AudioFetchResult + { + public bool success; + public float length; // measured audio clip length (seconds) + public float fetchSeconds; // wall-clock spent fetching (~= overlay reveal offset) + public string error; + } + + /// + /// Replicate weflab's TTS text: the alert template with tokens replaced, plus the donor message. + /// + private string ComposeTtsText(DonationData donation) + { + string label = LabelFor(donation.platform, donation.subtype); + string unit = UnitFor(donation.platform, donation.subtype); + string count = donation.rawValue > 0 ? donation.rawValue.ToString() : ""; + + string text = ttsTemplate ?? ""; + text = Regex.Replace(text, @"\{닉네임\}|\{이름\}|\{nickname\}", donation.donorName ?? ""); + text = Regex.Replace(text, @"\{종류\}|\{type\}", label); + text = Regex.Replace(text, @"\{개수\}|\{개월\}|\{number\}", count); + text = Regex.Replace(text, @"\{개\}|\{unit\}", unit); + // Strip any remaining unreplaced tokens + text = Regex.Replace(text, @"\{[^}]*\}", ""); + text = Regex.Replace(text, @"\s+", " ").Trim(); + + // weflab reads the donor message after the alert template (signsound voice) + if (!string.IsNullOrEmpty(donation.message)) + text = string.IsNullOrEmpty(text) ? donation.message : text + " " + donation.message; + + return text; + } + + private static string LabelFor(string platform, string subtype) + { + switch ((subtype ?? "").ToUpperInvariant()) + { + case "SENDBALLOON": + case "VIDEOBALLOON": + case "ADBALLOON": return "별풍선"; + case "GEM": return "젬"; + case "CHZZK": return "치즈"; + case "SUPERCHAT": return "슈퍼챗"; + case "STICKER": return "스티커"; + case "BITS": return "비트"; + case "BEAM": return "빔"; + } + // Platform fallback + switch ((platform ?? "").ToLowerInvariant()) + { + case "afreeca": + case "soop": + case "soopg": return "별풍선"; + case "naver": + case "chzzk": return "치즈"; + case "youtube": return "슈퍼챗"; + case "twitch": return "비트"; + default: return "후원"; + } + } + + private static string UnitFor(string platform, string subtype) + { + switch ((platform ?? "").ToLowerInvariant()) + { + case "naver": + case "chzzk": + case "youtube": return "원"; + default: return "개"; // balloons / bits / gem + } + } + + /// + /// Fetch weflab's TTS for the given text and measure the resulting audio clip length. + /// The wall-clock spent here approximates the overlay's synthesis-to-reveal latency. + /// + private IEnumerator FetchTtsDuration(string text, AudioFetchResult result) + { + float t0 = Time.realtimeSinceStartup; + + if (string.IsNullOrEmpty(text)) + { + result.error = "empty text"; + yield break; + } + + // 1) Resolve the MP3 URL: GET voiceurl?text=...&speed=...&lang=... + string reqUrl = $"{voiceUrlBase}?text={UnityWebRequest.EscapeURL(text)}&speed={ttsSpeed}&lang={ttsLang}"; + string mp3Url; + using (UnityWebRequest urlReq = UnityWebRequest.Get(reqUrl)) + { + urlReq.SetRequestHeader("Referer", pageUrl); + yield return urlReq.SendWebRequest(); + + if (urlReq.result != UnityWebRequest.Result.Success) + { + result.error = $"voiceurl: {urlReq.error}"; + yield break; + } + + mp3Url = urlReq.downloadHandler.text?.Trim().Trim('"'); + } + + if (string.IsNullOrEmpty(mp3Url)) + { + result.error = "empty mp3 url"; + yield break; + } + if (!mp3Url.StartsWith("http")) + mp3Url = baseDomain + (mp3Url.StartsWith("/") ? "" : "/") + mp3Url; + + // 2) Download the MP3 as an AudioClip and read its length + using (UnityWebRequest clipReq = UnityWebRequestMultimedia.GetAudioClip(mp3Url, AudioType.MPEG)) + { + clipReq.SetRequestHeader("Referer", pageUrl); + yield return clipReq.SendWebRequest(); + + if (clipReq.result != UnityWebRequest.Result.Success) + { + result.error = $"mp3: {clipReq.error}"; + yield break; + } + + AudioClip clip = DownloadHandlerAudioClip.GetContent(clipReq); + if (clip == null || clip.length <= 0f) + { + result.error = "invalid clip"; + yield break; + } + + result.length = clip.length; + result.fetchSeconds = Time.realtimeSinceStartup - t0; + result.success = true; + } + } + /// /// Trigger donation events based on amount range /// @@ -761,23 +1429,23 @@ namespace WefLab /// /// Send Socket.IO CONNECT message (40) /// - private void SendSocketConnect() + private void SendSocketConnect(PlatformConnection conn) { - Debug.Log("[WefLab] Sending Socket.IO CONNECT (40)"); - SendMessage("40"); + Debug.Log($"[WefLab] ({conn.Label}) Sending Socket.IO CONNECT (40)"); + SendMessage(conn, "40"); } /// - /// Send join message to subscribe to donation events + /// Send join message to subscribe to this connection's page (alert donations or chat). /// - private void SendJoinMessage() + private void SendJoinMessage(PlatformConnection conn) { var joinData = new { type = "join", page = "page", idx = userIdx, - pageid = "alert", + pageid = conn.page, // "alert" or "chat" preset = "0" }; @@ -785,22 +1453,22 @@ namespace WefLab string json = JsonConvert.SerializeObject(message); string fullMessage = "42" + json; - Debug.Log($"[WefLab] Sending JOIN message: {fullMessage}"); - SendMessage(fullMessage); + Debug.Log($"[WefLab] ({conn.Label}) Sending JOIN message: {fullMessage}"); + SendMessage(conn, fullMessage); } /// - /// Send raw message through WebSocket + /// Send raw message through a specific platform connection /// - private void SendMessage(string message) + private void SendMessage(PlatformConnection conn, string message) { - if (ws != null && ws.IsAlive) + if (conn.ws != null && conn.ws.IsAlive) { - ws.Send(message); + conn.ws.Send(message); } else { - Debug.LogWarning("[WefLab] Cannot send message - WebSocket not connected"); + Debug.LogWarning($"[WefLab] ({conn.Label}) Cannot send message - WebSocket not connected"); } } @@ -837,7 +1505,12 @@ namespace WefLab /// public bool IsConnected() { - return isConnected && ws != null && ws.IsAlive; + foreach (var conn in connections) + { + if (conn.connected && conn.ws != null && conn.ws.IsAlive) + return true; + } + return false; } /// @@ -918,3 +1591,4 @@ namespace WefLab #endregion } } +