// enum 변환 방법
itemType = (변환할 Enum)Enum.Parse(typeof(변환할 Enum), 문자열);
// ex
ItemType itemType = (ItemType)Enum.Parse(typeof(ItemType), values[인덱스]);
// 숫자 변환 방법
//숫자 변환:
int.Parse(values[인덱스];
float.Parse(values[인덱스])
일단. IK가 무엇인지 알아야 한다. 유니티 공식문서에 따르면 , 대부분의 애니메이션은 스켈레톤의 조인트 각도를 미리 정해진 값으로 회전하여 만든다. 자식 뼈대 (조인트)는 부모 뼈대 (조인트)의 회전에 따라 변하고, 이런 방식을 순 운동학 (FK)라고 한다.
하지만 이런 방법을 반대로 바라본다면 더욱 유용하다. 오브젝트를 기준으로 뼈대 (조인트) 값을 계산하여 적용하는 것이다. 예를 들어 플레이어가 다른 오브젝트를 건드리거나, 울퉁불퉁한 표면 위에 캐릭터의 두 발이 자연스럽게 밀착해 있도록 할 수 있다. 이러한 접근법을 역운동학 (IK)라고 한다.
유니티 공식문서에 있는 IK 관련 이미지
IK를 적용하기 위해서는 아바타의 타입이 휴머노이드여야 한다.
📝사전준비
1. 플레이어의 Animator의 Layer의 설정에서 IK Pass 옵션을 체크해준다
2. 플레이어가 걸어갈 발판/경사로 등의 레이어를 Walkable로 지정해 준다
📝구현
PlayerAnimator.cs
public class PlayerAnimator : MonoBehaviour
{
[Header("===Animator===")]
[SerializeField] private Animator animator;
[Range(0, 1f)]
public float distanceToGround;
public LayerMask layerMask;
void Start()
{
animator = GetComponent<Animator>();
}
private void OnAnimatorIK(int layerIndex)
{
}
}
■ 공식문서에서는, IK 목표의 변환 가중치를 설정한다. ( 0 = IK전 원래 애니메이션에서 1 = 목표에서 )
■ 즉 가중치를 설정하여 원래 애니메이션과 , IK 조정 사이의 혼합정도를 결정한다.
□animator.SetIKRotationWeight()
■ SetIKPositionWeight과 같이 회전에 대한 가중치를 설정한다.
🔖 가중치 가중치가 0일 때 : 원래 애니메이션이 100% 적용된다. IK는 전혀 영향을 주지 않는다 가중치가 1일 때 : 조정이 100% 적용된다. 원래 애니메이션은 무시된다 가중치가 0 ~ 1 사이일 때 : 원래 애니메이션과 IK 조정값이 해당 비율로 혼합된다
🔖 여기서 AvaterIkGoal은 Enum으로, LeftFoot과 RightFoot은 Animator에 적용된 아바타의 Left Leg의 Foot과 , Right Leg의 Foot를 의미한다.
// left foot
RaycastHit hit;
Ray ray = new Ray(animator.GetIKPosition(AvatarIKGoal.LeftFoot) + Vector3.up, Vector3.down);
Debug.DrawRay(ray.origin, ray.direction * (distanceToGround + 1f), Color.red);
□ Ray 생성
■ 시작지점 : 아바타의 LeftFoot의 위치에서 1f 올라간 곳,
■ 방향 : 시작지점에서 아래쪽 방향
□ DrawRay로 레이를 그리면, 주황색 동그라미에서 시작하여 아래쪽으로 ray를 쏘고 있는 것을 확인할 수 있다.
if (Physics.Raycast(ray, out hit, distanceToGround + 1f , LayerManager.Instance.IgnorePlayerLayer))
{
if(hit.transform.gameObject.layer == LayerManager.Instance.WalkableLayerInt)
{
// 추가예정
// 플레이어 발 위치 , 회전 적용
}
}
□ Raycast
■플레이어를 제외한 오브젝트를 감지해야 하기 때문에 PlayerLayer을 제외한 레이어만 검사
■ 레이캐스트가 성공적으로 충돌체를 감지하면 , if문 실행
□ 해당 충돌체의 레이어가 'Walkable'이면?
■플레이어 발의 위치와 회전을 변경
(+) 레이어에 관해서
layer을 검사할 땐 layerMask의 인덱스 값으로 검사해야 하기 때문에 GetMask를 사용해 준다
해당코드는 어디에 있던지 상관없다
[SerializeField] private LayerMask ignorePlayerLayer; // 플레이어 레이어를 제외한 레이어
[SerializeField] private int walkableLayerInt;
void Start()
{
walkableLayerInt = LayerMask.NameToLayer("Walkable");
}
PlayerLayer을 뺀 LayerMask 정보는 인스펙터 창에서 PlayerLayer을 제외한 다른 레이어들을 체크해주면 된다.
0. 오디오를 "듣기"위해서 Main Camera에 AudioListener 컴포넌트를 달아준다
1. 오브젝트에 AudioSource 컴포넌트를 추가한다
■ AudioSource 필드에 오브젝트의 AudioSource 컴포넌트를 할당한다
■ AudioClip필드에 실행할 오디오 클립을 할당한다
2. BGMPlayer.cs
public class BGMPlayer : MonoBehaviour
{
public AudioSource source;
public AudioClip clip;
void Start()
{
source = GetComponent<AudioSource>();
// 1. Play 함수
source.Play();
// 2. PlayOnShot 함수
source.PlayOneShot(clip);
}
}
■ AudioSource.Play()
□ 해당 소스에 할당된 AudioClip을 처음부터 재생
□ 매개변수가 없음 (AudioSource 컴포넌트에 AudioClip이 미리 할당되어야 함)
■ AudioSource.PlayOneShot(AudioClip)
□ 한 번에 여러 오디오를 겹쳐 재생할 수 있음
□ 재생시킬 오디오 clip을 매개변수로 받음
□ 같은 소스에서 여러 효과음을 동시에 재생할 때 유용함
3. 실행
■ 실행하면 오디오가 1회 실행되는 것을 확인할 수 있다
■ 혹시 들리지 않는다면 게임화면의 소리가 음소거되어있는지 확인하자
📝3. 오디오 볼륨 조절 슬라이더
1. Create → Audio Mixer를 클릭하여 오디오믹서를 만든다
2. 우측 Groups에서 (+) 버튼을 눌러 그룹을 만든 뒤 이름을 BGM과 SFX로 지정한다
3. Asset폴더에 생성되어 있는 AudioMixer하위의 Master을 클릭한 후
인스펙터 창의 Volume 위에서 우클릭
→ Expose 'Volume (of Master) to script"를 클릭
■🔖스크립트로 Audio Mixer의 Master / BGM / SFX 그룹에 접근하기 위해서 Expose를 해줘야 한다.
■ 볼륨 옆에 → 화살표가 뜨면 된 것이다
4. AudioSource가 할당되어 있는 오브젝트의 AudioSource → Output에 만든 AudioMixer의 그룹을 할당해 준다
■ BGM을 실행하는 오브젝트의 AudioSource뿐만 아니라 SFX를 실행하는 AudioSource에도 Output을 설정해 준다
5. AudioMixer의 파라미터 설정
■ 해당 그룹에 맞는 파라미터로 이름을 수정해 주면 된다
■ 왼쪽에 흐릿한 글씨로 Volume(of BGM)를 확인한다. BGM이면 AudioSource의 Output이 "BGM"인 경우에 해당한다.
6. AudioSlider.cs
using UnityEngine.Audio;
using UnityEngine.UI;
public class AudioSlider : MonoBehaviour
{
public AudioMixer audioMixer;
public Slider MasterSlider;
public Slider BGMSlier;
public Slider SFXSlider;
private float minVolume = -60f;
private float maxVolume = 10f;
void Start()
{
MasterSlider.onValueChanged.AddListener(SetMasterVolume);
BGMSlier.onValueChanged.AddListener(SetBGMVolume);
SFXSlider.onValueChanged.AddListener(SetSFXVolume);
}
public void SetMasterVolume(float volume)
{
audioMixer.SetFloat("Master", Mathf.Clamp(volume * 100 - 80 , minVolume , maxVolume));
}
public void SetBGMVolume(float volume)
{
audioMixer.SetFloat("BGM", Mathf.Clamp(volume * 100 - 80 , minVolume , maxVolume));
}
public void SetSFXVolume(float volume)
{
audioMixer.SetFloat("SFX", Mathf.Clamp(volume * 100 - 80 , minVolume , maxVolume));
}
}
■ Slider.onValueChanged.AddListener()
□ 슬라이더에서 값 수치가 변경될 경우에 실행됨
□ UI 슬라이더와 오디오 볼륨을 연결할 때 사용
■ AudioMixer.SetFloat(string, float)
□ 첫 번째 매개변수: 믹서에서 정의한 파라미터 이름
□두 번째 매개변수: 설정할 값 (일반적으로 dB 단위)
□ AudioMixer은 -80부터 +20까지 소리를 지원한다.
□ 너무 작거나, 너무 크면 소리가 깨지는 현상이 발생하기 때문에 min과 max값 사이에서 볼륨의 크기를 정한다.
// 원본 타입을 유지한 채 JSON으로 변환
string json = JsonSerialized.ConvertOriginalListToJson(dataArray, type);
// 파일로 저장
JsonSerialized.SaveJsonToFile(json, className[i]);
Debug.Log($"{className[i]} 데이터를 성공적으로 변환했습니다.");
}
■ JsonSerialized 클래스의 ConvertOriginalListToJson() 메서드를 실행 후 json 문자열을 return받는다
■JsonSerialized 클래스의 SaveJsonToFile() 메서드를 실행 후 json 파일을 저장한다.
📝 JsonSerialized.cs
- 전체코드
[SerializeField]
public class ListWrapper<T>
{
public List<T> values;
}
public static class JsonSerialized
{
// C:\Users\[user name]\AppData\LocalLow\[company name]\[product name]
static string savePath = Application.persistentDataPath;
// 원본 타입의 리스트를 변환하는 메서드 (리플렉션 사용)
public static string ConvertOriginalListToJson(object dataArray, Type elementType)
{
// dataArray는 현재 List<T>
// 1. 적절한 ListWrapper<tyoe> 타입 생성
Type wrapperType = typeof(ListWrapper<>).MakeGenericType(elementType);
// 2. 래퍼 인스턴스 생성
object wrapper = Activator.CreateInstance(wrapperType);
// 3. values 필드 가져오기
FieldInfo valuesField = wrapperType.GetField("values");
// 4. dataArray를 values 필드에 할당
// dataArray는 object타입이지만 실제로는 List<T> (매개변수로 List<T>를 넘겼기 때문)
valuesField.SetValue(wrapper, dataArray);
// 5. JsonUtility로 직렬화
return JsonUtility.ToJson(wrapper);
}
public static void SaveJsonToFile(string json, string saveFileName)
{
try
{
string path = Path.Combine(savePath, saveFileName);
File.WriteAllText(path, json);
Debug.Log($"{saveFileName}이 저장되었습니다. 경로: {path}");
}
catch (Exception ex)
{
Debug.LogError($"파일 저장 중 오류 발생: {ex.Message}");
}
}
}
- 상세코드
🔖 ConvertOriginalListToJson() 메서드
// dataArray는 현재 List<T>
// 1. 적절한 ListWrapper<tyoe> 타입 생성
Type wrapperType = typeof(ListWrapper<>).MakeGenericType(elementType);
■ ListWrapper클래스의 제네릭 타입을 위에서 매개변수의 type(현재 클래스의 타입)으로 설정한다.
// 2. 래퍼 인스턴스 생성
object wrapper = Activator.CreateInstance(wrapperType);
■Reflection의 Activator.CreateInstance를 사용해서제네릭 클래스인 ListWrapper를 인스턴스화 한다.
public interface ICsvParsable
{
void Parse(string[] values);
}
📝 Stage.cs - 테스트용 클래스
[System.Serializable]
public class Stage : ICsvParsable
{
[SerializeField] private int hp;
[SerializeField] private string name;
[SerializeField] private List<string> animal;
public void Parse(string[] values)
{
hp = int.Parse(values[0]);
name = values[1];
animal = new List<string>();
string[] temp = values[3].Split('-');
for (int i = 0; i < temp.Length; i++)
{
animal.Add(temp[i]);
}
}
}
■ [System.Serializable]
□ 직렬화 하기 위해서 클래스 위에 추가한다
■ csv를 파싱한 데이터를 필드에 넣기위해서 ICsvParsable 인터페이스를 구현한다
■ Parse(string[] values)
□ 필드에 맞게 변수를 형변환해서 필드를 설정한다
📝 CsvJsonButton.cs
[CustomEditor(typeof(CsvToJsonConverter))]
public class CsvJsonButton : Editor
{
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
CsvToJsonConverter mana = (CsvToJsonConverter)target;
if (GUILayout.Button("Cvs to Json"))
{
mana.CsvConverByName();
}
}
}
public class VideoChatManger : MonoBehaviour
{
[Header("===Data===")]
[SerializeField] private string _appID = "85636c045ba54ceda8cee9c505f35e5b";
[SerializeField] private string _token = "";
[SerializeField] private string _currentChannelName;
[Header("===오브젝트===")]
static public GameObject _videoChatObject; // raw 이미지 프리팹
static public Transform _videoChatLayout; // 프리팹 담길 상위 부모
[Header("===아고라 인스턴스===")]
static public IRtcEngine rtcEngine;
}
□복사한 Agora 프로젝트의 Id를 변수로 할당한다.
private void SetUpVideoRtcEngine()
{
// 아고라 인스턴스 생성
rtcEngine = Agora.Rtc.RtcEngine.CreateAgoraRtcEngine();
// 세팅
RtcEngineContext context = new RtcEngineContext();
context.appId = _appID;
context.channelProfile = CHANNEL_PROFILE_TYPE.CHANNEL_PROFILE_LIVE_BROADCASTING;
context.audioScenario = AUDIO_SCENARIO_TYPE.AUDIO_SCENARIO_DEFAULT;
context.areaCode = AREA_CODE.AREA_CODE_GLOB;
// rtc 엔진 초기화
rtcEngine.Initialize(context);
Debug.Log("아고라 엔진 셋업");
}
□ AppId를 통해서 RTC 엔진을 초기화 세팅한다.
private void SetVideoConfiguration()
{
rtcEngine.EnableAudio();
rtcEngine.EnableVideo();
VideoEncoderConfiguration config = new VideoEncoderConfiguration();
// 화면 영역
config.dimensions = new VideoDimensions(200, 200);
config.frameRate = 15;
config.bitrate = 1000;
rtcEngine.SetVideoEncoderConfiguration(config);
rtcEngine.SetChannelProfile(CHANNEL_PROFILE_TYPE.CHANNEL_PROFILE_COMMUNICATION);
rtcEngine.SetClientRole(CLIENT_ROLE_TYPE.CLIENT_ROLE_BROADCASTER);
Debug.Log("아고라 비디오 셋업");
}
□ 비디오 세팅 메서드
□ EnableVideo();
■ 비디오 모듈을 활성화시키는 함수
■ 채널에 가입하기 전 or 채널에 있는 동안 호출할 수 있다
private void InitEventHandler()
{
UserEventHandler handler = new UserEventHandler(this);
rtcEngine.InitEventHandler(handler);
Debug.Log("아고라 유저 이벤트 핸들러 셋업");
}
internal class UserEventHandler : IRtcEngineEventHandler
{
readonly VideoChatManger _chatManger;
internal UserEventHandler(VideoChatManger chatManger)
{
_chatManger = chatManger;
}
// 채널에 접속 성공시 출력 오버라이딩
public override void OnJoinChannelSuccess(RtcConnection connection, int elapsed)
{
Debug.Log($"You Joined Channel : " + connection.channelId);
}
public override void OnUserOffline(RtcConnection connection, uint remoteUid, USER_OFFLINE_REASON_TYPE reason)
{
// 함수 추가 예정
}
public override void OnUserJoined(RtcConnection connection, uint remoteUid, int elapsed)
{
// 함수 추가 예정
}
// 로컬 비디오 스트림 상태 변경 콜백
public override void OnLocalVideoStateChanged(VIDEO_SOURCE_TYPE source, LOCAL_VIDEO_STREAM_STATE state, LOCAL_VIDEO_STREAM_REASON errorCode)
{
Debug.Log($"로컬 비디오 상태: {state}, 에러 코드: {errorCode}");
switch (state)
{
case LOCAL_VIDEO_STREAM_STATE.LOCAL_VIDEO_STREAM_STATE_CAPTURING:
Debug.Log("카메라 캡처 중 - 카메라가 정상적으로 활성화되었습니다.");
break;
case LOCAL_VIDEO_STREAM_STATE.LOCAL_VIDEO_STREAM_STATE_STOPPED:
Debug.Log("카메라 캡처가 중지되었습니다.");
break;
case LOCAL_VIDEO_STREAM_STATE.LOCAL_VIDEO_STREAM_STATE_FAILED:
// 오류 코드별 상세 처리
if (errorCode == LOCAL_VIDEO_STREAM_REASON.LOCAL_VIDEO_STREAM_REASON_DEVICE_BUSY)
{
Debug.LogError("카메라 장치가 이미 사용 중입니다.");
}
else if (errorCode == LOCAL_VIDEO_STREAM_REASON.LOCAL_VIDEO_STREAM_REASON_DEVICE_NO_PERMISSION)
{
Debug.LogError("카메라 접근 권한이 없습니다.");
}
else
{
Debug.LogError($"카메라 캡처 실패! 상세 오류 코드: {errorCode}");
}
break;
}
}
}
□ 이벤트핸들러 작성
□ IRtcEngineEventHandler를 상속받아야 한다
□ 채널에 접속 성공 시 / 유저가 채널에서 떠날 / 유저가 채널에 입장했을 때 / 로컬 비디오 스트림 상태가 변화 콜백시 출력
- Start()
private void Start()
{
// rtc 엔진 생성, 초기화
SetUpVideoRtcEngine();
// 비디오 셋업
SetVideoConfiguration();
// 이벤트 핸들러 셋업
InitEventHandler();
}
: 각 초기화 함수를 Start에서 실행해 준다
📝 채널 입장
// 해당 채널에 들어오면
public void Join(string channelName)
{
// 현재 채널 이름 저장
_currentChannelName = channelName;
rtcEngine.JoinChannel(_token, channelName, 0, null);
rtcEngine.EnableVideo();
// 채널에 뷰 생성
string _cameraName = PlayerManager.Instnace.nowPlayer.PlayerName;
MakeVideoView(0, channelName);
Debug.Log($"{channelName} 의 채널에 조인 ");
}
□ 채널이름으로 해당 채널에 입장
□ 카메라 뷰 생성
// 뷰 생성
static public void MakeVideoView(uint uid, string channelId = "")
{
VideoSurface videoSurface = MakeImageSurface(uid.ToString());
// null이면 return
if (ReferenceEquals(videoSurface, null))
return;
// 로컬 , 리모트 유저 뷰 설정
if (uid != 0)
{
videoSurface.SetForUser(uid, channelId);
}
else
{
// 로컬일때
videoSurface.SetForUser(uid, channelId, VIDEO_SOURCE_TYPE.VIDEO_SOURCE_CAMERA_PRIMARY);
}
videoSurface.SetEnable(true);
}
// 비디오 서페이스 생성
static public VideoSurface MakeImageSurface(string name)
{
// raw 이미지 오브젝트 생성
GameObject videochat = Instantiate(_videoChatObject);
if (videochat == null)
return null;
// 오브젝트 이름 설정
videochat.name = name;
if (_videoChatLayout != null)
{
// 비디오 레이아웃 밑에 넣기
videochat.transform.SetParent(_videoChatLayout, false);
}
else
{
Debug.Log("Layout is null");
}
videochat.transform.localPosition = Vector3.zero;
var videoSurface = videochat.AddComponent<VideoSurface>();
return videoSurface;
}
□ MakeVideoView()
■로컬 사용자일 때(uid == 0)
□기본적으로 카메라를 사용하게 설정
□ MakeImageSurface()
■ raw 이미지 오브젝트를 생성 후
■ VideoSurface추가
📝 채널 퇴장
// 해당 채널에서 나가면
public void Leave()
{
rtcEngine.DisableVideo();
rtcEngine.LeaveChannel();
// 생성된 뷰 삭제
DestoryAll();
Debug.Log($"{_currentChannelName} 의 채널에서 퇴장 ");
}
□ 채널에서 나가면 현재 생성되어 있는 뷰를 삭제
static public void DestoryAll()
{
List<uint> uids = new List<uint>();
for (int i = 0; i < _videoChatLayout.transform.childCount; i++)
{
uids.Add(uint.Parse(_videoChatLayout.transform.GetChild(i).name));
}
for (int i = 0; i < uids.Count; i++)
{
DestoryVideoView(uids[i]);
}
}
static public void DestoryVideoView(uint id)
{
var obj = FindChild(UiManager.Instnace._canvas.transform, id.ToString()).gameObject;
if (!ReferenceEquals(obj, null))
Destroy(obj);
}
static public Transform FindChild(Transform parent, string name)
{
Transform result = parent.Find(name);
if (result != null)
return result;
// 자식 오브젝트 모두 검색
foreach(Transform child in parent)
{
result = FindChild(child, name);
if (result != null) return result;
}
return null;
}
□ 카메라 layoutGroup하위에 있는 카메라들의 이름을 저장 한 뒤, canvas 하위에서 해당 오브젝트를 찾아 Destory()
: Join() 함수 내부의 MakeVideo() 함수에서 uid를 항상 0으로 넘기고 있기 때문에 , 생성되는 Videosurface 오브젝트의 이름은 항상 0이다
: DestoryAll에서 오브젝트의 이름을 가지고 Destroy()하는 부분에서 오류가 생긴다.
: 다른 유저가 채널에 입장했을 때, 즉 리모트 유저가 채널에 입장했을 때는
그 유저의 고유 아이디를 가지고 VideoView를 만들어야 한다.
- 해결방안
: UserEventHandler 클래스의 UserEventHandler에서 오버라이딩 한 메서드에서 MakeView()를 추가해 준다.
public override void OnUserOffline(RtcConnection connection, uint remoteUid, USER_OFFLINE_REASON_TYPE reason)
{
// 코드 추가
DestoryVideoView(remoteUid);
}
public override void OnUserJoined(RtcConnection connection, uint remoteUid, int elapsed)
{
// 코드 추가
MakeVideoView(remoteUid, _chatManger._currentChannelName);
}
: 이렇게 하게 되면 리모트 유저가 채널에 입장했을 때, 리모트 유저의 고유 아이디로 View를 만들게 된다.