[ 목차 ]
오늘은 저번 포스팅에 이어서 State 패턴 횡스크롤 2D를 한다.
이번 포스팅은 벽 상태와 플레이어 공격에 대해서 배워보도록 한다!
State 패턴 횡스크롤 2D (1)
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 27일차 (2) - State 패턴 횡스크롤 2D
[ 목차 ] 오늘의 포스팅은 상속을 활용한 횡스크롤 2D를 간단하게 실습한 이후에 게임디자인 패턴에서 배운 state 패턴 응용을 하는 것이다. 1. 스테이트머신 패턴 응용 이제 스테이트머신 패턴을
gang-design.com
State 패턴 횡스크롤 2D (2)
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 28일 - State 패턴 횡스크롤 2D (2)
[ 목차 ] 오늘의 포스팅은 지난 실습에 이어서 하는 내용이다.추가적으로는 State 패턴을 활용한다는 점인데, 게임디자인패턴 중 State를 실습을 통해 어떻게 쓰이는지 하나씩 알아보도록 한다! Stat
gang-design.com
1. 벽에 미끄러지는 상태
Physica Material 2D를 생성한다.
Create - 2D - Physics Material 2D로 선택한다.
Friction을 0으로 바꾸어준다.
만든 메테리얼을 Player의 Capsule Collider 2D의 Material에 넣어준다.
이제 벽에 붙었을 때의 캐릭터 애니메이션 클립을 만든다.
Animator에 새롭게 추가된 애니메이션 클립을 Entry와 Exit를 연결해 준다.
새로운 Parameters WallSlide를 생성한다.
player_wallSlide → Exit로 가는 트랜지션의 Inspector로 가서
Has Exit Time 체크해제
Transition Duration 0
WallSlide false로 설정한다.
벽을 타고 올라갈 지형을 배치해 준다.
이제 벽에 붙어 미끄러질 때의 상태에 대한 코드를 작성한다.
새로운 스크립트 PlayerWallSilideState 작성한다.
PlayerState를 상속받을 수 있게 MonoBehaviour가 아닌 PlayerState로 작성한다.
public class PlayerWallSilideState : PlayerState
{
}
Alt + Enter를 통해 생성자 생성을 한다.
public class PlayerWallSilideState : PlayerState
{
public PlayerWallSilideState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}
}
다시 Alt + Enter를 통해 재정의 생성을 한다.
그럼 이렇게 기본 세팅이 완료된다.
using UnityEngine;
public class PlayerWallSilideState : PlayerState
{
public PlayerWallSilideState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}
public override void Enter()
{
base.Enter();
}
public override void Exit()
{
base.Exit();
}
public override void Update()
{
base.Update();
}
}
Player 스크립트로 가서
플레이어의 상태 중 wallSlide 상태 추가한다.
public PlayerWallSilideState wallSlide { get; private set; }
각 상태 인스턴스 생성에서도 wallSlide를 추가한다.
private void Awake()
{
// 상태 머신 인스턴스 생성
stateMachine = new PlayerStateMachine();
// 각 상태 인스턴스 생성 (this: 플레이어 객체, stateMachine: 상태 머신, "Idle"/"Move": 상태 이름)
idleState = new PlayerIdleState(this, stateMachine, "Idle");
moveState = new PlayerMoveState(this, stateMachine, "Move");
jumpState = new PlayerJumpState(this, stateMachine, "Jump");
airState = new PlayerAirState(this, stateMachine, "Jump");
dashState = new PlayerDashState(this, stateMachine, "Dash");
wallSlide = new PlayerWallSilideState(this, stateMachine, "WallSlide");
}
벽을 감지하는 함수를 추가한다.
캐릭터가 바라보는 방향으로 Ray선이 Ground레이어 물체가 닿으면 true를 반환한다.
public bool IsWallDetected() => Physics2D.Raycast(wallCheck.position, Vector2.right * facingDir, wallCheckDistance, whatIsGround);
그렇게 Player 전체 스크립트이다.
using UnityEngine;
public class Player : MonoBehaviour
{
[Header("이동 정보")]
public float moveSpeed = 12f;
public float jumpForce;
//추가된거
[Header("대시 정보")]
[SerializeField] private float dashCooldown;
private float dashUsageTimer;
public float dashSpeed;
public float dashDuration;
public float dashDir { get; private set; }
[Header("충돌 정보")]
[SerializeField] private Transform groundCheck;
[SerializeField] private float groundCheckDistance;
[SerializeField] private Transform wallCheck;
[SerializeField] private float wallCheckDistance;
[SerializeField] private LayerMask whatIsGround;
public int facingDir { get; private set; } = 1;
private bool facingRight = true;
#region Components
public Animator anim { get; private set; }
public Rigidbody2D rb { get; private set; }
#endregion
#region States
// 플레이어의 상태를 관리하는 상태 머신
public PlayerStateMachine stateMachine { get; private set; }
// 플레이어의 상태 (대기 상태, 이동 상태)
public PlayerIdleState idleState { get; private set; }
public PlayerMoveState moveState { get; private set; }
public PlayerJumpState jumpState { get; private set; }
public PlayerAirState airState { get; private set; }
public PlayerDashState dashState { get; private set; }
public PlayerWallSlideState wallSlide { get; private set; }
#endregion
private void Awake()
{
// 상태 머신 인스턴스 생성
stateMachine = new PlayerStateMachine();
// 각 상태 인스턴스 생성 (this: 플레이어 객체, stateMachine: 상태 머신, "Idle"/"Move": 상태 이름)
idleState = new PlayerIdleState(this, stateMachine, "Idle");
moveState = new PlayerMoveState(this, stateMachine, "Move");
jumpState = new PlayerJumpState(this, stateMachine, "Jump");
airState = new PlayerAirState(this, stateMachine, "Jump");
dashState = new PlayerDashState(this, stateMachine, "Dash");
wallSlide = new PlayerWallSlideState(this, stateMachine, "WallSlide");
}
private void Start()
{
anim = GetComponentInChildren<Animator>();
rb = GetComponent<Rigidbody2D>();
// 게임 시작 시 초기 상태를 대기 상태(idleState)로 설정
stateMachine.Initialize(idleState);
}
private void Update()
{
stateMachine.currentState.Update();
CheckForDashInput();
}
//추가
private void CheckForDashInput()
{
dashUsageTimer -= Time.deltaTime;
if (Input.GetKeyDown(KeyCode.LeftShift) && dashUsageTimer < 0)
{
dashUsageTimer = dashCooldown;
dashDir = Input.GetAxisRaw("Horizontal");
if (dashDir == 0)
dashDir = facingDir;
stateMachine.ChangeState(dashState);
}
}
public void SetVelocity(float _xVelocity, float _yVelocity)
{
rb.linearVelocity = new Vector2(_xVelocity, _yVelocity);
FlipController(_xVelocity);
}
public bool IsGroundDetected() => Physics2D.Raycast(groundCheck.position, Vector2.down, groundCheckDistance, whatIsGround);
public bool IsWallDetected() => Physics2D.Raycast(wallCheck.position, Vector2.right * facingDir, wallCheckDistance, whatIsGround);
private 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, wallCheck.position.y));
}
public void Flip()
{
facingDir = facingDir * -1;
facingRight = !facingRight;
transform.Rotate(0, 180, 0);
}
public void FlipController(float _x)
{
if (_x > 0 && !facingRight)
Flip();
else if (_x< 0 && facingRight)
Flip();
}
}
PlayerState에 yInput에 관한 코드를 추가한다.
protected float yInput;
상하를 입력받는 yInput 코드를 Update() 메서드 안에 넣는다.
yInput = Input.GetAxisRaw("Vertical");
PlayerState 전체 스크립트이다.
using UnityEngine;
public class PlayerState
{
protected PlayerStateMachine stateMachine;
protected Player player;
protected Rigidbody2D rb;
protected float xInput;
protected float yInput;
private string animBoolName;
protected float stateTimer;
public PlayerState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName)
{
this.player = _player;
this.stateMachine = _stateMachine;
this.animBoolName = _animBoolName;
}
public virtual void Enter()
{
player.anim.SetBool(animBoolName, true);
rb = player.rb;
}
public virtual void Update()
{
stateTimer -= Time.deltaTime;
xInput = Input.GetAxisRaw("Horizontal");
player.anim.SetFloat("yVelocity", rb.linearVelocityY);
}
public virtual void Exit()
{
player.anim.SetBool(animBoolName, false);
}
}
공중 상태 PlayerAirState 스크립트로 이동한다.
Update() 메서드에서 공중에 있으면서 벽에 가까이 있으면 벽에 붙는 슬라이드 상태로 전환한다.
if (player.IsWallDetected())
stateMachine.ChangeState(player.wallSlide);
바닥에 닿았을 때 idle 상태로 전환한다.
if (player.IsGroundDetected())
stateMachine.ChangeState(player.idleState);
방향키 입력되면 공중에서도 수평 이동 속도를 설정해 준다.
if (xInput != 0)
player.SetVelocity(player.moveSpeed * 0.8f * xInput, rb.linearVelocityY);
그렇게 PlayerAirState 전체 스크립트이다.
using UnityEngine;
public class PlayerAirState : PlayerState
{
public PlayerAirState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}
public override void Enter()
{
base.Enter();
}
public override void Exit()
{
base.Exit();
}
public override void Update()
{
base.Update();
if (player.IsWallDetected())
stateMachine.ChangeState(player.wallSlide);
if (player.IsGroundDetected())
stateMachine.ChangeState(player.idleState);
if (xInput != 0)
player.SetVelocity(player.moveSpeed * 0.8f * xInput, rb.linearVelocityY);
}
}
PlayerWallSilideState 스크립트의 Update() 메서드로 이동한다.
방향키가 눌렸고, 그 방향이 현재 바라보는 방향과 다르면 Idle 상태로 전환한다.
자연스러운 전환을 위한 코드이다.
if(xInput != 0)
{
if (xInput != 0 && player.facingDir != xInput)
stateMachine.ChangeState(player.idleState);
}
그리고 플레이어가 벽에 붙었을 때 움직임 제어와 상태 전환을 다루는 스크립트를 추가한다.
if (yInput < 0)
rb.linearVelocity = new Vector2(0, rb.linearVelocityY);
else
rb.linearVelocity = new Vector2(0, rb.linearVelocityY * 0.7f);
if (player.IsGroundDetected())
stateMachine.ChangeState(player.idleState);
PlayerWallSlideState 전체 스크립트이다.
using UnityEngine;
public class PlayerWallSlideState : PlayerState
{
public PlayerWallSlideState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}
public override void Enter()
{
base.Enter();
}
public override void Exit()
{
base.Exit();
}
public override void Update()
{
base.Update();
if (xInput != 0 && player.facingDir != xInput)
{
stateMachine.ChangeState(player.idleState);
}
if (yInput < 0)
rb.linearVelocity = new Vector2(0, rb.linearVelocityY);
else
rb.linearVelocity = new Vector2(0, rb.linearVelocityY * 0.7f);
if (player.IsGroundDetected())
stateMachine.ChangeState(player.idleState);
}
}
PlayerIdleState로 가서
벽에 붙어 있을 때에 입력 방향이 캐릭터가 바라보는 방향과 같으면 아무것도 하지 않고 리턴한다.
using UnityEngine;
public class PlayerIdleState : PlayerGroundedState
{
public PlayerIdleState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName)
: base(_player, _stateMachine, _animBoolName)
{
}
public override void Enter()
{
base.Enter();
}
public override void Update()
{
base.Update();
if (xInput == player.facingDir && player.IsWallDetected())
return;
if (xInput != 0)
stateMachine.ChangeState(player.moveState);
}
public override void Exit()
{
base.Exit();
}
}
2. 벽 점프
이제 벽 점프를 구현할 것이다.
새로운 스크립트
PlayerWallJump 스크립트를 만든다.
상속을 PlayerState를 만들고, 생성자 생성과 재정의 생성을 통해 기본 세팅을 한다.
상태에 진입하면 stateTimer를 0.4초로 초기화한다. (0.4초 동안은 벽 점프 상태 유지)
플레이어의 이동 속도 방향과 힘을 직접 지정하는 코드를 추가한다.
Update() 메서드에서 0.4초 지나면 AirState 상태로 전환된다.
using UnityEngine;
public class PlayerWallJump : PlayerState
{
public PlayerWallJump(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}
public override void Enter()
{
base.Enter();
stateTimer = 0.4f;
player.SetVelocity(5 * -player.facingDir, player.jumpForce);
}
public override void Exit()
{
base.Exit();
}
public override void Update()
{
base.Update();
if(stateTimer < 0)
{
stateMachine.ChangeState(player.airState);
}
}
}
Player 스크립트로 이동한다.
플레이어의 상태 코드를 추가한다. (벽 점프 상태)
public PlayerWallJump wallJump { get; private set; }
Awake() 메서드에서 벽 점프 인스턴스 생성 코드를 추가한다.
wallJump = new PlayerWallJump(this, stateMachine, "WallJump");
PlayerWallSlideState로 이동한다.
Update() 메서드에서 스페이스를 누르면 벽 점프를 하는 코드를 추가한다.
if(Input.GetKeyDown(KeyCode.Space))
{
stateMachine.ChangeState(player.wallJump);
return;
}
PlayerWallSlideState 전체 코드이다.
using UnityEngine;
public class PlayerWallSlideState : PlayerState
{
public PlayerWallSlideState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}
public override void Enter()
{
base.Enter();
}
public override void Exit()
{
base.Exit();
}
public override void Update()
{
base.Update();
if(Input.GetKeyDown(KeyCode.Space))
{
stateMachine.ChangeState(player.wallJump);
return;
}
if (xInput != 0 && player.facingDir != xInput)
{
stateMachine.ChangeState(player.idleState);
}
if (yInput < 0)
rb.linearVelocity = new Vector2(0, rb.linearVelocityY);
else
rb.linearVelocity = new Vector2(0, rb.linearVelocityY * 0.7f);
if (player.IsGroundDetected())
stateMachine.ChangeState(player.idleState);
}
}
PlayerIdleState 스크립트로 이동한다.
Enter() 메서드 안에 속도를 완전히 멈추는 코드를 추가한다.
public override void Enter()
{
base.Enter();
rb.linearVelocity = new Vector2(0, 0);
}
PlayerWallJump 스크립트로 이동한다.
Update() 메서드에서 플레이어가 땅에 닿았는지 확인하고, 닿았다면 상태를 idle로 전환한다.
if (player.IsGroundDetected())
stateMachine.ChangeState(player.idleState);
PlayerDashState 스크립트로 이동한다.
Update() 메서드 안에 플레이어가 바닥에 없고,
벽에 붙어있다면 wallSlide 상태로 바꾸는 코드를 대시할 때도 적용한다.
if (!player.IsGroundDetected() && player.IsWallDetected())
stateMachine.ChangeState(player.wallSlide);
3. 플레이어 공격
PlayerPrimaryAttack 이름의 스크립트를 새로 생성한다.
PlayerState 상속으로 하고, 생성자 생성과 재정의 생성을 한다.
using UnityEngine;
public class PlayerPrimaryAttack : PlayerState
{
public PlayerPrimaryAttack(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}
public override void Enter()
{
base.Enter();
}
public override void Exit()
{
base.Exit();
}
public override void Update()
{
base.Update();
}
}
Player 스크립트로 이동해서 상태 변수와
Awake() 메서드 안에 인스턴스 생성 코드를 추가한다.
public PlayerPrimaryAttack primaryAttack { get; private set; }
primaryAttack = new PlayerPrimaryAttack(this, stateMachine, "Attack");
이제 공격 애니메이션을 추가한다.
player_Attack1 이름의 Animation Clip을 만들어 해당 애니메이션 이미지를 넣는다.
player_Attack2, 3도 만든다.
Animator에서 Attack 이름의 Bool Parameters를 생성한다.
Entry → player_Attack1 → Exit로 이어준다.
player_Attack1 → Exit 트랜지션
아래 사진과 같이 Inspector를 수정한다.
PlayerGroundedState 스크립트 이동한다.
Update() 메서드에서 아래 코드 추가
마우스 왼쪽 버튼을 클릭하면 공격상태로 바뀐다.
if (Input.GetKeyDown(KeyCode.Mouse0))
stateMachine.ChangeState(player.primaryAttack);
PlayerGroundedState 전체 스크립트
using UnityEngine;
public class PlayerGroundedState : PlayerState
{
public PlayerGroundedState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName)
: base(_player, _stateMachine, _animBoolName)
{
}
public override void Enter()
{
base.Enter();
}
public override void Exit()
{
base.Exit();
}
public override void Update()
{
base.Update();
if (Input.GetKeyDown(KeyCode.Mouse0))
stateMachine.ChangeState(player.primaryAttack);
if (!player.IsGroundDetected())
stateMachine.ChangeState(player.airState);
if (Input.GetKeyDown(KeyCode.Space) && player.IsGroundDetected())
stateMachine.ChangeState(player.jumpState);
}
}
PlayerState로 이동한다.
공격을 종료하는 트리거에 관한 코드를 적는다.
protected bool triggerCalled;
상태 진입 시인 Enter() 메서드 안에
triggerCalled 변수가 false로 아직 호출되지 않았다는 것을 표현한다.
public virtual void Enter()
{
player.anim.SetBool(animBoolName, true);
rb = player.rb;
triggerCalled = false;
}
애니메이션이 끝났을 때에 실행되는 이벤트 메서드를 추가한다.
public virtual void AnimationFinishTrigger()
{
triggerCalled = true;
}
PlayerState 전체 스크립트이다.
using UnityEngine;
public class PlayerState
{
protected PlayerStateMachine stateMachine;
protected Player player;
protected Rigidbody2D rb;
protected float xInput;
protected float yInput;
private string animBoolName;
protected float stateTimer;
protected bool triggerCalled;
public PlayerState(Player _player, PlayerStateMachine _stateMachine, string _animBoolName)
{
this.player = _player;
this.stateMachine = _stateMachine;
this.animBoolName = _animBoolName;
}
public virtual void Enter()
{
player.anim.SetBool(animBoolName, true);
rb = player.rb;
triggerCalled = false;
}
public virtual void Update()
{
stateTimer -= Time.deltaTime;
xInput = Input.GetAxisRaw("Horizontal");
yInput = Input.GetAxisRaw("Vertical");
player.anim.SetFloat("yVelocity", rb.linearVelocityY);
}
public virtual void Exit()
{
player.anim.SetBool(animBoolName, false);
}
public virtual void AnimationFinishTrigger()
{
triggerCalled = true;
}
}
이제 끝나는 지점에 메서드를 추가하면 된다.
PlayerPrimaryAttack 스크립트로 이동한다.
Update() 메서드에 가서 조건문으로 triggerCalled가 실행되면 idle상태로 전환하는 것이다.
using UnityEngine;
public class PlayerPrimaryAttack : PlayerState
{
public PlayerPrimaryAttack(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}
public override void Enter()
{
base.Enter();
}
public override void Exit()
{
base.Exit();
}
public override void Update()
{
base.Update();
if (triggerCalled)
stateMachine.ChangeState(player.idleState);
}
}
Player 스크립트에 코드 하나를 추가한다.
public void AnimationTrigger() => stateMachine.currentState.AnimationFinishTrigger();
Player 전체 스크립트이다.
using UnityEngine;
public class Player : MonoBehaviour
{
[Header("이동 정보")]
public float moveSpeed = 12f;
public float jumpForce;
//추가된거
[Header("대시 정보")]
[SerializeField] private float dashCooldown;
private float dashUsageTimer;
public float dashSpeed;
public float dashDuration;
public float dashDir { get; private set; }
[Header("충돌 정보")]
[SerializeField] private Transform groundCheck;
[SerializeField] private float groundCheckDistance;
[SerializeField] private Transform wallCheck;
[SerializeField] private float wallCheckDistance;
[SerializeField] private LayerMask whatIsGround;
public int facingDir { get; private set; } = 1;
private bool facingRight = true;
#region Components
public Animator anim { get; private set; }
public Rigidbody2D rb { get; private set; }
#endregion
#region States
// 플레이어의 상태를 관리하는 상태 머신
public PlayerStateMachine stateMachine { get; private set; }
// 플레이어의 상태 (대기 상태, 이동 상태)
public PlayerIdleState idleState { get; private set; }
public PlayerMoveState moveState { get; private set; }
public PlayerJumpState jumpState { get; private set; }
public PlayerAirState airState { get; private set; }
public PlayerDashState dashState { get; private set; }
public PlayerWallSlideState wallSlide { get; private set; }
public PlayerWallJump wallJump { get; private set; }
public PlayerPrimaryAttack primaryAttack { get; private set; }
#endregion
private void Awake()
{
// 상태 머신 인스턴스 생성
stateMachine = new PlayerStateMachine();
// 각 상태 인스턴스 생성 (this: 플레이어 객체, stateMachine: 상태 머신, "Idle"/"Move": 상태 이름)
idleState = new PlayerIdleState(this, stateMachine, "Idle");
moveState = new PlayerMoveState(this, stateMachine, "Move");
jumpState = new PlayerJumpState(this, stateMachine, "Jump");
airState = new PlayerAirState(this, stateMachine, "Jump");
dashState = new PlayerDashState(this, stateMachine, "Dash");
wallSlide = new PlayerWallSlideState(this, stateMachine, "WallSlide");
wallJump = new PlayerWallJump(this, stateMachine, "WallJump");
primaryAttack = new PlayerPrimaryAttack(this, stateMachine, "Attack");
}
private void Start()
{
anim = GetComponentInChildren<Animator>();
rb = GetComponent<Rigidbody2D>();
// 게임 시작 시 초기 상태를 대기 상태(idleState)로 설정
stateMachine.Initialize(idleState);
}
private void Update()
{
stateMachine.currentState.Update();
CheckForDashInput();
}
public void AnimationTrigger() => stateMachine.currentState.AnimationFinishTrigger();
private void CheckForDashInput()
{
if (IsWallDetected())
return;
dashUsageTimer -= Time.deltaTime;
if (Input.GetKeyDown(KeyCode.LeftShift) && dashUsageTimer < 0)
{
dashUsageTimer = dashCooldown;
dashDir = Input.GetAxisRaw("Horizontal");
if (dashDir == 0)
dashDir = facingDir;
stateMachine.ChangeState(dashState);
}
}
public void SetVelocity(float _xVelocity, float _yVelocity)
{
rb.linearVelocity = new Vector2(_xVelocity, _yVelocity);
FlipController(_xVelocity);
}
public bool IsGroundDetected() => Physics2D.Raycast(groundCheck.position, Vector2.down, groundCheckDistance, whatIsGround);
public bool IsWallDetected() => Physics2D.Raycast(wallCheck.position, Vector2.right * facingDir, wallCheckDistance, whatIsGround);
private 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, wallCheck.position.y));
}
public void Flip()
{
facingDir = facingDir * -1;
facingRight = !facingRight;
transform.Rotate(0, 180, 0);
}
public void FlipController(float _x)
{
if (_x > 0 && !facingRight)
Flip();
else if (_x< 0 && facingRight)
Flip();
}
}
이제 AnimationTrigger() 메서드를 실행시켜 본다.
PlayerAnimationTrigger 스크립트 생성하기
using UnityEngine;
public class PlayerAnimationTrigger : MonoBehaviour
{
//Animator 객체에 넣어줄 것이기 때문에 부모인 player 찾기
private Player player => GetComponentInParent<Player>();
private void AnimationTrigger()
{
player.AnimationTrigger();
}
}
Animator 객체에 위의 스크립트를 넣는다.
Animation 창에서 player_Attack1 클립의 마지막 프레임에 Add Event 추가
Inspector에서
PlayerAnimationTrigger → Methods → AnimationTrigger() 선택
이 행동을 Attack2와 Attack3까지 동일하게 해 준다.
이렇게 되면 한 번의 공격이 끝나면 idle 상태로 돌아간다.
이것을 하지 않으면 한 번 클릭 후에 무한으로 공격하게 된다.
4. 공격 콤보
이제 콤보를 구현해 본다.
PlayerPrimaryAttack 스크립트로 이동한다.
콤보 단계에 대한 변수
private int comboCounter;
마지막 공격 시간 기록 변수
private float lastTimeAttacked;
콤보가 유지되는 시간 간격 변수
private float comboWindow = 2;
‘
Enter() 메서드
콤보가 끝까지 가거나, 마지막 공격 이후 2초 이상 경과하면 콤보 초기화
콤보 인덱스를 애니메이션 파라미터로 전달
public override void Enter()
{
base.Enter();
if (comboCounter > 2 || Time.time >= lastTimeAttacked + comboWindow)
comboCounter = 0;
player.anim.SetInteger("ComboCounter", comboCounter);
}
Exit() 메서드
공격 상태에서 벗어날 때 호출
콤보 카운터를 하나 올리고 마지막 공격 시간을 저장
public override void Exit()
{
base.Exit();
comboCounter++;
lastTimeAttacked = Time.time;
}
PlayerPrimaryAttack 전체 스크립트
using UnityEngine;
public class PlayerPrimaryAttack : PlayerState
{
private int comboCounter;
private float lastTimeAttacked;
private float comboWindow = 2;
public PlayerPrimaryAttack(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}
public override void Enter()
{
base.Enter();
if (comboCounter > 2 || Time.time >= lastTimeAttacked + comboWindow)
comboCounter = 0;
player.anim.SetInteger("ComboCounter", comboCounter);
}
public override void Exit()
{
base.Exit();
comboCounter++;
lastTimeAttacked = Time.time;
}
public override void Update()
{
base.Update();
if (triggerCalled)
stateMachine.ChangeState(player.idleState);
}
}
Player 스크립트로 이동
네임스페이스 코드 추가
using System.Collections;
using UnityEngine;
using UnityEngine.InputSystem.iOS;
bool 값인 isBusy 프로퍼티 변수 생성
public bool isBusy { get; private set; }
코루틴으로 일정 시간 동안 isBusy = true 상태를 유지하고 나서 다시 false로 된다.
public IEnumerator BusyFor(float _seconds)
{
isBusy = true;
yield return new WaitForSeconds(_seconds);
isBusy = false;
}
“WallJump” → “Jump” 변경
wallJump = new PlayerWallJump(this, stateMachine, "Jump");
Player 전체 스크립트
using System.Collections;
using UnityEngine;
using UnityEngine.InputSystem.iOS;
public class Player : MonoBehaviour
{
public bool isBusy { get; private set; }
[Header("이동 정보")]
public float moveSpeed = 12f;
public float jumpForce;
//추가된거
[Header("대시 정보")]
[SerializeField] private float dashCooldown;
private float dashUsageTimer;
public float dashSpeed;
public float dashDuration;
public float dashDir { get; private set; }
[Header("충돌 정보")]
[SerializeField] private Transform groundCheck;
[SerializeField] private float groundCheckDistance;
[SerializeField] private Transform wallCheck;
[SerializeField] private float wallCheckDistance;
[SerializeField] private LayerMask whatIsGround;
public int facingDir { get; private set; } = 1;
private bool facingRight = true;
#region Components
public Animator anim { get; private set; }
public Rigidbody2D rb { get; private set; }
#endregion
#region States
// 플레이어의 상태를 관리하는 상태 머신
public PlayerStateMachine stateMachine { get; private set; }
// 플레이어의 상태 (대기 상태, 이동 상태)
public PlayerIdleState idleState { get; private set; }
public PlayerMoveState moveState { get; private set; }
public PlayerJumpState jumpState { get; private set; }
public PlayerAirState airState { get; private set; }
public PlayerDashState dashState { get; private set; }
public PlayerWallSlideState wallSlide { get; private set; }
public PlayerWallJump wallJump { get; private set; }
public PlayerPrimaryAttack primaryAttack { get; private set; }
#endregion
private void Awake()
{
// 상태 머신 인스턴스 생성
stateMachine = new PlayerStateMachine();
// 각 상태 인스턴스 생성 (this: 플레이어 객체, stateMachine: 상태 머신, "Idle"/"Move": 상태 이름)
idleState = new PlayerIdleState(this, stateMachine, "Idle");
moveState = new PlayerMoveState(this, stateMachine, "Move");
jumpState = new PlayerJumpState(this, stateMachine, "Jump");
airState = new PlayerAirState(this, stateMachine, "Jump");
dashState = new PlayerDashState(this, stateMachine, "Dash");
wallSlide = new PlayerWallSlideState(this, stateMachine, "WallSlide");
wallJump = new PlayerWallJump(this, stateMachine, "Jump");
primaryAttack = new PlayerPrimaryAttack(this, stateMachine, "Attack");
}
private void Start()
{
anim = GetComponentInChildren<Animator>();
rb = GetComponent<Rigidbody2D>();
// 게임 시작 시 초기 상태를 대기 상태(idleState)로 설정
stateMachine.Initialize(idleState);
}
private void Update()
{
stateMachine.currentState.Update();
CheckForDashInput();
}
public IEnumerator BusyFor(float _seconds)
{
isBusy = true;
yield return new WaitForSeconds(_seconds);
isBusy = false;
}
public void AnimationTrigger() => stateMachine.currentState.AnimationFinishTrigger();
private void CheckForDashInput()
{
//if (IsWallDetected())
// return;
dashUsageTimer -= Time.deltaTime;
if (Input.GetKeyDown(KeyCode.LeftShift) && dashUsageTimer < 0)
{
dashUsageTimer = dashCooldown;
dashDir = Input.GetAxisRaw("Horizontal");
if (dashDir == 0)
dashDir = facingDir;
stateMachine.ChangeState(dashState);
}
}
public void SetVelocity(float _xVelocity, float _yVelocity)
{
rb.linearVelocity = new Vector2(_xVelocity, _yVelocity);
FlipController(_xVelocity);
}
public bool IsGroundDetected() => Physics2D.Raycast(groundCheck.position, Vector2.down, groundCheckDistance, whatIsGround);
public bool IsWallDetected() => Physics2D.Raycast(wallCheck.position, Vector2.right * facingDir, wallCheckDistance, whatIsGround);
private 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, wallCheck.position.y));
}
public void Flip()
{
facingDir = facingDir * -1;
facingRight = !facingRight;
transform.Rotate(0, 180, 0);
}
public void FlipController(float _x)
{
if (_x > 0 && !facingRight)
Flip();
else if (_x< 0 && facingRight)
Flip();
}
}
Animator - Parameters int 형식의 ComboCounter 이름으로 생성
Animator에서 Create Sub-State Machine 추가
이렇게 직사각형이 아닌 다른 모양의 스테이트 머신이 생성된다.
PrimaryAttack 이름으로 변경하고 Entry와 연결해 준다.
PrimaryAttack을 더블 클릭해 안으로 들어가 여기에서 공격 콤보 애니메이션을 구현할 것이다.
Create State - Emtry 생성해 초록색의 Emtry와 새로 생성한 Emtry을 연결한다.
player_Attack1부터 3까지를 가져와 생성한 Emtry와 Exit를 연결한다.
Emtry → player_Attack1의 Inspector이다.
Has Exit Time 체크 해제
Transition Duration 0
ComboCounter - Equals - 0으로 설정 (Attack2의 경우 1, Attack3의 경우 2, 이렇게 설정한다.)
Attack → Exit 트랜지션의 Inspector이다.
Has Exit Time 체크 해제
Transition Duration 0
Attack - false 이렇게 설정한다.
Attack1, Attack2, Attack3 동일하게 해 준다.
기존에 연결해 두었던 player_Attack1은 삭제하고 대신에 PrimaryAttack에서 작동할 것이다.
PlayerPrimaryAttack 스크립트 이동
Enter() 메서드에서
stateTimer = 0.1f 코드 추가
공격 모션 중 일시적인 딜레이를 위함이다.
public override void Enter()
{
base.Enter();
if (comboCounter > 2 || Time.time >= lastTimeAttacked + comboWindow)
comboCounter = 0;
player.anim.SetInteger("ComboCounter", comboCounter);
stateTimer = 0.1f;
}
isBusy = true를 만들기 위한 코루틴 실행 코드 추가
public override void Exit()
{
base.Exit();
player.StartCoroutine("BusyFor", 0.1f);
comboCounter++;
lastTimeAttacked = Time.time;
}
stateTimer가 0보다 작아지면 플레이어 속도를 멈춘다.
public override void Update()
{
base.Update();
if (stateTimer < 0)
rb.linearVelocity = new Vector2(0, 0);
if (triggerCalled)
stateMachine.ChangeState(player.idleState);
}
PlayerPrimaryAttack 전체 스크립트이다.
using UnityEngine;
public class PlayerPrimaryAttack : PlayerState
{
private int comboCounter;
private float lastTimeAttacked;
private float comboWindow = 2;
public PlayerPrimaryAttack(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}
public override void Enter()
{
base.Enter();
if (comboCounter > 2 || Time.time >= lastTimeAttacked + comboWindow)
comboCounter = 0;
player.anim.SetInteger("ComboCounter", comboCounter);
stateTimer = 0.1f;
}
public override void Exit()
{
base.Exit();
player.StartCoroutine("BusyFor", 0.1f);
comboCounter++;
lastTimeAttacked = Time.time;
}
public override void Update()
{
base.Update();
if (stateTimer < 0)
rb.linearVelocity = new Vector2(0, 0);
if (triggerCalled)
stateMachine.ChangeState(player.idleState);
}
}
Player 스크립트
공격 시 이동제어나 연출에 쓰이는 코드를 추가한다.
플레이어를 특정 방향으로 밀어주는 힘을 설정하는 배열 코드 추가
[Header("공격 디테일")]
public Vector2[] attackMovement;
플레이어의 속도를 완전히 0으로 멈추는 함수,
공격 중에 이동을 멈추기 위함이다.
public void ZeroVelocity() => rb.linearVelocity = new Vector2(0, 0);
Player 전체 스크립트
using System.Collections;
using UnityEngine;
using UnityEngine.InputSystem.iOS;
public class Player : MonoBehaviour
{
[Header("공격 디테일")]
public Vector2[] attackMovement;
public bool isBusy { get; private set; }
[Header("이동 정보")]
public float moveSpeed = 12f;
public float jumpForce;
//추가된거
[Header("대시 정보")]
[SerializeField] private float dashCooldown;
private float dashUsageTimer;
public float dashSpeed;
public float dashDuration;
public float dashDir { get; private set; }
[Header("충돌 정보")]
[SerializeField] private Transform groundCheck;
[SerializeField] private float groundCheckDistance;
[SerializeField] private Transform wallCheck;
[SerializeField] private float wallCheckDistance;
[SerializeField] private LayerMask whatIsGround;
public int facingDir { get; private set; } = 1;
private bool facingRight = true;
#region Components
public Animator anim { get; private set; }
public Rigidbody2D rb { get; private set; }
#endregion
#region States
// 플레이어의 상태를 관리하는 상태 머신
public PlayerStateMachine stateMachine { get; private set; }
// 플레이어의 상태 (대기 상태, 이동 상태)
public PlayerIdleState idleState { get; private set; }
public PlayerMoveState moveState { get; private set; }
public PlayerJumpState jumpState { get; private set; }
public PlayerAirState airState { get; private set; }
public PlayerDashState dashState { get; private set; }
public PlayerWallSlideState wallSlide { get; private set; }
public PlayerWallJump wallJump { get; private set; }
public PlayerPrimaryAttack primaryAttack { get; private set; }
#endregion
private void Awake()
{
// 상태 머신 인스턴스 생성
stateMachine = new PlayerStateMachine();
// 각 상태 인스턴스 생성 (this: 플레이어 객체, stateMachine: 상태 머신, "Idle"/"Move": 상태 이름)
idleState = new PlayerIdleState(this, stateMachine, "Idle");
moveState = new PlayerMoveState(this, stateMachine, "Move");
jumpState = new PlayerJumpState(this, stateMachine, "Jump");
airState = new PlayerAirState(this, stateMachine, "Jump");
dashState = new PlayerDashState(this, stateMachine, "Dash");
wallSlide = new PlayerWallSlideState(this, stateMachine, "WallSlide");
wallJump = new PlayerWallJump(this, stateMachine, "Jump");
primaryAttack = new PlayerPrimaryAttack(this, stateMachine, "Attack");
}
private void Start()
{
anim = GetComponentInChildren<Animator>();
rb = GetComponent<Rigidbody2D>();
// 게임 시작 시 초기 상태를 대기 상태(idleState)로 설정
stateMachine.Initialize(idleState);
}
private void Update()
{
stateMachine.currentState.Update();
CheckForDashInput();
}
public IEnumerator BusyFor(float _seconds)
{
isBusy = true;
yield return new WaitForSeconds(_seconds);
isBusy = false;
}
public void AnimationTrigger() => stateMachine.currentState.AnimationFinishTrigger();
private void CheckForDashInput()
{
//if (IsWallDetected())
// return;
dashUsageTimer -= Time.deltaTime;
if (Input.GetKeyDown(KeyCode.LeftShift) && dashUsageTimer < 0)
{
dashUsageTimer = dashCooldown;
dashDir = Input.GetAxisRaw("Horizontal");
if (dashDir == 0)
dashDir = facingDir;
stateMachine.ChangeState(dashState);
}
}
#region 속력
public void ZeroVelocity() => rb.linearVelocity = new Vector2(0, 0);
public void SetVelocity(float _xVelocity, float _yVelocity)
{
rb.linearVelocity = new Vector2(_xVelocity, _yVelocity);
FlipController(_xVelocity);
}
#endregion
#region 충돌
public bool IsGroundDetected() => Physics2D.Raycast(groundCheck.position, Vector2.down, groundCheckDistance, whatIsGround);
public bool IsWallDetected() => Physics2D.Raycast(wallCheck.position, Vector2.right * facingDir, wallCheckDistance, whatIsGround);
private 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, wallCheck.position.y));
}
#endregion
#region 플립
public void Flip()
{
facingDir = facingDir * -1;
facingRight = !facingRight;
transform.Rotate(0, 180, 0);
}
public void FlipController(float _x)
{
if (_x > 0 && !facingRight)
Flip();
else if (_x < 0 && facingRight)
Flip();
}
#endregion
}
PlayerPrimaryAttack 스크립트
Enter() 메서드에 코드를 추가할 것이다.
기본 공격 방향을 플레이어가 바라보는 방향으로 설정한다.
float attackDir = player.facingDir;
입력 방향을 우선시해서 공격 방향으로 설정
if (xInput!= 0) attackDir = xInput;
콤보마다 캐릭터가 이동하며 공격하는 연출 코드 추가
player.SetVelocity(player.attackMovement[comboCounter].x * player.facingDir, player.attackMovement[comboCounter].y);
공격 애니메이션에 따라 캐릭터가 이동하는 방향과 속도를 조절하는 코드
public override void Enter()
{
base.Enter();
if (comboCounter > 2 || Time.time >= lastTimeAttacked + comboWindow)
comboCounter = 0;
player.anim.SetInteger("ComboCounter", comboCounter);
float attackDir = player.facingDir;
if (xInput != 0)
attackDir = xInput;
player.SetVelocity(player.attackMovement[comboCounter].x * player.facingDir, player.attackMovement[comboCounter].y);
stateTimer = 0.1f;
}
PlayerPrimaryAttack 전체 스크립트
using UnityEngine;
public class PlayerPrimaryAttack : PlayerState
{
private int comboCounter;
private float lastTimeAttacked;
private float comboWindow = 2;
public PlayerPrimaryAttack(Player _player, PlayerStateMachine _stateMachine, string _animBoolName) : base(_player, _stateMachine, _animBoolName)
{
}
public override void Enter()
{
base.Enter();
if (comboCounter > 2 || Time.time >= lastTimeAttacked + comboWindow)
comboCounter = 0;
player.anim.SetInteger("ComboCounter", comboCounter);
float attackDir = player.facingDir;
if (xInput != 0)
attackDir = xInput;
player.SetVelocity(player.attackMovement[comboCounter].x * player.facingDir, player.attackMovement[comboCounter].y);
stateTimer = 0.1f;
}
public override void Exit()
{
base.Exit();
player.StartCoroutine("BusyFor", 0.1f);
comboCounter++;
lastTimeAttacked = Time.time;
}
public override void Update()
{
base.Update();
if (stateTimer < 0)
rb.linearVelocity = new Vector2(0, 0);
if (triggerCalled)
stateMachine.ChangeState(player.idleState);
}
}
Player 스크립트의 Inspector에 보면 공격 디테일이 생겨 있을 것이다.
이 배열에서 +를 통해 총 3개까지 추가한다.
설정은 아래와 맞추어준다.
이렇게 공격 모드를 마무리한다!!
오늘은 이렇게 벽 점프 상태와 플레이어 공격을 구현해 보았다.
기본적인 이동과 점프는 익숙한 상태였는데,
벽 점프를 구현하면서 벽에 붙어있는 상태와
붙어있을 때 점점 아래로 내려가는 모습을 표현한다는 게 신선하게 다가왔다.
이렇게 점점 여러 개 늘어나겠지..?
어려워질 것 같기도 하고.. 코드가 길어지면 복잡해지는 것도 사실이다..
이럴 때일수록 복습 복습!!! 복습을 실천화하자..
글을 작성하면서도 복습을 할 수 있으니 좋은 것 같다.
'Development > 멋쟁이사자처럼 게임개발 부트캠프' 카테고리의 다른 글
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 28일차 - State 패턴 횡스크롤 2D (2) (0) | 2025.04.18 |
---|---|
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 27일차 (2) - State 패턴 횡스크롤 2D (0) | 2025.04.18 |
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 27일차 (1) - 상속 횡스크롤 2D (0) | 2025.04.17 |
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 26일차 (2) - 2D 횡스크롤 (0) | 2025.04.16 |
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 26일차 (1) - 게임디자인패턴 : 스트래티지(Strategy), 스테이트(State) (0) | 2025.04.13 |