[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 36회차 미션 시작합니다.

04. 배틀로얄 - 17, 18 번을 진행합니다.




이번 시간에는 저번시간에 제작한 SoundClip을 사용하는 SoundData 모델을 제작합니다.
내용 난이도는 EffectData와 크게 다르지 않지만, 다양한 옵션 등으로 인해 코드가 좀 깁니다.





위의 위치에 SoundData Script를 생성하고 작업을 시작합니다.


/// <summary>
/// 사운드 클립을 배열로 소지, 사운드 데이터를 저장하고 로드하고, 프리로딩을 가짐.
/// </summary>
public class SoundData : BaseData
{
    public SoundClip[] soundClips = new SoundClip[0];
    
    private string clipPath = "Sound/";
    private string xmlFilePath = "";
    private string xmlFileName = "soundData.xml";
    private string dataPath = "Data/soundData";
    private static string SOUND = "sound";
    private static string CLIP = "clip";
    
    public SoundData() {}
    
    public void SaveData() {
        using (XmlTextWriter xml = new XmlTextWriter(xmlFilePath + xmlFileName, System.Text.Encoding.Unicode)) {
            xml.WriteStartDocument();
            xml.WriteStartElement(SOUND);
            xml.WriteElementString("length", GetDataCount().ToString());
            xml.WriteWhitespace("\n");
            
            for (int i = 0; i < names.Length; i++) {
                SoundClip clip = soundClips[i];
                xml.WriteStartElement(CLIP);
                xml.WriteElementString("id", i.ToString());
                xml.WriteElementString("name", names[i]);
                xml.WriteElementString("loops", clip.checkTime.Length.ToString());
                xml.WriteElementString("maxvol", clip.maxVolume.ToString());
                xml.WriteElementString("pitch", clip.pitch.ToString());
                xml.WriteElementString("dopplerlevel", clip.dopplerlevel.ToString());
                xml.WriteElementString("rolloffmode", clip.rolloffMode.ToString());
                xml.WriteElementString("mindistance", clip.minDistance.ToString());
                xml.WriteElementString("maxdistance", clip.maxDistance.ToString());
                xml.WriteElementString("spartialblend", clip.spartialBlend.ToString());
                if (clip.isLoop == true) {
                    xml.WriteElementString("loop", "true");
                }
                xml.WriteElementString("clippath", clip.clipPath);
                xml.WriteElementString("clipname", clip.clipName);
                xml.WriteElementString("checktimecount", clip.checkTime.Length.ToString());
                string str = "";
                foreach (float t in clip.checkTime) {
                    str += t.ToString() + "/";
                }
                xml.WriteElementString("checktime", str);
                str = "";
                xml.WriteElementString("settimecount", clip.setTime.Length.ToString());
                foreach (float t in clip.setTime) {
                    str += t.ToString() + "/";
                }
                xml.WriteElementString("settime", str);
                xml.WriteElementString("type", clip.playType.ToString());
                
                xml.WriteEndElement(); // CLIP
            }
            
            xml.WriteEndElement(); // SOUND
            xml.WriteEndDocument();
        }
    }
   
    public void LoadData() {
        xmlFilePath = Application.dataPath + dataDirectory;
        TextAsset asset = (TextAsset)Resources.Load(dataPath, typeof(TextAsset));
        if (asset == null || asset.text == null) {
            AddData("NewSound");
            return;
        }
        
        using (XmlTextReader reader = new XmlTextReader(new StringReader(asset.text))) {
            int currentID = 0;
            while (reader.Read()) {
                if (reader.IsStartElement()) {
                    switch (reader.Name) {
                        case "length":
                            int length = int.Parse(reader.ReadString());
                            names = new string[length];
                            soundClips = new SoundClip[length];
                            break;
                        case "clip":
                        case "id":
                            currentID = int.Parse(reader.ReadString());
                            soundClips[currentID] = new SoundClip();
                            soundClips[currentID].realId = currentID;
                            break;
                        case "name":
                            names[currentID] = reader.ReadString();
                            break;
                        case "loops":
                            int count = int.Parse(reader.ReadString());
                            soundClips[currentID].checkTime = new float[count];
                            soundClips[currentID].setTime = new float[count];
                            break;
                        case "maxvol":
                            soundClips[currentID].maxVolume = float.Parse(reader.ReadString());
                            break;
                        case "pitch":
                            soundClips[currentID].pitch = float.Parse(reader.ReadString()));
                            break;
                        case "dopplerlevel":
                            soundClips[currentID].dopplerLevel = float.Parse(reader.ReadString());
                            break;
                        case "rolloffmode":
                            soundClips[currentID].rolloffMode = (AudioRolloffMode)Enum.Parse(typeof(AudioRolloffMode), reader.ReadString()));
                        case "mindistance":
                            soundClips[currentID].minDistance = float.Parse(reader.ReadString());
                            break;
                        case "maxdistance":
                            soundClips[currentID].maxDistance = float.Parse(reader.ReadString());
                            break;
                        case "spartialblend":
                            soundClips[currentID].spartialBlend = float.Parse(reader.ReadString());
                            break;
                        case "loop":
                            soundClips[currentID].isLoop = true;
                            break;
                        case "clippath":
                            soundClips[currentID].clipPath = reader.ReadString();
                            break;
                        case "clipname":
                            soundClips[currentID].clipName = reader.ReadString();
                            break;
                        case "checktimecount":
                            break;
                        case "checktime":
                            SetLoopTime(true, soundClips[currentID], reader.ReadString());
                            break;
                        case "settime":
                            SetLoopTime(false, soundClips[currentID], reader.ReadString());
                            break;
                        case "type":
                            soundClips[currentID].playType = (SoundPlayType)Enum.Parse(typeof(SoundPlayType), reader.ReadString()));
                    }
                }
            }
        }
        
        // 미리 불러와 놓기. 리소스가 많아 로딩이 느리다면 제거하라.
        foreach (SoundClip clip in soundClips) {
            clip.PreLoad();
        }
    }
    
    void SetLoopTime(bool isCheck, SoundClip clip, string timeString) {
        string[] time = timeString.Split('/');
        for (int i = 0; i < time.Length; i++) {
            if (time[i] != string.Empty) {
                if (isCheck == true)
                    clip.checkTime[i] = float.Parse(time[i]);
                else
                    clip.setTime[i] = float.Parse(time[i]);
            }
        }
    }
    
    public override int AddData(string newName) {
        if (names == null) {
            names = new string[] { newName };
            soundClips = new SoundClip[] { new SoundClip() };
        }
        else {
            names = ArrayHelper.Add(newName, names);
            soundClips = ArrayHelper.Add(new SoundClip(), soundClips);
        }
    }
    
    public override void RemoveData(int index) {
        names = ArrayHelper.Remove(index, names);
        if (names.Length == 0) names = null;
        soundClips = ArrayHelper.Remove(index, soundClips);
    }
    
    public SoundClip GetCopy(int index) {
        if (index < 0 || index >= soundClips.Length) return null;
        SoundClip clip = new SoundClip();
        SoundClip original = soundClips[index];
        clip.realId = index;
        clip.clipPath = original.clipPath;
        clip.clipName = original.clipName;
        clip.maxVolume = original.maxVolume;
        clip.pitch = original.pitch;
        clip.dopplerLevel = original.dopplerLevel;
        clip.rolloffMode = original.rolloffMode;
        clip.minDistance = original.minDistance;
        clip.maxDistance = original.maxDistance;
        clip.spartialBlend = original.spartialBlend;
        clip.isLoop = original.isLoop;
        clip.checkTime = new float[original.checkTime.Length];
        clip.setTime = new float[original.setTime.Length];
        clip.playType = original.playType;
        for (int i=0; i < clip.checkTime.Length; i++) {
            clip.checkTime[i] = original.checkTime[i];
            clip.setTime[i] = original.setTime[i];
        }
        clip.PreLoad();
        return clip;
    }
    
    public override void Copy(int index) {
        names = ArrayHelper.Add(names[index], names);
        soundClips = ArrayHelper.Add(GetCopy(index), soundClips);
    }
}




여기까지가 SoundData의 완료입니다.. ^^;;;

xml로 저장하고 불러와서 파싱하기가 주요코드인데 옵션들이 많다보니 반복적인 코드가 많고 눈이 빙글빙글 ㅎㅎ..

다음 시간에는 SoundTool을 제작합니다...





<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 35회차 미션 시작합니다.

04. 배틀로얄 - 15, 16 번을 진행합니다.




BaseData::GetDataCount() 함수의 오류가 있어서 버그를 수정한 내용입니다.
위와 같이 수정하고 저번시간의 EffectTool Window를 실행해 보면 기본적인 구동이 되는 것을 확인할 수 있습니다.




ADD를 클릭하여 Item을 하나 더 추가하고.. 이름을 설정하고 Prefabs/Effects에 준비된 Effect GameObject를 마우스클릭으로 드래그하여 이팩트에 가져다 놓으면 연결되어 동작하는 것도 확인할 수 있습니다.




추가한 이후 [SAVE]를 클릭하면 effectData와 EffectList에 영향을 주게 됩니다.

우선 effectData 파일을 더블클릭하여 열어봅니다.
다음과 같이 한 줄로 구성된 xml 파일의 내용을 볼 수 있습니다.
이렇게 보면 좀 불편한대요..


 

 


이렇게 Excel 문서에서 열어서 보면 좀더 구성이 잘된 모양의 동일한 effectData xml 파일의 내용을 볼 수가 있습니다.

 



또한 EffectList 파일을 더블클릭하여 열어보면 EffectList라는 enum도 Tool에서 추가한 아이템 내용대로 업데이트된 것을 확인할 수가 있습니다.
이렇게 수행되도록 만든 코드가 CreateEnumStructure()라는 함수였고 이것이 수행된 결과인 것입니다.




이제 Manager 폴더 아래에 DataManager와 EffectManager 2개의 스크립트를 준비합니다.

우선 DataManager부터 작성을 시작합니다.

public class DataManager : MonoBehaviour
{
    private static EffectData effectData = null;
    
    void Start() {
        if (effectData == null) {
            effectData = ScriptableObject.CreateInstance<EffectData>();
            effectData.LoadData();
        }
    }
    
    public static EffectData EffectData() {
        if (effectData == null) {
            effectData = ScriptableObject.CreateInstance<EffectData>();
            effectData.LoadData();
        }
        return effectData;
    }
}


이제 EffectManager를 작성합니다.

public class EffectManager : SingletonMonoBehaviour<EffectManager>
{
    private Transform effectRoot = null;
    
    void Start() {
        effectRoot = new GameObject("EffectRoot").transform;
        effectRoot.SetParent(transform);
    }
    
    //## 핵심 코드
    public GameObject EffectOneShot(int index, Vector3 position) {
        EffectClip clip = DataManager.EffectData().GetClip(index);
        GameObject effectInstance = clip.Instantiate(position);
        effectInstance.SetActive(true);
        return effectInstance;
    }
}





SingletonMonoBehaviour의 소스는 위와 같으며 프로젝트에 기본 포함하여 배포되었습니다.

위와같이 하여 EffectTool이 끝난거라고 합니다.. 무언가 Unity에서 확인해보고 구동하는 것이 있을거라 생각했는데 아니었네요 ㅎㅎ
바로 SoundTool 제작으로 넘어갑니다..




SoundClip 스크립트를 만들고 작성을 시작합니다 ^^; SoundTool은 EffectTool과 핵심적인 기능은 동일한데 Sound의 특성상 Loop 기능이나 CheckPoint 및 FadeOut등의 효과 등의 처리가 많아서 데이터가 많기 때문에 조금 복잡하다는 것만 다르다고 보면 됩니다.


// 루프, 페이드 관련 속성, 오디오 클립 속성들.
public class SoundClip
{
    public SoundPlayType playTpe = SoundPlayType.None;
    public string clipName = string.Empty;
    public string clipPath = string.Empty;
    public float maxVolume = 1.0f;
    public bool isLoop = false;
    public float[] checkTime = new float[0];
    public float[] setTime = new float[0];
    public int realId = 0;
    
    private AudioClip clip = null;
    public int currentLoop = 0;
    public float pitch = 1.0f;
    public float dopplerLevel = 1.0f;
    public AudioRolloffMode rolloffMode = AudioRolloffMode.Logarithmic;
    public float minDistance = 10000.0f;
    public float maxDistance = 50000.0f;
    public float sparialBlend = 1.0f;
    
    public float fadeTime1 = 0.0f;
    public float fadeTime2 = 0.0f;
    public Interpolate.Function interpolate_Func;
    public bool isFadeIn = false;
    public bool isFadeOut = false;
    
    public SoundClip() { }
    public SoundClip(string clipPath, string clipName) {
        this.clipPath = clipPath;
        this.clipName = clipName;
    }
    public void PreLoad() {
        if (this.clip == null) {
            string fullPath = this.clipPath + this.clipName;
            this.clip = ResourceManager.Load(fullPath) as AudioClip;
        }
    }
    
    public void AddLoop() {
        checkTime = ArrayHelper.Add(0.0f, this.checkTime);
        setTime = ArrayHelper.Add(0.0f, this.setTime);
    }
    public void RemoveLoop(int index) {
        checkTime = ArrayHelper.Remove(index, this.checkTime);
        setTime = ArrayHelper.Remove(index, this.setTime);
    }
    public AudioClip GetClip() {
        if (clip == null) PreLoad();
        if (clip == null && clipName != string.Empty) {
            Debug.LogWarning($"Can not load audio clip Resource {clipName}");
            return null;
        }
        return clip;
    }
    public void ReleaseClip() {
        if (clip != null) clip = null;
    }
    public bool HasLoop() {
        return checkTime.Length > 0;
    }
    public void NextLoop() {
        currentLoop++;
        if (currentLoop >= checkTime.Length) currentLoop = 0;
    }
    public void CheckLoop(AudioSource source) {
        if (HasLoop() && source.time >= checkTime[currentLoop]) {
            source.time = setTime[currentLoop];
            NextLoop();
        }
    }
    
    public void FadeIn(float time, Interpolate.EaseType easeType) {
        isFadeOut = false;
        fadeTime1 = 0.0f;
        fadeTime2 = time;
        interpolate_Func = Interpolate.Ease(easeType);
        isFadeIn = true;
    }
    public void FadeOut(float time, Interpolate.EaseType easeType) {
        isFadeIn = false;
        fadeTime1 = 0.0f;
        fadeTime2 = time;
        interpolate_Func = Interpolate.Ease(easeType);
        isFadeOut = true;
    }
    /// <summary>
    /// 페이드인,아웃 효과 프로세스
    /// </summary>
    public void DoFade(float time, AudioSource audio) {
        if (isFadeIn == true) {
            fadeTime1 += time;
            audio.volume = Interpolate.Ease(interpolate_Func, 0, maxVolume, fadeTime1, fadeTime2);
            if (fadeTime1 >= fadeTime2) isFadeIn = false;
        }
        else if (isFadeOut == true) {
            fadeTime1 += time;
            audio.volume = Interpolate.Ease(interpolate_Func, maxVolume, 0 - maxVolume, fadeTime1, fadeTime2);
            if (fadeTime1 >= fadeTime2) {
                isFadeOut = false;
                audio.Stop();
            }
        }
    }
}

여기까지가 SoundClip 완성입니다. 다음시간부터는 SoundManager를 통하여 Clip을 관리하는 기능을 구현할 것입니다.
결국 SoundClip은 소리 재생인데 게임 상황상 여러가지 옵션이 많다보니 함수를 많이 구현하게 된 것입니다.


Easing functions에 대해서는 구글에서 검색한 이미지를 하나 추가합니다.






<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 34회차 미션 시작합니다.

04. 배틀로얄 - 13, 14 번을 진행합니다.




EffectTool Script 생성하여 진행합니다 ^^~


using UnityEngine;
using UnityEditor; // Editor Tool을 만들기 위해 필수
using System.Text;
using UnityObject = UnityEngine.Object;

// Clip Property를 수정하고, 읽고, 저장하는 기능.
public class EffectTool : EditorWindow
{
    //UI 그리는데 필요한 변수들.
    pubic int uiWidthXLarge = 450; // pixel 크기.
    pubic int uiWidthLarge = 300;
    pubic int uiWidthMiddle = 200;
    private int selection = 0; // 선택 index
    private Vector2 SP1 = Vector2.zero;
    private Vector2 SP2 = Vector2.zero;
}

여기까지가 기본적인 처리를 위한 준비 과정을 마친 상태이고, 이제 본격적으로 이팩트 툴 작성에 들어갑니다 ^^


 



public class EffectTool : EditorWindow
{
    // 위의 변수들..
    
    // 이팩트 클립
    private GameObject effectSource = null;
    // 이팩트 데이터
    private static EffectData effectData;
    
    [MenuItem("Tools/Effect Tool")]  // 유니티에 메뉴 생성
    static void Init() {
        effectData = ScriptableObject.CreateInstance<EffectData>();
        effectData.LoadData();
        
        EffectTool window = GetWindow<EffectTool>(false, "Effect Tool");
        window.Show();
    }
    
    private void OnGUI() {
        if (effectData == null) return;
        
        EditorGUILayout.BeginVertical();
        {
            // 상단 - add, remove, copy
            UnityObject source = effectSource;
            EditorHelper.EditorToolTopLayer(effectData, ref selection, ref source, this.uiWidthMiddle);
            effectSource = (GameObject)source;
            
            EditorGUILayout.BeginHorizontal();
            {
                // 중단 - data list
                EditorHelper.EditorToolListLayer(ref SP1, effectData, ref selection, ref source, this.uiWidthLarge);
                effectSource = (GameObject)source;
                
                // 설정 부분
                EditorGUILayout.BeginVertical();
                {
                    SP2 = EditorGUILayout.BeginScrollView(this.SP2);
                    {
                        if (effectData.GetDataCount() > 0) {
                            EditorGUILayout.BeginVertical();
                            {
                                EditorGUILayout.Separator();
                                EditorGUILayout.LabelField("ID", selection.ToString(), GUILayout.Width(uiWidthLarge));
                                effectData.names[selection] = EditorGUILayout.TextField("이름.", effectData.names[selection], GUILayout.Width(uiWidthXLarge));
                                effectData.effectClips[selection].effectType = (EffectType)EditorGUILayout.EnumPopup("이팩트 타입.", effectData.effectClips[selection].effectType, GUILayout.Width(uiWidthLarge));
                                EditorGUILayout.Separator();
                                if (effectSource == null && effectData.effectClips[selection].effectName != string.Empty) {
                                    effectData.effectClips[selection].PreLoad();
                                    effectSource = Resources.Load(effectData.effectClips[selection].effectPath + effectData.effectClips[selection].effectName) as GameObject;
                                }
                                effectSource = (GameObject)EditorGUILayout.ObjectField("이팩트", this.effectSource, typeof(GameObject), false, GUILayout.Width(uiWidthXLarge));
                                if (effectSource != null) {
                                    effectData.effectClips[selection].effectPath = EditorHelper.GetPath(this.effectSource);
                                    effectData.effectClips[selection].effectName = effectSource.name;
                                }
                                else {
                                    effectData.effectClips[selection].effectPath = string.Empty;
                                    effectData.effectClips[selection].effectName = string.Empty;
                                    effectSource = null;
                                }
                                EditorGUILayout.Separator();
                            }
                            EditorGUILayout.EndVertical();
                        }
                    }
                    EditorGUILayout.EndScrollView();
                }
                EditorGUILayout.EndVertical();
            }
            EditorGUILayout.EndHorizontal();
        }
        EditorGUILayout.EndVertical();
        
        EditorGUILayout.Separator();
       
        // 하단
        EditorGUILayout.BeginHorizontal();
        {
            if (GUILayout.Button("Reload Settings")) {
                effectData = CreateInstance<EffectData>();
                effectData.LoadData();
                selection = 0;
                this.effectSource = null;
            }
            if (GUILayout.Button("Save")) {
                EffectTool.effectData.SaveData();
                CreateEnumStructure();
                AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
            }
        }
        EditorGUILayout.EndHorizontal();
    }
    
    public void CreateEnumStructure() {
        string enumName = "EffectList";
        StringBuilder builder = new StringBuilder();
        builder.AppendLine();
        for (int i = 0; i < effectData.names.Length; i++) {
            if (effectData.names[i] != string.Empty) {
                builder.AppendLine("   " + effectData.names[i] + " = " + i + ",");
            }
        }
        EditorHelper.CreateEnumStructure(enumName, builder);
    }
}


또 EffectData가 VisualStudio에서 오류를 발생하네요.. 전체 컴파일이라거나 namespace 등의 문제가 아닐까 싶긴합니다만..
관련 변수, 함수들 인텔리전스가 지원되지 않으니 강의하면서 답답하실 듯 하네요.. 미리 확인이 되지 않았던건가 하는 아쉬움은 남네요.
어여 해결이 되어서 뒤의 내용들에서는 수월하게 진행될 수 있기를 바랍니다.




드디어 위에까지 작업을 하면 위와 같이 Tools - Effect Tool 이라는 메뉴가 생성된 것을 볼 수가 있습니다..


 



그리고 클릭을 해보면 와우~ Vertical, Horizontal 함수를 열심히 사용하여 만든 "Effect Tool" 창이 나타나고, 지금까지 작업한대로 버튼들과 리스트컨트롤이 하나 붙어서 보이는 것을 확인할 수 있습니다.

Unity UI 제작을 일일이 Typing으로 하니 소스보기가 좀 복잡하네요 ㅎㅎ. 좀더 복잡한 UI를 가지는 사운드 툴은 확실히 정신이 없을 듯하네요..

Unity UI 제작을 지원하는 Addon 등이 없나 궁금해지네요.

 

궁금해서 검색해보니 Unity UI Addons를 누가 만들어 배포하고 있긴 하네요 ^^. 물론 안정성이나 검증되지 않아 사용하지 않으셨는지는 모르겠습니다. 나중에 시간이 되면 사용해 봐야겠습니다.

 


아직 버그들이 있기에 좀더 수정 작업을 진행하게 되고, 다음 시간에는 데이터 매니저와 이팩트 매니저를 만들어서 전체 EffectTool이 구동되는 것을 확인하고, 바로 이어서 사운드툴 제작으로 들어가겠습니다 ^^~





<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

+ Recent posts