2023 전공동아리 1, 2분기 팀 프로젝트
깃허브 : https://github.com/DodgeNinga/Ninja_Hypertension
다운로드 : https://drive.google.com/file/d/1SrAg67E13QxtzfpZN-wIP_RqMNokIwkE/view?usp=sharing
1. 게임 개요
기획 의도 :
팀원들과 회의를 하다 액션 플렛포머를 개발하고 싶다는 이야기가 나왔고 어떻게 하면 참신한 기획이 나올까 고민하다 고혈압(Hypertension) 이라는 키워드를 사용하여 플레이어가 몬스터를 공격하면 체력(혈압)이 올라가며 능력치가 올라가고 공격을 받아 체력(혈압)이 낮아지면 능력치가 하락하는 시스템을 기획하였다 이후 다른 게임들과의 차별성을 강조하기 위하여 체력(혈압)이 최고치가 되면 플레이어가 사망하는 시스템도 기획하였다
개발 인원 : 5명
설명 : 전공 동아리 탈주닌자 2팀에서 팀원 5명(2학년 2명 1학년 3명)과 1, 2분기 동안 같이 개발한 게임입니다
저는 이 프로젝트에서 팀장과 매인 개발을 담당했고 1분기 2등 2분기 1등이라는 성적을 달성하였습니다
장르 : 액션 플렛포머
2. 개발 전 구상
고혈압이라는 키워드에 맞추어 최대한 어두운 느낌의 픽셀 그래픽체력(혈압)이 최고치가 되면 플레이어가 사망하는 시스템
공략 가능한 여러 보스
액션 플렛포머라는 장르에 맞게 최대한 타격감을 살리기
몬스터를 공격하면 체력(혈압)이 상승하는 시스템
픽셀 그래픽
3. 구현
플레이어
당시 플레이어를 구현하며 기능 별로 스크립트를 나누어 작성하면 어떨까?라는 생각이 들어서 최대한 기능별로 스크립트를 나누어 플레이어를 개발하였습니다 플레이어 움직임, 공격, 대시등 여러 기능들을 나누어 작성하였고 코드 가독성은 좋아졌다고 생각하지만 스크립트가 너무 많아져서 불편했던 부분이 있었습니다
코드(나누어서 작성하였던 코드 중 일부분만 담았습니다)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerMove : PlayerBehaviorRoot
{
[SerializeField] private float moveSpeed;
private float currentSpeed;
private bool moveAble = true;
private bool isAdd;
protected override void Awake()
{
base.Awake();
AddEvent();
currentSpeed = moveSpeed;
}
private void Move(float inputX)
{
if (!moveAble) return;
rigid.velocity = new Vector2(inputX * currentSpeed, rigid.velocity.y);
}
private void Run()
{
if (playerControllValue.isAnySkillAttack) return;
currentSpeed = currentSpeed * 1.5f;
animator.SetIsRun(1);
}
private void EndRun()
{
currentSpeed = moveSpeed;
animator.SetIsRun(0);
}
public void KnockBack(Vector2 vel)
{
StartCoroutine(KnockBackCo(vel));
}
public void SetMoveAble(float value)
{
moveAble = value switch
{
0 => false,
1 => true,
_ => false,
};
}
public void SetMoveSpeed(float value)
{
if(value == -1)
{
currentSpeed = moveSpeed;
}
else
{
currentSpeed = value;
}
}
public void Stop()
{
rigid.velocity = new Vector2(0, rigid.velocity.y);
}
public override void AddEvent()
{
if (isAdd) return;
isAdd = true;
actionSystem.OnHorizontalEvent += Move;
actionSystem.OnRunKeyPressEvent += Run;
actionSystem.OnRunKeyUpEvent += EndRun;
}
public override void RemoveEvent()
{
isAdd = false;
actionSystem.OnHorizontalEvent -= Move;
actionSystem.OnRunKeyPressEvent -= Run;
actionSystem.OnRunKeyUpEvent -= EndRun;
}
private IEnumerator KnockBackCo(Vector2 vel)
{
moveAble = false;
rigid.velocity = vel;
yield return new WaitUntil(() =>
{
return rigid.velocity == Vector2.zero;
});
moveAble = true;
}
}
플레이어 애니메이션
게임에 특성상 여러 애니메이션이 많아 애니메이션을 관리하는 스크립트를 하나 작성하여 애니메이션을 관리하는 형식으로 디자인했습니다 그리고 그 당시 배운 애니메이션 해쉬를 사용해 보았습니다 애니메이션 중앙 관리를 하니 다른 곳에서 애니메이션을 거의 건들지 않아 관리가 매우 편하였지만 스크립트가 너무 길어진다는 문제가 있었습니다
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerAnimator : MonoBehaviour
{
#region Hash
private readonly int MoveVelXHash = Animator.StringToHash("MoveVelX");
private readonly int MoveVelYHash = Animator.StringToHash("MoveVelY");
private readonly int JumpHash = Animator.StringToHash("Jump");
private readonly int LandingTriggerHash = Animator.StringToHash("LandingTrigger");
private readonly int WallFallHash = Animator.StringToHash("WallFall");
private readonly int AttackTriggerHash = Animator.StringToHash("AttackTrigger");
private readonly int ComboCountHash = Animator.StringToHash("ComboCount");
private readonly int AttackEndHash = Animator.StringToHash("AttackEnd");
private readonly int IsAirHash = Animator.StringToHash("IsAir");
private readonly int SkillHoldHash = Animator.StringToHash("SkillHold");
private readonly int SkillHoldTriggerHash = Animator.StringToHash("SkillHoldTrigger");
private readonly int DashTriggerHash = Animator.StringToHash("DashTrigger");
private readonly int IsDashHash = Animator.StringToHash("IsDash");
private readonly int DieTriggerHash = Animator.StringToHash("DieTrigger");
private readonly int IsGroundHash = Animator.StringToHash("IsGround");
private readonly int IsHitHash = Animator.StringToHash("IsHit");
private readonly int JumpAttackTriggerHash = Animator.StringToHash("JumpAttackTrigger");
private readonly int RollingTriggerHash = Animator.StringToHash("AirRollTrigger");
private readonly int RollingEndTriggerHash = Animator.StringToHash("RollingEndTrigger");
private readonly int PoundTriggerHash = Animator.StringToHash("PoundTrigger");
private readonly int PoundEndTriggerHash = Animator.StringToHash("PoundEndTrigger");
private readonly int DashAttackTriggerHash = Animator.StringToHash("DashAttackTrigger");
private readonly int DashAttackHoldingEndTriggerHash = Animator.StringToHash("DashAttackHoldingEndTrigger");
private readonly int IsRunHash = Animator.StringToHash("IsRun");
#endregion
private MargedSencer margedSencer;
private PlayerFlip playerFlip;
private SpriteRenderer spriteRenderer;
private Animator animator;
private Rigidbody2D rigid;
private JumpCol jumpCol;
private float fallDownTime;
public bool fallDownAble { get; set; } = true;
private void Awake()
{
rigid = GetComponent<Rigidbody2D>();
playerFlip = GetComponent<PlayerFlip>();
spriteRenderer = GetComponent<SpriteRenderer>();
animator = GetComponent<Animator>();
jumpCol = GetComponentInChildren<JumpCol>();
margedSencer = GetComponentInChildren<MargedSencer>();
}
private void Update()
{
SetMoveVelHash();
ChackLanding();
FallDownChack();
WallChack();
SetIsAir();
SetIsGround();
}
private void SetIsGround()
{
animator.SetBool(IsGroundHash, jumpCol.isGround);
}
private void SetMoveVelHash()
{
animator.SetFloat(MoveVelXHash, Mathf.Abs(rigid.velocity.x));
animator.SetFloat(MoveVelYHash, rigid.velocity.y);
}
private void FallDownChack()
{
if(rigid.velocity.y < 0 && jumpCol.isGround == false)
{
fallDownTime += Time.deltaTime;
}
else
{
fallDownTime = 0;
}
}
private void ChackLanding()
{
if(fallDownTime > 1f && jumpCol.isGround == true && fallDownAble)
{
fallDownTime = 0;
animator.SetTrigger(LandingTriggerHash);
}
}
private void WallChack()
{
if((margedSencer.RightSencer || margedSencer.LeftSencer) && fallDownTime > 0f)
{
animator.SetFloat(WallFallHash, 1);
if(margedSencer.RightSencer) spriteRenderer.flipX = true;
else spriteRenderer.flipX = false;
playerFlip.useFlip = false;
}
else
{
animator.SetFloat(WallFallHash, 0);
playerFlip.useFlip = true;
}
}
private void SetIsAir()
{
animator.SetFloat(IsAirHash, jumpCol.isGround ? 0 : 1);
}
public void SetAttackTrigger()
{
animator.ResetTrigger(AttackEndHash);
animator.SetTrigger(AttackTriggerHash);
}
public void SetIsRun(float value) => animator.SetFloat(IsRunHash, value);
public void SetSkillHoldHash(bool value) => animator.SetBool(SkillHoldHash, value);
public void SetIsDash(bool value) => animator.SetBool(IsDashHash, value);
public void SetIsHit(bool value) => animator.SetBool(IsHitHash, value);
public void SetComboCount(int count) => animator.SetInteger(ComboCountHash, count);
public void ResetComboCount() => animator.SetInteger(ComboCountHash, 0);
public void SetEndAttack() => animator.SetTrigger(AttackEndHash);
public void SetDashAttackHoldingEndTrigger() => animator.SetTrigger(DashAttackHoldingEndTriggerHash);
public void SetSkillHoldTriggerHash() => animator.SetTrigger(SkillHoldTriggerHash);
public void ResetLandingTrigger() => animator.ResetTrigger(LandingTriggerHash);
public void SetDashTrigger() => animator.SetTrigger(DashTriggerHash);
public void SetDieTrigger() => animator.SetTrigger(DieTriggerHash);
public void SetJumpAttackTrigger() => animator.SetTrigger(JumpAttackTriggerHash);
public void SetRollingTrigger() => animator.SetTrigger(RollingTriggerHash);
public void SetRollingEndTrigger() => animator.SetTrigger(RollingEndTriggerHash);
public void SetPoundTrigger() => animator.SetTrigger(PoundTriggerHash);
public void SetPoundEndTrigger() => animator.SetTrigger(PoundEndTriggerHash);
public void SetDashAttackTrigger() => animator.SetTrigger(DashAttackTriggerHash);
public void SetJump() => animator.SetTrigger(JumpHash);
}
그리고 이벤트 발행 구독 방식을 사용하여 플레이어의 입력을 관리하는 스크립트도 작성하였습니다
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerActionSystem : MonoBehaviour
{
public event Action<float> OnHorizontalEvent;
public event Action<float> OnVerticalEvnet;
public event Action<float> OnHorizontalRawEvent;
public event Action OnJumpKeyDownEvent;
public event Action OnJumpKeyUpEvent;
public event Action OnSkillKeyPressEvent;
public event Action OnSkillKeyUpEvent;
public event Action OnHoldSkillPressEvnet;
public event Action OnHoldSkillKeyUpEvent;
public event Action OnDashKeyPressEvent;
public event Action OnInteractionKeyPressEvent;
public event Action OnJumpAttackKeyPressEvent;
public event Action OnRollingAttaclKeyPressEvent;
public event Action OnPoundKeyPressEvent;
public event Action OnDashAttackKeyPressEvent;
public event Action OnRunKeyPressEvent;
public event Action OnRunKeyUpEvent;
//여기에 키 인풋코드 추가
public void OnHorizontalExecute(float v) => OnHorizontalEvent?.Invoke(v);
public void OnVerticalExecute(float v) => OnVerticalEvnet?.Invoke(v);
public void OnHorizontalRawExecute(float v) => OnHorizontalRawEvent?.Invoke(v);
public void OnJumpKeyDownExecute() => OnJumpKeyDownEvent?.Invoke();
public void OnJumpKeyUpExecute() => OnJumpKeyUpEvent?.Invoke();
public void OnSkillKeyPressExecute() => OnSkillKeyPressEvent?.Invoke();
public void OnSkillKeyUpExecute() => OnSkillKeyUpEvent?.Invoke();
public void OnHoldSkillPressExecute() => OnHoldSkillPressEvnet?.Invoke();
public void OnHoldSkillUpExecute() => OnHoldSkillKeyUpEvent?.Invoke();
public void OnDashKeyPressEventExecute() => OnDashKeyPressEvent?.Invoke();
public void OnInteractionKeyPressExecute() => OnInteractionKeyPressEvent?.Invoke();
public void OnJumpAttackKeyPressExecute() => OnJumpAttackKeyPressEvent?.Invoke();
public void OnRollingAttaclKeyPressEventExecute() => OnRollingAttaclKeyPressEvent?.Invoke();
public void OnPoundKeyPressEventExecute() => OnPoundKeyPressEvent?.Invoke();
public void OnDashAttackKeyPressEventExecute() => OnDashAttackKeyPressEvent?.Invoke();
public void OnRunKeyPressEventExecute() => OnRunKeyPressEvent?.Invoke();
public void OnRunKeyUpEventExecute() => OnRunKeyUpEvent?.Invoke();
}
보스
액션 플렛포머 게임이다 보니 보스도 매우 중요한 요소였습니다 AI를 만드는 방식 중 하나인 FSM을 사용하여 여러 보스를 개발하였습니다
당시 작성했던 FSM의 일부
컨트롤러
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GolemController : AIController
{
private HitSencer sencer;
private SpriteRenderer spriteRenderer;
protected override void Awake()
{
base.Awake();
sencer = GetComponent<HitSencer>();
spriteRenderer = GetComponent<SpriteRenderer>();
}
protected override void Update()
{
base.Update();
sencer.swap = spriteRenderer.flipX;
}
}
공격 상태
using FD.Dev;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
public class GolemAttackState : AIState
{
[SerializeField] private float coolTimeMin, coolTimeMax;
[SerializeField] private float atkCoolDownMin, atkCoolDownMax;
private GolemAnimator animator;
public bool isCoolDown { get; private set; }
protected override void Awake()
{
base.Awake();
animator = controller.GetComponent<GolemAnimator>();
}
public override void EnterState()
{
if (isCoolDown) return;
isCoolDown = true;
animator.SetIsAttack(true);
StartCoroutine(SkillSelectCo());
}
public override void ExitState()
{
animator.SetIsAttack(false);
}
public override void UpdateState()
{
}
private IEnumerator SkillSelectCo()
{
yield return new WaitForSecondsRealtime(Random.Range(coolTimeMin, coolTimeMax));
int randomIDX = Random.Range(0, 3);
animator.SetAttackSelectInt(randomIDX);
animator.SetAttackTrigger();
FAED.InvokeDelayReal(() => isCoolDown = false,
Random.Range(atkCoolDownMin, atkCoolDownMax));
}
}
튜토리얼
게임을 플레이하며 저희 게임이 처음 하는 사람들에게 조금 어렵고 불친절하다는 것을 발견했고 튜토리얼을 만들기로 하였습니다 기획자 친구가 스토리도 작성해 주어 배경 스토리와 기능을 함께 알려주는 튜토리얼을 개발하였습니다
using Class;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
public class StoryManager : MonoBehaviour
{
[SerializeField] private UnityEvent startEvent;
[SerializeField] private List<StoryEvnetClass> storyEvents;
private void Start()
{
startEvent?.Invoke();
}
private void Update()
{
for (int i = 0; i < storyEvents.Count; i++)
{
StoryEvnetClass story = storyEvents[i];
foreach (var evt in story.storySencers)
{
if(evt.Sencing())
{
story.ableEvent?.Invoke();
storyEvents.Remove(story);
}
}
}
}
}
[System.Serializable]
public class StoryEvnetClass
{
public List<StorySencer> storySencers;
public UnityEvent ableEvent;
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public abstract class StorySencer : MonoBehaviour
{
public abstract bool Sencing();
}
4. 프로젝트의 문제점
다른 학년과 프로젝트를 개발하는 것은 거의 처음이라 서로 코드 스타일을 맞추거나 부족한 부분을 알려주는 부분에서 시간이 많이 소모된 거 같습니다
5. 프로젝트로 느낀 점
제대로 된 팀 프로젝트를 개발한 것은 이 프로젝트가 거의 처음이고 심지어 다른 학년과 개발하여 걱정도 많았지만 생각보다 재미있는 경험이었고 개인 프로젝트를 하는 것보다 퀄리티도 훨씬 좋게 나오고 실력도 많이 늘어 좋았습니다 그리고 기장으로써 동아리 프로젝트를 하며 1분기 2등, 2분기 1등, 3분기 1등이라는 결과를 만들어 내어서 매우 뿌듯했습니다
'포트폴리오 > 팀 프로젝트' 카테고리의 다른 글
[팀 프로젝트] Ancient City : Pottery (0) | 2024.04.29 |
---|---|
[팀 프로젝트] 약한마왕디펜스 (0) | 2024.03.06 |
[팀 프로젝트] 참새의 여행 (0) | 2024.03.06 |