[ 목차 ]
오늘의 포스팅은 2D 횡스크롤을 만드는 것이다.
같은 2D 게임을 만들어도 시스템을 만드는데 어떻게 달라지는지
이후 포스팅에도 쭈욱 이어질 것이다.
간단한 실습을 통해 2D 횡스크롤에 많이 쓰이는 것을 구현한다.
횡스크롤 2D
1. 프로젝트 생성
이번엔 상태 디자인 패턴을 활용해서
횡스크롤 2D를 만들고자 한다.
이번엔 URP 2D로 생성하려고 한다.
버전은 6000.0.40f1으로 진행하였다.
Universal 2D로 생성한다.
먼저 Player인 공과 바닥 역할을 할 플랫폼을 추가한다.
Player인 공은 Rigidbody 2D와 Collider 2D를 넣어준다.
그리고 플랫폼에도 box Collider 2D를 생성한다.
Project - Create - 2D - Physics Material 2D 생성
이름을 Bouns로 수정한다.
Friction 0 (마찰력 - 0이면 안 미끄러짐)
Bounciness 1 (탄성 - 높을수록 점점 더 튀어 오름)
이제 Circle Collider 2D의 Material에 Bouns를 넣어준다.
2. 인풋 시스템
이제 Player 스크립트를 작성한다.
여기서 키 입력에 따른 인풋 시스템 실습을 해보자.
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Debug.Log("점프");
}
if (Input.GetKey(KeyCode.Alpha1)) //KeyCode는 눌리고 있을 때
{
Debug.Log("키가 눌리는 중");
}
if (Input.GetKeyUp(KeyCode.Space))
{
Debug.Log("키 눌렀다가 놓을 떄");
}
}
이 Space와 동일하게 다른 방법도 있다.
if (Input.GetButtonDown("Jump"))
{
Debug.Log("버튼 눌렀다!");
}
왜 여기는 Space가 아닌데 Space의 입력을 받을까?
Edit - Project Settings - Input Manager
여기에서 Jump가 이미 유니티에서 Space로 지정이 되어 있어 Jump가 인식이 되는 것이다.
여기에서 기존이 30인데, 숫자를 31로 늘리면
새로운 Input Manager가 생성이 된다.
여기에서 Name과 Positive Button을 원하는 대로 지정하면
위의 Jump의 Space의 코드와 동일하게 기능을 가져간다.
이제 위의 키를 지우고,
좌우 방향키에 따른 움직임 구현이다.
void Update()
{
Debug.Log(Input.GetAxis("Horizontal"));
}
콘솔에 찍히는 것은 왼쪽으로 가면 0 ~ -1까지 출력이 되고,
오른쪽으로 가면 0 ~ 1까지 출력이 된다.
이번엔 조금 다르게 적어보겠다.
GetAxis가 아닌 GetAxisRaw로 적어본다.
이것은 GetAxis처럼 소수점까지 출력이 되는 게 아니라
-1, 0, 1 이렇게 3가지 정수 숫자로만 표시된다.
void Update()
{
Debug.Log(Input.GetAxisRaw("Horizontal"));
}
이제 인풋 시스템을 뒤로하고, 물리적인 움직임을 구현해 볼 것이다.
Rigidbody를 넣을 필드를 구성하고, 물리적인 움직임 코드를 작성한다.
using UnityEngine;
public class Player : MonoBehaviour
{
public Rigidbody2D rb;
void Start()
{
rb.linearVelocity = new Vector2(5, rb.linearVelocity.y);
}
}
이제 인풋 시스템과 물리적인 움직임 구현을 합쳐본다.
rb.linerVelocity는 rigidbody의 현재 속도를 설정하는 데 사용된다.
x축은 속도 입력 값에 속하고, y축은 기존 속도를 유지한다.
using UnityEngine;
public class Player : MonoBehaviour
{
public Rigidbody2D rb;
private float xInput;
void Start()
{
}
void Update()
{
xInput = Input.GetAxisRaw("Horizontal");
rb.linearVelocity = new Vector2(xInput, rb.linearVelocityY);
}
}
이제 벽을 하나 더 생성해 준다.
Update 줄에서 xInput * 4를 하게 되면 기존보다 이동 속도가 4배 빨라진다.
rb.linearVelocity = new Vector2(xInput * 4, rb.linearVelocityY);
아니면 변수로 지정하는 방법도 있다.
moveSpeed 변수를 지정해 xInput * moveSpeed로 바꿀 수 있다.
using UnityEngine;
public class Player : MonoBehaviour
{
public Rigidbody2D rb;
private float xInput;
public float moveSpeed;
void Start()
{
}
void Update()
{
xInput = Input.GetAxisRaw("Horizontal");
rb.linearVelocity = new Vector2(xInput * moveSpeed, rb.linearVelocityY);
}
}
이렇게 인스펙터에서 속도를 지정해 줄 수 있다.
3. 점프
이제 점프를 구현할 것이다.
기존의 rb.linearVelocityY 대신에 rb.linearVelocity.y로 변경하고,
GetKeyDown으로 Space 바를 누르면 점프가 되는 코드를 구현한다.
using UnityEngine;
public class Player : MonoBehaviour
{
public Rigidbody2D rb;
private float xInput;
public float moveSpeed;
public float jumpForce;
void Start()
{
}
void Update()
{
xInput = Input.GetAxisRaw("Horizontal");
rb.linearVelocity = new Vector2(xInput * moveSpeed, rb.linearVelocity.y);
if(Input.GetKeyDown(KeyCode.Space))
{
rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce);
}
}
}
이렇게 Jump를 입력할 수 있다.
그리고 private 변수도 Inspector 창에서 수정 가능하게 해주는 속성을 입력할 수도 있다.
이것을 사용하면 다른 클래스에서 값을 변경하지 못하고, 코드 구조를 안전하게 유지할 수 있다.
위에서 나온 것이지만, 복습의 개념으로 다시 한번 짚고 넘어가자.
private Rigidbody2D rb;
private float xInput;
[SerializeField]
private float moveSpeed;
[SerializeField]
public float jumpForce;
여기에서 Rigidbody2D 또한 private로 변경하면 코드에서 직접 가져와야 한다.
void Start()
{
rb = GetComponent<Rigidbody2D>();
}
이제 플레이어 이미지를 배치하고 공 오브젝트에 넣었던 기능을 넣는다.
Rigidbody2D, Capsule Collider 2D, Player Script를 넣어준다.
4. 애니메이션
이제 애니메이터를 자식으로 생성해 적용하는 방법을 해볼 것이다.
GameObject를 Player 자식으로 생성한다.
그리고 Player 객체의 Sprite Renderer를 지워준다.
자식인 Animator에 Sprite Renderer를 생성해서 이미지를 넣어준다.
Scene아래에 Pivot으로 변경 후에 자식인 Animator를 움직여서
Player오브젝트에서 Transform이 정 가운데로 올 수 있게 한다.
z 체크 해제를 해서
z축으로 회전하는 것을 막아준다.
이제 애니메이션을 생성할 것이다.
Project에서 Animator Controller를 생성하고 Animator 오브젝트에게 붙여준다.
(1) Player idle
그리고 Animation 창에서 Player_idle 만들고
idle 상태인 이미지를 모두 선택해 animation으로 가져와준다.
Animation 창에 프레임 숫자 가장 오른쪽에 보면 세로로 된 점 3개가 보일 것이다.
위에 것 말고 밑에 것이다.
거기서 Show Sample Rate를 선택한다.
기존 Sample은 60으로 잡혀있는데, 12로 변경해 준다.
12로 변경하면 조금 느려진 것을 알 수 있다.
(2) Player Move
이제 새로운 클립을 만들 것이다.
Create New Clip을 선택한다.
그리고 playerMove (이동할 때의 모션)을 만들어준다.
idle 만들었던 방식과 동일하게 한다.
똑같이 Samples도 12로 맞춰준다.
이제 Animator에 보면 이렇게 되어 있을 것이다.
Animator 왼쪽 위에 Paramters 의 + 버튼을 누르면 bool을 클릭해
isMoving 이름의 bool을 생성해 준다.
idle과 Move 서로 transition으로 이어준다.
Inspector에서 move → idle은 isMoving false로 설정하고,
idle → move는 isMoving true로 설정한다.
Has Exit Time 체크 해제하고 Transition Duration을 0으로 설정한다.
현재 animation은 Player의 자식으로 되어있다.
이것을 불러와야 하는 스크립트를 작성해야 한다.
기존의 Player 스크립트를 다시 꺼낸다.
필드 하나만 선언하면 된다.
public Animator Anim;
이제 Animator를 넣는 공간이 생겼으니, 자식인 Animator를 드래그 앤 드롭을 해준다.
private으로 설정한다면?
Start에서 Animator를 찾는다.
GetComponent와 GetComponentInChildren을 비교하자면
GetComponent : 현재 오브젝트에서만 찾는다.
GetComponentInChildern : 현재 오브젝트 + 자식 오브젝트까지 탐색
private Animator Anim;
void Start()
{
rb = GetComponent<Rigidbody2D>();
Anim = GetComponentInChildren<Animator>();
}
그리고 animation의 bool 코드도 작성해 준다.
필드를 작성한다.
[SerializeField]
private bool isMoving;
Update() 안에 SetBool로 작성한다.
Anim.SetBool("isMoving", isMoving);
이렇게 Player 전체 코드이다.
using UnityEngine;
public class Player : MonoBehaviour
{
private Rigidbody2D rb;
private float xInput;
[SerializeField]
private float moveSpeed;
[SerializeField]
private float jumpForce;
private Animator Anim;
[SerializeField]
private bool isMoving;
void Start()
{
rb = GetComponent<Rigidbody2D>();
Anim = GetComponentInChildren<Animator>();
}
void Update()
{
xInput = Input.GetAxisRaw("Horizontal");
rb.linearVelocity = new Vector2(xInput * moveSpeed, rb.linearVelocity.y);
if(Input.GetKeyDown(KeyCode.Space))
{
rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce);
}
Anim.SetBool("isMoving", isMoving);
}
}
이제 플레이했을 때 isMoving이 체크된다면 playerMove 애니메이션이 실행된다.
이것을 실행하는 코드 조건문을 작성한다.
Update() 메서드 안에 플레이어 이동 상태를 확인하는 코드를 작성한다.
rb.linerVelocity.x : Rigidbody2D의 속도를 가져오고, x 방향이 0이 아니라면?
왼쪽 혹은 오른쪽으로 움직이고 있다는 결과이다.
그럴 때의 isMoving을 true로 만들어 Move를 실행하게 한다.
아닐 때에는 false이다.
if(rb.linearVelocity.x != 0)
{
isMoving = true;
}
else
{
isMoving = false;
}
이거와 동일하지만 다르게 표현하는 코드를 작성해 보자.
isMoving에 rb.linerVelocity.x의 결과를 직접 대입한다.
위의 코드와 동일하지만, 간결한 이 방법으로 진행하겠다.
isMoving = rb.linearVelocity.x != 0;
Anim.SetBool("isMoving", isMoving);
그렇게 된 전체코드이다.
using UnityEngine;
public class Player : MonoBehaviour
{
private Rigidbody2D rb;
private float xInput;
[SerializeField]
private float moveSpeed;
[SerializeField]
private float jumpForce;
private Animator Anim;
[SerializeField]
private bool isMoving;
void Start()
{
rb = GetComponent<Rigidbody2D>();
Anim = GetComponentInChildren<Animator>();
}
void Update()
{
xInput = Input.GetAxisRaw("Horizontal");
rb.linearVelocity = new Vector2(xInput * moveSpeed, rb.linearVelocity.y);
if(Input.GetKeyDown(KeyCode.Space))
{
rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce);
}
isMoving = rb.linearVelocity.x != 0;
Anim.SetBool("isMoving", isMoving);
}
}
그리고 여기에서 메서드를 따로 나눌 수도 있다.
작성했던 이동 상태를 확인하는 코드를 메서드를 생성해 옮긴다.
//Alt + 화살표 키로 코드 통째로 위 아래로 옮길 수 있다.
private void AnimatorControllers()
{
isMoving = rb.linearVelocity.x != 0;
Anim.SetBool("isMoving", isMoving);
}
Update()에서는 AnimatorControllers(); 하나만 작성해 실행한다.
동일한 방식으로 Jump 코드도 메서드를 나누어서 실행하는 방법으로 진행할 수 있다.
private void Jump()
{
rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce);
}
if(Input.GetKeyDown(KeyCode.Space))
{
Jump();
}
Move도 나누어주겠다.
private void Movement()
{
rb.linearVelocity = new Vector2(xInput * moveSpeed, rb.linearVelocity.y);
}
void Update()
{
xInput = Input.GetAxisRaw("Horizontal");
Movement();
if (Input.GetKeyDown(KeyCode.Space))
{
Jump();
}
AnimatorControllers();
}
Input과 GetKeyDown을 합쳐서 하나의 메서드로 만든다.
이렇게 코드 문서화로 만들 수 있다.
void Update()
{
Movement();
CheckInput();
AnimatorControllers();
}
private void CheckInput()
{
xInput = Input.GetAxisRaw("Horizontal");
if (Input.GetKeyDown(KeyCode.Space))
{
Jump();
}
}
만일 bool isMoving을 로컬에서만 사용할 경우
필드를 선언하지 않고, isMoving 변수 앞에 bool을 붙이는 방법도 있다.
private void AnimatorControllers()
{
bool isMoving = rb.linearVelocity.x != 0;
Anim.SetBool("isMoving", isMoving);
}
5. 방향 전환
이제 반대로 이동할 때 이미지가 반전이 되는 것을 실행할 것이다.
다양한 방법이 있는데 이번엔 Rotate로 해보겠다.
private void Flip()
{
transform.Rotate(0, 180, 0);
}
Update() 안에 이렇게 작성한다.
이렇게 하면 임시적으로 R키를 누르면 이미지가 반전된다.
if (Input.GetKeyDown(KeyCode.R))
Flip();
이제 제대로 된 방향 전환 코드를 작성한다.
facingDir와 facingRight의 초기값을 작성한다.
private int facingDir = 1;
private bool facingRight = true;
facingDir 값이 1이면 오른쪽, -1이면 왼쪽을 나타낸다.
이것을 -1을 곱해서 반전을 일으키게 한다.
facingRight는 캐릭터가 오른쪽을 보고 있는지 여부를 반전한다.
private void Flip()
{
facingDir = facingDir * -1;
facingRight = !facingRight;
transform.Rotate(0, 180, 0);
}
만일 오른쪽으로 이동 중인데, 왼쪽을 보고 있다면 Flip() 함수를 실행한다.
그 반대의 경우에도 Filp() 함수를 실행한다.
private void FlipController()
{
if(rb.linearVelocityX > 0 && !facingRight) //오른쪽으로 이동 중인데, 현재 왼쪽을 보고 있다면
{
Flip();
}
else if(rb.linearVelocityX < 0 && facingRight) //왼쪽으로 이동 중인데, 현재 오른쪽을 보고 있다면
{
Flip();
}
}
Update() 메서드 안에 FlipController(); 코드를 작성하면 방향에 맞게 자동으로 캐릭터가 회전한다.
그리고 임시 키로 작성한 코드는 삭제하고,
전체코드이다.
using UnityEngine;
public class Player : MonoBehaviour
{
private Rigidbody2D rb;
private float xInput;
[SerializeField]
private float moveSpeed;
[SerializeField]
private float jumpForce;
private Animator Anim;
private int facingDir = 1;
private bool facingRight = true;
void Start()
{
rb = GetComponent<Rigidbody2D>();
Anim = GetComponentInChildren<Animator>();
}
void Update()
{
Movement();
CheckInput();
FlipController();
AnimatorControllers();
}
private void CheckInput()
{
xInput = Input.GetAxisRaw("Horizontal");
if (Input.GetKeyDown(KeyCode.Space))
{
Jump();
}
}
private void Movement()
{
rb.linearVelocity = new Vector2(xInput * moveSpeed, rb.linearVelocity.y);
}
private void Jump()
{
rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce);
}
//Alt + 화살표 키로 코드 통째로 위 아래로 옮길 수 있다.
private void AnimatorControllers()
{
bool isMoving = rb.linearVelocity.x != 0;
Anim.SetBool("isMoving", isMoving);
}
private void Flip()
{
facingDir = facingDir * -1;
facingRight = !facingRight;
transform.Rotate(0, 180, 0);
}
private void FlipController()
{
if(rb.linearVelocityX > 0 && !facingRight) //0보다 크면 오른쪽을 누르는 상태
{
Flip();
}
else if(rb.linearVelocityX < 0 && facingRight)
{
Flip();
}
}
}
6. 점프 중복 방지
이제 점프키를 누르면 계속 점프하게 되어 무한으로 올라가는 현상을 바꾸어 줄 것이다.
다시 Player 스크립트로 간다.
실수형의 필드로 선언한다.
[SerializeField]
private float groundCheckDistance;
OnDrawGizmos() 활용해서 시작점에서 끝점까지 직선을 그린다.
private void OnDrawGizmos()
{
Gizmos.DrawLine(transform.position, new Vector3(transform.position.x, transform.position.y - groundCheckDistance));
}
값에 따라 아래쪽에 흰 선이 생기는 길이가 달라진다.
선의 길이는 바닥에 닿을까 말까 할 정도까지 늘려준다.
바닥을 감지하는 코드를 작성한다.
isGrounded 변수는 플레이어가 바닥에 닿아 있는지를 저장하는 변수이다.
true는 바닥에 닿은 거고, false는 공중에 떠 있는 것이다.
whatIsGround는 어떤 레이어를 바닥으로 인식할 것인지 지정한다.
private bool isGrounded;
[SerializeField]
private LayerMask whatIsGround;
Update() 안에 이 코드를 작성한다.
레이를 발사하여 충돌한 오브젝트가 특정 레이어에 속해 있는지 확인하는 것이다.
isGrounded = Physics2D.Raycast(transform.position, Vector2.down, groundCheckDistance, whatIsGround);
Debug.Log(isGrounded);
이렇게 된 Player의 전체 코드이다.
using UnityEngine;
public class Player : MonoBehaviour
{
private Rigidbody2D rb;
private float xInput;
[SerializeField]
private float moveSpeed;
[SerializeField]
private float jumpForce;
private Animator Anim;
private int facingDir = 1;
private bool facingRight = true;
[SerializeField]
private float groundCheckDistance;
private bool isGrounded;
[SerializeField]
private LayerMask whatIsGround;
void Start()
{
rb = GetComponent<Rigidbody2D>();
Anim = GetComponentInChildren<Animator>();
}
void Update()
{
CheckInput();
Movement();
isGrounded = Physics2D.Raycast(transform.position, Vector2.down, groundCheckDistance, whatIsGround);
Debug.Log(isGrounded);
FlipController();
AnimatorControllers();
}
private void CheckInput()
{
xInput = Input.GetAxisRaw("Horizontal");
if (Input.GetKeyDown(KeyCode.Space))
{
Jump();
}
}
private void Movement()
{
rb.linearVelocity = new Vector2(xInput * moveSpeed, rb.linearVelocity.y);
}
private void Jump()
{
rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce);
}
//Alt + 화살표 키로 코드 통째로 위 아래로 옮길 수 있다.
private void AnimatorControllers()
{
bool isMoving = rb.linearVelocity.x != 0;
Anim.SetBool("isMoving", isMoving);
}
private void Flip()
{
facingDir = facingDir * -1;
facingRight = !facingRight;
transform.Rotate(0, 180, 0);
}
private void FlipController()
{
if(rb.linearVelocityX > 0 && !facingRight) //0보다 크면 오른쪽을 누르는 상태
{
Flip();
}
else if(rb.linearVelocityX < 0 && facingRight)
{
Flip();
}
}
private void OnDrawGizmos()
{
Gizmos.DrawLine(transform.position, new Vector3(transform.position.x, transform.position.y - groundCheckDistance));
}
}
Add Layer로 Ground 이름의 Layer를 생성한다.
바닥 오브젝트인 Platform 둘 다 Layer를 Ground로 변경한다.
이제 이것을 한 번만 점프할 수 있게 제한하기 위해 Jump() 메서드에서
if(isGrounded)를 추가한다.
private void Jump()
{
if(isGrounded)
rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce);
}
이렇게 되면 점프하고 있을 동안엔 점프가 발동되지 않는다.
코드를 문서화하기 위해 Update()에 있는 아래 코드를 CollisionChecks 메서드로 추출한다.
isGrounded = Physics2D.Raycast(transform.position, Vector2.down, groundCheckDistance, whatIsGround);
Debug.Log(isGrounded);
void Update()
{
CheckInput();
Movement();
CollisionChecks();
FlipController();
AnimatorControllers();
}
private void CollisionChecks()
{
isGrounded = Physics2D.Raycast(transform.position, Vector2.down, groundCheckDistance, whatIsGround);
}
이제 코드가 많아지면 inspector에서 구분하기 어려워질 수 있다.
[Header(”구분할 이름”)]을 작성하면 코드를 구분하기 쉬워질 것이다.
코드의 순서도 중요하니까 직렬화를 생각하면서 하면 된다.
[Header("Collision info")]
[SerializeField]
private float groundCheckDistance;
[SerializeField]
private LayerMask whatIsGround;
private bool isGrounded;
이렇게 Collision info라는 제목이 생성되었다.
게임이 작은 부분 하나라도 건들 게 많다고 느껴지기도 한다.
신기하면서도 이렇게 게임이 만들어지니 많은 시간이 소요되겠구나를 느낀다.
이제 진짜 직업으로 바라봤을 때에는 게임을 분석하게 되겠지. . .
개자이너... 화이팅 .. . .
'Development > 멋쟁이사자처럼 게임개발 부트캠프' 카테고리의 다른 글
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 27일차 (2) - State 패턴 횡스크롤 2D (0) | 2025.04.18 |
---|---|
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 27일차 (1) - 상속 횡스크롤 2D (0) | 2025.04.17 |
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 26일차 (1) - 게임디자인패턴 : 스트래티지(Strategy), 스테이트(State) (0) | 2025.04.13 |
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 25일차 - 게임디자인패턴 : 싱글톤, 옵저버, 팩토리 (6) | 2025.04.13 |
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 24일차 - URP 2D Light (0) | 2025.04.12 |