0. 들어가기 전에
이번에는 게임 시작, 실패, 성공 판넬을 제작했고 씬을 이동할 수 있도록 버튼에 기능을 추가했다.
1. 게임 오브젝트
일단 메인씬과 야생씬 둘 다 게임 오브젝트를 추가했다.
1.1 TempWorldButton
야생 씬으로 이동하기 위해 임시로 만들어놓은 게임 오브젝트이다. 다음에 수정할 예정이다.
1.2 GameStartPanel
야생 게임 시작 버튼이다. 이 버튼을 누르기 전까지는 게임이 시작되지 않도록 했다.
1.3 GameOverPanel
플레이어가 죽으면 뜨는 판넬이다. 집으로 돌아가는 버튼을 누르면 메인 씬으로 넘어가도록 했다.
1.4 GameClearPanel
목표 시간에 도달할 때까지 플레이어가 죽지 않았다면 뜨는 판넬이다. 버튼을 누르면 메인 씬으로 돌아가도록 했다. 게임에 성공하면 점토를 보상으로 얻는데 이 기능은 다음에 추가할 것이다.
1.5 GameOverLine
플레이어가 이 선을 통과하면 죽도록 했다.
1.6 Heart
기존 체력바에 애니메이션을 추가했다. 파도 치는 효과를 주고 싶어서 추가했다..
1.7 Canvas
기존 캔버스 게임 오브젝트에 UIController 스크립트를 추가했다. 야생 씬의 UI 를 관리하기 위함이다..
2. 스크립트
이번에 새로 만든 스크립트는 UIController 이고, GameManager, ScrollingBackground, Player, MapSpawner 를 수정했다.
2.1 UIController 스크립트
using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEditor.SearchService;
using UnityEngine;
using UnityEngine.UI;
public class UIController : MonoBehaviour
{
[Header("Heart UI")]
public GameObject[] hearts;
// 게임 시작, 종료 판넬
[Header("GamePanel")]
public GameObject gameStartPanel;
public Button gameStartButton;
public GameObject gameOverPanel;
public Button gameOverButton;
public Animator gameOverAnim;
public GameObject gameClearPanel;
public Button gameClearButton;
public Animator gameClearAnim;
private void Awake()
{
// 버튼 정보 세팅하는 메서드 호출
SetGameStartButton();
SetGameEndButton();
// 게임 시작 판넬 띄우도록
gameStartPanel.SetActive(true);
}
private IEnumerator Start()
{
// 아직 게임 시작 안 했으면 좀 기다리기..
while (!GameManager.instance.isGameStart)
{
yield return null;
}
for (int i=0; i<hearts.Length; i++)
{
// 하트 애니메이션 수행
hearts[i].GetComponent<Animator>().Play("Move", -1, i*0.2f);
}
StartCoroutine(OpenGameEndPanel()); // 게임 종료 판넬 띄우는 코루틴 시작
}
public void CloseSelf(GameObject obj)
{
// 오브젝트 비활성화
obj.SetActive(false);
}
public void GameStart()
{
// 게임 시작 신호 주기
GameManager.instance.isGameStart = true;
}
public void SetGameStartButton()
{
// 게임 시작 버튼에 연결
gameStartButton.onClick.AddListener(() => CloseSelf(gameStartPanel));
gameStartButton.onClick.AddListener(GameStart);
}
public void SetGameEndButton()
{
// 집에 돌아가야행~~
gameOverButton.onClick.AddListener(() => CloseSelf(gameOverPanel));
gameClearButton.onClick.AddListener(() => CloseSelf(gameClearPanel));
gameOverButton.onClick.AddListener(GameManager.instance.MoveScene);
gameClearButton.onClick.AddListener(GameManager.instance.MoveScene);
}
public IEnumerator OpenGameEndPanel()
{
// 게임이 종료될 때까지 기다려
while (!GameManager.instance.isGameEnd)
{
yield return null;
}
// 게임 성공 여부에 따라 판넬 띄우기
if (GameManager.instance.isGameClear)
{
gameClearPanel.SetActive(true);
gameClearAnim.SetTrigger("Show"); // 애니메이션 수행
}
else if (!GameManager.instance.isGameClear)
{
gameOverPanel.SetActive(true);
gameOverAnim.SetTrigger("Show"); // 애니메이션 수행
}
}
}
2.2 GameManager 스크립트
using System;
using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.Rendering.Universal;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using static UnityEditor.Experimental.GraphView.Port;
public class GameManager : MonoBehaviour
{
[Header("Game Data")]
public float love; // 애정
public float gold; // 골드
public bool[] unLockedClays; // 점토들의 해금 여부
public bool[] catchedClays; // 야생에서 잡아왔는지 확인용
public int clayHouseLevel = 1; // 점토 아파트 레벨
public int clayClickLevel = 1; // 점토 클릭 레벨
public int[] clayHouseLoveList; // 업그레이드 비용
public int[] clayClickLoveList; // 업그레이드 비용
public int curPossibleClayNum = 1; // 최대로 키울 수 있는 점토의 개수(1레벨은 1마리, 2레벨은 2마리...)
[Header("Game Manager")]
public static GameManager instance; // 싱글톤 이용하기 위함
public string curScene;
[Header("GameDataUI")]
public GameDataUIController gameDataUI;
public delegate void SetInfoPanelHandler(string text); // 델리게이트 선언
public event SetInfoPanelHandler OnSetInfoPanel;
[Header("Pool Manager")]
public PoolManager poolManager;
[Header("Coroutine")]
public Coroutine updateTextUICoroutine;
[Header("Game Exit")]
public Button gameExitButton;
[Header("Effect")]
// 0: 점토 레벨업, 1: 점토 판매, 2: 점토 해금, 3: 업그레이드
public ParticleSystem[] effectsPrefabs; // 프리팹 넣어놓기
public ParticleSystem[] effects; // 관리용 변수
public string[] effectGameObjectNames;
[Header("Toy Control")]
public int curToyIdx = -1; // 현재 선택된 장난감
public RuntimeAnimatorController[] clayToyAnimators; // 가구랑 상호작용하는 애니메이터
public string[] toyInfo; // 가구를 클릭하면 안내 판넬에 띄울 내용
public delegate void SetClayHouseLevel(int houseLevel, int clickLevel);
public SetClayHouseLevel OnSetClayHouseInfo;
// Light & UI Control
public delegate void SetLightHandler(bool flag);
public event SetLightHandler OnSetLightHandler; // 여기에 빛 관리하는 메서드 연결해놓을 것(점토의 드래그 시작되면 이 델리게이트에 연결된 메서드를 호출하도록..)
// 야생 콘텐츠 관련
[Header("World Manager")]
// 게임 시작 종료 여부
public bool isGameStart = false;
public bool isGameEnd = false;
public bool isGameClear = false;
private void Awake()
{
// 싱글톤 이용
if (instance != null && instance != this)
{
// 만약 이미 존재하면 그냥 없애
Destroy(gameObject);
return;
}
instance = this;
DontDestroyOnLoad(gameObject); // 얘는 다른 씬으로 전환되어도 안 없앨 거임
curScene = SceneManager.GetActiveScene().name; // 씬 이름 가져오기
if (curScene == "ClayHouse") {
// 현재 씬이 ClayHouse 일 때
poolManager = GameObject.Find("PoolManager").GetComponent<PoolManager>(); // 풀매니저 찾아서 할당
for (int i = 0; i < effectsPrefabs.Length; i++)
{
// 이펙트 생성해서 넣어놓기
effects[i] = Instantiate(effectsPrefabs[i], GameObject.Find(effectGameObjectNames[i]).transform);
effects[i].gameObject.SetActive(false); // 비활성화
}
}
else if (curScene == "World") {
// 현재 씬이 World 일 때
}
}
private IEnumerator Start()
{
// DataManager 초기화 완료 기다리기
while (!DataManager.instance.isInitialized)
{
yield return null;
}
Debug.Log("음 이제 DataManager 이용할 수 있어용~");
// 현재 씬이 클레이 하우스인 경우에만 호출 되도록..
if (curScene == "ClayHouse")
{
gameExitButton = GameObject.Find("OptionPanelParent").transform.Find("Option Panel").transform.Find("Image").transform.Find("Exit Button").GetComponent<Button>();
gameExitButton.onClick.AddListener(DataManager.instance.SaveGameData); // 게임 데이터 저장 메서드 연결
gameExitButton.onClick.AddListener(GameExit); // 게임 종료 메서드 연결
// 메서드 연결하기
DataManager.instance.OnSave -= SetSaveData; // 중복 방지하기 위해 먼저 빼줌
DataManager.instance.OnSave += SetSaveData;
LoadDataSet(); // 데이터 반영
}
}
private void LoadDataSet()
{
if (curScene == "World") return; // 만약 현재 씬이 야생이면 그냥 빠져나가도록..
// 저장된 게임 데이터가 있는 경우 데이터 가져와서 반영
if (DataManager.instance.data.unlockClays != null)
{
// 저장된 데이터 반영해서 가져오기
for (int i = 0; i < unLockedClays.Length; i++)
{
unLockedClays[i] = DataManager.instance.data.unlockClays[i];
catchedClays[i] = DataManager.instance.data.catchClays[i];
}
}
// 저장된 게임 수치 데이터가 있는 경우 데이터 가져와서 반영
if (DataManager.instance.data.valueDatas != null)
{
// 저장된 데이터 반영해서 가져오기
gold = DataManager.instance.data.valueDatas.gold;
love = DataManager.instance.data.valueDatas.love;
clayHouseLevel = DataManager.instance.data.valueDatas.clayHouseLevel;
clayClickLevel = DataManager.instance.data.valueDatas.clayClickLevel;
curPossibleClayNum = DataManager.instance.data.valueDatas.curPossibleClayNum;
SetGoldLove(); // 로드한 데이터 반영해서 데이터 UI 업데이트..
SetUpgradePanel(); // 델리게이트 호출
}
}
public void SetUpgradePanel()
{
if (curScene == "World") return; // 만약 현재 씬이 야생이면 그냥 빠져나가도록..
OnSetClayHouseInfo?.Invoke(clayHouseLevel, clayClickLevel); // UpgradePanel 클래스의 SetUpgardePanel() 메서드 호출
}
public void SetGoldLove()
{
if (curScene == "World") return; // 만약 현재 씬이 야생이면 그냥 빠져나가도록..
// 로드한 데이터에 맞게 데이터 UI 변경할 수 있도록..
// 이미 코루틴이 종료되지 않은 중에 동일한게 또 들어오면 겹쳐서 반영이 돼서 이상하게 될 수 있으므로 null 인지 판단해야함.
if (updateTextUICoroutine != null)
{
StopCoroutine(updateTextUICoroutine);
}
updateTextUICoroutine = StartCoroutine(gameDataUI.UpdateTextUI("gold", gold, gold));
updateTextUICoroutine = StartCoroutine(gameDataUI.UpdateTextUI("love", love, love));
}
// 재화 얻는 함수
public void GetGold(float capacity)
{
if (curScene == "World") return; // 만약 현재 씬이 야생이면 그냥 빠져나가도록..
// 이미 코루틴이 종료되지 않은 중에 동일한게 또 들어오면 겹쳐서 반영이 돼서 이상하게 될 수 있으므로 null 인지 판단해야함.
if (updateTextUICoroutine != null)
{
StopCoroutine(updateTextUICoroutine);
}
updateTextUICoroutine = StartCoroutine(gameDataUI.UpdateTextUI("gold", gold + capacity, gold));
gold += capacity;
//PlayerPrefs.SetFloat("Gold", gold); // 데이터 저장
}
public void GetLove(float capacity)
{
if (curScene == "World") return; // 만약 현재 씬이 야생이면 그냥 빠져나가도록..
// 이미 코루틴이 종료되지 않은 중에 동일한게 또 들어오면 겹쳐서 반영이 돼서 이상하게 될 수 있으므로 null 인지 판단해야함.
if (updateTextUICoroutine != null)
{
StopCoroutine(updateTextUICoroutine);
}
updateTextUICoroutine = StartCoroutine(gameDataUI.UpdateTextUI("love", love + capacity, love));
love += capacity;
//PlayerPrefs.SetFloat("Love", love); // 데이터 저장
}
public void SetSaveData()
{
if (curScene == "World") return; // 만약 현재 씬이 야생이면 그냥 빠져나가도록..
int size = unLockedClays.Length;
DataManager.instance.data.unlockClays = new List<bool>();
DataManager.instance.data.catchClays = new List<bool>();
for (int i = 0; i < size; i++)
{
DataManager.instance.data.unlockClays.Add(unLockedClays[i]); // 해금 여부 저장
DataManager.instance.data.catchClays.Add(catchedClays[i]); // 포획 여부 저장
}
// 수치 데이터 저장
DataManager.instance.data.valueDatas = new ValueDatas();
DataManager.instance.data.valueDatas.gold = gold;
DataManager.instance.data.valueDatas.love = love;
DataManager.instance.data.valueDatas.clayHouseLevel = clayHouseLevel;
DataManager.instance.data.valueDatas.clayClickLevel = clayClickLevel;
DataManager.instance.data.valueDatas.curPossibleClayNum = curPossibleClayNum;
}
public void GameExit()
{
// 게임 종료
Application.Quit();
}
public void StartInfoPanel(string text)
{
if (curScene == "World") return; // 만약 현재 씬이 야생이면 그냥 빠져나가도록..
// 연결된 메서드 실행시키기
OnSetInfoPanel?.Invoke(text);
}
public void SetLightAndUI(bool flag)
{
if (curScene == "World") return; // 만약 현재 씬이 야생이면 그냥 빠져나가도록..
OnSetLightHandler?.Invoke(flag); // flag 값을 전달해서 델리게이트에 연결된 메서드 호출
}
public void MoveScene()
{
if (curScene == "ClayHouse")
{
curScene = "World"; // 씬 이름 바꿔주깅
// 현재가 점토 집인 경우에는 World 씬으로 이동
SceneManager.LoadScene("World");
}
else if (curScene == "World")
{
curScene = "ClayHouse";
// 현재 World 씬인 경우 점토 집 씬으로 이동
SceneManager.LoadScene("ClayHouse");
}
}
public void WorldGameClear()
{
// 야생에 성공했을 때 호출되는 메서드
isGameStart = false;
isGameEnd = true;
isGameClear = true;
}
public void WorldGameFail()
{
// 야생에 성공했을 때 호출되는 메서드
isGameStart = false;
isGameEnd = true;
isGameClear = false;
}
}
2.3 ScrollingBackground 스크립트
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ScrollingBackground : MonoBehaviour
{
public float speed;
private void Awake()
{
speed = 7f; // 이동속도 고정..
}
private void Update()
{
// 아직 게임 시작 안 했으면 좀 기다리기..
while (!GameManager.instance.isGameStart)
{
return;
}
// 초당 speed 의 속도로 왼쪽으로 평행이동..
transform.Translate(Vector3.left * speed * Time.deltaTime);
}
}
2.4 Player 스크립트
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using UnityEngine;
public class Player : MonoBehaviour
{
[Header("Player Info")]
public int heart = 3; // 목숨 3개
public float jumpPower = 500f; // 점프 힘
public int jumpCount = 0; // 누적 점프 횟수(최대 두번 뛸 수 있도록)
public bool isGrounded = false; // 바닥에 닿았는지 여부
public bool isDead = false; // 사망 여부
public bool isInvincibility = false; // 무적 여부
public Rigidbody2D rigid; // 리지드바디 컴포넌트
public Animator anim; // 애니메이터 컴포넌트
public AudioSource playerAudio; // 사용할 오디오 소스 컴포넌트
public SpriteRenderer spriteRenderer; // 스프라이트 렌더러
public AudioClip deathClip; // 죽을 때 나는 소리
public AudioClip jumpClip; // 점프할 때 나는 소리
public AudioClip attackedClip; // 맞을 때 나는 소리
public float gamePlayTime = 60f; // 일단 1분으로..
public float curTime = 0;
private void Start()
{
rigid = GetComponent<Rigidbody2D>();
anim = GetComponent<Animator>();
playerAudio = GetComponent<AudioSource>();
spriteRenderer = GetComponent<SpriteRenderer>();
}
private void Update()
{
// 아직 게임 시작 안 했으면 좀 기다리기..
while (!GameManager.instance.isGameStart)
{
return;
}
if (curTime >= gamePlayTime)
{
// 플레이 시간 지나면 더이상 진행하지 않음
GameEnd();
}
if (isDead)
{
// 사망하면 더이상 진행하지 않음
return;
}
if (Input.GetMouseButtonDown(0) && jumpCount < 2)
{
// 점프 횟수 증가
jumpCount++;
// 점프 직전에 속도를 0 으로 변경
rigid.velocity = Vector3.zero;
// 위쪽으로 힘주기
rigid.AddForce(new Vector2(0, jumpPower));
// 오디오 소스 재생
playerAudio.clip = jumpClip;
playerAudio.Play();
}
else if (Input.GetMouseButtonUp(0) && rigid.velocity.y > 0)
{
// 마우스 왼쪽 버튼에서 손을 떼는 순간 && 속도의 y 값이 양수(위로 상승 중)
// 현재 속도를 절반으로 변경
rigid.velocity = rigid.velocity * 0.5f;
}
// 애니메이터의 Grounded 파라미터를 isGrounded 값으로 갱신
anim.SetBool("Grounded", isGrounded);
curTime += Time.deltaTime; // 시간 더해주기
}
public void GameEnd()
{
gameObject.SetActive(false); // 비활성화!
// 여기서 게임 매니저 게임 종료(성공) 메서드 호출
GameManager.instance.WorldGameClear();
}
public void Die()
{
StartCoroutine(DieCoroutine());
}
private void OnTriggerEnter2D(Collider2D collision)
{
// Collider 를 통해 다른 객체가 경계를 통과했을 때 자동으로 호출되는 메서드
// 두 객체 중 적어도 하나에 Collider 가 있고, Is Trigger 옵션이 활성화 되어 있어야함.
// 체력 깎이는 부분은 무적 상태라면 수행 안 하고 걍 빠져나가기..
if (isInvincibility) return;
// 만약 플레이어가 체력 깎이는 바닥을 통과했을 때
if (collision.tag == "AttackGround")
{
Die(); // 죽어
}
}
private void OnCollisionEnter2D(Collision2D collision)
{
if (collision.gameObject.tag == "Ground")
{
isGrounded = true; // 땅에 닿았음 표시
jumpCount = 0; // 리셋
}
else if (collision.gameObject.tag == "Attack")
{
if (isInvincibility) return; // 무적 상태면 그냥 나가기..
// 장애물에 닿았으면 heart 값 -1 해주기..
Attacked();
}
else if (collision.gameObject.tag == "Monster")
{
if (isInvincibility) return; // 무적 상태면 그냥 나가기..
// 만약 플레이어가 몬스터보다 위에 있고, y 축의 속도가 감소하는 중이라면 몬스터를 밟은 거임
if (transform.position.y >= collision.gameObject.transform.position.y)
{
// 몬스터 머리 밟으면 다시 이단 점프 가능하도록..
isGrounded = true; // 땅에 닿았음 표시
jumpCount = 0; // 리셋
// 몬스터의 Die 메서드 호출
collision.transform.GetComponent<Monster>().Die();
}
else
{
Attacked();
}
}
}
private void OnCollisionExit2D(Collision2D collision)
{
if (collision.gameObject.tag == "Ground")
{
isGrounded = false;
}
}
private void Attacked()
{
// 효과음
playerAudio.clip = attackedClip;
playerAudio.Play();
// 피 깎기
heart--;
if (heart == 0)
{
Die(); // 죽어
return;
}
// 잠시 무적 상태로..
StartCoroutine(Invincibility());
}
private IEnumerator Invincibility()
{
isInvincibility = true;
spriteRenderer.color = new Color32(255, 0, 0, 180); // 반투명하게..(빨갛게)
gameObject.layer = 9; // PlayerDamaged 레이어는 9번임
anim.SetTrigger("Hit"); // 애니메이션 수행
yield return new WaitForSeconds(2f); // 2초 동안 무적
gameObject.layer = 8; // Player 레이어는 8번임
spriteRenderer.color = new Color32(255, 255, 255, 255); // 원래 상태로..
isInvincibility = false; // 다시 false 로 바꾸고 빠져나가기..
}
private IEnumerator DieCoroutine()
{
// 여기서 게임 종료(실패) 로직 수행할거임
GameManager.instance.WorldGameFail(); // 게임 실패 정보 세팅
// 애니메이터의 Die 트리거 파라미터를 세팅함
anim.SetTrigger("Die");
// 오디오 소스 클릅을 deathClip 으로 변경
playerAudio.clip = deathClip;
// 오디오 실행
playerAudio.Play();
// 속도를 제로로 변경
rigid.velocity = Vector2.zero;
// 사망 상태 true 로
isDead = true;
yield return new WaitForSeconds(3f);
gameObject.SetActive(false); // 비활성화
}
}
2.5 MapSpawner 스크립트
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Unity.VisualScripting;
using UnityEngine;
public class MapSpawner : MonoBehaviour
{
[Header("Map Contoller")]
public GameObject[] mapPrefabs; // 현재 맵 프리팹
public GameObject[] easyMapPrefabs;
public GameObject[] normalMapPrefabs;
public GameObject[] hardMapPrefabs;
public List<GameObject>[] pool;
public GameObject easyBackground; // 쉬움 단계 배경
public GameObject normalBackground; // 보통 단계 배경
public GameObject hardBackground; // 어려움 단계 배경
public float curTime = 0;
public float targetTime = 0.5f; // targetTime 마다 맵 생성
public int mapCount;
public int mapLevel; // 0: 쉬움, 1: 보통, 2: 어려움
private Coroutine mapCoroutine;
private void Awake()
{
// 맵 난이도에 맞게 mapCount 설정..
if (mapLevel == 0)
{
easyBackground.SetActive(true); // 배경 활성화
mapPrefabs = easyMapPrefabs; // 현재 팹 프리팹을 이지맵으로 설정
}
else if (mapLevel == 1)
{
normalBackground.SetActive(true); // 배경 활성화
mapPrefabs = normalMapPrefabs; // 현재 팹 프리팹을 노멀맵으로 설정
}
else if (mapLevel == 2)
{
hardBackground.SetActive(true); // 배경 활성화
mapPrefabs = hardMapPrefabs; // 현재 팹 프리팹을 하드맵으로 설정
}
mapCount = mapPrefabs.Length; // 크기 설정
pool = new List<GameObject>[mapCount]; // 배열 만들기
for (int i=0; i<mapCount; i++)
{
pool[i] = new List<GameObject>(); // 리스트 새로 만들기
}
}
private IEnumerator Start()
{
// 무한 루프..
// 야생 게임이 시작될 때까지 기다리기..
while (!GameManager.instance.isGameStart)
{
yield return null;
}
mapCoroutine = StartCoroutine(SpawnMap()); // 맵 생성 코루틴 시작
StartCoroutine(GameEnd()); // 게임 종료 코루틴 시작
}
private IEnumerator SpawnMap()
{
while (true)
{
int mapIdx = Random.Range(0, mapCount);
GameObject select = null;
foreach (GameObject map in pool[mapIdx])
{
// 만약 놀고 있는 맵 게임 오브젝트를 발견하면 그거 활성화
if (map.activeSelf == false)
{
select = map;
map.SetActive(true); // 맵 활성화
break;
}
}
// 발견 못 하면 새로 생성
if (select == null)
{
select = Instantiate(mapPrefabs[mapIdx], transform);
pool[mapIdx].Add(select); // 새로 생성한 게임 오브젝트를 풀에 넣기
}
yield return new WaitForSeconds(targetTime); // targetTime 만큼 기다리기
}
}
private IEnumerator GameEnd()
{
while (!GameManager.instance.isGameEnd)
yield return null;
StopCoroutine(mapCoroutine); // 게임 끝났으니까 종료!
}
}
3. 결과물
4. 참고자료
이번에 공부한 자료 정리.
4.1 SceneManager
[유니티] 씬 전환, 씬 이동, Scene 전환 (SceneManager)
[유니티] 씬 전환, 씬 이동, Scene 전환 (SceneManager)
안녕하세요. BlockDMask입니다. 오늘은 유니티에서 SceneManger를 이용해서 씬을 전환하는 방법에 대해서 알아보겠습니다. 1. Build Setting 씬 추가 2. C# 스크립트 작성 1. 유니티 BuildSetting 에 씬 추가 1-1) F
blockdmask.tistory.com