[ 목차 ]
오늘의 포스팅은 상속을 활용한 횡스크롤 2D이다.
부모 클래스에서 공통 기능을 적용하고, 자식 클래스에서 확장하는 상속을 활용해
2D 횡스크롤을 이어서 구현할 것이다.
많이는 아니고 간단한 실습이니 가볍게 즐기길 바란다!
1. 상속
오브젝트가 많아지면 복잡해지니 상속을 사용해 효율적으로 작업을 하는 것이다.
이전에 상속을 배운 적은 있지만, 자세히 그리고 어떻게 활용하는지 잘 몰랐었다.
상속은 코드의 재사용성과 유지보수성이 높아지고, 공통 기능을 부모 클래스에 두고
자식 클래스에서 확장할 수 있는 것이 장점이다.
상속에는 여러 방법이 있다.
단일 상속(Single Inheritance) | 하나의 부모 클래스를 상속받는 기본적인 방식 | : (콜론) |
메서드 오버라이딩(Method Overriding) | 부모 클래스의 메서드를 자식 클래스에서 재정의 | virtual / override / base |
추상 클래스(Abstract Class) | 직접 객체 생성이 불가능하고, 자식 클래스가 반드시 구현해야 하는 메서드를 포함 | abstract |
인터페이스(Interface) | 여러 개의 동작을 정의하여 다중 구현 가능 | interface / implements |
조합 (Composition) | 상속 대신 클래스 내부에서 다른 클래스를 포함하여 사용 | 클래스 포함 |
여기에서 단일 상속과 메서드 오버라이딩을 활용해 상속 실습을 진행해 보겠다.
우선 부모의 스크립트를 만들어보겠다.
Shape 스크립트를 작성한다.
using UnityEngine;
public class Shape : MonoBehaviour
{
//상속 테스트
public string shapeName;
void Start()
{
Debug.Log("Hello, my shape is" + shapeName);
}
void Update()
{
}
}
이것을 Square 도형에 스크립트를 넣고 디버깅을 해본다.
이제 상속으로 만들려면 Start() 메서드를 바꾸어 주어야 한다.
여기에서 메서드 오버라이딩으로 public virtual void Start() 이렇게 변경한다.
using UnityEngine;
public class Shape : MonoBehaviour
{
//상속 테스트
public string shapeName;
//상속으로 만드려면 public / protected / public virtual 활용
public virtual void Start()
{
Debug.Log("Hello, my shape is " + shapeName);
}
void Update()
{
}
}
이제 자식 스크립트를 만들어본다.
새로운 스크립트로 Circle 생성한다.
여기에서의 Start() 메서드는
public override void Start()
이렇게 변경해 준다.
using UnityEngine;
public class Circle : Shape
{
public override void Start()
{
base.Start(); //부모 것을 호출하기
}
}
이렇게 되면 부모의 디버깅을 받아와 자식의 Shape Name인 Circle과 함께 출력된다.
여기서 코드의 흐름을 살펴보자.
디버깅 하나를 만들어준다.
using UnityEngine;
public class Circle : Shape
{
public override void Start()
{
base.Start(); //부모 것을 호출하기
Debug.Log("나는 도형 상속 받은 자식이다.");
}
}
콘솔에서 출력된 것을 보면 자식 스크립트에서는
상속받은 자식임을 알리는 부모 것을 받아와 출력한 후에
자식의 디버깅이 출력된 것을 알 수 있다.
조금 응용을 해본다.
부모 스크립트에서 속도 벡터와 중력을 가져온다.
using UnityEngine;
public class Shape : MonoBehaviour
{
//상속 테스트
public string shapeName;
public Rigidbody2D rb;
public Vector2 velocity;
//상속으로 만드려면 public / protected / public virtual 활용
public virtual void Start()
{
Debug.Log("Hello, my shape is " + shapeName);
rb.linearVelocity = velocity;
}
void Update()
{
}
}
이렇게 부모와 자식 스크립트 둘 다 같은 항목이 추가된 것을 알 수 있다.
2. 상속 적용하기
Player 스크립트에서 이제 부모로 만들 것을 새로운 Entity 스크립트로 이사를 해볼 것이다.
공통적으로 들어갈 목록을 정해서 가져오는 것이다.
using UnityEngine;
//Entity란? 게임오브젝트, NPC, 플레이어 등을 지칭하는 개체
public class Entity : MonoBehaviour
{
protected Rigidbody2D rb;
protected Animator Anim;
protected int facingDir = 1;
protected bool facingRight = true;
[Header("Collision info")]
[SerializeField] protected Transform groundCheck;
[SerializeField] protected float groundCheckDistance;
[SerializeField] protected LayerMask whatIsGround;
protected bool isGrounded;
protected virtual void Start() //상속
{
rb = GetComponent<Rigidbody2D>(); //객체에서 Rigidbody 2D 가져오기
Anim = GetComponentInChildren<Animator>(); //객체의 자식에게 있는 Animator 가져오기
}
protected virtual void Update()
{
CollisionChecks();
}
protected virtual void CollisionChecks()
{
isGrounded = Physics2D.Raycast(groundCheck.position, Vector2.down, groundCheckDistance, whatIsGround);
}
protected virtual void Flip()
{
facingDir = facingDir * -1;
facingRight = !facingRight;
transform.Rotate(0, 180, 0);
}
protected virtual void OnDrawGizmos()
{
Gizmos.DrawLine(groundCheck.position, new Vector3(groundCheck.position.x, groundCheck.position.y - groundCheckDistance));
}
}
그리고 Player 스크립트는 Player : MonoBehaviour가 아닌
Player : Entity 이렇게 변경해야 한다.
Entity 스크립트를 부모로 상속받기 위함이다.
이사를 하게 된 코드는 Player 스크립트에서 삭제해주고,
부모로부터 상속받은 자식으로
Start() 메서드와 Update()를
protected override void Start()
protected override void Update()
이렇게 변경한다.
메서드 안에는 위에 상속 테스트 했던 것처럼
base.Start()
base.Update()
이렇게 작성해 준다.
using System;
using UnityEngine;
public class Player : Entity
{
private float xInput;
[SerializeField]
private float moveSpeed;
[SerializeField]
private float jumpForce;
[Header("대쉬 정보")]
[SerializeField] private float dashspeed;
[SerializeField] private float dashDuration;
[SerializeField] private float dashTime;
[SerializeField] private float dashCooldown;
[SerializeField] private float dashCooldownTimer;
[Header("공격 정보")]
[SerializeField] private float comboTime = 0.3f;
private float comboTimeWindow;
private bool isAttacking;
private int comboCounter;
protected override void Start()
{
base.Start();
}
protected override void Update()
{
base.Update();
CheckInput();
Movement();
dashTime -= Time.deltaTime;
dashCooldownTimer -= Time.deltaTime;
comboTimeWindow -= Time.deltaTime;
FlipController();
AnimatorControllers();
}
public void AttackOver()
{
isAttacking = false;
comboCounter++;
if (comboCounter > 2)
comboCounter = 0;
}
private void CheckInput()
{
xInput = Input.GetAxisRaw("Horizontal");
if(Input.GetKeyDown(KeyCode.Mouse0)) //마우스 왼쪽
{
StartAttackEvent();
}
if (Input.GetKeyDown(KeyCode.Space))
{
Jump();
}
if (Input.GetKeyDown(KeyCode.LeftShift))
{
DashAbility();
}
}
private void StartAttackEvent()
{
if (!isGrounded)
return;
if (comboTimeWindow < 0)
comboCounter = 0;
isAttacking = true;
comboTimeWindow = comboTime;
}
private void DashAbility()
{
if(dashCooldownTimer < 0 && !isAttacking)
{
dashCooldownTimer = dashCooldown;
dashTime = dashDuration;
}
}
private void Movement()
{
if(isAttacking)
{
rb.linearVelocity = new Vector2(0, 0);
}
//대쉬일 때와 아닐 때
if(dashTime > 0)
{
rb.linearVelocity = new Vector2(facingDir * dashspeed, 0);
}
else
{
rb.linearVelocity = new Vector2(xInput * moveSpeed, rb.linearVelocity.y);
}
}
private void Jump()
{
if(isGrounded)
rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce);
}
//Alt + 화살표 키로 코드 통째로 위 아래로 옮길 수 있다.
private void AnimatorControllers()
{
bool isMoving = rb.linearVelocity.x != 0;
Anim.SetFloat("yVelocity", rb.linearVelocityY);
Anim.SetBool("isMoving", isMoving);
Anim.SetBool("isGrounded", isGrounded);
Anim.SetBool("isDashing", dashTime > 0); //0보다 클 때
Anim.SetBool("isAttacking", isAttacking);
Anim.SetInteger("comboCounter", comboCounter);
}
private void FlipController()
{
if(rb.linearVelocityX > 0 && !facingRight) //0보다 크면 오른쪽을 누르는 상태
{
Flip();
}
else if(rb.linearVelocityX < 0 && facingRight)
{
Flip();
}
}
}
새롭게 변형한 것이 있다면
GroundCheck를 GameObject로 생성해 Player의 자식으로 넣어준다.
그리고 Player Inspector에서 자식으로 생성한 GroundCheck를 넣어준다.
겉보기엔 움직임 동작을 하는데에 차이는 없어 보이지만,
코드를 보기 좋게 문서화한 것으로 추후에 도움이 많이 되는 작업이니 꼭 알아두자!
3. 적 생성하기
이제 플레이어와 적대적인 몬스터를 만들어준다.
적의 이미지를 가져와 배치한다.
Hierarchy - Create Empty Parent
이것은 현재 오브젝트에서 부모를 생성하는 것이다.
생성되면 현재 오브젝트는 생성된 오브젝트의 자식으로 들어가게 된다.
부모 오브젝트에 Capsule Collider 2D, Rigidbody 2D를 생성한다.
새로운 스크립트 Enemy_Skeleton을 생성한다.
상속을 Entity로 받게 한다.
적의 이동 정보와 바라보는 방향으로 일정 속도로 움직이게 하는 코드를 작성한다.
using UnityEngine;
public class Enemy_Skeleton : Entity
{
[Header("이동 정보")]
[SerializeField] private float moveSpeed;
protected override void Start()
{
base.Start();
}
protected override void Update()
{
base.Update();
rb.linearVelocity = new Vector2(moveSpeed * facingDir, rb.linearVelocity.y);
}
}
여기에서도 플레이어와 마찬가지로 GroundCheck를 생성해 Inspector GroundCheck에 넣는다.
땅을 체크하는 거리를 설정하고,
어떤 레이어인지 Ground 레이어를 생성해 지정해 준다.
이동 정보에서는 얼마 정도의 속도로 이동할 것인지 입력한다.
유니티에선 기본적으로 Interpolate가 None으로 되어있다.
이것은 네트워크 때에도 중요한데, 충돌처리 체크가 덜할 때가 있다.
충돌 처리를 더 정확하게 항상 체크하게 할 수 있게 None에서 Interpolate로 변경한다.
충돌처리 GroundCheck 선을 조금 더 앞에 둘 수도 있다.
이것은 하면서 원하는 대로 조정해 보는 것이 좋다.
이제 isGrounded가 아닐 때 방향 전환을 해주는 코드를 하나 추가한다.
Enemy_Skeleton 스크립트로 간다.
protected override void Update()
{
base.Update();
if (!isGrounded)
Flip();
rb.linearVelocity = new Vector2(moveSpeed * facingDir, rb.linearVelocity.y);
}
포함된 전체 스크립트이다.
using UnityEngine;
public class Enemy_Skeleton : Entity
{
[Header("이동 정보")]
[SerializeField] private float moveSpeed;
protected override void Start()
{
base.Start();
}
protected override void Update()
{
base.Update();
if (!isGrounded)
Flip();
rb.linearVelocity = new Vector2(moveSpeed * facingDir, rb.linearVelocity.y);
}
}
4. 적의 벽 체크 후 방향 전환
이제 적이 벽도 체크해서 방향 전환을 해야 한다.
Entity 스크립트로 가서
벽체크하는 코드를 작성한다.
[SerializeField] protected Transform wallCheck; //벽체크
[SerializeField] protected float wallCheckDistance;
protected bool isWallDetected;
라인 그리는 코드에서 벽체크 라인도 그려주는 코드를 생성한다.
protected virtual void OnDrawGizmos()
{
Gizmos.DrawLine(groundCheck.position, new Vector3(groundCheck.position.x, groundCheck.position.y - groundCheckDistance));
Gizmos.DrawLine(wallCheck.position, new Vector3(wallCheck.position.x + wallCheckDistance * facingDir, wallCheck.position.y));
}
벽을 체크하는 것도 추가한다.
protected virtual void CollisionChecks()
{
isGrounded = Physics2D.Raycast(groundCheck.position, Vector2.down, groundCheckDistance, whatIsGround);
isWallDetected = Physics2D.Raycast(wallCheck.position, Vector2.right, wallCheckDistance * facingDir, whatIsGround);
}
이제 자식 스크립트 Enemy_Skeleton으로 가서
! isGrounded || isWallDetected
이렇게 수정한다.
이것은 바닥이 아니거나 벽이거나! 둘 중 하나만 충족해도 방향 전환을 한다는 것이다.
protected override void Update()
{
base.Update();
if (!isGrounded || isWallDetected)
Flip();
rb.linearVelocity = new Vector2(moveSpeed * facingDir, rb.linearVelocity.y);
}
이제 Wall Check가 필요하니
자식 오브젝트로 생성해 Skeleton Inspector에 넣는다.
공통인 Entity 스크립트에 벽체크를 넣었으면
Player 오브젝트에도 WallCheck를 생성해서 넣어줘야 한다.
5. 플레이어 탐지 및 공격
플레이어를 감지하고, 공격하는 스크립트를 만들어보겠다.
Enemy_Skeleton 스크립트를 추가 및 수정한다.
Update() 메서드 안에 플레이어 감지 이동 또는 공격을 하는 코드를 추가한다.
거리가 1 이상이면 플레이어를 추적한다.
거리가 1 이하면 공격 모드를 활성화한다.
if (isPlayerDetected.distance > 1)
{
if (isPlayerDetected.distance > 1)
{
//추적
rb.linearVelocity = new Vector2(moveSpeed * 1.5f * facingDir, rb.linearVelocity.y);
Debug.Log("플레이어 발견!");
isAttacking = false;
}
else
{
Debug.Log("공격! " + isPlayerDetected.collider.gameObject.name);
isAttacking = true;
}
}
그리고 Movement() 메서드를 추출해
공격이 아닐 때 이동하는 것으로 변경한다.
private void Movement()
{
if(!isAttacking) //공격이 아닐 때 실행
{
rb.linearVelocity = new Vector2(moveSpeed * facingDir, rb.linearVelocity.y);
}
}
플레이어를 탐지하는 코드를 추가한다.
protected override void CollisionChecks()
{
base.CollisionChecks();
isPlayerDetected = Physics2D.Raycast(transform.position, Vector2.right, playerCheckDistance * facingDir, whatIsPlayer);
}
감지 범위를 시각적으로 나타내기 위한 선을 그려준다. (기즈모)
컬러도 변경할 수 있다.
protected override void OnDrawGizmos()
{
base.OnDrawGizmos();
Gizmos.color = Color.blue;
Gizmos.DrawLine(transform.position, new Vector3(transform.position.x + playerCheckDistance * facingDir, transform.position.y));
}
이제 적이 플레이어를 감지하기 위해 Player 레이어를 생성한다.
그리고 Player 오브젝트에 Layer를 Player로 변경한다.
변경할 때 이런 창이 하나 뜰 텐데,
자식까지 변경할 것인지, 부모만 변경할 것인지 물어보는 것이다.
yes를 누르면 자식까지 변경, No를 부르면 부모만 변경이다.
이제 Skeleton 오브젝트 안에 속해있는 스크립트에서 감지 거리와 어떤 레이어로 Player를 찾을 건지 설정을 해야 한다.
What is Player에 Player 레이어를 선택하고, 거리를 조정한다.
플레이어와 파란 선이 닿으면
플레이어 발견! 이 출력되고,
거리가 1 이하가 되면 공격! Player로 변경된다.
이렇게 상속을 이용해 적이 플레이어를 발견하는 것까지 구현하였다.
이 이후에도 많은 기능이 있을 것이지만, 그것은 후에 상태 디자인패턴과 같이 구현할 때 이어서 할 것이다.
지금까지 2D 횡스크롤 실습을 하였을 때는 기즈모로 벽과 땅, 플레이어를 캐치한다는 사실이 가장 신기한 것 같다.
배우기 전의 나였으면 네모난 범위를 설정해 이것이 적용되는 코드를 만들어야 하는 줄 알았다..
그렇게 스크립트를 GPT와 활용해서 해보았던 나 자신이 부끄럽다!!
오늘도 고생한 나에게 칭찬~
'Development > 멋쟁이사자처럼 게임개발 부트캠프' 카테고리의 다른 글
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 28일차 - State 패턴 횡스크롤 2D (2) (0) | 2025.04.18 |
---|---|
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 27일차 (2) - State 패턴 횡스크롤 2D (0) | 2025.04.18 |
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 26일차 (2) - 2D 횡스크롤 (0) | 2025.04.16 |
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 26일차 (1) - 게임디자인패턴 : 스트래티지(Strategy), 스테이트(State) (0) | 2025.04.13 |
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 25일차 - 게임디자인패턴 : 싱글톤, 옵저버, 팩토리 (6) | 2025.04.13 |