diff --git a/.gitignore b/.gitignore index fab0a65..b9b4d84 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,15 @@ ExportedObj/ *.VC.db *.vsconfig +# Claude AI related files +CLAUDE.md +claude.md +*CLAUDE.md +*claude.md +.claude/ +claude-* +*claude* + # Unity 메타 파일 *.pidb.meta *.pdb.meta @@ -121,3 +130,4 @@ InitTestScene*.unity* /Assets/Plugins/WebGLTemplates /Assets/Domain/Character/Model/Chikuwa/ziraitikuwa /Assets/Domain/Character/Model/Chikuwa/model +/WebGL Builds diff --git a/Assets/App/Scenes/MainScene.unity b/Assets/App/Scenes/MainScene.unity index c64d012..ed5b236 100644 --- a/Assets/App/Scenes/MainScene.unity +++ b/Assets/App/Scenes/MainScene.unity @@ -130,6 +130,127 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 1aa08ab6e0800fa44ae55d278d1423e3, type: 3} m_Name: m_EditorClassIdentifier: +--- !u!1 &73955217 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 73955221} + - component: {fileID: 73955220} + - component: {fileID: 73955219} + - component: {fileID: 73955218} + m_Layer: 5 + m_Name: Button + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &73955218 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 73955217} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 73955219} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!114 &73955219 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 73955217} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!222 &73955220 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 73955217} + m_CullTransparentMesh: 1 +--- !u!224 &73955221 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 73955217} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1394009831} + m_Father: {fileID: 149056741} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.5, y: 1} + m_AnchorMax: {x: 0.5, y: 1} + m_AnchoredPosition: {x: -462.43, y: -337} + m_SizeDelta: {x: 245.1334, y: 93.1072} + m_Pivot: {x: 0.5, y: 0.5} --- !u!1 &133449153 GameObject: m_ObjectHideFlags: 0 @@ -209,6 +330,51 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &149056740 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 149056741} + - component: {fileID: 149056742} + m_Layer: 5 + m_Name: Image + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &149056741 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 149056740} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 73955221} + m_Father: {fileID: 1145326587} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &149056742 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 149056740} + m_CullTransparentMesh: 1 --- !u!1 &322172995 GameObject: m_ObjectHideFlags: 0 @@ -407,6 +573,73 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &364439184 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 364439186} + - component: {fileID: 364439187} + - component: {fileID: 364439188} + m_Layer: 0 + m_Name: GameObject + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &364439186 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 364439184} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &364439187 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 364439184} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: b273f87c4feba7c45b007339de53f770, type: 3} + m_Name: + m_EditorClassIdentifier: + showDebugUI: 1 +--- !u!114 &364439188 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 364439184} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 59d58c6b995b5494abd828406d93edee, type: 3} + m_Name: + m_EditorClassIdentifier: + loginButton: {fileID: 73955218} + logoutButton: {fileID: 0} + refreshButton: {fileID: 0} + clearButton: {fileID: 0} + checkButton: {fileID: 0} + statusText: {fileID: 0} + userInfoText: {fileID: 0} + debugText: {fileID: 0} + oauth2Config: {fileID: 0} --- !u!1 &396605756 stripped GameObject: m_CorrespondingSourceObject: {fileID: 7836491653151255046, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} @@ -709,8 +942,7 @@ MonoBehaviour: m_EditorClassIdentifier: _chatBubblePanel: {fileID: 2017944365} _characterManager: {fileID: 873552998} - _characterId: 44444444-4444-4444-4444-444444444444 - _userId: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb + _characterId: 22222222-2222-2222-2222-222222222222 --- !u!4 &829067253 Transform: m_ObjectHideFlags: 0 @@ -861,7 +1093,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 8165641762294667086, guid: cd9756d05ff7ef847b3abde3e768a611, type: 3} propertyPath: m_AnchorMax.x - value: 1 + value: 0.5 objectReference: {fileID: 0} - target: {fileID: 8165641762294667086, guid: cd9756d05ff7ef847b3abde3e768a611, type: 3} propertyPath: m_AnchorMax.y @@ -869,7 +1101,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 8165641762294667086, guid: cd9756d05ff7ef847b3abde3e768a611, type: 3} propertyPath: m_AnchorMin.x - value: 0 + value: 0.5 objectReference: {fileID: 0} - target: {fileID: 8165641762294667086, guid: cd9756d05ff7ef847b3abde3e768a611, type: 3} propertyPath: m_AnchorMin.y @@ -877,7 +1109,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 8165641762294667086, guid: cd9756d05ff7ef847b3abde3e768a611, type: 3} propertyPath: m_SizeDelta.x - value: -440 + value: 1000 objectReference: {fileID: 0} - target: {fileID: 8165641762294667086, guid: cd9756d05ff7ef847b3abde3e768a611, type: 3} propertyPath: m_SizeDelta.y @@ -965,12 +1197,12 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1087467994} serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 597.223, y: 1244.2465, z: -33.809093} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] - m_Father: {fileID: 1104447313} + m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1087467996 MonoBehaviour: @@ -989,7 +1221,7 @@ MonoBehaviour: _logContentParent: {fileID: 2076648994} _logEntryPrefab: {fileID: 6280972447933324029, guid: d74792089ff38e04a9a7c41def8766bb, type: 3} _clearButton: {fileID: 0} - _toggleButton: {fileID: 0} + _toggleButton: {fileID: 1871102646} _filterInput: {fileID: 0} _settings: {fileID: 11400000, guid: 8e5ce280818bafb45bd699331eb688e4, type: 2} --- !u!1 &1104447311 @@ -1021,8 +1253,7 @@ Transform: m_LocalPosition: {x: 597.223, y: 1244.2465, z: -33.809093} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 - m_Children: - - {fileID: 1087467995} + m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!114 &1104447314 @@ -1038,7 +1269,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: _webSocketManager: {fileID: 0} - _sessionManager: {fileID: 0} _httpApiClient: {fileID: 0} _audioManager: {fileID: 0} _loadingManager: {fileID: 0} @@ -1145,6 +1375,8 @@ RectTransform: - {fileID: 1274474669} - {fileID: 981746805} - {fileID: 396605757} + - {fileID: 149056741} + - {fileID: 1871102645} m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} @@ -1199,6 +1431,142 @@ MonoBehaviour: _audioSource: {fileID: 0} _audioMixerGroup: {fileID: 0} _poolSize: 10 +--- !u!1 &1222649206 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1222649207} + - component: {fileID: 1222649209} + - component: {fileID: 1222649208} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1222649207 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1222649206} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1871102645} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &1222649208 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1222649206} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: Button + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2} + m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281479730 + m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 24 + m_fontSizeBase: 24 + m_fontWeight: 400 + m_enableAutoSizing: 0 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!222 &1222649209 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1222649206} + m_CullTransparentMesh: 1 --- !u!224 &1274474669 stripped RectTransform: m_CorrespondingSourceObject: {fileID: 8165641762294667086, guid: cd9756d05ff7ef847b3abde3e768a611, type: 3} @@ -1230,7 +1598,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchorMin.x - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchorMin.y @@ -1238,7 +1606,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_SizeDelta.x - value: 0 + value: 651.4789 objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_SizeDelta.y @@ -1274,7 +1642,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchoredPosition.x - value: 0 + value: -325.73926 objectReference: {fileID: 0} - target: {fileID: 2925110611670761417, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchoredPosition.y @@ -1294,7 +1662,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 5385075895113204022, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_Value - value: 1 + value: 0 objectReference: {fileID: 0} - target: {fileID: 7067315487710106426, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_AnchorMax.x @@ -1304,10 +1672,18 @@ PrefabInstance: propertyPath: m_AnchorMax.y value: 1 objectReference: {fileID: 0} + - target: {fileID: 7067315487710106426, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} + propertyPath: m_AnchorMin.x + value: 0 + objectReference: {fileID: 0} - target: {fileID: 7067315487710106426, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_SizeDelta.x value: -17 objectReference: {fileID: 0} + - target: {fileID: 7067315487710106426, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} + propertyPath: m_AnchoredPosition.x + value: 0 + objectReference: {fileID: 0} - target: {fileID: 7836491653151255046, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} propertyPath: m_Name value: ConsolePanel @@ -1329,6 +1705,229 @@ PrefabInstance: m_AddedGameObjects: [] m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: 4ad5b82186255a54aafec3328e7a1737, type: 3} +--- !u!1 &1389169656 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1389169657} + - component: {fileID: 1389169658} + m_Layer: 0 + m_Name: Background + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &1389169657 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1389169656} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 28.531504, y: 22.2846, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!212 &1389169658 +SpriteRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1389169656} + m_Enabled: 1 + m_CastShadows: 0 + m_ReceiveShadows: 0 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: a97c105638bdf8b4a8650670310a4cd3, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 0 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_Sprite: {fileID: 10907, guid: 0000000000000000f000000000000000, type: 0} + m_Color: {r: 0, g: 0, b: 0, a: 1} + m_FlipX: 0 + m_FlipY: 0 + m_DrawMode: 0 + m_Size: {x: 0.16, y: 0.16} + m_AdaptiveModeThreshold: 0.5 + m_SpriteTileMode: 0 + m_WasSpriteAssigned: 1 + m_MaskInteraction: 0 + m_SpriteSortPoint: 0 +--- !u!1 &1394009830 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1394009831} + - component: {fileID: 1394009833} + - component: {fileID: 1394009832} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1394009831 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1394009830} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 73955221} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &1394009832 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1394009830} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: Button + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2} + m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281479730 + m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 24 + m_fontSizeBase: 24 + m_fontWeight: 400 + m_enableAutoSizing: 0 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!222 &1394009833 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1394009830} + m_CullTransparentMesh: 1 --- !u!114 &1411584568 stripped MonoBehaviour: m_CorrespondingSourceObject: {fileID: 5859485178518072226, guid: 4e9594e3adccfc04793a3b9f79181ebd, type: 3} @@ -1457,6 +2056,127 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 2da0c512f12947e489f739169773d7ca, type: 3} m_Name: m_EditorClassIdentifier: +--- !u!1 &1871102644 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1871102645} + - component: {fileID: 1871102648} + - component: {fileID: 1871102647} + - component: {fileID: 1871102646} + m_Layer: 5 + m_Name: Button + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1871102645 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1871102644} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1222649207} + m_Father: {fileID: 1145326587} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.5, y: 1} + m_AnchorMax: {x: 0.5, y: 1} + m_AnchoredPosition: {x: 426.5, y: -37.551} + m_SizeDelta: {x: 243.7604, y: 75.1018} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &1871102646 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1871102644} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 1871102647} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!114 &1871102647 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1871102644} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!222 &1871102648 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1871102644} + m_CullTransparentMesh: 1 --- !u!114 &2017944365 stripped MonoBehaviour: m_CorrespondingSourceObject: {fileID: 7731201516897828228, guid: 290ac1b848f75584f9077b5dd03337f6, type: 3} @@ -1575,6 +2295,7 @@ SceneRoots: m_ObjectHideFlags: 0 m_Roots: - {fileID: 322172998} + - {fileID: 1087467995} - {fileID: 1104447313} - {fileID: 1145326587} - {fileID: 829067253} @@ -1582,3 +2303,5 @@ SceneRoots: - {fileID: 332900996} - {fileID: 622824879} - {fileID: 873552999} + - {fileID: 364439186} + - {fileID: 1389169657} diff --git a/Assets/Editor.meta b/Assets/Core/Attribute.meta similarity index 77% rename from Assets/Editor.meta rename to Assets/Core/Attribute.meta index 0baaa73..e97ab69 100644 --- a/Assets/Editor.meta +++ b/Assets/Core/Attribute.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: c880b556fd897fc4b8ff8fec8f532d2f +guid: 1794da2da2137f4498020db26b5d93fb folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Assets/Core/Attribute/ReadOnlyAttribute.cs b/Assets/Core/Attribute/ReadOnlyAttribute.cs new file mode 100644 index 0000000..c5c68b1 --- /dev/null +++ b/Assets/Core/Attribute/ReadOnlyAttribute.cs @@ -0,0 +1,3 @@ +using UnityEngine; + +public class ReadOnlyAttribute : PropertyAttribute { } diff --git a/Assets/Core/Attribute/ReadOnlyAttribute.cs.meta b/Assets/Core/Attribute/ReadOnlyAttribute.cs.meta new file mode 100644 index 0000000..9a6dea6 --- /dev/null +++ b/Assets/Core/Attribute/ReadOnlyAttribute.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1086dd3147594bf4d852540670558430 \ No newline at end of file diff --git a/Assets/Core/Attribute/ReadOnlyDrawer.cs b/Assets/Core/Attribute/ReadOnlyDrawer.cs new file mode 100644 index 0000000..1eb0c68 --- /dev/null +++ b/Assets/Core/Attribute/ReadOnlyDrawer.cs @@ -0,0 +1,15 @@ +#if UNITY_EDITOR +using UnityEngine; +using UnityEditor; + +[CustomPropertyDrawer(typeof(ReadOnlyAttribute))] +public class ReadOnlyDrawer : PropertyDrawer +{ + public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) + { + GUI.enabled = false; + EditorGUI.PropertyField(position, property, label, true); + GUI.enabled = true; + } +} +#endif diff --git a/Assets/Core/Attribute/ReadOnlyDrawer.cs.meta b/Assets/Core/Attribute/ReadOnlyDrawer.cs.meta new file mode 100644 index 0000000..7293b46 --- /dev/null +++ b/Assets/Core/Attribute/ReadOnlyDrawer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d65e5602e59c87544a23313f75c9b504 \ No newline at end of file diff --git a/Assets/Core/Audio/AudioRecorder.cs b/Assets/Core/Audio/AudioRecorder.cs index 2f65c7e..8768ed8 100644 --- a/Assets/Core/Audio/AudioRecorder.cs +++ b/Assets/Core/Audio/AudioRecorder.cs @@ -1,4 +1,5 @@ #nullable enable +#if !UNITY_WEBGL || UNITY_EDITOR using System; using UnityEngine; using System.Collections.Generic; @@ -36,7 +37,12 @@ public class AudioRecorder : Singleton // 프로퍼티 public bool IsRecording => _isRecording; public float RecordingDuration => _isRecording ? Time.time - _recordingStartTime : 0f; - public bool IsRecordingAvailable => Microphone.devices.Length > 0; + public bool IsRecordingAvailable => +#if UNITY_WEBGL && !UNITY_EDITOR + false; +#else + Microphone.devices.Length > 0; +#endif public float RecordingProgress => _isRecording ? Mathf.Clamp01(RecordingDuration / _maxRecordingLength) : 0f; #region Unity Lifecycle @@ -98,7 +104,14 @@ public bool StartRecording() _isRecording = true; _recordingStartTime = Time.time; +#if UNITY_WEBGL && !UNITY_EDITOR + Debug.LogWarning("[AudioRecorder] WebGL에서는 마이크로폰이 지원되지 않습니다."); + OnError?.Invoke("WebGL에서는 음성 녹음이 지원되지 않습니다."); + _isRecording = false; + return false; +#else _recordingClip = Microphone.Start(_currentDevice ?? string.Empty, false, _maxRecordingLength, _sampleRate); +#endif if (_recordingClip == null) { _isRecording = false; @@ -148,12 +161,10 @@ public bool StartRecording() { Debug.Log($"[AudioRecorder] 음성 녹음 완료됨 ({actualRecordingDuration:F1}초, {processedClip.samples} 샘플)"); OnRecordingCompleted?.Invoke(processedClip); - OnRecordingStopped?.Invoke(); return processedClip; } } - OnRecordingStopped?.Invoke(); return null; } catch (Exception ex) @@ -165,7 +176,7 @@ public bool StartRecording() } finally { - // 중복 호출 방지를 위해 성공 분기에서 이미 호출했다면 옵저버 측에서 idempotent 처리 가정 + // 성공/실패 불문하고 한 번만 Stopped 이벤트를 발생 OnRecordingStopped?.Invoke(); } } @@ -354,4 +365,5 @@ private void ApplyNoiseReduction(float[] audioData) #endregion } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/Assets/Core/DebugConsole/GameDebugConsoleManager.cs b/Assets/Core/DebugConsole/GameDebugConsoleManager.cs index 8078a99..527d8b6 100644 --- a/Assets/Core/DebugConsole/GameDebugConsoleManager.cs +++ b/Assets/Core/DebugConsole/GameDebugConsoleManager.cs @@ -56,6 +56,7 @@ void Start() { Application.logMessageReceived += OnLogMessageReceived; SetupUI(); + ValidateButtonSetup(); } void OnDestroy() @@ -115,19 +116,36 @@ private void InitializeConsole() private void SetupUI() { + Debug.Log("[DEBUG_CONSOLE] SetupUI called"); + if (_clearButton != null) { _clearButton.onClick.AddListener(ClearLogs); + Debug.Log("[DEBUG_CONSOLE] Clear button listener added"); + } + else + { + Debug.LogWarning("[DEBUG_CONSOLE] _clearButton is null!"); } if (_toggleButton != null) { _toggleButton.onClick.AddListener(ToggleConsole); + Debug.Log("[DEBUG_CONSOLE] Toggle button listener added"); + } + else + { + Debug.LogWarning("[DEBUG_CONSOLE] _toggleButton is null!"); } if (_filterInput != null) { _filterInput.onValueChanged.AddListener(OnFilterChanged); + Debug.Log("[DEBUG_CONSOLE] Filter input listener added"); + } + else + { + Debug.LogWarning("[DEBUG_CONSOLE] _filterInput is null!"); } } @@ -341,11 +359,18 @@ private System.Collections.IEnumerator ScrollToBottomCoroutine() public void ToggleConsole() { + Debug.Log($"[DEBUG_CONSOLE] ToggleConsole called. Current state: {_isConsoleVisible}"); + _isConsoleVisible = !_isConsoleVisible; if (_consolePanel != null) { _consolePanel.SetActive(_isConsoleVisible); + Debug.Log($"[DEBUG_CONSOLE] Console panel set to: {_isConsoleVisible}"); + } + else + { + Debug.LogWarning("[DEBUG_CONSOLE] _consolePanel is null!"); } if (_isConsoleVisible) @@ -517,5 +542,34 @@ public void SetupLayoutGroup() contentSizeFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize; contentSizeFitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize; } + + public void ValidateButtonSetup() + { + Debug.Log("[DEBUG_CONSOLE] === Button Setup Validation ==="); + + if (_toggleButton == null) + { + Debug.LogError("[DEBUG_CONSOLE] _toggleButton is null! Please assign it in the inspector."); + return; + } + + Debug.Log($"[DEBUG_CONSOLE] Toggle button found: {_toggleButton.name}"); + Debug.Log($"[DEBUG_CONSOLE] Toggle button active: {_toggleButton.gameObject.activeInHierarchy}"); + Debug.Log($"[DEBUG_CONSOLE] Toggle button interactable: {_toggleButton.interactable}"); + Debug.Log($"[DEBUG_CONSOLE] Toggle button onClick event count: {_toggleButton.onClick.GetPersistentEventCount()}"); + + if (_consolePanel == null) + { + Debug.LogError("[DEBUG_CONSOLE] _consolePanel is null! Please assign it in the inspector."); + } + else + { + Debug.Log($"[DEBUG_CONSOLE] Console panel found: {_consolePanel.name}"); + Debug.Log($"[DEBUG_CONSOLE] Console panel active: {_consolePanel.activeInHierarchy}"); + } + + Debug.Log($"[DEBUG_CONSOLE] Console visible state: {_isConsoleVisible}"); + Debug.Log("[DEBUG_CONSOLE] === End Validation ==="); + } } } \ No newline at end of file diff --git a/Assets/Core/Managers/SystemManager.cs b/Assets/Core/Managers/SystemManager.cs index b33cc2c..abc4075 100644 --- a/Assets/Core/Managers/SystemManager.cs +++ b/Assets/Core/Managers/SystemManager.cs @@ -16,7 +16,6 @@ public class SystemManager : Singleton { [Header("Core Managers")] [SerializeField] private WebSocketManager? _webSocketManager; - [SerializeField] private SessionManager? _sessionManager; [SerializeField] private HttpApiClient? _httpApiClient; [SerializeField] private AudioManager? _audioManager; [SerializeField] private LoadingManager? _loadingManager; @@ -35,7 +34,6 @@ public class SystemManager : Singleton public bool IsInitialized { get; private set; } public WebSocketManager? WebSocketManager => _webSocketManager; - public SessionManager? SessionManager => _sessionManager; public AudioManager? AudioManager => _audioManager; public LoadingManager? LoadingManager => _loadingManager; @@ -174,18 +172,9 @@ public async UniTask InitializeAppAsync() public void Shutdown() { try { _httpApiClient?.Shutdown(); } catch {} - try { _sessionManager?.Shutdown(); } catch {} try { _webSocketManager?.Shutdown(); } catch {} Debug.Log("[SystemManager] 시스템 종료 완료"); } - - [ContextMenu("Log Manager Status")] - public void LogManagerStatus() - { - Debug.Log($"[SystemManager] Initialized: {IsInitialized}"); - Debug.Log($"[SystemManager] Current Camera: {(_camera != null ? _camera.name : "null")}"); - Debug.Log($"[SystemManager] WS: {(WebSocketManager != null ? "OK" : "null")}, Session: {(SessionManager != null ? "OK" : "null")}, Audio: {(AudioManager != null ? "OK" : "null")}, Loading: {(LoadingManager != null ? "OK" : "null")}"); - } [ContextMenu("Update Camera")] public void UpdateCameraFromContextMenu() @@ -230,7 +219,6 @@ private void CreateManagersIfNotExist() if (_createManagersIfNotExist) { _webSocketManager = WebSocketManager.Instance; - _sessionManager = SessionManager.Instance; _httpApiClient = HttpApiClient.Instance; _audioManager = AudioManager.Instance; _loadingManager = LoadingManager.Instance; @@ -239,21 +227,14 @@ private void CreateManagersIfNotExist() private async UniTask InitializeManagersAsync() { - if (_webSocketManager == null || _sessionManager == null || _httpApiClient == null) + if (_webSocketManager == null || _httpApiClient == null) { throw new InvalidOperationException("필수 매니저 인스턴스를 찾을 수 없습니다."); } _loadingManager?.BeginLoadingUI(); _audioManager?.Initialize(); _webSocketManager.Initialize(); - _sessionManager.Initialize(_webSocketManager); - _httpApiClient.Initialize(_sessionManager); - - bool connected = await _sessionManager.EnsureConnectionAsync(); - if (!connected) - { - throw new InvalidOperationException("세션 연결 실패"); - } + _httpApiClient.Initialize(); } } diff --git a/Assets/Domain/Character/Model/Chikuwa/Character-Zero.asset b/Assets/Domain/Character/Model/Chikuwa/Character-Zero.asset index d5b8bda..affd45d 100644 --- a/Assets/Domain/Character/Model/Chikuwa/Character-Zero.asset +++ b/Assets/Domain/Character/Model/Chikuwa/Character-Zero.asset @@ -14,21 +14,72 @@ MonoBehaviour: m_EditorClassIdentifier: characterId: zero characterName: "\uC81C\uB85C" - characterPrefab: {fileID: 8051974178353762967, guid: 9be43d9f8e24d634186c522ebce74618, type: 3} - thumbnail: {fileID: 2800000, guid: e029dae0a0b46124ba7b0b6b2ac00a76, type: 3} + characterPrefab: {fileID: 7609475132481125670, guid: 9e7b6e32e30a8e144aac535afecd9340, type: 3} + thumbnail: {fileID: 2800000, guid: a443448c8422da640825449c667a9378, type: 3} characterDescription: "\uCE58\uC640\uC9F1 \uBAA8\uB378 \uAE30\uBC18" - emotionMappings: - - emotionKey: - expressionName: - defaultIntensity: 0 - defaultDurationMs: 0 - actionMappings: - - actionKey: - motionGroup: - motionName: - isLockAtActive: 0 + motionClips: + - id: idle-001 + animationClip: {fileID: 7400000, guid: 5b3644cae20d1a146a7ca8d29925d135, type: 2} + motionGroup: idle + endBehavior: 2 + - id: idle-002 + animationClip: {fileID: 7400000, guid: 0efa33e6067f6bc4f8f52d2db2d16dc9, type: 2} + motionGroup: idle + endBehavior: 2 + - id: idle-003 + animationClip: {fileID: 7400000, guid: cc4dc2ead0e65e8428069b4cf8025d0e, type: 2} + motionGroup: idle + endBehavior: 2 + - id: idle-004 + animationClip: {fileID: 7400000, guid: 53df6e94dd66db04ea25417432f57f9b, type: 2} + motionGroup: idle + endBehavior: 2 + - id: idle-005 + animationClip: {fileID: 7400000, guid: 75265740bcd415b4c9a7a4b2b345792f, type: 2} + motionGroup: idle + endBehavior: 2 + - id: reaction-001 + animationClip: {fileID: 7400000, guid: 42e9cdec6f408ec46b6412c6469dda7d, type: 2} + motionGroup: reaction + endBehavior: 2 + - id: reaction-003 + animationClip: {fileID: 7400000, guid: 4b6b057adfddf154a9e3dcf7da473530, type: 2} + motionGroup: reaction + endBehavior: 2 + - id: talk-001 + animationClip: {fileID: 7400000, guid: 072cd8c391ae42f42a39656159e35ad9, type: 2} + motionGroup: talk + endBehavior: 2 + - id: talk-002 + animationClip: {fileID: 7400000, guid: 89cee24987f9232488208dec4e8c013b, type: 2} + motionGroup: talk + endBehavior: 2 + - id: talk-003 + animationClip: {fileID: 7400000, guid: 6f123d03354080e45aa867de59eaeec2, type: 2} + motionGroup: talk + endBehavior: 2 + - id: talk-004 + animationClip: {fileID: 7400000, guid: 22f41941dbe5b9f41a6e594c58040732, type: 2} + motionGroup: talk + endBehavior: 2 + - id: talk-005 + animationClip: {fileID: 7400000, guid: 8dd9a6482b05b9a47ab42cc8069312f4, type: 2} + motionGroup: talk + endBehavior: 2 + - id: tilting_head-001 + animationClip: {fileID: 7400000, guid: 6006af9d28af263498dd56c9b760972e, type: 2} + motionGroup: tilting_head + endBehavior: 2 + - id: tilting_head-001 + animationClip: {fileID: 7400000, guid: 6006af9d28af263498dd56c9b760972e, type: 2} + motionGroup: tilting_head + endBehavior: 2 + fadeMotionList: {fileID: 11400000, guid: 22396aca66750b949a35e986ba93eb19, type: 2} + enableAutoIdle: 1 + autoIdleInterval: 2 + isLookAtActive: 1 lookSensitivity: 1 - lockAtDamping: 0 + lookAtDamping: 0 useLipSync: 1 gain: 10 smoothing: 1 diff --git a/Assets/Domain/Character/Model/Model.fadeMotionList.asset b/Assets/Domain/Character/Model/Model.fadeMotionList.asset deleted file mode 100644 index 798e9bd..0000000 --- a/Assets/Domain/Character/Model/Model.fadeMotionList.asset +++ /dev/null @@ -1,17 +0,0 @@ -%YAML 1.1 -%TAG !u! tag:unity3d.com,2011: ---- !u!114 &11400000 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 0} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 403ae2dd693bb1d4b924f6b8d206b053, type: 3} - m_Name: Model.fadeMotionList - m_EditorClassIdentifier: - MotionInstanceIds: - CubismFadeMotionObjects: - - {fileID: 0} diff --git a/Assets/Domain/Character/Model/Sample/natoriConfig.asset b/Assets/Domain/Character/Model/Sample/natoriConfig.asset index 7c8e318..f5efe6d 100644 --- a/Assets/Domain/Character/Model/Sample/natoriConfig.asset +++ b/Assets/Domain/Character/Model/Sample/natoriConfig.asset @@ -20,4 +20,4 @@ MonoBehaviour: isLockAtActive: 1 gain: 10 smoothing: 1 - modelPrefab: {fileID: 6181935751025943507, guid: 43e77a085e1072e4dbc6393f20643f3b, type: 3} + modelPrefab: {fileID: 7609475132481125670, guid: 9e7b6e32e30a8e144aac535afecd9340, type: 3} diff --git a/Assets/Domain/Character/Script/CharacterActionController.cs b/Assets/Domain/Character/Script/CharacterActionController.cs deleted file mode 100644 index 5949ce3..0000000 --- a/Assets/Domain/Character/Script/CharacterActionController.cs +++ /dev/null @@ -1,111 +0,0 @@ -#nullable enable -using UnityEngine; -using ProjectVG.Domain.Chat.Model; - -namespace ProjectVG.Domain.Character.Service -{ - - public enum CharacterActionType - { - Idle, - Listen, - Talk - } - - public class CharacterActionController : MonoBehaviour - { - private Animator? _animator; - private bool _isPlaying = false; - private CharacterActionType _currentAction = CharacterActionType.Idle; - - /// - /// 서비스를 초기화한다. - /// - /// 캐릭터의 Animator - public void Initialize(Animator animator) - { - _animator = animator; - _isPlaying = false; - _currentAction = CharacterActionType.Idle; - } - - /// - /// 액션을 실행한다. - /// - /// 액션 타입 - public void PlayAction(CharacterActionType actionType) - { - if (_animator == null) - { - Debug.LogWarning("[CharacterActionController] Animator가 초기화되지 않았습니다."); - return; - } - - try - { - _currentAction = actionType; - - Debug.Log(actionType); - - switch (actionType) - { - case CharacterActionType.Idle: - _animator.SetTrigger("Idle"); - _animator.SetBool("Talk", false); - _isPlaying = false; - break; - - case CharacterActionType.Listen: - _animator.SetTrigger("Listen"); - _animator.SetBool("Talk", false); - _isPlaying = true; - break; - - case CharacterActionType.Talk: - _animator.SetBool("Talk", true); - _isPlaying = true; - break; - } - - Debug.Log($"[CharacterActionController] 액션 재생: {actionType}"); - } - catch (System.Exception ex) - { - Debug.LogError($"[CharacterActionController] 액션 재생 실패: {ex.Message}"); - _isPlaying = false; - } - } - - /// - /// 현재 재생 중인 액션을 중지한다. - /// - public void StopCurrentAction() - { - if (_animator == null) return; - - _animator.SetTrigger("Idle"); - _animator.SetBool("Talk", false); - _isPlaying = false; - _currentAction = CharacterActionType.Idle; - Debug.Log("[CharacterActionController] 액션 중지"); - } - - /// - /// 액션이 재생 중인지 확인한다. - /// - /// 액션 재생 중이면 true - public bool IsPlaying() - { - return _isPlaying && _animator != null; - } - - /// - /// 현재 액션 타입을 반환한다. - /// - /// 현재 액션 타입 - public CharacterActionType GetCurrentAction() - { - return _currentAction; - } - } -} diff --git a/Assets/Domain/Character/Script/CharacterActionController.cs.meta b/Assets/Domain/Character/Script/CharacterActionController.cs.meta deleted file mode 100644 index d2bbf75..0000000 --- a/Assets/Domain/Character/Script/CharacterActionController.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f43ea94184a478548b8bd29496a6070a \ No newline at end of file diff --git a/Assets/Domain/Character/Script/CharacterActionDefinitions.cs b/Assets/Domain/Character/Script/CharacterActionDefinitions.cs new file mode 100644 index 0000000..4744aee --- /dev/null +++ b/Assets/Domain/Character/Script/CharacterActionDefinitions.cs @@ -0,0 +1,77 @@ +#nullable enable +using System; + +namespace ProjectVG.Domain.Character.Service +{ + /// + /// 캐릭터 액션 타입과 관련된 상수들을 정의하는 Enum + /// + public enum CharacterAction + { + Idle, + Talk, + Listen + } + + /// + /// CharacterAction의 확장 메서드들 + /// + public static class CharacterActionExtensions + { + /// + /// 액션 타입을 Animator 상태 이름으로 변환 (소문자) + /// + public static string ToStateName(this CharacterAction action) + { + return action switch + { + CharacterAction.Idle => "idle", + CharacterAction.Talk => "talk", + CharacterAction.Listen => "listen", + _ => "idle" + }; + } + + /// + /// 액션 타입을 Animator 트리거 이름으로 변환 (PlayXxx 형태) + /// + public static string ToTriggerName(this CharacterAction action) + { + return action switch + { + CharacterAction.Idle => "PlayIdle", + CharacterAction.Talk => "PlayTalk", + CharacterAction.Listen => "PlayListen", + _ => "PlayIdle" + }; + } + + /// + /// 기존 CharacterActionType을 새로운 CharacterAction으로 변환 + /// + public static CharacterAction ToCharacterAction(this CharacterActionType actionType) + { + return actionType switch + { + CharacterActionType.Idle => CharacterAction.Idle, + CharacterActionType.Talk => CharacterAction.Talk, + CharacterActionType.Listen => CharacterAction.Listen, + _ => CharacterAction.Idle + }; + } + + /// + /// CharacterAction을 기존 CharacterActionType으로 변환 (하위 호환성) + /// + public static CharacterActionType ToCharacterActionType(this CharacterAction action) + { + return action switch + { + CharacterAction.Idle => CharacterActionType.Idle, + CharacterAction.Talk => CharacterActionType.Talk, + CharacterAction.Listen => CharacterActionType.Listen, + _ => CharacterActionType.Idle + }; + } + } +} \ No newline at end of file diff --git a/Assets/Domain/Character/Script/CharacterActionDefinitions.cs.meta b/Assets/Domain/Character/Script/CharacterActionDefinitions.cs.meta new file mode 100644 index 0000000..5bbdea1 --- /dev/null +++ b/Assets/Domain/Character/Script/CharacterActionDefinitions.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0f572dbc3aad0a42adf40e213b4f214 \ No newline at end of file diff --git a/Assets/Domain/Character/Script/CharacterActionType.cs b/Assets/Domain/Character/Script/CharacterActionType.cs new file mode 100644 index 0000000..b817865 --- /dev/null +++ b/Assets/Domain/Character/Script/CharacterActionType.cs @@ -0,0 +1,25 @@ +#nullable enable + +namespace ProjectVG.Domain.Character.Service +{ + /// + /// 캐릭터 액션 타입 + /// + public enum CharacterActionType + { + /// + /// 대기 상태 + /// + Idle, + + /// + /// 듣기 상태 + /// + Listen, + + /// + /// 말하기 상태 + /// + Talk + } +} \ No newline at end of file diff --git a/Assets/Domain/Character/Script/CharacterActionType.cs.meta b/Assets/Domain/Character/Script/CharacterActionType.cs.meta new file mode 100644 index 0000000..bc0d315 --- /dev/null +++ b/Assets/Domain/Character/Script/CharacterActionType.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f44f9785a1b367a4b911c3b7e50bef73 \ No newline at end of file diff --git a/Assets/Domain/Character/Script/CharacterManager.cs b/Assets/Domain/Character/Script/CharacterManager.cs index d1066ca..d44fccb 100644 --- a/Assets/Domain/Character/Script/CharacterManager.cs +++ b/Assets/Domain/Character/Script/CharacterManager.cs @@ -18,7 +18,7 @@ public class CharacterManager : MonoBehaviour private CharacterModelLoader _modelLoader; private GameObject _currentCharacter; - private CharacterActionController _currentActionService; + private ICharacterActionController _actionController; private int _loadVersion = 0; #region Unity Lifecycle @@ -53,13 +53,6 @@ public void Initialize() // 임시로 zero 캐릭터 로드 LoadCharacter("zero"); - if (_modelTransform != null) { - var scaler = _modelTransform.GetComponent(); - if (scaler == null) { - scaler = _modelTransform.gameObject.AddComponent(); - Debug.Log($"[CharacterManager] Live2DModelScaler가 자동으로 추가되었습니다: {_modelTransform.name}"); - } - } } public void Shutdown() { @@ -96,9 +89,20 @@ public async Task LoadCharacter(string characterId) if (newCharacter != null) { _currentCharacter = newCharacter; - _currentActionService = _currentCharacter.GetComponent(); + // ICharacterActionController 인터페이스를 구현하는 컴포넌트를 찾기 + _actionController = _currentCharacter.GetComponent(); + if (_actionController == null) + { + // 구현체들을 직접 확인 (fallback) + _actionController = _currentCharacter.GetComponent(); + if (_actionController == null) + { + _actionController = _currentCharacter.GetComponent(); + } + } + _currentCharacter.SetActive(true); - Debug.Log($"[CharacterManager] 캐릭터 로드 완료: {characterId}"); + Debug.Log($"[CharacterManager] 캐릭터 로드 완료: {characterId}, ActionController: {_actionController?.GetType().Name ?? "None"}"); } } @@ -111,7 +115,7 @@ public void UnloadCurrentCharacter() { Destroy(_currentCharacter); _currentCharacter = null; - _currentActionService = null; + _actionController = null; } } @@ -121,9 +125,9 @@ public void UnloadCurrentCharacter() /// 액션 데이터 public void PlayAction(CharacterActionData actionData) { - if (_currentActionService != null && actionData.HasAction()) + if (_actionController != null && actionData.HasAction()) { - _currentActionService.PlayAction(actionData.ActionType); + _actionController.PlayAction(actionData.ActionType); } } @@ -132,7 +136,7 @@ public void PlayAction(CharacterActionData actionData) /// public void StopCurrentAction() { - _currentActionService?.StopCurrentAction(); + _actionController?.StopCurrentAction(); } /// @@ -141,7 +145,7 @@ public void StopCurrentAction() /// 액션 재생 중이면 true public bool IsActionPlaying() { - return _currentActionService?.IsPlaying() ?? false; + return _actionController?.IsPlaying() ?? false; } /// diff --git a/Assets/Domain/Character/Script/CharacterModelLoader.cs b/Assets/Domain/Character/Script/CharacterModelLoader.cs index c7f4cea..b1f3322 100644 --- a/Assets/Domain/Character/Script/CharacterModelLoader.cs +++ b/Assets/Domain/Character/Script/CharacterModelLoader.cs @@ -1,6 +1,9 @@ -using Cysharp.Threading.Tasks; + using Cysharp.Threading.Tasks; using Live2D.Cubism.Framework; using Live2D.Cubism.Framework.MouthMovement; +using Live2D.Cubism.Framework.Motion; +using Live2D.Cubism.Framework.Expression; +using Live2D.Cubism.Framework.MotionFade; using Live2D.Cubism.Core; using ProjectVG.Core.Audio; using ProjectVG.Domain.Character.Live2D.Model; @@ -54,6 +57,27 @@ public async UniTask LoadAndInitializeModelAsync(string characterId, #region Private Methods + /// + /// Animator Controller를 제거한다 (Live2D 모드에서만, Live2D와 충돌 방지) + /// + private void ClearAnimatorController(GameObject modelInstance, Live2DModelConfig? config) + { + // Animator 모드인 경우 Animator Controller를 제거하지 않음 + if (config != null && config.ActionControllerMode == Live2DModelConfig.ActionControllerType.Animator) + { + Debug.Log($"[CharacterModelLoader] Animator 모드이므로 Animator Controller를 유지합니다: {modelInstance.name}"); + return; + } + + var animator = modelInstance.GetComponent(); + if (animator != null && animator.runtimeAnimatorController != null) + { + Debug.LogWarning($"[CharacterModelLoader] Animator Controller가 설정되어 있습니다. Live2D 호환성을 위해 제거합니다: {modelInstance.name}"); + animator.runtimeAnimatorController = null; + Debug.Log($"[CharacterModelLoader] Animator Controller 제거 완료: {modelInstance.name}"); + } + } + /// /// 캐릭터 설정을 가져온다 /// @@ -92,8 +116,14 @@ private async UniTask CreateModelInstanceAsync(Live2DModelConfig con /// private void SetupModelComponents(GameObject modelInstance, Live2DModelConfig config) { + // Animator Controller 제거 (Live2D 모드에서만) + ClearAnimatorController(modelInstance, config); + SetupLipSync(modelInstance, config); SetupAutoEyeBlink(modelInstance, config); + SetupMotionController(modelInstance, config); + SetupExpressionController(modelInstance, config); + SetupFadeController(modelInstance, config); SetupActionController(modelInstance); } @@ -107,9 +137,16 @@ private void SetupLipSync(GameObject modelInstance, Live2DModelConfig config) return; } - var mouthController = modelInstance.GetComponent(); + var mouthController = modelInstance.GetComponent(); if (mouthController == null) { - mouthController = modelInstance.AddComponent(); + mouthController = modelInstance.AddComponent(); + Debug.Log($"[CharacterModelLoader] CubismAudioMouthInput 컴포넌트를 추가했습니다: {modelInstance.name}"); + } + mouthController.BlendMode = CubismParameterBlendMode.Additive; + + var mouthInputController = modelInstance.GetComponent(); + if (mouthInputController == null) { + mouthInputController = modelInstance.AddComponent(); Debug.Log($"[CharacterModelLoader] CubismAudioMouthInput 컴포넌트를 추가했습니다: {modelInstance.name}"); } @@ -117,10 +154,10 @@ private void SetupLipSync(GameObject modelInstance, Live2DModelConfig config) Debug.LogWarning($"[CharacterModelLoader] Voice AudioSource가 null입니다. 립싱크가 동작하지 않을 수 있습니다: {modelInstance.name}"); } else { Debug.Log($"[CharacterModelLoader] Voice AudioSource 설정 완료: {modelInstance.name}, AudioSource: {_voiceAudioSource.name}"); - mouthController.AudioInput = _voiceAudioSource; + mouthInputController.AudioInput = _voiceAudioSource; } - mouthController.Gain = config.Gain; - mouthController.Smoothing = config.Smoothing; + mouthInputController.Gain = config.Gain; + mouthInputController.Smoothing = config.Smoothing; // Live2D 모델에 Mouth 파라미터가 있는지 확인 var model = modelInstance.GetComponent(); @@ -161,23 +198,140 @@ private void SetupAutoEyeBlink(GameObject modelInstance, Live2DModelConfig confi } /// - /// 액션 서비스를 설정한다 + /// Motion 컨트롤러를 설정한다 + /// + private void SetupMotionController(GameObject modelInstance, Live2DModelConfig config) + { + var motionController = modelInstance.GetComponent(); + if (motionController == null) { + motionController = modelInstance.AddComponent(); + } + + Debug.Log($"[CharacterModelLoader] CubismMotionController 설정 완료: {modelInstance.name}"); + } + + /// + /// Expression 컨트롤러를 설정한다 + /// + private void SetupExpressionController(GameObject modelInstance, Live2DModelConfig config) + { + var expressionController = modelInstance.GetComponent(); + if (expressionController == null) { + expressionController = modelInstance.AddComponent(); + } + + Debug.Log($"[CharacterModelLoader] CubismExpressionController 설정 완료: {modelInstance.name}"); + } + + /// + /// Fade 컨트롤러를 설정한다 + /// + private void SetupFadeController(GameObject modelInstance, Live2DModelConfig config) + { + var fadeController = modelInstance.GetComponent(); + if (fadeController == null) { + fadeController = modelInstance.AddComponent(); + Debug.Log($"[CharacterModelLoader] CubismFadeController 컴포넌트를 추가했습니다: {modelInstance.name}"); + } + + if (config.FadeMotionList != null) { + fadeController.CubismFadeMotionList = config.FadeMotionList; + Debug.Log($"[CharacterModelLoader] CubismFadeMotionList 설정 완료: {modelInstance.name}"); + } + else { + Debug.LogWarning($"[CharacterModelLoader] CubismFadeMotionList가 설정되지 않았습니다. Live2D 모션 페이드가 제대로 작동하지 않을 수 있습니다: {modelInstance.name}"); + Debug.LogWarning($"해결방법: Live2DModelConfig에서 'Cubism Fade Motion List' 필드를 설정하거나, Live2D 모델에 포함된 .fadeMotionList 에셋을 할당하세요."); + } + + // Fade Controller 리프레시 (컴포넌트 초기화) + fadeController.Refresh(); + } + + /// + /// 액션 컨트롤러를 설정한다 (Live2D 또는 Animator 기반) /// private void SetupActionController(GameObject modelInstance) { - var actionService = modelInstance.GetComponent(); + // 현재 모델의 Config 찾기 + string modelId = modelInstance.name; + if (_modelRegistry == null || !_modelRegistry.TryGetConfig(modelId, out var config)) + { + Debug.LogWarning($"[CharacterModelLoader] 모델 '{modelId}'의 Config를 찾을 수 없습니다. Live2D 방식으로 기본 설정합니다."); + SetupLive2DActionController(modelInstance, null); + return; + } + + // Config에 따라 적절한 액션 컨트롤러 설정 + switch (config.ActionControllerMode) + { + case Live2DModelConfig.ActionControllerType.Animator: + SetupAnimatorActionController(modelInstance, config); + break; + case Live2DModelConfig.ActionControllerType.Live2D: + default: + SetupLive2DActionController(modelInstance, config); + break; + } + } + + /// + /// Live2D 기반 액션 컨트롤러를 설정한다 + /// + private void SetupLive2DActionController(GameObject modelInstance, Live2DModelConfig? config) + { + var actionService = modelInstance.GetComponent(); if (actionService == null) { - actionService = modelInstance.AddComponent(); + actionService = modelInstance.AddComponent(); + } + + var motionController = modelInstance.GetComponent(); + if (motionController == null) { + Debug.LogWarning($"[CharacterModelLoader] CubismMotionController를 찾을 수 없습니다: {modelInstance.name}"); + return; + } + + if (config != null) + { + actionService.Initialize(motionController, config.MotionClips); + Debug.Log($"[CharacterModelLoader] Live2DCharacterActionController 초기화 완료: {modelInstance.name}, Motion Clips: {config.MotionClips?.Count ?? 0}개"); + } + else + { + Debug.LogWarning($"[CharacterModelLoader] Config가 null이므로 빈 Motion Clips로 초기화합니다: {modelInstance.name}"); + actionService.Initialize(motionController, new System.Collections.Generic.List()); + } + } + + /// + /// Animator 기반 액션 컨트롤러를 설정한다 + /// + private void SetupAnimatorActionController(GameObject modelInstance, Live2DModelConfig config) + { + var actionService = modelInstance.GetComponent(); + if (actionService == null) { + actionService = modelInstance.AddComponent(); } var animator = modelInstance.GetComponent(); if (animator == null) { - Debug.LogWarning($"[CharacterModelLoader] Animator를 찾을 수 없습니다: {modelInstance.name}"); + Debug.LogError($"[CharacterModelLoader] Animator 컴포넌트를 찾을 수 없습니다: {modelInstance.name}"); return; - } - actionService.Initialize(animator); - Debug.Log($"[CharacterModelLoader] CharacterActionController 초기화 완료: {modelInstance.name}"); - } + } + + // Animator Controller 설정 + if (config.AnimatorController != null) + { + animator.runtimeAnimatorController = config.AnimatorController; + Debug.Log($"[CharacterModelLoader] Animator Controller 설정 완료: {config.AnimatorController.name}"); + } + else + { + Debug.LogWarning($"[CharacterModelLoader] Animator Controller가 Config에 설정되지 않았습니다: {modelInstance.name}"); + } + + actionService.Initialize(animator); + Debug.Log($"[CharacterModelLoader] AnimatorCharacterActionController 초기화 완료: {modelInstance.name}"); + } #endregion } diff --git a/Assets/Domain/Character/Script/Component/CameraResolutionScaler.cs b/Assets/Domain/Character/Script/Component/CameraResolutionScaler.cs index 78918b3..9f13e7f 100644 --- a/Assets/Domain/Character/Script/Component/CameraResolutionScaler.cs +++ b/Assets/Domain/Character/Script/Component/CameraResolutionScaler.cs @@ -26,15 +26,15 @@ public class CameraResolutionScaler : MonoBehaviour private void Start() { - Initialize(); + // Initialize(); } private void Update() { - if (applyOnResolutionChange && HasResolutionChanged()) - { - ApplyCameraScale(); - } + // if (applyOnResolutionChange && HasResolutionChanged()) + // { + // ApplyCameraScale(); + // } } #endregion diff --git a/Assets/Domain/Character/Script/Component/ResolutionManager.cs b/Assets/Domain/Character/Script/Component/ResolutionManager.cs index 3c29307..2a641a0 100644 --- a/Assets/Domain/Character/Script/Component/ResolutionManager.cs +++ b/Assets/Domain/Character/Script/Component/ResolutionManager.cs @@ -26,15 +26,15 @@ public class ResolutionManager : MonoBehaviour private void Start() { - Initialize(); + // Initialize(); } private void Update() { - if (applyOnResolutionChange && HasResolutionChanged()) - { - ApplyScaleToAllModels(); - } + // if (applyOnResolutionChange && HasResolutionChanged()) + // { + // ApplyScaleToAllModels(); + // } } #endregion diff --git a/Assets/Domain/Character/Script/Config/Live2DModelConfig.cs b/Assets/Domain/Character/Script/Config/Live2DModelConfig.cs index cee40fa..bdf698b 100644 --- a/Assets/Domain/Character/Script/Config/Live2DModelConfig.cs +++ b/Assets/Domain/Character/Script/Config/Live2DModelConfig.cs @@ -2,44 +2,102 @@ using System.Collections.Generic; using UnityEngine; using UnityEngine.Serialization; +using Live2D.Cubism.Framework.MotionFade; namespace ProjectVG.Domain.Character.Live2D.Model { [CreateAssetMenu(fileName = "Live2DModelConfig", menuName = "ProjectVG/Live2D/ModelConfig", order = 100)] public class Live2DModelConfig : ScriptableObject { - [Serializable] - public class EmotionMapping + public enum MotionEndBehavior { - [Header("감정 설정")] - [Tooltip("감정 키입니다. 서버에서 전송되는 감정 값과 일치해야 합니다.")] - public string emotionKey; - - [Tooltip("Live2D Expression 이름입니다. 모델의 표정 파일명과 일치해야 합니다.")] - public string expressionName; - - [Header("기본값")] - [Tooltip("감정의 기본 강도입니다. (0.0 ~ 1.0)")] - [Range(0f, 1f)] - public float defaultIntensity = 0.5f; - - [Tooltip("감정의 기본 지속시간입니다. (밀리초)")] - [Range(500, 10000)] - public int defaultDurationMs = 2000; + [Tooltip("애니메이션 종료 후 멈춤")] + Stop, + [Tooltip("같은 그룹의 다른 애니메이션 반복")] + Loop, + [Tooltip("자동으로 Idle 상태로 복귀")] + ReturnToIdle + } + + public enum ActionControllerType + { + [Tooltip("Live2D CubismMotionController 기반 제어")] + Live2D, + [Tooltip("Unity Animator 기반 제어")] + Animator } [Serializable] - public class ActionMapping + public class MotionClipMapping { - [Header("행동 설정")] - [Tooltip("행동 키입니다. 서버에서 전송되는 행동 값과 일치해야 합니다.")] - public string actionKey; - - [Tooltip("Live2D 모션 그룹 이름입니다.")] - public string motionGroup; - - [Tooltip("Live2D 모션 파일 이름입니다.")] - public string motionName; + [Tooltip("클립 ID (읽기 전용)")] + [SerializeField, ReadOnly] private string id; + + [Tooltip("AnimationClip 파일")] + public AnimationClip animationClip; + + [Tooltip("모션 그룹 이름 (파일명에서 자동 추출됨)")] + [SerializeField] private string motionGroup; + + [Tooltip("애니메이션 종료 후 행동")] + public MotionEndBehavior endBehavior = MotionEndBehavior.ReturnToIdle; + + #region Fild Auto + + public string Id { + get { + if (animationClip != null && string.IsNullOrEmpty(id)) { + id = animationClip.name; + UpdateMotionGroupFromFileName(); + } + return id; + } + } + + public string MotionGroup { + get { + if (animationClip != null && string.IsNullOrEmpty(motionGroup)) { + UpdateMotionGroupFromFileName(); + } + return motionGroup; + } + set => motionGroup = value; + } + + /// + /// 파일명에서 모션 그룹을 자동 추출한다. + /// 예: "idle-001" -> "idle", "talk-002" -> "talk" + /// + private void UpdateMotionGroupFromFileName() + { + if (animationClip == null) return; + + string fileName = animationClip.name; + + if (fileName.Contains("-")) { + motionGroup = fileName.Split('-')[0].ToLower(); + } + else if (fileName.Contains("_")) { + motionGroup = fileName.Split('_')[0].ToLower(); + } + else { + motionGroup = fileName.ToLower(); + } + } + + /// + /// AnimationClip이 변경될 때 호출되는 메서드 (Inspector에서) + /// + public void OnValidate() + { + if (animationClip != null) { + // 파일명, 모션 그룹 자동 업데이트 + id = animationClip.name; + UpdateMotionGroupFromFileName(); + } + } + + #endregion } [Header("[ 캐릭터 기본정보 ]")] @@ -49,27 +107,46 @@ public class ActionMapping [Tooltip("캐릭터 이름")] [SerializeField] private string characterName; - + [Tooltip("Live2D 캐릭터 프리팹 (Cubism 모델 포함)")] [SerializeField] private GameObject characterPrefab; - + [Tooltip("캐릭터 썸네일 이미지")] [SerializeField] private Texture2D thumbnail; - + [Tooltip("캐릭터 설명")] [SerializeField, Multiline] private string characterDescription; [Space(5)] [Header("────────────────────────────────────────")] - [Header("[ 행동/표정 ]")] + + [Space(5)] + [Header("[ Action Controller 설정 ]")] + [Space(2)] + [Tooltip("액션 컨트롤러 타입")] + [SerializeField] private ActionControllerType actionControllerType = ActionControllerType.Live2D; + + [Tooltip("Unity Animator Controller (Animator 타입 사용 시)")] + [SerializeField] private RuntimeAnimatorController animatorController; + + [Header("[ Motion 클립 설정 ]")] + [Space(2)] + [Tooltip("AnimationClip 목록 (Live2D 타입 사용 시)")] + [SerializeField] private List motionClips = new List(); + + [Tooltip("CubismFadeMotionList (Live2D 타입 사용 시, 선택사항)")] + [SerializeField] private CubismFadeMotionList fadeMotionList; + + [Header("[ Auto Idle 설정 ]")] [Space(2)] - [Tooltip("감정 → Live2D Expression 매핑")] - [SerializeField] private List emotionMappings = new List(); - - [Tooltip("행동 → Live2D Motion 매핑")] - [SerializeField] private List actionMappings = new List(); - + [Tooltip("자동 Idle 모션 재생 여부")] + [SerializeField] private bool enableAutoIdle = true; + + [Tooltip("Auto Idle 모션 변경 간격 (초)")] + [Range(2f, 30f)] + [SerializeField] private float autoIdleInterval = 5f; + [Space(5)] [Header("────────────────────────────────────────")] [Header("[ 시선 설정 ]")] @@ -77,11 +154,11 @@ public class ActionMapping [Tooltip("시선 추적 사용 여부")] [SerializeField] private bool isLookAtActive = true; - + [Tooltip("시선 민감도 (값이 클수록 회전이 커짐)")] [Range(0f, 30f)] [SerializeField] private float lookSensitivity = 1.0f; - + [Tooltip("시선 반응 속도 (값이 작을수록 빠름)")] [Range(0f, 5f)] [SerializeField, FormerlySerializedAs("lockAtDamping")] @@ -98,7 +175,7 @@ public class ActionMapping [Tooltip("음량 배수 (1 = 기본)")] [Range(1f, 10f)] [SerializeField] private float gain = 1f; - + [Tooltip("입 움직임 부드러움 (값이 클수록 부드럽지만 부하 증가)")] [Range(0f, 1f)] [SerializeField] private float smoothing = 1f; @@ -115,11 +192,11 @@ public class ActionMapping [Tooltip("눈 깜빡임 간격의 평균 시간 (초)")] [Range(1f, 10f)] [SerializeField] private float eyeBlinkMean = 2.5f; - + [Tooltip("평균에서의 최대 편차 (초)")] [Range(0.5f, 5f)] [SerializeField] private float eyeBlinkMaximumDeviation = 2f; - + [Tooltip("눈 깜빡임 시간 스케일")] [Range(1f, 20f)] [SerializeField] private float eyeBlinkTimescale = 10f; @@ -128,11 +205,11 @@ public class ActionMapping [Tooltip("눈을 감는 동작 시간 (초)")] [Range(0.1f, 3f)] [SerializeField] private float eyeBlinkClosingSeconds = 1.0f; - + [Tooltip("눈이 감긴 상태 지속 시간 (초)")] [Range(0.1f, 2f)] [SerializeField] private float eyeBlinkClosedSeconds = 0.5f; - + [Tooltip("눈을 여는 동작 시간 (초)")] [Range(0.1f, 3f)] [SerializeField] private float eyeBlinkOpeningSeconds = 1.5f; @@ -149,9 +226,17 @@ public class ActionMapping public GameObject ModelPrefab => characterPrefab; public string ModelDescription => characterDescription; - // 행동/표정 - public List EmotionMappings => emotionMappings; - public List ActionMappings => actionMappings; + // Action Controller 설정 + public ActionControllerType ActionControllerMode => actionControllerType; + public RuntimeAnimatorController AnimatorController => animatorController; + + // Motion 클립 + public List MotionClips => motionClips; + public CubismFadeMotionList FadeMotionList => fadeMotionList; + + // Auto Idle 설정 + public bool EnableAutoIdle => enableAutoIdle; + public float AutoIdleInterval => autoIdleInterval; // 세부 설정 public bool IsLookAtActive => isLookAtActive; @@ -163,7 +248,7 @@ public class ActionMapping public float Smoothing => smoothing; public bool UseLipSync => useLipSync; public bool UseAutoEyeBlink => useAutoEyeBlink; - + // 눈 깜빡임 설정 public float EyeBlinkMean => eyeBlinkMean; public float EyeBlinkMaximumDeviation => eyeBlinkMaximumDeviation; @@ -171,6 +256,20 @@ public class ActionMapping public float EyeBlinkClosingSeconds => eyeBlinkClosingSeconds; public float EyeBlinkClosedSeconds => eyeBlinkClosedSeconds; public float EyeBlinkOpeningSeconds => eyeBlinkOpeningSeconds; + + /// + /// Inspector에서 값이 변경될 때 자동으로 호출 + /// + private void OnValidate() + { + if (motionClips != null) { + foreach (var clip in motionClips) { + if (clip != null) { + clip.OnValidate(); + } + } + } + } } } diff --git a/Assets/Infrastructure/Bridge.meta b/Assets/Domain/Character/Script/Implementation.meta similarity index 77% rename from Assets/Infrastructure/Bridge.meta rename to Assets/Domain/Character/Script/Implementation.meta index e6b4956..d0216c1 100644 --- a/Assets/Infrastructure/Bridge.meta +++ b/Assets/Domain/Character/Script/Implementation.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 8e7bce2422064f948a9595706ec79f62 +guid: 4987975cc1c490a41915350b6f3f90bd folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Assets/Domain/Character/Script/Implementation/AnimatorCharacterActionController.cs b/Assets/Domain/Character/Script/Implementation/AnimatorCharacterActionController.cs new file mode 100644 index 0000000..387253e --- /dev/null +++ b/Assets/Domain/Character/Script/Implementation/AnimatorCharacterActionController.cs @@ -0,0 +1,312 @@ +#nullable enable +using UnityEngine; +using System.Collections; + +namespace ProjectVG.Domain.Character.Service +{ + /// + /// Unity Animator를 사용한 캐릭터 액션 컨트롤러 구현체 + /// + public class AnimatorCharacterActionController : MonoBehaviour, ICharacterActionController + { + private Animator? _animator; + private CharacterActionType _currentAction = CharacterActionType.Idle; + private bool _isPlayingAction = false; + + // Motion End Behavior Control + public System.Action? OnMotionStop { get; set; } + public System.Action? OnMotionLoop { get; set; } + public System.Action? OnMotionReturnToIdle { get; set; } + + #region Unity Lifecycle + + private void Awake() + { + _animator = GetComponent(); + if (_animator == null) + { + Debug.LogError("[AnimatorCharacterActionController] Animator component not found!"); + } + + SetupDefaultCallbacks(); + } + + private void OnEnable() + { + // 활성화 시 Idle 상태로 시작 + if (_currentAction == CharacterActionType.Idle) + { + StartCoroutine(DelayedIdleStart()); + } + } + + /// + /// Animator 초기화 대기 후 Idle 애니메이션 시작 + /// + private IEnumerator DelayedIdleStart() + { + // 1프레임 대기 (Animator 초기화 완료 대기) + yield return null; + + if (_currentAction == CharacterActionType.Idle && _animator != null) + { + var idleAction = _currentAction.ToCharacterAction(); + _animator.SetTrigger(idleAction.ToTriggerName()); + } + } + + #endregion + + #region ICharacterActionController Implementation + + /// + /// Animator를 주입하여 초기화한다. + /// + /// Unity Animator 컴포넌트 + public void Initialize(Animator animator) + { + _animator = animator; + _currentAction = CharacterActionType.Idle; + _isPlayingAction = false; + + if (_animator == null) + { + Debug.LogError("[AnimatorCharacterActionController] Animator가 null입니다."); + return; + } + + if (_animator.runtimeAnimatorController == null) + { + Debug.LogError("[AnimatorCharacterActionController] Animator Controller가 설정되지 않았습니다."); + return; + } + + Debug.Log($"[AnimatorCharacterActionController] 초기화 완료 - Controller: {_animator.runtimeAnimatorController.name}"); + } + + /// + /// 인터페이스 구현을 위한 매개변수 없는 초기화 메서드 + /// + public void Initialize() + { + if (_animator == null) + { + _animator = GetComponent(); + } + + if (_animator == null) + { + Debug.LogError("[AnimatorCharacterActionController] Initialize() called but no Animator found!"); + return; + } + + Initialize(_animator); + } + + /// + /// 액션을 실행한다. + /// + /// 액션 타입 + public void PlayAction(CharacterActionType actionType) + { + if (_animator == null) + { + Debug.LogWarning("[AnimatorCharacterActionController] Animator가 초기화되지 않았습니다."); + return; + } + + if (_animator.runtimeAnimatorController == null) + { + Debug.LogWarning("[AnimatorCharacterActionController] Animator Controller가 설정되지 않았습니다."); + return; + } + + try + { + _currentAction = actionType; + + var characterAction = actionType.ToCharacterAction(); + string triggerName = characterAction.ToTriggerName(); + + _animator.SetTrigger(triggerName); + + if (actionType != CharacterActionType.Idle) + { + _isPlayingAction = true; + // 액션 애니메이션이 끝나면 Idle로 돌아가도록 스케줄링 + StartCoroutine(WaitForAnimationAndReturnToIdle(actionType)); + } + else + { + _isPlayingAction = false; + } + + Debug.Log($"[AnimatorCharacterActionController] 액션 재생: {actionType} (트리거: {triggerName})"); + } + catch (System.Exception ex) + { + Debug.LogError($"[AnimatorCharacterActionController] 액션 재생 실패: {ex.Message}"); + _isPlayingAction = false; + } + } + + /// + /// 현재 재생 중인 액션을 중지한다. + /// + public void StopCurrentAction() + { + if (_animator == null) return; + + StopAllCoroutines(); + PlayAction(CharacterActionType.Idle); + _isPlayingAction = false; + _currentAction = CharacterActionType.Idle; + + Debug.Log("[AnimatorCharacterActionController] 액션 중지"); + } + + /// + /// 현재 모션을 즉시 중지하고 Idle로 돌아간다. + /// + public void ForceStopAndReturnToIdle() + { + if (_animator == null) return; + + StopAllCoroutines(); + + // 즉시 Idle로 전환 + _currentAction = CharacterActionType.Idle; + _isPlayingAction = false; + + var idleAction = CharacterAction.Idle; + _animator.SetTrigger(idleAction.ToTriggerName()); + + Debug.Log("[AnimatorCharacterActionController] 강제 중지 후 Idle로 복귀"); + } + + /// + /// 액션이 재생 중인지 확인한다. + /// + /// 액션 재생 중이면 true + public bool IsPlaying() + { + return _isPlayingAction && _animator != null; + } + + /// + /// 현재 액션 타입을 반환한다. + /// + /// 현재 액션 타입 + public CharacterActionType GetCurrentAction() + { + return _currentAction; + } + + /// + /// 모션 종료 동작을 변경한다. + /// + public void SetMotionEndBehavior(System.Action? onStop = null, System.Action? onLoop = null, System.Action? onReturnToIdle = null) + { + if (onStop != null) OnMotionStop = onStop; + if (onLoop != null) OnMotionLoop = onLoop; + if (onReturnToIdle != null) OnMotionReturnToIdle = onReturnToIdle; + } + + /// + /// 현재 모션을 중지한다. + /// + public void StopCurrentMotion() + { + OnMotionStop?.Invoke(); + } + + /// + /// 현재 모션을 루프한다. + /// + public void LoopCurrentMotion() + { + OnMotionLoop?.Invoke(); + } + + /// + /// 현재 모션을 종료하고 Idle로 돌아간다. + /// + public void ReturnToIdle() + { + OnMotionReturnToIdle?.Invoke(); + } + + #endregion + + #region Private Methods + + /// + /// 기본 모션 종료 콜백을 설정한다. + /// + private void SetupDefaultCallbacks() + { + OnMotionStop = () => { + Debug.Log("[AnimatorCharacterActionController] 모션 정지"); + _isPlayingAction = false; + }; + + OnMotionLoop = () => { + Debug.Log("[AnimatorCharacterActionController] 모션 루프 - 같은 액션 반복"); + PlayAction(_currentAction); + }; + + OnMotionReturnToIdle = () => { + Debug.Log("[AnimatorCharacterActionController] 모션 종료 - Idle로 복귀"); + PlayAction(CharacterActionType.Idle); + }; + } + + + /// + /// 애니메이션 완료를 기다린 후 Idle로 돌아간다. + /// + private IEnumerator WaitForAnimationAndReturnToIdle(CharacterActionType actionType) + { + if (_animator == null) yield break; + + var characterAction = actionType.ToCharacterAction(); + string stateName = characterAction.ToStateName(); + + // 애니메이션이 시작될 때까지 대기 + yield return new WaitUntil(() => IsInState(stateName)); + + // 애니메이션이 완료될 때까지 대기 + yield return new WaitUntil(() => !IsInState(stateName) || GetNormalizedTime() >= 0.95f); + + // Idle로 돌아가기 + if (_currentAction == actionType) // 중간에 다른 액션이 실행되지 않았다면 + { + OnMotionReturnToIdle?.Invoke(); + } + } + + /// + /// 현재 Animator가 지정된 상태에 있는지 확인한다. + /// + private bool IsInState(string stateName) + { + if (_animator == null) return false; + + var stateInfo = _animator.GetCurrentAnimatorStateInfo(0); + return stateInfo.IsName(stateName); + } + + /// + /// 현재 애니메이션의 정규화된 시간을 반환한다. + /// + private float GetNormalizedTime() + { + if (_animator == null) return 1.0f; + + var stateInfo = _animator.GetCurrentAnimatorStateInfo(0); + return stateInfo.normalizedTime; + } + + #endregion + } +} \ No newline at end of file diff --git a/Assets/Domain/Character/Script/Implementation/AnimatorCharacterActionController.cs.meta b/Assets/Domain/Character/Script/Implementation/AnimatorCharacterActionController.cs.meta new file mode 100644 index 0000000..85cb8a8 --- /dev/null +++ b/Assets/Domain/Character/Script/Implementation/AnimatorCharacterActionController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e790d7e19b7f6ca4d89ef92666ae3e4a \ No newline at end of file diff --git a/Assets/Domain/Character/Script/Implementation/Live2DCharacterActionController.cs b/Assets/Domain/Character/Script/Implementation/Live2DCharacterActionController.cs new file mode 100644 index 0000000..17b917e --- /dev/null +++ b/Assets/Domain/Character/Script/Implementation/Live2DCharacterActionController.cs @@ -0,0 +1,1067 @@ +#nullable enable +using Live2D.Cubism.Core; +using Live2D.Cubism.Framework.Motion; +using Live2D.Cubism.Framework.MotionFade; +using ProjectVG.Domain.Character.Live2D.Model; +using ProjectVG.Domain.Chat.Model; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using UnityEngine; + +namespace ProjectVG.Domain.Character.Service +{ + /// + /// 모션 전환 정보를 담는 클래스 + /// + public class MotionTransition + { + public Live2DModelConfig.MotionClipMapping? previousMotion; + public Live2DModelConfig.MotionClipMapping newMotion; + public float startTime; + public float duration; + public System.Action? endCallback; + public bool isCompleted; + + public MotionTransition(Live2DModelConfig.MotionClipMapping? prev, Live2DModelConfig.MotionClipMapping next, float dur, System.Action? callback = null) + { + previousMotion = prev; + newMotion = next; + startTime = Time.time; + duration = dur; + endCallback = callback; + isCompleted = false; + } + + /// + /// 전환 진행률을 반환한다. (0.0 ~ 1.0) + /// + public float GetProgress() + { + if (isCompleted) return 1.0f; + + float elapsed = Time.time - startTime; + return Mathf.Clamp01(elapsed / duration); + } + + /// + /// 부드러운 전환 곡선을 적용한 가중치를 반환한다. + /// + public float GetSmoothWeight() + { + float progress = GetProgress(); + return Mathf.SmoothStep(0.0f, 1.0f, progress); + } + + /// + /// 전환 곡선 타입에 따른 가중치를 반환한다. + /// + /// 전환 곡선 타입 + public float GetWeightByCurveType(TransitionCurveType curveType) + { + float progress = GetProgress(); + + return curveType switch + { + TransitionCurveType.Linear => progress, + TransitionCurveType.EaseIn => progress * progress, + TransitionCurveType.EaseOut => 1.0f - (1.0f - progress) * (1.0f - progress), + TransitionCurveType.EaseInOut => Mathf.SmoothStep(0.0f, 1.0f, progress), + _ => progress + }; + } + + /// + /// 새 모션의 가중치를 반환한다. (0.0 -> 1.0) + /// + public float GetNewMotionWeight(TransitionCurveType curveType) + { + return GetWeightByCurveType(curveType); + } + + /// + /// 이전 모션의 가중치를 반환한다. (1.0 -> 0.0) + /// + public float GetPreviousMotionWeight(TransitionCurveType curveType) + { + return 1.0f - GetWeightByCurveType(curveType); + } + } + + /// + /// 전환 곡선 타입 + /// + public enum TransitionCurveType + { + Linear, + EaseIn, + EaseOut, + EaseInOut + } + + public class Live2DCharacterActionController : MonoBehaviour, ICharacterActionController + { + private CubismMotionController? _motionController; + private CubismFadeController? _fadeController; + private List? _motionClips; + + // 현재 상태 + private CharacterActionType _currentAction = CharacterActionType.Idle; + private Live2DModelConfig.MotionClipMapping? _currentMotionClip; + private bool _isPlayingAction = false; + + // Talk 상태 관리 + private bool _isTalkModeActive = false; + private Coroutine? _talkLoopCoroutine; + + + // Motion Control System + private Coroutine? _currentMotionCoroutine; + private System.Action? _currentMotionEndCallback; + + // Motion Transition System + private MotionTransition? _currentTransition; + private Coroutine? _transitionCoroutine; + + // Transition Settings + [SerializeField] private float _transitionDuration = 1.0f; // 부드러운 전환을 위한 1초 설정 + [SerializeField] private bool _enableSmoothTransition = true; + [SerializeField] private TransitionCurveType _transitionCurveType = TransitionCurveType.EaseInOut; + + // Motion End Behavior Control + public System.Action? OnMotionStop { get; set; } + public System.Action? OnMotionLoop { get; set; } + public System.Action? OnMotionReturnToIdle { get; set; } + public System.Action? OnTalkLoop { get; set; } + + #region Unity Lifecycle + + /// + /// 서비스를 초기화한다. + /// + /// Live2D Motion Controller + /// Motion Clip Mappings + public void Initialize(CubismMotionController motionController, List motionClips) + { + _motionController = motionController; + _fadeController = GetComponent(); + _motionClips = motionClips; + _currentAction = CharacterActionType.Idle; + _isPlayingAction = false; + + // 모션 종료 이벤트 핸들러 등록 + if (_motionController != null) + { + _motionController.AnimationEndHandler += OnLive2DMotionEnd; + } + + if (_fadeController == null) + { + Debug.LogWarning("[Live2DCharacterActionController] CubismFadeController를 찾을 수 없습니다. Live2D 자체 페이딩에 의존합니다."); + } + else + { + Debug.Log("[Live2DCharacterActionController] CubismFadeController 감지됨 - 부드러운 전환 지원"); + } + + // 기본 Action 콜백 설정 + SetupDefaultCallbacks(); + + // 모션 클립 구성 및 전환 설정 디버깅 + Debug.Log($"===============[Live2DCharacterActionController] 초기화 완료 - Motion Clips: {_motionClips?.Count ?? 0}개"); + Debug.Log($"[Live2DCharacterActionController] 부드러운 전환: {_enableSmoothTransition}, 전환 시간: {_transitionDuration}초, 곡선: {_transitionCurveType}"); + + if (_motionClips != null) + { + foreach (var clip in _motionClips) + { + Debug.Log($"[Live2DCharacterActionController] Motion Clip: {clip.Id} | Group: '{clip.MotionGroup}' | AnimationClip: {(clip.animationClip != null ? "O" : "X")}"); + } + + // idle 그룹 확인 + var idleClips = _motionClips.Where(c => c.MotionGroup.Equals("idle", System.StringComparison.OrdinalIgnoreCase)).ToList(); + Debug.Log($"[Live2DCharacterActionController] idle 그룹 모션 클립 수: {idleClips.Count}개 - Idle 모션 간 부드러운 전환 적용됨"); + } + } + + /// + /// 인터페이스 구현을 위한 매개변수 없는 초기화 메서드 + /// + public void Initialize() + { + // 이미 초기화된 경우 추가 작업 없음 + if (_motionController != null && _motionClips != null) + { + return; + } + + Debug.LogWarning("[Live2DCharacterActionController] Initialize() called without parameters. Make sure to call Initialize(motionController, motionClips) first."); + } + + /// + /// GameObject가 활성화될 때 Idle 모션을 시작한다. + /// + private void OnEnable() + { + if (_currentAction == CharacterActionType.Idle && _motionController != null) + { + // CubismMotionController가 완전히 초기화될 때까지 잠시 대기 + StartCoroutine(DelayedIdleStart()); + } + } + + /// + /// CubismMotionController 초기화 대기 후 Idle 모션 시작 + /// + private IEnumerator DelayedIdleStart() + { + // 1프레임 대기 (CubismMotionController OnEnable 완료 대기) + yield return null; + + // 추가로 초기화 완료 확인을 위한 짧은 대기 + yield return new WaitForSeconds(0.1f); + + if (_currentAction == CharacterActionType.Idle && _motionController != null) + { + PlayRandomIdleMotion(); + } + } + + /// + /// GameObject가 비활성화될 때 모든 코루틴을 중지한다. + /// + private void OnDisable() + { + StopAllMotionCoroutines(); + } + + #endregion + + + /// + /// 기본 모션 종료 콜백을 설정한다. + /// + private void SetupDefaultCallbacks() + { + OnMotionStop = () => { + Debug.Log("[Live2DCharacterActionController] 모션 정지"); + _isPlayingAction = false; + }; + + OnMotionLoop = () => { + Debug.Log("[Live2DCharacterActionController] 모션 루프 - 같은 그룹의 다른 모션 재생"); + if (_currentMotionClip != null) + { + PlayRandomMotionFromGroup(_currentMotionClip.MotionGroup, endCallback: OnMotionLoop); + } + }; + + OnMotionReturnToIdle = () => { + Debug.Log("[Live2DCharacterActionController] 모션 종료 - Idle로 복귀"); + PlayAction(CharacterActionType.Idle); + }; + + OnTalkLoop = () => { + Debug.Log("[Live2DCharacterActionController] Talk 모션 루프 - 다음 Talk 모션 재생"); + if (_isTalkModeActive) + { + PlayRandomMotionFromGroup("talk", endCallback: OnTalkLoop); + } + else + { + Debug.Log("[Live2DCharacterActionController] Talk 모드 비활성화 - Idle로 복귀"); + PlayAction(CharacterActionType.Idle); + } + }; + } + + /// + /// 액션을 실행한다. + /// + /// 액션 타입 + public void PlayAction(CharacterActionType actionType) + { + if (_motionController == null) + { + Debug.LogWarning("[Live2DCharacterActionController] MotionController가 초기화되지 않았습니다."); + return; + } + + if (_motionClips == null || _motionClips.Count == 0) { + Debug.LogWarning("[Live2DCharacterActionController] MotionClips이 설정되지 않았습니다."); + return; + } + + try + { + // 새 액션 시작 전에 항상 이전 모션 관련 코루틴 정리 + StopAllMotionCoroutines(); + + _currentAction = actionType; + string motionGroup = GetMotionGroupName(actionType); + + if (actionType == CharacterActionType.Idle) + { + // Idle 액션인 경우 다양한 Idle 모션을 순차 재생 + PlayRandomIdleMotion(); + } + else + { + // 일반 액션인 경우 ReturnToIdle로 종료 + PlayRandomMotionFromGroup(motionGroup, endCallback: OnMotionReturnToIdle); + } + + Debug.Log($"[Live2DCharacterActionController] 액션 재생: {actionType}, 그룹: {motionGroup}"); + } + catch (System.Exception ex) + { + Debug.LogError($"[Live2DCharacterActionController] 액션 재생 실패: {ex.Message}"); + _isPlayingAction = false; + } + } + + /// + /// 현재 재생 중인 액션을 중지한다. + /// + public void StopCurrentAction() + { + if (_motionController == null) return; + + // 모든 모션 관련 코루틴 중지 + StopAllMotionCoroutines(); + + // Idle 모션 재생 + PlayAction(CharacterActionType.Idle); + _isPlayingAction = false; + _currentAction = CharacterActionType.Idle; + Debug.Log("[Live2DCharacterActionController] 액션 중지"); + } + + /// + /// 현재 모션을 즉시 중지하고 Idle로 돌아간다. + /// + public void ForceStopAndReturnToIdle() + { + if (_motionController == null) return; + + // 모든 모션과 코루틴 중지 + ForceStopMotion(); + StopAllMotionCoroutines(); + + // 즉시 Idle로 전환 + _currentAction = CharacterActionType.Idle; + _isPlayingAction = false; + _currentMotionClip = null; + + PlayRandomIdleMotion(); + + Debug.Log("[Live2DCharacterActionController] 강제 중지 후 Idle로 복귀"); + } + + + /// + /// 액션이 재생 중인지 확인한다. + /// + /// 액션 재생 중이면 true + public bool IsPlaying() + { + return _isPlayingAction && _motionController != null; + } + + /// + /// 현재 액션 타입을 반환한다. + /// + /// 현재 액션 타입 + public CharacterActionType GetCurrentAction() + { + return _currentAction; + } + + #region Talk Mode Control + + /// + /// Talk 모드를 시작한다. Talk 모션들을 연속적으로 루프한다. + /// + public void StartTalkMode() + { + if (_isTalkModeActive) + { + Debug.Log("[Live2DCharacterActionController] Talk 모드 이미 활성화됨"); + return; + } + + _isTalkModeActive = true; + _currentAction = CharacterActionType.Talk; + + // Talk 모션 시작 (OnTalkLoop 콜백으로 연속 루프) + PlayRandomMotionFromGroup("talk", endCallback: OnTalkLoop); + + Debug.Log("[Live2DCharacterActionController] Talk 모드 시작 - 연속 루프 모드"); + } + + /// + /// Talk 모드를 종료한다. 현재 재생 중인 Talk 모션이 끝난 후 Idle로 돌아간다. + /// + public void StopTalkMode() + { + if (!_isTalkModeActive) + { + Debug.Log("[Live2DCharacterActionController] Talk 모드 이미 비활성화됨"); + return; + } + + _isTalkModeActive = false; + + Debug.Log("[Live2DCharacterActionController] Talk 모드 종료 요청 - 현재 모션 종료 후 Idle로 복귀"); + + // 현재 Talk 모션이 끝나면 OnTalkLoop에서 _isTalkModeActive 확인 후 Idle로 전환 + // 즉시 중단하려면 ForceStopAndReturnToIdle() 사용 + } + + /// + /// Talk 모드가 활성화되어 있는지 확인한다. + /// + /// Talk 모드 활성화 여부 + public bool IsTalkModeActive() + { + return _isTalkModeActive; + } + + /// + /// Talk 모드를 즉시 중단하고 Idle로 돌아간다. + /// + public void ForceStopTalkMode() + { + _isTalkModeActive = false; + ForceStopAndReturnToIdle(); + Debug.Log("[Live2DCharacterActionController] Talk 모드 즐시 중단 및 Idle 복귀"); + } + + #endregion + + /// + /// 액션 타입을 모션 그룹 이름으로 변환한다. + /// + private string GetMotionGroupName(CharacterActionType actionType) + { + return actionType switch + { + CharacterActionType.Idle => "idle", + CharacterActionType.Listen => "reaction", + CharacterActionType.Talk => "talk", + _ => "idle" + }; + } + + #region Motion Control System + + /// + /// 지정된 그룹에서 랜덤한 모션을 재생한다. (부드러운 전환 적용) + /// + /// 모션 그룹 + /// 모션 종료 시 호출될 콜백 + private void PlayRandomMotionFromGroup(string motionGroup, System.Action? endCallback = null) + { + if (_motionClips == null || _motionController == null) return; + + // 해당 그룹에서 모션 선택 + var groupMotions = _motionClips + .Where(clip => clip.MotionGroup.Equals(motionGroup, System.StringComparison.OrdinalIgnoreCase)) + .Where(clip => clip.animationClip != null) + .ToList(); + + if (groupMotions.Count == 0) + { + Debug.LogWarning($"[Live2DCharacterActionController] '{motionGroup}' 그룹의 모션을 찾을 수 없습니다."); + return; + } + + // 랜덤 선택 + var selectedMotion = groupMotions[Random.Range(0, groupMotions.Count)]; + + // 부드러운 전환 시스템 사용 + if (_enableSmoothTransition && _currentMotionClip != null) + { + StartMotionTransition(selectedMotion, endCallback); + } + else + { + // 부드러운 전환이 비활성화되었거나 현재 모션이 없는 경우 즉시 전환 + PerformImmediateTransition(selectedMotion, endCallback); + } + + Debug.Log($"[Live2DCharacterActionController] 모션 재생: {selectedMotion.Id} (그룹: {motionGroup})"); + } + + /// + /// 짧은 딜레이 후 모션을 재생한다. (Priority 충돌 방지) + /// + private IEnumerator DelayedMotionPlay(AnimationClip clip, int priority, System.Action? endCallback) + { + yield return new WaitForSeconds(_transitionDuration * 0.1f); // Priority 충돌 방지용 짧은 대기 + + if (_motionController != null) + { + // 모션 재생 (CubismFadeController가 자동 페이드 처리) + _motionController.PlayAnimation(clip, priority: 2, isLoop: false); + _isPlayingAction = true; + + // 모션 종료 처리 설정 + _currentMotionEndCallback = endCallback; + StopAllMotionCoroutines(); + _currentMotionCoroutine = StartCoroutine(WaitForMotionEnd(clip.length, endCallback)); + + Debug.Log($"[Live2DCharacterActionController] 모션 재생: {_currentMotionClip?.Id} (그룹: {_currentMotionClip?.MotionGroup}), 길이: {clip.length}s"); + } + } + + /// + /// Idle 모션을 다양하게 순차 재생한다. + /// + private void PlayRandomIdleMotion() + { + System.Action recursiveIdleCallback = () => { + if (_currentAction == CharacterActionType.Idle) + { + StartCoroutine(DelayedIdleMotionStart()); + } + }; + + // Idle 모션 전용 재생 (PlayRandomMotionFromGroup 대신 직접 처리) + PlayIdleMotionDirect(recursiveIdleCallback); + } + + /// + /// Idle 모션을 직접 재생한다. (부드러운 전환 적용) + /// + private void PlayIdleMotionDirect(System.Action? endCallback) + { + if (_motionClips == null || _motionController == null) return; + + // idle 그룹 모션 찾기 + var idleMotions = _motionClips + .Where(clip => clip.MotionGroup.Equals("idle", System.StringComparison.OrdinalIgnoreCase)) + .Where(clip => clip.animationClip != null) + .ToList(); + + if (idleMotions.Count == 0) + { + Debug.LogWarning("[Live2DCharacterActionController] idle 모션을 찾을 수 없습니다."); + return; + } + + // 현재와 같은 모션을 선택하지 않도록 필터링 + var availableIdles = idleMotions; + if (_currentMotionClip != null && idleMotions.Count > 1) + { + availableIdles = idleMotions.Where(idle => idle.Id != _currentMotionClip.Id).ToList(); + if (availableIdles.Count == 0) availableIdles = idleMotions; // 필터링 후 비어있으면 원본 사용 + } + + // 랜덤 선택 + var selectedIdle = availableIdles[Random.Range(0, availableIdles.Count)]; + + // Idle 모션 간에도 부드러운 전환 적용 + if (_enableSmoothTransition && _currentMotionClip != null) + { + // 현재 모션이 있다면 부드러운 전환 적용 + Debug.Log($"[Live2DCharacterActionController] Idle 모션 부드러운 전환: {_currentMotionClip.Id} -> {selectedIdle.Id}"); + StartMotionTransition(selectedIdle, endCallback); + } + else + { + // 첫 번째 Idle 재생이거나 부드러운 전환이 비활성화된 경우 즉시 전환 + Debug.Log($"[Live2DCharacterActionController] Idle 모션 즉시 전환: {selectedIdle.Id}"); + PerformImmediateIdleTransition(selectedIdle, endCallback); + } + + // Idle 모션 전용 설정 + _isPlayingAction = false; // Idle은 Action이 아님 + } + + /// + /// Idle 모션을 즉시 전환한다 (기존 방식 유지) + /// + private void PerformImmediateIdleTransition(Live2DModelConfig.MotionClipMapping selectedIdle, System.Action? endCallback) + { + if (_motionController == null || selectedIdle.animationClip == null) return; + + // 전환이 진행 중이라면 중단 + if (_transitionCoroutine != null) + { + StopCoroutine(_transitionCoroutine); + _transitionCoroutine = null; + _currentTransition = null; + } + + _motionController.StopAllAnimation(); + _currentMotionClip = selectedIdle; + + // 짧은 대기 후 Idle 모션 재생 (PriorityIdle 사용) + StartCoroutine(DelayedMotionPlay(selectedIdle.animationClip, CubismMotionPriority.PriorityIdle, endCallback)); + + Debug.Log($"[Live2DCharacterActionController] Idle 모션 즉시 전환 실행: {selectedIdle.Id}"); + } + + /// + /// 딜레이 후 다음 Idle 모션을 재생한다. + /// + private IEnumerator DelayedIdleMotionStart() + { + // Idle 모션 간 전환을 위한 짧은 대기 시간 + yield return new WaitForSeconds(_transitionDuration * 0.1f); + + // 여전히 Idle 상태이고 전환이 진행 중이 아닐 때만 다음 모션 재생 + if (_currentAction == CharacterActionType.Idle && !IsTransitioning()) + { + Debug.Log("[Live2DCharacterActionController] 다음 Idle 모션 재생 시작"); + PlayRandomIdleMotion(); + } + } + + /// + /// 모션 종료를 기다리는 코루틴 + /// + private IEnumerator WaitForMotionEnd(float duration, System.Action? endCallback) + { + yield return new WaitForSeconds(duration); + + // 모션이 완료되면 콜백 호출 + endCallback?.Invoke(); + _currentMotionCoroutine = null; + } + + /// + /// 모션 종료 동작을 변경한다. + /// + public void SetMotionEndBehavior(System.Action? onStop = null, System.Action? onLoop = null, System.Action? onReturnToIdle = null) + { + if (onStop != null) OnMotionStop = onStop; + if (onLoop != null) OnMotionLoop = onLoop; + if (onReturnToIdle != null) OnMotionReturnToIdle = onReturnToIdle; + } + + /// + /// 현재 모션을 중지한다. + /// + public void StopCurrentMotion() + { + OnMotionStop?.Invoke(); + } + + /// + /// 현재 모션을 루프한다. + /// + public void LoopCurrentMotion() + { + OnMotionLoop?.Invoke(); + } + + /// + /// 현재 모션을 종료하고 Idle로 돌아간다. + /// + public void ReturnToIdle() + { + OnMotionReturnToIdle?.Invoke(); + } + + #endregion + + #region Motion Transition Settings + + /// + /// 부드러운 전환 기능을 활성화/비활성화한다. + /// + /// 활성화 여부 + public void SetSmoothTransitionEnabled(bool enabled) + { + _enableSmoothTransition = enabled; + Debug.Log($"[Live2DCharacterActionController] 부드러운 전환 설정: {enabled}"); + } + + /// + /// 모션 전환 지속 시간을 설정한다. + /// + /// 전환 시간 (초) + public void SetTransitionDuration(float duration) + { + _transitionDuration = Mathf.Max(0.2f, duration); // 최소 시간을 더 길게 + Debug.Log($"[Live2DCharacterActionController] 전환 지속 시간 설정: {_transitionDuration}초 (단일레이어 최적화)"); + } + + /// + /// 전환 곡선 타입을 설정한다. + /// + /// 곡선 타입 + public void SetTransitionCurveType(TransitionCurveType curveType) + { + _transitionCurveType = curveType; + Debug.Log($"[Live2DCharacterActionController] 전환 곡선 타입 설정: {curveType}"); + } + + /// + /// 현재 전환 설정 정보를 반환한다. + /// + public (bool enabled, float duration, TransitionCurveType curveType) GetTransitionSettings() + { + return (_enableSmoothTransition, _transitionDuration, _transitionCurveType); + } + + /// + /// 전환이 현재 진행 중인지 확인한다. + /// + public bool IsTransitionInProgress() + { + return IsTransitioning(); + } + + /// + /// 현재 전환의 진행률을 반환한다. (0.0 ~ 1.0, 전환 중이 아니면 -1) + /// + public float GetTransitionProgress() + { + return _currentTransition?.GetProgress() ?? -1.0f; + } + + /// + /// 빠른 전환 설정 적용 (Idle 모션용) + /// + public void SetFastTransition() + { + SetTransitionDuration(_transitionDuration * 0.3f); + SetTransitionCurveType(TransitionCurveType.EaseOut); + Debug.Log("[Live2DCharacterActionController] 빠른 전환 설정 적용"); + } + + /// + /// 부드러운 전환 설정 적용 (액션 모션용) + /// + public void SetSmoothTransition() + { + SetTransitionDuration(_transitionDuration * 0.6f); + SetTransitionCurveType(TransitionCurveType.EaseInOut); + Debug.Log("[Live2DCharacterActionController] 부드러운 전환 설정 적용"); + } + + /// + /// 느린 전환 설정 적용 (특수 효과용) + /// + public void SetSlowTransition() + { + SetTransitionDuration(_transitionDuration * 1.5f); + SetTransitionCurveType(TransitionCurveType.EaseInOut); + Debug.Log("[Live2DCharacterActionController] 느린 전환 설정 적용"); + } + + #endregion + + /// + /// Live2D 모션 상태를 체크한다. + /// + private bool IsMotionPlaying() + { + if (_motionController == null) return false; + + try + { + // Layer 0에서 모션이 재생 중인지 확인 + return _motionController.IsPlayingAnimation(0); + } + catch (System.Exception ex) + { + Debug.LogWarning($"[Live2DCharacterActionController] 모션 상태 체크 실패: {ex.Message}"); + return false; + } + } + + /// + /// 현재 재생 중인 모션을 강제로 중지한다. + /// + private void ForceStopMotion() + { + if (_motionController != null) + { + _motionController.StopAllAnimation(); + Debug.Log("[Live2DCharacterActionController] 모든 모션 강제 중지"); + } + } + + #region Motion Control Helpers + + /// + /// 모든 모션 관련 코루틴을 중지한다. + /// + private void StopAllMotionCoroutines() + { + // 이 컴포넌트가 시작한 모든 코루틴 중지 + StopAllCoroutines(); + _currentMotionCoroutine = null; + _currentMotionEndCallback = null; + _transitionCoroutine = null; + } + + /// + /// 현재 전환이 진행 중인지 확인한다. + /// + private bool IsTransitioning() + { + return _currentTransition != null && !_currentTransition.isCompleted; + } + + /// + /// 새로운 모션 전환을 시작한다. + /// + /// 새로운 모션 클립 + /// 전환 완료 후 실행될 콜백 + private void StartMotionTransition(Live2DModelConfig.MotionClipMapping newMotionClip, System.Action? endCallback = null) + { + // 이전 전환 중단 + if (_transitionCoroutine != null) + { + StopCoroutine(_transitionCoroutine); + _transitionCoroutine = null; + } + + // 부드러운 전환이 비활성화된 경우 즉시 전환 + if (!_enableSmoothTransition) + { + PerformImmediateTransition(newMotionClip, endCallback); + return; + } + + // 새로운 전환 생성 + _currentTransition = new MotionTransition(_currentMotionClip, newMotionClip, _transitionDuration, endCallback); + _currentMotionClip = newMotionClip; + + // 전환 코루틴 시작 + _transitionCoroutine = StartCoroutine(TransitionCoroutine()); + + Debug.Log($"[Live2DCharacterActionController] 모션 전환 시작: {_currentTransition.previousMotion?.Id} -> {newMotionClip.Id}"); + } + + /// + /// 즉시 모션을 전환한다 (기존 방식) + /// + private void PerformImmediateTransition(Live2DModelConfig.MotionClipMapping newMotionClip, System.Action? endCallback = null) + { + if (_motionController == null || newMotionClip.animationClip == null) return; + + _motionController.StopAllAnimation(); + _motionController.PlayAnimation(newMotionClip.animationClip, priority: 2, isLoop: false); + _currentMotionClip = newMotionClip; + _isPlayingAction = true; + + // 기존 모션 종료 처리 설정 + _currentMotionEndCallback = endCallback; + StopAllMotionCoroutines(); + _currentMotionCoroutine = StartCoroutine(WaitForMotionEnd(newMotionClip.animationClip.length, endCallback)); + } + + #endregion + + #region Motion Transition Coroutine + + /// + /// 부드러운 모션 전환을 처리하는 메인 코루틴 (단일 레이어 최적화) + /// + private IEnumerator TransitionCoroutine() + { + if (_currentTransition == null || _motionController == null) + { + yield break; + } + + var transition = _currentTransition; + var newClip = transition.newMotion.animationClip; + if (newClip == null) + { + yield break; + } + + // 단일 레이어 환경에서는 Live2D 내장 페이딩 시스템을 활용한 부드러운 전환 + bool useLayerWeights = _motionController.LayerCount > 1; + + Debug.Log($"[Live2DCharacterActionController] 전환 시작 - LayerCount: {_motionController.LayerCount}, 단일레이어 최적화: {!useLayerWeights}"); + + if (!useLayerWeights) + { + // 단일 레이어 환경: 전환 시간을 고려한 딜레이 후 새 모션 재생 + yield return PerformSingleLayerTransition(transition); + } + else + { + // 다중 레이어 환경: 기존 방식 사용 + yield return PerformMultiLayerTransition(transition); + } + + // 전환 완료 후 정리 + _transitionCoroutine = null; + + // 콜백 실행 + var callback = transition.endCallback; + _currentTransition = null; + callback?.Invoke(); + + Debug.Log($"[Live2DCharacterActionController] 모션 전환 완료: {transition.newMotion.Id}"); + } + + /// + /// 단일 레이어에서의 부드러운 전환 (Live2D 내장 페이딩 활용) + /// + private IEnumerator PerformSingleLayerTransition(MotionTransition transition) + { + var newClip = transition.newMotion.animationClip; + if (newClip == null || _motionController == null) yield break; + + // Idle 모션과 일반 액션 모션에 따른 Priority 설정 + int priority = (transition.newMotion.MotionGroup.Equals("idle", System.StringComparison.OrdinalIgnoreCase)) + ? CubismMotionPriority.PriorityIdle + : 2; + + // CubismFadeController의 FadeInTime을 고려한 최적 전환 타이밍 + // 이전 모션이 일정 부분 재생된 후 새 모션을 시작하여 자연스러운 오버랩 생성 + float fadeOverlap = _transitionDuration * 0.2f; // 전환시간의 20% 오버랩 + float startDelay = Mathf.Max(0.1f, _transitionDuration - fadeOverlap); + + Debug.Log($"[Live2DCharacterActionController] 단일레이어 부드러운 전환 - {startDelay:F2}초 후 새 모션 시작 (오버랩: {fadeOverlap:F2}초)"); + + // 전환 시작까지 대기 + yield return new WaitForSeconds(startDelay); + + // Live2D CubismFadeController가 내장 FadeInTime/FadeOutTime을 사용하여 자동 페이딩 처리 + // Priority와 함께 모션 시작 - Live2D가 자동으로 이전 모션과 블렌딩 + _motionController.PlayAnimation(newClip, layerIndex: 0, priority: priority, isLoop: false); + _isPlayingAction = !transition.newMotion.MotionGroup.Equals("idle", System.StringComparison.OrdinalIgnoreCase); + + Debug.Log($"[Live2DCharacterActionController] 새 모션 시작: {transition.newMotion.Id} (Priority: {priority})"); + + // 페이딩 완료까지 대기 + yield return new WaitForSeconds(fadeOverlap); + + transition.isCompleted = true; + Debug.Log($"[Live2DCharacterActionController] 단일레이어 전환 완료"); + } + + /// + /// 다중 레이어에서의 부드러운 전환 (기존 방식) + /// + private IEnumerator PerformMultiLayerTransition(MotionTransition transition) + { + var newClip = transition.newMotion.animationClip; + if (newClip == null || _motionController == null) yield break; + + // Idle 모션과 일반 액션 모션에 따른 Priority 설정 + int priority = (transition.newMotion.MotionGroup.Equals("idle", System.StringComparison.OrdinalIgnoreCase)) + ? CubismMotionPriority.PriorityIdle + : 2; + + // 새 모션을 레이어 1에 즉시 시작 + _motionController.PlayAnimation(newClip, layerIndex: 1, priority: priority, isLoop: false); + _isPlayingAction = !transition.newMotion.MotionGroup.Equals("idle", System.StringComparison.OrdinalIgnoreCase); + + // 전환 진행 + while (!transition.isCompleted) + { + float progress = transition.GetProgress(); + + if (progress >= 1.0f) + { + CompleteTransition(); + break; + } + + // 가중치 계산 + float newWeight = transition.GetNewMotionWeight(_transitionCurveType); + float oldWeight = transition.GetPreviousMotionWeight(_transitionCurveType); + ApplyLayerWeights(oldWeight, newWeight); + + yield return null; // 다음 프레임까지 대기 + } + } + + /// + /// 모션 레이어에 가중치를 적용한다 + /// + private void ApplyLayerWeights(float oldWeight, float newWeight) + { + // CubismMotionController의 레이어 가중치를 직접 설정할 수 있는지 확인 + // 현재 Live2D SDK에서는 직접적인 레이어 가중치 설정이 제한적이므로 + // CubismFadeController를 통한 자동 페이딩에 의존 + + Debug.Log($"[Live2DCharacterActionController] 가중치 적용 - Old: {oldWeight:F2}, New: {newWeight:F2}"); + } + + /// + /// Fade Controller를 통한 가중치 적용 + /// + private void ApplyFadeWeights(float oldWeight, float newWeight) + { + // CubismFadeController가 자동으로 모션 간 페이딩을 처리 + // 여기서는 전환 상태만 모니터링 + + Debug.Log($"[Live2DCharacterActionController] 페이드 가중치 - Old: {oldWeight:F2}, New: {newWeight:F2}"); + } + + /// + /// 모션 전환을 완료한다 + /// + private void CompleteTransition() + { + if (_currentTransition == null) return; + + _currentTransition.isCompleted = true; + + // 이전 모션이 재생 중인 레이어들을 정리 + if (_motionController != null) + { + // 레이어 0의 모션 중지 (이전 모션) + _motionController.StopAnimation(0); + + // 새 모션을 레이어 0으로 이동 (선택적) + // Live2D SDK의 특성상 자동으로 정리되므로 추가 작업 불필요 + } + + // 모션 종료 처리를 위한 대기 코루틴 시작 + var newClip = _currentTransition.newMotion.animationClip; + if (newClip != null) + { + float remainingTime = newClip.length - _currentTransition.duration; + if (remainingTime > 0) + { + _currentMotionCoroutine = StartCoroutine(WaitForMotionEnd(remainingTime, _currentTransition.endCallback)); + } + } + + Debug.Log("[Live2DCharacterActionController] 모션 전환 완료 처리"); + } + + #endregion + + /// + /// Live2D 모션이 종료될 때 호출되는 이벤트 핸들러 + /// + private void OnLive2DMotionEnd(int instanceId) + { + if (_currentMotionCoroutine == null && _currentMotionEndCallback != null) + { + var callback = _currentMotionEndCallback; + _currentMotionEndCallback = null; + callback.Invoke(); + } + } + + private void OnDestroy() + { + // 이벤트 핸들러 해제 + if (_motionController != null) + { + _motionController.AnimationEndHandler -= OnLive2DMotionEnd; + } + + // 모든 전환 및 모션 코루틴 정리 + StopAllMotionCoroutines(); + _currentTransition = null; + } + } +} \ No newline at end of file diff --git a/Assets/Domain/Character/Script/Implementation/Live2DCharacterActionController.cs.meta b/Assets/Domain/Character/Script/Implementation/Live2DCharacterActionController.cs.meta new file mode 100644 index 0000000..96afb7b --- /dev/null +++ b/Assets/Domain/Character/Script/Implementation/Live2DCharacterActionController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c0cfad9dbe725df438fbc1d4ef84f810 \ No newline at end of file diff --git a/Assets/Domain/Character/Script/Interface.meta b/Assets/Domain/Character/Script/Interface.meta new file mode 100644 index 0000000..1089469 --- /dev/null +++ b/Assets/Domain/Character/Script/Interface.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8f5f5fbb105709748a36f5264c692ee8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Domain/Character/Script/Interface/ICharacterActionController.cs b/Assets/Domain/Character/Script/Interface/ICharacterActionController.cs new file mode 100644 index 0000000..5ff7343 --- /dev/null +++ b/Assets/Domain/Character/Script/Interface/ICharacterActionController.cs @@ -0,0 +1,70 @@ +#nullable enable +using Live2D.Cubism.Core; +using Live2D.Cubism.Framework.Motion; +using ProjectVG.Domain.Character.Live2D.Model; +using System.Collections.Generic; +using UnityEngine; + +namespace ProjectVG.Domain.Character.Service +{ + public interface ICharacterActionController + { + /// + /// 액션 컨트롤러를 초기화한다. + /// + void Initialize(); + + /// + /// 액션을 실행한다. + /// + /// 액션 타입 + void PlayAction(CharacterActionType actionType); + + /// + /// 현재 재생 중인 액션을 중지한다. + /// + void StopCurrentAction(); + + /// + /// 현재 모션을 즉시 중지하고 Idle로 돌아간다. + /// + void ForceStopAndReturnToIdle(); + + /// + /// 액션이 재생 중인지 확인한다. + /// + /// 액션 재생 중이면 true + bool IsPlaying(); + + /// + /// 현재 액션 타입을 반환한다. + /// + /// 현재 액션 타입 + CharacterActionType GetCurrentAction(); + + /// + /// 모션 종료 동작을 변경한다. + /// + void SetMotionEndBehavior(System.Action? onStop = null, System.Action? onLoop = null, System.Action? onReturnToIdle = null); + + /// + /// 현재 모션을 중지한다. + /// + void StopCurrentMotion(); + + /// + /// 현재 모션을 루프한다. + /// + void LoopCurrentMotion(); + + /// + /// 현재 모션을 종료하고 Idle로 돌아간다. + /// + void ReturnToIdle(); + + // Events + System.Action? OnMotionStop { get; set; } + System.Action? OnMotionLoop { get; set; } + System.Action? OnMotionReturnToIdle { get; set; } + } +} \ No newline at end of file diff --git a/Assets/Domain/Character/Script/Interface/ICharacterActionController.cs.meta b/Assets/Domain/Character/Script/Interface/ICharacterActionController.cs.meta new file mode 100644 index 0000000..1889e06 --- /dev/null +++ b/Assets/Domain/Character/Script/Interface/ICharacterActionController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 995e981d4d26f2741a76d19151756792 \ No newline at end of file diff --git a/Assets/Domain/Chat/Model/CharacterActionData.cs b/Assets/Domain/Chat/Model/CharacterActionData.cs index 816e0ba..917a708 100644 --- a/Assets/Domain/Chat/Model/CharacterActionData.cs +++ b/Assets/Domain/Chat/Model/CharacterActionData.cs @@ -1,5 +1,6 @@ #nullable enable -using UnityEngine; +using System.Collections.Generic; +using System.Linq; using ProjectVG.Domain.Character.Service; namespace ProjectVG.Domain.Chat.Model @@ -7,10 +8,20 @@ namespace ProjectVG.Domain.Chat.Model public class CharacterActionData { /// - /// 캐릭터가 수행할 행동 타입 + /// 주 액션 타입 (첫 번째 액션 또는 기본 액션) /// public CharacterActionType ActionType { get; set; } = CharacterActionType.Talk; + /// + /// 모든 액션들 (새 API에서 지원하는 복수 액션) + /// + public List Actions { get; set; } = new List(); + + /// + /// 감정 상태 (새 API에서 지원) + /// + public string? Emotion { get; set; } + /// /// 액션 타입으로 초기화 /// @@ -18,22 +29,48 @@ public class CharacterActionData public CharacterActionData(CharacterActionType actionType = CharacterActionType.Talk) { ActionType = actionType; + Actions = new List { actionType }; } /// - /// 행동 문자열로 초기화 + /// 단일 행동 문자열로 초기화 (레거시 지원) /// /// 행동 문자열 public CharacterActionData(string? action = null) { ActionType = ParseActionString(action); + Actions = new List { ActionType }; + } + + /// + /// 복수 액션과 감정으로 초기화 (새 API) + /// + /// 액션 문자열 배열 + /// 감정 상태 + public CharacterActionData(string[]? actions, string? emotion = null) + { + Emotion = emotion; + Actions = ParseActionsArray(actions); + ActionType = Actions.FirstOrDefault(); } /// /// 행동이 설정되어 있는지 확인 /// /// 행동이 설정되어 있으면 true - public bool HasAction() => true; + public bool HasAction() => Actions.Count > 0; + + /// + /// 복수의 액션이 있는지 확인 + /// + /// 액션이 2개 이상이면 true + public bool HasMultipleActions() => Actions.Count > 1; + + /// + /// 감정이 설정되어 있는지 확인 + /// + /// 감정이 설정되어 있으면 true + public bool HasEmotion() => !string.IsNullOrEmpty(Emotion); /// /// 문자열을 액션 타입으로 파싱 @@ -50,8 +87,38 @@ private CharacterActionType ParseActionString(string? actionString) "idle" => CharacterActionType.Idle, "listen" => CharacterActionType.Listen, "talk" => CharacterActionType.Talk, - _ => CharacterActionType.Idle + "clapping" => CharacterActionType.Talk, // 추가 액션들은 Talk으로 매핑 (필요시 enum 확장) + "jumping" => CharacterActionType.Talk, + "waving" => CharacterActionType.Talk, + _ => CharacterActionType.Talk }; } + + /// + /// 액션 배열을 파싱하여 액션 타입 리스트로 변환 + /// + /// 액션 문자열 배열 + /// 파싱된 액션 타입 리스트 + private List ParseActionsArray(string[]? actions) + { + if (actions == null || actions.Length == 0) + { + return new List { CharacterActionType.Talk }; + } + + return actions + .Where(action => !string.IsNullOrEmpty(action)) + .Select(ParseActionString) + .ToList(); + } + + /// + /// 모든 액션을 순차적으로 실행하기 위한 열거자 + /// + /// 액션 시퀀스 + public IEnumerable GetActionSequence() + { + return Actions; + } } } \ No newline at end of file diff --git a/Assets/Domain/Chat/Model/ChatMessage.cs b/Assets/Domain/Chat/Model/ChatMessage.cs index e70e31e..a55f7b5 100644 --- a/Assets/Domain/Chat/Model/ChatMessage.cs +++ b/Assets/Domain/Chat/Model/ChatMessage.cs @@ -15,23 +15,39 @@ public class ChatMessage public CostInfo? CostInfo { get; set; } public DateTime Timestamp { get; set; } = DateTime.UtcNow; - public static ChatMessage FromChatResponse(ChatResponse response) + // 새로운 API 필드들 + public string? Emotion { get; set; } + public int Order { get; set; } = 0; + public string RequestId { get; set; } = string.Empty; + + /// + /// 새로운 API ChatData에서 ChatMessage로 변환 + /// + /// 새 API 채팅 데이터 + /// ChatMessage 인스턴스 + public static ChatMessage FromChatData(ChatData chatData) { var chatMessage = new ChatMessage { - SessionId = response.SessionId, - Text = response.Text, - Timestamp = response.Timestamp, - ActionData = new CharacterActionData(response.Action) + Text = chatData.Text, + Emotion = chatData.Emotion, + Order = chatData.Order, + RequestId = chatData.RequestId, + ActionData = new CharacterActionData(chatData.Actions, chatData.Emotion) }; - if (!string.IsNullOrEmpty(response.AudioData)) + // 타임스탬프 파싱 (새 API는 string 형태) + if (!string.IsNullOrEmpty(chatData.Timestamp)) { - chatMessage.VoiceData = VoiceData.FromBase64(response.AudioData, response.AudioFormat); + if (DateTime.TryParse(chatData.Timestamp, out var parsedTimestamp)) + { + chatMessage.Timestamp = parsedTimestamp; + } } - if ((response.UsedCost ?? 0) > 0 || (response.RemainingCost ?? 0) > 0) + // 오디오 데이터 처리 + if (!string.IsNullOrEmpty(chatData.AudioData)) { - chatMessage.CostInfo = new CostInfo(response.UsedCost ?? 0f, response.RemainingCost ?? 0f); + chatMessage.VoiceData = VoiceData.FromBase64(chatData.AudioData, chatData.AudioFormat); } return chatMessage; @@ -45,6 +61,24 @@ public static ChatMessage FromChatResponse(ChatResponse response) public bool HasCostInfo() => CostInfo != null && CostInfo.HasCostInfo(); + /// + /// 감정 데이터가 있는지 확인 + /// + /// 감정 데이터가 있으면 true + public bool HasEmotionData() => !string.IsNullOrEmpty(Emotion); + + /// + /// 복수의 액션이 있는지 확인 + /// + /// 복수 액션이 있으면 true + public bool HasMultipleActions() => ActionData != null && ActionData.HasMultipleActions(); + + /// + /// 요청 ID가 설정되어 있는지 확인 + /// + /// 요청 ID가 있으면 true + public bool HasRequestId() => !string.IsNullOrEmpty(RequestId); + public AudioClip? GetAudioClip() => VoiceData?.AudioClip; } diff --git a/Assets/Domain/Chat/Service/ChatSystemManager.cs b/Assets/Domain/Chat/Service/ChatSystemManager.cs index de919b9..7ec23c3 100644 --- a/Assets/Domain/Chat/Service/ChatSystemManager.cs +++ b/Assets/Domain/Chat/Service/ChatSystemManager.cs @@ -10,6 +10,7 @@ using ProjectVG.Infrastructure.Network.Services; using ProjectVG.Domain.Chat.View; using ProjectVG.Domain.Character.Service; +using ProjectVG.Infrastructure.Network.DTOs.Chat; namespace ProjectVG.Domain.Chat.Service @@ -25,8 +26,7 @@ public class ChatSystemManager : MonoBehaviour [SerializeField] private CharacterManager? _characterManager; [Header("Chat Settings")] - [SerializeField] private string _characterId = "44444444-4444-4444-4444-444444444444"; - [SerializeField] private string _userId = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + [SerializeField] private string _characterId = "11111111-1111-1111-1111-111111111111"; private WebSocketManager? _webSocketManager; private AudioManager? _audioManager; @@ -137,11 +137,13 @@ public async void SendUserMessage(string message) } if (_chatApiService != null) { - var response = await _chatApiService.SendChatAsync( - message: message, - characterId: _characterId, - userId: _userId - ); + ChatRequest chatRequest = new() { + Message = message, + CharacterId = _characterId, + UseTTS = true, + RequestAt = DateTime.Now + }; + var response = await _chatApiService.SendChatAsync(chatRequest); if (response == null) { Debug.LogWarning("[ChatSystemManager] 채팅 응답이 null입니다."); } diff --git a/Assets/Domain/Chat/View/VoiceInputView.cs b/Assets/Domain/Chat/View/VoiceInputView.cs index c5713ce..244ea53 100644 --- a/Assets/Domain/Chat/View/VoiceInputView.cs +++ b/Assets/Domain/Chat/View/VoiceInputView.cs @@ -1,4 +1,5 @@ #nullable enable +#if !UNITY_WEBGL || UNITY_EDITOR using System; using UnityEngine; using UnityEngine.UI; @@ -336,4 +337,5 @@ private void OnRecordingError(string error) #endregion } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/Assets/Resources/Live2DModelRegistry.asset b/Assets/Domain/Live2DModelRegistry.asset similarity index 100% rename from Assets/Resources/Live2DModelRegistry.asset rename to Assets/Domain/Live2DModelRegistry.asset diff --git a/Assets/Resources/Live2DModelRegistry.asset.meta b/Assets/Domain/Live2DModelRegistry.asset.meta similarity index 100% rename from Assets/Resources/Live2DModelRegistry.asset.meta rename to Assets/Domain/Live2DModelRegistry.asset.meta diff --git a/Assets/Editor/OpenDocsMenu.cs b/Assets/Editor/OpenDocsMenu.cs deleted file mode 100644 index e067144..0000000 --- a/Assets/Editor/OpenDocsMenu.cs +++ /dev/null @@ -1,14 +0,0 @@ -using UnityEditor; -using UnityEngine; - -public static class OpenDocsMenu -{ - /** - * 프로젝트 문서(루트 Docs/)를 파일 탐색기로 여는 메뉴 항목을 추가합니다. - */ - [MenuItem("Help/Open Project Docs")] - public static void OpenProjectDocs() - { - - } -} diff --git a/Assets/Editor/OpenDocsMenu.cs.meta b/Assets/Editor/OpenDocsMenu.cs.meta deleted file mode 100644 index 30ad15e..0000000 --- a/Assets/Editor/OpenDocsMenu.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 51cc0cffee369434a853777240686338 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth.meta b/Assets/Infrastructure/Auth.meta new file mode 100644 index 0000000..a4d6684 --- /dev/null +++ b/Assets/Infrastructure/Auth.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3f5e5e688529fdd4da5b8fb92bcacb36 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/AuthManager.cs b/Assets/Infrastructure/Auth/AuthManager.cs new file mode 100644 index 0000000..bf061b9 --- /dev/null +++ b/Assets/Infrastructure/Auth/AuthManager.cs @@ -0,0 +1,534 @@ +using System; +using UnityEngine; +using Cysharp.Threading.Tasks; +using ProjectVG.Infrastructure.Auth.Models; +using ProjectVG.Infrastructure.Auth.Services; +using ProjectVG.Infrastructure.Auth.OAuth2; + +namespace ProjectVG.Infrastructure.Auth +{ + /// + /// 인증 시스템 전체를 관리하는 파사드/중재자 역할의 매니저 + /// Guest 로그인, OAuth2 로그인, 토큰 자동 갱신 등을 통합 관리 + /// + public class AuthManager : MonoBehaviour + { + #region Singleton Pattern + + private static AuthManager _instance; + public static AuthManager Instance + { + get + { + if (_instance == null) + { + var go = new GameObject("AuthManager"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + return _instance; + } + } + + #endregion + + #region Private Fields + + private TokenManager _tokenManager; + private TokenRefreshService _tokenRefreshService; + private GuestAuthService _guestAuthService; + private ServerOAuth2Provider _oauth2Provider; + + private bool _isInitialized = false; + private bool _isAutoRefreshInProgress = false; + private bool _initStarted = false; + private UniTask _initTask; + + #endregion + + #region Public Properties + + /// + /// 현재 로그인 상태 (Access Token이 존재하고 유효한 경우) + /// + public bool IsLoggedIn => _tokenManager?.HasValidTokens ?? false; + + /// + /// Refresh Token이 존재하고 유효한 경우 + /// + public bool HasValidRefreshToken => _tokenManager?.HasRefreshToken == true && + !_tokenManager.IsRefreshTokenExpired(); + + /// + /// 현재 사용자 ID + /// + public string CurrentUserId => _tokenManager?.CurrentUserId; + + /// + /// 자동 토큰 갱신 진행 중 여부 + /// + public bool IsAutoRefreshInProgress => _isAutoRefreshInProgress; + + #endregion + + #region Events + + /// + /// 로그인 성공 시 발생하는 이벤트 (Guest, OAuth2 모두 포함) + /// + public event Action OnLoginSuccess; + + /// + /// 로그인 실패 시 발생하는 이벤트 + /// + public event Action OnLoginFailed; + + /// + /// 로그아웃 시 발생하는 이벤트 + /// + public event Action OnLoggedOut; + + /// + /// 자동 토큰 갱신 성공 시 발생하는 이벤트 + /// + public event Action OnTokenAutoRefreshed; + + /// + /// 토큰 갱신 실패로 재로그인이 필요한 경우 발생하는 이벤트 + /// + public event Action OnReLoginRequired; + + #endregion + + #region Unity Lifecycle + + private void Awake() + { + if (_instance == null) + { + _instance = this; + DontDestroyOnLoad(gameObject); + _initStarted = true; + _initTask = InitializeAsync(); + } + else if (_instance != this) + { + Destroy(gameObject); + } + } + + private async UniTask EnsureInitializedAsync() + { + if (_isInitialized) return; + if (!_initStarted) + { + _initStarted = true; + _initTask = InitializeAsync(); + } + await _initTask; + } + + private async UniTask InitializeAsync() + { + try + { + Debug.Log("[AuthManager] 초기화 시작"); + + // 의존성 초기화 + _tokenManager = TokenManager.Instance; + _tokenRefreshService = TokenRefreshService.Instance; + _guestAuthService = GuestAuthService.Instance; + + // TokenManager 이벤트 구독 + _tokenManager.OnTokensUpdated += HandleTokensUpdated; + _tokenManager.OnTokensExpired += HandleTokensExpired; + _tokenManager.OnTokensCleared += HandleTokensCleared; + + // TokenRefreshService 이벤트 구독 + _tokenRefreshService.OnTokenRefreshed += HandleTokenRefreshed; + _tokenRefreshService.OnTokenRefreshFailed += HandleTokenRefreshFailed; + + // GuestAuthService 이벤트 구독 + _guestAuthService.OnGuestLoginSuccess += HandleGuestLoginSuccess; + _guestAuthService.OnGuestLoginFailed += HandleGuestLoginFailed; + + _isInitialized = true; + Debug.Log("[AuthManager] 초기화 완료"); + + // 앱 시작 시 자동 로그인 시도 + await TryAutoLoginAsync(); + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] 초기화 실패: {ex.Message}"); + } + } + + private void OnDestroy() + { + // 이벤트 구독 해제 + if (_tokenManager != null) + { + _tokenManager.OnTokensUpdated -= HandleTokensUpdated; + _tokenManager.OnTokensExpired -= HandleTokensExpired; + _tokenManager.OnTokensCleared -= HandleTokensCleared; + } + + if (_tokenRefreshService != null) + { + _tokenRefreshService.OnTokenRefreshed -= HandleTokenRefreshed; + _tokenRefreshService.OnTokenRefreshFailed -= HandleTokenRefreshFailed; + } + + if (_guestAuthService != null) + { + _guestAuthService.OnGuestLoginSuccess -= HandleGuestLoginSuccess; + _guestAuthService.OnGuestLoginFailed -= HandleGuestLoginFailed; + } + } + + #endregion + + #region Public Methods - Login + + /// + /// Guest 로그인 수행 + /// + /// 로그인 성공 여부 + public async UniTask LoginAsGuestAsync() + { + await EnsureInitializedAsync(); + + if (!_isInitialized) + { + Debug.LogError("[AuthManager] AuthManager 초기화에 실패했습니다."); + return false; + } + + try + { + Debug.Log("[AuthManager] Guest 로그인 시작"); + return await _guestAuthService.LoginAsGuestAsync(); + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] Guest 로그인 실패: {ex.Message}"); + OnLoginFailed?.Invoke(ex.Message); + return false; + } + } + + /// + /// OAuth2 로그인 수행 + /// + /// 로그인 성공 여부 + public async UniTask LoginWithOAuth2Async() + { + await EnsureInitializedAsync(); + + if (!_isInitialized) + { + Debug.LogError("[AuthManager] AuthManager 초기화에 실패했습니다."); + return false; + } + + try + { + Debug.Log("[AuthManager] OAuth2 로그인 시작"); + + // OAuth2Provider 초기화 (필요한 경우) + if (_oauth2Provider == null) + { + var config = ProjectVG.Infrastructure.Auth.OAuth2.Config.ServerOAuth2Config.Instance; + if (config == null) + { + throw new InvalidOperationException("ServerOAuth2Config를 찾을 수 없습니다."); + } + _oauth2Provider = new ServerOAuth2Provider(config); + } + + // OAuth2 로그인 수행 + var tokenSet = await _oauth2Provider.LoginWithServerOAuth2Async(); + + if (tokenSet?.AccessToken != null) + { + // 토큰 저장 + _tokenManager.SaveTokens(tokenSet); + Debug.Log("[AuthManager] OAuth2 로그인 성공"); + OnLoginSuccess?.Invoke(tokenSet); + return true; + } + else + { + throw new InvalidOperationException("OAuth2 로그인에서 유효한 토큰을 받지 못했습니다."); + } + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] OAuth2 로그인 실패: {ex.Message}"); + OnLoginFailed?.Invoke(ex.Message); + return false; + } + } + + /// + /// 로그아웃 수행 (토큰 삭제) + /// + public void Logout() + { + try + { + Debug.Log("[AuthManager] 로그아웃 시작"); + _tokenManager.ClearTokens(); + Debug.Log("[AuthManager] 로그아웃 완료"); + OnLoggedOut?.Invoke(); + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] 로그아웃 실패: {ex.Message}"); + } + } + + #endregion + + #region Public Methods - Token Management + + /// + /// 수동으로 토큰 갱신 요청 + /// + /// 갱신 성공 여부 + public async UniTask RefreshTokenAsync() + { + await EnsureInitializedAsync(); + + if (!_isInitialized) + { + Debug.LogError("[AuthManager] AuthManager 초기화에 실패했습니다."); + return false; + } + + try + { + Debug.Log("[AuthManager] 수동 토큰 갱신 시작"); + return await _tokenRefreshService.RefreshAccessTokenAsync(); + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] 수동 토큰 갱신 실패: {ex.Message}"); + return false; + } + } + + /// + /// 토큰이 곧 만료되는지 확인하고 필요시 갱신 + /// + /// 만료 몇 분 전에 갱신할지 + /// 유효한 토큰 보장 여부 + public async UniTask EnsureValidTokenAsync(int minutesBeforeExpiry = 5) + { + await EnsureInitializedAsync(); + + if (!_isInitialized) + { + Debug.LogError("[AuthManager] AuthManager 초기화에 실패했습니다."); + return false; + } + + try + { + // 이미 유효한 토큰이 있고 만료가 임박하지 않은 경우 + /* + var currentToken = _tokenManager.GetAccessToken(); + if (IsLoggedIn && currentToken != null && !currentToken.IsExpiringSoon(minutesBeforeExpiry)) + { + return true; + } + */ + + // 토큰이 만료되었거나 곧 만료될 경우 갱신 시도 + if (HasValidRefreshToken) + { + Debug.Log($"[AuthManager] 토큰이 {minutesBeforeExpiry}분 내에 만료 예정 - 갱신 시도"); + return await _tokenRefreshService.RefreshAccessTokenAsync(); + } + + Debug.LogWarning("[AuthManager] 유효한 Refresh Token이 없습니다."); + return false; + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] 토큰 유효성 보장 실패: {ex.Message}"); + return false; + } + } + + #endregion + + #region Private Methods - Auto Login + + /// + /// 앱 시작 시 자동 로그인 시도 + /// + private async UniTask TryAutoLoginAsync() + { + try + { + Debug.Log("[AuthManager] 자동 로그인 시도 시작"); + + // 이미 유효한 Access Token이 있는 경우 + if (IsLoggedIn) + { + Debug.Log("[AuthManager] 이미 유효한 Access Token이 존재합니다."); + var tokenSet = _tokenManager.LoadTokens(); + OnLoginSuccess?.Invoke(tokenSet); + return; + } + + // Refresh Token으로 Access Token 재발급 시도 + if (HasValidRefreshToken) + { + Debug.Log("[AuthManager] Refresh Token으로 자동 로그인 시도"); + _isAutoRefreshInProgress = true; + + bool refreshSuccess = await _tokenRefreshService.RefreshAccessTokenAsync(); + + _isAutoRefreshInProgress = false; + + if (refreshSuccess) + { + Debug.Log("[AuthManager] 자동 로그인 성공"); + var tokenSet = _tokenManager.LoadTokens(); + OnLoginSuccess?.Invoke(tokenSet); + OnTokenAutoRefreshed?.Invoke("자동 로그인 성공"); + } + else + { + Debug.LogWarning("[AuthManager] 자동 로그인 실패 - 재로그인 필요"); + OnReLoginRequired?.Invoke("자동 로그인 실패"); + } + } + else + { + Debug.Log("[AuthManager] 저장된 유효한 토큰이 없습니다 - 로그인 필요"); + OnReLoginRequired?.Invoke("토큰 없음"); + } + } + catch (Exception ex) + { + Debug.LogError($"[AuthManager] 자동 로그인 시도 실패: {ex.Message}"); + OnReLoginRequired?.Invoke(ex.Message); + } + } + + #endregion + + #region Event Handlers + + private void HandleTokensUpdated(TokenSet tokenSet) + { + Debug.Log("[AuthManager] 토큰 업데이트됨"); + // 로그인 성공 이벤트는 각 로그인 메서드에서 직접 발생 + } + + private void HandleTokensExpired() + { + Debug.LogWarning("[AuthManager] 토큰 만료됨 - 자동 갱신 시도"); + // TokenRefreshService가 자동으로 갱신 시도함 + } + + private void HandleTokensCleared() + { + Debug.Log("[AuthManager] 토큰이 삭제됨"); + } + + private void HandleTokenRefreshed(string newAccessToken) + { + Debug.Log("[AuthManager] 토큰 갱신 성공"); + OnTokenAutoRefreshed?.Invoke(newAccessToken); + } + + private void HandleTokenRefreshFailed(string error) + { + Debug.LogError($"[AuthManager] 토큰 갱신 실패: {error}"); + OnReLoginRequired?.Invoke(error); + } + + private void HandleGuestLoginSuccess(TokenSet tokenSet) + { + Debug.Log("[AuthManager] Guest 로그인 성공"); + OnLoginSuccess?.Invoke(tokenSet); + } + + private void HandleGuestLoginFailed(string error) + { + Debug.LogError($"[AuthManager] Guest 로그인 실패: {error}"); + OnLoginFailed?.Invoke(error); + } + + #endregion + + #region Public Methods - Utility + + /// + /// 현재 Access Token 반환 (문자열) + /// + /// Access Token 문자열 (만료된 경우 null) + public string GetAccessToken() + { + return _tokenManager?.GetAccessToken(); + } + + /// + /// 현재 Access Token 객체 반환 + /// + /// AccessToken 객체 (없거나 만료된 경우 null) + public AccessToken GetCurrentAccessToken() + { + if (_tokenManager?.HasValidTokens == true) + { + var tokenSet = _tokenManager.LoadTokens(); + return tokenSet?.AccessToken; + } + return null; + } + + /// + /// Guest 로그인 가능 여부 확인 + /// + /// Guest 로그인 가능 여부 + public bool CanLoginAsGuest() + { + return _guestAuthService?.CanLoginAsGuest() ?? false; + } + + /// + /// 디버그 정보 반환 + /// + /// AuthManager 상태 정보 + public string GetDebugInfo() + { + var info = "=== AuthManager Debug Info ===\n"; + info += $"Is Initialized: {_isInitialized}\n"; + info += $"Is Logged In: {IsLoggedIn}\n"; + info += $"Has Valid Refresh Token: {HasValidRefreshToken}\n"; + info += $"Current User ID: {CurrentUserId ?? "None"}\n"; + info += $"Auto Refresh In Progress: {_isAutoRefreshInProgress}\n"; + info += $"Can Login As Guest: {CanLoginAsGuest()}\n"; + + if (_tokenManager != null) + { + var accessToken = GetCurrentAccessToken(); + if (accessToken != null) + { + info += $"Access Token Expires At: {accessToken.ExpiresAt}\n"; + info += $"Access Token Expires Soon (5min): {accessToken.IsExpiringSoon(5)}\n"; + } + } + + info += "==============================="; + return info; + } + + #endregion + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/AuthManager.cs.meta b/Assets/Infrastructure/Auth/AuthManager.cs.meta new file mode 100644 index 0000000..a2f00e5 --- /dev/null +++ b/Assets/Infrastructure/Auth/AuthManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d6a5faf59b732b459c9af71f83f202d \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Examples.meta b/Assets/Infrastructure/Auth/Examples.meta new file mode 100644 index 0000000..388f3a5 --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 51cf94ce6b94d38468a2ac8b3cae990c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs b/Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs new file mode 100644 index 0000000..291c0bb --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs @@ -0,0 +1,401 @@ +using System; +using UnityEngine; +using UnityEngine.UI; +using Cysharp.Threading.Tasks; +using ProjectVG.Infrastructure.Auth; +using ProjectVG.Infrastructure.Auth.Models; + +namespace ProjectVG.Infrastructure.Auth.Examples +{ + /// + /// AuthManager 사용 예제 + /// UI 버튼을 통해 다양한 인증 기능을 테스트할 수 있습니다. + /// + public class AuthManagerExample : MonoBehaviour + { + [Header("UI References")] + [SerializeField] private Button guestLoginButton; + [SerializeField] private Button oauth2LoginButton; + [SerializeField] private Button logoutButton; + [SerializeField] private Button refreshTokenButton; + [SerializeField] private Button checkStatusButton; + [SerializeField] private Text statusText; + [SerializeField] private Text debugInfoText; + + private AuthManager _authManager; + + #region Unity Lifecycle + + private void Start() + { + InitializeUI(); + SetupAuthManager(); + } + + private void OnDestroy() + { + UnsubscribeFromAuthEvents(); + } + + #endregion + + #region UI Setup + + private void InitializeUI() + { + // 버튼 이벤트 연결 + if (guestLoginButton != null) + guestLoginButton.onClick.AddListener(() => OnGuestLoginClicked().Forget()); + + if (oauth2LoginButton != null) + oauth2LoginButton.onClick.AddListener(() => OnOAuth2LoginClicked().Forget()); + + if (logoutButton != null) + logoutButton.onClick.AddListener(OnLogoutClicked); + + if (refreshTokenButton != null) + refreshTokenButton.onClick.AddListener(() => OnRefreshTokenClicked().Forget()); + + if (checkStatusButton != null) + checkStatusButton.onClick.AddListener(OnCheckStatusClicked); + + UpdateStatusText("AuthManager 초기화 중..."); + } + + #endregion + + #region AuthManager Setup + + private void SetupAuthManager() + { + try + { + _authManager = AuthManager.Instance; + + // AuthManager 이벤트 구독 + _authManager.OnLoginSuccess += HandleLoginSuccess; + _authManager.OnLoginFailed += HandleLoginFailed; + _authManager.OnLoggedOut += HandleLoggedOut; + _authManager.OnTokenAutoRefreshed += HandleTokenAutoRefreshed; + _authManager.OnReLoginRequired += HandleReLoginRequired; + + Debug.Log("[AuthManagerExample] AuthManager 설정 완료"); + UpdateStatusText("AuthManager 준비 완료"); + + // 초기 상태 업데이트 + UpdateUI(); + } + catch (Exception ex) + { + Debug.LogError($"[AuthManagerExample] AuthManager 설정 실패: {ex.Message}"); + UpdateStatusText($"초기화 실패: {ex.Message}"); + } + } + + private void UnsubscribeFromAuthEvents() + { + if (_authManager != null) + { + _authManager.OnLoginSuccess -= HandleLoginSuccess; + _authManager.OnLoginFailed -= HandleLoginFailed; + _authManager.OnLoggedOut -= HandleLoggedOut; + _authManager.OnTokenAutoRefreshed -= HandleTokenAutoRefreshed; + _authManager.OnReLoginRequired -= HandleReLoginRequired; + } + } + + #endregion + + #region Button Event Handlers + + private async UniTaskVoid OnGuestLoginClicked() + { + try + { + UpdateStatusText("Guest 로그인 시도 중..."); + SetButtonsEnabled(false); + + bool success = await _authManager.LoginAsGuestAsync(); + + if (!success) + { + UpdateStatusText("Guest 로그인 실패"); + } + // 성공 시에는 HandleLoginSuccess 이벤트에서 처리 + } + catch (Exception ex) + { + Debug.LogError($"[AuthManagerExample] Guest 로그인 오류: {ex.Message}"); + UpdateStatusText($"Guest 로그인 오류: {ex.Message}"); + } + finally + { + SetButtonsEnabled(true); + } + } + + private async UniTaskVoid OnOAuth2LoginClicked() + { + try + { + UpdateStatusText("OAuth2 로그인 시도 중...\n브라우저에서 Google 로그인을 완료해주세요."); + SetButtonsEnabled(false); + + bool success = await _authManager.LoginWithOAuth2Async(); + + if (!success) + { + UpdateStatusText("OAuth2 로그인 실패"); + } + // 성공 시에는 HandleLoginSuccess 이벤트에서 처리 + } + catch (Exception ex) + { + Debug.LogError($"[AuthManagerExample] OAuth2 로그인 오류: {ex.Message}"); + UpdateStatusText($"OAuth2 로그인 오류: {ex.Message}"); + } + finally + { + SetButtonsEnabled(true); + } + } + + private void OnLogoutClicked() + { + try + { + UpdateStatusText("로그아웃 중..."); + _authManager.Logout(); + // 로그아웃 완료는 HandleLoggedOut 이벤트에서 처리 + } + catch (Exception ex) + { + Debug.LogError($"[AuthManagerExample] 로그아웃 오류: {ex.Message}"); + UpdateStatusText($"로그아웃 오류: {ex.Message}"); + } + } + + private async UniTaskVoid OnRefreshTokenClicked() + { + try + { + UpdateStatusText("토큰 갱신 중..."); + SetButtonsEnabled(false); + + bool success = await _authManager.RefreshTokenAsync(); + + if (success) + { + UpdateStatusText("토큰 갱신 성공"); + } + else + { + UpdateStatusText("토큰 갱신 실패"); + } + + UpdateUI(); + } + catch (Exception ex) + { + Debug.LogError($"[AuthManagerExample] 토큰 갱신 오류: {ex.Message}"); + UpdateStatusText($"토큰 갱신 오류: {ex.Message}"); + } + finally + { + SetButtonsEnabled(true); + } + } + + private void OnCheckStatusClicked() + { + UpdateUI(); + UpdateStatusText("상태 정보를 업데이트했습니다."); + } + + #endregion + + #region AuthManager Event Handlers + + private void HandleLoginSuccess(TokenSet tokenSet) + { + Debug.Log("[AuthManagerExample] 로그인 성공 이벤트 수신"); + UpdateStatusText($"로그인 성공!\n사용자 ID: {_authManager.CurrentUserId}"); + UpdateUI(); + SetButtonsEnabled(true); + } + + private void HandleLoginFailed(string error) + { + Debug.LogError($"[AuthManagerExample] 로그인 실패 이벤트 수신: {error}"); + UpdateStatusText($"로그인 실패: {error}"); + UpdateUI(); + SetButtonsEnabled(true); + } + + private void HandleLoggedOut() + { + Debug.Log("[AuthManagerExample] 로그아웃 이벤트 수신"); + UpdateStatusText("로그아웃 완료"); + UpdateUI(); + } + + private void HandleTokenAutoRefreshed(string newAccessToken) + { + Debug.Log("[AuthManagerExample] 토큰 자동 갱신 이벤트 수신"); + UpdateStatusText("토큰이 자동으로 갱신되었습니다."); + UpdateUI(); + } + + private void HandleReLoginRequired(string reason) + { + Debug.LogWarning($"[AuthManagerExample] 재로그인 필요 이벤트 수신: {reason}"); + UpdateStatusText($"재로그인이 필요합니다.\n이유: {reason}"); + UpdateUI(); + } + + #endregion + + #region UI Update Methods + + private void UpdateUI() + { + if (_authManager == null) return; + + // 버튼 활성화 상태 업데이트 + bool isLoggedIn = _authManager.IsLoggedIn; + bool hasValidRefreshToken = _authManager.HasValidRefreshToken; + + if (guestLoginButton != null) + guestLoginButton.interactable = !isLoggedIn && _authManager.CanLoginAsGuest(); + + if (oauth2LoginButton != null) + oauth2LoginButton.interactable = !isLoggedIn; + + if (logoutButton != null) + logoutButton.interactable = isLoggedIn; + + if (refreshTokenButton != null) + refreshTokenButton.interactable = hasValidRefreshToken; + + // 디버그 정보 업데이트 + if (debugInfoText != null) + { + debugInfoText.text = _authManager.GetDebugInfo(); + } + } + + private void UpdateStatusText(string status) + { + if (statusText != null) + { + statusText.text = $"[{DateTime.Now:HH:mm:ss}] {status}"; + } + Debug.Log($"[AuthManagerExample] Status: {status}"); + } + + private void SetButtonsEnabled(bool enabled) + { + if (guestLoginButton != null) + guestLoginButton.interactable = enabled && !_authManager.IsLoggedIn && _authManager.CanLoginAsGuest(); + + if (oauth2LoginButton != null) + oauth2LoginButton.interactable = enabled && !_authManager.IsLoggedIn; + + if (logoutButton != null) + logoutButton.interactable = enabled && _authManager.IsLoggedIn; + + if (refreshTokenButton != null) + refreshTokenButton.interactable = enabled && _authManager.HasValidRefreshToken; + + if (checkStatusButton != null) + checkStatusButton.interactable = enabled; + } + + #endregion + + #region Test Methods (Unity Inspector에서 호출 가능) + + [ContextMenu("Test Guest Login")] + public void TestGuestLogin() + { + OnGuestLoginClicked().Forget(); + } + + [ContextMenu("Test OAuth2 Login")] + public void TestOAuth2Login() + { + OnOAuth2LoginClicked().Forget(); + } + + [ContextMenu("Test Logout")] + public void TestLogout() + { + OnLogoutClicked(); + } + + [ContextMenu("Test Token Refresh")] + public void TestTokenRefresh() + { + OnRefreshTokenClicked().Forget(); + } + + [ContextMenu("Show Debug Info")] + public void ShowDebugInfo() + { + if (_authManager != null) + { + Debug.Log(_authManager.GetDebugInfo()); + } + } + + [ContextMenu("Test Auto Login Simulation")] + public async void TestAutoLoginSimulation() + { + try + { + Debug.Log("[AuthManagerExample] 자동 로그인 시뮬레이션 시작"); + + // 현재 토큰 상태 확인 + if (_authManager.IsLoggedIn) + { + Debug.Log("[AuthManagerExample] 이미 로그인된 상태입니다."); + return; + } + + // Refresh Token으로 자동 로그인 시도 + if (_authManager.HasValidRefreshToken) + { + UpdateStatusText("자동 로그인 시뮬레이션 중..."); + bool success = await _authManager.RefreshTokenAsync(); + + if (success) + { + UpdateStatusText("자동 로그인 시뮬레이션 성공"); + Debug.Log("[AuthManagerExample] 자동 로그인 시뮬레이션 성공"); + } + else + { + UpdateStatusText("자동 로그인 시뮬레이션 실패 - 재로그인 필요"); + Debug.Log("[AuthManagerExample] 자동 로그인 시뮬레이션 실패"); + } + } + else + { + UpdateStatusText("유효한 Refresh Token이 없어 자동 로그인 불가"); + Debug.Log("[AuthManagerExample] 유효한 Refresh Token이 없습니다."); + } + } + catch (Exception ex) + { + Debug.LogError($"[AuthManagerExample] 자동 로그인 시뮬레이션 오류: {ex.Message}"); + UpdateStatusText($"자동 로그인 시뮬레이션 오류: {ex.Message}"); + } + finally + { + UpdateUI(); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs.meta b/Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs.meta new file mode 100644 index 0000000..bcdc07e --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples/AuthManagerExample.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9f606a2fb3216f042a46e97bfca2f735 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs b/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs new file mode 100644 index 0000000..6af2b46 --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs @@ -0,0 +1,251 @@ +using UnityEngine; +using ProjectVG.Infrastructure.Auth.Services; +using ProjectVG.Infrastructure.Auth.Utils; +using ProjectVG.Infrastructure.Auth.Models; + +namespace ProjectVG.Infrastructure.Auth.Examples +{ + /// + /// Guest 로그인 사용 예제 + /// + public class GuestLoginExample : MonoBehaviour + { + [Header("Debug UI")] + public bool showDebugUI = true; + + private GuestAuthService _guestAuthService; + private TokenManager _tokenManager; + private bool _isLoggingIn = false; + + private void Start() + { + InitializeServices(); + SetupEventHandlers(); + + // 앱 시작 시 자동 게스트 로그인 시도 + CheckAutoGuestLogin(); + } + + private void InitializeServices() + { + _guestAuthService = GuestAuthService.Instance; + _tokenManager = TokenManager.Instance; + + Debug.Log("[GuestLoginExample] 서비스 초기화 완료"); + } + + private void SetupEventHandlers() + { + // Guest 로그인 이벤트 구독 + _guestAuthService.OnGuestLoginSuccess += HandleGuestLoginSuccess; + _guestAuthService.OnGuestLoginFailed += HandleGuestLoginFailed; + + // Token 이벤트 구독 + _tokenManager.OnTokensUpdated += HandleTokensUpdated; + _tokenManager.OnTokensExpired += HandleTokensExpired; + } + + private async void CheckAutoGuestLogin() + { + // 이미 유효한 토큰이 있으면 자동 로그인 스킵 + if (_tokenManager.HasValidTokens) + { + Debug.Log("[GuestLoginExample] 유효한 토큰 존재 - 자동 로그인 스킵"); + return; + } + + // RefreshToken이 있으면 자동 갱신 대기 + if (_tokenManager.HasRefreshToken && !_tokenManager.IsRefreshTokenExpired()) + { + Debug.Log("[GuestLoginExample] RefreshToken 존재 - 자동 갱신 대기"); + return; + } + + // Guest 로그인 가능하면 자동 로그인 시도 + if (_guestAuthService.CanLoginAsGuest()) + { + Debug.Log("[GuestLoginExample] 자동 Guest 로그인 시도"); + await PerformGuestLoginAsync(); + } + } + + /// + /// Guest 로그인 수행 + /// + public async void PerformGuestLogin() + { + await PerformGuestLoginAsync(); + } + + private async System.Threading.Tasks.Task PerformGuestLoginAsync() + { + if (_isLoggingIn) + { + Debug.LogWarning("[GuestLoginExample] 이미 로그인 진행 중입니다."); + return; + } + + _isLoggingIn = true; + + try + { + Debug.Log("[GuestLoginExample] Guest 로그인 시작"); + + // 디바이스 정보 출력 + Debug.Log($"[GuestLoginExample] 디바이스 정보: {DeviceIdProvider.GetPlatformInfo()}"); + Debug.Log($"[GuestLoginExample] 디바이스 ID: {MaskString(_guestAuthService.GetCurrentDeviceId())}"); + + // Guest 로그인 수행 + bool success = await _guestAuthService.LoginAsGuestAsync(); + + if (success) + { + Debug.Log("[GuestLoginExample] Guest 로그인 성공"); + } + else + { + Debug.LogError("[GuestLoginExample] Guest 로그인 실패"); + } + } + catch (System.Exception ex) + { + Debug.LogError($"[GuestLoginExample] Guest 로그인 중 오류: {ex.Message}"); + } + finally + { + _isLoggingIn = false; + } + } + + + /// + /// Guest 로그인 상태 확인 + /// + public void CheckGuestLoginStatus() + { + var status = _guestAuthService.GetGuestLoginStatus(); + Debug.Log("=== Guest Login Status ==="); + Debug.Log(status.GetDebugInfo()); + Debug.Log("=========================="); + } + + /// + /// 모든 토큰 삭제 (테스트용) + /// + public void ClearAllTokens() + { + _tokenManager.ClearTokens(); + Debug.Log("[GuestLoginExample] 모든 토큰 삭제 완료"); + } + + /// + /// 디바이스 ID 리셋 (테스트용) + /// + public void ResetDeviceId() + { + _guestAuthService.ResetDeviceId(); + Debug.Log("[GuestLoginExample] 디바이스 ID 리셋 완료"); + } + + #region Event Handlers + + private void HandleGuestLoginSuccess(TokenSet tokenSet) + { + Debug.Log("[GuestLoginExample] Guest 로그인 성공 이벤트 수신"); + Debug.Log($"[GuestLoginExample] AccessToken 만료: {tokenSet.AccessToken.ExpiresAt}"); + } + + private void HandleGuestLoginFailed(string error) + { + Debug.LogError($"[GuestLoginExample] Guest 로그인 실패 이벤트 수신: {error}"); + } + + private void HandleTokensUpdated(TokenSet tokenSet) + { + Debug.Log("[GuestLoginExample] 토큰 업데이트 이벤트 수신"); + } + + private void HandleTokensExpired() + { + Debug.Log("[GuestLoginExample] 토큰 만료 이벤트 수신 - 자동 갱신 시도"); + } + + #endregion + + #region Debug UI + + private void OnGUI() + { + if (!showDebugUI) return; + + GUILayout.BeginArea(new Rect(10, 10, 400, 600)); + GUILayout.Label("=== Guest Login Debug UI ===", GUI.skin.box); + + // 로그인 버튼 + GUI.enabled = !_isLoggingIn && _guestAuthService.CanLoginAsGuest(); + if (GUILayout.Button(_isLoggingIn ? "로그인 중..." : "Guest 로그인")) + { + PerformGuestLogin(); + } + GUI.enabled = true; + + GUILayout.Space(10); + + if (GUILayout.Button("Guest 로그인 상태 확인")) + { + CheckGuestLoginStatus(); + } + + GUILayout.Space(10); + + // 테스트 버튼들 + GUILayout.Label("=== 테스트 기능 ===", GUI.skin.box); + + if (GUILayout.Button("모든 토큰 삭제")) + { + ClearAllTokens(); + } + + if (GUILayout.Button("디바이스 ID 리셋")) + { + ResetDeviceId(); + } + + GUILayout.Space(10); + + // 현재 상태 표시 + GUILayout.Label("=== 현재 상태 ===", GUI.skin.box); + GUILayout.Label($"로그인 중: {_isLoggingIn}"); + GUILayout.Label($"유효한 토큰: {_tokenManager?.HasValidTokens ?? false}"); + GUILayout.Label($"RefreshToken: {_tokenManager?.HasRefreshToken ?? false}"); + GUILayout.Label($"Guest 로그인 가능: {_guestAuthService?.CanLoginAsGuest() ?? false}"); + + GUILayout.EndArea(); + } + + #endregion + + private string MaskString(string input) + { + if (string.IsNullOrEmpty(input) || input.Length < 8) + return "***"; + return $"{input.Substring(0, 4)}****{input.Substring(input.Length - 4)}"; + } + + private void OnDestroy() + { + // 이벤트 구독 해제 + if (_guestAuthService != null) + { + _guestAuthService.OnGuestLoginSuccess -= HandleGuestLoginSuccess; + _guestAuthService.OnGuestLoginFailed -= HandleGuestLoginFailed; + } + + if (_tokenManager != null) + { + _tokenManager.OnTokensUpdated -= HandleTokensUpdated; + _tokenManager.OnTokensExpired -= HandleTokensExpired; + } + } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs.meta b/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs.meta new file mode 100644 index 0000000..d68574b --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples/GuestLoginExample.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b273f87c4feba7c45b007339de53f770 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs new file mode 100644 index 0000000..1497ccd --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs @@ -0,0 +1,534 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; +using Cysharp.Threading.Tasks; +using ProjectVG.Infrastructure.Auth.OAuth2; +using ProjectVG.Infrastructure.Auth.OAuth2.Config; +using ProjectVG.Infrastructure.Auth.Models; +using System; + +namespace ProjectVG.Infrastructure.Auth.Examples +{ + /// + /// 서버 OAuth2 로그인 예제 + /// + public class ServerOAuth2Example : MonoBehaviour + { + [Header("UI 컴포넌트")] + [SerializeField] private Button loginButton; + [SerializeField] private Button logoutButton; + [SerializeField] private Button refreshButton; + [SerializeField] private Button clearButton; + [SerializeField] private Button checkButton; + [SerializeField] private TextMeshProUGUI statusText; + [SerializeField] private TextMeshProUGUI userInfoText; + [SerializeField] private TextMeshProUGUI debugText; + + [Header("설정")] + [SerializeField] private ServerOAuth2Config oauth2Config; + + // OAuth2 설정 자동 로드 + private ServerOAuth2Config OAuth2Config => oauth2Config ?? ServerOAuth2Config.Instance; + + private ServerOAuth2Provider _oauth2Provider; + private TokenManager _tokenManager; + private TokenRefreshService _refreshService; + private TokenSet _currentTokenSet; + private bool _isLoggedIn = false; + + #region Unity Lifecycle + + private void Start() + { + InitializeOAuth2Provider(); + InitializeTokenServices(); + SetupEventHandlers(); + UpdateUI(); + } + + private void OnDestroy() + { + // 이벤트 핸들러 해제 + if (loginButton != null) loginButton.onClick.RemoveAllListeners(); + if (logoutButton != null) logoutButton.onClick.RemoveAllListeners(); + if (refreshButton != null) refreshButton.onClick.RemoveAllListeners(); + if (clearButton != null) clearButton.onClick.RemoveAllListeners(); + if (checkButton != null) checkButton.onClick.RemoveAllListeners(); + + // Token 서비스 이벤트 해제 + if (_tokenManager != null) + { + _tokenManager.OnTokensUpdated -= OnTokensUpdated; + _tokenManager.OnTokensExpired -= OnTokensExpired; + _tokenManager.OnTokensCleared -= OnTokensCleared; + } + + if (_refreshService != null) + { + _refreshService.OnTokenRefreshed -= OnTokenRefreshed; + _refreshService.OnTokenRefreshFailed -= OnTokenRefreshFailed; + } + } + + #endregion + + #region Initialization + + private void InitializeOAuth2Provider() + { + var config = OAuth2Config; + + if (config == null) + { + Debug.LogError("[ServerOAuth2Example] OAuth2 설정을 로드할 수 없습니다."); + ShowStatus("OAuth2 설정을 로드할 수 없습니다.", Color.red); + return; + } + + // HttpApiClient 초기화 상태 확인 + var httpClient = ProjectVG.Infrastructure.Network.Http.HttpApiClient.Instance; + if (httpClient == null) + { + Debug.LogError("[ServerOAuth2Example] HttpApiClient.Instance가 null입니다."); + ShowStatus("HttpApiClient가 초기화되지 않았습니다.", Color.red); + return; + } + + Debug.Log($"[ServerOAuth2Example] HttpApiClient 초기화 상태: {httpClient.IsInitialized}"); + + if (!httpClient.IsInitialized) + { + Debug.LogError("[ServerOAuth2Example] HttpApiClient가 초기화되지 않았습니다."); + ShowStatus("HttpApiClient가 초기화되지 않았습니다.", Color.red); + return; + } + + _oauth2Provider = new ServerOAuth2Provider(config); + + if (!_oauth2Provider.IsConfigured) + { + Debug.LogError("[ServerOAuth2Example] OAuth2 설정이 유효하지 않습니다."); + ShowStatus("OAuth2 설정이 유효하지 않습니다.", Color.red); + return; + } + + Debug.Log("[ServerOAuth2Example] OAuth2 Provider 초기화 완료"); + Debug.Log(_oauth2Provider.GetDebugInfo()); + } + + private void InitializeTokenServices() + { + _tokenManager = TokenManager.Instance; + _refreshService = TokenRefreshService.Instance; + + // 이벤트 구독 + _tokenManager.OnTokensUpdated += OnTokensUpdated; + _tokenManager.OnTokensExpired += OnTokensExpired; + _tokenManager.OnTokensCleared += OnTokensCleared; + _refreshService.OnTokenRefreshed += OnTokenRefreshed; + _refreshService.OnTokenRefreshFailed += OnTokenRefreshFailed; + + Debug.Log("[ServerOAuth2Example] Token 서비스 초기화 완료"); + } + + private void SetupEventHandlers() + { + if (loginButton != null) + loginButton.onClick.AddListener(() => _ = OnLoginButtonClicked()); + + if (logoutButton != null) + logoutButton.onClick.AddListener(() => _ = OnLogoutButtonClicked()); + + if (refreshButton != null) + refreshButton.onClick.AddListener(() => _ = OnRefreshButtonClicked()); + + if (clearButton != null) + clearButton.onClick.AddListener(OnClearButtonClicked); + + if (checkButton != null) + checkButton.onClick.AddListener(() => _ = OnCheckButtonClicked()); + } + + #endregion + + #region UI Event Handlers + + private async UniTaskVoid OnLoginButtonClicked() + { + if (_isLoggedIn) + { + ShowStatus("이미 로그인되어 있습니다.", Color.yellow); + return; + } + + if (_oauth2Provider == null) + { + ShowStatus("OAuth2 Provider가 초기화되지 않았습니다.", Color.red); + return; + } + + try + { + ShowStatus("서버 OAuth2 로그인 시작...", Color.blue); + SetButtonsEnabled(false); + + // 전체 OAuth2 로그인 플로우 실행 + _currentTokenSet = await _oauth2Provider.LoginWithServerOAuth2Async(); + + if (_currentTokenSet?.HasRefreshToken() == true) + { + _isLoggedIn = true; + ShowStatus("서버 OAuth2 로그인 성공!", Color.green); + + // 상세 토큰 정보 로그 + LogDetailedTokenInfo(); + + // 토큰 정보 표시 + DisplayTokenInfo(); + + // 자동 토큰 갱신 시작 + _ = StartAutoTokenRefresh(); + } + else + { + ShowStatus("로그인 실패: 유효하지 않은 토큰", Color.red); + } + } + catch (System.Exception ex) + { + ShowStatus($"로그인 실패: {ex.Message}", Color.red); + Debug.LogError($"[ServerOAuth2Example] 로그인 오류: {ex}"); + } + finally + { + SetButtonsEnabled(true); + UpdateUI(); + } + } + + private async UniTaskVoid OnLogoutButtonClicked() + { + if (!_isLoggedIn) + { + ShowStatus("로그인되어 있지 않습니다.", Color.yellow); + return; + } + + try + { + ShowStatus("로그아웃 중...", Color.blue); + + // TokenManager에서 토큰 정리 + _tokenManager.ClearTokens(); + + // 토큰 정리 + _currentTokenSet?.Clear(); + _currentTokenSet = null; + _isLoggedIn = false; + + // UI 초기화 + ClearUserInfo(); + ShowStatus("로그아웃 완료", Color.green); + } + catch (System.Exception ex) + { + ShowStatus($"로그아웃 실패: {ex.Message}", Color.red); + Debug.LogError($"[ServerOAuth2Example] 로그아웃 오류: {ex}"); + } + finally + { + UpdateUI(); + } + } + + #endregion + + #region Token Management + + private async UniTaskVoid StartAutoTokenRefresh() + { + while (_isLoggedIn && _currentTokenSet?.HasRefreshToken() == true) + { + try + { + // 토큰 갱신이 필요한지 확인 + if (_currentTokenSet.NeedsRefresh()) + { + Debug.Log("[ServerOAuth2Example] 토큰 갱신 시작"); + + // 현재는 전체 재로그인 플로우 실행 + var newTokenSet = await _oauth2Provider.LoginWithServerOAuth2Async(); + + if (newTokenSet?.HasRefreshToken() == true) + { + _currentTokenSet = newTokenSet; + Debug.Log("[ServerOAuth2Example] 토큰 갱신 성공"); + DisplayTokenInfo(); + } + else + { + Debug.LogWarning("[ServerOAuth2Example] 토큰 갱신 실패"); + break; + } + } + + // 1분 대기 + await UniTask.Delay(60000); + } + catch (System.Exception ex) + { + Debug.LogError($"[ServerOAuth2Example] 토큰 갱신 오류: {ex.Message}"); + break; + } + } + } + + #endregion + + #region Token Management Buttons + + private async UniTaskVoid OnRefreshButtonClicked() + { + try + { + ShowStatus("토큰 갱신 시작...", Color.yellow); + + var success = await _refreshService.ForceRefreshAsync(); + + if (success) + { + ShowStatus("토큰 갱신 성공!", Color.green); + // 갱신된 토큰으로 현재 토큰 세트 업데이트 + _currentTokenSet = _tokenManager.LoadTokens(); + } + else + { + ShowStatus("토큰 갱신 실패", Color.red); + } + + UpdateUI(); + } + catch (System.Exception ex) + { + ShowStatus($"토큰 갱신 오류: {ex.Message}", Color.red); + Debug.LogError($"[ServerOAuth2Example] 토큰 갱신 실패: {ex.Message}"); + } + } + + private void OnClearButtonClicked() + { + try + { + _tokenManager.ClearTokens(); + _currentTokenSet = null; + _isLoggedIn = false; + ShowStatus("토큰 삭제 완료", Color.blue); + UpdateUI(); + } + catch (System.Exception ex) + { + ShowStatus($"토큰 삭제 오류: {ex.Message}", Color.red); + Debug.LogError($"[ServerOAuth2Example] 토큰 삭제 실패: {ex.Message}"); + } + } + + private async UniTaskVoid OnCheckButtonClicked() + { + try + { + ShowStatus("토큰 상태 확인 중...", Color.yellow); + + var hasValidToken = await _refreshService.EnsureValidTokenAsync(); + + if (hasValidToken) + { + ShowStatus("유효한 토큰이 있습니다.", Color.green); + // 유효한 토큰으로 현재 토큰 세트 업데이트 + _currentTokenSet = _tokenManager.LoadTokens(); + } + else + { + ShowStatus("유효한 토큰이 없습니다.", Color.red); + } + + UpdateUI(); + } + catch (System.Exception ex) + { + ShowStatus($"토큰 확인 오류: {ex.Message}", Color.red); + Debug.LogError($"[ServerOAuth2Example] 토큰 확인 실패: {ex.Message}"); + } + } + + #endregion + + #region Token Events + + private void OnTokensUpdated(TokenSet tokenSet) + { + Debug.Log("[ServerOAuth2Example] 토큰 업데이트됨"); + _currentTokenSet = tokenSet; + UpdateUI(); + } + + private void OnTokensExpired() + { + Debug.Log("[ServerOAuth2Example] 토큰 만료됨"); + ShowStatus("토큰이 만료되었습니다.", Color.red); + UpdateUI(); + } + + private void OnTokensCleared() + { + Debug.Log("[ServerOAuth2Example] 토큰 삭제됨"); + _currentTokenSet = null; + _isLoggedIn = false; + ShowStatus("토큰이 삭제되었습니다.", Color.blue); + UpdateUI(); + } + + private void OnTokenRefreshed(string newAccessToken) + { + Debug.Log("[ServerOAuth2Example] 토큰 갱신 성공"); + ShowStatus("토큰 갱신 성공!", Color.green); + UpdateUI(); + } + + private void OnTokenRefreshFailed(string error) + { + Debug.Log($"[ServerOAuth2Example] 토큰 갱신 실패: {error}"); + ShowStatus($"토큰 갱신 실패: {error}", Color.red); + UpdateUI(); + } + + #endregion + + #region UI Management + + private void UpdateUI() + { + if (loginButton != null) + loginButton.interactable = !_isLoggedIn; + + if (logoutButton != null) + logoutButton.interactable = _isLoggedIn; + + if (refreshButton != null) + refreshButton.interactable = _isLoggedIn; + + if (clearButton != null) + clearButton.interactable = _isLoggedIn; + + if (checkButton != null) + checkButton.interactable = true; // 항상 활성화 + } + + private void SetButtonsEnabled(bool enabled) + { + if (loginButton != null) loginButton.interactable = enabled && !_isLoggedIn; + if (logoutButton != null) logoutButton.interactable = enabled && _isLoggedIn; + if (refreshButton != null) refreshButton.interactable = enabled && _isLoggedIn; + if (clearButton != null) clearButton.interactable = enabled && _isLoggedIn; + if (checkButton != null) checkButton.interactable = enabled; + } + + private void ShowStatus(string message, Color color) + { + if (statusText != null) + { + statusText.text = message; + statusText.color = color; + } + + Debug.Log($"[ServerOAuth2Example] {message}"); + } + + private void DisplayTokenInfo() + { + if (userInfoText == null || _currentTokenSet == null) + return; + + var info = $"토큰 정보:\n"; + info += $"Access Token: {_currentTokenSet.AccessToken?.Token?.Substring(0, Math.Min(20, _currentTokenSet.AccessToken.Token.Length))}...\n"; + info += $"Access Token 만료: {_currentTokenSet.AccessToken?.ExpiresAt:yyyy-MM-dd HH:mm:ss}\n"; + info += $"Refresh Token: {(string.IsNullOrEmpty(_currentTokenSet.RefreshToken?.Token) ? "없음" : "있음")}\n"; + info += $"갱신 필요: {_currentTokenSet.NeedsRefresh()}"; + + userInfoText.text = info; + } + + /// + /// 상세 토큰 정보를 콘솔에 로그 + /// + private void LogDetailedTokenInfo() + { + if (_currentTokenSet == null) + { + Debug.LogError("[ServerOAuth2Example] 토큰 세트가 null입니다."); + return; + } + + Debug.Log("=== 🎉 OAuth2 로그인 성공 - 토큰 정보 ==="); + + // Access Token 정보 + if (_currentTokenSet.AccessToken != null) + { + Debug.Log($"✅ Access Token: {_currentTokenSet.AccessToken.Token}"); + Debug.Log($" - 만료 시간: {_currentTokenSet.AccessToken.ExpiresAt:yyyy-MM-dd HH:mm:ss}"); + Debug.Log($" - 만료까지: {(_currentTokenSet.AccessToken.ExpiresAt - DateTime.UtcNow).TotalMinutes:F1}분"); + } + else + { + Debug.LogError("❌ Access Token이 null입니다."); + } + + // Refresh Token 정보 + if (_currentTokenSet.RefreshToken != null) + { + Debug.Log($"✅ Refresh Token: {_currentTokenSet.RefreshToken.Token}"); + Debug.Log($" - 만료 시간: {_currentTokenSet.RefreshToken.ExpiresAt:yyyy-MM-dd HH:mm:ss}"); + Debug.Log($" - 만료까지: {(_currentTokenSet.RefreshToken.ExpiresAt - DateTime.UtcNow).TotalDays:F1}일"); + Debug.Log($" - 디바이스 ID: {_currentTokenSet.RefreshToken.DeviceId}"); + } + else + { + Debug.LogWarning("⚠️ Refresh Token이 null입니다."); + } + + // 토큰 세트 상태 + Debug.Log($"📊 토큰 세트 상태:"); + Debug.Log($" - 갱신 필요: {_currentTokenSet.NeedsRefresh()}"); + Debug.Log($" - Refresh Token 보유: {_currentTokenSet.HasRefreshToken()}"); + + Debug.Log("=== 토큰 정보 끝 ==="); + } + + private void ClearUserInfo() + { + if (userInfoText != null) + { + userInfoText.text = "로그인하여 토큰 정보를 확인하세요."; + } + + if (debugText != null) + { + debugText.text = "토큰 정보가 없습니다."; + } + } + + #endregion + + #region Public Methods + + /// + /// 현재 토큰 세트 조회 + /// + public TokenSet GetCurrentTokenSet() + { + return _currentTokenSet; + } + + + #endregion + } +} diff --git a/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs.meta b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs.meta new file mode 100644 index 0000000..0e296ee --- /dev/null +++ b/Assets/Infrastructure/Auth/Examples/ServerOAuth2Example.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59d58c6b995b5494abd828406d93edee \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/JwtTokenParser.cs b/Assets/Infrastructure/Auth/JwtTokenParser.cs new file mode 100644 index 0000000..71f3aa3 --- /dev/null +++ b/Assets/Infrastructure/Auth/JwtTokenParser.cs @@ -0,0 +1,55 @@ +using System; +using System.Text; +using UnityEngine; +using Newtonsoft.Json; + +namespace ProjectVG.Infrastructure.Auth +{ + public static class JwtTokenParser + { + public static DateTime GetExpirationTime(string token) + { + if (string.IsNullOrEmpty(token)) + return DateTime.MinValue; + + try + { + var parts = token.Split('.'); + if (parts.Length != 3) + return DateTime.MinValue; + + var payload = parts[1]; + var payloadJson = Encoding.UTF8.GetString(Base64UrlDecode(payload)); + var jwtPayload = JsonConvert.DeserializeObject(payloadJson); + + if (jwtPayload == null || jwtPayload.exp <= 0) + return DateTime.MinValue; + + return DateTimeOffset.FromUnixTimeSeconds(jwtPayload.exp).UtcDateTime; + } + catch (Exception ex) + { + Debug.LogError($"[JwtTokenParser] JWT 파싱 실패: {ex.Message}"); + return DateTime.MinValue; + } + } + + private static byte[] Base64UrlDecode(string input) + { + input = input.Replace('-', '+').Replace('_', '/'); + input = PadBase64String(input); + return Convert.FromBase64String(input); + } + + private static string PadBase64String(string base64) + { + var padding = base64.Length % 4; + return padding > 0 ? base64.PadRight(base64.Length + (4 - padding), '=') : base64; + } + + private class JwtPayload + { + public long exp { get; set; } + } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/JwtTokenParser.cs.meta b/Assets/Infrastructure/Auth/JwtTokenParser.cs.meta new file mode 100644 index 0000000..d9d86be --- /dev/null +++ b/Assets/Infrastructure/Auth/JwtTokenParser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 56aba44677b62804d97d984948b307f6 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Models.meta b/Assets/Infrastructure/Auth/Models.meta new file mode 100644 index 0000000..d6cfb07 --- /dev/null +++ b/Assets/Infrastructure/Auth/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 54cf275a6da6ecd44b44c6ac73a399da +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/Models/AccessToken.cs b/Assets/Infrastructure/Auth/Models/AccessToken.cs new file mode 100644 index 0000000..b3db9e6 --- /dev/null +++ b/Assets/Infrastructure/Auth/Models/AccessToken.cs @@ -0,0 +1,30 @@ +using System; + +namespace ProjectVG.Infrastructure.Auth.Models +{ + [Serializable] + public class AccessToken + { + public string Token { get; set; } + public DateTime ExpiresAt { get; set; } + + public AccessToken() { } + + public AccessToken(string token) + { + Token = token ?? throw new ArgumentNullException(nameof(token)); + var exp = JwtTokenParser.GetExpirationTime(token); + ExpiresAt = exp == DateTime.MinValue ? DateTime.MinValue : exp.ToUniversalTime(); + } + + public bool IsExpired() + { + return DateTime.UtcNow >= ExpiresAt; + } + + public bool IsExpiringSoon(int minutesBeforeExpiry) + { + return DateTime.UtcNow.AddMinutes(minutesBeforeExpiry) >= ExpiresAt; + } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Models/AccessToken.cs.meta b/Assets/Infrastructure/Auth/Models/AccessToken.cs.meta new file mode 100644 index 0000000..ff5456f --- /dev/null +++ b/Assets/Infrastructure/Auth/Models/AccessToken.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2b8e8d468f9dc2346b7637200a36f19a \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Models/RefreshToken.cs b/Assets/Infrastructure/Auth/Models/RefreshToken.cs new file mode 100644 index 0000000..b42c945 --- /dev/null +++ b/Assets/Infrastructure/Auth/Models/RefreshToken.cs @@ -0,0 +1,33 @@ +using System; + +namespace ProjectVG.Infrastructure.Auth.Models +{ + [Serializable] + public class RefreshToken + { + public string Token { get; set; } + public DateTime ExpiresAt { get; set; } + public string DeviceId { get; set; } + + public RefreshToken() { } + + public RefreshToken(string token, int expiresIn, string deviceId = null) + { + Token = token ?? throw new ArgumentNullException(nameof(token)); + ExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn); + DeviceId = deviceId ?? string.Empty; + } + + public RefreshToken(string token, DateTime expiresAt, string deviceId = null) + { + Token = token ?? throw new ArgumentNullException(nameof(token)); + ExpiresAt = expiresAt; + DeviceId = deviceId ?? string.Empty; + } + + public bool IsExpired() + { + return DateTime.UtcNow >= ExpiresAt; + } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Models/RefreshToken.cs.meta b/Assets/Infrastructure/Auth/Models/RefreshToken.cs.meta new file mode 100644 index 0000000..4167094 --- /dev/null +++ b/Assets/Infrastructure/Auth/Models/RefreshToken.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7d2eacc69bf7b76418550b805b0204bd \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Models/TokenSet.cs b/Assets/Infrastructure/Auth/Models/TokenSet.cs new file mode 100644 index 0000000..43ec56e --- /dev/null +++ b/Assets/Infrastructure/Auth/Models/TokenSet.cs @@ -0,0 +1,150 @@ +using System; + +namespace ProjectVG.Infrastructure.Auth.Models +{ + /// + /// 토큰 세트 (Access Token + Refresh Token) + /// + [Serializable] + public class TokenSet + { + /// + /// Access Token + /// + public AccessToken AccessToken { get; set; } + + /// + /// Refresh Token + /// + public RefreshToken RefreshToken { get; set; } + + /// + /// 생성 시간 + /// + public DateTime CreatedAt { get; set; } + + public TokenSet() + { + CreatedAt = DateTime.UtcNow; + } + + public TokenSet(AccessToken accessToken, RefreshToken refreshToken = null) + { + AccessToken = accessToken ?? throw new ArgumentNullException(nameof(accessToken)); + RefreshToken = refreshToken; + CreatedAt = DateTime.UtcNow; + } + + /// + /// Access Token이 만료되었는지 확인 + /// + public bool IsAccessTokenExpired() + { + return AccessToken?.IsExpired() ?? true; + } + + /// + /// Refresh Token이 있는지 확인 + /// + public bool HasRefreshToken() + { + return RefreshToken != null && !IsRefreshTokenExpired(); + } + + /// + /// Refresh Token이 만료되었는지 확인 + /// + public bool IsRefreshTokenExpired() + { + return RefreshToken?.IsExpired() ?? true; + } + + /// + /// 토큰 갱신이 필요한지 확인 + /// + /// 만료 전 버퍼 시간 (분) + /// 갱신 필요 여부 + public bool NeedsRefresh(int bufferMinutes = 5) + { + if (AccessToken == null) + return true; + + if (AccessToken.IsExpired()) + return true; + + // 만료 5분 전에 갱신 + var refreshTime = AccessToken.ExpiresAt.AddMinutes(-bufferMinutes); + return DateTime.UtcNow >= refreshTime; + } + + /// + /// 토큰 세트를 새로 업데이트 + /// + /// 새 Access Token + /// 새 Refresh Token (선택적) + public void UpdateTokens(AccessToken newAccessToken, RefreshToken newRefreshToken = null) + { + AccessToken = newAccessToken ?? throw new ArgumentNullException(nameof(newAccessToken)); + + // Refresh Token이 제공된 경우에만 업데이트 + if (newRefreshToken != null) + { + RefreshToken = newRefreshToken; + } + + CreatedAt = DateTime.UtcNow; + } + + /// + /// 토큰 세트 초기화 + /// + public void Clear() + { + AccessToken = null; + RefreshToken = null; + } + + /// + /// 디버그 정보 출력 + /// + /// 디버그 정보 + public string GetDebugInfo() + { + var info = $"TokenSet Debug Info:\n"; + info += $"Created At: {CreatedAt:yyyy-MM-dd HH:mm:ss} UTC\n"; + info += $"Has Refresh Token: {HasRefreshToken()}\n"; + info += $"Needs Refresh: {NeedsRefresh()}\n"; + + if (AccessToken != null) + { + info += $"Access Token:\n"; +#if UNITY_EDITOR || DEVELOPMENT_BUILD + var at = AccessToken.Token; + var atMasked = string.IsNullOrEmpty(at) || at.Length < 8 ? "***" : $"{at.Substring(0,4)}****{at.Substring(at.Length-4)}"; + info += $" Token: {atMasked}\n"; +#else + info += $" Token: (hidden in release)\n"; +#endif + info += $" Expires At: {AccessToken.ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC\n"; + info += $" Is Expired: {AccessToken.IsExpired()}\n"; + } + + if (RefreshToken != null) + { + info += $"Refresh Token:\n"; +#if UNITY_EDITOR || DEVELOPMENT_BUILD + var rt = RefreshToken.Token; + var rtMasked = string.IsNullOrEmpty(rt) || rt.Length < 8 ? "***" : $"{rt.Substring(0,4)}****{rt.Substring(rt.Length-4)}"; + info += $" Token: {rtMasked}\n"; +#else + info += $" Token: (hidden in release)\n"; +#endif + info += $" Expires At: {RefreshToken.ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC\n"; + info += $" Is Expired: {RefreshToken.IsExpired()}\n"; + info += $" Device ID: (masked)\n"; + } + + return info; + } + } +} diff --git a/Assets/Infrastructure/Auth/Models/TokenSet.cs.meta b/Assets/Infrastructure/Auth/Models/TokenSet.cs.meta new file mode 100644 index 0000000..efbb734 --- /dev/null +++ b/Assets/Infrastructure/Auth/Models/TokenSet.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc2ca688c549a0b4f81b25db48667d3e \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2.meta b/Assets/Infrastructure/Auth/OAuth2.meta new file mode 100644 index 0000000..4a2335d --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: baf49892df204df428512f4cbefd61d1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/OAuth2/Config.meta b/Assets/Infrastructure/Auth/OAuth2/Config.meta new file mode 100644 index 0000000..7cb5211 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Config.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 895e898dacca990498955ec530983eff +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs b/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs new file mode 100644 index 0000000..9c7f497 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs @@ -0,0 +1,202 @@ +using System; +using UnityEngine; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Config +{ + /// + /// 서버 OAuth2 설정 + /// + [Serializable] + [CreateAssetMenu(fileName = "ServerOAuth2Config", menuName = "Auth/Server OAuth2 Config")] + public class ServerOAuth2Config : ScriptableObject + { + [Header("OAuth2 설정")] + + [Header("플랫폼별 리다이렉트 URI")] + [SerializeField] private string webGLRedirectUri = "http://localhost:3000/auth/callback"; + [SerializeField] private string androidRedirectUri = "com.yourgame://auth/callback"; + [SerializeField] private string iosRedirectUri = "com.yourgame://auth/callback"; + [SerializeField] private string windowsRedirectUri = "http://localhost:3000/auth/callback"; + [SerializeField] private string macosRedirectUri = "http://localhost:3000/auth/callback"; + + [Header("고급 설정")] + [SerializeField] private int pkceCodeVerifierLength = 64; + [SerializeField] private int stateLength = 16; + [SerializeField] private float timeoutSeconds = 300f; + + // Singleton instance + private static ServerOAuth2Config _instance; + public static ServerOAuth2Config Instance + { + get + { + if (_instance == null) + { + _instance = Resources.Load("ServerOAuth2Config"); + if (_instance == null) + { + Debug.LogError("ServerOAuth2Config를 찾을 수 없습니다. Resources 폴더에 ServerOAuth2Config.asset 파일을 생성하세요."); + _instance = CreateDefaultInstance(); + } + } + return _instance; + } + } + + /// + /// 서버 URL (NetworkConfig에서 가져옴) + /// + public string ServerUrl => ProjectVG.Infrastructure.Network.Configs.NetworkConfig.HttpServerAddress; + + + /// + /// 현재 플랫폼의 리다이렉트 URI + /// + public string GetCurrentPlatformRedirectUri() + { +#if UNITY_WEBGL && !UNITY_EDITOR + return webGLRedirectUri; +#elif UNITY_ANDROID && !UNITY_EDITOR + return androidRedirectUri; +#elif UNITY_IOS && !UNITY_EDITOR + return iosRedirectUri; +#elif UNITY_STANDALONE_WIN && !UNITY_EDITOR + return windowsRedirectUri; +#elif UNITY_STANDALONE_OSX && !UNITY_EDITOR + return macosRedirectUri; +#else + // 에디터에서는 Windows 설정 사용 + return windowsRedirectUri; +#endif + } + + /// + /// PKCE Code Verifier 길이 + /// + public int PKCECodeVerifierLength => pkceCodeVerifierLength; + + /// + /// State 길이 + /// + public int StateLength => stateLength; + + /// + /// 타임아웃 (초) + /// + public float TimeoutSeconds => timeoutSeconds; + + /// + /// 설정 유효성 검사 + /// + public bool IsValid() + { + // NetworkConfig 유효성 검사 + var networkConfig = ProjectVG.Infrastructure.Network.Configs.NetworkConfig.Instance; + if (networkConfig == null) + { + Debug.LogError("[ServerOAuth2Config] NetworkConfig를 찾을 수 없습니다."); + return false; + } + + // 기본 필수 값 검사 + var hasRequiredFields = !string.IsNullOrEmpty(GetCurrentPlatformRedirectUri()); + + // PKCE 설정 검사 + var hasValidPKCE = pkceCodeVerifierLength >= 43 && pkceCodeVerifierLength <= 128 && + stateLength >= 16 && stateLength <= 64; + + // 타임아웃 검사 + var hasValidTimeout = timeoutSeconds > 0; + + var isValid = hasRequiredFields && hasValidPKCE && hasValidTimeout; + + if (!isValid) + { + Debug.LogError($"[ServerOAuth2Config] 설정 유효성 검사 실패:"); + + if (string.IsNullOrEmpty(GetCurrentPlatformRedirectUri())) + Debug.LogError($" - redirectUri: 비어있음"); + else if (GetCurrentPlatformRedirectUri().Contains("your-domain")) + Debug.LogError($" - redirectUri: '{GetCurrentPlatformRedirectUri()}' (기본값입니다. 실제 도메인으로 변경하세요)"); + else + Debug.LogError($" - redirectUri: '{GetCurrentPlatformRedirectUri()}'"); + + if (!hasValidPKCE) + { + Debug.LogError($" - pkceCodeVerifierLength: {pkceCodeVerifierLength} (43-128 사이여야 함)"); + Debug.LogError($" - stateLength: {stateLength} (16-64 사이여야 함)"); + } + + if (!hasValidTimeout) + Debug.LogError($" - timeoutSeconds: {timeoutSeconds} (0보다 커야 함)"); + } + else + { + Debug.Log($"[ServerOAuth2Config] 설정 유효성 검사 통과"); + Debug.Log($" - 서버: {ServerUrl} (NetworkConfig에서 가져옴)"); + Debug.Log($" - 플랫폼: {GetCurrentPlatformName()}"); + Debug.Log($" - 리다이렉트 URI: {GetCurrentPlatformRedirectUri()}"); + } + + return isValid; + } + + /// + /// 현재 플랫폼 이름 + /// + public string GetCurrentPlatformName() + { +#if UNITY_WEBGL + return "WebGL"; +#elif UNITY_ANDROID + return "Android"; +#elif UNITY_IOS + return "iOS"; +#elif UNITY_STANDALONE_WIN + return "Windows"; +#elif UNITY_STANDALONE_OSX + return "macOS"; +#else + return "Unknown"; +#endif + } + + /// + /// 기본 인스턴스 생성 + /// + private static ServerOAuth2Config CreateDefaultInstance() + { + var instance = CreateInstance(); + + // JavaScript 클라이언트와 동일한 기본값으로 초기화 + instance.webGLRedirectUri = "http://localhost:3000/auth/callback"; + instance.androidRedirectUri = "com.yourgame://auth/callback"; + instance.iosRedirectUri = "com.yourgame://auth/callback"; + instance.windowsRedirectUri = "http://localhost:3000/auth/callback"; + instance.macosRedirectUri = "http://localhost:3000/auth/callback"; + instance.pkceCodeVerifierLength = 64; // PKCE 표준에 맞게 64바이트 + instance.stateLength = 16; + instance.timeoutSeconds = 300f; + + Debug.LogWarning("기본 ServerOAuth2Config를 생성했습니다. Resources 폴더에 ServerOAuth2Config.asset 파일을 생성하는 것을 권장합니다."); + + return instance; + } + +#if UNITY_EDITOR + [Header("개발자 도구")] + [SerializeField] private bool showDebugInfo = false; + + private void OnValidate() + { + if (showDebugInfo) + { + Debug.Log($"[ServerOAuth2Config] 현재 플랫폼: {GetCurrentPlatformName()}"); + Debug.Log($"[ServerOAuth2Config] 서버 URL: {ServerUrl}"); + Debug.Log($"[ServerOAuth2Config] 리다이렉트 URI: {GetCurrentPlatformRedirectUri()}"); + Debug.Log($"[ServerOAuth2Config] 설정 유효: {IsValid()}"); + } + } +#endif + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs.meta new file mode 100644 index 0000000..bb94adb --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Config/ServerOAuth2Config.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5a7fa5cf92f687c4aa7ac92837c95546 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers.meta b/Assets/Infrastructure/Auth/OAuth2/Handlers.meta new file mode 100644 index 0000000..514cbca --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 67efa4d19606a8c4b81b6b658f047223 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs b/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs new file mode 100644 index 0000000..dd923bf --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs @@ -0,0 +1,335 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using UnityEngine; +using Cysharp.Threading.Tasks; +using System.Threading; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Handlers +{ + /// + /// 데스크톱 OAuth2 콜백 핸들러 + /// 로컬 HTTP 서버를 통해 OAuth2 콜백을 처리 + /// + public class DesktopCallbackHandler : IOAuth2CallbackHandler + { + private string _expectedState; + private float _timeoutSeconds; + private bool _isInitialized = false; + private bool _isDisposed = false; + private HttpListener _listener; + private CancellationTokenSource _cancellationTokenSource; + private string _callbackUrl; + private DateTime _lastActivityTime; + private bool _isWaitingForCallback = false; + + public string PlatformName => "Desktop"; + public bool IsSupported => HttpListener.IsSupported; + + public async Task InitializeAsync(string expectedState, float timeoutSeconds) + { + _expectedState = expectedState; + _timeoutSeconds = timeoutSeconds; + _isInitialized = true; + _cancellationTokenSource = new CancellationTokenSource(); + _lastActivityTime = DateTime.UtcNow; + + // Unity 이벤트 등록 + Application.focusChanged += OnApplicationFocusChanged; + + // 로컬 HTTP 서버 시작 + await StartLocalServerAsync(); + + Debug.Log($"[DesktopCallbackHandler] 초기화 완료 - State: {expectedState}, Timeout: {timeoutSeconds}초"); + } + + public async Task WaitForCallbackAsync() + { + if (!_isInitialized) + { + throw new InvalidOperationException("데스크톱 콜백 핸들러가 초기화되지 않았습니다."); + } + + Debug.Log("[DesktopCallbackHandler] OAuth2 콜백 대기 시작"); + _isWaitingForCallback = true; + + var startTime = DateTime.UtcNow; + var timeout = TimeSpan.FromSeconds(_timeoutSeconds); + + while (DateTime.UtcNow - startTime < timeout && !_isDisposed) + { + if (!string.IsNullOrEmpty(_callbackUrl)) + { + Debug.Log($"[DesktopCallbackHandler] OAuth2 콜백 수신: {_callbackUrl}"); + _isWaitingForCallback = false; + return _callbackUrl; + } + + // 앱이 포커스를 잃었을 때 더 자주 체크 + var checkInterval = Application.isFocused ? 100 : 50; // 백그라운드일 때 더 빠르게 체크 + await UniTask.Delay(checkInterval); + + // 디버그 정보 출력 (10초마다) + if ((DateTime.UtcNow - _lastActivityTime).TotalSeconds >= 10) + { + Debug.Log($"[DesktopCallbackHandler] 콜백 대기 중... (경과: {(DateTime.UtcNow - startTime).TotalSeconds:F1}초, 포커스: {Application.isFocused})"); + _lastActivityTime = DateTime.UtcNow; + } + } + + Debug.LogWarning("[DesktopCallbackHandler] OAuth2 콜백 타임아웃"); + _isWaitingForCallback = false; + return null; + } + + public void Cleanup() + { + _isDisposed = true; + _isWaitingForCallback = false; + _cancellationTokenSource?.Cancel(); + _listener?.Stop(); + _listener?.Close(); + + // Unity 이벤트 해제 + Application.focusChanged -= OnApplicationFocusChanged; + + Debug.Log("[DesktopCallbackHandler] 정리 완료"); + } + + /// + /// 로컬 HTTP 서버 시작 + /// + private async Task StartLocalServerAsync() + { + try + { + _listener = new HttpListener(); + + // OAuth2Config의 현재 플랫폼 리다이렉트 URI를 사용 + var redirectUri = ProjectVG.Infrastructure.Auth.OAuth2.Config.ServerOAuth2Config.Instance.GetCurrentPlatformRedirectUri(); + var uri = new Uri(redirectUri); + // HttpListener는 pathless 프리픽스를 권장(하위 경로 허용) + var prefix = $"{uri.Scheme}://{uri.Host}:{uri.Port}/"; + _listener.Prefixes.Add(prefix); + _listener.Start(); + + Debug.Log($"[DesktopCallbackHandler] 로컬 서버 시작: {prefix}"); + + // 서버 리스닝 시작 + _ = ListenForCallbackAsync(); + + await UniTask.CompletedTask; + } + catch (Exception ex) + { + Debug.LogError($"[DesktopCallbackHandler] 로컬 서버 시작 실패: {ex.Message}"); + throw; + } + } + + /// + /// 콜백 리스닝 + /// + private async Task ListenForCallbackAsync() + { + try + { + while (!_isDisposed && _listener.IsListening) + { + var context = await _listener.GetContextAsync(); + _ = ProcessCallbackAsync(context); + } + } + catch (Exception ex) + { + if (!_isDisposed) + { + Debug.LogError($"[DesktopCallbackHandler] 콜백 리스닝 중 오류: {ex.Message}"); + } + } + } + + /// + /// 콜백 처리 + /// + private async Task ProcessCallbackAsync(HttpListenerContext context) + { + try + { + var request = context.Request; + var response = context.Response; + + Debug.Log($"[DesktopCallbackHandler] 요청 수신: {SanitizeUrl(request.Url?.ToString())}"); + + // OAuth2 콜백 URL인지 확인 (auth/callback 또는 auth/google/callback) + if (request.Url.AbsolutePath.Contains("auth/callback") || request.Url.AbsolutePath.Contains("auth/google/callback")) + { + var state = request.QueryString["state"]; + var success = request.QueryString["success"]; + + Debug.Log($"[DesktopCallbackHandler] OAuth2 콜백 파라미터 - State: {state}, Success: {success}"); + + if (!string.IsNullOrEmpty(state) && state == _expectedState) + { + // 성공 응답 + response.StatusCode = (int)HttpStatusCode.OK; + var successHtml = @" + + + + + OAuth2 성공 + + + +
✅ OAuth2 로그인 성공!
+
Unity 앱으로 돌아가세요.
+ + + "; + + var buffer = System.Text.Encoding.UTF8.GetBytes(successHtml); + response.ContentType = "text/html"; + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer, 0, buffer.Length); + response.Close(); + + // 콜백 URL 저장 + _callbackUrl = request.Url.ToString(); + } + else + { + // 실패 응답 + response.StatusCode = (int)HttpStatusCode.BadRequest; + var errorHtml = @" + + + + + OAuth2 실패 + + + +
❌ OAuth2 로그인 실패
+
Unity 앱으로 돌아가세요.
+ + + "; + + var buffer = System.Text.Encoding.UTF8.GetBytes(errorHtml); + response.ContentType = "text/html"; + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer, 0, buffer.Length); + response.Close(); + } + } + else + { + // 기본 응답 + response.StatusCode = (int)HttpStatusCode.OK; + var html = @" + + + + + OAuth2 콜백 서버 + + + +

OAuth2 콜백 서버

+

Unity OAuth2 콜백을 처리하는 서버입니다.

+ + "; + + var buffer = System.Text.Encoding.UTF8.GetBytes(html); + response.ContentType = "text/html"; + response.ContentLength64 = buffer.Length; + response.OutputStream.Write(buffer, 0, buffer.Length); + response.Close(); + } + } + catch (Exception ex) + { + Debug.LogError($"[DesktopCallbackHandler] 콜백 처리 중 오류: {ex.Message}"); + } + finally + { + try { context.Response?.OutputStream?.Close(); } catch { } + try { context.Response?.Close(); } catch { } + } + } + + /// + /// 앱 포커스 변경 이벤트 처리 + /// + private void OnApplicationFocusChanged(bool hasFocus) + { + if (_isWaitingForCallback) + { + Debug.Log($"[DesktopCallbackHandler] 앱 포커스 변경: {hasFocus}"); + + if (hasFocus) + { + // 앱이 다시 포커스를 받았을 때 콜백 URL 확인 + CheckForCallbackUrl(); + } + } + } + + /// + /// 콜백 URL 확인 (앱 포커스 복귀 시) + /// + private void CheckForCallbackUrl() + { + try + { + // 로컬 서버에서 최근 요청 확인 + if (_listener != null && _listener.IsListening) + { + Debug.Log("[DesktopCallbackHandler] 앱 포커스 복귀 - 콜백 URL 재확인"); + } + } + catch (Exception ex) + { + Debug.LogError($"[DesktopCallbackHandler] 콜백 URL 확인 중 오류: {ex.Message}"); + } + } + + /// + /// URL에서 민감한 정보를 마스킹 + /// + private static string SanitizeUrl(string url) + { + if (string.IsNullOrEmpty(url)) + return url; + + try + { + var u = new Uri(url); + var qs = System.Web.HttpUtility.ParseQueryString(u.Query); + if (qs["state"] != null) qs["state"] = "***"; + if (qs["code"] != null) qs["code"] = "***"; + var builder = new UriBuilder(u) { Query = qs.ToString() }; + return builder.Uri.ToString(); + } + catch + { + return url; + } + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs.meta new file mode 100644 index 0000000..78ec94e --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/DesktopCallbackHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2496e0a056d2a304ea56f51de2a637c5 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs b/Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs new file mode 100644 index 0000000..3147dbf --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading.Tasks; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Handlers +{ + /// + /// OAuth2 콜백 핸들러 인터페이스 + /// 플랫폼별 OAuth2 콜백 처리 방법을 정의 + /// + public interface IOAuth2CallbackHandler + { + /// + /// 콜백 핸들러 초기화 + /// + /// 예상되는 State 값 + /// 타임아웃 (초) + Task InitializeAsync(string expectedState, float timeoutSeconds); + + /// + /// 콜백 대기 + /// + /// 콜백 URL (없으면 null) + Task WaitForCallbackAsync(); + + /// + /// 콜백 핸들러 정리 + /// + void Cleanup(); + + /// + /// 현재 플랫폼 이름 + /// + string PlatformName { get; } + + /// + /// 지원 여부 + /// + bool IsSupported { get; } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs.meta new file mode 100644 index 0000000..297aafb --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/IOAuth2CallbackHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 107a800c174df5946b80fb8ade2ca9a4 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs b/Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs new file mode 100644 index 0000000..eefefa1 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs @@ -0,0 +1,134 @@ +using System; +using System.Threading.Tasks; +using UnityEngine; +using Cysharp.Threading.Tasks; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Handlers +{ + /// + /// 모바일 OAuth2 콜백 핸들러 + /// 커스텀 스킴을 통해 OAuth2 콜백을 처리 + /// + public class MobileCallbackHandler : IOAuth2CallbackHandler + { + private string _expectedState; + private float _timeoutSeconds; + private bool _isInitialized = false; + private bool _isDisposed = false; + private string _lastCustomUrl; + + public string PlatformName => "Mobile"; + public bool IsSupported => true; + + public async Task InitializeAsync(string expectedState, float timeoutSeconds) + { + _expectedState = expectedState; + _timeoutSeconds = timeoutSeconds; + _isInitialized = true; + + Debug.Log($"[MobileCallbackHandler] 초기화 완료 - State: {expectedState}, Timeout: {timeoutSeconds}초"); + await UniTask.CompletedTask; + } + + public async Task WaitForCallbackAsync() + { + if (!_isInitialized) + { + throw new InvalidOperationException("모바일 콜백 핸들러가 초기화되지 않았습니다."); + } + + Debug.Log("[MobileCallbackHandler] OAuth2 콜백 대기 시작"); + + var startTime = DateTime.UtcNow; + var timeout = TimeSpan.FromSeconds(_timeoutSeconds); + + while (DateTime.UtcNow - startTime < timeout && !_isDisposed) + { + // 커스텀 스킴 URL 확인 + var callbackUrl = CheckCustomSchemeUrl(); + + if (!string.IsNullOrEmpty(callbackUrl)) + { + Debug.Log($"[MobileCallbackHandler] OAuth2 콜백 수신: {callbackUrl}"); + return callbackUrl; + } + + // 100ms 대기 + await UniTask.Delay(100); + } + + Debug.LogWarning("[MobileCallbackHandler] OAuth2 콜백 타임아웃"); + return null; + } + + public void Cleanup() + { + _isDisposed = true; + Debug.Log("[MobileCallbackHandler] 정리 완료"); + } + + /// + /// 커스텀 스킴 URL 확인 + /// + private string CheckCustomSchemeUrl() + { + try + { + // Unity에서 커스텀 스킴 URL을 받는 방법 + // 실제로는 플랫폼별 네이티브 플러그인이 필요할 수 있음 + + // 임시로 시뮬레이션 (실제 구현에서는 네이티브 플러그인 사용) + if (Application.isFocused && !string.IsNullOrEmpty(_lastCustomUrl)) + { + var url = _lastCustomUrl; + _lastCustomUrl = null; // 한 번만 사용 + return url; + } + + return null; + } + catch (Exception ex) + { + Debug.LogError($"[MobileCallbackHandler] 커스텀 스킴 URL 확인 중 오류: {ex.Message}"); + return null; + } + } + + /// + /// 커스텀 스킴 URL 설정 (네이티브 플러그인에서 호출) + /// + public void SetCustomSchemeUrl(string url) + { + if (!string.IsNullOrEmpty(url) && url.Contains("auth/callback")) + { + _lastCustomUrl = url; + Debug.Log($"[MobileCallbackHandler] 커스텀 스킴 URL 설정: {url}"); + } + } + + /// + /// 앱 포커스 변경 시 호출 (Unity에서 자동 호출) + /// + public void OnApplicationFocus(bool hasFocus) + { + if (hasFocus) + { + Debug.Log("[MobileCallbackHandler] 앱 포커스 획득"); + // 여기서 커스텀 스킴 URL을 확인할 수 있음 + // 실제로는 네이티브 플러그인을 통해 URL을 받아야 함 + } + } + + /// + /// 앱 일시정지 시 호출 (Unity에서 자동 호출) + /// + public void OnApplicationPause(bool pauseStatus) + { + if (!pauseStatus) // 앱 재개 + { + Debug.Log("[MobileCallbackHandler] 앱 재개"); + // 여기서 커스텀 스킴 URL을 확인할 수 있음 + } + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs.meta new file mode 100644 index 0000000..174baf9 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/MobileCallbackHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ca02408ec03f4064aa227b1c43b8b99d \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs b/Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs new file mode 100644 index 0000000..743a7fe --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs @@ -0,0 +1,70 @@ +using UnityEngine; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Handlers +{ + /// + /// OAuth2 콜백 핸들러 팩토리 + /// 플랫폼에 맞는 콜백 핸들러를 생성 + /// + public static class OAuth2CallbackHandlerFactory + { + /// + /// 현재 플랫폼에 맞는 콜백 핸들러 생성 + /// + /// 플랫폼별 콜백 핸들러 + public static IOAuth2CallbackHandler CreateHandler() + { +#if UNITY_WEBGL && !UNITY_EDITOR + Debug.Log("[OAuth2CallbackHandlerFactory] WebGL 콜백 핸들러 생성"); + return new WebGLCallbackHandler(); +#elif UNITY_ANDROID && !UNITY_EDITOR + Debug.Log("[OAuth2CallbackHandlerFactory] Android 콜백 핸들러 생성"); + return new MobileCallbackHandler(); +#elif UNITY_IOS && !UNITY_EDITOR + Debug.Log("[OAuth2CallbackHandlerFactory] iOS 콜백 핸들러 생성"); + return new MobileCallbackHandler(); +#elif UNITY_STANDALONE_WIN && !UNITY_EDITOR + Debug.Log("[OAuth2CallbackHandlerFactory] Windows 콜백 핸들러 생성"); + return new DesktopCallbackHandler(); +#elif UNITY_STANDALONE_OSX && !UNITY_EDITOR + Debug.Log("[OAuth2CallbackHandlerFactory] macOS 콜백 핸들러 생성"); + return new DesktopCallbackHandler(); +#else + // 에디터에서는 데스크톱 핸들러 사용 + Debug.Log("[OAuth2CallbackHandlerFactory] 에디터용 데스크톱 콜백 핸들러 생성"); + return new DesktopCallbackHandler(); +#endif + } + + /// + /// 현재 플랫폼 이름 반환 + /// + /// 플랫폼 이름 + public static string GetCurrentPlatformName() + { +#if UNITY_WEBGL + return "WebGL"; +#elif UNITY_ANDROID + return "Android"; +#elif UNITY_IOS + return "iOS"; +#elif UNITY_STANDALONE_WIN + return "Windows"; +#elif UNITY_STANDALONE_OSX + return "macOS"; +#else + return "Editor"; +#endif + } + + /// + /// 현재 플랫폼이 지원되는지 확인 + /// + /// 지원 여부 + public static bool IsPlatformSupported() + { + var handler = CreateHandler(); + return handler.IsSupported; + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs.meta new file mode 100644 index 0000000..1163ee3 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/OAuth2CallbackHandlerFactory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e7a4d8435fbbf744aaa12df69d475c39 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs b/Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs new file mode 100644 index 0000000..2c5b32a --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs @@ -0,0 +1,121 @@ +using System; +using System.Threading.Tasks; +using UnityEngine; +using Cysharp.Threading.Tasks; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Handlers +{ + /// + /// WebGL OAuth2 콜백 핸들러 + /// URL 파라미터를 통해 OAuth2 콜백을 처리 + /// + public class WebGLCallbackHandler : IOAuth2CallbackHandler + { + private string _expectedState; + private float _timeoutSeconds; + private bool _isInitialized = false; + private bool _isDisposed = false; + + public string PlatformName => "WebGL"; + public bool IsSupported => true; + + public async Task InitializeAsync(string expectedState, float timeoutSeconds) + { + _expectedState = expectedState; + _timeoutSeconds = timeoutSeconds; + _isInitialized = true; + + Debug.Log($"[WebGLCallbackHandler] 초기화 완료 - State: {expectedState}, Timeout: {timeoutSeconds}초"); + await UniTask.CompletedTask; + } + + public async Task WaitForCallbackAsync() + { + if (!_isInitialized) + { + throw new InvalidOperationException("WebGL 콜백 핸들러가 초기화되지 않았습니다."); + } + + Debug.Log("[WebGLCallbackHandler] OAuth2 콜백 대기 시작"); + + var startTime = DateTime.UtcNow; + var timeout = TimeSpan.FromSeconds(_timeoutSeconds); + + while (DateTime.UtcNow - startTime < timeout && !_isDisposed) + { + // WebGL에서 URL 파라미터 확인 + var callbackUrl = CheckUrlParameters(); + + if (!string.IsNullOrEmpty(callbackUrl)) + { + Debug.Log($"[WebGLCallbackHandler] OAuth2 콜백 수신: {callbackUrl}"); + return callbackUrl; + } + + // 100ms 대기 + await UniTask.Delay(100); + } + + Debug.LogWarning("[WebGLCallbackHandler] OAuth2 콜백 타임아웃"); + return null; + } + + public void Cleanup() + { + _isDisposed = true; + Debug.Log("[WebGLCallbackHandler] 정리 완료"); + } + + /// + /// URL 파라미터에서 OAuth2 콜백 확인 + /// + private string CheckUrlParameters() + { + try + { + // WebGL에서 현재 URL 가져오기 + var currentUrl = Application.absoluteURL; + + if (string.IsNullOrEmpty(currentUrl)) + return null; + + // URL에 OAuth2 콜백 파라미터가 있는지 확인 + if (currentUrl.Contains("success=") && currentUrl.Contains("state=")) + { + // URL에서 state 파라미터 추출 + var stateParam = ExtractParameter(currentUrl, "state"); + + if (!string.IsNullOrEmpty(stateParam) && stateParam == _expectedState) + { + return currentUrl; + } + } + + return null; + } + catch (Exception ex) + { + Debug.LogError($"[WebGLCallbackHandler] URL 파라미터 확인 중 오류: {ex.Message}"); + return null; + } + } + + /// + /// URL에서 파라미터 추출 + /// + private string ExtractParameter(string url, string parameterName) + { + try + { + var uri = new Uri(url); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + return query[parameterName]; + } + catch (Exception ex) + { + Debug.LogError($"[WebGLCallbackHandler] 파라미터 추출 중 오류: {ex.Message}"); + return null; + } + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs.meta new file mode 100644 index 0000000..f93b3fe --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Handlers/WebGLCallbackHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c96d3d3c9d55bd84ab286fcc5b22190b \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs b/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs new file mode 100644 index 0000000..56584d5 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using ProjectVG.Infrastructure.Auth.Models; +using ProjectVG.Infrastructure.Auth.OAuth2.Models; + +namespace ProjectVG.Infrastructure.Auth.OAuth2 +{ + /// + /// 서버 OAuth2 클라이언트 인터페이스 + /// + public interface IServerOAuth2Client + { + /// + /// OAuth2 설정이 유효한지 확인 + /// + bool IsConfigured { get; } + + /// + /// PKCE 파라미터 생성 (Code Verifier, Code Challenge, State) + /// + Task GeneratePKCEAsync(); + + /// + /// 서버 OAuth2 인증 시작 + /// + Task StartServerOAuth2Async(PKCEParameters pkce); + + /// + /// OAuth2 콜백 처리 + /// + Task<(bool success, string state)> HandleOAuth2CallbackAsync(string callbackUrl); + + /// + /// 서버에서 토큰 요청 + /// + Task RequestTokenAsync(string state); + + /// + /// 전체 OAuth2 로그인 플로우 (편의 메서드) + /// + Task LoginWithServerOAuth2Async(); + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs.meta b/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs.meta new file mode 100644 index 0000000..dbfcfd1 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/IServerOAuth2Client.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c42caa18e3802e449824bef97cd5fedf \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Models.meta b/Assets/Infrastructure/Auth/OAuth2/Models.meta new file mode 100644 index 0000000..3cf1d8e --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Models.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3bfa6910952dbda4ab175d8487454a5d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs b/Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs new file mode 100644 index 0000000..ede848a --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs @@ -0,0 +1,65 @@ +using System; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Models +{ + /// + /// PKCE (Proof Key for Code Exchange) 파라미터 + /// + [Serializable] + public class PKCEParameters + { + /// + /// Code Verifier (43-128자 랜덤 문자열) + /// + public string CodeVerifier { get; set; } + + /// + /// Code Challenge (SHA256 해시된 Code Verifier) + /// + public string CodeChallenge { get; set; } + + /// + /// State 파라미터 (CSRF 방지) + /// + public string State { get; set; } + + /// + /// 생성 시간 + /// + public DateTime CreatedAt { get; set; } + + public PKCEParameters() + { + CreatedAt = DateTime.UtcNow; + } + + public PKCEParameters(string codeVerifier, string codeChallenge, string state) + { + CodeVerifier = codeVerifier; + CodeChallenge = codeChallenge; + State = state; + CreatedAt = DateTime.UtcNow; + } + + /// + /// PKCE 파라미터 유효성 검사 + /// + public bool IsValid() + { + return !string.IsNullOrEmpty(CodeVerifier) && + !string.IsNullOrEmpty(CodeChallenge) && + !string.IsNullOrEmpty(State) && + CodeVerifier.Length >= 43 && CodeVerifier.Length <= 128; + } + + /// + /// 만료 시간 검사 (기본 10분) + /// + public bool IsExpired(TimeSpan? maxAge = null) + { + var age = DateTime.UtcNow - CreatedAt; + var maxAgeValue = maxAge ?? TimeSpan.FromMinutes(10); + return age > maxAgeValue; + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs.meta new file mode 100644 index 0000000..0154a62 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Models/PKCEParameters.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 297b4b48ad8a852469f2dff27f6e0f14 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs b/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs new file mode 100644 index 0000000..c18a81d --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs @@ -0,0 +1,189 @@ +using System; +using Newtonsoft.Json; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Models +{ + /// + /// 서버 OAuth2 인증 요청 모델 + /// + [Serializable] + public class ServerOAuth2AuthorizeRequest + { + + [JsonProperty("state")] + public string State { get; set; } + + [JsonProperty("code_challenge")] + public string CodeChallenge { get; set; } + + [JsonProperty("code_challenge_method")] + public string CodeChallengeMethod { get; set; } = "S256"; + + [JsonProperty("code_verifier")] + public string CodeVerifier { get; set; } + + [JsonProperty("client_redirect_uri")] + public string ClientRedirectUri { get; set; } + } + + /// + /// 서버 OAuth2 인증 응답 모델 + /// + [Serializable] + public class ServerOAuth2AuthorizeResponse + { + [JsonProperty("success")] + public bool Success { get; set; } + + [JsonProperty("auth_url")] + public string AuthUrl { get; set; } + + [JsonProperty("state")] + public string State { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } + } + + /// + /// 서버 OAuth2 토큰 응답 모델 + /// + [Serializable] + public class ServerOAuth2TokenResponse + { + [JsonProperty("success")] + public bool Success { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } + + // HTTP 헤더에서 추출할 토큰 정보 + [JsonIgnore] + public string AccessToken { get; set; } + + [JsonIgnore] + public string RefreshToken { get; set; } + + [JsonIgnore] + public int ExpiresIn { get; set; } + + [JsonIgnore] + public string UserId { get; set; } + } + + /// + /// OAuth2 콜백 URL 파싱 결과 + /// + [Serializable] + public class OAuth2CallbackResult + { + /// + /// 성공 여부 + /// + public bool Success { get; set; } + + /// + /// State 파라미터 + /// + public string State { get; set; } + + /// + /// 에러 메시지 (실패 시) + /// + public string Error { get; set; } + + /// + /// 원본 URL + /// + public string OriginalUrl { get; set; } + + /// + /// 파싱된 쿼리 파라미터 + /// + public System.Collections.Generic.Dictionary QueryParameters { get; set; } + + public OAuth2CallbackResult() + { + QueryParameters = new System.Collections.Generic.Dictionary(); + } + + /// + /// 성공 결과 생성 + /// + public static OAuth2CallbackResult SuccessResult(string state, string originalUrl = null) + { + return new OAuth2CallbackResult + { + Success = true, + State = state, + OriginalUrl = originalUrl + }; + } + + /// + /// 실패 결과 생성 + /// + public static OAuth2CallbackResult ErrorResult(string error, string originalUrl = null) + { + return new OAuth2CallbackResult + { + Success = false, + Error = error, + OriginalUrl = originalUrl + }; + } + } + + /// + /// OAuth2 브라우저 열기 결과 + /// + [Serializable] + public class OAuth2BrowserResult + { + /// + /// 브라우저 열기 성공 여부 + /// + public bool Success { get; set; } + + /// + /// 열린 URL + /// + public string OpenedUrl { get; set; } + + /// + /// 에러 메시지 + /// + public string Error { get; set; } + + /// + /// 플랫폼 정보 + /// + public string Platform { get; set; } + + /// + /// 브라우저 타입 + /// + public string BrowserType { get; set; } + + public static OAuth2BrowserResult SuccessResult(string url, string platform, string browserType) + { + return new OAuth2BrowserResult + { + Success = true, + OpenedUrl = url, + Platform = platform, + BrowserType = browserType + }; + } + + public static OAuth2BrowserResult ErrorResult(string error, string platform) + { + return new OAuth2BrowserResult + { + Success = false, + Error = error, + Platform = platform + }; + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs.meta new file mode 100644 index 0000000..b834a6e --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Models/ServerOAuth2Models.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8b7600ec9722b884c998af930c66ef22 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs new file mode 100644 index 0000000..b85b730 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs @@ -0,0 +1,522 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using UnityEngine; +using Cysharp.Threading.Tasks; +using ProjectVG.Infrastructure.Auth.Models; +using ProjectVG.Infrastructure.Auth.OAuth2.Config; +using ProjectVG.Infrastructure.Auth.OAuth2.Models; +using ProjectVG.Infrastructure.Auth.OAuth2.Utils; +using ProjectVG.Infrastructure.Auth.OAuth2.Handlers; +using ProjectVG.Infrastructure.Network.Http; +using Newtonsoft.Json; + +namespace ProjectVG.Infrastructure.Auth.OAuth2 +{ + /// + /// 서버 OAuth2 제공자 + /// 새로운 서버 OAuth2 정책에 따른 구현 + /// + public class ServerOAuth2Provider : IServerOAuth2Client + { + private readonly ServerOAuth2Config _config; + private readonly HttpApiClient _httpClient; + private readonly PKCEParameters _currentPKCE; + private IOAuth2CallbackHandler _callbackHandler; + + public ServerOAuth2Provider(ServerOAuth2Config config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _httpClient = HttpApiClient.Instance; + _currentPKCE = null; + + // HttpApiClient 초기화 상태 확인 + if (_httpClient == null) + { + Debug.LogError("[ServerOAuth2Provider] HttpApiClient.Instance가 null입니다."); + throw new InvalidOperationException("HttpApiClient가 초기화되지 않았습니다."); + } + + Debug.Log($"[ServerOAuth2Provider] HttpApiClient 초기화 상태: {_httpClient.IsInitialized}"); + + // Unity 백그라운드 실행 설정 + Application.runInBackground = true; + Debug.Log("[ServerOAuth2Provider] 백그라운드 실행 활성화"); + + // 플랫폼별 콜백 핸들러 생성 + _callbackHandler = OAuth2CallbackHandlerFactory.CreateHandler(); + Debug.Log($"[ServerOAuth2Provider] {_callbackHandler.PlatformName} 콜백 핸들러 생성됨"); + } + + #region IServerOAuth2Client Properties + + /// + /// OAuth2 설정이 유효한지 확인 + /// + public bool IsConfigured => _config != null && _config.IsValid(); + + #endregion + + #region IServerOAuth2Client Implementation + + /// + /// PKCE 파라미터 생성 (Code Verifier, Code Challenge, State) + /// + public async Task GeneratePKCEAsync() + { + try + { + Debug.Log("[ServerOAuth2Provider] PKCE 파라미터 생성 시작"); + + var pkce = await PKCEGenerator.GeneratePKCEAsync( + _config.PKCECodeVerifierLength, + _config.StateLength + ); + + if (!PKCEGenerator.ValidatePKCE(pkce)) + { + throw new InvalidOperationException("생성된 PKCE 파라미터가 유효하지 않습니다."); + } + + Debug.Log($"[ServerOAuth2Provider] PKCE 생성 완료 - State: {pkce.State}"); + return pkce; + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] PKCE 생성 실패: {ex.Message}"); + throw; + } + } + + /// + /// 서버 OAuth2 인증 시작 + /// + /// PKCE 파라미터 + /// Google OAuth2 URL + public async Task StartServerOAuth2Async(PKCEParameters pkce) + { + if (pkce == null || !pkce.IsValid()) + { + throw new ArgumentException("유효하지 않은 PKCE 파라미터입니다.", nameof(pkce)); + } + + try + { + Debug.Log("[ServerOAuth2Provider] 서버 OAuth2 인증 시작"); + Debug.Log($"[ServerOAuth2Provider] HttpApiClient 초기화 상태: {_httpClient.IsInitialized}"); + + // 1. JavaScript와 동일한 방식으로 쿼리 파라미터 생성 + var queryParams = new Dictionary + { + { "state", pkce.State }, + { "code_challenge", pkce.CodeChallenge }, + { "code_challenge_method", "S256" }, + { "code_verifier", pkce.CodeVerifier }, + { "client_redirect_uri", GetClientRedirectUri() } + }; + + Debug.Log($"[ServerOAuth2Provider] 쿼리 파라미터: {JsonConvert.SerializeObject(queryParams)}"); + + // 2. 쿼리 파라미터를 URL에 추가 + string provider = "/google"; + var queryString = string.Join("&", queryParams.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")); + var authorizeUrl = $"{_config.ServerUrl}/auth/oauth2/authorize{provider}?{queryString}"; + + Debug.Log($"[ServerOAuth2Provider] 최종 URL: {authorizeUrl}"); + + // 3. 서버 API 호출 (GET 요청) - 인증 불필요 + var response = await _httpClient.GetAsync(authorizeUrl, requiresAuth: false); + + if (response == null) + { + throw new InvalidOperationException("서버 응답이 null입니다."); + } + + if (!response.Success) + { + throw new InvalidOperationException($"서버 OAuth2 인증 실패: {response.Message}"); + } + + if (string.IsNullOrEmpty(response.AuthUrl)) + { + throw new InvalidOperationException("서버에서 반환된 인증 URL이 비어있습니다."); + } + + Debug.Log($"[ServerOAuth2Provider] Google OAuth2 URL 생성 완료: {response.AuthUrl}"); + return response.AuthUrl; + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] 서버 OAuth2 인증 시작 실패: {ex.Message}"); + throw; + } + } + + /// + /// OAuth2 콜백 처리 (redirect URL에서 state 추출) + /// + /// 콜백 URL + /// 성공 여부와 state 값 + public async Task<(bool success, string state)> HandleOAuth2CallbackAsync(string callbackUrl) + { + if (string.IsNullOrEmpty(callbackUrl)) + { + Debug.LogError("[ServerOAuth2Provider] 콜백 URL이 비어있습니다."); + return (false, null); + } + + try + { + Debug.Log($"[ServerOAuth2Provider] OAuth2 콜백 처리: {callbackUrl}"); + + // URL 파싱 + OAuth2CallbackResult callbackResult; + + if (OAuth2CallbackParser.IsCustomScheme(callbackUrl)) + { + // 커스텀 스킴 URL (모바일 앱) + callbackResult = OAuth2CallbackParser.ParseSchemeUrl(callbackUrl); + } + else + { + // 일반 URL (WebGL, 데스크톱) + callbackResult = OAuth2CallbackParser.ParseCallbackUrl(callbackUrl); + } + + if (!callbackResult.Success) + { + Debug.LogError($"[ServerOAuth2Provider] 콜백 파싱 실패: {callbackResult.Error}"); + return (false, null); + } + + if (string.IsNullOrEmpty(callbackResult.State)) + { + Debug.LogError("[ServerOAuth2Provider] State 파라미터가 비어있습니다."); + return (false, null); + } + + Debug.Log($"[ServerOAuth2Provider] 콜백 처리 성공 - State: {callbackResult.State}"); + return (true, callbackResult.State); + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] 콜백 처리 실패: {ex.Message}"); + return (false, null); + } + } + + /// + /// 서버에서 토큰 요청 + /// + /// OAuth2 state 값 + /// JWT 토큰 세트 + public async Task RequestTokenAsync(string state) + { + if (string.IsNullOrEmpty(state)) + { + throw new ArgumentException("State 파라미터가 비어있습니다.", nameof(state)); + } + + try + { + Debug.Log($"[ServerOAuth2Provider] 토큰 요청 시작 - State: {state}"); + + // 1. 서버 토큰 API 호출 (HTTP 헤더 포함) - 인증 불필요 + var tokenUrl = $"{_config.ServerUrl}/auth/oauth2/token?state={Uri.EscapeDataString(state)}"; + var (response, headers) = await _httpClient.GetWithHeadersAsync(tokenUrl, requiresAuth: false); + + if (response == null) + { + throw new InvalidOperationException("토큰 응답이 null입니다."); + } + + if (!response.Success) + { + throw new InvalidOperationException($"토큰 요청 실패: {response.Message}"); + } + + // 2. HTTP 헤더에서 토큰 정보 추출 (JavaScript와 동일한 방식) + var accessToken = headers.ContainsKey("X-Access-Token") ? headers["X-Access-Token"] : null; + var refreshToken = headers.ContainsKey("X-Refresh-Token") ? headers["X-Refresh-Token"] : null; + var expiresInStr = headers.ContainsKey("X-Expires-In") ? headers["X-Expires-In"] : null; + var userId = headers.ContainsKey("X-User-Id") ? headers["X-User-Id"] : null; + + // 헤더에서 값을 가져올 수 없는 경우 응답 본문에서 가져오기 (폴백) + if (string.IsNullOrEmpty(accessToken)) + accessToken = response.AccessToken; + if (string.IsNullOrEmpty(refreshToken)) + refreshToken = response.RefreshToken; + if (string.IsNullOrEmpty(userId)) + userId = response.UserId; + + var expiresIn = 3600; // 기본값 1시간 + if (!string.IsNullOrEmpty(expiresInStr) && int.TryParse(expiresInStr, out var parsedExpiresIn)) + expiresIn = parsedExpiresIn; + else if (response.ExpiresIn > 0) + expiresIn = response.ExpiresIn; + + if (string.IsNullOrEmpty(accessToken)) + { + throw new InvalidOperationException("Access Token이 비어있습니다."); + } + + // 3. TokenSet 생성 + var accessTokenModel = new AccessToken(accessToken); + var refreshTokenModel = !string.IsNullOrEmpty(refreshToken) + ? new RefreshToken(refreshToken, expiresIn * 2, userId) // userId를 DeviceId로 사용 + : null; + + var tokenSet = new TokenSet(accessTokenModel, refreshTokenModel); + + Debug.Log($"[ServerOAuth2Provider] 토큰 요청 성공 - UserId: {userId}"); + + // 토큰 수신 확인 로그 + Debug.Log("=== 🔐 서버에서 토큰 수신 완료 ==="); + Debug.Log($"Access Token: {accessToken?.Substring(0, Math.Min(20, accessToken?.Length ?? 0))}..."); + Debug.Log($"Refresh Token: {(string.IsNullOrEmpty(refreshToken) ? "없음" : refreshToken.Substring(0, Math.Min(20, refreshToken.Length)) + "...")}"); + Debug.Log($"Expires In: {expiresIn}초"); + Debug.Log($"User ID: {userId}"); + Debug.Log("=== 토큰 수신 완료 ==="); + + // TokenManager에 토큰 저장 + try + { + Debug.Log("=== 🔐 ServerOAuth2Provider TokenManager 저장 시작 ==="); + var tokenManager = TokenManager.Instance; + tokenManager.SaveTokens(tokenSet); + Debug.Log("[ServerOAuth2Provider] TokenManager에 토큰 저장 완료"); + Debug.Log("=== TokenManager 저장 완료 ==="); + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] TokenManager 토큰 저장 실패: {ex.Message}"); + // 토큰 저장 실패해도 토큰은 반환 (사용자가 직접 저장할 수 있도록) + } + + return tokenSet; + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] 토큰 요청 실패: {ex.Message}"); + throw; + } + } + + /// + /// 전체 OAuth2 로그인 플로우 (편의 메서드) + /// + /// OAuth2 스코프 + /// JWT 토큰 세트 + public async Task LoginWithServerOAuth2Async() + { + try + { + Debug.Log("[ServerOAuth2Provider] 전체 OAuth2 로그인 플로우 시작"); + + // 1. PKCE 파라미터 생성 + var pkce = await GeneratePKCEAsync(); + + // 2. 서버 OAuth2 인증 시작 + var authUrl = await StartServerOAuth2Async(pkce); + + // 3. 브라우저에서 OAuth2 로그인 진행 + var browserResult = await OpenOAuth2BrowserAsync(authUrl); + if (!browserResult.Success) + { + throw new InvalidOperationException($"브라우저 열기 실패: {browserResult.Error}"); + } + + Debug.Log("✅ 브라우저가 열렸습니다. Google 로그인을 진행해주세요."); + + // 4. 콜백 대기 및 처리 + var callbackResult = await WaitForOAuth2CallbackAsync(pkce.State); + if (!callbackResult.success) + { + throw new InvalidOperationException("OAuth2 콜백 처리 실패"); + } + + Debug.Log("✅ OAuth2 콜백 수신 완료. 토큰을 요청합니다."); + + // 5. 토큰 요청 + var tokenSet = await RequestTokenAsync(callbackResult.state); + + Debug.Log("=== 🔐 OAuth2 로그인 완료 ==="); + return tokenSet; + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] 전체 OAuth2 로그인 플로우 실패: {ex.Message}"); + throw; + } + } + + #endregion + + #region Private Methods + + /// + /// 클라이언트 리다이렉트 URI 생성 + /// + /// 클라이언트 리다이렉트 URI + private string GetClientRedirectUri() + { +#if UNITY_WEBGL && !UNITY_EDITOR + // WebGL에서는 현재 페이지 URL 사용 + return Application.absoluteURL; +#elif UNITY_ANDROID && !UNITY_EDITOR + // Android에서는 커스텀 스킴 사용 + return _config.GetCurrentPlatformRedirectUri(); +#elif UNITY_IOS && !UNITY_EDITOR + // iOS에서는 커스텀 스킴 사용 + return _config.GetCurrentPlatformRedirectUri(); +#else + // 데스크톱에서는 로컬 서버 URL 사용 + return _config.GetCurrentPlatformRedirectUri(); +#endif + } + + /// + /// OAuth2 브라우저 열기 + /// + /// 인증 URL + /// 브라우저 열기 결과 + private async Task OpenOAuth2BrowserAsync(string authUrl) + { + try + { + Debug.Log($"[ServerOAuth2Provider] OAuth2 브라우저 열기: {authUrl}"); + + var platform = _config.GetCurrentPlatformName(); + string browserType; + +#if UNITY_WEBGL && !UNITY_EDITOR + // WebGL에서는 현재 탭에서 리다이렉트 + Application.ExternalEval($"window.location.href = '{authUrl}';"); + browserType = "Current Tab"; +#elif UNITY_ANDROID && !UNITY_EDITOR + // Android에서는 기본 브라우저 사용 + Application.OpenURL(authUrl); + browserType = "Default Browser"; +#elif UNITY_IOS && !UNITY_EDITOR + // iOS에서는 기본 브라우저 사용 + Application.OpenURL(authUrl); + browserType = "Default Browser"; +#else + // 데스크톱에서는 기본 브라우저 사용 (백그라운드에서 실행) + try + { + // Windows에서 백그라운드로 브라우저 열기 + var process = new System.Diagnostics.Process(); + process.StartInfo.FileName = authUrl; + process.StartInfo.UseShellExecute = true; + process.StartInfo.WindowStyle = System.Diagnostics.ProcessWindowStyle.Normal; + process.Start(); + + Debug.Log("[ServerOAuth2Provider] 브라우저가 백그라운드에서 열렸습니다."); + } + catch (Exception ex) + { + Debug.LogWarning($"[ServerOAuth2Provider] 백그라운드 브라우저 열기 실패, 기본 방식 사용: {ex.Message}"); + Application.OpenURL(authUrl); + } + browserType = "Background Browser"; +#endif + + // 브라우저 열기 대기 (더 짧게) + await UniTask.Delay(500); + + Debug.Log("[ServerOAuth2Provider] 브라우저 열기 완료 - 콜백 대기 시작"); + return OAuth2BrowserResult.SuccessResult(authUrl, platform, browserType); + } + catch (Exception ex) + { + var platform = _config.GetCurrentPlatformName(); + return OAuth2BrowserResult.ErrorResult(ex.Message, platform); + } + } + + /// + /// OAuth2 콜백 대기 + /// + /// 예상되는 State 값 + /// 콜백 처리 결과 + private async Task<(bool success, string state)> WaitForOAuth2CallbackAsync(string expectedState) + { + try + { + Debug.Log($"[ServerOAuth2Provider] OAuth2 콜백 대기 시작 - State: {expectedState}"); + Debug.Log("[ServerOAuth2Provider] 브라우저에서 OAuth2 로그인을 완료한 후 Unity 앱으로 돌아와주세요."); + + // 콜백 핸들러 초기화 + await _callbackHandler.InitializeAsync(expectedState, _config.TimeoutSeconds); + + // 콜백 대기 (더 강화된 로직) + var startTime = DateTime.UtcNow; + var timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds); + + while (DateTime.UtcNow - startTime < timeout) + { + // 앱이 포커스를 잃었을 때 더 자주 체크 + var checkInterval = Application.isFocused ? 200 : 100; + await UniTask.Delay(checkInterval); + + // 콜백 핸들러에서 URL 확인 + var callbackUrl = await _callbackHandler.WaitForCallbackAsync(); + + if (!string.IsNullOrEmpty(callbackUrl)) + { + var result = await HandleOAuth2CallbackAsync(callbackUrl); + if (result.success && result.state == expectedState) + { + Debug.Log("[ServerOAuth2Provider] OAuth2 콜백 수신 완료"); + return result; + } + } + + // 디버그 정보 출력 (5초마다) + var elapsed = (DateTime.UtcNow - startTime).TotalSeconds; + if (elapsed % 5 < 0.2) // 5초마다 + { + Debug.Log($"[ServerOAuth2Provider] 콜백 대기 중... (경과: {elapsed:F1}초, 포커스: {Application.isFocused})"); + } + } + + Debug.LogError("[ServerOAuth2Provider] OAuth2 콜백 타임아웃"); + return (false, null); + } + catch (Exception ex) + { + Debug.LogError($"[ServerOAuth2Provider] OAuth2 콜백 대기 중 오류: {ex.Message}"); + return (false, null); + } + finally + { + // 콜백 핸들러 정리 + _callbackHandler?.Cleanup(); + } + } + + #endregion + + #region Public Utility Methods + + /// + /// 디버그 정보 출력 + /// + /// 디버그 정보 + public string GetDebugInfo() + { + var info = $"ServerOAuth2Provider Debug Info:\n"; + info += $"Is Configured: {IsConfigured}\n"; + info += $"Server URL: {_config?.ServerUrl}\n"; + info += $"Platform: {_config?.GetCurrentPlatformName()}\n"; + info += $"Redirect URI: {_config?.GetCurrentPlatformRedirectUri()}\n"; + info += $"Client Redirect URI: {GetClientRedirectUri()}\n"; + + return info; + } + + #endregion + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs.meta b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs.meta new file mode 100644 index 0000000..9b6383a --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/ServerOAuth2Provider.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9031eac5ad4a73c4e85b2f68ac1f0d10 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Utils.meta b/Assets/Infrastructure/Auth/OAuth2/Utils.meta new file mode 100644 index 0000000..643258e --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Utils.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0f34035e6795c99428c22479b367fe5e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs b/Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs new file mode 100644 index 0000000..14741ae --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web; +using ProjectVG.Infrastructure.Auth.OAuth2.Models; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Utils +{ + /// + /// OAuth2 콜백 URL 파싱 유틸리티 + /// + public static class OAuth2CallbackParser + { + /// + /// OAuth2 콜백 URL 파싱 + /// + /// 콜백 URL + /// 파싱 결과 + public static OAuth2CallbackResult ParseCallbackUrl(string callbackUrl) + { + if (string.IsNullOrEmpty(callbackUrl)) + { + return OAuth2CallbackResult.ErrorResult("콜백 URL이 비어있습니다."); + } + + try + { + // URL 파싱 + var uri = new Uri(callbackUrl); + var query = HttpUtility.ParseQueryString(uri.Query); + + // 쿼리 파라미터를 Dictionary로 변환 (대소문자 무시) + var queryParams = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string key in query.AllKeys) + { + if (!string.IsNullOrEmpty(key)) + { + queryParams[key] = query[key]; + } + } + + // success 파라미터 확인 + if (!queryParams.TryGetValue("success", out var successRaw) || string.IsNullOrEmpty(successRaw)) + { + return OAuth2CallbackResult.ErrorResult("success 파라미터가 비어있습니다.", callbackUrl); + } + var success = successRaw.Equals("true", StringComparison.OrdinalIgnoreCase); + + if (success) + { + // 성공 케이스: state 파라미터 확인 + if (!queryParams.ContainsKey("state")) + { + return OAuth2CallbackResult.ErrorResult("state 파라미터가 없습니다.", callbackUrl); + } + + var state = queryParams["state"]; + if (string.IsNullOrEmpty(state)) + { + return OAuth2CallbackResult.ErrorResult("state 파라미터가 비어있습니다.", callbackUrl); + } + + var result = OAuth2CallbackResult.SuccessResult(state, callbackUrl); + result.QueryParameters = queryParams; + return result; + } + else + { + // 실패 케이스: error 파라미터 확인 + var error = queryParams.ContainsKey("error") ? queryParams["error"] : "알 수 없는 오류"; + + var result = OAuth2CallbackResult.ErrorResult(error, callbackUrl); + result.QueryParameters = queryParams; + return result; + } + } + catch (UriFormatException ex) + { + return OAuth2CallbackResult.ErrorResult($"잘못된 URL 형식: {ex.Message}", callbackUrl); + } + catch (Exception ex) + { + return OAuth2CallbackResult.ErrorResult($"콜백 URL 파싱 실패: {ex.Message}", callbackUrl); + } + } + + /// + /// 커스텀 스킴 URL 파싱 (모바일 앱) + /// + /// 스킴 URL (예: myapp://oauth/callback?success=true&state=...) + /// 파싱 결과 + public static OAuth2CallbackResult ParseSchemeUrl(string schemeUrl) + { + if (string.IsNullOrEmpty(schemeUrl)) + { + return OAuth2CallbackResult.ErrorResult("스킴 URL이 비어있습니다."); + } + + try + { + // 스킴 URL에서 쿼리 부분 추출 + var queryStartIndex = schemeUrl.IndexOf('?'); + if (queryStartIndex == -1) + { + return OAuth2CallbackResult.ErrorResult("쿼리 파라미터가 없습니다.", schemeUrl); + } + + var queryString = schemeUrl.Substring(queryStartIndex + 1); + var query = HttpUtility.ParseQueryString(queryString); + + // 쿼리 파라미터를 Dictionary로 변환 + var queryParams = new Dictionary(); + foreach (string key in query.AllKeys) + { + if (!string.IsNullOrEmpty(key)) + { + queryParams[key] = query[key]; + } + } + + // success 파라미터 확인 + if (!queryParams.ContainsKey("success")) + { + return OAuth2CallbackResult.ErrorResult("success 파라미터가 없습니다.", schemeUrl); + } + + var success = queryParams["success"].ToLower(); + + if (success == "true") + { + // 성공 케이스: state 파라미터 확인 + if (!queryParams.ContainsKey("state")) + { + return OAuth2CallbackResult.ErrorResult("state 파라미터가 없습니다.", schemeUrl); + } + + var state = queryParams["state"]; + if (string.IsNullOrEmpty(state)) + { + return OAuth2CallbackResult.ErrorResult("state 파라미터가 비어있습니다.", schemeUrl); + } + + var result = OAuth2CallbackResult.SuccessResult(state, schemeUrl); + result.QueryParameters = queryParams; + return result; + } + else + { + // 실패 케이스: error 파라미터 확인 + var error = queryParams.ContainsKey("error") ? queryParams["error"] : "알 수 없는 오류"; + + var result = OAuth2CallbackResult.ErrorResult(error, schemeUrl); + result.QueryParameters = queryParams; + return result; + } + } + catch (Exception ex) + { + return OAuth2CallbackResult.ErrorResult($"스킴 URL 파싱 실패: {ex.Message}", schemeUrl); + } + } + + /// + /// URL에서 특정 파라미터 추출 + /// + /// URL + /// 파라미터 이름 + /// 파라미터 값 + public static string ExtractParameter(string url, string parameterName) + { + if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(parameterName)) + { + return null; + } + + try + { + var uri = new Uri(url); + var query = HttpUtility.ParseQueryString(uri.Query); + return query[parameterName]; + } + catch + { + return null; + } + } + + /// + /// URL이 OAuth2 콜백인지 확인 + /// + /// 확인할 URL + /// OAuth2 콜백 여부 + public static bool IsOAuth2Callback(string url) + { + if (string.IsNullOrEmpty(url)) + { + return false; + } + + try + { + var uri = new Uri(url); + var query = HttpUtility.ParseQueryString(uri.Query); + + // success 파라미터가 있으면 OAuth2 콜백으로 간주 + return query.AllKeys.Contains("success"); + } + catch + { + return false; + } + } + + /// + /// URL이 커스텀 스킴인지 확인 + /// + /// 확인할 URL + /// 예상 스킴 + /// 커스텀 스킴 여부 + public static bool IsCustomScheme(string url, string expectedScheme = null) + { + if (string.IsNullOrEmpty(url)) + { + return false; + } + + try + { + var uri = new Uri(url); + + // 스킴이 http, https가 아니면 커스텀 스킴으로 간주 + if (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || + uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // 특정 스킴을 기대하는 경우 + if (!string.IsNullOrEmpty(expectedScheme)) + { + return uri.Scheme.Equals(expectedScheme, StringComparison.OrdinalIgnoreCase); + } + + return true; + } + catch + { + return false; + } + } + + /// + /// 디버그 정보 생성 + /// + /// 콜백 결과 + /// 디버그 정보 + public static string GetDebugInfo(OAuth2CallbackResult callbackResult) + { + if (callbackResult == null) + { + return "콜백 결과가 null입니다."; + } + + var info = $"OAuth2 Callback Debug Info:\n"; + info += $"Success: {callbackResult.Success}\n"; + info += $"State: {callbackResult.State}\n"; + info += $"Error: {callbackResult.Error}\n"; + info += $"Original URL: {callbackResult.OriginalUrl}\n"; + info += $"Query Parameters Count: {callbackResult.QueryParameters?.Count ?? 0}\n"; + + if (callbackResult.QueryParameters != null && callbackResult.QueryParameters.Count > 0) + { + info += "Query Parameters:\n"; + foreach (var kvp in callbackResult.QueryParameters) + { + info += $" {kvp.Key}: {kvp.Value}\n"; + } + } + + return info; + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs.meta new file mode 100644 index 0000000..3866ba3 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Utils/OAuth2CallbackParser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f711e0bc0a0086743b170059d5e421c7 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs b/Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs new file mode 100644 index 0000000..910170a --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs @@ -0,0 +1,185 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using UnityEngine; +using ProjectVG.Infrastructure.Auth.OAuth2.Models; + +namespace ProjectVG.Infrastructure.Auth.OAuth2.Utils +{ + /// + /// PKCE (Proof Key for Code Exchange) 생성 유틸리티 + /// 서버 권장사항에 따른 구현 + /// + public static class PKCEGenerator + { + /// + /// PKCE 파라미터 생성 (서버 권장사항 준수) + /// + /// Code Verifier 길이 (43-128) + /// State 길이 (16-64) + /// PKCE 파라미터 + public static async Task GeneratePKCEAsync(int codeVerifierLength = 64, int stateLength = 16) + { + try + { + // 1. Code Verifier 생성 (43-128자 랜덤 문자열) + var codeVerifier = GenerateRandomString(codeVerifierLength); + + // 2. Code Challenge 생성 (SHA256 해시) + var codeChallenge = await GenerateCodeChallengeAsync(codeVerifier); + + // 3. State 생성 + var state = GenerateRandomString(stateLength); + + return new PKCEParameters(codeVerifier, codeChallenge, state); + } + catch (Exception ex) + { + throw new InvalidOperationException($"PKCE 생성 실패: {ex.Message}", ex); + } + } + + /// + /// 랜덤 문자열 생성 (Base64Url 인코딩) + /// + /// 생성할 길이 + /// Base64Url 인코딩된 랜덤 문자열 + private static string GenerateRandomString(int length) + { + if (length < 1) + throw new ArgumentException("길이는 1 이상이어야 합니다.", nameof(length)); + + // 랜덤 바이트 생성 + var randomBytes = new byte[length]; + using (var rng = new RNGCryptoServiceProvider()) + { + rng.GetBytes(randomBytes); + } + + // Base64 인코딩 후 URL 안전하게 변환 + var base64String = Convert.ToBase64String(randomBytes); + return base64String + .Replace("+", "-") + .Replace("/", "_") + .Replace("=", "") + .Substring(0, Math.Min(length, base64String.Length)); + } + + /// + /// Code Challenge 생성 (SHA256 해시) + /// + /// Code Verifier + /// Base64Url 인코딩된 Code Challenge + private static async Task GenerateCodeChallengeAsync(string codeVerifier) + { + if (string.IsNullOrEmpty(codeVerifier)) + throw new ArgumentException("Code Verifier가 비어있습니다.", nameof(codeVerifier)); + + try + { + // SHA256 해시 계산 + using (var sha256 = SHA256.Create()) + { + var codeVerifierBytes = Encoding.UTF8.GetBytes(codeVerifier); + var hashBytes = await Task.Run(() => sha256.ComputeHash(codeVerifierBytes)); + + // Base64 인코딩 후 URL 안전하게 변환 + var base64String = Convert.ToBase64String(hashBytes); + return base64String + .Replace("+", "-") + .Replace("/", "_") + .Replace("=", ""); + } + } + catch (Exception ex) + { + throw new InvalidOperationException($"Code Challenge 생성 실패: {ex.Message}", ex); + } + } + + /// + /// PKCE 파라미터 유효성 검사 + /// + /// 검사할 PKCE 파라미터 + /// 유효성 여부 + public static bool ValidatePKCE(PKCEParameters pkce) + { + if (pkce == null) + return false; + + // 기본 유효성 검사 + if (!pkce.IsValid()) + return false; + + // 만료 검사 + if (pkce.IsExpired()) + return false; + + // Code Verifier 길이 검사 (43-128자) + if (pkce.CodeVerifier.Length < 43 || pkce.CodeVerifier.Length > 128) + { + Debug.LogError($"[PKCEGenerator] Code Verifier 길이 검사 실패: {pkce.CodeVerifier.Length} (43-128 사이여야 함)"); + return false; + } + + // Base64Url 형식 검사 + if (!IsBase64UrlSafe(pkce.CodeVerifier) || !IsBase64UrlSafe(pkce.CodeChallenge) || !IsBase64UrlSafe(pkce.State)) + { + Debug.LogError("[PKCEGenerator] Base64Url 형식 검사 실패"); + return false; + } + + return true; + } + + /// + /// Base64Url 안전 문자열 검사 + /// + /// 검사할 문자열 + /// Base64Url 안전 여부 + private static bool IsBase64UrlSafe(string input) + { + if (string.IsNullOrEmpty(input)) + return false; + + // Base64Url 안전 문자만 포함하는지 검사 + foreach (char c in input) + { + if (!((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_')) + { + return false; + } + } + + return true; + } + + /// + /// PKCE 파라미터 디버그 정보 출력 + /// + /// PKCE 파라미터 + /// 디버그 정보 문자열 + public static string GetDebugInfo(PKCEParameters pkce) + { + if (pkce == null) + return "PKCE 파라미터가 null입니다."; + + var info = $"PKCE Debug Info:\n"; + info += $"Code Verifier: {pkce.CodeVerifier}\n"; + info += $"Code Verifier Length: {pkce.CodeVerifier?.Length ?? 0}\n"; + info += $"Code Challenge: {pkce.CodeChallenge}\n"; + info += $"Code Challenge Length: {pkce.CodeChallenge?.Length ?? 0}\n"; + info += $"State: {pkce.State}\n"; + info += $"State Length: {pkce.State?.Length ?? 0}\n"; + info += $"Created At: {pkce.CreatedAt:yyyy-MM-dd HH:mm:ss} UTC\n"; + info += $"Is Valid: {pkce.IsValid()}\n"; + info += $"Is Expired: {pkce.IsExpired()}\n"; + + return info; + } + } +} diff --git a/Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs.meta b/Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs.meta new file mode 100644 index 0000000..54351c3 --- /dev/null +++ b/Assets/Infrastructure/Auth/OAuth2/Utils/PKCEGenerator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1e0fe0663eefd6e488b09677b6a724f1 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Services.meta b/Assets/Infrastructure/Auth/Services.meta new file mode 100644 index 0000000..094cda6 --- /dev/null +++ b/Assets/Infrastructure/Auth/Services.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3f32a7cd3cce3c44b846f03de6123ae1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/Services/GuestAuthService.cs b/Assets/Infrastructure/Auth/Services/GuestAuthService.cs new file mode 100644 index 0000000..0d171f4 --- /dev/null +++ b/Assets/Infrastructure/Auth/Services/GuestAuthService.cs @@ -0,0 +1,254 @@ +using System; +using System.Threading.Tasks; +using UnityEngine; +using Cysharp.Threading.Tasks; +using ProjectVG.Infrastructure.Auth.Utils; +using ProjectVG.Infrastructure.Auth.Models; +using ProjectVG.Infrastructure.Network.DTOs.Auth; +using ProjectVG.Infrastructure.Network.Http; +using ProjectVG.Infrastructure.Network.Configs; + +namespace ProjectVG.Infrastructure.Auth.Services +{ + /// + /// Guest 인증 서비스 + /// 디바이스 고유 ID를 사용한 게스트 로그인 처리 + /// + public class GuestAuthService : MonoBehaviour + { + private static GuestAuthService _instance; + public static GuestAuthService Instance + { + get + { + if (_instance == null) + { + var go = new GameObject("GuestAuthService"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + return _instance; + } + } + + private HttpApiClient _httpClient; + private TokenManager _tokenManager; + + public event Action OnGuestLoginSuccess; + public event Action OnGuestLoginFailed; + + private void Awake() + { + if (_instance == null) + { + _instance = this; + DontDestroyOnLoad(gameObject); + Initialize(); + } + else if (_instance != this) + { + Destroy(gameObject); + } + } + + private void Initialize() + { + _httpClient = HttpApiClient.Instance; + _tokenManager = TokenManager.Instance; + + Debug.Log("[GuestAuthService] 초기화 완료"); + } + + /// + /// Guest 로그인 수행 + /// + /// 로그인 성공 여부 + public async UniTask LoginAsGuestAsync() + { + try + { + Debug.Log("[GuestAuthService] Guest 로그인 시작"); + + // 디바이스 고유 ID 생성 + string deviceId = DeviceIdProvider.GetDeviceId(); + Debug.Log($"[GuestAuthService] 디바이스 ID 생성: {MaskDeviceId(deviceId)}"); + + // Guest 로그인 요청 + var request = new GuestLoginRequest(deviceId); + + Debug.Log($"[GuestAuthService] 서버 요청: {request.GetDebugInfo()}"); + + // API 호출 - requiresAuth=false (로그인 전이므로) + var response = await _httpClient.PostAsync( + "api/v1/auth/guest-login", + deviceId, // 서버는 [FromBody] string guestId를 받음 + requiresAuth: false + ); + + if (response == null) + { + throw new Exception("서버 응답이 null입니다."); + } + + Debug.Log($"[GuestAuthService] 서버 응답: {response.GetDebugInfo()}"); + + if (!response.Success) + { + throw new Exception(response.Message ?? "Guest 로그인 실패"); + } + + // 토큰 검증 + if (response.Tokens == null || string.IsNullOrEmpty(response.Tokens.AccessToken)) + { + throw new Exception("서버에서 유효한 토큰을 받지 못했습니다."); + } + + // TokenSet 생성 및 저장 + var tokenSet = response.ToTokenSet(); + _tokenManager.SaveTokens(tokenSet); + + Debug.Log("[GuestAuthService] Guest 로그인 성공"); + Debug.Log($"[GuestAuthService] 사용자 ID: {response.User?.UserId}"); + Debug.Log($"[GuestAuthService] AccessToken : {tokenSet.AccessToken.Token}"); + Debug.Log($"[GuestAuthService] AccessToken 만료: {tokenSet.AccessToken.ExpiresAt}"); + + // 성공 이벤트 발생 + OnGuestLoginSuccess?.Invoke(tokenSet); + + return true; + } + catch (Exception ex) + { + Debug.LogError($"[GuestAuthService] Guest 로그인 실패: {ex.Message}"); + + // 실패 이벤트 발생 + OnGuestLoginFailed?.Invoke(ex.Message); + + return false; + } + } + + /// + /// 현재 디바이스가 게스트 로그인 가능한지 확인 + /// + /// 게스트 로그인 가능 여부 + public bool CanLoginAsGuest() + { + try + { + string deviceId = DeviceIdProvider.GetDeviceId(); + return !string.IsNullOrEmpty(deviceId); + } + catch (Exception ex) + { + Debug.LogError($"[GuestAuthService] 디바이스 ID 생성 실패: {ex.Message}"); + return false; + } + } + + /// + /// 현재 디바이스 ID 반환 + /// + /// 디바이스 ID + public string GetCurrentDeviceId() + { + return DeviceIdProvider.GetDeviceId(); + } + + /// + /// 디바이스 ID 초기화 (테스트/디버깅용) + /// + public void ResetDeviceId() + { + DeviceIdProvider.ClearDeviceId(); + Debug.Log("[GuestAuthService] 디바이스 ID 초기화 완료"); + } + + /// + /// Guest 로그인 가능 상태 확인 + /// + /// 현재 상태 정보 + public GuestLoginStatus GetGuestLoginStatus() + { + var status = new GuestLoginStatus + { + CanLoginAsGuest = CanLoginAsGuest(), + DeviceId = GetCurrentDeviceId(), + HasStoredTokens = _tokenManager.HasValidTokens || _tokenManager.HasRefreshToken, + PlatformInfo = DeviceIdProvider.GetPlatformInfo() + }; + + return status; + } + + /// + /// 디바이스 ID 마스킹 (로깅용) + /// + private string MaskDeviceId(string deviceId) + { + if (string.IsNullOrEmpty(deviceId) || deviceId.Length < 8) + { + return "***"; + } + + return $"{deviceId.Substring(0, 4)}****{deviceId.Substring(deviceId.Length - 4)}"; + } + + /// + /// 디버그 정보 출력 + /// + public string GetDebugInfo() + { + var info = "GuestAuthService Debug Info:\n"; + info += $"Can Login As Guest: {CanLoginAsGuest()}\n"; + info += $"Current Device ID: {MaskDeviceId(GetCurrentDeviceId())}\n"; + info += $"Has Valid Tokens: {_tokenManager?.HasValidTokens ?? false}\n"; + info += $"Has Refresh Token: {_tokenManager?.HasRefreshToken ?? false}\n"; + info += $"{DeviceIdProvider.GetPlatformInfo()}\n"; + + return info; + } + + private void OnDestroy() + { + // 이벤트 정리는 구독자가 담당 + } + } + + /// + /// Guest 로그인 상태 정보 + /// + [Serializable] + public class GuestLoginStatus + { + /// + /// Guest 로그인 가능 여부 + /// + public bool CanLoginAsGuest { get; set; } + + /// + /// 현재 디바이스 ID + /// + public string DeviceId { get; set; } + + /// + /// 저장된 토큰 존재 여부 + /// + public bool HasStoredTokens { get; set; } + + /// + /// 플랫폼 정보 + /// + public string PlatformInfo { get; set; } + + /// + /// 디버그 정보 출력 + /// + public string GetDebugInfo() + { + return $"GuestLoginStatus: CanLogin={CanLoginAsGuest}, " + + $"HasTokens={HasStoredTokens}, " + + $"Platform={PlatformInfo}"; + } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Services/GuestAuthService.cs.meta b/Assets/Infrastructure/Auth/Services/GuestAuthService.cs.meta new file mode 100644 index 0000000..fc62867 --- /dev/null +++ b/Assets/Infrastructure/Auth/Services/GuestAuthService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e47475a6323a8e94bbf28c623daa3b87 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/TokenManager.cs b/Assets/Infrastructure/Auth/TokenManager.cs new file mode 100644 index 0000000..d9355ca --- /dev/null +++ b/Assets/Infrastructure/Auth/TokenManager.cs @@ -0,0 +1,363 @@ +using System; +using System.Collections; +using System.Security.Cryptography; +using System.Text; +using UnityEngine; +using ProjectVG.Infrastructure.Auth.Models; +using Newtonsoft.Json; + +namespace ProjectVG.Infrastructure.Auth +{ + public class TokenManager : MonoBehaviour + { + private const string REFRESH_TOKEN_KEY = "refresh_token"; + private const string USER_ID_KEY = "user_id"; + private const string ENCRYPTION_KEY = "ProjectVG_OAuth2_Secure_Key_2024"; + + private static TokenManager _instance; + public static TokenManager Instance + { + get + { + if (_instance == null) + { + var go = new GameObject("TokenManager"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + return _instance; + } + } + + private AccessToken _currentAccessToken; + private RefreshToken _currentRefreshToken; + private string _currentUserId; + + public event Action OnTokensUpdated; + public event Action OnTokensExpired; + public event Action OnTokensCleared; + + public bool HasValidTokens => _currentAccessToken != null && !_currentAccessToken.IsExpired(); + public bool HasRefreshToken => _currentRefreshToken != null; + public string CurrentUserId => _currentUserId; + + private void Awake() + { + if (_instance == null) + { + _instance = this; + DontDestroyOnLoad(gameObject); + LoadTokensFromStorage(); + + if (HasRefreshToken && !IsRefreshTokenExpired()) + { + StartCoroutine(AutoRecoverAccessTokenCoroutine()); + } + } + else if (_instance != this) + { + Destroy(gameObject); + } + } + + public void SaveTokens(TokenSet tokenSet) + { + if (tokenSet?.AccessToken == null) + { + Debug.LogWarning("[TokenManager] 저장할 Access Token이 없습니다."); + return; + } + + try + { + _currentAccessToken = tokenSet.AccessToken; + _currentRefreshToken = tokenSet.RefreshToken; + _currentUserId = tokenSet.RefreshToken?.DeviceId; + + if (_currentRefreshToken != null) + { + var refreshTokenData = new TokenStorageData + { + Token = _currentRefreshToken.Token, + ExpiresAt = _currentRefreshToken.ExpiresAt, + UserId = _currentRefreshToken.DeviceId + }; + var encryptedRefreshToken = EncryptData(JsonConvert.SerializeObject(refreshTokenData)); + PlayerPrefs.SetString(REFRESH_TOKEN_KEY, encryptedRefreshToken); + } + else + { + PlayerPrefs.DeleteKey(REFRESH_TOKEN_KEY); + } + + if (!string.IsNullOrEmpty(_currentUserId)) + { + PlayerPrefs.SetString(USER_ID_KEY, _currentUserId); + } + + PlayerPrefs.Save(); + Debug.Log("[TokenManager] 토큰 저장 완료"); + + OnTokensUpdated?.Invoke(tokenSet); + } + catch (Exception ex) + { + Debug.LogError($"[TokenManager] 토큰 저장 실패: {ex.Message}"); + throw; + } + } + + public TokenSet LoadTokens() + { + if (_currentAccessToken != null && _currentRefreshToken != null) + { + return new TokenSet(_currentAccessToken, _currentRefreshToken); + } + + LoadTokensFromStorage(); + + if (_currentAccessToken != null && _currentRefreshToken != null) + { + return new TokenSet(_currentAccessToken, _currentRefreshToken); + } + + return null; + } + + public string GetAccessToken() + { + if (_currentAccessToken != null && !_currentAccessToken.IsExpired()) + { + return _currentAccessToken.Token; + } + + if (_currentRefreshToken != null && !_currentRefreshToken.IsExpired()) + { + OnTokensExpired?.Invoke(); + } + + return null; + } + + public string GetRefreshToken() + { + return _currentRefreshToken?.Token; + } + + public bool IsAccessTokenExpired() + { + return _currentAccessToken?.IsExpired() ?? true; + } + + public bool IsRefreshTokenExpired() + { + return _currentRefreshToken?.IsExpired() ?? true; + } + + public void UpdateAccessToken(string newAccessToken) + { + if (string.IsNullOrEmpty(newAccessToken)) + { + Debug.LogError("[TokenManager] 새로운 Access Token이 비어있습니다."); + return; + } + + try + { + _currentAccessToken = new AccessToken(newAccessToken); + + var tokenSet = new TokenSet(_currentAccessToken, _currentRefreshToken); + OnTokensUpdated?.Invoke(tokenSet); + } + catch (Exception ex) + { + Debug.LogError($"[TokenManager] Access Token 갱신 실패: {ex.Message}"); + throw; + } + } + + public void ClearTokens() + { + try + { + _currentAccessToken = null; + _currentRefreshToken = null; + _currentUserId = null; + + PlayerPrefs.DeleteKey(REFRESH_TOKEN_KEY); + PlayerPrefs.DeleteKey(USER_ID_KEY); + PlayerPrefs.Save(); + + OnTokensCleared?.Invoke(); + } + catch (Exception ex) + { + Debug.LogError($"[TokenManager] 토큰 삭제 실패: {ex.Message}"); + throw; + } + } + + private void LoadTokensFromStorage() + { + try + { + _currentAccessToken = null; + + if (PlayerPrefs.HasKey(REFRESH_TOKEN_KEY)) + { + var encryptedRefreshToken = PlayerPrefs.GetString(REFRESH_TOKEN_KEY); + var decryptedRefreshToken = DecryptData(encryptedRefreshToken); + var refreshTokenData = JsonConvert.DeserializeObject(decryptedRefreshToken); + + _currentRefreshToken = new RefreshToken( + refreshTokenData.Token, + refreshTokenData.ExpiresAt, + refreshTokenData.UserId + ); + } + + if (PlayerPrefs.HasKey(USER_ID_KEY)) + { + _currentUserId = PlayerPrefs.GetString(USER_ID_KEY); + } + } + catch (Exception ex) + { + Debug.LogError($"[TokenManager] 토큰 로드 실패: {ex.Message}"); + ClearTokens(); + } + } + + private string EncryptData(string data) + { + try + { + using (var aes = Aes.Create()) + { + aes.Key = Encoding.UTF8.GetBytes(ENCRYPTION_KEY.PadRight(32, '0').Substring(0, 32)); + aes.GenerateIV(); // 랜덤 IV 생성 + + using (var encryptor = aes.CreateEncryptor()) + using (var ms = new System.IO.MemoryStream()) + { + // 먼저 IV를 기록 + ms.Write(aes.IV, 0, aes.IV.Length); + using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) + using (var sw = new System.IO.StreamWriter(cs)) + { + sw.Write(data); + } + return Convert.ToBase64String(ms.ToArray()); + } + } + } + catch (Exception ex) + { + Debug.LogError($"[TokenManager] 암호화 실패: {ex.Message}"); + throw; + } + } + + private string DecryptData(string encryptedData) + { + try + { + // 새로운 방식 (랜덤 IV) 시도 + return DecryptDataWithRandomIV(encryptedData); + } + catch (Exception) + { + // 실패 시 기존 방식 (고정 IV) 시도 + try + { + Debug.LogWarning("[TokenManager] 새로운 방식 복호화 실패, 기존 방식으로 시도합니다."); + return DecryptDataWithFixedIV(encryptedData); + } + catch (Exception ex) + { + Debug.LogError($"[TokenManager] 모든 복호화 방식 실패: {ex.Message}"); + throw; + } + } + } + + private string DecryptDataWithRandomIV(string encryptedData) + { + using (var aes = Aes.Create()) + { + aes.Key = Encoding.UTF8.GetBytes(ENCRYPTION_KEY.PadRight(32, '0').Substring(0, 32)); + + var allBytes = Convert.FromBase64String(encryptedData); + if (allBytes.Length < 16) throw new InvalidOperationException("암호문 길이가 유효하지 않습니다."); + + // IV 추출 + var iv = new byte[16]; + Buffer.BlockCopy(allBytes, 0, iv, 0, iv.Length); + + // 암호문 추출 + var cipher = new byte[allBytes.Length - iv.Length]; + Buffer.BlockCopy(allBytes, iv.Length, cipher, 0, cipher.Length); + + aes.IV = iv; + + using (var decryptor = aes.CreateDecryptor()) + using (var ms = new System.IO.MemoryStream(cipher)) + using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) + using (var sr = new System.IO.StreamReader(cs)) + { + return sr.ReadToEnd(); + } + } + } + + private string DecryptDataWithFixedIV(string encryptedData) + { + using (var aes = Aes.Create()) + { + aes.Key = Encoding.UTF8.GetBytes(ENCRYPTION_KEY.PadRight(32, '0').Substring(0, 32)); + aes.IV = new byte[16]; // 기존 고정 IV + + using (var decryptor = aes.CreateDecryptor()) + using (var ms = new System.IO.MemoryStream(Convert.FromBase64String(encryptedData))) + using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) + using (var sr = new System.IO.StreamReader(cs)) + { + return sr.ReadToEnd(); + } + } + } + + private IEnumerator AutoRecoverAccessTokenCoroutine() + { + yield return new WaitForSeconds(0.5f); + + if (TokenRefreshService.Instance != null) + { + StartCoroutine(TryRefreshTokenCoroutine()); + } + } + + private IEnumerator TryRefreshTokenCoroutine() + { + var refreshTask = TokenRefreshService.Instance.RefreshAccessTokenAsync(); + yield return new WaitUntil(() => refreshTask.Status != Cysharp.Threading.Tasks.UniTaskStatus.Pending); + + if (refreshTask.Status == Cysharp.Threading.Tasks.UniTaskStatus.Succeeded) + { + bool success = refreshTask.GetAwaiter().GetResult(); + if (!success) + { + Debug.LogWarning("[TokenManager] 앱 시작 시 AccessToken 자동 복구 실패"); + } + } + } + } + + [Serializable] + public class TokenStorageData + { + public string Token { get; set; } + public DateTime ExpiresAt { get; set; } + public string UserId { get; set; } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/TokenManager.cs.meta b/Assets/Infrastructure/Auth/TokenManager.cs.meta new file mode 100644 index 0000000..de7c45e --- /dev/null +++ b/Assets/Infrastructure/Auth/TokenManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: af60fb15aa1dc0947aae32b10c6583e6 \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/TokenRefreshService.cs b/Assets/Infrastructure/Auth/TokenRefreshService.cs new file mode 100644 index 0000000..3f96a5b --- /dev/null +++ b/Assets/Infrastructure/Auth/TokenRefreshService.cs @@ -0,0 +1,230 @@ +using System; +using System.Threading.Tasks; +using UnityEngine; +using Cysharp.Threading.Tasks; +using ProjectVG.Infrastructure.Auth.OAuth2; +using ProjectVG.Infrastructure.Auth.OAuth2.Config; +using ProjectVG.Infrastructure.Network.Http; +using ProjectVG.Infrastructure.Network.Configs; +using ProjectVG.Infrastructure.Auth.Models; +using ProjectVG.Infrastructure.Auth.OAuth2.Models; + +namespace ProjectVG.Infrastructure.Auth +{ + /// + /// 토큰 갱신 서비스 + /// Refresh Token을 사용하여 Access Token을 자동으로 갱신 + /// + public class TokenRefreshService : MonoBehaviour + { + private static TokenRefreshService _instance; + public static TokenRefreshService Instance + { + get + { + if (_instance == null) + { + var go = new GameObject("TokenRefreshService"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + return _instance; + } + } + + private TokenManager _tokenManager; + private ServerOAuth2Provider _oauth2Provider; + private bool _isRefreshing = false; + + public event Action OnTokenRefreshed; + public event Action OnTokenRefreshFailed; + + private void Awake() + { + if (_instance == null) + { + _instance = this; + DontDestroyOnLoad(gameObject); + Initialize(); + } + else if (_instance != this) + { + Destroy(gameObject); + } + } + + private void Initialize() + { + _tokenManager = TokenManager.Instance; + + // TokenManager 이벤트 구독 + _tokenManager.OnTokensExpired += HandleTokensExpired; + + Debug.Log("[TokenRefreshService] 초기화 완료"); + } + + /// + /// 토큰 만료 시 자동 갱신 시도 + /// + private async void HandleTokensExpired() + { + Debug.Log("[TokenRefreshService] 토큰 만료 감지 - 갱신 시도"); + await RefreshAccessTokenAsync(); + } + + /// + /// Access Token 갱신 + /// + public async UniTask RefreshAccessTokenAsync() + { + if (_isRefreshing) + { + Debug.Log("[TokenRefreshService] 이미 토큰 갱신 중입니다."); + return false; + } + + if (_tokenManager.IsRefreshTokenExpired()) + { + Debug.LogError("[TokenRefreshService] Refresh Token이 만료되었습니다. 재로그인이 필요합니다."); + OnTokenRefreshFailed?.Invoke("Refresh Token이 만료되었습니다."); + return false; + } + + _isRefreshing = true; + + try + { + Debug.Log("[TokenRefreshService] Access Token 갱신 시작"); + + // OAuth2 Provider 초기화 (필요한 경우) + if (_oauth2Provider == null) + { + var config = ServerOAuth2Config.Instance; + if (config == null) + { + throw new InvalidOperationException("ServerOAuth2Config를 찾을 수 없습니다."); + } + _oauth2Provider = new ServerOAuth2Provider(config); + } + + // Refresh Token으로 새로운 Access Token 요청 + var refreshToken = _tokenManager.GetRefreshToken(); + if (string.IsNullOrEmpty(refreshToken)) + { + throw new InvalidOperationException("Refresh Token이 없습니다."); + } + + // 서버에 토큰 갱신 요청 + var newTokenSet = await RequestTokenRefreshAsync(refreshToken); + + if (newTokenSet?.AccessToken != null) + { + // 새로운 토큰 저장 + _tokenManager.SaveTokens(newTokenSet); + + Debug.Log("[TokenRefreshService] Access Token 갱신 성공"); + OnTokenRefreshed?.Invoke(newTokenSet.AccessToken.Token); + return true; + } + else + { + throw new InvalidOperationException("서버에서 새로운 토큰을 받지 못했습니다."); + } + } + catch (Exception ex) + { + Debug.LogError($"[TokenRefreshService] Access Token 갱신 실패: {ex.Message}"); + OnTokenRefreshFailed?.Invoke(ex.Message); + return false; + } + finally + { + _isRefreshing = false; + } + } + + /// + /// 서버에 토큰 갱신 요청 + /// + private async UniTask RequestTokenRefreshAsync(string refreshToken) + { + try + { + var httpClient = HttpApiClient.Instance; + + // 서버 토큰 갱신 엔드포인트 호출 + var refreshRequest = new + { + refresh_token = refreshToken, + grant_type = "refresh_token" + }; + + var response = await httpClient.PostAsync( + "api/v1/auth/refresh", + refreshRequest, + requiresAuth: false // 토큰 갱신은 인증 불필요 + ); + + if (response == null || !response.Success) + { + throw new InvalidOperationException($"토큰 갱신 실패: {response?.Message ?? "응답이 null입니다."}"); + } + + var accessToken = new AccessToken(response.AccessToken); + + var newRefreshToken = !string.IsNullOrEmpty(response.RefreshToken) + ? new RefreshToken(response.RefreshToken, response.ExpiresIn * 2, response.UserId) // UserId를 DeviceId로 사용 + : null; + + return new TokenSet(accessToken, newRefreshToken); + } + catch (Exception ex) + { + Debug.LogError($"[TokenRefreshService] 서버 토큰 갱신 요청 실패: {ex.Message}"); + throw; + } + } + + /// + /// 토큰 갱신 상태 확인 + /// + public bool IsRefreshing => _isRefreshing; + + /// + /// 강제 토큰 갱신 (사용자가 직접 호출) + /// + public async UniTask ForceRefreshAsync() + { + Debug.Log("[TokenRefreshService] 강제 토큰 갱신 시작"); + return await RefreshAccessTokenAsync(); + } + + /// + /// 토큰 상태 확인 및 필요시 갱신 + /// + public async UniTask EnsureValidTokenAsync() + { + if (_tokenManager.HasValidTokens) + { + return true; + } + + if (_tokenManager.HasRefreshToken && !_tokenManager.IsRefreshTokenExpired()) + { + return await RefreshAccessTokenAsync(); + } + + Debug.LogWarning("[TokenRefreshService] 유효한 토큰이 없고 Refresh Token도 만료되었습니다."); + return false; + } + + + private void OnDestroy() + { + if (_tokenManager != null) + { + _tokenManager.OnTokensExpired -= HandleTokensExpired; + } + } + } +} diff --git a/Assets/Infrastructure/Auth/TokenRefreshService.cs.meta b/Assets/Infrastructure/Auth/TokenRefreshService.cs.meta new file mode 100644 index 0000000..056bb3e --- /dev/null +++ b/Assets/Infrastructure/Auth/TokenRefreshService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 19627176d6dfc384a8a5a4f76bdda34f \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Utils.meta b/Assets/Infrastructure/Auth/Utils.meta new file mode 100644 index 0000000..60e204b --- /dev/null +++ b/Assets/Infrastructure/Auth/Utils.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f87432ca0be4bfa4e801547fe86566f8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs b/Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs new file mode 100644 index 0000000..d1a68b7 --- /dev/null +++ b/Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs @@ -0,0 +1,238 @@ +using System; +using UnityEngine; + +namespace ProjectVG.Infrastructure.Auth.Utils +{ + /// + /// 디바이스 고유 ID 제공자 + /// 플랫폼별로 디바이스 고유 식별자를 생성/관리 + /// + public static class DeviceIdProvider + { + private const string DEVICE_ID_KEY = "projectvg_device_id"; + private static string _cachedDeviceId; + + /// + /// 디바이스 고유 ID 반환 + /// + /// 디바이스 고유 ID + public static string GetDeviceId() + { + if (!string.IsNullOrEmpty(_cachedDeviceId)) + { + return _cachedDeviceId; + } + + // PlayerPrefs에서 저장된 ID 확인 + if (PlayerPrefs.HasKey(DEVICE_ID_KEY)) + { + _cachedDeviceId = PlayerPrefs.GetString(DEVICE_ID_KEY); + Debug.Log($"[DeviceIdProvider] 저장된 디바이스 ID 로드: {MaskDeviceId(_cachedDeviceId)}"); + return _cachedDeviceId; + } + + // 새로운 디바이스 ID 생성 + _cachedDeviceId = GenerateDeviceId(); + + // PlayerPrefs에 저장 + PlayerPrefs.SetString(DEVICE_ID_KEY, _cachedDeviceId); + PlayerPrefs.Save(); + + Debug.Log($"[DeviceIdProvider] 새로운 디바이스 ID 생성: {MaskDeviceId(_cachedDeviceId)}"); + return _cachedDeviceId; + } + + /// + /// 플랫폼별 디바이스 ID 생성 + /// + private static string GenerateDeviceId() + { + string platformPrefix; + string platformId; + +#if UNITY_ANDROID && !UNITY_EDITOR + platformPrefix = "android"; + platformId = GetAndroidDeviceId(); +#elif UNITY_IOS && !UNITY_EDITOR + platformPrefix = "ios"; + platformId = GetIOSDeviceId(); +#elif UNITY_WEBGL && !UNITY_EDITOR + platformPrefix = "webgl"; + platformId = GetWebGLDeviceId(); +#elif UNITY_STANDALONE_WIN && !UNITY_EDITOR + platformPrefix = "windows"; + platformId = GetWindowsDeviceId(); +#elif UNITY_STANDALONE_OSX && !UNITY_EDITOR + platformPrefix = "macos"; + platformId = GetMacOSDeviceId(); +#elif UNITY_STANDALONE_LINUX && !UNITY_EDITOR + platformPrefix = "linux"; + platformId = GetLinuxDeviceId(); +#else + // Unity Editor 또는 알 수 없는 플랫폼 + platformPrefix = "editor"; + platformId = GetEditorDeviceId(); +#endif + + // 플랫폼 접두사 + 하이픈 + 디바이스 ID + 타임스탬프 + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + return $"{platformPrefix}-{platformId}-{timestamp}"; + } + +#if UNITY_ANDROID && !UNITY_EDITOR + private static string GetAndroidDeviceId() + { + try + { + // Android Device ID 사용 + using (var unityClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) + using (var currentActivity = unityClass.GetStatic("currentActivity")) + using (var contentResolver = currentActivity.Call("getContentResolver")) + using (var settingsSecure = new AndroidJavaClass("android.provider.Settings$Secure")) + { + string androidId = settingsSecure.CallStatic("getString", contentResolver, "android_id"); + if (!string.IsNullOrEmpty(androidId)) + { + return androidId; + } + } + } + catch (Exception ex) + { + Debug.LogError($"[DeviceIdProvider] Android ID 생성 실패: {ex.Message}"); + } + + // Fallback: SystemInfo.deviceUniqueIdentifier + return SystemInfo.deviceUniqueIdentifier; + } +#endif + +#if UNITY_IOS && !UNITY_EDITOR + private static string GetIOSDeviceId() + { + // iOS는 IDFV (Identifier for Vendor) 사용 + // SystemInfo.deviceUniqueIdentifier가 IDFV를 반환함 + return SystemInfo.deviceUniqueIdentifier; + } +#endif + +#if UNITY_WEBGL && !UNITY_EDITOR + private static string GetWebGLDeviceId() + { + // WebGL에서는 브라우저 기반 ID 생성 + string browserId = SystemInfo.deviceUniqueIdentifier; + + // 추가로 브라우저 정보 포함 + string userAgent = Application.platform.ToString(); + string screenInfo = $"{Screen.width}x{Screen.height}"; + + return $"{browserId}-{userAgent.GetHashCode()}-{screenInfo.GetHashCode()}"; + } +#endif + +#if UNITY_STANDALONE_WIN && !UNITY_EDITOR + private static string GetWindowsDeviceId() + { + try + { + // Windows Machine GUID 사용 + var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography"); + if (key != null) + { + var machineGuid = key.GetValue("MachineGuid")?.ToString(); + if (!string.IsNullOrEmpty(machineGuid)) + { + return machineGuid; + } + } + } + catch (Exception ex) + { + Debug.LogError($"[DeviceIdProvider] Windows Machine GUID 생성 실패: {ex.Message}"); + } + + // Fallback + return SystemInfo.deviceUniqueIdentifier; + } +#endif + +#if UNITY_STANDALONE_OSX && !UNITY_EDITOR + private static string GetMacOSDeviceId() + { + // macOS는 SystemInfo.deviceUniqueIdentifier 사용 + return SystemInfo.deviceUniqueIdentifier; + } +#endif + +#if UNITY_STANDALONE_LINUX && !UNITY_EDITOR + private static string GetLinuxDeviceId() + { + // Linux는 SystemInfo.deviceUniqueIdentifier 사용 + return SystemInfo.deviceUniqueIdentifier; + } +#endif + + private static string GetEditorDeviceId() + { + // Unity Editor에서는 고정된 ID + 랜덤 요소 사용 + string editorId = SystemInfo.deviceUniqueIdentifier; + + // Editor에서는 개발 편의성을 위해 단순한 ID 생성 + if (string.IsNullOrEmpty(editorId) || editorId == "n/a") + { + editorId = $"editor-{Environment.MachineName}-{Environment.UserName}"; + } + + return editorId; + } + + /// + /// 디바이스 ID 마스킹 (로깅용) + /// + private static string MaskDeviceId(string deviceId) + { + if (string.IsNullOrEmpty(deviceId) || deviceId.Length < 8) + { + return "***"; + } + + // 앞 4자리와 뒤 4자리만 표시 + return $"{deviceId.Substring(0, 4)}****{deviceId.Substring(deviceId.Length - 4)}"; + } + + /// + /// 디바이스 ID 초기화 (테스트용) + /// + public static void ClearDeviceId() + { + _cachedDeviceId = null; + PlayerPrefs.DeleteKey(DEVICE_ID_KEY); + PlayerPrefs.Save(); + Debug.Log("[DeviceIdProvider] 디바이스 ID 초기화 완료"); + } + + /// + /// 현재 플랫폼 정보 반환 + /// + public static string GetPlatformInfo() + { + return $"Platform: {Application.platform}, " + + $"OS: {SystemInfo.operatingSystem}, " + + $"Device: {SystemInfo.deviceModel}"; + } + + /// + /// 디버그 정보 출력 + /// + public static string GetDebugInfo() + { + var info = "DeviceIdProvider Debug Info:\n"; + info += $"Cached Device ID: {MaskDeviceId(_cachedDeviceId)}\n"; + info += $"Has Stored ID: {PlayerPrefs.HasKey(DEVICE_ID_KEY)}\n"; + info += $"{GetPlatformInfo()}\n"; + info += $"System Device ID: {MaskDeviceId(SystemInfo.deviceUniqueIdentifier)}\n"; + + return info; + } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs.meta b/Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs.meta new file mode 100644 index 0000000..1084a39 --- /dev/null +++ b/Assets/Infrastructure/Auth/Utils/DeviceIdProvider.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6e444377c5386384481442b3dd4f2105 \ No newline at end of file diff --git a/Assets/Infrastructure/Network/Configs/NetworkConfig.cs b/Assets/Infrastructure/Network/Configs/NetworkConfig.cs index d4c1d94..4eff86a 100644 --- a/Assets/Infrastructure/Network/Configs/NetworkConfig.cs +++ b/Assets/Infrastructure/Network/Configs/NetworkConfig.cs @@ -1,3 +1,4 @@ +using System; using UnityEngine; using ProjectVG.Infrastructure.Config; @@ -70,6 +71,8 @@ public static NetworkConfig Instance Debug.LogError("NetworkConfig를 찾을 수 없습니다. Resources 폴더에 NetworkConfig.asset 파일을 생성하세요."); _instance = CreateDefaultInstance(); } + // 런타임 환경 가드 적용 + ApplyRuntimeGuard(_instance); } return _instance; } @@ -118,7 +121,8 @@ public static string HttpServerAddress case EnvironmentType.Production: server = Instance.productionServer; break; default: server = Instance.developmentServer; break; } - return $"http://{server}"; + var scheme = CurrentEnvironment == EnvironmentType.Production ? "https" : "http"; + return $"{scheme}://{server}"; } } @@ -134,7 +138,8 @@ public static string WebSocketServerAddress case EnvironmentType.Production: server = Instance.productionServer; break; default: server = Instance.developmentServer; break; } - return $"ws://{server}"; + var wsScheme = CurrentEnvironment == EnvironmentType.Production ? "wss" : "ws"; + return $"{wsScheme}://{server}"; } } @@ -148,7 +153,8 @@ public static string GetWebSocketServerAddressFor(EnvironmentType env) case EnvironmentType.Production: server = Instance.productionServer; break; default: server = Instance.developmentServer; break; } - return $"ws://{server}"; + var wsScheme = env == EnvironmentType.Production ? "wss" : "ws"; + return $"{wsScheme}://{server}"; } public static string GetWebSocketUrl() @@ -172,7 +178,7 @@ public static string GetWebSocketUrlWithVersion() public static string GetWebSocketUrlWithSession(string sessionId) { var baseWsUrl = GetWebSocketUrlWithVersion(); - return $"{baseWsUrl}?sessionId={sessionId}"; + return $"{baseWsUrl}?sessionId={Uri.EscapeDataString(sessionId ?? string.Empty)}"; } // HTTP 공통 설정 정적 접근자 복원 @@ -203,12 +209,20 @@ public static string GetWebSocketUrlWithSession(string sessionId) public static bool IsJsonMessageType => Instance.wsMessageType?.ToLower() == "json"; public static bool IsBinaryMessageType => Instance.wsMessageType?.ToLower() == "binary"; - // HTTP URL 유틸 복원 + // HTTP URL 유틸 복원 - /api/v1 자동 추가 제거 public static string GetFullApiUrl(string endpoint) + { + var baseUrl = HttpServerAddress; + return $"{baseUrl.TrimEnd('/')}/{endpoint.TrimStart('/')}"; + } + + // 버전이 포함된 API URL 헬퍼 메서드들 + public static string GetVersionedApiUrl(string endpoint) { var baseUrl = HttpServerAddress; return $"{baseUrl.TrimEnd('/')}/{Instance.apiPath.TrimStart('/').TrimEnd('/')}/{Instance.apiVersion.TrimStart('/').TrimEnd('/')}/{endpoint.TrimStart('/')}"; } + public static string GetUserApiUrl(string path = "") => GetFullApiUrl($"users/{path.TrimStart('/')}"); public static string GetCharacterApiUrl(string path = "") => GetFullApiUrl($"characters/{path.TrimStart('/')}"); public static string GetConversationApiUrl(string path = "") => GetFullApiUrl($"conversations/{path.TrimStart('/')}"); diff --git a/Assets/Infrastructure/Network/DTOs/Auth.meta b/Assets/Infrastructure/Network/DTOs/Auth.meta new file mode 100644 index 0000000..037369f --- /dev/null +++ b/Assets/Infrastructure/Network/DTOs/Auth.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 02f5bf9ee5b574445a0f1cc302e4bde9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs new file mode 100644 index 0000000..5b4440d --- /dev/null +++ b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs @@ -0,0 +1,46 @@ +using System; +using Newtonsoft.Json; + +namespace ProjectVG.Infrastructure.Network.DTOs.Auth +{ + /// + /// Guest 로그인 요청 DTO + /// + [Serializable] + public class GuestLoginRequest + { + /// + /// 게스트 ID (디바이스 고유 ID 기반) + /// + [JsonProperty("guestId")] + public string GuestId { get; set; } + + public GuestLoginRequest() + { + } + + public GuestLoginRequest(string guestId) + { + GuestId = guestId ?? throw new ArgumentNullException(nameof(guestId)); + } + + /// + /// 유효성 검사 + /// + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(GuestId); + } + + /// + /// 디버그 정보 출력 + /// + public string GetDebugInfo() + { + var maskedGuestId = string.IsNullOrEmpty(GuestId) ? "null" : + GuestId.Length > 8 ? $"{GuestId.Substring(0, 4)}****{GuestId.Substring(GuestId.Length - 4)}" : "****"; + + return $"GuestLoginRequest: GuestId={maskedGuestId}, Valid={IsValid()}"; + } + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs.meta b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs.meta new file mode 100644 index 0000000..29eb019 --- /dev/null +++ b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginRequest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f62442eca3b5793478030e3f21476485 \ No newline at end of file diff --git a/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs new file mode 100644 index 0000000..3c86d60 --- /dev/null +++ b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs @@ -0,0 +1,168 @@ +using System; +using Newtonsoft.Json; +using ProjectVG.Infrastructure.Auth.Models; + +namespace ProjectVG.Infrastructure.Network.DTOs.Auth +{ + /// + /// Guest 로그인 응답 DTO + /// + [Serializable] + public class GuestLoginResponse + { + /// + /// 성공 여부 + /// + [JsonProperty("success")] + public bool Success { get; set; } + + /// + /// 토큰 정보 + /// + [JsonProperty("tokens")] + public GuestTokenInfo Tokens { get; set; } + + /// + /// 사용자 정보 + /// + [JsonProperty("user")] + public GuestUserInfo User { get; set; } + + /// + /// 오류 메시지 + /// + [JsonProperty("message")] + public string Message { get; set; } + + /// + /// TokenSet으로 변환 + /// + public TokenSet ToTokenSet() + { + if (Tokens == null) + { + return null; + } + + if (string.IsNullOrEmpty(Tokens.AccessToken)) + { + // 서버 응답에 액세스 토큰이 없으면 TokenSet 생성 불가 + return null; + } + var accessToken = new AccessToken(Tokens.AccessToken); + + RefreshToken refreshToken = null; + if (!string.IsNullOrEmpty(Tokens.RefreshToken)) + { + refreshToken = new RefreshToken( + Tokens.RefreshToken, + Tokens.RefreshExpiresIn, + User?.UserId ?? "guest" + ); + } + + return new TokenSet(accessToken, refreshToken); + } + + /// + /// 디버그 정보 출력 + /// + public string GetDebugInfo() + { + var info = $"GuestLoginResponse: Success={Success}"; + + if (!string.IsNullOrEmpty(Message)) + { + info += $", Message={Message}"; + } + + if (Tokens != null) + { + info += $", HasTokens=true"; + info += $", AccessTokenLength={Tokens.AccessToken?.Length ?? 0}"; + info += $", HasRefreshToken={!string.IsNullOrEmpty(Tokens.RefreshToken)}"; + } + + if (User != null) + { + info += $", UserId={User.UserId}"; + } + + return info; + } + } + + /// + /// Guest 토큰 정보 + /// + [Serializable] + public class GuestTokenInfo + { + /// + /// 액세스 토큰 + /// + [JsonProperty("accessToken")] + public string AccessToken { get; set; } + + /// + /// 리프레시 토큰 + /// + [JsonProperty("refreshToken")] + public string RefreshToken { get; set; } + + /// + /// 액세스 토큰 만료 시간 (초) + /// + [JsonProperty("expiresIn")] + public int ExpiresIn { get; set; } + + /// + /// 리프레시 토큰 만료 시간 (초) + /// + [JsonProperty("refreshExpiresIn")] + public int RefreshExpiresIn { get; set; } + + /// + /// 토큰 타입 + /// + [JsonProperty("tokenType")] + public string TokenType { get; set; } = "Bearer"; + } + + /// + /// Guest 사용자 정보 + /// + [Serializable] + public class GuestUserInfo + { + /// + /// 사용자 ID + /// + [JsonProperty("userId")] + public string UserId { get; set; } + + /// + /// 게스트 ID + /// + [JsonProperty("guestId")] + public string GuestId { get; set; } + + /// + /// 생성 시간 + /// + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } + + /// + /// 마지막 로그인 시간 + /// + [JsonProperty("lastLoginAt")] + public DateTime LastLoginAt { get; set; } + + /// + /// 사용자 타입 + /// + [JsonProperty("userType")] + public string UserType { get; set; } = "guest"; + } +} \ No newline at end of file diff --git a/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs.meta b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs.meta new file mode 100644 index 0000000..338391b --- /dev/null +++ b/Assets/Infrastructure/Network/DTOs/Auth/GuestLoginResponse.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f57e18d77084bfa4e9cd5b8c9847957f \ No newline at end of file diff --git a/Assets/Infrastructure/Network/DTOs/Chat/ChatRequest.cs b/Assets/Infrastructure/Network/DTOs/Chat/ChatRequest.cs index 08651b3..0476be6 100644 --- a/Assets/Infrastructure/Network/DTOs/Chat/ChatRequest.cs +++ b/Assets/Infrastructure/Network/DTOs/Chat/ChatRequest.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using UnityEngine; using Newtonsoft.Json; @@ -5,30 +6,21 @@ namespace ProjectVG.Infrastructure.Network.DTOs.Chat { [Serializable] - public class ChatRequest + public record ChatRequest { - [JsonProperty("session_id")] - [SerializeField] public string sessionId; - [JsonProperty("message")] - [SerializeField] public string message; - + public string Message { get; set; } = string.Empty; + [JsonProperty("character_id")] - [SerializeField] public string characterId; - - [JsonProperty("user_id")] - [SerializeField] public string userId; - + public string CharacterId { get; set; } = string.Empty; + [JsonProperty("action")] - [SerializeField] public string action = "chat"; - - [JsonProperty("actor")] - [SerializeField] public string actor; - - [JsonProperty("instruction")] - [SerializeField] public string instruction; - - [JsonProperty("requested_at")] - [SerializeField] public string requestedAt; + public string? Action { get; set; } + + [JsonProperty("use_tts")] + public bool UseTTS { get; set; } = true; + + [JsonProperty("request_at")] + public DateTime RequestAt { get; set; } } } \ No newline at end of file diff --git a/Assets/Infrastructure/Network/DTOs/Chat/ChatResponse.cs b/Assets/Infrastructure/Network/DTOs/Chat/ChatResponse.cs index 88a1421..cb8ffe1 100644 --- a/Assets/Infrastructure/Network/DTOs/Chat/ChatResponse.cs +++ b/Assets/Infrastructure/Network/DTOs/Chat/ChatResponse.cs @@ -6,7 +6,7 @@ namespace ProjectVG.Infrastructure.Network.DTOs.Chat { [Serializable] - public class ChatResponse + public class WebSocketResponse { [JsonProperty("type")] public string Type { get; set; } = "chat"; @@ -14,15 +14,31 @@ public class ChatResponse [JsonProperty("message_type")] public string MessageType { get; set; } = "json"; - [JsonProperty("session_id")] - public string SessionId { get; set; } = string.Empty; - + [JsonProperty("data")] + public ChatData? Data { get; set; } + } + + [Serializable] + public class ChatData + { [JsonProperty("text")] public string? Text { get; set; } - - [JsonProperty("action")] - public string? Action { get; set; } - + + [JsonProperty("emotion")] + public string? Emotion { get; set; } + + [JsonProperty("actions")] + public string[]? Actions { get; set; } + + [JsonProperty("order")] + public int Order { get; set; } + + [JsonProperty("request_id")] + public string RequestId { get; set; } = string.Empty; + + [JsonProperty("timestamp")] + public string Timestamp { get; set; } = string.Empty; + [JsonProperty("audio_data")] public string? AudioData { get; set; } @@ -31,14 +47,5 @@ public class ChatResponse [JsonProperty("audio_length")] public float? AudioLength { get; set; } - - [JsonProperty("used_cost")] - public float? UsedCost { get; set; } - - [JsonProperty("remaining_cost")] - public float? RemainingCost { get; set; } - - [JsonProperty("timestamp")] - public DateTime Timestamp { get; set; } = DateTime.UtcNow; } } \ No newline at end of file diff --git a/Assets/Infrastructure/Network/Http/HttpApiClient.cs b/Assets/Infrastructure/Network/Http/HttpApiClient.cs index d9028e9..0537df2 100644 --- a/Assets/Infrastructure/Network/Http/HttpApiClient.cs +++ b/Assets/Infrastructure/Network/Http/HttpApiClient.cs @@ -19,10 +19,10 @@ public class HttpApiClient : Singleton private const string ACCEPT_HEADER = "application/json"; private const string AUTHORIZATION_HEADER = "Authorization"; private const string BEARER_PREFIX = "Bearer "; + private const string DEFAULT_FILE_NAME = "file.wav"; private readonly Dictionary defaultHeaders = new Dictionary(); private CancellationTokenSource cancellationTokenSource; - private SessionManager _sessionManager; public bool IsInitialized { get; private set; } #region Unity Lifecycle @@ -44,9 +44,8 @@ private void OnDestroy() /// /// 초기화 실행 /// - public void Initialize(SessionManager sessionManager) + public void Initialize() { - _sessionManager = sessionManager; cancellationTokenSource?.Cancel(); cancellationTokenSource?.Dispose(); cancellationTokenSource = new CancellationTokenSource(); @@ -55,89 +54,140 @@ public void Initialize(SessionManager sessionManager) IsInitialized = true; } - /// - /// 기본 헤더 추가 - /// public void AddDefaultHeader(string key, string value) { defaultHeaders[key] = value; } - /// - /// 인증 토큰 설정 - /// + public void RemoveDefaultHeader(string key) + { + defaultHeaders.Remove(key); + } + public void SetAuthToken(string token) { AddDefaultHeader(AUTHORIZATION_HEADER, $"{BEARER_PREFIX}{token}"); } - public async UniTask GetAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) + private void EnsureAuthToken(bool requiresAuth) + { + if (!requiresAuth) return; + + try + { + var tokenManager = ProjectVG.Infrastructure.Auth.TokenManager.Instance; + var accessToken = tokenManager.GetAccessToken(); + + Debug.Log($"[HttpApiClient] Access Token: {accessToken}"); + + if (!string.IsNullOrEmpty(accessToken)) + { + SetAuthToken(accessToken); + } + else + { + Debug.LogWarning("[HttpApiClient] 유효한 Access Token을 찾을 수 없습니다."); + RemoveDefaultHeader(AUTHORIZATION_HEADER); + } + } + catch (Exception ex) + { + Debug.LogError($"[HttpApiClient] 토큰 설정 실패: {ex.Message}"); + RemoveDefaultHeader(AUTHORIZATION_HEADER); + } + } + + public async UniTask GetAsync(string endpoint, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { + EnsureInitialized(); + EnsureAuthToken(requiresAuth); + var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbGET, null, headers, cancellationToken); } - public async UniTask PostAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, CancellationToken cancellationToken = default) + /// + /// GET 요청 (HTTP 헤더 포함 응답) + /// + public async UniTask<(T Data, Dictionary Headers)> GetWithHeadersAsync(string endpoint, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { + EnsureInitialized(); + EnsureAuthToken(requiresAuth); + + var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); + return await SendJsonRequestWithHeadersAsync(url, UnityWebRequest.kHttpVerbGET, null, headers, cancellationToken); + } + + /// + /// POST 요청 (HTTP 헤더 포함 응답) + /// + public async UniTask<(T Data, Dictionary Headers)> PostWithHeadersAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) + { + EnsureInitialized(); + EnsureAuthToken(requiresAuth); + + var url = GetFullUrl(endpoint); + var jsonData = SerializeData(data); + return await SendJsonRequestWithHeadersAsync(url, UnityWebRequest.kHttpVerbPOST, jsonData, headers, cancellationToken); + } + + public async UniTask PostAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) + { + EnsureInitialized(); + EnsureAuthToken(requiresAuth); + var url = GetFullUrl(endpoint); - var jsonData = SerializeData(data, requiresSession); + var jsonData = SerializeData(data); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbPOST, jsonData, headers, cancellationToken); } - public async UniTask PutAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresSession = false, CancellationToken cancellationToken = default) + public async UniTask PutAsync(string endpoint, object data = null, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { + EnsureInitialized(); + EnsureAuthToken(requiresAuth); + var url = GetFullUrl(endpoint); - var jsonData = SerializeData(data, requiresSession); + var jsonData = SerializeData(data); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbPUT, jsonData, headers, cancellationToken); } - public async UniTask DeleteAsync(string endpoint, Dictionary headers = null, CancellationToken cancellationToken = default) + public async UniTask DeleteAsync(string endpoint, Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { + EnsureInitialized(); + EnsureAuthToken(requiresAuth); + var url = GetFullUrl(endpoint); return await SendJsonRequestAsync(url, UnityWebRequest.kHttpVerbDELETE, null, headers, cancellationToken); } - public async UniTask UploadFileAsync(string endpoint, byte[] fileData, string fileName, string fieldName = "file", Dictionary headers = null, CancellationToken cancellationToken = default) + public async UniTask UploadFileAsync(string endpoint, byte[] fileData, string fileName, string fieldName = "file", Dictionary headers = null, bool requiresAuth = false, CancellationToken cancellationToken = default) { + EnsureInitialized(); + EnsureAuthToken(requiresAuth); + var url = GetFullUrl(endpoint); - var formData = new Dictionary - { - { fieldName, fileData } - }; - var fileNames = new Dictionary - { - { fieldName, fileName } - }; + var formData = new Dictionary { { fieldName, fileData } }; + var fileNames = new Dictionary { { fieldName, fileName } }; + return await SendFormDataRequestAsync(url, formData, fileNames, headers, cancellationToken); } - public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary headers = null, CancellationToken cancellationToken = default) + public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary headers, bool requiresAuth = false, CancellationToken cancellationToken = default) { + EnsureInitialized(); + EnsureAuthToken(requiresAuth); + var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); return await SendFormDataRequestAsync(url, formData, null, headers, cancellationToken); } - public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary fileNames, Dictionary headers = null, CancellationToken cancellationToken = default) + public async UniTask PostFormDataAsync(string endpoint, Dictionary formData, Dictionary fileNames, Dictionary headers, bool requiresAuth = false, CancellationToken cancellationToken = default) { - var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); - - // 파일 크기 검사 - if (NetworkConfig.EnableFileSizeCheck) - { - foreach (var kvp in formData) - { - if (kvp.Value is byte[] byteData) - { - if (byteData.Length > NetworkConfig.MaxFileSize) - { - var fileSizeMB = byteData.Length / 1024.0 / 1024.0; - var maxSizeMB = NetworkConfig.MaxFileSize / 1024.0 / 1024.0; - throw new FileSizeExceededException(fileSizeMB, maxSizeMB); - } - } - } - } + EnsureInitialized(); + EnsureAuthToken(requiresAuth); + ValidateFileSize(formData); + var url = IsFullUrl(endpoint) ? endpoint : GetFullUrl(endpoint); return await SendFormDataRequestAsync(url, formData, fileNames, headers, cancellationToken); } @@ -155,6 +205,34 @@ public void Shutdown() #region Private Methods + private void EnsureInitialized() + { + if (!IsInitialized) + { + Initialize(); + } + } + + private void ValidateFileSize(Dictionary formData) + { + if (!NetworkConfig.EnableFileSizeCheck) return; + + foreach (var kvp in formData) + { + if (kvp.Value is byte[] byteData && byteData.Length > NetworkConfig.MaxFileSize) + { + var fileSizeMB = byteData.Length / 1024.0 / 1024.0; + var maxSizeMB = NetworkConfig.MaxFileSize / 1024.0 / 1024.0; + throw new FileSizeExceededException(fileSizeMB, maxSizeMB); + } + } + } + + private string SerializeData(object data) + { + return data == null ? null : JsonConvert.SerializeObject(data); + } + private void ApplyNetworkConfig() { } @@ -163,7 +241,15 @@ private void SetupDefaultHeaders() { defaultHeaders.Clear(); defaultHeaders["Content-Type"] = NetworkConfig.ContentType; + +#if !UNITY_WEBGL || UNITY_EDITOR defaultHeaders["User-Agent"] = NetworkConfig.UserAgent; +#else + // WebGL에서는 커스텀 헤더로 클라이언트 식별 + defaultHeaders["X-Client-Type"] = "ProjectVG-WebGL"; + defaultHeaders["X-Client-Version"] = Application.version; +#endif + defaultHeaders["Accept"] = ACCEPT_HEADER; } @@ -176,185 +262,146 @@ private bool IsFullUrl(string url) { return url.StartsWith("http://") || url.StartsWith("https://"); } + private async UniTask SendJsonRequestAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) + { + return await ExecuteRequestWithRetry(async (attempt, token) => + { + using var request = CreateJsonRequest(url, method, jsonData, headers); + var operation = request.SendWebRequest(); + await operation.WithCancellation(token); + + if (request.result == UnityWebRequest.Result.Success) + return ParseResponse(request); + + await HandleRequestFailure(request, attempt, token); + return default(T); + }, cancellationToken); + } - private string SerializeData(object data, bool requiresSession = false) + private async UniTask<(T Data, Dictionary Headers)> SendJsonRequestWithHeadersAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) { - if (data == null) return null; - - var jsonData = JsonConvert.SerializeObject(data); - - if (requiresSession && data is ChatRequest chatRequest && string.IsNullOrEmpty(chatRequest.sessionId)) + return await ExecuteRequestWithRetry<(T, Dictionary)>(async (attempt, token) => { - var sessionId = _sessionManager?.SessionId ?? ""; - if (!string.IsNullOrEmpty(sessionId)) - { - var jsonObject = JsonConvert.DeserializeObject>(jsonData); - jsonObject["session_id"] = sessionId; - jsonData = JsonConvert.SerializeObject(jsonObject); - } - else + using var request = CreateJsonRequest(url, method, jsonData, headers); + var operation = request.SendWebRequest(); + await operation.WithCancellation(token); + + if (request.result == UnityWebRequest.Result.Success) { - Debug.LogWarning("[HttpApiClient] 세션 연결이 필요한 요청이지만 세션 ID를 획득할 수 없습니다."); + var data = ParseResponse(request); + var responseHeaders = ExtractResponseHeaders(request); + return (data, responseHeaders); } - } - - return jsonData; + + await HandleRequestFailure(request, attempt, token); + return default; + }, cancellationToken); } - - - private async UniTask SendJsonRequestAsync(string url, string method, string jsonData, Dictionary headers, CancellationToken cancellationToken) + private async UniTask SendFormDataRequestAsync(string url, Dictionary formData, Dictionary fileNames, Dictionary headers, CancellationToken cancellationToken) { - var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); + fileNames ??= new Dictionary(); + + return await ExecuteRequestWithRetry(async (attempt, token) => + { + var form = CreateFormData(formData, fileNames); + using var request = UnityWebRequest.Post(url, form); + SetupRequest(request, headers); + request.timeout = (int)NetworkConfig.UploadTimeout; + + var operation = request.SendWebRequest(); + await operation.WithCancellation(token); + + if (request.result == UnityWebRequest.Result.Success) + return ParseResponse(request); + + await HandleRequestFailure(request, attempt, token, isFileUpload: true); + return default(T); + }, cancellationToken); + } - for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) + private CancellationToken CreateCombinedCancellationToken(CancellationToken cancellationToken) + { + if (cancellationTokenSource?.Token.CanBeCanceled == true) { try { - using var request = CreateJsonRequest(url, method, jsonData, headers); - - var operation = request.SendWebRequest(); - await operation.WithCancellation(combinedCancellationToken); - - if (request.result == UnityWebRequest.Result.Success) - { - return ParseResponse(request); - } - else - { - await HandleRequestFailure(request, attempt, combinedCancellationToken); - } - } - catch (OperationCanceledException) - { - throw; + return CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cancellationTokenSource.Token).Token; } - catch (Exception ex) when (ex is not ApiException) + catch (Exception ex) { - await HandleRequestException(ex, attempt, combinedCancellationToken); + Debug.LogError($"[HttpApiClient] CancellationToken 생성 실패: {ex.Message}"); } } - - throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, "최대 재시도 횟수 초과"); + + return cancellationToken; } - - - - - - - private async UniTask SendFormDataRequestAsync(string url, Dictionary formData, Dictionary fileNames, Dictionary headers, CancellationToken cancellationToken) + private async UniTask ExecuteRequestWithRetry(Func> requestFunc, CancellationToken cancellationToken) { - fileNames = fileNames ?? new Dictionary(); var combinedCancellationToken = CreateCombinedCancellationToken(cancellationToken); for (int attempt = 0; attempt <= NetworkConfig.MaxRetryCount; attempt++) { try { - var form = new WWWForm(); - Debug.Log($"[HttpApiClient] 폼 데이터 전송 시작 - URL: {url}"); - Debug.Log($"[HttpApiClient] 실제 전송 URL: {url}"); - - foreach (var kvp in formData) - { - if (kvp.Value is byte[] byteData) - { - string fileName = fileNames.ContainsKey(kvp.Key) ? fileNames[kvp.Key] : "file.wav"; - form.AddBinaryData(kvp.Key, byteData, fileName); - Debug.Log($"[HttpApiClient] 바이너리 데이터 추가 - 필드: {kvp.Key}, 파일명: {fileName}, 크기: {byteData.Length} bytes"); - } - else - { - form.AddField(kvp.Key, kvp.Value.ToString()); - Debug.Log($"[HttpApiClient] 필드 추가 - {kvp.Key}: {kvp.Value}"); - } - } - - using var request = UnityWebRequest.Post(url, form); - // 파일 업로드 시 Content-Type은 UnityWebRequest가 자동으로 설정하도록 함 - SetupRequest(request, headers); - request.timeout = (int)NetworkConfig.UploadTimeout; // Use UploadTimeout - - var operation = request.SendWebRequest(); - await operation.WithCancellation(combinedCancellationToken); - - if (request.result == UnityWebRequest.Result.Success) - { - return ParseResponse(request); - } - else - { - await HandleFileUploadFailure(request, attempt, combinedCancellationToken); - } + return await requestFunc(attempt, combinedCancellationToken); } catch (OperationCanceledException) { throw; } - catch (Exception ex) when (ex is not ApiException) + catch (ApiException) when (attempt == NetworkConfig.MaxRetryCount) + { + throw; + } + catch (Exception ex) when (ex is not ApiException && attempt < NetworkConfig.MaxRetryCount) { - await HandleFileUploadException(ex, attempt, combinedCancellationToken); + await DelayForRetry(attempt, combinedCancellationToken); } } - throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 파일 업로드 실패", 0, "최대 재시도 횟수 초과"); - } - - private CancellationToken CreateCombinedCancellationToken(CancellationToken cancellationToken) - { - return CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cancellationTokenSource.Token).Token; + throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, "최대 재시도 횟수 초과"); } - - private async UniTask HandleRequestFailure(UnityWebRequest request, int attempt, CancellationToken cancellationToken) + + private async UniTask HandleRequestFailure(UnityWebRequest request, int attempt, CancellationToken cancellationToken, bool isFileUpload = false) { var error = new ApiException(request.error, request.responseCode, request.downloadHandler?.text); + var requestType = isFileUpload ? "파일 업로드" : "API 요청"; if (ShouldRetry(request.responseCode) && attempt < NetworkConfig.MaxRetryCount) { - Debug.LogWarning($"[HttpApiClient] API 요청 실패 (시도 {attempt + 1}/{NetworkConfig.MaxRetryCount + 1}): {error.Message}"); - await UniTask.Delay(TimeSpan.FromSeconds(NetworkConfig.RetryDelay * (attempt + 1)), cancellationToken: cancellationToken); + Debug.LogWarning($"[HttpApiClient] {requestType} 실패 (시도 {attempt + 1}/{NetworkConfig.MaxRetryCount + 1}): {error.Message}"); + await DelayForRetry(attempt, cancellationToken); return; } throw error; } - - private async UniTask HandleRequestException(Exception ex, int attempt, CancellationToken cancellationToken) + + private async UniTask DelayForRetry(int attempt, CancellationToken cancellationToken) { - if (attempt < NetworkConfig.MaxRetryCount) - { - Debug.LogWarning($"[HttpApiClient] API 요청 예외 발생 (시도 {attempt + 1}/{NetworkConfig.MaxRetryCount + 1}): {ex.Message}"); - await UniTask.Delay(TimeSpan.FromSeconds(NetworkConfig.RetryDelay * (attempt + 1)), cancellationToken: cancellationToken); - return; - } - throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 요청 실패", 0, ex.Message); + await UniTask.Delay(TimeSpan.FromSeconds(NetworkConfig.RetryDelay * (attempt + 1)), cancellationToken: cancellationToken); } - - private async UniTask HandleFileUploadFailure(UnityWebRequest request, int attempt, CancellationToken cancellationToken) + + private WWWForm CreateFormData(Dictionary formData, Dictionary fileNames) { - var error = new ApiException(request.error, request.responseCode, request.downloadHandler?.text); + var form = new WWWForm(); - if (ShouldRetry(request.responseCode) && attempt < NetworkConfig.MaxRetryCount) + foreach (var kvp in formData) { - Debug.LogWarning($"[HttpApiClient] 파일 업로드 실패 (시도 {attempt + 1}/{NetworkConfig.MaxRetryCount + 1}): {error.Message}"); - await UniTask.Delay(TimeSpan.FromSeconds(NetworkConfig.RetryDelay * (attempt + 1)), cancellationToken: cancellationToken); - return; + if (kvp.Value is byte[] byteData) + { + string fileName = fileNames.ContainsKey(kvp.Key) ? fileNames[kvp.Key] : DEFAULT_FILE_NAME; + form.AddBinaryData(kvp.Key, byteData, fileName); + } + else + { + form.AddField(kvp.Key, kvp.Value?.ToString() ?? string.Empty); + } } - throw error; - } - - private async UniTask HandleFileUploadException(Exception ex, int attempt, CancellationToken cancellationToken) - { - if (attempt < NetworkConfig.MaxRetryCount) - { - Debug.LogWarning($"[HttpApiClient] 파일 업로드 예외 발생 (시도 {attempt + 1}/{NetworkConfig.MaxRetryCount + 1}): {ex.Message}"); - await UniTask.Delay(TimeSpan.FromSeconds(NetworkConfig.RetryDelay * (attempt + 1)), cancellationToken: cancellationToken); - return; - } - throw new ApiException($"{NetworkConfig.MaxRetryCount + 1}번 시도 후 파일 업로드 실패", 0, ex.Message); + return form; } @@ -402,23 +449,14 @@ private void SetupRequest(UnityWebRequest request, Dictionary he } } - // 디버깅: Content-Type 헤더 확인 - string contentType = request.GetRequestHeader("Content-Type"); - Debug.Log($"[HttpApiClient] 요청 헤더 설정 완료 - Content-Type: {contentType}"); } private T ParseResponse(UnityWebRequest request) { var responseText = request.downloadHandler?.text; - Debug.Log($"[HttpApiClient] 응답 파싱 - Status: {request.responseCode}, Content-Length: {request.downloadHandler?.data?.Length ?? 0}"); - Debug.Log($"[HttpApiClient] 응답 텍스트: '{responseText}'"); - if (string.IsNullOrEmpty(responseText)) - { - Debug.LogWarning("[HttpApiClient] 응답 텍스트가 비어있습니다."); return default(T); - } try { @@ -426,7 +464,6 @@ private T ParseResponse(UnityWebRequest request) } catch (Exception ex) { - Debug.LogError($"[HttpApiClient] JSON 파싱 실패: {ex.Message}"); return TryFallbackParse(responseText, request.responseCode, ex); } } @@ -439,8 +476,29 @@ private T TryFallbackParse(string responseText, long responseCode, Exception } catch (Exception fallbackEx) { - throw new ApiException($"응답 파싱 실패: {originalException.Message} (폴백도 실패: {fallbackEx.Message})", responseCode, responseText); + var errorMessage = $"JSON 파싱 실패: {originalException.Message} (Unity JsonUtility 폴백도 실패: {fallbackEx.Message})"; + Debug.LogError($"[HttpApiClient] {errorMessage}"); + throw new ApiException(errorMessage, responseCode, responseText); + } + } + + private Dictionary ExtractResponseHeaders(UnityWebRequest request) + { + var headers = new Dictionary(); + var headerNames = new[] + { + "X-Access-Token", "X-Refresh-Token", "X-Expires-In", "X-User-Id", + "Content-Type", "Authorization" + }; + + foreach (var headerName in headerNames) + { + var headerValue = request.GetResponseHeader(headerName); + if (!string.IsNullOrEmpty(headerValue)) + headers[headerName] = headerValue; } + + return headers; } private bool ShouldRetry(long responseCode) diff --git a/Assets/Infrastructure/Network/Services/CharacterApiService.cs b/Assets/Infrastructure/Network/Services/CharacterApiService.cs index 605caed..cd271cb 100644 --- a/Assets/Infrastructure/Network/Services/CharacterApiService.cs +++ b/Assets/Infrastructure/Network/Services/CharacterApiService.cs @@ -2,6 +2,7 @@ using UnityEngine; using Cysharp.Threading.Tasks; using ProjectVG.Infrastructure.Network.Http; +using ProjectVG.Infrastructure.Network.Configs; using ProjectVG.Infrastructure.Network.DTOs.Character; namespace ProjectVG.Infrastructure.Network.Services @@ -35,7 +36,7 @@ public async UniTask GetAllCharactersAsync(CancellationToken ca return null; } - return await _httpClient.GetAsync("character", cancellationToken: cancellationToken); + return await _httpClient.GetAsync("api/v1/character", cancellationToken: cancellationToken); } /// @@ -52,7 +53,7 @@ public async UniTask GetCharacterAsync(string characterId, Cancel return null; } - return await _httpClient.GetAsync($"character/{characterId}", cancellationToken: cancellationToken); + return await _httpClient.GetAsync($"api/v1/character/{characterId}", cancellationToken: cancellationToken); } /// @@ -63,7 +64,7 @@ public async UniTask GetCharacterAsync(string characterId, Cancel /// 생성된 캐릭터 정보 public async UniTask CreateCharacterAsync(CreateCharacterRequest request, CancellationToken cancellationToken = default) { - return await _httpClient.PostAsync("character", request, cancellationToken: cancellationToken); + return await _httpClient.PostAsync("api/v1/character", request, cancellationToken: cancellationToken); } /// @@ -102,7 +103,7 @@ public async UniTask CreateCharacterAsync( /// 수정된 캐릭터 정보 public async UniTask UpdateCharacterAsync(string characterId, UpdateCharacterRequest request, CancellationToken cancellationToken = default) { - return await _httpClient.PutAsync($"character/{characterId}", request, cancellationToken: cancellationToken); + return await _httpClient.PutAsync($"api/v1/character/{characterId}", request, cancellationToken: cancellationToken); } /// @@ -143,7 +144,7 @@ public async UniTask DeleteCharacterAsync(string characterId, Cancellation { try { - await _httpClient.DeleteAsync($"character/{characterId}", cancellationToken: cancellationToken); + await _httpClient.DeleteAsync($"api/v1/character/{characterId}", cancellationToken: cancellationToken); return true; } catch diff --git a/Assets/Infrastructure/Network/Services/ChatApiService.cs b/Assets/Infrastructure/Network/Services/ChatApiService.cs index b10a2c1..054c03a 100644 --- a/Assets/Infrastructure/Network/Services/ChatApiService.cs +++ b/Assets/Infrastructure/Network/Services/ChatApiService.cs @@ -4,6 +4,7 @@ using Cysharp.Threading.Tasks; using ProjectVG.Infrastructure.Network.Http; using ProjectVG.Infrastructure.Network.DTOs.Chat; +using ProjectVG.Infrastructure.Network.Configs; using Newtonsoft.Json; namespace ProjectVG.Infrastructure.Network.Services @@ -15,7 +16,6 @@ public class ChatApiService { private readonly HttpApiClient _httpClient; private const string CHAT_ENDPOINT = "chat"; - private const string DEFAULT_ACTION = "chat"; public ChatApiService() { @@ -29,35 +29,12 @@ public ChatApiService() /// 채팅 요청 데이터 /// 취소 토큰 /// 채팅 응답 - public async UniTask SendChatAsync(ChatRequest request, CancellationToken cancellationToken = default) + public async UniTask SendChatAsync(ChatRequest request, CancellationToken cancellationToken = default) { - ValidateRequest(request); ValidateHttpClient(); - var serverRequest = CreateServerRequest(request); - LogRequestDetails(serverRequest); - return await _httpClient.PostAsync(CHAT_ENDPOINT, serverRequest, requiresSession: true, cancellationToken: cancellationToken); - } - - /// - /// 간편한 채팅 요청 - /// - /// 메시지 - /// 캐릭터 ID - /// 사용자 ID - /// 액터 (선택사항) - /// 취소 토큰 - /// 채팅 응답 - public async UniTask SendChatAsync( - string message, - string characterId, - string userId, - string actor = null, - CancellationToken cancellationToken = default) - { - var request = CreateSimpleRequest(message, characterId, userId, actor); - return await SendChatAsync(request, cancellationToken); + return await _httpClient.PostAsync($"api/v1/{CHAT_ENDPOINT}", request, requiresAuth: true, cancellationToken: cancellationToken); } #region Private Methods @@ -70,62 +47,6 @@ private void ValidateHttpClient() } } - private void ValidateRequest(ChatRequest request) - { - if (request == null) - { - throw new ArgumentNullException(nameof(request), "채팅 요청이 null입니다."); - } - - if (string.IsNullOrEmpty(request.message)) - { - throw new ArgumentException("메시지가 비어있습니다.", nameof(request.message)); - } - - if (string.IsNullOrEmpty(request.characterId)) - { - throw new ArgumentException("캐릭터 ID가 비어있습니다.", nameof(request.characterId)); - } - - if (string.IsNullOrEmpty(request.userId)) - { - throw new ArgumentException("사용자 ID가 비어있습니다.", nameof(request.userId)); - } - } - - private ChatRequest CreateServerRequest(ChatRequest originalRequest) - { - return new ChatRequest - { - sessionId = originalRequest.sessionId, - message = originalRequest.message, - characterId = originalRequest.characterId, - userId = originalRequest.userId, - action = originalRequest.action, - actor = originalRequest.actor, - instruction = originalRequest.instruction, - requestedAt = originalRequest.requestedAt - }; - } - - private ChatRequest CreateSimpleRequest(string message, string characterId, string userId, string actor) - { - return new ChatRequest - { - sessionId = "", // HttpApiClient에서 자동 주입 - message = message, - characterId = characterId, - userId = userId, - actor = actor, - action = DEFAULT_ACTION, - requestedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ") - }; - } - - private void LogRequestDetails(ChatRequest request) - { - } - #endregion } } \ No newline at end of file diff --git a/Assets/Infrastructure/Network/Services/STTService.cs b/Assets/Infrastructure/Network/Services/STTService.cs index 3284d77..7170e0a 100644 --- a/Assets/Infrastructure/Network/Services/STTService.cs +++ b/Assets/Infrastructure/Network/Services/STTService.cs @@ -5,6 +5,7 @@ using System.Threading; using UnityEngine; using ProjectVG.Infrastructure.Network.Http; +using ProjectVG.Infrastructure.Network.Configs; using ProjectVG.Infrastructure.Network.DTOs.Chat; using Newtonsoft.Json; using Cysharp.Threading.Tasks; @@ -65,9 +66,9 @@ public async UniTask ConvertSpeechToTextAsync(byte[] audioData, string a // 서버 API에 맞게 language 파라미터만 사용 string forcedLanguage = "ko"; - string endpoint = $"stt/transcribe?language={forcedLanguage}"; + string endpoint = $"api/v1/stt/transcribe?language={forcedLanguage}"; - var response = await _httpClient.PostFormDataAsync(endpoint, formData, fileNames, cancellationToken: cancellationToken); + var response = await _httpClient.PostFormDataAsync(endpoint, formData, fileNames, null, requiresAuth: false, cancellationToken: cancellationToken); if (response != null && !string.IsNullOrEmpty(response.Text)) { diff --git a/Assets/Infrastructure/Network/Services/SessionManager.cs b/Assets/Infrastructure/Network/Services/SessionManager.cs deleted file mode 100644 index 7d4b339..0000000 --- a/Assets/Infrastructure/Network/Services/SessionManager.cs +++ /dev/null @@ -1,307 +0,0 @@ -using UnityEngine; -using System; -using Cysharp.Threading.Tasks; -using ProjectVG.Infrastructure.Network.WebSocket; - -namespace ProjectVG.Infrastructure.Network.Services -{ - /// - /// 새로운 이벤트 기반 SessionManager - /// WebSocketManager의 연결/해제 상태를 모니터링하고 세션 ID를 관리 - /// - public class SessionManager : Singleton - { - [Header("Session Info")] - [SerializeField] private string _currentSessionId = ""; - [SerializeField] private bool _isInitialized = false; - - private WebSocketManager _webSocketManager; - - // 공개 속성 - public string SessionId => _currentSessionId; - public bool IsSessionConnected => !string.IsNullOrEmpty(_currentSessionId) && _webSocketManager?.IsConnected == true; - public bool IsWebSocketConnected => _webSocketManager?.IsConnected == true; - public bool IsWebSocketConnecting => _webSocketManager?.IsConnecting == true; - public bool IsInitialized => _isInitialized; - - // 이벤트 - public event Action OnSessionStarted; - public event Action OnSessionEnded; - public event Action OnSessionError; - - #region Unity Lifecycle - - protected override void Awake() - { - base.Awake(); - } - - private void OnDestroy() - { - Shutdown(); - } - - #endregion - - #region Public Methods - - /// - /// 세션 ID 요청 - /// - public async UniTask GetSessionIdAsync() - { - if (IsSessionConnected) - { - Debug.Log($"[SessionManager] 현재 세션 ID 반환: {_currentSessionId}"); - return _currentSessionId; - } - - Debug.Log("[SessionManager] 세션이 없거나 연결되지 않음. 연결을 시도합니다."); - bool connected = await EnsureConnectionAsync(); - - if (connected) - { - return _currentSessionId; - } - else - { - Debug.LogError("[SessionManager] 세션 연결에 실패했습니다."); - return null; - } - } - - /// - /// 세션 연결 보장 - /// - public async UniTask EnsureConnectionAsync() - { - Debug.Log($"[SessionManager] EnsureConnectionAsync 호출 - 초기화 상태: {_isInitialized}, WebSocketManager: {(_webSocketManager != null ? "존재" : "null")}"); - - if (IsSessionConnected) - { - Debug.Log("[SessionManager] 이미 세션이 연결되어 있습니다."); - return true; - } - - // DI로 주입받은 WebSocketManager 확인 - if (_webSocketManager == null) - { - Debug.LogError("[SessionManager] WebSocketManager가 DI로 주입되지 않았습니다. DependencyManager 설정을 확인하세요."); - OnSessionError?.Invoke("WebSocketManager가 null입니다."); - return false; - } - - return await RequestConnectionAsync(); - } - - /// - /// 연결 요청 - /// - private async UniTask RequestConnectionAsync() - { - try - { - // DI로 주입받은 WebSocketManager 사용 - if (_webSocketManager == null) - { - Debug.LogError("[SessionManager] WebSocketManager가 DI로 주입되지 않았습니다."); - OnSessionError?.Invoke("WebSocketManager가 null입니다."); - return false; - } - - // 1. WebSocket 연결 상태 확인 및 연결 요청 - if (!IsWebSocketConnected) - { - if (IsWebSocketConnecting) - { - Debug.Log("[SessionManager] WebSocket이 이미 연결 중입니다. 연결 완료를 기다립니다."); - } - else - { - Debug.Log("[SessionManager] WebSocket 연결을 요청합니다."); - bool connected = await _webSocketManager.ConnectAsync(); - if (!connected) - { - Debug.LogError("[SessionManager] WebSocket 연결에 실패했습니다."); - return false; - } - } - } - - // 2. 연결 완료 대기 (폴링) - return await WaitForSessionConnection(); - } - catch (Exception ex) - { - Debug.LogError($"[SessionManager] 연결 요청 실패: {ex.Message}"); - Debug.LogError($"[SessionManager] 스택 트레이스: {ex.StackTrace}"); - OnSessionError?.Invoke($"연결 요청 실패: {ex.Message}"); - return false; - } - } - - /// - /// 세션 연결 완료 대기 - /// - private async UniTask WaitForSessionConnection() - { - Debug.Log("[SessionManager] 세션 연결 완료 대기 중..."); - - const int timeoutSeconds = 10; - const int pollIntervalMs = 100; // 100ms마다 체크 - int elapsedMs = 0; - - while (elapsedMs < timeoutSeconds * 1000) - { - // 세션이 연결되었는지 확인 - if (IsSessionConnected) - { - Debug.Log($"[SessionManager] 세션 연결 완료: {_currentSessionId}"); - return true; - } - - // WebSocket 연결이 끊어졌다면 실패 - if (!IsWebSocketConnected && !IsWebSocketConnecting) - { - Debug.LogError("[SessionManager] WebSocket 연결이 끊어졌습니다."); - return false; - } - - await UniTask.Delay(pollIntervalMs); - elapsedMs += pollIntervalMs; - } - - Debug.LogError($"[SessionManager] 세션 연결 타임아웃 ({timeoutSeconds}초)"); - return false; - } - - /// - /// 세션 해제 - /// - public void EndSession() - { - if (!string.IsNullOrEmpty(_currentSessionId)) - { - string oldSessionId = _currentSessionId; - _currentSessionId = ""; - - Debug.Log($"[SessionManager] 세션 종료: {oldSessionId}"); - OnSessionEnded?.Invoke(); - } - } - - #endregion - - #region Private Methods - 초기화 및 이벤트 핸들링 - - /// - /// 초기화 실행 - /// - public void Initialize(WebSocketManager webSocketManager) - { - try - { - _webSocketManager = webSocketManager; - Debug.Log("[SessionManager] 초기화 시작"); - - if (_webSocketManager == null) - { - Debug.LogError("[SessionManager] WebSocketManager가 null입니다."); - OnSessionError?.Invoke("WebSocketManager가 null입니다."); - return; - } - - SubscribeToWebSocketEvents(); - - _isInitialized = true; - Debug.Log("[SessionManager] 초기화 완료"); - } - catch (Exception ex) - { - Debug.LogError($"[SessionManager] 초기화 실패: {ex.Message}"); - Debug.LogError($"[SessionManager] 스택 트레이스: {ex.StackTrace}"); - OnSessionError?.Invoke($"초기화 실패: {ex.Message}"); - } - } - - private void SubscribeToWebSocketEvents() - { - if (_webSocketManager == null) return; - - // 기존 구독 해제 (중복 방지) - UnsubscribeFromWebSocketEvents(); - - // 새로운 이벤트 구독 - _webSocketManager.OnSessionConnected += OnWebSocketSessionConnected; - _webSocketManager.OnSessionDisconnected += OnWebSocketSessionDisconnected; - _webSocketManager.OnDisconnected += OnWebSocketDisconnected; - _webSocketManager.OnError += OnWebSocketError; - - Debug.Log("[SessionManager] WebSocket 이벤트 구독 완료"); - } - - private void UnsubscribeFromWebSocketEvents() - { - if (_webSocketManager == null) return; - - _webSocketManager.OnSessionConnected -= OnWebSocketSessionConnected; - _webSocketManager.OnSessionDisconnected -= OnWebSocketSessionDisconnected; - _webSocketManager.OnDisconnected -= OnWebSocketDisconnected; - _webSocketManager.OnError -= OnWebSocketError; - } - - #endregion - - #region WebSocket 이벤트 핸들러 - - private void OnWebSocketSessionConnected(string sessionId) - { - Debug.Log($"[SessionManager] WebSocket 세션 연결됨: {sessionId}"); - - _currentSessionId = sessionId; - OnSessionStarted?.Invoke(sessionId); - } - - private void OnWebSocketSessionDisconnected() - { - Debug.Log("[SessionManager] WebSocket 세션 연결 해제됨"); - - _currentSessionId = ""; - OnSessionEnded?.Invoke(); - } - - private void OnWebSocketDisconnected() - { - Debug.Log("[SessionManager] WebSocket 연결 해제됨"); - - if (!string.IsNullOrEmpty(_currentSessionId)) - { - _currentSessionId = ""; - OnSessionEnded?.Invoke(); - } - } - - private void OnWebSocketError(string error) - { - Debug.LogError($"[SessionManager] WebSocket 오류: {error}"); - OnSessionError?.Invoke(error); - } - - #endregion - - #region IManager 구현 - - public void Shutdown() - { - - UnsubscribeFromWebSocketEvents(); - - EndSession(); - - _isInitialized = false; - Debug.Log("[SessionManager] 종료 완료"); - } - - #endregion - } -} \ No newline at end of file diff --git a/Assets/Infrastructure/Network/Services/SessionManager.cs.meta b/Assets/Infrastructure/Network/Services/SessionManager.cs.meta deleted file mode 100644 index f4b9335..0000000 --- a/Assets/Infrastructure/Network/Services/SessionManager.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f0048fe563a65f94a90a49760ba27126 \ No newline at end of file diff --git a/Assets/Infrastructure/Network/WebSocket/INativeWebSocket.cs b/Assets/Infrastructure/Network/WebSocket/INativeWebSocket.cs index 12fd0d1..be45fa1 100644 --- a/Assets/Infrastructure/Network/WebSocket/INativeWebSocket.cs +++ b/Assets/Infrastructure/Network/WebSocket/INativeWebSocket.cs @@ -25,5 +25,8 @@ public interface INativeWebSocket : IDisposable // 메시지 전송 UniTask SendMessageAsync(string message, CancellationToken cancellationToken = default); + + // 메시지 큐 처리 (NativeWebSocket 패키지용) + void DispatchMessageQueue(); } } \ No newline at end of file diff --git a/Assets/Infrastructure/Network/WebSocket/Platforms/DesktopWebSocket.cs b/Assets/Infrastructure/Network/WebSocket/Platforms/DesktopWebSocket.cs index 88d7c48..1cda84f 100644 --- a/Assets/Infrastructure/Network/WebSocket/Platforms/DesktopWebSocket.cs +++ b/Assets/Infrastructure/Network/WebSocket/Platforms/DesktopWebSocket.cs @@ -160,6 +160,11 @@ private async Task ReceiveLoopAsync() } } + public void DispatchMessageQueue() + { + // DesktopWebSocket은 네이티브 소켓을 사용하므로 메시지 큐 처리 불필요 + } + public void Dispose() { if (_isDisposed) diff --git a/Assets/Infrastructure/Network/WebSocket/Platforms/MobileWebSocket.cs b/Assets/Infrastructure/Network/WebSocket/Platforms/MobileWebSocket.cs index 7969bc8..5c28747 100644 --- a/Assets/Infrastructure/Network/WebSocket/Platforms/MobileWebSocket.cs +++ b/Assets/Infrastructure/Network/WebSocket/Platforms/MobileWebSocket.cs @@ -158,6 +158,11 @@ private async Task ReceiveLoopAsync() } } + public void DispatchMessageQueue() + { + // MobileWebSocket은 네이티브 소켓을 사용하므로 메시지 큐 처리 불필요 + } + public void Dispose() { if (_isDisposed) diff --git a/Assets/Infrastructure/Network/WebSocket/Platforms/WebGLWebSocket.cs b/Assets/Infrastructure/Network/WebSocket/Platforms/WebGLWebSocket.cs index 849911d..3e83af9 100644 --- a/Assets/Infrastructure/Network/WebSocket/Platforms/WebGLWebSocket.cs +++ b/Assets/Infrastructure/Network/WebSocket/Platforms/WebGLWebSocket.cs @@ -2,27 +2,25 @@ using System.Threading; using UnityEngine; using Cysharp.Threading.Tasks; -using UnityEngine.Networking; +using NativeWebSocket; namespace ProjectVG.Infrastructure.Network.WebSocket.Platforms { /// /// WebGL 플랫폼용 WebSocket 구현체 - /// UnityWebRequest.WebSocket을 사용합니다. + /// NativeWebSocket 패키지를 사용합니다. /// public class WebGLWebSocket : INativeWebSocket { - public bool IsConnected { get; private set; } - public bool IsConnecting { get; private set; } + public bool IsConnected => _webSocket?.State == WebSocketState.Open; + public bool IsConnecting => _webSocket?.State == WebSocketState.Connecting; public event Action OnConnected; public event Action OnDisconnected; public event Action OnError; -#pragma warning disable CS0067 public event Action OnMessageReceived; -#pragma warning restore CS0067 - private UnityWebRequest _webRequest; + private NativeWebSocket.WebSocket _webSocket; private CancellationTokenSource _cancellationTokenSource; private bool _isDisposed = false; @@ -38,42 +36,36 @@ public async UniTask ConnectAsync(string url, CancellationToken cancellati return IsConnected; } - IsConnecting = true; + if (_isDisposed) + { + Debug.LogError("[WebGL WebSocket] Cannot connect: WebSocket is disposed"); + return false; + } try { - var combinedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationTokenSource.Token).Token; - - // UnityWebRequest.WebSocket 사용 - _webRequest = UnityWebRequest.Get(url); - _webRequest.SetRequestHeader("Upgrade", "websocket"); - _webRequest.SetRequestHeader("Connection", "Upgrade"); - - var operation = _webRequest.SendWebRequest(); - await operation.WithCancellation(combinedCancellationToken); + var wsUrl = url.Replace("http://", "ws://").Replace("https://", "wss://"); + Debug.Log($"[WebGL WebSocket] 연결 시도: {wsUrl}"); - if (_webRequest.result == UnityWebRequest.Result.Success) - { - IsConnected = true; - IsConnecting = false; - OnConnected?.Invoke(); - - // 메시지 수신 루프 시작 - _ = ReceiveLoopAsync(); - - return true; - } - else - { - var error = $"WebGL WebSocket 연결 실패: {_webRequest.error}"; - Debug.LogError(error); - OnError?.Invoke(error); - return false; - } + _webSocket = new NativeWebSocket.WebSocket(wsUrl); + + _webSocket.OnOpen += OnNativeConnected; + _webSocket.OnMessage += OnNativeMessageReceived; + _webSocket.OnError += OnNativeError; + _webSocket.OnClose += OnNativeDisconnected; + + await _webSocket.Connect(); + + Debug.Log("[WebGL WebSocket] 연결 성공"); + return true; + } + catch (OperationCanceledException) + { + Debug.Log("[WebGL WebSocket] 연결이 취소되었습니다."); + return false; } catch (Exception ex) { - IsConnecting = false; var error = $"WebGL WebSocket 연결 중 예외 발생: {ex.Message}"; Debug.LogError(error); OnError?.Invoke(error); @@ -83,27 +75,23 @@ public async UniTask ConnectAsync(string url, CancellationToken cancellati public async UniTask DisconnectAsync() { - if (!IsConnected) + if (!IsConnected && !IsConnecting) { return; } try { - IsConnected = false; - IsConnecting = false; - - _webRequest?.Abort(); - _webRequest?.Dispose(); - _webRequest = null; - - await UniTask.CompletedTask; // 비동기 작업 시뮬레이션 + if (_webSocket != null && _webSocket.State == WebSocketState.Open) + { + await _webSocket.Close(); + } - OnDisconnected?.Invoke(); + Debug.Log("[WebGL WebSocket] 연결 해제됨"); } catch (Exception ex) { - Debug.LogError($"WebGL WebSocket 연결 해제 중 오류: {ex.Message}"); + Debug.LogError($"[WebGL WebSocket] 연결 해제 중 오류: {ex.Message}"); } } @@ -111,60 +99,112 @@ public async UniTask SendMessageAsync(string message, CancellationToken ca { if (!IsConnected) { - Debug.LogWarning("WebGL WebSocket이 연결되지 않았습니다."); + Debug.LogWarning("[WebGL WebSocket] 연결되지 않은 상태에서 메시지 전송 시도"); + return false; + } + + if (_isDisposed) + { + Debug.LogError("[WebGL WebSocket] WebSocket이 해제된 상태입니다."); return false; } try { - // TODO : WebGL에서는 WebSocket 메시지 전송을 위한 별도 구현 필요 - await UniTask.CompletedTask; - return true; + if (_webSocket != null && _webSocket.State == WebSocketState.Open) + { + await _webSocket.SendText(message); + Debug.Log($"[WebGL WebSocket] 메시지 전송 성공: {message.Substring(0, Math.Min(message.Length, 50))}..."); + return true; + } + else + { + Debug.LogWarning("[WebGL WebSocket] WebSocket이 연결되지 않았습니다."); + return false; + } } catch (Exception ex) { - Debug.LogError($"WebGL WebSocket 메시지 전송 실패: {ex.Message}"); + Debug.LogError($"[WebGL WebSocket] 메시지 전송 실패: {ex.Message}"); return false; } } - private async UniTask ReceiveLoopAsync() + private void OnNativeConnected() { + if (_isDisposed) return; + + Debug.Log($"[WebGL WebSocket] 연결 성공! Platform: {Application.platform}, IsWebGL: {Application.platform == RuntimePlatform.WebGLPlayer}"); + OnConnected?.Invoke(); + } + + private void OnNativeMessageReceived(byte[] data) + { + if (_isDisposed) return; + try { - while (IsConnected && !_isDisposed) - { - // TODO : WebGL에서는 WebSocket 메시지 수신을 위한 별도 구현 필요 - await UniTask.Delay(100); - } + var message = System.Text.Encoding.UTF8.GetString(data); + Debug.Log($"[WebGL WebSocket] 메시지 수신! Platform: {Application.platform}"); + Debug.Log($"[WebGL WebSocket] 데이터 크기: {data.Length} bytes"); + Debug.Log($"[WebGL WebSocket] 메시지 내용: {message.Substring(0, Math.Min(message.Length, 200))}..."); + OnMessageReceived?.Invoke(message); } catch (Exception ex) { - if (!_isDisposed) - { - Debug.LogError($"WebGL WebSocket 수신 루프 오류: {ex.Message}"); - OnError?.Invoke(ex.Message); - } - } - finally - { - IsConnected = false; - if (!_isDisposed) - { - OnDisconnected?.Invoke(); - } + Debug.LogError($"[WebGL WebSocket] 메시지 처리 중 오류: {ex.Message}"); } } + private void OnNativeError(string error) + { + if (_isDisposed) return; + + Debug.LogError($"[WebGL WebSocket] 오류 발생: {error}"); + OnError?.Invoke(error); + } + + private void OnNativeDisconnected(WebSocketCloseCode closeCode) + { + if (_isDisposed) return; + + Debug.Log($"[WebGL WebSocket] 연결 해제됨 - Code: {closeCode}"); + OnDisconnected?.Invoke(); + } + + public void DispatchMessageQueue() + { +#if !UNITY_WEBGL || UNITY_EDITOR + _webSocket?.DispatchMessageQueue(); + Debug.Log($"[WebGL WebSocket] DispatchMessageQueue 호출됨 - Platform: {Application.platform}"); +#else + Debug.Log($"[WebGL WebSocket] WebGL에서는 DispatchMessageQueue 생략 - Platform: {Application.platform}"); +#endif + } + public void Dispose() { if (_isDisposed) return; _isDisposed = true; + + if (_webSocket != null) + { + _webSocket.OnOpen -= OnNativeConnected; + _webSocket.OnMessage -= OnNativeMessageReceived; + _webSocket.OnError -= OnNativeError; + _webSocket.OnClose -= OnNativeDisconnected; + + if (_webSocket.State == WebSocketState.Open) + { + _webSocket.Close(); + } + _webSocket = null; + } + _cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Dispose(); - _webRequest?.Dispose(); } } } \ No newline at end of file diff --git a/Assets/Infrastructure/Network/WebSocket/WebSocketFactory.cs b/Assets/Infrastructure/Network/WebSocket/WebSocketFactory.cs index 6e15db3..979dc2d 100644 --- a/Assets/Infrastructure/Network/WebSocket/WebSocketFactory.cs +++ b/Assets/Infrastructure/Network/WebSocket/WebSocketFactory.cs @@ -34,6 +34,10 @@ public static INativeWebSocket CreateWebSocket() Debug.Log($"[WebSocketFactory] 데스크톱 플랫폼 ({Application.platform}): DesktopWebSocket 사용"); return new DesktopWebSocket(); + case RuntimePlatform.WebGLPlayer: + Debug.Log($"[WebSocketFactory] WebGL 플랫폼: WebGLWebSocket 사용"); + return new WebGLWebSocket(); + default: Debug.LogWarning($"[WebSocketFactory] 지원되지 않는 플랫폼 ({Application.platform}): DesktopWebSocket 사용"); return new DesktopWebSocket(); @@ -51,6 +55,8 @@ public static INativeWebSocket CreateWebSocket(WebSocketType type) return new DesktopWebSocket(); case WebSocketType.Mobile: return new MobileWebSocket(); + case WebSocketType.WebGL: + return new WebGLWebSocket(); default: return CreateWebSocket(); } @@ -63,7 +69,8 @@ public enum WebSocketType { Auto, Desktop, - Mobile + Mobile, + WebGL } } } \ No newline at end of file diff --git a/Assets/Infrastructure/Network/WebSocket/WebSocketManager.cs b/Assets/Infrastructure/Network/WebSocket/WebSocketManager.cs index 68bb660..c976c4f 100644 --- a/Assets/Infrastructure/Network/WebSocket/WebSocketManager.cs +++ b/Assets/Infrastructure/Network/WebSocket/WebSocketManager.cs @@ -1,11 +1,13 @@ using System; using System.Text; using System.Threading; +using System.Collections.Generic; using UnityEngine; using Cysharp.Threading.Tasks; using ProjectVG.Infrastructure.Network.Configs; using ProjectVG.Infrastructure.Network.DTOs.Chat; using ProjectVG.Domain.Chat.Model; +using ProjectVG.Infrastructure.Auth; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -22,21 +24,22 @@ public class WebSocketManager : Singleton private bool _isConnected = false; private bool _isConnecting = false; private int _reconnectAttempts = 0; - private string _sessionId; private bool _autoReconnect = true; private float _reconnectDelay = 5f; private int _maxReconnectAttempts = 10; private float _maxReconnectDelay = 60f; private bool _useExponentialBackoff = true; private bool _isShutdown = false; - - // 순환 의존성 해결: 직접 참조 대신 이벤트 사용 - public event Action OnSessionMessageReceived; - // 새로운 설계: 세션 연결/해제 이벤트 - public event Action OnSessionConnected; // 세션 ID와 함께 연결 완료 - public event Action OnSessionDisconnected; // 세션 연결 해제 + private TokenManager _tokenManager; + private TokenRefreshService _tokenRefreshService; + // 요청 추적을 위한 딕셔너리 + private Dictionary> _responseTracker = new Dictionary>(); + + // 메시지 이벤트 + public event Action OnMessageReceived; + public event Action OnConnected; public event Action OnDisconnected; public event Action OnError; @@ -44,7 +47,6 @@ public class WebSocketManager : Singleton public bool IsConnected => _isConnected; public bool IsConnecting => _isConnecting; - public string SessionId => _sessionId; public bool AutoReconnect => _autoReconnect; public int ReconnectAttempts => _reconnectAttempts; @@ -53,6 +55,16 @@ public class WebSocketManager : Singleton protected override void Awake() { base.Awake(); + _tokenManager = TokenManager.Instance; + _tokenRefreshService = TokenRefreshService.Instance; + } + + private void Update() + { +#if !UNITY_WEBGL || UNITY_EDITOR + // NativeWebSocket의 메시지 큐 처리 (WebGL 제외) + _nativeWebSocket?.DispatchMessageQueue(); +#endif } private void OnDestroy() @@ -75,6 +87,11 @@ public void Initialize() } _cancellationTokenSource = new CancellationTokenSource(); InitializeNativeWebSocket(); + + // TokenManager 이벤트 구독 - 토큰 변경 시 연결 상태 관리 + _tokenManager.OnTokensUpdated += OnTokensUpdated; + _tokenManager.OnTokensCleared += OnTokensCleared; + #pragma warning disable CS4014 StartConnectionMonitoring(); #pragma warning restore CS4014 @@ -83,7 +100,7 @@ public void Initialize() /// /// 서버와 웹소켓 연결 시도 /// - public async UniTask ConnectAsync(string sessionId = null, CancellationToken cancellationToken = default) + public async UniTask ConnectAsync(CancellationToken cancellationToken = default) { if (_isConnected || _isConnecting) { @@ -91,17 +108,42 @@ public async UniTask ConnectAsync(string sessionId = null, CancellationTok return _isConnected; } + Console.WriteLine($"[WebSocket] ConnectAsync 호출 - 연결 상태: {_isConnected}, 연결 중: {_isConnecting}"); + Console.WriteLine($"[WebSocket] 토큰 상태 - AccessToken: {_tokenManager.GetAccessToken()?.Substring(0, 10) ?? "null"}, RefreshToken: {_tokenManager.GetRefreshToken()?.Substring(0, 10) ?? "null"}"); + + // 로그인 상태 확인 + if (!_tokenManager.HasValidTokens) + { + Debug.LogWarning("[WebSocket] 유효한 Access Token이 없어 연결할 수 없습니다."); + + // Refresh Token으로 Access Token 재요청 시도 + if (_tokenManager.HasRefreshToken && !_tokenManager.IsRefreshTokenExpired()) + { + Debug.Log("[WebSocket] Refresh Token으로 Access Token 갱신 시도"); + var refreshSuccess = await _tokenRefreshService.RefreshAccessTokenAsync(); + if (!refreshSuccess) + { + Debug.LogError("[WebSocket] 토큰 갱신 실패 - 연결 불가"); + return false; + } + } + else + { + Debug.LogError("[WebSocket] 유효한 Refresh Token도 없습니다. 재로그인이 필요합니다."); + return false; + } + } + _isConnecting = true; - _sessionId = sessionId; try { var combinedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationTokenSource.Token).Token; - var wsUrl = GetWebSocketUrl(sessionId); + var wsUrl = GetWebSocketUrlWithToken(); Debug.Log($"[WebSocket] 환경: {NetworkConfig.CurrentEnvironment}"); Debug.Log($"[WebSocket] 서버 주소(환경기반): {NetworkConfig.WebSocketServerAddress}"); - Debug.Log($"[WebSocket] 연결 시도 URL: {wsUrl}"); + Debug.Log($"[WebSocket] 연결 시도 URL: {wsUrl.Substring(0, Math.Min(wsUrl.Length, 100))}..."); var success = await _nativeWebSocket.ConnectAsync(wsUrl, combinedCancellationToken); @@ -126,7 +168,7 @@ public async UniTask ConnectAsync(string sessionId = null, CancellationTok } catch (Exception ex) { - var error = $"WebSocket 연결 중 예외 발생: {ex.Message}\n환경: {NetworkConfig.CurrentEnvironment}\n서버 주소: {NetworkConfig.WebSocketServerAddress}\n요청 URL: {GetWebSocketUrl(sessionId)}"; + var error = $"WebSocket 연결 중 예외 발생: {ex.Message}\n환경: {NetworkConfig.CurrentEnvironment}\n서버 주소: {NetworkConfig.WebSocketServerAddress}"; Debug.LogError($"[WebSocket] {error}"); OnError?.Invoke(error); return false; @@ -185,6 +227,16 @@ public void Shutdown() } _isShutdown = true; + // 리소스 정리 + _responseTracker?.Clear(); + + // 이벤트 구독 해제 + if (_tokenManager != null) + { + _tokenManager.OnTokensUpdated -= OnTokensUpdated; + _tokenManager.OnTokensCleared -= OnTokensCleared; + } + _autoReconnect = false; DisconnectAsync().Forget(); @@ -211,13 +263,14 @@ private void InitializeNativeWebSocket() _nativeWebSocket.OnMessageReceived += OnNativeMessageReceived; } - private string GetWebSocketUrl(string sessionId = null) + private string GetWebSocketUrlWithToken() { string baseUrl = NetworkConfig.GetWebSocketUrl(); + string accessToken = _tokenManager.GetAccessToken(); - if (!string.IsNullOrEmpty(sessionId)) + if (!string.IsNullOrEmpty(accessToken)) { - return $"{baseUrl}?sessionId={sessionId}"; + return $"{baseUrl}?token={accessToken}"; } return baseUrl; @@ -243,7 +296,7 @@ private async UniTaskVoid TryReconnectAsync() if (!_isConnected) { - await ConnectAsync(_sessionId); + await ConnectAsync(); } } @@ -254,9 +307,9 @@ private async UniTaskVoid StartConnectionMonitoring() { await UniTask.Delay(TimeSpan.FromSeconds(30), cancellationToken: token); - if (!_isConnected && !_isConnecting && _autoReconnect && _reconnectAttempts < _maxReconnectAttempts) + if (!_isConnected && !_isConnecting && _autoReconnect && _reconnectAttempts < _maxReconnectAttempts && _tokenManager.HasValidTokens) { - await ConnectAsync(_sessionId); + await ConnectAsync(); } } } @@ -276,15 +329,7 @@ private void OnNativeDisconnected() _isConnected = false; _isConnecting = false; - string previousSessionId = _sessionId; - _sessionId = null; - - Debug.LogWarning("[WebSocket] 세션이 끊어졌습니다. 재연결을 시도합니다."); - - if (!string.IsNullOrEmpty(previousSessionId)) - { - OnSessionDisconnected?.Invoke(); - } + Debug.LogWarning("[WebSocket] 연결이 끊어졌습니다. 재연결을 시도합니다."); OnDisconnected?.Invoke(); @@ -392,82 +437,116 @@ private void ProcessMessage(string message) { try { - var jsonObject = JObject.Parse(message); - string messageType = jsonObject["type"]?.ToString(); - JToken dataToken = jsonObject["data"]; - - switch (messageType) + var response = JsonConvert.DeserializeObject(message); + if (response?.Data == null) + { + Debug.LogWarning($"[WebSocket] 유효하지 않은 메시지 구조: {message.Substring(0, Math.Min(message.Length, 100))}"); + return; + } + + switch (response.Type) { - case "session": - ProcessSessionMessage(dataToken.ToString(Formatting.None)); - break; case "chat": - ProcessChatMessage(dataToken.ToString(Formatting.None)); + ProcessChatMessage(response.Data); break; default: - Debug.LogWarning($"[WebSocket] 알 수 없는 메시지 타입: {messageType}"); + Debug.Log($"[WebSocket] 알 수 없는 메시지 타입: {response.Type}"); + OnMessageReceived?.Invoke(message); break; } } catch (Exception ex) { Debug.LogError($"[WebSocket] 메시지 처리 중 오류: {ex.Message}"); + Debug.LogError($"[WebSocket] 원시 메시지: {message}"); } } - private void ProcessSessionMessage(string data) + private void ProcessChatMessage(ChatData chatData) { try { - Debug.Log($"[WebSocket] 세션 메시지 수신: {data?.Substring(0, Math.Min(50, data?.Length ?? 0))}..."); - - var jsonObject = JObject.Parse(data); - string sessionId = jsonObject["session_id"]?.ToString(); + // 요청 ID로 응답 추적 + TrackResponse(chatData); - if (!string.IsNullOrEmpty(sessionId)) + // ChatMessage로 변환하여 이벤트 발생 + var chatMessage = ChatMessage.FromChatData(chatData); + if (chatMessage == null) { - _sessionId = sessionId; - Debug.Log($"[WebSocket] 세션 연결 완료: {sessionId}"); - OnSessionConnected?.Invoke(sessionId); + Debug.LogError("[WebSocket] ChatMessage 변환 실패"); + return; } - OnSessionMessageReceived?.Invoke(data); + OnChatMessageReceived?.Invoke(chatMessage); - Debug.Log($"[WebSocket] 세션 메시지 처리 완료"); + Debug.Log($"[WebSocket] 채팅 메시지 처리: RequestId={chatData.RequestId}, Order={chatData.Order}, Text={chatData.Text?.Substring(0, Math.Min(chatData.Text.Length, 50))}..."); } catch (Exception ex) { - Debug.LogError($"[WebSocket] 세션 메시지 처리 중 오류: {ex.Message}"); + Debug.LogError($"[WebSocket] 새 채팅 메시지 처리 중 오류: {ex.Message}"); } } - - private void ProcessChatMessage(string data) + + private void TrackResponse(ChatData chatData) { - try + string requestId = chatData.RequestId; + + if (string.IsNullOrEmpty(requestId)) { - var chatResponse = JsonConvert.DeserializeObject(data); - if (chatResponse == null) - { - Debug.LogError("[WebSocket] ChatResponse 파싱 실패"); - return; - } - - var chatMessage = ChatMessage.FromChatResponse(chatResponse); - if (chatMessage == null) - { - Debug.LogError("[WebSocket] ChatMessage 변환 실패"); - return; - } - - OnChatMessageReceived?.Invoke(chatMessage); + return; } - catch (Exception ex) + + if (!_responseTracker.ContainsKey(requestId)) + { + _responseTracker[requestId] = new List(); + } + + _responseTracker[requestId].Add(chatData); + + // order 필드를 사용하여 메시지 순서 관리 + _responseTracker[requestId].Sort((a, b) => a.Order.CompareTo(b.Order)); + + // 임시: 단일 응답으로 간주하고 바로 완료 처리 + // 실제로는 서버에서 완료 신호를 보내거나 타임아웃 로직이 필요 + OnRequestComplete(requestId, _responseTracker[requestId]); + _responseTracker.Remove(requestId); + } + + private void OnRequestComplete(string requestId, List responses) + { + Debug.Log($"[WebSocket] 요청 완료: RequestId={requestId}, 응답 수={responses.Count}"); + + // 필요시 완료된 요청에 대한 추가 처리 가능 + // 예: 모든 응답을 합쳐서 하나의 메시지로 처리하거나 + // UI에 요청 완료 상태를 표시하는 등 + } + + /// + /// 토큰 업데이트 이벤트 핸들러 - 로그인 완료 시 자동 연결 + /// + private void OnTokensUpdated(ProjectVG.Infrastructure.Auth.Models.TokenSet tokenSet) + { + Debug.Log("[WebSocket] 토큰이 업데이트되었습니다. 연결을 시도합니다."); + + // 로그인 완료 시 WebSocket 자동 연결 + if (!_isConnected && !_isConnecting) { - Debug.LogError($"[WebSocket] 채팅 메시지 처리 중 오류: {ex.Message}"); - Debug.LogError($"[WebSocket] 원시 데이터: {data}"); + ConnectAsync().Forget(); } } + /// + /// 토큰 클리어 이벤트 핸들러 - 로그아웃 시 연결 해제 + /// + private void OnTokensCleared() + { + Debug.Log("[WebSocket] 토큰이 클리어되었습니다. 연결을 해제합니다."); + + // 로그아웃 시 WebSocket 연결 해제 + _autoReconnect = false; + DisconnectAsync().Forget(); + } + #endregion } } \ No newline at end of file diff --git a/Assets/Resources/ServerOAuth2Config.asset b/Assets/Resources/ServerOAuth2Config.asset new file mode 100644 index 0000000..9fb104b --- /dev/null +++ b/Assets/Resources/ServerOAuth2Config.asset @@ -0,0 +1,25 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5a7fa5cf92f687c4aa7ac92837c95546, type: 3} + m_Name: ServerOAuth2Config + m_EditorClassIdentifier: + clientId: test-client-id + scope: openid profile email + webGLRedirectUri: http://localhost:3000/auth/callback + androidRedirectUri: com.yourgame://auth/callback + iosRedirectUri: com.yourgame://auth/callback + windowsRedirectUri: http://localhost:3000/auth/callback + macosRedirectUri: http://localhost:3000/auth/callback + pkceCodeVerifierLength: 64 + stateLength: 16 + timeoutSeconds: 300 + showDebugInfo: 0 diff --git a/Assets/Domain/Character/Model/Model.fadeMotionList.asset.meta b/Assets/Resources/ServerOAuth2Config.asset.meta similarity index 79% rename from Assets/Domain/Character/Model/Model.fadeMotionList.asset.meta rename to Assets/Resources/ServerOAuth2Config.asset.meta index 9b3ec5d..e42006e 100644 --- a/Assets/Domain/Character/Model/Model.fadeMotionList.asset.meta +++ b/Assets/Resources/ServerOAuth2Config.asset.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: b1083908ab87d904a95806ad80fc75eb +guid: e3e96e5220c979744a485156e5e314cd NativeFormatImporter: externalObjects: {} mainObjectFileID: 11400000 diff --git a/Assets/Settings/Build Profiles.meta b/Assets/Settings/Build Profiles.meta new file mode 100644 index 0000000..86f3296 --- /dev/null +++ b/Assets/Settings/Build Profiles.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e905b4512c3fa4b42be0930289cbc2f9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Settings/Build Profiles/New Windows Profile.asset b/Assets/Settings/Build Profiles/New Windows Profile.asset new file mode 100644 index 0000000..45b373a --- /dev/null +++ b/Assets/Settings/Build Profiles/New Windows Profile.asset @@ -0,0 +1,48 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 15003, guid: 0000000000000000e000000000000000, type: 0} + m_Name: New Windows Profile + m_EditorClassIdentifier: + m_AssetVersion: 1 + m_BuildTarget: 19 + m_Subtarget: 2 + m_PlatformId: 4e3c793746204150860bf175a9a41a05 + m_PlatformBuildProfile: + rid: 2171463563232149504 + m_OverrideGlobalSceneList: 0 + m_Scenes: [] + m_ScriptingDefines: [] + m_PlayerSettingsYaml: + m_Settings: [] + references: + version: 2 + RefIds: + - rid: 2171463563232149504 + type: {class: WindowsPlatformSettings, ns: UnityEditor.WindowsStandalone, asm: UnityEditor.WindowsStandalone.Extensions} + data: + m_Development: 0 + m_ConnectProfiler: 0 + m_BuildWithDeepProfilingSupport: 0 + m_AllowDebugging: 0 + m_WaitForManagedDebugger: 0 + m_ManagedDebuggerFixedPort: 0 + m_ExplicitNullChecks: 0 + m_ExplicitDivideByZeroChecks: 0 + m_ExplicitArrayBoundsChecks: 0 + m_CompressionType: 0 + m_InstallInBuildFolder: 0 + m_WindowsBuildAndRunDeployTarget: 0 + m_Architecture: 0 + m_CreateSolution: 0 + m_CopyPDBFiles: 0 + m_WindowsDevicePortalAddress: + m_WindowsDevicePortalUsername: diff --git a/Assets/Settings/Build Profiles/New Windows Profile.asset.meta b/Assets/Settings/Build Profiles/New Windows Profile.asset.meta new file mode 100644 index 0000000..bc6784e --- /dev/null +++ b/Assets/Settings/Build Profiles/New Windows Profile.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6538fac070ee98b459d9d05c034f0aa5 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/WebSocketApiTest.cs b/Assets/Tests/WebSocketApiTest.cs new file mode 100644 index 0000000..39659fb --- /dev/null +++ b/Assets/Tests/WebSocketApiTest.cs @@ -0,0 +1,103 @@ +using System; +using UnityEngine; +using Newtonsoft.Json; +using ProjectVG.Infrastructure.Network.DTOs.Chat; +using ProjectVG.Domain.Chat.Model; + +namespace ProjectVG.Tests +{ + /// + /// 새로운 WebSocket API 스펙 테스트 클래스 + /// + public class WebSocketApiTest : MonoBehaviour + { + void Start() + { + TestNewApiMessageParsing(); + TestMultipleActionsAndEmotion(); + } + + /// + /// 새로운 API 메시지 구조 테스트 + /// + private void TestNewApiMessageParsing() + { + Debug.Log("[WebSocketApiTest] === 새로운 API 메시지 파싱 테스트 ==="); + + // 새로운 API 형식의 샘플 메시지 + string newApiMessage = @"{ + ""type"": ""chat"", + ""message_type"": ""json"", + ""data"": { + ""text"": ""안녕하세요! 반가워요!"", + ""emotion"": ""happy"", + ""actions"": [""clapping"", ""jumping""], + ""order"": 0, + ""request_id"": ""550e8400-e29b-41d4-a716-446655440000"", + ""timestamp"": ""2025-01-01T00:00:00.000Z"", + ""audio_data"": ""UklGRnoGAABXQVZFZm10IBAAAA"", + ""audio_format"": ""wav"", + ""audio_length"": 3.5 + } + }"; + + try + { + var response = JsonConvert.DeserializeObject(newApiMessage); + if (response?.Data != null) + { + Debug.Log($"[WebSocketApiTest] ✓ 새 API 파싱 성공"); + Debug.Log($"[WebSocketApiTest] Text: {response.Data.Text}"); + Debug.Log($"[WebSocketApiTest] Emotion: {response.Data.Emotion}"); + Debug.Log($"[WebSocketApiTest] Actions: [{string.Join(", ", response.Data.Actions ?? new string[0])}]"); + Debug.Log($"[WebSocketApiTest] RequestId: {response.Data.RequestId}"); + Debug.Log($"[WebSocketApiTest] Order: {response.Data.Order}"); + + // ChatMessage로 변환 테스트 + var chatMessage = ChatMessage.FromChatData(response.Data); + Debug.Log($"[WebSocketApiTest] ✓ ChatMessage 변환 성공"); + Debug.Log($"[WebSocketApiTest] ChatMessage.Emotion: {chatMessage.Emotion}"); + Debug.Log($"[WebSocketApiTest] ChatMessage.RequestId: {chatMessage.RequestId}"); + Debug.Log($"[WebSocketApiTest] ChatMessage.HasMultipleActions: {chatMessage.HasMultipleActions()}"); + Debug.Log($"[WebSocketApiTest] ChatMessage.HasEmotionData: {chatMessage.HasEmotionData()}"); + } + else + { + Debug.LogError("[WebSocketApiTest] ✗ 새 API 파싱 실패: Data가 null"); + } + } + catch (Exception ex) + { + Debug.LogError($"[WebSocketApiTest] ✗ 새 API 파싱 중 오류: {ex.Message}"); + } + } + + /// + /// 복수 액션 및 감정 처리 테스트 + /// + private void TestMultipleActionsAndEmotion() + { + Debug.Log("[WebSocketApiTest] === 복수 액션 및 감정 처리 테스트 ==="); + + try + { + // 다양한 액션과 감정 조합 테스트 + var testActions = new string[] { "clapping", "jumping", "waving", "idle" }; + var actionData = new CharacterActionData(testActions, "excited"); + + Debug.Log($"[WebSocketApiTest] ✓ CharacterActionData 생성 성공"); + Debug.Log($"[WebSocketApiTest] Primary Action: {actionData.ActionType}"); + Debug.Log($"[WebSocketApiTest] All Actions: [{string.Join(", ", actionData.GetActionSequence())}]"); + Debug.Log($"[WebSocketApiTest] Emotion: {actionData.Emotion}"); + Debug.Log($"[WebSocketApiTest] HasMultipleActions: {actionData.HasMultipleActions()}"); + Debug.Log($"[WebSocketApiTest] HasEmotion: {actionData.HasEmotion()}"); + + Debug.Log("[WebSocketApiTest] === 모든 테스트 완료 ==="); + } + catch (Exception ex) + { + Debug.LogError($"[WebSocketApiTest] ✗ 액션/감정 테스트 중 오류: {ex.Message}"); + } + } + } +} \ No newline at end of file diff --git a/Assets/Tests/WebSocketApiTest.cs.meta b/Assets/Tests/WebSocketApiTest.cs.meta new file mode 100644 index 0000000..5d95a41 --- /dev/null +++ b/Assets/Tests/WebSocketApiTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 239fed6d5d1e4a64a917b6fc1445b91d \ No newline at end of file diff --git a/Packages/manifest.json b/Packages/manifest.json index 1384ca0..2596a65 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -1,9 +1,11 @@ { "dependencies": { "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask", + "com.endel.nativewebsocket": "https://github.com/endel/NativeWebSocket.git#upm", "com.unity.adaptiveperformance.google.android": "5.1.5", "com.unity.adaptiveperformance.samsung.android": "5.1.0", "com.unity.collab-proxy": "2.8.2", + "com.unity.connect.share": "4.2.3", "com.unity.feature.2d": "2.0.1", "com.unity.feature.mobile": "1.0.0", "com.unity.ide.rider": "3.0.36", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 5ec4f8e..b8f13c3 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -7,6 +7,13 @@ "dependencies": {}, "hash": "f213ff497e4ff462a77319cf677cf20cc0860ca9" }, + "com.endel.nativewebsocket": { + "version": "https://github.com/endel/NativeWebSocket.git#upm", + "depth": 0, + "source": "git", + "dependencies": {}, + "hash": "1d8b49b3fee41c09a98141f1f1a5e4db47e14229" + }, "com.unity.2d.animation": { "version": "10.2.1", "depth": 1, @@ -160,6 +167,23 @@ }, "url": "https://packages.unity.com" }, + "com.unity.connect.share": { + "version": "4.2.3", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.editorcoroutines": "1.0.0", + "com.unity.settings-manager": "1.0.2" + }, + "url": "https://packages.unity.com" + }, + "com.unity.editorcoroutines": { + "version": "1.0.0", + "depth": 1, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, "com.unity.ext.nunit": { "version": "2.0.5", "depth": 1, @@ -320,6 +344,13 @@ "dependencies": {}, "url": "https://packages.unity.com" }, + "com.unity.settings-manager": { + "version": "2.1.0", + "depth": 1, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, "com.unity.shadergraph": { "version": "17.0.4", "depth": 1, diff --git a/ProjectSettings/EditorBuildSettings.asset b/ProjectSettings/EditorBuildSettings.asset index aae99c7..768d44a 100644 --- a/ProjectSettings/EditorBuildSettings.asset +++ b/ProjectSettings/EditorBuildSettings.asset @@ -8,9 +8,6 @@ EditorBuildSettings: - enabled: 1 path: Assets/App/Scenes/MainScene.unity guid: 0845d716b4db3d745a759f81d547dea6 - - enabled: 1 - path: Assets/App/Scenes/StartSence.unity - guid: 45dd910a68ba96943b623bd5a5e542ce m_configObjects: com.unity.adaptiveperformance.google.android.provider_settings: {fileID: 11400000, guid: e83dfb33c7f04304db0a9f4b231aa925, type: 2} com.unity.adaptiveperformance.loader_settings: {fileID: 11400000, guid: 5daa2912411094b4abb351f7b36c0b50, type: 2} diff --git a/ProjectSettings/GraphicsSettings.asset b/ProjectSettings/GraphicsSettings.asset index 2e05892..c1d387d 100644 --- a/ProjectSettings/GraphicsSettings.asset +++ b/ProjectSettings/GraphicsSettings.asset @@ -59,7 +59,7 @@ GraphicsSettings: m_AlbedoSwatchInfos: [] m_RenderPipelineGlobalSettingsMap: UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 93b439a37f63240aca3dd4e01d978a9f, type: 2} - m_LightsUseLinearIntensity: 0 + m_LightsUseLinearIntensity: 1 m_LightsUseColorTemperature: 1 m_LogWhenShaderIsCompiled: 0 m_LightProbeOutsideHullStrategy: 0 diff --git a/ProjectSettings/Packages/com.unity.connect.share/Settings.json b/ProjectSettings/Packages/com.unity.connect.share/Settings.json new file mode 100644 index 0000000..c4f7040 --- /dev/null +++ b/ProjectSettings/Packages/com.unity.connect.share/Settings.json @@ -0,0 +1,26 @@ +{ + "m_Dictionary": { + "m_DictionaryValues": [ + { + "type": "System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", + "key": "firstTime", + "value": "{\"m_Value\":false}" + }, + { + "type": "System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", + "key": "autoPublish", + "value": "{\"m_Value\":true}" + }, + { + "type": "System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", + "key": "createDefaultBuildsFolder", + "value": "{\"m_Value\":true}" + }, + { + "type": "System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089", + "key": "buildOutputDirList", + "value": "{\"m_Value\":\"C:/Users/imdls/Documents/Project/ProjectVG_Client/Build/Web;;;;;;;;;\"}" + } + ] + } +} \ No newline at end of file diff --git a/ProjectSettings/ProjectSettings.asset b/ProjectSettings/ProjectSettings.asset index 1966f32..600ed35 100644 --- a/ProjectSettings/ProjectSettings.asset +++ b/ProjectSettings/ProjectSettings.asset @@ -4,7 +4,7 @@ PlayerSettings: m_ObjectHideFlags: 0 serializedVersion: 28 - productGUID: c788582c51c747e4cb5346503a1c7457 + productGUID: d4a8d6b8bc019a34d8c8e808530b78de AndroidProfiler: 0 AndroidFilterTouchesWhenObscured: 0 AndroidEnableSustainedPerformanceMode: 0 @@ -13,7 +13,7 @@ PlayerSettings: useOnDemandResources: 0 accelerometerFrequency: 60 companyName: DefaultCompany - productName: ProjectVG_Client + productName: ProjectVG-Client defaultCursor: {fileID: 0} cursorHotspot: {x: 0, y: 0} m_SplashScreenBackgroundColor: {r: 0.13725491, g: 0.12156863, b: 0.1254902, a: 1} @@ -47,7 +47,7 @@ PlayerSettings: defaultScreenWidthWeb: 960 defaultScreenHeightWeb: 600 m_StereoRenderingPath: 0 - m_ActiveColorSpace: 0 + m_ActiveColorSpace: 1 unsupportedMSAAFallback: 0 m_SpriteBatchMaxVertexCount: 65535 m_SpriteBatchVertexThreshold: 300 @@ -55,7 +55,7 @@ PlayerSettings: mipStripping: 0 numberOfMipsStripped: 0 numberOfMipsStrippedPerMipmapLimitGroup: {} - m_StackTraceTypes: 020000000200000002000000020000000200000001000000 + m_StackTraceTypes: 010000000100000001000000010000000100000001000000 iosShowActivityIndicatorOnLoading: -1 androidShowActivityIndicatorOnLoading: -1 iosUseCustomAppBackgroundBehavior: 0 @@ -70,7 +70,7 @@ PlayerSettings: androidStartInFullscreen: 1 androidRenderOutsideSafeArea: 1 androidUseSwappy: 1 - androidBlitType: 2 + androidBlitType: 0 androidResizeableActivity: 1 androidDefaultWindowWidth: 1920 androidDefaultWindowHeight: 1080 @@ -82,7 +82,7 @@ PlayerSettings: androidApplicationEntry: 2 defaultIsNativeResolution: 1 macRetinaSupport: 1 - runInBackground: 1 + runInBackground: 0 muteOtherAudioSources: 0 Prepare IOS For Recording: 0 Force IOS Speakers When Recording: 0 @@ -90,15 +90,15 @@ PlayerSettings: hideHomeButton: 0 submitAnalytics: 1 usePlayerLog: 1 - dedicatedServerOptimizations: 0 + dedicatedServerOptimizations: 1 bakeCollisionMeshes: 0 forceSingleInstance: 0 useFlipModelSwapchain: 1 resizableWindow: 0 useMacAppStoreValidation: 0 macAppStoreCategory: public.app-category.games - gpuSkinning: 0 - meshDeformation: 0 + gpuSkinning: 1 + meshDeformation: 1 xboxPIXTextureCapture: 0 xboxEnableAvatar: 0 xboxEnableKinect: 0 @@ -140,12 +140,9 @@ PlayerSettings: loadStoreDebugModeEnabled: 0 visionOSBundleVersion: 1.0 tvOSBundleVersion: 1.0 - bundleVersion: 1.0.2 + bundleVersion: 1.0 preloadedAssets: - {fileID: -944628639613478452, guid: 2bcd2660ca9b64942af0de543d8d7100, type: 3} - - {fileID: -5189789383737813234, guid: 5daa2912411094b4abb351f7b36c0b50, type: 2} - - {fileID: 11400000, guid: e83dfb33c7f04304db0a9f4b231aa925, type: 2} - - {fileID: 11400000, guid: 08834a4b1dd61094587329f7b37c3aae, type: 2} metroInputSource: 0 wsaTransparentSwapchain: 0 m_HolographicPauseOnTrackingLoss: 1 @@ -167,11 +164,7 @@ PlayerSettings: androidMaxAspectRatio: 2.4 androidMinAspectRatio: 1 applicationIdentifier: - Android: com.DefaultCompany.com.unity.template.mobile2D - Lumin: com.DefaultCompany.com.unity.template.mobile2D - Standalone: com.DefaultCompany.com.unity.template.mobile2D - iPhone: com.DefaultCompany.com.unity.template.mobile2D - tvOS: com.DefaultCompany.com.unity.template.mobile2D + Standalone: com.DefaultCompany.2D-URP buildNumber: Standalone: 0 VisionOS: 0 @@ -258,8 +251,8 @@ PlayerSettings: iOSAutomaticallyDetectAndAddCapabilities: 1 appleEnableProMotion: 0 shaderPrecisionModel: 0 - clonedFromGUID: f918f204a65eb664ba9e5b39d533ff8e - templatePackageId: com.unity.template.mobile2d@6.0.0 + clonedFromGUID: c19f32bac17ee4170b3bf8a6a0333fb9 + templatePackageId: com.unity.template.universal-2d@5.1.0 templateDefaultScene: Assets/Scenes/SampleScene.unity useCustomMainManifest: 0 useCustomLauncherManifest: 0 @@ -390,41 +383,9 @@ PlayerSettings: m_SubKind: m_BuildTargetBatching: [] m_BuildTargetShaderSettings: [] - m_BuildTargetGraphicsJobs: - - m_BuildTarget: MacStandaloneSupport - m_GraphicsJobs: 0 - - m_BuildTarget: Switch - m_GraphicsJobs: 0 - - m_BuildTarget: MetroSupport - m_GraphicsJobs: 0 - - m_BuildTarget: AppleTVSupport - m_GraphicsJobs: 0 - - m_BuildTarget: BJMSupport - m_GraphicsJobs: 0 - - m_BuildTarget: LinuxStandaloneSupport - m_GraphicsJobs: 0 - - m_BuildTarget: PS4Player - m_GraphicsJobs: 0 - - m_BuildTarget: iOSSupport - m_GraphicsJobs: 0 - - m_BuildTarget: WindowsStandaloneSupport - m_GraphicsJobs: 0 - - m_BuildTarget: XboxOnePlayer - m_GraphicsJobs: 0 - - m_BuildTarget: LuminSupport - m_GraphicsJobs: 0 - - m_BuildTarget: AndroidPlayer - m_GraphicsJobs: 0 - - m_BuildTarget: WebGLSupport - m_GraphicsJobs: 0 + m_BuildTargetGraphicsJobs: [] m_BuildTargetGraphicsJobMode: [] - m_BuildTargetGraphicsAPIs: - - m_BuildTarget: AndroidPlayer - m_APIs: 150000000b000000 - m_Automatic: 1 - - m_BuildTarget: iOSSupport - m_APIs: 10000000 - m_Automatic: 1 + m_BuildTargetGraphicsAPIs: [] m_BuildTargetVRSettings: [] m_DefaultShaderChunkSizeInMB: 16 m_DefaultShaderChunkCount: 0 @@ -444,10 +405,7 @@ PlayerSettings: m_BuildTargetDefaultTextureCompressionFormat: - serializedVersion: 3 m_BuildTarget: Android - m_Formats: 03000000 - - serializedVersion: 3 - m_BuildTarget: WebGL - m_Formats: 03000000 + m_Formats: 01000000 playModeTestRunnerEnabled: 0 runPlayModeTestAsEditModeTest: 0 actionOnDotNetUnhandledException: 1 @@ -645,7 +603,7 @@ PlayerSettings: ps4GarlicHeapSize: 2048 ps4ProGarlicHeapSize: 2560 playerPrefsMaxSize: 32768 - ps4Passcode: D25bFOIlcWmANChRvUT1ao3Ud48vHsjc + ps4Passcode: frAQBc8Wsa1xVPfvJcrgRYwTiizs2trQ ps4pnSessions: 1 ps4pnPresence: 1 ps4pnFriends: 1 @@ -734,14 +692,14 @@ PlayerSettings: editorAssembliesCompatibilityLevel: 1 m_RenderingPath: 1 m_MobileRenderingPath: 1 - metroPackageName: ProjectVG_Client + metroPackageName: ProjectVG-Client metroPackageVersion: metroCertificatePath: metroCertificatePassword: metroCertificateSubject: metroCertificateIssuer: metroCertificateNotAfter: 0000000000000000 - metroApplicationDescription: ProjectVG_Client + metroApplicationDescription: ProjectVG-Client wsaImages: {} metroTileShortName: metroTileShowName: 0 diff --git a/connectwebgl.zip b/connectwebgl.zip new file mode 100644 index 0000000..564ce64 Binary files /dev/null and b/connectwebgl.zip differ diff --git a/webgl_sharing b/webgl_sharing new file mode 100644 index 0000000..1a43cae --- /dev/null +++ b/webgl_sharing @@ -0,0 +1 @@ +66c249dd-2064-4e8c-b905-8b87e0a0d086 \ No newline at end of file