Study/Unity learn

Unity / Basic Design Patterns

hiheybye 2024. 5. 23. 14:32

Design Patterns

Contents

더보기

 

Introducing design pattern

The SOLID principles

  • Single-responsibbility principle | Open-closed principle | Liskov segregation principle | Interface segregation principle | Dependency inversion principle | Interface vs abstract

Essential Design patterns for GameProgramming

  • Observer Pattern | State Pattern | Object Pooling | MVP pattern | Factory pattern | Command Pattern | Singleton

 

Gameprogrammingpattern

# Prototype, Game Loop, Update Method, Component 는 확인해보고 진행 할 것

 

Introduction

Design Patterns Revisited

  • Command | Flyweight | Observer | Prototype | Singleton | State

Sequencing Patterns

  • Double Buffer | Game Loop | Update Method

Behavioral Patterns

  • Bytecode | Subclass Sandbox |Type Object

Decoupling Patterns

  • Component / Event Queue / Service Locator

Optimization Patterns

  • Data Locality / Dirty Flag / Obect Pool / Spatial Partion

 

 

Gang of Four

# Level up your code with Game Programming Pattern 에서 언급된 패턴 먼저 체크


Shout-out  at Level up your code with Game Programming Pattern

  • Adapter Pattern | Decotator Pattern | Facade Pattern | Template method Pattern | Strategy Pattern | Composite

 

Creantional Patterns

  • Abstract Factory Pattern | Builder Pattern | Factory Method Pattern | Prototype Pattern | Singleton pattern

Structural Patterns

  • Adapter Pattern | Bridge Pattern | Composite Pattern | Decotator Pattern | Facade Pattern | Flyweight Pattern | Proxy Pattern

Behavioral Patterns

  • Chain of responsibility | Command Pattern | Interpreter Pattern | Iterator Pattern | Observer Pattern | Strategy Pattern | Template method Pattern | Visitor Pattern | Mediator Pattern | State Pattern | Memento Pattern

Daily 

더보기

Goals - 코드 작성 개선을 위한 기초적인 디자인 패턴을 공부한다.

 

Design Patterns (Basic) 

  • part 1 : Unity Learn - Design Pattern
  • part 2 : Level up your code with Game Programming Pattern & gameprogrammingpatterns
  • part 3 : Gang of four

Part1

  •  Unity Learn에서 소개된 디자인 패턴
  • Level up your code with Game Programming Pattern, Unity Learn에서 소개된 참고 자료
    • Unity Learn - Design Pattern이 Level up your code with Game Programming Pattern을 기반으로 작성되었음으로 공통되는 구성은 간단히 보강만 하고 넘어감

Part2

  • gameprogrammingpatterns
    • Level up your code with Game Programming Patterngameprogrammingpatterns에 많은 영향을 받아 작성된것으로 보임, 공통되는 구성에 대해 다른 관점도 다룬다는 생각  진행

Part3

  • Gang of Four 중 여태 다뤄지지 않는 패턴을 간단히 소개하고 넘어감
    • 몇몇 샤라웃된 패턴은 자세히 진행

 

  part 1,2,3,4 파트 별로 진행할 내용의 구성 및 순서가 다름 특히나 Gang of Four 의 생산-구조-행위 구조를 따라 글을 작성하기에는 기존 글 구성이 바뀜, 또한 내용의 뉘앙스가 뒤죽박죽이 될 것

  어디까지나 Unity 기준으로 작성된 글 구조를 유지하고 Gang of Four에서의 다뤄지지 않은 요소를 간단하게 아래에 배치

 

 

2024 / 05   할당
Part 1 - unity Learn - Design Pattern total & Level up your code with Game Programming Pattern
23 Observer pattern 시작 - 이론 T
24 Observer pattern 기초 사용법 - 완 -
26 State pattern 시작 - 이론, 기초 사용법 - 완 T
  Object Pooling 시작 - 이론, 기초 사용법 - 완 T
27 MVP pattern 시작 - 이론, 기초 사용법 - 완 -
  Factory pattern 시작 - 이론, 기초 사용법 - 완 -
28 Command pattern 시작 - 이론, 기초 사용법 -완
-
29 The SOLID principles T
30 Singleton pattern 및 Part 1 내용과 공통된 구성 재확인
-
Part 1 End - total 7days 21 hours T4 -5
Part 2 Level up your code with Game Programming Pattern & gameprogrammingpatterns
 30 gameprogrammingpatterns
Command Patter - 중
-
2024/06    
01 Command Pattern - (보강) 완, Flyweight Pattern-완  T
  Observer Pattern - (보강) 완, Prototype Pattern -
03 Prototype Pattern - 1 (완), Singleton - 3  T
  Singleton - 1 (완), State - 1  -
04 State - 1 (완), Double Buffer - 2△(완), Game Loop -1▽ T
  Game Loop - 2 (완) -
10 Update Method - 3 (완), Bytecode - 1 T
19 Bytecode - 2(완), Subclass Sandbox - 2▽(완) T
20  Type Object - 2(완)  -

 

Reference

더보기

Design Patterns

   디자인 패턴을 사용하여, 리펙토링 및 테스트 필요한 시간을 줄여, 온보딩(팀워크) 및 개발 속도를 증대할 수 있다.

Introducing Design patterns

  • 마주하게 되는 문제에 대해서, 많은 개발자가 이미 거쳐갔고 그 해결방안은 개발되었다. 디자인 패턴을 공부함으로써 바퀴를 재발명하는 일을 줄인다.
  • 디자인패턴을 공부하고 적용함으로써 코드를 깔끔하게 유지하고 더욱 큰 어플리케이션을 빌드할 수 있게 된다.
  • 디자인 패턴은 모든 상황에 적용할 수는 없다. 하지만 큰 어플리케이션을 구현할때, 올바르게 접근하는 관점을 알게 된다.
  • "단순함을 유지하되, 필요한 만큼만 복잡함을 추가한다."

 

The SOLID principles

  • SOLID principles은 코드를 간결하게 작성하게 만들고 효과적으로 유지 및 확장 할 수 있도록 한다.
  • SOLID 를 적용하는 것은 거 많은 작업을 요구할 수 있다. 특히 리펙터 작업이 필요하다. 하지만 장기적으로 이점이 많다. 얼마나 엄격하게 따를지 결정한다. 그에 따라 구현방법은 다양하다.
  • principle 자체보다는 principle를 만들어진 이유, 관점에 대해서 고려한다.

 

SOLID

  • Single responsibility | Open-closed | Liskov substitution | Interface segregation| Dependency inversion

Single responsibility principle

더보기
  • 클래스를 변경해야 하는 이유는 단 하나여야한다. 오로지 클래스의 단일 책임이다.
    • SOLID principle 중에서 가장 중요하다.
    • 각 module, class, function은 하나의 책임을 가진고 로직의 한 부분만 캡슐화한다.
  • 작은 클래스와 메서드는 설명, 이해, 구현하기 쉽다.
  • GameObject의 컴포넌트 역시 이러한 컨셉을 가지고 있다. 각 컴포넌트는 각자의 역할과 책임을 가진다.

/목적

  • Readabliity - 쉽게 읽히도록한다. 별도의 규칙은 존재하지 않지만, 많은 개발자들은 코드 한계를 200~300 라인으로 제한한다. 설정한 제한을 넘는다면, refactor를 고려한다.
  • Extensiblity - 더 쉽게 상속 받도록 만든다. 의도하지 않은 부분이 수정되거나 교체되지 않도록한다.
  • Reusability - 쉽게 재사용 할 수 있게 만든다.

구현하기 쉽다는 것은 단순하다는 것이 아니다. 단순함은 쉽다는 것이아니다. 단순함이 쉬움을 만들어 낸다. 

 

 

반면 교사 예제

 public class UnrefactoredPlayer : MonoBehaviour
 {

     [SerializeField] private string _inputAxisName;

     [SerializeField] private float _positionMultiplier;

     private float _yPosition;

     private AudioSource _bounceSfx;

     private void Start()
     {
         _bounceSfx = GetComponent<AudioSource>();
     }

     private void Update()
     {
         float delta = Input.GetAxis(_inputAxisName) * Time.deltaTime;

         _yPosition = Mathf.Clamp(_yPosition + delta, -1, 1);

         transform.position = new Vector3(transform.position.x, _yPosition * _positionMultiplier, transform.position.z);
     }

     private void OnTriggerEnter(Collider other)
     {
         _bounceSfx.Play();
     }
 }

 

UnrefactoredPlayer 클래스는 책임이 뒤죽박죽이다. 프로젝트가 커짐에 따라 유지하기가 어려워 진다.

 

 RefactoredPlayer

[RequireComponent(typeof(PlayerAudio), typeof(PlayerInput), typeof(PlayerMovement))]
public class Player : MonoBehaviour
{
    [SerializeField] private PlayerAudio playerAudio;
    [SerializeField] private PlayerInput playerInput;
    [SerializeField] private PlayerMovement playerMovement;

    private void Start()
    {
        playerAudio = GetComponent<PlayerAudio>();
        playerInput = GetComponent<PlayerInput>();
        playerMovement = GetComponent<PlayerMovement>();
    }
}

  Play script는 다른 컴포넌트(각 하나만 할 수 있는 클래스)를 관리한다. 이를 통해 요구사항이 바뀔때, 해당하는 코드를 쉽게 접근 가능하다.

  하지만 지나치게 단순화하여 한 메소드 당 하나의 클래스를 생성하지 않도록 한다. 단일 책임의 균형을 잘 유지하도록 한다.

Open - closed principle

더보기
  • 클래스는 확장에 열려있고, 수정엔 닫혀있어야한다.
  • 쉽게 디버그를 할수 있게 만든다.
  • 인터페이스와 추상을 사용하는 것은 새로운 클래스를 만드는 것에 이점이 있다.

 

 

반면 교사 예제

 public class AreaCalculator 
 {
     // old implemenation: not using open-closed principle

     public float GetRectangleArea(Rectangle rectangle)
     {
         return rectangle.Width * rectangle.Height;
     }

     public float GetCircleArea(Circle circle)
     {
         return circle.Radius * circle.Radius * Mathf.PI;
     }

}

public class Rectangle
{
	public float width;
    public float height;
}

public class Circle
{
	public float radis; 
}

 예제는 분명 잘 작동하지만, 새로운 모양을 추가하기 위해서는 새로운 메서드를 작성해야한다.

무엇보다, 상위 모듈에서 각 모양을 구별하고 맞는 메서드를 호출해야한다.

 

확장(새로운 모양 추가에 대해) 기존 코드(script)를 건들이지 않게 해야한다.

 

 


올바른 예제

public abstract class Shape
{
    public abstract float CalculateArea();
}


public class AreaCalculator 
{

    public float GetArea(Shape shape)
    {
        return shape.CalculateArea();
    }
}




    public class Rectangle : Shape
{
    public float Width { get; set; }
    public float Height { get; set; }

    public override float CalculateArea()
    {
        return Width * Height;
    }
}



    public class Circle : Shape
{
    public float Radius { get; set; }

    public override float CalculateArea()
    {
        return Radius * Radius * Mathf.PI;
    }
}

 

이제 AreaCalculator 는 Shape이 뭔지 몰라도 Area를 계산하는 기능을 가지게 된다. 이로써 기존 코드를 수정하지 않고 새로운 Shape를 추가 할 수 있다.

Liskov substitution principle

더보기
  • 자식 클래스는 부모 클래스로 대채 가능해야한다.
    • #자식 클래스의 참조 변수을 캐스팅을 통해 부모 클래스의 참조 변수로 만들어, 부모 클래스의 변수를  대채 가능하고, 작동에 문제가 없어야한다.
    • #과자가 필요한 것이라면, 그게 고래밥이든 새우깡이던 상관이 없어야한다는 것
  • 주의하지 않는다면, 불필요한 복잡성을 가지게 될 수 있음으로 주의
  • 이를 통해 파생 클래스를 탄탄하고 유연하게 만들 수 있다.
  •  

/Tip

  • 자식 클래스에서 부모 클래스의 기능을 제거하려고 한다면, LSP를 따르지 않는 것이다.
    • 기능 제거를 위해 NotImplementedException을 던질려는 경우
  • 추상화를 단순하게 유지한다.
    • 아래 예제를 해결하기 위해 부모 클래스에 더 많은 로직을 넣으려는 건 LSP를 따르지 않는 것이다
  • 자식 클래스는 부모 클래스와 같은 맴버를 가져야한다
  •  클래스 계층 구조를 구성할 때, 충분히 고려한다
    • 승용차과 기차와 같이 다른 부모 클래스를 상속해야함을 생각해야한다.
  • 상속 보다는 구성을 선호한다.
    • 상속을 통하기 보다는, 특정 기능을 캡슐화라는 별개 클래스 혹은 인터페이스를 만든다.

 

반면 교사 예제

 public class Vehicle
 {
     public float speed = 100;
     public string name;
     public Vector3 direction;

    public void GoForward()
    {
    }
    public void Reverse()
    {
    }
    public void TurnRight()
    {
    }
    public void TurnLeft()
    {
    }
 }
 
 
 
 public class Navigator
{

    public void Move(Vehicle vehicle)
    {
        vehicle.GoForward();
        vehicle.TurnLeft();
        vehicle.TurnRight();

    }
}

 

  이 코드에는 다음과 같은 확장에 대해 다음과 같은 문제가 존재한다. Train 같은 정해진 레일 위를 달리는 요소를 추가 되었다고 한다면, navigator.Move 에서 Vehicle vehicle을 Train(Vehicle를 상속 받은)으로 대체할 수가 없다.

하지만 물론 그렇다 하더라도 큰 문제는 없어보이지만, 이 경우 Train이 예측할 수 없게(의도와 달리 레일을 벗어나) 작동 할 수 있다.

 

올바른 예제

public interface ITurnable 
 {
     public void TurnRight();
     public void TurnLeft();

 }
 
 public interface IMovable
{
    public float MoveSpeed { get; set; }
    public float Acceleration { get; set; }

    public void GoForward();
    public void Reverse();

}
public class RailVehicle : IMovable
{

  public string Name;

  private float moveSpeed = 100f;
  private float acceleration = 5f;

  public float TurnSpeed = 5f;

  public float MoveSpeed { get => moveSpeed; set => moveSpeed = value; }
  public float Acceleration { get => acceleration; set => acceleration = value; }


  // implement these differently than RoadVehicles
  public virtual void GoForward()
  {
  }
  public virtual void Reverse()
  {
  }
}

public class RoadVehicle : IMovable, ITurnable
{
    public string Name;

    private float moveSpeed = 100f;
    private float acceleration = 5f;

    public float TurnSpeed = 5f;

    public float MoveSpeed { get => moveSpeed; set => moveSpeed = value; }
    public float Acceleration { get => acceleration; set => acceleration = value; }

    public virtual void GoForward()
    {
    }
    public virtual void Reverse()
    {
    }
    public virtual void TurnLeft()
    {
    }
    public virtual void TurnRight()
    {
    }
}

public class Train : RailVehicle
{
}


public class Car : RoadVehicle
{
}


public class Navigator
{

    public void MoveRoadVehicle(RoadVehicle roadVehicle)
    {
        roadVehicle.GoForward();
        roadVehicle.TurnLeft();
        roadVehicle.TurnRight();
    }

    public void MoveRailVehicle(RailVehicle railVehicle)
    {
        railVehicle.GoForward();
        railVehicle.GoForward();
        railVehicle.Reverse();
    }

}

기능을 상속보다는 인터페이스를 통해 구성한다. 이를 통해 LSP를 만족한다

이 방법은 실제 세계에 대한 직관에 반한다고 생각할 수 있다. 이것을 the circle-ellipse problem이라고 한다.

모든 "is a"  관계가 상속으로 대변되지 않는다.

 

계층 구조를 통해 디자인해라.

 

 

Interface segregation principle

더보기
  • 어떤 클라이언트에게 사용하지 않는 메서드에 의존하도록 강제해서는 안된다.
  • single-responsibility에 따라 큰 인터페이스를 피하고 작은 클래스와 메서드를 유지한다.
  • #또한 리스코브 치환 법칙에 따라, Interface로 치환해서 사용하는 경우도 잦다.

 

반면 교사 예제

public interface IUnitStats 
{
    public float Health { get; set; }
    public int Defense { get; set; }

    public void Die();
    public void TakeDamage();
    public void RestoreHealth();

    public float MoveSpeed { get; set; }
    public float Acceleration { get; set; }

    public void MoveForward();
    public void Reverse();
    public void TurnLeft();
    public void TurnRight();

    public int Strength { get; set; }
    public int Dexterity { get; set; }
    public int Endurance { get; set; }

    //public float Mass { get; set; }
    //public float ExplosiveForce { get; set; }
    //public float FuseDelay { get; set; }
    //public float Timeout { get; set; }

    //public void Explode();

}

  만약 새로운 파괴가능한 소품을 넣고 싶다고 한다면, 위 인터페이스의 파생을 만든다 하더라도 불필요한 기능을 가지게 된다.

 

 

 

올바른 예제

public interface IMovable
{
    public float MoveSpeed { get; set; }
    public float Acceleration { get; set; }

    public void MoveForward();
    public void Reverse();
    public void TurnLeft();
    public void TurnRight();
}

 public interface IDamageable 
 {

     public float Health { get; set; }
     public int Defense { get; set; }

     public void Die();
     public void TakeDamage();
     public void RestoreHealth();
 }
 
  public interface IExplodable
 {
     public float Mass { get; set; }
     public float ExplosiveForce { get; set; }
     public float FuseDelay { get; set; }
     public float Timeout { get; set; }

     public void Explode();
 }
 
 public class EnemyUnit : MonoBehaviour, IDamageable, IMovable, IUnitStats
{
    public float Health { get; set; }
    public int Defense { get; set; }
    public int Strength { get; set; }
    public int Dexterity { get; set; }
    public int Endurance { get; set; }
    public float MoveSpeed { get; set; }
    public float Acceleration { get; set; }

    // implement logic here
    public void TakeDamage()
    {
    }
    public void Die()
    {            
    }
    public void RestoreHealth()
    {
    }
    public void MoveForward()
    {           
    }
    public void Reverse()
    {            
    }
    public void TurnLeft()
    {            
    }
    public void TurnRight()
    {            
    }
}

public class ExplodingBarrel : MonoBehaviour, IExplodable, IDamageable
{
    public float Mass { get; set; }
    public float ExplosiveForce { get; set; }
    public float FuseDelay { get; set; }
    public float Timeout { get; set; }
    public float Health { get; set; }
    public int Defense { get; set; }

    // implement logic here
    public void Die()
    {        
    }
    public void Explode()
    {
    }
    public void RestoreHealth()
    {       
    }
    public void TakeDamage()
    {        
    }
}

 

 각 기능에 대한 인터페이스를 만들고, 이를 통해 클래스를 구성한다.

이를 통해 상속보다는 구성을 선호해야한다는 것을 알 수 있다.

Dependency inversion principle

더보기
  • 상위 모튤은 하위 모듈에게서 직적적으로 아무것도 가져와서는 안된다. 둘다 추상적 개념만 의존해야한다.
  • 하나으 클래스가 다른 것과 관계를 가지면 의존성 결합을 가진다. 각 의존성은 소프트웨어 디자인에 약간의 위험을 가져온다
  • 결합도가 높다. 한 클래스가 다른 클래스의 작동방식에 대해 너무 많은 것을 알고 있으면 다른 클래스를 손상시킬 수 있다. 이것은 올바르지 않은 코드이며, 에러를 불러오는 스노우 볼이 된다.

 

  • 적은 의존성은 가능하다. 각 클래스는 조화를 위해, 외부 연결을 의지하기 보다는 내부 부분을 가져야한다.

 

  • 상위 수준의 클래스는 하위 수준의 클래스를 의존하여 무언가를 끝내라 한다. SOLID에 의해서 이것을 뒤집어야 야 한다.

 

 

반면 교사 예제

public class Door : MonoBehaviour
{
    public void Open()
    {
        Debug.Log("The door is open.");
    }

    public void Close()
    {
        Debug.Log("The door is closed.");
    }
}
public class Switch : MonoBehaviour
{
    public void Activate()
    {
        if (IsActivated)
        {
            IsActivated = false;
            Door.Close();
        }
        else
        {
            IsActivated = true;
            Door.Open();
        }
    }
}

상위 레벨에서 무언가가 일어나면, 작동하도록 책임을 가지고

하위 레벨에서 실제 동작을 책임진다.

 

스위치는 문을 여닫는 메서드를 호출한다. 문제는 문에서 스위치로 직접적으로 이어지는 종속성이다.

만약 전등이나 로봇의 스위치가 필요하다면 어떻게 하는가?

 

추가적인 메서드를 작성할 수 있지만 이는 open-closed principle를 위반한다.

이를 개선하기 위해 ISwitchable 인터페이스를 적용한다.

 

public interface ISwitchable 
{
    public bool IsActive { get; }

    public void Activate();
    public void Deactivate();
}

public class Door : MonoBehaviour, ISwitchable
{
    private bool isActive;
    public bool IsActive => isActive;

    public void Activate()
    {
        isActive = true;
        Debug.Log("The door is open.");
    }

    public void Deactivate()
    {
        isActive = false;
        Debug.Log("The door is closed.");
    }
}

public class Trap : MonoBehaviour, ISwitchable
{
    private bool isActive;
    public bool IsActive => isActive;

    public void Activate()
    {
        isActive = true;
        Debug.Log("The trap is active.");
    }

    public void Deactivate()
    {
        isActive = false;
        Debug.Log("The trap is inactive.");
    }
}


public class Switch : MonoBehaviour
{
	public ISwitchable client;

    public void Toggle()
    {
        if (client.IsActive)
        {
            client.Deactivate();
        }
        else
        {
            client.Activate();
        }
    }

}

#  interface에서 property를 가져서 파생 클래스에 여건을 만든다.?

 

이렇게 종속성을 반전 시킬 수 있다.

인터페이스는 둘 사이의 고정된 연결을 만들기 보다는 추상적 개념을 만든다.

스위치는 문의 메서드의 직접적으로 종속하지 않는다. 그 대신 ISwitchable 인터페이스의 매서드를 사용한다.

 

이를 통해 재사용성을 얻는다. 스위치는 ISwitchable 가지는 클래스에 대해 적용할 수 있다.

 

스위치의 클라이언트는 스위치의 호환성을 위해  ISwitchable 를 가지면 된다.

 

 

 

Interfaces vs abstract classes

더보기
  • "상속 보다 구성을 선호한다." 는 생각의 방향을 유지하면서, 많은 예제가 인터페이스를 사용했다. 하지만 디자인 원리를 따르기 위해 abstract를 사용할 수 있다.
  • 두 방법 상황에 따라 모두 추상화를 달성하는 유효한 방법이다.

/Abstract classes

  • 추상 클래스는 base class 를 정의할 수 있다. 그로써 상속을 통해,  공통 기능을 파생 클래스로 넘길 수 있다.
  • 아래의 경우 처럼  ISwitchavle 를 추상 클래스로 만들 수 있다.
  • Abstract class의 이점은 필드, 상수, 정적멤버를 가질 수 있다는 것이다.
  • 인터페이서와 달리 추상 클래스는  로직을 구현하고 구현 클래스에 코어를 나누도록 할 수 있다.
  • 두개 이상의 클래스 상속이 불가한 점에 제한받기 전까지는 잘 작동한다.
  • 구현하고자하는 클래스에 대해 무엇을 상속 받아야하는지 결정하기 어렵다.(ex:  ISwitchavle, Robot)

 /Interfaces

  • 인터페이스는 Interface segregation principle를 통해, 패러다임에 딱 들어맞지 않는 대상에 대해 유용한 유연성을 가진다.
  • 하지만 인터페이스는 오로지 멤버의 선언만을 가질 수 있다.

 

따라서 기능을 공유하려는 경우 추상메서드를 유연성을 가지기 위해서는 인터페이스를 사용한다.

추상 클래스 인터페이스
메서드 구현 가능 선언만 가능
변수와 필드의 선언 및 사용 가능 메서드와 properies 선언 가능(필드는 그렇지 않다)
정적 멤버 가능 정적 멤버 불가능
생성자 사용 가능 생성자 사용 불가능
모든 접근지정자 사용 가능 public 만 사용 가능

 

Design Pattern

  • 디자인 패턴은 정해진 라이브러리나 프레임워크, 알고리즘 같은 것이 아니다. 결과물을 성취하기 위한 단계이다.
  • 같은 패턴을 따르더라도, 다른 코드가 만들어진다. 패턴은 어디서든 적용 응용될 수 있다.
  • 디자인 패턴을 알아두면,  여러 이점을 얻을 수 있다.
    • 디자인 패턴을 통해, 개발자들이 문제를 해결하기 위해 어떻게 접근했는지 알 수 있다.
    • 구현하고자하는 코드를 짧게 표현할 수 있다.
    • 새로운 에셋 및 프레임을 이해하는 데에 도움이 된다.
  • 모든 어플리케이션에 디자인패턴을 적용하려 하지마라, Maslw's hammer (망치를 들면 모든 문제는 못이 되어 버린다)

Patterns within unity

유니티는 몇가지의 게임 개발 패턴을 구현하여 제공하고 있다.

  • Game loop - 게임의 핵심은 컴퓨터 성능 독립된 무한 루프이다. 컴퓨터의 다른 성능에 대해, 타임 스탬프를 통해 이러한 루프를 제공하고 있다. MonoBehaviour 의 Updata(), LateUpdate(), FixedUpdate()로 게임플레이를 관리 할 수 있다. 
  • Update - 프레임 마다 각 객체를 업데이트한다. Updata(), LateUpdate(), FixedUpdate()로 적용되어 있다.
  • Prototype - 원본에 영향을 주지 않는 복사된 객체가 필요할 때 사용되는 패턴이다. 유니티의 prefab system이 복사본을 만들 수 있도록 해준다. Prefab Variants, nest Prefabs 기능도 제공한다.
  • Component - 책임을 컴포넌트 별로 분할한다. 유니티의 gameobject 컴포넌트로 적용되어 있다.

 

Essential Design Pattern for Gameprogramming

  • Observer Pattern | State Pattern | Object Pooling | MVP pattern | Factory pattern | Command Pattern | Singleton

Observer pattern

더보기

  몇몇 상황에서 오브젝트가 다른 오브젝트를 참조하지 않고 다른 오브젝트에게 알릴 수 있는 메커니즘이 필요하다. (이때문에) 코드베이스(코드량)가 증가하면, 불필요한 종속성이 증가하고 결국 유연성이 떨어지고 오버헤드가 발생하게 된다. 이를 해결 할 수 있는 일반적인 해결책이 observer pattern 이다.

 

  게임 플레이 과정에서 발생하는 거의 모든 것에 Observer pattern를 적용할 수 있다.

 

  •   오브젝트가 서로 상호작용하되, 일대다 종속성을 사용해 느슨한 결합 상태를 유지할 수 있다. 즉, 하나의 오브젝트가 변경되면 모든 종속 객체에 자동으로 알림이 전달된다.
  •   브로딩캐스팅하는 객체를 subject라고 하고, 듣는 객체들을 observers 혹은 subscribers 라고 한다
  •   이 디자인 패턴을 통해, 관찰자와 대상를 분리하고, 관찰자는 다른 관찰자의 동작과 독립된다. 이를 통해 시스템의 다른 부분에 영향을 주지않고 코드를 수정하거나 확장하는것이 쉬워진다.
  •   쉽게 코드를 재사용할 수 있으며,  객체간의 종속성이 명확하여 가독성이 향상된다.

 

Event

  C#에서는 이벤트를 사용해 observer pattern이 다양하게 구현되어있다. 보통은 직접구현할 필요가 없다.

  이벤트는 사건이 발생했음을 나태나는 알림이고, 몇가지 절차를 가진다.

 

  • publisher(subject) 는 delegate(대리자?)를 기반으로 생성된다.  delegate는 특정 함수 시그니처를 정의한다. event는 subject가 런타임동안 수행할 몇몇 작업이다. publisher는 종속 observers의 리스트를 유지한다. 상태가 변하면 알림을 보내게 된다. 이것이 Event이다.
  • 각 Observer는 event handler를 만든다. delegate의 시그니처와 반드시 일치해야한다. Obsercer는 알림을 수신하고 각자 알아서 자신을 업데이트한다.
  • 각 Observer는 관찰자의 event를 구독한다. 필요한 만큼 구독할 수 있다. Observer 모두 구독한 event가 일어나기를 기다린다.
  • publisher가 런타임에 이벤트 발생을 알리면, 자체 내부 논리를 수행하는 event handler를 호출한다.

# 함수 시그니처은 함수를 특정 지을수 있는 함수이름이다 아이디 따위를 일컫는 말인듯하다.

 

Delegate는 메서드 시그니처를 정의하는 형식이다. 메서드 자체를 다른 메서드의 인수로 사용할 수 있게 한다. 메서드에 대한 참조를 가진 변수라고 생각하면 된다.

 

Event는 느슨한 결합방식으로 서로 통신할 수 있게하는 특별한 타입의 delegate이다.

 

 

Pattern 개량법

  •  ObservableCollection 사용, c#에서 제공하며,  항목이 추가되거나 제거되면 목록이 새로고침되면 알려진다.
  • 고유 인스턴스 ID를 인수로 전달한다. 이를 통해 로직에서 GameObject를 확인할 수 있다.
  • 정적  EventManager를 활용한다.
  • 이벤트 큐 생성, 명령 패턴 결합하여, 이벤트를 이벤트 큐에 캡슐화 할 수 있다. 그 뒤 명령 버퍼를 사용하여, 이벤트의 재생 수와 선택적 무시를 할 수 있다.

 

In Code

 

Subject/publisher

using UnityEngine;
using System;

public class Subject: MonoBehaviour
{
    public event Action ThingHappened;

    public void DoThing()
    {
        ThingHappened?.Invoke();
    }
}

   위 클래스에서 MonoBehavior은 GameObject에 쉽게 연결하기 위한 것이로, 필수사항은 아니다.

 

  자신만의 delegate를 정의해도 되지만, 대부분의 경우 사용가능한 System. Action이 있다. 만약 파라미터를 함께 보내야 한다면, Action<T> delegate를 사용하면 된다.(최대 16 인수) List<T> 처럼 사용하면 된다.

 

  ? 연산자는 null 조건부 연산자이다. 즉, event가 null이 아닐 경우 에만 호출된다. null 아니면, Invoke() 통해 event가 발생되고, event를 구독하고 있는  event handlers가 실행된다.

 

Event는 보통 동사로 이벤트가 발생했음을 나타내는 이름을 사용하는 것이 일반적이다.

# 맞다면, delegate로 구현되였거나, 그 일종이므로, 메서드로 생각해도 될 것 같다.

 

Observer

public class Observer : MonoBehaviour
{
    [SerializeField] private Subject subjectToObserve;

    private void OnThingHappened()
    {
        // any logic that responds to event goes here
        Debug.Log("Observer responds");
    }

    private void Awake()
    {
        if (subjectToObserve != null)
        {
            subjectToObserve.ThingHappened += OnThingHappened;
        }
    }

    private void OnDestroy()
    {
        if (subjectToObserve != null)
        {
            subjectToObserve.ThingHappened -= OnThingHappened;
        }
    }
}

 

public event Action ThingHappened; ,  이벤트에 대해서 += 연산자를 사용해 구독할 수 있고, -= 연산자를 사용해 구독을 해제 할 수 있다.

  • 구독  :  += 연산자를 사용하면, ThingHappened와 OnThingHappened 메서드가 결합한다.
  • 해제  :   -= 연산자 사용, 런타임 중, 이벤트가 발생했는데, 관찰자가 제거된 경우, 오류가 발생할 수 있다. 따라서 관찰자 수명 주기에 따라 적절히 구독을 해제해야한다.

이벤트 핸들러를 표시하기 위해, 접두사 On을 붙혀 명명한다.

 

# 사용 예시를 보아, 결국은 컴포넌트의 참조가 필요해 보이지만, 구독과 해제가 자유롭다. 구독과 해제가 하드코딩 되진 않는다. 구독 시 메서드가 같이 호출된다는 것으로 보면 될 듯하다.

 

 

In  Unity

  유니티에서는 UnityEngine.Events API 의 UnityAction delegate를 사용하는 별도의 UnityEvents 시스템을 가지고 있다.

  UnityEvents는 간단하게 게임플레이 이벤트를 만들 수 있지만, System event, action 보다는 느릴 수 있다.

  UnityActions는 인수를 사용하는 메서드를 호출하는 데 이점이 있지만, UnityEvent는 인수가 없는 메서드로 제한된다.

 

UnityEvents 는 더 간단하고 사용하기 싶지만, 호출할 수 있는 메서드 유형이 더 제한된다.

 

 

 

UnityEvent, UnityAction 비교

UnityEvent

public UnityEvent onClick;

 

  이 멤버가 스크립트에 추가되면, UI / button 오브젝트와 같이 유니티 에디터 인스펙터에서 메서드를 참조할 수 있는 공간이 생긴다.

 

 

onClick.Invoke();

 UnityEvent를 위와 같이 Invoke() 할 수 있다.

 

onClick.onClick.AddListener(UnityAction call);
onClick.RemoveListener(UnityAction call);

 구독하기 위해서는 += 연산자를 사용할 수 없고 마찬가지로 -= 연산자를 사용할 수 없다. 그 대신, .AddListener(), RemoveListener()를 사용한다. 

# UnityAction call 은 메서드의 시그니처(메서드명)을 의미하는 듯(delegate?)

 

UnityAction 

public UnityAction onClick;

  UnityAction의 경우, 위 멤버가 추가되어도, 유니티 에디터 인스펙터에서 표시되지 않는다.

onClick.Invoke();

  UnityAction 를 위와 같이 Invoke() 할 수 있다.

onClick +=
onClick -=

 구독 및 해제 사용시 +=  ,  -= 연산자를 사용하면 된다.

 

# 약간의 조사 및 추론에 의하면, UnityEvent는 에디터에서 지원가능하도록 직렬화를 적용하였고, 그로인해 사용에 편리하지만 제약을 얻은 것으로 보인다. UnityEvent는 UnityAction 사용해 구현 됬을 것이라 생각된다.

# System action 과 UnityAction은 내부 구현이 거의 비슷하다고 한다. 다만 호환은 안된다고.

 

 

 

장점과 단점

장점

  • publisher와 subscriber를 분리하는데 도움이 된다. 직접적 종속성 대신 느슨하게 유지하면서 통신한다.
  • C# 및 Unity는 System.Action, UnityEvents, UnityActions와 같이 이미 이벤트 시스템이 포함되어 별도로 구현하지 않아도 된다.
  • 각 관찰자과 각자의 이벤트 처리 논리를 가진다. 이를 통해 디버그 및 단위 테스트가 용이하다.
  • 사용자 인터페이스에 적합하다.

단점

  • 복잡성이 추가된다. 처음에 더 많은 세팅이 필요하게되고, 구독 및 해제(객체의 생명주기와 함께)에 신경써야한다.
  • 종속성이 아예 없는 것이 아니라 참조가 필요하다. 이를 위해 중앙집중식 정적 EventManager를 사용하면 도움이 된다.
  • 많은 게임 오브젝트에 대해 성능이 저하될 수 있다.

 

Pattern 개량법

  •  ObservableCollection 사용, c#에서 제공하며,  항목이 추가되거나 제거되면 목록이 새로고침되면 알려진다.
  • 고유 인스턴스 ID를 인수로 전달한다. 이를 통해 로직에서 GameObject를 확인할 수 있다.
  • 정적  EventManager를 활용한다.
  • 이벤트 큐 생성, 명령 패턴 결합하여, 이벤트를 이벤트 큐에 캡슐화 할 수 있다. 그 뒤 명령 버퍼를 사용하여, 이벤트의 재생 수와 선택적 무시를 할 수 있다.

2024.05.24 - [Endeavor/Unity Stuff] - Unity - FPS microgame code 분석 의 EventManager.cs 참고

 

Unity - FPS microgame code 분석

Goals Unity FPS - microgame의 code를 분석한다# 다수의 추측도 포함.FPS microgame / Assets / FPS / Scripts / AI / DetectionModule.cs EnemyController.cs  DetectionModule.cs EnemyMobile.cs  EnemyTurret.cs FollowPlayer.cs  NavigationModu

roaring-stretching-thought-wood.tistory.com

 

 

 

State pattern

더보기

States and state machines

  런타임에 변경되는 많은 시스템을 추적하고 관리해야 한다. 예를 들어 캐릭터 역시, Standing, Jumping, Walking 등의  state를 가진다.

 

FSM

  • 여러 상태에 대해, 주어진 시간에 하나의 현재 상태만 활성화 된다.
  • 각 상태는 런타임 조건에 따라 다른 상태로의 전환을 트리거 할 수 있다.
  • 전환이 발생하면 출력 상태가 새로운 활성 상태가 된다.

게임개발에서 finite-state-machine FSM를 사용해, 게임 액터 및 소품의 내부 상태를 추적한다. (Animation State Machines 도 FSM를 사용한다.)

 

State design pattern

  • FSM과 다른점은, 상태를 나타내는 인터페이스와 각 상태에 대해 이 인퍼페이스를 구현하는 클래스를 정의한다.
  • 상태에 따라 행동을 바꾸는 데에 필요한 Context나 Class는  현재 상태 객체의 참조를 가지고 있다.
  • 내부 상태가 변경되면 상태 객체에 대한 참조를 업데이트하여 다른 객체를 가르키도록하여, 컨텍스트의 동작을 변경한다.

/효과

  • SOLID 원칙을 준수하는 데 도움이 된다. 각 상태는 비교적 작으며 다른 상태로 전환하기 위한 조건만 추적한다.
  • 개방-폐쇄 윈칙을 유지하면서, 기존 상태에 영향을 주지않고 더 많은 상태를 추가할 수 있다(# 전환하기 위한 조건은 적절히 손 봐야 할듯? 다른 종류의 상태에 대한 건가?)
  • 하나의 Monolithic script보다 번거로운 전환이나 if문을 피할 수 있다.
  • 상태 변경 사항을 외부 개체에 전달하도록 기능을 확장할 수도 있다. 이벤트 추가도 가능하다.
  • 추적할 상태가 몇 개뿐일 경우 추가 구조가 과도할 수 있다. 이 패턴은 상태가 특정 복잡성까지 증가할 것으로 예상되는 경우에만 의미가 있을 수 있다.

Gang of Four 따라, state design pattern은 두가지 문제를 해결한다.

  • 객체는 내부 상태가 변경되면, 동작도 변경되어야 한다.
  • 상태별 동작은 독립적으로 정의 된다. 새 상태가 추가되어도, 기존 상태의 동작에는 영향을 주지 않는다.

새 상태가 추가되어도, 기존 상태에 미치는 영향을 최소화하기 위해, 상태를 객체로 캡슐화한다.

이에 따라 특정 상태는 Entry, Update, Exit로 캡슐화된다.

 

이에 따른 interface 코드

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace DesignPatterns.State
{
    public interface IState
    {
        public void Enter()
        {
            // code that runs when we first enter the state
        }

        public void Update()
        {
            // per-frame logic, include condition to transition to a new state
        }

        public void Exit()
        {
            // code that runs when we exit the state
        }
    }
}

 

구현 예시

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace DesignPatterns.State
{
    public class IdleState : IState
    {

        private PlayerController player;

        // pass in any parameters you need in the constructors
        public IdleState(PlayerController player)
        {
            this.player = player;
        }

        public void Enter()
        {
            // code that runs when we first enter the state
            //Debug.Log("Entering Idle State");
        }

        // per-frame logic, include condition to transition to a new state
        public void Update()
        {
            // if we're no longer grounded, transition to jumping
            if (!player.IsGrounded)
            {
                player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.jumpState);
            }

            // if we move above a minimum threshold, transition to walking
            if (Mathf.Abs(player.CharController.velocity.x) > 0.1f || Mathf.Abs(player.CharController.velocity.z) > 0.1f)
            {
                player.PlayerStateMachine.TransitionTo(player.PlayerStateMachine.walkState);
            }
        }

        public void Exit()
        {
            // code that runs when we exit the state
            //Debug.Log("Exiting Idle State");
        }
    }
}

 

  •  메서드 Update()에 if 문으로 상태 천이 조건 및 메서드 TransitionTo() 가 호출된다. (monobehavier를 상속하지 않음으로, Update() 문은 따로 호출되어야함.)

 

 

 StateMachine.cs 코드

[Serializable]
public class StateMachine
{
    public IState CurrentState { get; private set; }

    // reference to the state objects
    public WalkState walkState;
    public JumpState jumpState;
    public IdleState idleState;

    // event to notify other objects of the state change
    public event Action<IState> stateChanged;

    // pass in necessary parameters into constructor 
    public StateMachine(PlayerController player)
    {
        // create an instance for each state and pass in PlayerController
        this.walkState = new WalkState(player);
        this.jumpState = new JumpState(player);
        this.idleState = new IdleState(player);
    }

    // set the starting state
    public void Initialize(IState state)
    {
        CurrentState = state;
        state.Enter();

        // notify other objects that state has changed
        stateChanged?.Invoke(state);
    }

    // exit this state and enter another
    public void TransitionTo(IState nextState)
    {
        CurrentState.Exit();
        CurrentState = nextState;
        nextState.Enter();

        // notify other objects that state has changed
        stateChanged?.Invoke(nextState);
    }

    // allow the StateMachine to update this state
    public void Update()
    {
        if (CurrentState != null)
        {
            CurrentState.Update();
        }
    }
}

 

#코드 분석

  • StateMachine에서 모든 State를 생성하고 참조를 가진다.
  • Update()에서 State에 따른 Update()를 호출한다. monobehavier를 상속하지 않음으로, Update() 문은 따로 호출되어야한다. PlayerController의 Update() 메서드에서 호출된다.
  • PlayerController의 특정 변수( speed변수 등)를 바꾼다기 보단, 말 그대로 PlayerController의 상태를 구별하기 위한 것으로 보여진다.
    • # 생각해보면 PlayerController의 속도 및 위치에 따른 상태를 나타내는 것인데, 상태에서 속도 변수를 변경한다는게 말이 안되는 것이긴 하다.

 

StateMachine

  • Serializalbe 특성을 사용해 inspector에 표시할수있다 (???). 다른 Monobehaviour 객체가 StateMachine을 field로 사용 할 수 있어야 한다.
  • CurrentState는 읽기 전용, 외부에서는 초기화 메서드를 사용해 초기화한다.
  • 각 State는 TransitionTo 메서드를 호출하기위한 자체조건을 결정

 

응용

  예제에서는 IState에서  IColoralbe를 상속 받아  Color MeshColor 멤버를 추가하여, 외부에서 상태에 따라 텍스트 색상을 변경하도록 구현되었다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace DesignPatterns.State
{
    // extra interface added just for the example project,
    // used to change the player's color

    public interface IColorable 
    {
        public Color MeshColor { get; set; }
    }
}
public interface IState: IColorable
{
	//~~~
}
public class IdleState : IState
{
    //~~~

    // color to change player (alternately: pass in color value with constructor)
    private Color meshColor = Color.gray;
    public Color MeshColor { get => meshColor; set => meshColor = value; }

    //~~~
}
 public class PlayerStateView : MonoBehaviour
 {
     [SerializeField] private Text labelText;

     private PlayerController player;
     private StateMachine playerStateMachine;

     // mesh to changecolor
     private MeshRenderer meshRenderer;

     private void Awake()
     {
         player = GetComponent<PlayerController>();
         meshRenderer = GetComponent<MeshRenderer>();

         // cache to save typing
         playerStateMachine = player.PlayerStateMachine;

         // listen for any state changes
         playerStateMachine.stateChanged += OnStateChanged;
     }

     void OnDestroy()
     {
         // unregister the subscription if we destroy the object
         playerStateMachine.stateChanged -= OnStateChanged;
     }

     // change the UI.Text when the state changes
     private void OnStateChanged(IState state)
     {
         if (labelText != null)
         {
             labelText.text = state.GetType().Name;
             labelText.color = state.MeshColor;
         }

         ChangeMeshColor(state);
     }

     // set mesh material to the current state's associated color
     private void ChangeMeshColor(IState state)
     {
         if (meshRenderer == null)
         {
             return;
         }

         meshRenderer.sharedMaterial.color = state.MeshColor;
     }
 }

 

 

개량법

  • 애니메이션의 state pattern과 연동한다.
  • 이벤트를 추가한다
  • 계층적으로 상태를 구현한다.
  • 간단한 AI를 구현한다.

 

 

Object pooling

더보기
  • 오브젝트 생성 및 소멸자 호출은 비교적 많은 리소스를 소모하므로, 반복적으로 생성 및 삭제하는 대신, 오브젝트를 미리 생성하고 active 및 disactive 하여 재사용하는 Design pattern 이다.
  • pattern은 플레이어가 끊김 현상을 알아차리지 못하는 적절한 시간에 사전 인스턴스화 되야한다.

 

Unity 에서는 pool API를 제공한다. objectpool 자체의 구현은 생략

using UnityEngine.Pool;

스택 기반의 ObjectPool 클래스를 얻을 수 있다. 필요에 따라 CollectionPool 클래스를 사용할 수도 있다.

추가적으로, DictionaryPool<T0,T1> 및 HashSetPool<T0>도 제공한다.

LinkedPool 를 사용하여 경우에 따라, 더 나은 메모리 관리로 이어지지만, 데이터 구조를 관리하기 위한 항목당 더 많은  CPU주기를 소비한다.

 

 

ObjectPool 

 

 Object<T> 의 생성자에는 일부 논리를 설정하는 데에 유용한 기능이 있다.

  • pool을 채우기 위한 풀링된 아이템 생성
  • pool에서 아이템을 가져오기
  • pool에서 아이템을 반환하기
  • 풀링된 아이템을 파괴하기(최대 리미트를 건들였을 경우)
public class ObjectPool<T> : IDisposable, IObjectPool<T> where T : class
{
	//~~~
    
    public ObjectPool(
    Func<T> createFunc,
    Action<T> actionOnGet = null,
    Action<T> actionOnRelease = null,
    Action<T> actionOnDestroy = null,
    bool collectionCheck = true, int defaultCapacity = 10,
    int maxSize = 10000
    )
    
    //~~~
}

#구현 코드가 생각보다 간단하다.

 

Func<T> createFunc

  • Func<T>, return을 통해 type T를 반환하는 메서드를 대상으로 한다. (#로직 상)
  • Instantiate() 를 통해 객체를 생성해 반환해 줘야한다.(#아마 일반적으로 생성해서 반환할 듯)
  • 아래 예제에서는 발사체 class RevisedProjectile 에서 objectPool에 접근에 용이하도록 property 를 선언했다. 그리고 이것에 해당하는 메서드에서 초기화했다.

Action<T> actionOnGet

  • Action<T>, T를 인수로 받고 리턴타입이 void인 메서드를 대상으로 한다. ( #로직 상)
  • Get() 할 때, 같이 실행되는 로직이다. 없어도 무방. objectPool .Get() 으로 받은 뒤 로직을 구성해도 된다.
  • 아래 예제에서는 SetActive(true)를 여기서 조작한다. 

Action<T> actionOnRelease

  • Action<T>, T를 인수로 받고 리턴타입이 void인 메서드를 대상으로 한다. (#로직 상)
  • Release() 할 때, 같이 실행되는 로직이다. 없어도 무방. objectPool.Release()를 호출하기 전에 받은 뒤 로직을 구성해도 된다.
  • 아래 예제에서는 SetActive(False)를 여기서 조작한다. 

bool collectionCheck

  • objectPool.Release()로 pool 객체를 반환해주기 전에, 이미 pool 내에 존재하는 객체인지 확인하는 로직의 동작 여부를 나타낸다.
  • False를 하면 내부 리스트를 검사하는 로직을 패스하지만, 재활성화된 개체가 반환될 위험이 존재하게 된다.

 

defaultCapacity

  • 미리 커밋하려는 리스트 메모리 할당량이다.(#개체 자체를 미리 생성해 두지 않는건 왜지?)
  • m_List = new List<T>(defaultCapacity);

maxPoolSize

  • 스택의 최대 크기이며, 이 크기를 초과해서 반환되면 파괴된다.

 

 

주어진 프로젝트를 살펴보니, 초기에는 투사체가 없다가 필요한 만큼 생성된다.

#미리 생성해 두기 위해서는 별도의 로직이 필요해보인다.

 

ObjectPool 사용예제

 public class RevisedGun : MonoBehaviour
{
    //~~~
    [Tooltip("Prefab to shoot")]
    [SerializeField] private RevisedProjectile projectilePrefab;
    
    // stack-based ObjectPool available with Unity 2021 and above
    private IObjectPool<RevisedProjectile> objectPool;

    // throw an exception if we try to return an existing item, already in the pool
    [SerializeField] private bool collectionCheck = true;

    // extra options to control the pool capacity and maximum size
    [SerializeField] private int defaultCapacity = 20;
    [SerializeField] private int maxSize = 100;

    private void Awake()
    {
     objectPool = new ObjectPool<RevisedProjectile>(CreateProjectile,
         OnGetFromPool, OnReleaseToPool, OnDestroyPooledObject,
         collectionCheck, defaultCapacity, maxSize);
    }

    // invoked when creating an item to populate the object pool
    private RevisedProjectile CreateProjectile()
    {
     RevisedProjectile projectileInstance = Instantiate(projectilePrefab);
     projectileInstance.ObjectPool = objectPool;
     return projectileInstance;
    }

    // invoked when returning an item to the object pool
    private void OnReleaseToPool(RevisedProjectile pooledObject)
    {
     pooledObject.gameObject.SetActive(false);
    }

    // invoked when retrieving the next item from the object pool
    private void OnGetFromPool(RevisedProjectile pooledObject)
    {
     pooledObject.gameObject.SetActive(true);
    }

    // invoked when we exceed the maximum number of pooled items (i.e. destroy the pooled object)
    private void OnDestroyPooledObject(RevisedProjectile pooledObject)
    {
     Destroy(pooledObject.gameObject);
    }

    //~~~
}

 

 

발사체 컨트롤 예제

public class RevisedGun : MonoBehaviour
{
    [Tooltip("Projectile force")]
    [SerializeField] private float muzzleVelocity = 700f;

    [Tooltip("End point of gun where shots appear")]
    [SerializeField] private Transform muzzlePosition;

    [Tooltip("Time between shots / smaller = higher rate of fire")]
    [SerializeField] private float cooldownWindow = 0.1f;

    //~~~

    
    
    private void FixedUpdate()
    {
        // shoot if we have exceeded delay
        if (Input.GetButton("Fire1") && Time.time > nextTimeToShoot && objectPool != null)
        {

            // get a pooled object instead of instantiating
            RevisedProjectile bulletObject = objectPool.Get();

            if (bulletObject == null)
                return;

            // align to gun barrel/muzzle position
            bulletObject.transform.SetPositionAndRotation(muzzlePosition.position, muzzlePosition.rotation);

            // move projectile forward
            bulletObject.GetComponent<Rigidbody>().AddForce(bulletObject.transform.forward * muzzleVelocity, ForceMode.Acceleration);

            // turn off after a few seconds
            bulletObject.Deactivate();

            // set cooldown delay
            nextTimeToShoot = Time.time + cooldownWindow;

        }
    }
}

# 코드 분석

nextTimeToShoot = Time.time + cooldownWindow; 및 if 조건 Time.time > nextTimeToShoot를 이용한 공격 딜레이

objectPool.Get(); 통해 객체를 받고, 객체를 설정

bulletObject.Deactivate(); 는 class RevisedProjectile에 선언된, 일정 시간 후 Release(this)를 하는 메서드

#

// projectile revised to use UnityEngine.Pool in Unity 2021
public class RevisedProjectile : MonoBehaviour
{
    // deactivate after delay
    [SerializeField] private float timeoutDelay = 3f;

    private IObjectPool<RevisedProjectile> objectPool;

    // public property to give the projectile a reference to its ObjectPool
    public IObjectPool<RevisedProjectile> ObjectPool { set => objectPool = value; }

    public void Deactivate()
    {
        StartCoroutine(DeactivateRoutine(timeoutDelay));
    }

    IEnumerator DeactivateRoutine(float delay)
    {
        yield return new WaitForSeconds(delay);

        // reset the moving Rigidbody
        Rigidbody rBody = GetComponent<Rigidbody>();
        rBody.velocity = new Vector3(0f, 0f, 0f);
        rBody.angularVelocity = new Vector3(0f, 0f, 0f);

        // release the projectile back to the pool
        objectPool.Release(this);
    }
}

 

# 코드 분석

IObjectPool<T>,  class ObjectPool<T> 가 상속받은 인터페이스로 선언됨, property 멤버도 생성

IEnumerator, 코루틴으로 몇초 뒤에 Release하도록 구성됨

#

 

 

MVC, MVP Pattern

더보기

Model View Controller

  • 어플리케이션의 데이터와 논리를 표시하는 방식을 분리한다.
  • Unity에서는 데이터(Model), 시각적 표현 UI(View), 둘 사이의 상호작용을 제어하는 논리(Controller or Presenter) 로 분리한다.

/ 장점과 단점

  • 원할한 작업 분할, 단순화된 단위 테스트, 가독성

 

  • 미리 계획해야한다.
  • 모든 프로젝트가 이 패턴에 의해 쉽게 분할되지 않는다.
  • 간단한 스크립트에서는 큰 이점을 얻지 못한다.

 

 

 

MVC

  • 컨셉은  소프트웨어의 논리적인 부분을 데이터 및 시청각화 분리하는 것이다.
  • 불필요한 종속성을 줄이고, 잠재적으로 spaghetti code를 줄일 수 있다.

/ 구조

  • Model : 데이터를 저장한다. 값을 보유하는 데이터 컨테이너, 별도의 게임플레이 로직을 수행하거나 계산을 수행하지 않는다.
  • View : 인터페이스, 화면에 데이터의 시각표현을 형식화하고 렌더링한다.
  • Controller : 로직을 처리한다. 게임 데이터를 처리하고 런타임 시 값이 어떻게 변경되는지 계산한다.

 

Controller와 View에는 데이터가 포함되지 않는다. MVC는 각 레이어의 기능을 제한한다. 표면적으로는 단일 책임 원칙의 확장으로 생각할 수 있다.

 

 

MVP

Model View Presenter

  MVC를 unity에 개발할때, UI 프레임워크는 view로 사용하면 된다. 하지만 모델 데이터의 변경 사항을 수신하기 위한 view 관련 코드가 필요하다. 유효한 방법이지만, 많은 Unity 개발자는 컨트롤러가 중개자 역할을 하는 MVC의 변형을 사용한다.(#아마 인터페이스로 상호작용하기 위함 인듯?)

 

 

Pensenter (Controller at MVC) 가 다른 레이어를 중재하는 역할을 한다. 사용자 입력을 View가 처리한다.

 

 

 

예제

예제에서는 데이터와 UI가 하나의 script에 포함되어있지만, 실제로 이렇게 구현하지 않음을 유의한다.

 

namespace DesignPatterns.MVP
{
    // The Model. This contains the data for our MVP pattern.
    public class Health : MonoBehaviour
    {
        // This event notifies the Presenter that the health has changed.
        // This is useful if setting the value (e.g. saving to disk or
        // storing in a database) takes some time.
        public event Action HealthChanged;

        private const int minHealth = 0;
        private const int maxHealth = 100;
        private int currentHealth;

        public int CurrentHealth { get => currentHealth; set => currentHealth = value; }
        public int MinHealth => minHealth;
        public int MaxHealth => maxHealth;

        public void Increment(int amount)
        {
            currentHealth += amount;
            currentHealth = Mathf.Clamp(currentHealth, minHealth, maxHealth);
            UpdateHealth();
        }

        public void Decrement(int amount)
        {
            currentHealth -= amount;
            currentHealth = Mathf.Clamp(currentHealth, minHealth, maxHealth);
            UpdateHealth();
        }

        // max the health value
        public void Restore()
        {
            currentHealth = maxHealth;
            UpdateHealth();
        }

        // invokes the event
        public void UpdateHealth()
        {
            HealthChanged.Invoke();
        }
    }
}

 

 데이터를 가지는 model에서 실제 상태 값을 저장하고, 값이 변경되면 이벤트를 invoke한다. 여기에 게임플레이 로직(액션을 통한 체력 증감)이 포함되지 않으며, 데이터를 늘이고 줄이는 방법만 포함된다.

 

mathf.Clamp(currentHealth, minHealth, maxHealth) 에서는 값을 범위를 벗어나지 않는 값을 반환함.

 

namespace DesignPatterns.MVP
{
    // The Presenter. This listens for View changes in the user interface and the manipulates the Model (Health)
    // in response. The Presenter updates the View when the Model changes.

    public class HealthPresenter : MonoBehaviour
    {
        [Header("Model")]
        [SerializeField] Health health;

        [Header("View")]
        [SerializeField] Slider healthSlider;
        [SerializeField] Text healthText;

        private void Start()
        {
            if (health != null)
            {
                health.HealthChanged += OnHealthChanged;
            }

            Reset();
        }

        private void OnDestroy()
        {
            if (health != null)
            {
                health.HealthChanged -= OnHealthChanged;
            }
        }

        // send damage to the model
        public void Damage(int amount)
        {
            health?.Decrement(amount);
        }

        public void Heal(int amount)
        {
            health?.Increment(amount);
        }

        // send reset to the model
        public void Reset()
        {
            health?.Restore();
        }

        public void UpdateView()
        {
            if (health == null)
                return;

            // format the data for view
            if (healthSlider !=null && health.MaxHealth != 0)
            {
                healthSlider.value = (float) health.CurrentHealth / (float)health.MaxHealth;
            }

            if (healthText != null)
            {
                healthText.text = health.CurrentHealth.ToString();
            }
        }

        // listen for model changes and update the view
        public void OnHealthChanged()
        {
            UpdateView();
        }
    }
}

 

 데부분의 객체는 Health 자체를 조작하지 않는다. 해당 작업을 위해 HealthPresenter을 사용해야한다. 일반적으로, Health가 HealthChanged 이벤트를 발생시킬 때까지 인터체이스 업데이트를 기다린다. 이런 방법은 값을 설정하는 데 시간이 짧은 경우 (값을 디스크에 저장하거나 데이터베이스에 저장하는 경우) 유용하다.

 

 

Factory pattern

더보기
  • 다른 객체를 생성하는 특정 객체를 가짐으로써, 다양한 무언가에 대해 런타임에 필요할 때까지 모르는 경우에 대해 대처할 수 있다.
  • 이를 위해 팩토리라는 객체를 지정하고,  product 생성과 관련된 세부사항이 캡슐화 된다.
  • product가 공통 인터페이스 혹은 기본 클래스를 따르는 경우, 구성 로직을 더 포함하게 만들어 factory에서 감출 수 있다. 이렇게, 새로운 유형의 product를 생성하는 것이 더 용이해진다.

 

/ 장점 단점

  • product 유형이 많을때 용이하다.
  • 각 팩토리는 기부 세부 정보에 접근하지않고, 초기화를 호출하는 방법만 알면 된다.

 

  • 패턴 구현을 위해 여러 클래스와 하위 클래스를 생성해야한다. 약간의 오버해드가 발생한다.

 

 코드 예제

namespace DesignPatterns.Factory
{
    // a common interface between products
    public interface IProduct
    {
        // add common properties and methods here
        public string ProductName { get; set; }

        // customize this for each concrete product
        public void Initialize();
    }
}
  • 제품에 대한 인터페이스, 특정 템플릿을 따르되 그 외의 기능은 공유하지 않는다.
namespace DesignPatterns.Factory
{
    // base class for factories
    public abstract class Factory : MonoBehaviour
    {
        public abstract IProduct GetProduct(Vector3 position);

        // shared method with all factories
        public string GetLog(IProduct product)
        {
            string logMessage = "Factory: created product " + product.ProductName;
            return logMessage;
        }
    }
}

 

  • 이 샘플에서는 추상 메서드를 사용한다. 서브클래스 사용시, 리스코프 치환에 대해 유의한다. 부모클래스의 객체를 서브클래스의 객체로 대체 할 수 있어야 한다. 이를 통해, 부모 클래스의 주소 변수가 하위 클래스 객체를 참조하는지 알지못하는 채로 사용할 수 있어야 한다.

 

 

파생 예제

ProductA

namespace DesignPatterns.Factory
{
    public class ProductA : MonoBehaviour, IProduct
    {
        [SerializeField] private string productName = "ProductA";
        public string ProductName { get => productName; set => productName = value ; }

        private ParticleSystem particleSystem;

        public void Initialize()
        {
            // any unique logic to this product
            gameObject.name = productName;
            particleSystem = GetComponentInChildren<ParticleSystem>();
            particleSystem?.Stop();
            particleSystem?.Play();
        }
    }
}

ProductB

namespace DesignPatterns.Factory
{
    public class ProductB : MonoBehaviour, IProduct
    {
        [SerializeField] private string productName = "ProductB";
        public string ProductName { get => productName; set => productName = value; }

        private AudioSource audioSource;

        public void Initialize()
        {
            // do some logic here
            audioSource = GetComponent<AudioSource>();
            audioSource?.Stop();
            audioSource?.Play();

        }
    }
}
  • interface는 공통적으로 따르고 각각 자신만의 추가 요소와 별도로 구현된 메서드를 가진다.
  • 각 Product의 다른 동작을 알 필요 없이, Initialize() 만 알면 된다는 점을 유의한다.

 

 

ConcreteFactoryA

namespace DesignPatterns.Factory
{
    public class ConcreteFactoryA : Factory
    {
        // used to create a Prefab
        [SerializeField] private ProductA productPrefab;

        public override IProduct GetProduct(Vector3 position)
        {
            // create a Prefab instance and get the product component
            GameObject instance = Instantiate(productPrefab.gameObject, position, Quaternion.identity);
            ProductA newProduct = instance.GetComponent<ProductA>();

            // each product contains its own logic
            newProduct.Initialize();

            return newProduct;
        }
    }
}

 

ConcreteFactoryB

namespace DesignPatterns.Factory
{
    public class ConcreteFactoryB : Factory
    {
        // used to create a Prefab
        [SerializeField] private ProductB productPrefab;

        public override IProduct GetProduct(Vector3 position)
        {
            // create a Prefab instance and get the product component
            GameObject instance = Instantiate(productPrefab.gameObject, position, Quaternion.identity);
            ProductB newProduct = instance.GetComponent<ProductB>();

            // each product contains its own logic
            newProduct.Initialize();

            // add any unique behavior to this factory
            instance.name = newProduct.ProductName;
            Debug.Log(GetLog(newProduct));

            return newProduct;
        }
    }
}

 

 

  • interface를 공통적으로 따르고  각각 자신만의 추가 요소와 별도로 구현된 메서드를 가진다

 

예제에서는 A,B 랜덤 생산을 위해 상위 모듈 ClickToCreate Factory[] 변수를 통해, factory a,b를 받아 랜덤을 적용하였다.

 

 

추가 고려 사항

  • 사전을 사용하여 제품 검색
  • 팩토리를 정적으로 생성
  • MonoBehaviours일 필요없음
  • 객체 풀 패턴과 결합한다.

Command pattern

더보기
  • 원래 Gang of Four 중 하나이며, 특정 일련의 작업을 추적하려고 할때 유용하다.
  • 명령패턴을 사용하여 작업을 객체로 표현하고, 액션을 캡슐화하여 사용자 입력에 응답하여 게임오브젝트의 동작을 제어하는 유연하고 확장가능한 시스템을 만들 수 있다. 
  • 버퍼를 통해 명령을 저장하여, 나중에 재생할 수 있도록, 작업을 잠재적으로 지연시켜 실행 타이밍을 제어할 수 있다.

/활용 예시

  • 유닛 생산 대기열
  • 턴 기반 게임에서, 턴을 넘기기전에 실행을 자유롭게 하거나 되돌림
  • 퍼즐게임에서 실행을 하거나 되돌림
  • 격투게임에서 콤보를 통한 특정 공격 실행

 

/장단점

  • 플레이어 입력을 처리하는 데에서 이점과 확장성이 생긴다.

 

  • 더 많은 구조가 필요하다.

 

 

명령 인터페이스 예제

명령 개체에는 수행할 논리와 실행 취소 방법이 포함된다.

namespace DesignPatterns.Command
{
    // interface to wrap your actions in a "command object"
    public interface ICommand
    {
        public void Execute();
        public void Undo();
    }
}

 

  • 명령 개체에는 수행할 논리와 실행 취소 방법이 포함된다.
  • CommandInvoker에서 Command의 실행 주체와, 내부 로직을 알 필요없이, Execute()와 Undo() 만으로 시스템이 구성되어야 한다.

 

CommandInvoker 예제

public class CommandInvoker
{
    // stack of command objects to undo
    private static Stack<ICommand> _undoStack = new Stack<ICommand>();

    // second stack of redoable commands
    private static Stack<ICommand> _redoStack = new Stack<ICommand>();

    // execute a command object directly and save to the undo stack
    public static void ExecuteCommand(ICommand command)
    {
        command.Execute();
        _undoStack.Push(command);

        // clear out the redo stack if we make a new move
        _redoStack.Clear();
    }

    public static void UndoCommand()
    {
        if (_undoStack.Count > 0)
        {
            ICommand activeCommand = _undoStack.Pop();
            _redoStack.Push(activeCommand);
            activeCommand.Undo();
        }
    }

    public static void RedoCommand()
    {
        if (_redoStack.Count > 0)
        {
            ICommand activeCommand = _redoStack.Pop();
            _undoStack.Push(activeCommand);
            activeCommand.Execute();
        }
    }
}

 

 

 

  • 명령 실행 및 실행 취소를 담당한다. 추가로 Redo 기능을 위해, _redoStack를 가진다. 

 

명령 구현 예제

public class MoveCommand : ICommand
{
    private PlayerMover _playerMover;
    private Vector3 _movement;

    // pass parameters into the constructor
    public MoveCommand(PlayerMover player, Vector3 moveVector)
    {
        this._playerMover = player;
        this._movement = moveVector;
    }

    // logic of thing to do goes here
    public void Execute()
    {
        // add point to path visualization
        _playerMover?.PlayerPath.AddToPath(_playerMover.transform.position + _movement);

        // move by vector
        _playerMover.Move(_movement);
    }
    // undo logic goes here
    public void Undo()
    {
        // move opposite direction
        _playerMover.Move(-_movement);

        // remove point from path visualization
        _playerMover?.PlayerPath.RemoveFromPath();
 
    }
}

 # 코드 분석

move 동작을 직접적으로 구현한다기 보단, 메서드 호출로 Move커맨드를 구성한다고 보여진다. 이를통해, Move커맨드에 대해, Path 를 표시하는 기능 메서드 호출을 통해 구성되었다.

#

각 커맨드에 필요한 변수 멤버를 가진다.

 

 InputManger

구성한 Command를 이용하여 동작을 구성한다.

public class InputManager : MonoBehaviour
{
    // UI Button controls
    [Header("Button Controls")]
    [SerializeField] Button forwardButton;
    [SerializeField] Button backButton;
    [SerializeField] Button leftButton;
    [SerializeField] Button rightButton;
    [SerializeField] Button undoButton;
    [SerializeField] Button redoButton;

    [SerializeField] private PlayerMover player;

    private void Start()
    {
        // button setup
        forwardButton.onClick.AddListener(OnForwardInput);
        backButton.onClick.AddListener(OnBackInput);
        rightButton.onClick.AddListener(OnRightInput);
        leftButton.onClick.AddListener(OnLeftInput);
        undoButton.onClick.AddListener(OnUndoInput);
        redoButton.onClick.AddListener(OnRedoInput);
    }

    private void RunPlayerCommand(PlayerMover playerMover, Vector3 movement)
    {
        if (playerMover == null)
        {
            return;
        }

        // check if movement is unobstructed
        if (playerMover.IsValidMove(movement))
        {
            // issue the command and save to undo stack
            ICommand command = new MoveCommand(playerMover, movement);

            // we run the command immediately here, but you can also delay this for extra control over the timing
            CommandInvoker.ExecuteCommand(command);
        }
    }

    private void OnLeftInput()
    {
        RunPlayerCommand(player, Vector3.left);
    }

    private void OnRightInput()
    {
        RunPlayerCommand(player, Vector3.right);
    }

    private void OnForwardInput()
    {
        RunPlayerCommand(player, Vector3.forward);
    }

    private void OnBackInput()
    {
        RunPlayerCommand(player, Vector3.back);
    }

    private void OnUndoInput()
    {
        CommandInvoker.UndoCommand();
    }

    private void OnRedoInput()
    {
        CommandInvoker.RedoCommand();
    }
}

 CmmandInvoker를 통해 커맨드 동작을 실행시킨다.

 

 

 

 

개량법

  • 더 많은 커맨드를 생성
  • redo 기능을 위한 추가 추가 스택(예제에서 구현됨)
  • queue 및 index를 사용해, 한 자료구조 내에서 undo, redo 구현
  • 스택 사이즈 제한
  • 필요한 파라미터를 전달 (예제에서 구현됨)

 

 

 # 콤보 시스템 스크립트 분석 예정.

 

 

 

Singleton Pattern

더보기
  • 클래스가 하나만 생성되도록 보장한다.
  • global 접근이 용이하도록한다.

 

  • 씬 간 플로우에 있어서, 하나의 객체만 유지하고 할 때(데이터 지속성 관련), 유용하다.
  • Game Programming Pattens에서는 Singleton 패턴은 득보다 실이 더 많은, anit- pattern이라고 한다. 이러한 평판은 남용으로 인해 생겼났다.

 

/장단점

  • global 접근이 필요하다. 많은 종속성을 숨기기 때문에 디버그가 힘들어 진다.
  • unit 테스트는 반드시 독립된 단위로 실시한다. 때문에 씬 간 상태를 변화시킬 수 있는 싱글톤은 테스트에 간섭한다.
  • 대부분은 디자인 패턴은 종속성을 제거하는 데 비해, 싱글톤은 반대이다.

기업 레벨의 프로젝트에서는 싱글톤을 피한다.

 

  • 싱글톤은 상대적으로 학습 속도가 빠르다.
  • 유저친화적이다. 씬의 객체가 언제든지 싱글톤 객체에 접근할 수 있다.
  • GetComponent와 같은 비교적 오래 걸리는 작업을 피할 수 있다.

 

SimpleSingleton

 public class SimpleSingleton : MonoBehaviour
 {
     // global access
     public static SimpleSingleton Instance;

     private void Awake()
     {
         if (Instance != null)
         {
             // if Instance is already set, destroy this duplicate
             Destroy(gameObject);
         }
         else
         {
             // if Instance is not set, make this instance the singleton
             Instance = this;
         }
     }
 }

 

 static과 Awake() 를 사용해, 생성된 객체가 하나만 유지되도록 한다.

 

simpleSingleton의 이슈

  • 새로운 씬에서 객체가 사라진다(씬간 데이터 지속성이 필요하다.)
  • singleton 특성을 사용하기 위해서는 사용하기 전에 설정해야한다.

lazy instantiation 게으른 초기화를 통해 싱클톤이 자동으로 구현되도록 한다.

 

SingletonPersistent

public class Singleton : MonoBehaviour
{
    private static Singleton _instance;

    // global access
    public static Singleton Instance
    {
        get
        {
            if (_instance == null)
            {
                SetupInstance();
            }
            return _instance;
        }
    }

    private void Awake()
    {
        // if this is the first instance, make this the persistent singleton
        if (_instance == null)
        {
            _instance = this;
            DontDestroyOnLoad(this.gameObject);
        }
        // otherwise, remove any duplicates
        else
        {
            Destroy(gameObject);
        }
    }

    private static void SetupInstance()
    {
        // lazy instantiation
        _instance = FindObjectOfType<Singleton>();

        if (_instance == null)
        {
            GameObject gameObj = new GameObject();
            gameObj.name = "Singleton";
            _instance = gameObj.AddComponent<Singleton>();
            DontDestroyOnLoad(gameObj);
        }
    }



}

 

 

 

 

제너릭 적용

 public class Singleton<T> : MonoBehaviour where T : Component
 {
     private static T _instance;
     public static T Instance
     {
         get
         {
             if (_instance == null)
             {
                 _instance = (T)FindObjectOfType(typeof(T));

                 if (_instance == null)
                 {
                     SetupInstance();
                 }
                 else
                 {
                     string typeName = typeof(T).Name;

                     Debug.Log("[Singleton] " + typeName + " instance already created: " +
                         _instance.gameObject.name);
                 }
             }

             return _instance;
         }
     }

     public virtual void Awake()
     {
         RemoveDuplicates();

     }

     private static void SetupInstance()
     {
         // lazy instantiation
         _instance = (T)FindObjectOfType(typeof(T));

         if (_instance == null)
         {
             GameObject gameObj = new GameObject();
             gameObj.name = typeof(T).Name;

             _instance = gameObj.AddComponent<T>();
             DontDestroyOnLoad(gameObj);
         }
     }

     private void RemoveDuplicates()
     {
         if (_instance == null)
         {
             _instance = this as T;
             DontDestroyOnLoad(gameObject);
         }
         else
         {
             Destroy(gameObject);
         }
     }
 }

 

사용 예제

    public class GameManager : Singleton<GameManager>
    {


    }

 

 

 

Game Programming Patterns

  • writter : Bob Nystrom

Introduction

  • 게임에서 일반적으로 직면하는 엔지니어링 문제에 적합한 패턴이다.
    • 타임과 순서, 올바른 순서로 올바른 타이밍에
    • 서로 발끝을 밟거나 발자국을 남기지 않고 다양한 동작을 신속하게 구축하는 방법
    • 이 동작이 정의되면, 이제야 상호작용이 시작된다. 이러한 상호작용은 코드베이스가 서로 얽히지 않는 상태에서 이루어져야한다.
    • 성능, 프레임 드랍 및 화난 리뷰어와 A급 게임 사이 차이를 만들어 낼 수도 있다.

 

SoftArchitecture

  • 코드를 구성하는 것, 모든 것을 main() 넣는 것 일지라도, 어느정도의 조직이 있다.
  • "좋은 디자인이란, 내가 변화를 가했을 때, 마치 전체 프로그램이 그것을 예상하고 만들어진 것 처럼 보인다는 것"
    • “Just write your code so that changes don’t disturb its placid surface.” 
    • 디자인의 척도는 변경사항을 얼마나 잘 수용하느냐에 달려있다.
  • 핵심은 수정할 때의 알아야할 코드가 적어야 한다는 것이다. (원숭이 뇌 뉴런을 학습시킬 정보를 줄인다.)
    • 무언가를 추가하거나 디버드를 하는 등 어떤 이유 때문에 코드를 바꾸기전에, 관련된 부분들과 존재하는 코드가 무엇을 하는지 알아야한다. 이 부분이 가장 시간이 오래걸린다.
  • 올바른 구문으로 구현하는건, 건 앞 뒤로 해야할 것이 많지만, 비교적 간단한 경우가 많고, 실제 코딩은 사소한 일이 된다.

/Cost

  • decoupling pattern를 통해, 나누어진 부분을 독립적으로 이해하도록 만든다.
  • 좋은 아키텍처는 진정한 노력과 규율이 필요하다.
  • 나중에 유연성이 추가될 것이라고 추측하게 되면서, 오히려 유지 관리가 걸리는 코드와 복잡성을 게임에 추가한다. 예측이 맞으면 보상을 받지만, 결국 도움이 되지 않으면 바로 해가 된다.
  • 코드에 얽매여 게임을 출시하려고 한다는 사실을 간과하기 쉽다.
  • "확장성에 대한 사이렌 노래는 무엇을 위한 엔진인지도 파악하지 못하고 엔진작업을 하는 개발자를 짜증나게 한다.

/Performance and Speed

  • 디자인이 안정될 때까지 코드를 유연하게 유지하고 마지막에 추상화 일부를 때어내 성능을 향상시킨다.
  • 좋은 아키텍처와 빠른 런타임 성능과 오늘 마감 은 서로 부분적으로 반대된다. 여기에는 간단한 대답이 없으며 단지 절충안만 있을 뿐이다.

/Simplicity

  • simplicity를 통해 이러한 제약을 완화할 수 있다. 하지만 간단히 적용할 수 있지 않다

Tip

  • 추상화와 디커플링을 통해 프로그램을 빠르고 쉽게 발전시킬수 있지만, 유연성이 필요하다고 확신하지 않는 한 이를 수행하는 데 시간을 낭비하지 마십시요
  • 개발 주기에 걸쳐 성능에 대한 생각을 가지고 설계하되, 가정을 코드에 고정시키는 낮은 수준의 핵심 최적화는 가능한 한 늧게까지 미루십시오
  • 게임 디자인 공간을 탐색하기 위해 빠르게 움직이되, 뒤죽박죽이 되지 않도록 하십시오
  • 재미있는 것을 만들고 싶다면 재미있게 만들어보십시오
  • 마감 2개월 전에 게임이 1FPS에서 돌아가는 문제"에 대한 걱정은 잔소리일뿐이다.

 

# 각 코드는 자료를 참고 하면서 임의로 C# 으로 구현한거라 미숙한 부분이 많음

Design Patterns Revisited

  • Command | Flyweight | Observer | Prototype | Singleton | State

Command

더보기

#Part 1에서 한번 다룸

 

"Encapsulate a request as an object, thereby letting users parameterize clients with different requests, queue or log requests, and support undoable operations"

 

정말 끔직한 문장이다 -  Bob Nystrom

 

 

"A command is a reified method call" 

커맨드 패턴은 구체화된 메서드 호출이다.

  • "Reify" 는 "make real", 다른 말로 "first-class"로 만드는것
  • 어떤 개념을 취하여, 변수를 추가하거나 함수에 넘길 수 있는 데이터의 조각(객체)로 만드는 것.
  • 객체에 랩핑된 메서드 호출

"Commands are an object-oriented replacement for callbacks"

명령 패턴은 콜백을 객체지향적으로 재체한다.

 

Configuring Input

사용자 입력과 게임 액션을 일대일로 매칭하면 작동은 되지만, 많은 게임의 경우 유저가 키를 직접 맵핑하는 기능을 지원한다. 이를 위해, 키에 매칭한 메서드를 "교체" 할 수 있게 만들어야 한다. 교체는 변수 할당과 비슷하게, 게임 액션을 개체로 만들어야 한다는 것이다. 

 

메서드, 키 일대일 매칭

public class InputManager : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKey(KeyCode.UpArrow)) { Jump(); }
        else if (Input.GetKey(KeyCode.DownArrow)) { FireGun(); }
        else if (Input.GetKey(KeyCode.LeftArrow)) { SwapWeapon(); }
        else if (Input.GetKey(KeyCode.RightArrow)) { LunchIneffectively(); }

    }
}

 

Command pattern

public interface Command
{
    public abstract void Execute();

}

class JumpCommand : Command
{
    public void Execute()
    {
        Jump();
    }
    void Jump() { }
}

public class InputManager : MonoBehaviour
{
    Command buttonX;
    Command buttonY;
    Command buttonA;
    Command buttonB;

    void Update()
    {
        if (Input.GetKey(KeyCode.UpArrow)) { buttonX.Execute(); }
        else if (Input.GetKey(KeyCode.DownArrow)) { buttonY.Execute(); }
        else if (Input.GetKey(KeyCode.LeftArrow)) { buttonA.Execute(); }
        else if (Input.GetKey(KeyCode.RightArrow)) { buttonB.Execute(); }

    }


}

 

 

Directions for Actor

  위의 Command Pattern 역시 잘 작동하지만, 제한적이다. 문제는 게임 액션 메소드( jump()  등)가 행위를 실행할 플레이어 아바타와 행위를 실행시킬 방법을 가진 최상위 객체가 되어버린 것이다. 이 Couoling(결합)으로 인해 유용성이 상당히 제한된다.

  이를 개선하기 위해 이러한 사항을 넘겨 줄 수 있도록한다.

public interface GameActor
{
    public abstract void Do();
}
public interface Command2
{
    public abstract void Execute(GameActor actor);

}
class JumpCommand2 : Command2
{
    public void Execute(GameActor actor)
    {
        actor.Do();
    }

}

 

#Command 시 객체를 생성하는 경우

public interface GameActor
{
    public abstract void Do();
}

class JumpCommand : Command
{
    private GameActor actor;
    public GameActor Actor { get { return actor; } set { actor = value; } }
    
    JumpCommand(GameActor actor)
    {
    	this.actor = actor;
    }
    public void Execute()
    {
        Jump();
    }
    void Jump() 
    {
        Actor.Do();
    }
}

 

 

이제 이를 해당하는 액터에서 실행 할 수 있도록 Command를 리턴하는 메서드(가져올 수 있도록)를 작성한다.

//~~~
Command2 currentCommand;
public Command2 CurrentCommand { get { return currentCommand; } }
void Update()
{
    if (Input.GetKey(KeyCode.UpArrow)) { currentCommand = buttonX; }
    else if (Input.GetKey(KeyCode.DownArrow)) { currentCommand = buttonY; }
    else if (Input.GetKey(KeyCode.LeftArrow)) { currentCommand = buttonA; }
    else if (Input.GetKey(KeyCode.RightArrow)) { currentCommand = buttonB; }

}

//~~~
public class Player : MonoBehaviour,GameActor
{
	//~~~
    void Command()
    {
        inputManager.CurrentCommand.Execute(this);
    }
    public void Do(){}
    //~~~
}

 

 명령과 명령을 수행하는 액터 사이에 레이어를 추가하여 작은 기능이 제공되었다. 플레이어가 게임의 어느 액터도 컨트롤 할 수 있게 만들 수 있다.

 이런식으로, AI엔진과 액터 사이에도 이러한 패턴을 적용할 수 있다. AI엔진에서 Command 객체만 리턴하도록 만들면 된다.

 

이렇게 액션과 액터의 decoupling을 통해 유연성을 얻을 수 있다.

 

이제 이것을 Queue나 커맨드의 흐름으로 생각해본다. 커맨드생산자(AI 엔진, 입력)와 커맨드소비자(액터) 이런식의 직렬화(serializable)를 통해 네트워크를 통한 게임플레이도 가능해진다.

 

 

Undo and Redo

  이 패턴에서 가장 알려진 용례로, 전략게임에서의 행동을 취소하는 Undo 기능을 구현할 수 있다. 커맨드 패턴이 없다면 Undo 기능은 정말로 구현하기 어렵다. (Mememto와 Persistent data structures 는 그리 도움이 되지 않는다.)

 

  플레이어가 입력을 할때마다 Command 객체를 생성하고, Command 객체에서는 각 행위를 복구시키는 기능을 탑재한다.

 

##

Part1 에서 다룬 Command pattern과 다른점은 전에는 위치을 복구시키는 메세드를 작성했다면, 여기서는 이전 위치를 Move에 기억하게 만들어 놓았다.

##

 

 

Classy and Dyhunctional?

 

# 대강 이 당시에는 c++에서는 델리게이트 같은 기능이 제한되었기 때문에 이러한 방식을 썻다는 얘기 같음. 사실상 이와 비슷하게 작동하기 때문에, Part1 에서도 Command 패턴은 undo redo 용례만 다루었다고 생각됨 

 

 

See Also

  • 다양한 명령 클래스가 생길 수 있다. 이를 쉽게 구현하기 위해, 파생 Command를 만들 수 있는 편리한 상위 레벨의 메서드를 가진 부모 클래스를 정의하는 것이 도움이 된다. 이렇게, execute() 메서드가 Subclass Sandbox 패턴이 된다. 
  •  여기서의 예제에서는 액터를 정확히 선택했지만, 몇몇 경우 특히 명령개체가 계층적인 경우, 개체가 응답하거나 하위 객체에 명령을 넘길수 있다. 이경우 Chain of Responsibility  패턴을 얻게 된다.
  • JumpCommand와 같은 일부 명령은 상태가 없는 순순한 행동(?)이다. 객체가 동일하기 때문에, 하나보다 많은 객체를 갖는 것은 메모리 낭비이다. Flyweight 패턴이 이를 해결한다.

Flyweight

더보기

  안개가 일어나고, 장엄함 장송이 드러나고, 셀수없이 많은 나무가 우뚝쏟아 녹지의 대성당을 이루고 있다. 나뭇잎으로 이루어진 스테인드 글라스는 햇빛을 황금빛 안개 줄기로 조각낸다. 거대한 줄기 사이로 울창한 숲이 눈에 띈다.

 

이는 게임 게발자가 꿈꾸는 일종의 초자연적 현상이고, 이러한 장면은 이름마저  이보다 겸손할 수 없는 FlyWeight 이다.

 

Forest for the Trees

이러한 장면을 구현하는 것은,  GPU 가 처리해야할 수 많은 폴리곤을 동반한다. 또한 이를 CPU에서 GPU로 전달해 줘야한다.

 

각 나무는 다음과 같은 요소로 이루어진다.

  • 줄기, 가지, 녹지을 정의하는 폴리곤의 메세
  • 나무껍집과 나뭇잎의 텍스쳐
  • 숲에서의 위치와 방향
  • 각 나무가 다르게보이도록 크기와 틴트롤 조절하는 변수

각 개체를 만들어 처리하는것은 너무나도 많은 GPU처리를 소모한다. 하지만 방법이 있다.

주요한 관점은 수많은 나무는 비슷해보인다는 것이다. 동일한 매시와 텍스처를 사용한다. 이러한 모든 객체의 모든 필드는 같다는 것을 의미한다.

 

이러한 공통요소와 차이요소를 나누어 클래스를 선언한다.

class TreeModel
{
    Mesh mesh;
    Texture bark;
    Texture leaves;
}

class Tree
{
    TreeModel model;
    Vector3 position;
    double height;
    double thickness;
    Color barkTint;
    Color leafTint;
}

이는 메모리 저장하는데에는 도움이 되지만 GPU에는 도움이 되지 않는다.

 

 

A Thousand Instances

  GPU에 밀어넣을 데이터를 줄이긴 위해서는 공유 데이터는 한번만 보내고 개별데이터를 밀어내는 것이다. 이는 오늘날의 그래픽 API가  지원한다. 이는 instanced randering이다

 

The Flyweight Pattern

  객체가 너무 많아서 가벼워야하는 경우 적용된다.

instanced randering와 같이 데이터의 종류를 공통된 것과 아닌 것으로 나눈다. 공통부분에 대해  "The Gang of Four" 애서는 공통부분을 본질적인 것으로 보지만, 책의 저자는 “context-free" 으로 본다.

  공통 부분이 아닌 부분은 extrinsic state 외부상태, 인스턴스의 고유한 항목이다. 이 패턴은 개체의 모든 위치에서 intrinsic state 를 공유해서 메모리를 절약한다.

  이것은 마치 기본적인 리소스 공유처럼 보인다. 패턴으로 부르기 힘들다. 이는 부분적으로 공유상태에 대해 명확하고 별도의 ID를 생각해 낼 수 있기 때문이다.

  공유객체에 대해 잘 정의된 ID가 없는 경우에 이 패턴을 사용하면 이 패턴이 덜 명확해지고 더 교묘해진다. 그러면 물체가 동시에 여러 장소에 마술처럼 있는 것 처럼 느껴진다.

 

 

A Place To Put Down Roots

나무가 자라는 땅도 게임에 표현해야한다. 바닥 타일 기반을 만들것이다. 각 타일은 특정 영역으로 덮혀진다

  각 영역은 게임플레이에 영향을 주는 속성을 갖는다.

  • 속도를 정하는 이동 코스트
  • 배로 건너는 등을 나타내는 flag
  • 사용될 텍스쳐

프로그래머는 효율성에 대한 편집증이 있기 때문에, 각 타일에 모든 상태를 저장하는 방법은 없다.. 그 대신 영역 타입에 열거형을 사용한다. 그리고 2차원 배열을 사용한다.

    enum Terrain
    {
        TERRAIN_GRASS,
        TERRAIN_HILL,
        TERRAIN_RIVER,
    }

    class World
    {

        Terrain[,] tiles = new Terrain[WIDTH,HEIGHT];

        public int GetMovementCost(int x,int y)
        {
            switch (tiles[x,y])
            {
                case Terrain.TERRAIN_GRASS:
                    return 1;
                case Terrain.TERRAIN_HILL:
                    return 2;
                case Terrain.TERRAIN_RIVER:
                    return 3;

                default:
                    return 0;
            }
        }

        public bool IsWater(int x, int y)
        {
            switch (tiles[x, y])
            {
                case Terrain.TERRAIN_GRASS:
                    return false ;
                case Terrain.TERRAIN_HILL:
                    return false;
                case Terrain.TERRAIN_RIVER:
                    return true;

                default:
                    return false;
            }

        }
    }

이 코드는 효과가 있지만, 코스트와 수륙여부가 영역의 대한 데이터이기 때문에 단일 지형 유형의 데이터가 번져있다. 이것을 캡슐화 한다.

 

    public class Terrain
    {   
        public Terrain(int movementCost, bool isWater, Texture texture)
        {
            this.movementCost = movementCost;
            this.isWater = isWater;
            this.texture = texture;

        }
        int movementCost;
        public int MovementCost => movementCost;
        bool isWater;
        public bool IsWater => isWater;
        Texture texture;
        Texture ThisTexture => texture;
    }

각 지형 객체를 만든 뒤, 2차원 배열 Terrain[,] ground을 만들고 객체를 참조시킨다.

 

 

What About Performance?

#대충 컴퓨터가 바뀜에 따라서 메모리의 배치 방법에 따라 열거형 혹은 객체를 사용한 패턴의 성능이 달라질 수 있다.

 

Flyweight pattern을 사용하여 수많은 객체를 사용하지 않아도 객체 지향 원칙을 적용할 수 있다. 

 

See Also

  • 위 예제에서는 지형 타입이 이미 저장되어 찾기 쉽고, 재사용하기 싶지만, 많은 경우 이러한 지형을 미리 생성하기 원치 않을 것이다. 무엇이 필요한지 모르면, 요청 시 생성하는 것이 좋다  이점을 얻을 려면, 요구했을때 이미 만들었는지 확인하고 리턴하면 된다. 이에따라 존재하는 객체를 찾을 수 있는 인터페이스, 구성을 캡슐화해야한다 이와 같이 생성자를 숨기는 것을  Factory Method의 예이다.
  • 이미 생성된 flyweight를 반환하기 위해서는 pool을 추적해야 한다. object pool은 객체를 저장하는데 좋은 공간이 된다.
  • State pattern을 사용할 때, "state" 객체는 상태를 사용하는 특정 머신과 관련된 필드가 없는 경우가 많다. state의 identity와 메서드는 충분히 유용하다. 이 경우 패턴을 적용하고 문제없이 동시에 여러 상태 시스템에서 동일한 state 객체를 재사용할 수 있다.

 

Observer

더보기

Observer 패턴이 사용된 MVC 아키텍쳐을 사용한 어플리케이션 없이 컴퓨터에 돌을 던질 수 없다.

 

Achievement Unlocked

게임의 한 측면과 관련된 코드를 잘 모아둔 상태에서 다양한 업적을 구현할 때, 어떻게 여러 업적이 모인 업적 시스템을 여러 코드와 연결하지 않고 구현 할 수 있나?

 

Observer 패턴의 목적이다. 받는게 누구인지 신경 쓰지 않고 알리는 것이다.

 

# 코드 설명은 생략 Part1 참고

 

It's to fast?

  Observer패턴은 동기식이므로 주의해야한다. 너무 느린 구독자는 객체를 가릴 수도 있다. 이벤트에 동기적으로 응답하는 경우 가능한한 빨리 반환해야한다. 처리하는 작업이 느린 경우, 해당 작업을 다른 쓰레드나 큐에 넣도록 한다.

  옵저버와 명시적 잠금을 혼합한다면 주의해야한다. 관찰자가  대상이 가지고 있는 잠금장치를 잡으려하면 게임이 Deadlock에 빠진다. 고도로 쓰레드 된 엔진이라면 비동기 EventQueue를 사용하는게 나을 수 있다.

 

Destroying subjects and observers

  일반적으로 소멸자에 호출을 추가하면 된다. 두뇌로 기억해 두기만 된다

 

 

What's going on?

 Observer 패턴을 통해 연결을 느슨하게 만들었지만, 여기서 문제가 생기면 찾아내기가 비교적 어렵다.

  커뮤니케이션을 양면으로 자주 생각하는 경우, Observer 패턴보다는 명확한 것을 사용한다.

  Observer 패턴의 경우, 서로 관련없는 덩어리가 하나의 큰 덩어리로 합쳐지지 않고 서로 통신 할 때 유용하다. 하나의 큰 덩어리내에서는 덜 유용하다

  하나의 덩어리를 수행할 때에 다른 덩어리에 대해 많은 지식이 필요하지 않도록 최소한의 의사소통을 원한다.

 

Prototype

더보기

 오늘날 프로토타입에 대해서 이야기 할 때, 디자인패턴의 프로초타입에 대해서 이야기하지 않는다.

 

The Prototype Design Pattern

 악령, 악마, 사령 과 같은 몬스터는 base class, Moster를 파생시켜 만들게 된다.

그와 비슷하게 각 몬스터 스포너 역시 base class, Spawner를 파생시켜 만들게 된다.

public class Monster{}
public class Ghost : Monster{}
public class Demon : Monster{}

public class Spawner
{
    public Monster SpawnMoster() { return new Monster(); }
}
public class GhostSpawner : Spawner
{
    public Monster SpawnMoster()
    {
        Monster ghost = new Ghost();
        return ghost;
    }
}
public class DemonSpawner : Spawner
{
    public Monster SpawnMoster()
    {
        Monster demon = new Demon();
        return demon;
    }
}

이러한 스포너들은 비슷한 부분이 정말 많게 되는데 이러한 중복 구문을 줄이는 방법이 있다.

  

방법은 spawn 방법을 spawner가 아닌 Moster에 추가하는 방법이다.

 

public class Monster 
{
    public virtual Monster clone()
    {
        return new Monster();
    }
}
public class Ghost : Monster 
{
    int health;
    int speed;
    Ghost(int health, int speed)
    {
        this.health = health;
        this.speed = speed;
    }
    public override Monster clone()
    {
        return new Ghost(health,speed);
    }

}

public class Spawner
{
    Monster prototype;
    public Monster clone()
    {
        return prototype.clone();
    }
}

 간단한 고스트 스포너를 만들려면, Spawner의 protype 변수에 Ghost 객체를 넣으면 된다. 적절한 속성 선택으로 다양한 Ghost 객체를 만들 수 있다.

 

How well does it work?

  각 몬스터의 스포너 클래스를 만들 필요는 없지만, 인위적인 문제에서 많은 코드를 절약하는 것처럼 보이지 않을 뿐만 아니라 인위적인 문제라는 사실도 있습니다. 각 몬스터마다 별도의 클래스가 있가는 것을 당연하게 받아들여야 했습니다. 요즘 게임엔진은 이런식으로 돌아가지 않습니다.

 

큰 계층구조 클래스는 관리하기 어렵기 때문에, Component 및 Type Object 를 사용해서 자체 클래스가 아니라 여러 엔티티를 모델링한다.

 

 

Spawn fuctions

각 몬스터에 대해 별도의 생성자 클래스를 만드는 대신 다른 방법이 있다. 생성 메서드를 만드는 것이다.

특정 몬스터의 구성을 위해 전체 클래스를 만드는 것보다 상용구가 적다.

public class Monster
{

}
public class Ghost : Monster
{
    int health;
    int speed;

}
public Monster spawnGhost()
{
    return new Ghost();
}

public delegate Monster SpawnCallback();

public class Spawner
{
    private SpawnCallback spawn;
    public Spawner(SpawnCallback spwan)
    {
        this.spawn = spwan;
    }
    public Monster SpawnMonster()
    {
        return spawn();
    }
}

public Monster CreateGhostEX()
{
    Spawner spawner = new Spawner(spawnGhost);
    return spawner.SpawnMonster();
}

 

 

Templates

 

public class Monster
{

}
public class Ghost : Monster
{
    int health;
    int speed;

}
public Monster spawnGhost()
{
    return new Ghost();
}

public delegate Monster SpawnCallback();

public class Spawner<T> where T : Monster, new()
{
    private SpawnCallback spawn;
    public Monster SpawnMonster()
    {
        return new T();
    }
}

 

The Prototype Language Paradigm

  보통 "객체 지향" 프로그래밍을 "class"의 동의어라고 생각한다. OPP와 반대 되는 신앙처럼 보일수 있지만, OPP하면 사용해 데이터와 코드를  묶어 사용해 객체를 정의할 수 있다. OPP 의 특성은 상태와 행동을 긴밀하게 묶는 것이다.

  클래스는 이를 위한 하나의 방법이지만, 소수의 사람들은 그렇지 않다고 주장한다. 80년대 "self" 를 만들었던 사람들이다. OOP와 마찬가지로, class가 없을 수 있었다.

 

Self

 

   "Without class, how do we make new things? the way yo do this in Self is by cloning"

 

  Self 는 class 기반 언어보다 더 객체 지향적이다. OPP의 관점은 상태와 행동의 결합이지만, 클래스는 상태와 행동을 분리하는 라인을 가진다. 함수를 실행시키기 위해, 인스턴스를 조회하고, 그 다음에 메소드를 조회한다. 행동은 class에 포함되어 있다. 메서드에 도달히기 위한 단계가 있다, 그것은 필드와 메서드가  다르다는 것을 의미한다.

 

Self (객체?) 는 그러한 구별을 제거한다. 무엇을 조회하던, object를 보면 된다. instrance는 상태와 행동 다 가지고 있다. 한개의 객체에 대해, 메서드는 완전히 그것의 유니크이다. 부모 객체는 행동과 상태를 재사용하게 만들어 주게 되고, 이에 따라 우리는 클래스 유틸리티의 일부를 다루었다. 또다른 핵심은 class가  instances를 만드는 방식을 제공해준다. class는 자신의 인스턴스를 위한 팩토리이다. 

 

  class가 없으면, 어떻게 새로운 것을 창조할 것이냐? Self(객체)를 통해 복제하면 된다.Clone() 은 이미 System에 적용되어 있다.

 

What about JavaScript?

  자바 스크립트에서는 새로운 객체를 기존 객체를 통해 만들며, new 를 통해 빈 객체를 생성하면 프로토타입 객체에 위임한다. 동작을 정의하려면 프로토타입 객체에서 메서드를 추가한다. 메서드 역시 대리자를 통해 전달한다.

# 새로운 객체의 메서드를 대리자를 통해 전달한다는 것이 인상깊다.

 

Prototypes for Data Modeling

#데이터 지속성을 위한 파일 저장에 대한 사항

  일회성 객체를 저장하기 위해, 반복되는 부분을 prototype으로 지정한다.

 

Singleton

더보기

이 장은 예외적으로 사용하지 않는 방법을 보여준다. 의도에도 불구하고 싱글톤 패턴은 득보다 실이 더 많다.  Gang of Four에서 사용을 자제하라고 하지만, 그 메시지는 게임 산업에서 종종 상실된다..

  싱글톤 패턴은 총상에 부목을 하는 것처럼 유용하다. 남용해서는 않된다.

 

The Singleton Pattern

  "클래스의 인스턴스가 하나임을 보장한다. 그리고 글로벌 액세스를 제공한다."

여기서 "그리고"를 유의하고, 나눠서 고려한다.

 

Restricting a class to one instance

  클래스의 인스턴스가 둘 이상이면 제대로 작동하지 않는 경우가 있다. 일반적으로 클래스가 자체 전역 상태를 유지하는 외부 시스템와 상호작용하는 경우이다. 

 

  파일 시스템을 랩핑하는 클래스를 생각해본다. 클래스의 작업이 비동기적으로 수행한다. 이는 여러 작업이 동시에 수행될 수 있음으로, 서로 조정되여야 한다는 것을 의미한다. 서로 상충되는 작업이 서로 간섭하지 않게 해야한다. 이렇게 하려면, 랩퍼에 대한 호출이 모든 과거 작업에 접근할 수 있어야 한다. 각 객체끼리 무슨 작업을 하는지 알수 없으므로, 하나의 인스턴스를 보장하기 위해 싱글턴을 사용한다.

 

Providing a global point of access

  static을 사용한다.

public class FileSystem
{
    public static FileSystem fileSystem;
    public void instance()
    {
        if (FileSystem.fileSystem == null)
            FileSystem.fileSystem = new FileSystem();
    }
}

 

Gpt, Lazy Initialization

    public class FileSystem
    {
        private static readonly Lazy<FileSystem> LazyInstance = new Lazy<FileSystem>(() => new FileSystem());
        public static FileSystem Instance { get { return LazyInstance.Value; } }
        private FileSystem() { }
    }

 

 

Why We Use It

  • 사용하지 않으면 만들지 않는다.
  • 런타임에 초기화 된다. 싱글톤 없이 정적 멤버를 사용한다면, 자동 초기화 제한 사항이 있다. 프로그램이 시작된 뒤의 정보를 사용할 수 없다. 이는 안정적으로 의존할 수 없음을 의미한다.컴파일러는 정적변수가 서로에 대해 초기화 되는 순서를 보장하지 않는다. 지연 초기화는 이러한 문제를 해결한다. 싱글톤은 가능한 늦게 초기화되려 한다. 그 때까지 필요한 정보가  사용가능해야 한다. 순환 종속성이 없다면, 싱글톤을 초기화할 때, 다른 싱글톤을 참조할 수 있다.
  • Subclass를 싱클톤 할 수 있다. 이는 강력하지만 종종 간과된다. 크로스 플랫폼을 위한 파일시스템 wrapper에 대해서, FileSystem 인터페이스를 만들고, 파생시켜 각 플랫폼의 파일시스템 클래스를 만들었다고 하자. 컴파일러 스위피를 사용하려 파일 시스템 래퍼를 생성한다.

 

Why We Regret Using It

  단기적으로 싱글톤 패턴은 무해하다. 하지만 장기적으로 대가를 지불해야한다. 불필요한 싱글톤을 cold hard code로 캐스팅하면 다음과 같은 문제를 겪는다.

 

It's a global variable

  게임이 점점 커지고 복잡해지면서, 아키텍터와 유지관리성이 병목현상이 발생하고, 생산성의 한계 때문에 게임 개발이 어려워지기 시작한다. 여러 개발자의 지혜에 따른다. 전역변수는 여러므로 해롭다.

 

  • 코드를 이해하기 더 어려워 진다. 글로벌 변수를 사용하지 않는 함수의 경우, 함수의 내용과 인수만 알면 되기 때문 손쉽게 이해할 수 있다. 하지만, (데이터와 관련된) 전역 함수를 사용한다면, 데이터에 무엇이 영향을 끼치는지 전체 코드를 확인해야한다. 전체 코드가 많으면 많을 수록, 사소한 설정문제도 찾아내는 것이 어려워 진다.
  • Coupling를 장려한다.
  • 동시성에 친화적이지 않다. 오늘날 코더는 최대한 활용하지 못지라도 최소한 mutil-thread 방식으로 동작해야한다. 무언가 전역으로 만들 때, 메모리의 chuck를 만들게 된다. 이것을 다른 쓰레드가 무엇을 하던 상관없이 쓰레드가 보고 포크 할 수 있다.이것은 deadlock, race condition, 쓰레드 동기 버그가 일어난다.

전역변수를 통틀어, 싱글톤 패턴을 무서워 해야할 이유이다. 하지만 어떻게 전역 상태 없이 게임을 구성할 것인가?

 

방법은 많이 있다. 명확하지도 쉽게 찾을 수도 없다. 

 

It Solves two problems even when you just have one

  "그리고"에  유의했던 이유이다. 싱글톤 패턴은 어딘가 이상하다. 이 패턴은 몇개의 문제를 해결하는가? 하나? 둘?

보통 편리한 액세스 때문에, 싱글톤 패턴을 고려하게 된다. 글로벌 액세스는 편리하지만, 여러 인스터스를 생성할 수 있는 것도 마찬가지로 편하다.

 

  디버그를 위한 Log class를 생각해본다. Log 기능에 싱글톤을 적용하면, 쉽게 접근가능하지만, 하나의 로거만 만들 수 있다는 이상한 제약을 가지게 된다. 만약 Log가 다양한 상황을 지원하도록 수정하려면, Log를 사용한 code를 모두 찾아 수정해야한다.

 

Lazy initialization takes control away from you

  지연 초기화로 인해 특정 순간에 프레임 드랍이 발생할 수도 있다. 마찬가지로, 메모리 조각화를 방지하기 위해서는 메모리가 힙에 배치되는 방법을 면밀히 조절해야한다. 

 

이 문제 때문에 대부분의 게임은 지연초기화를 의존하지 않는다. 대신 다음과 같이 사용한다.(#아마 게임 시작 시 미리 할당해 놓는다는 의미일 듯)

class FileSystem
{
    static FileSystem instance;
    static FileSystem Instance()
    {
        return instance;
    }
}

 

정적 멤버를 사용하면 다형성을 사용할 수 없고, 클래스는 정적 초기화 시 생성가능해야한다(?). 필요하지 않을 때 메모리를 해제할 수도 없다.

 

  싱글톤을 만드는 대신 여기 있는건, 간단한 정적 클래스이다.

 

 

 

What We Can Do Instead

  싱글톤은 더 이상 toolbox에 존재하지 않는다. 하지만 어떻게 싱글톤을 사용하지 않고 문제를 해결해야 할까?

 

See if you need the class at all

  게임에서 보이는 많은 싱글톤은 "manager","System","Engine"이다. 모호한 클래스이고 그저 다른 객체를 돌보기 위해 존재한다. 관리자 클래스가 유용하지만 종종 객체지향에 대한 익숙하지 않음을 반영하는 경우가 많다.

 

To limit a class to a single instance

  하나의 클래스 인스턴스를 보장한다. 싱글톤이 제공하는 절반의 핵심이다. 하지만 공개적이고 전역적인 액세스는 필요하지 않을 수 있다. 액세스를 특정 영역에 제한하거나 단일 클래스에만 비공개 설정할 수도 있다. 

 

  전역 액세스 없이 하나의 인스턴스를 보장하는 방법 중 하나.

class FileSystem
{
    static bool instantiated = false;
    FileSystem()
    {
        Assert.IsFalse(instantiated);
        instantiated = true;
    }
}

# 둘 이상의 인스턴스 생성을 버그로 간주하고 Exception을 해버린다, 유니티에서 씬간 데이터 지속성을 위해 (적어도 내가 아는 선에서는) 사용할텐데.

 

  단점은 단일 인스턴스를 런타임 단계에서야 보장한다는 것이다. 싱글톤은 컴파일단계에서 이를 보장한다.

 

To provide convenient access to a instance

  편리한 엑세스는 싱글톤을 사용하는 주요한 이유이다. 여러 영역에서 필요한 오브젝트를 가져다준다. 용이함에는 대가가 따른다. 원하지 않는 오브젝트를 가져오기도 쉬워진다.

 

  싱글톤을 샷건마냥 문제의 해결 수단으로 사용하지 말고, 액세스할 다른 방법을 고려해본다.

 

  • 전달
  • 부모 클래스로부터 가져오기, GameObject class에서 파생된 클래스, 부모 클래스에 static 멤버 및 메서드 추가하여 파생 클래스가 메서드를 가지게 한다. 
    class GameObject
    {
        static Logger logger;
        protected Logger GetLogger()
        {
            return logger;
        }
    }
    class Enemy : GameObject
    {
        void DoSomething()
        {
            GetLogger().Log("");
        }
    }

 파생된 객체가 제공된 메서드를 구현하도록 하는 것은 Subclass Sandbox 챕터를 확인한다.

 

  • 이미 글로벌인 것으로부터 가져온다. 전역변수가 없는 것이 이상적이지만, 대부분은 코드베이스는 전역적으로 사용 가능한 객체가 존재한다(Game, World). 이러한  편승하여 전역클래스를 줄인다.

%누군가는 이것이 Law of Demeter를 위반한다고 말하지만, 저자는 다수의 싱클톤보다 낫다고 생각한다.

 

Get it from Service Locator

 Service Locator 패턴을 사용하여, 개체에 대해 전역 액세스를 갖도록하는 단 하나의 class를 만든다.

 

 

What's Left for Singleton

 남은 질문은 "어디에 싱글톤을 사용해야만 한다는 것인가?"

State

더보기

  이 챕터에 과하게 담았다. 

 

We've All Been There -> Finite State Machine to the Rescue -> Enums and Swithches -> State Pattern

# FSM 및 Part 1 과 겹치는 부분 제외

 

Delegate to the state  #자료의 표현을 살린 것

public class HeroineState
{
    public virtual void HandleInput(Heroine heroine) { }
    public virtual void Update(Heroine heroine) { }
}

public class Heroine
{
    HeroineState state;

    public virtual void HandleInput()
    {
        state.HandleInput(this);
    }
    public virtual void update()
    {
        state.Update(this);
    }
}

 %이것은 마치 Strategy 혹은 Type Object  패턴처럼 보인다. 주 오브젝트가  하위개체에게 위임한다. 차이점은 의도가 다르다.

  • Strategy - 주 오브젝트에서 행동 일부를 분리하는 것
  • Type Object - 같은 타입 오브젝트에 대해 비슷하게 동작하는 오브젝트 생성
  • State - 주 개체가 위임한 개체를 변경하여 동작을 변경하는 것

 

Where Are the State Objects?

  state를 변경하기 위해서는, state를 할당해야 한다.

 

Static state

   state가 바뀔 때, state에서는 state를 호출해야한다. 이때 static instance를 만들어서 호출을 간단히 할 수있다.

Insantiate state

   static state가 멤버 변수를 갖는 경우, static state 인스턴스 이므로, 단일 플레이어에 대해서는 잘 작동할지는 몰라도, 둘 이상의 플레이어(혹은 actor)에 대해서 동작하지 않을 수 있다. 이럴 경우 새로운 state 인스턴스를 생성하는 방향으로 가야하고, 따라서 현재 state 인스턴스는 반환해야한다.

public class HeroineState
{
    public virtual HeroineState HandleInput(Heroine heroine) { return null; }
    public virtual void Update(Heroine heroine) { }
}
public class StandingState : HeroineState
{
    //~~~
}
public class DuckingState : HeroineState
{
    public override HeroineState HandleInput(Heroine heroine)
    {
        if (Input.GetKeyDown(KeyCode.DownArrow))
        { 
            return new StandingState();
        }
        return null;
    }

}
public class Heroine
{
    HeroineState state;

    public virtual void HandleInput()
    {
        HeroineState state = this.state.HandleInput(this);
        if (state != null)
        {
            Object.Destroy((Object)this.state);
            this.state = state;
        }
    }
    public virtual void update()
    {
        state.Update(this);
    }
}

 

 

Hierarchical State Machines

  비슷한 상태 머신은 상속을 통하여 구현한다. 상태는 상위 상태를 가진다. 이벤트가 발생하하고 하위 상태가 다룰 수 없을 때, 상위 상태로 롤업 된다. (#이때 부모가 상위 상태)

 

public class OnGroundState : HeroineState
{
    public override void HandleInput(Heroine heroine)
    {
        if (Input.GetKey(KeyCode.B))
        {
            //Jump
        }
        else if (Input.GetKeyDown(KeyCode.DownArrow))
        {
            //Duck
        }

    }
}

public class DuckingState : OnGroundState 
{
    public override void HandleInput(Heroine heroine)
    {
        if (Input.GetKeyUp(KeyCode.DownArrow))
        {
            //Stand Up
        }
        else {
            base.HandleInput(heroine);
        }

    }
}

 

  계층을 구현하는 유일한 방법은 아니다.  단일 상태 대신, 상태 스택을 명시하여, 동작처리 시 스택의 맨 위에서 시작하여 하나의 동작을 처리할 때까지 내려간다. (매칭되지 않으면 무시)

 

 

Pushdown Automata

  FSM 및 상태 스택을 사용하는 확장방법이다. FSM의 문제는 역사의 관점이 없다는 것이다(# 사실 이마저도 상태로 해결할 수 있다고 생각하지만) . 어디에 있는지 알수 있지만, 어디에 있었는지 알 수가 없다.

 

  만약 액터가 총을 쐈다고 한다면, 총을 쏜 이후의 상태는 어떻게 할것인가? 총쏘기전에 했던 상태로 돌아와야한다.

FSM 에서 전의 상태를 잊어버리기 때문에, 달리며 발사, 걸으며 발사, 서서 발사와 같은 상태를 만들어야한다.(# 만들기 싫으시다?)

 

  원하는 건, 이전 상태를 저장하는 방법이다. 여기서 Automata가 도움이 된다.

 

FSM은 단일 상태 포인터를 가진다면, Pushdown Automata는 상태의 스택을 가진다. FSM에서 새로운 상태로 천이한다만. Pushdown Automata는 두 가지가 기능이 추가된다.

 

  • 새로운 상태를 스택에 push 할 수 있다. 새로운 상태가 스택의 최상위에 추가되어서 현재상태는 언제나 스택의 최상위이다. 이전 상태는 스택아래에 내버려둔다.
  • 최상위 스택을 pop 할 수 있다. 상태가 나가면 그 아래있던 스택이 현재 스택이 된다.

이것을 통해, 이전 상태로 돌아가야하는 상태의 경우를 구현할 수 있다.

 

 

So How Useful Are They?

  상태 머신의 일반적인 확장에도 불구하고, 제한적이다. 요즘 game AI의 추세는 behavior tree, planning systems 이다. 복잡한 AI에 관심이 있다면, 다른책을 원하게 될 것이다.

 

이것은 상태 머신이나 Pushdown Automata, 다른 시스템이 유용하지 않는다는 것이 아니다. 상태 머신은 다음과 같은 상황에서 유용하다.

  • state에 따라, 변경되는 몇가지 행동 엔터티가 있는 경우
  • 상대적으로 적은 별개의 옵션을 엄격히 나눌 수 있따.
  • 엔터티가 시간에 따라 일련의 입력이나 이벤트에 응답해야하는 경우

게임에서 AI, 사용자 입력처리, 메뉴화면 탐색,텍스트 구문 분석, 네트워크 프로토콜, 비동기 구현에도 사용된다.

 

Sequencing Patterns

  • Double Buffer | Game Loop | Update Method

Double Buffer

더보기

Intent

  일련의 작업이 즉각적 혹은 동시에 일어나게 만든다.

 

Motivation

  컴퓨터는 순차적 짐승이다. 컴퓨터의 힘은 아무리 큰 작업이라도, 작은 단위로 나누어서 수행한다는 것에 있다. 사용자는 종종  단일 순간 단계 혹은 동시에 수행되는 여러 작업을 확인해야한다.

 

  일반적으로 게임 엔진은 랜더링을 수행해야한다. 렌더링 작업은 수많은 요소를 가지고 있고 이를 각 프레임마다 동시적으로 업데이트해야한다. Double buffering 은 이를 해결한다. 이해하기 위해서, 어떻게 화면을  디스플레이하는지 알아야 한다.

 

How computer graphics work(briefly) -> Act , Scene 1

  #중략

  매 프레임마다 빈 화면에서 요소 하나하나(나무 따로 땅 따로) 처리되는 것을 즉각적으로 표시하게하여 나타나는 걸 상상해본다. 문제가 있다. 매 프레임에 동시적으로 모든 요소를 표시할 필요가 있다.

 

Back to the graphics

  이것이 Double buffer가 하는 일이다. 하나의 버퍼가 아닌 두개의 버퍼를 가진다. 하나는 완성된 프레임을 읽고, 하나는 프레임 화면을 구성하고 있는 중이다. 두번째 버퍼가 완성되면 첫번째 버퍼와 역할을 바꾼다.

 

The Pattern

  buffered class는 buffer에 캡슐화 되어있다. 버퍼는 점진적으로 변경되지만,외부 코드를 위한, 원자화된 변경을 위해 두개의 버퍼를 가진다. next buffer, current buffer이다.

버퍼에 정보가 읽히면, 거건 current buffer다 버퍼에서 정보가 쓰이면, 그건 next buffer이다. 작성이 끝나면 두 버퍼를 swap 한다.

 

When To Use It

다음 사항을 모두 충족할 때에

  • 점진적으로 편집되는 상태가 존재
  • 수정 중에 접근 할 수 있는 상태
  • 작업 중인 상태에서는 접근을 막고 싶을 때
  • 상태를 읽을 수 있게하고, 작성 중에는 기다릴 필요가 없게하고 싶을 때

 

Keep in Mind

  buffer는 낮은 구현 수준을 갖기 때문에, 다른 코드 영역에 영향이 적다. 다른 코드 영역은 별반 차이를 알지도 못한다. 몇가지 주의사항은 존재한다.

 

The swap itself takes time

  Double-buffer 은 swap 단계가 필요하다.대게, 이것은 포인터 할당만큼 빠르지만, 처음부터 수정하는게 더 빠른 경우 도움이 되지 않는다.

 

We have to have two buffers

  두개의 버퍼를 위해서 메모리를 두배로 사용하게 된다. 메모리가 제한된 장치에서 두개의 버퍼를 감당할 수 없다면, 작성중에 접근하지 못하도록 하는 다른 방법을 찾아야 한다.

 

Sample Code

  생략 두개의 버퍼, 두개의 커서, swap 메서드

 

Not just for graphics

   쓰레드나 인터럽트에서 접근하는 상태인 경우

 

Atificial unintelligence -> Buffered slaps

  업데이트 될 상태와 현재 상태 시퀸스를 동기적으로 가져야하는 다수의 객체 관해서, 각 객체는 또 다른 객체의 현재 상태를 참조하여 업데이트 될 상태가 정해지기 때문에, 각 객체는 곧바로 상태를 업데이트 하는 것이 아니라 다른 객체들이 업데이트할 상태를 정할 때까지 업데이트를 유보해야한다. 이를 Double-buffer로 구현한다.

 

 

Design Decisions

   Double Buffer 는 매우 간단하며, 이 패턴을 구현할 때 두가지 주요 결정이 있다.

 

How are the buffers swapped?

  Swep은 작업동안 두 버퍼의 읽기 및 쓰기를 반드시 차단해야 하기 때문에 중요한 단계이다. 최고 성능을 위해서는 가능한 빨리 이루어져야 한다.

 

  • 버퍼 포인터를 swap한다.
    • 그래픽 디스플레이 예시에서 다룬것으로, 일반적인 double-buffering graphics 이다.
    • 버퍼가 얼마나 크던, 빠르다. 단순히 포인터 할당만 바꾸기 때문이다. 속도, 단순함에 있어서 이걸 이기기 힘들다.
    • 코드 외부에서는 영구적인 버퍼를 저장하지 못한다. 이것이 주요 제한사항이다. 데이터를 직접적으로 움직이지 않기 때문에, 나머지 코드에서 버퍼를 주기적으로 찾도록해야한다. 비디오 드라이버가 framebuffer가 고정된 메모리에 있기를 기대하는 시스템에서는 사용할 수 없다.
    • 현재 버퍼의 데이터는 두 프레임 전에 해당한다(#6swap 직후 시점인듯). 최신 프레임이 아니다. 세번째 프레임이 그려지는 중에 첫번째 프레임이 출력된다. 대부분의 경우 이는 문제는 되지 않는다. 버퍼에 데이터를 제거하기 때문이다. 하지만 버퍼에 존재하는 데이터 일부를 재사용 사용한다면, 그건 생각보다 이전의 데이터 임을 명시한다.
  • 버퍼 간 데이터를 복사한다.
    • 버퍼를 사용하는 사용자가 버퍼 포인터를 재지정 할 수 없다면, 버퍼 데이터를 복사하는 방법 밖에 없다.
    • 다음 버퍼는 한 프레임만 차이난다.
    • 교체하는 데에 더 시간이 든다. 최대의 단점이다. 버퍼가 크면 클수독 시간이 소모된다. 더군나나 복사하는 동안 두 버퍼 모두 읽기 및 쓰기를 할 수 없다.

 

What is the granularity of the buffer?

  버퍼는 몇개의 구성을 가지고 있냐는 것이다. 쓰임에 따라 다르다. 한 덩어리 일 수도 있고, 객체 컬렉션으로 구성되어 있을 수 도 있다. 대부분의 경우 항목에 특성에 따라 답이나오지만, 어느정도의 유연성이 있다. Actor의 경우처럼(객체 참조를 구성하는 경우처럼) 인덱스 참조를 통해 데이터를 저장할 수 있다.

  • 버퍼가 모놀리식 인 경우
    • swap이 간단하다. 버퍼는 하나의 쌍만 가지고 있으므로, 단일 스왑이 수행된다. 포인터를 swap 할 수 있으면, 버퍼 크기는 상관없다.
  • 많은 데이터 조각이 있는 경우
    • swap이 느려진다. 전체 컬렉션을 반복하고, 각 객체에 교환하라고 지시한다. 물론, 기존 버퍼를 건드릴 필요가 없다면, 인덱스를 활용해 모놀리식 버퍼와 동일한 성능을 얻기 위해 최적화 방법이 있다

 

See Also

  거의 모든 그래픽 API에는 Double Buffer 가 사용된다. OpenGL 의 swapBuffers(), Direct3D의 swapchains, XNA framework의 endDraw()

Game Loop

더보기

# Game Loop는 엔진에 이미 구현되어 있기 때문에 간단히 짚고 넘어감

Intent

  사용자의 입력과 프로세서 속도에서 게임 진행 시간을 분리한다.

 

Motivation

  거의 모든 게임은 하나는 갖고 똑같은 둘 이상은 갖지 않는다

 

Interview with a CPU -> Event loops -> A world out of time -> Seconds per second

  초창기 게임, 배시를 통한 대화형 게임, 사용자의 입력 이벤트를 기다리는 게임

  • 사용자가 대기하면 아무것도 일어나지 않음

-> 사용자 입력을 처리하지만 기다리지는 않는 루프, 물리와 같은 요소, render()

  • 프레임당 작업량, 플랫폼의 속도에 따른 시간, 초기에는 어떤 CPU에서 실행되는지 알기 때문에 이를 위해 특별히 코딩, 컴퓨터 성능에 따라 게임 속도가 차이남.

-> 하드웨어 차이에도 일관된 게임속도로 실행

 

 

The Pattren

  게임 루프는 게임플레이 동안 계속해서 돌아간다. 각 루프에서  blocking없이 사용자 입력을 처리하고, 게임을 update하고, 게임을 렌더한다. 게임 속도를 제어하기 위해 시간의 흐름을 추적한다.

 

When to use it

  이것은 사용하게 되어 있다.

 

Keep in Mind

  프로그램은 90% 의 시간을 10%의 코드을 위해 소비한다.

 

You may need to coordinate with the platform's event loop

  그래픽 UI 와 내장된 이벤트 루프가 있는 OS 또는 플랫폼 위에 게임을 구축하는 경우, 두 개의 애플리케이션 루프가 실행된다.

  때로는, 제어권을 가지고 루프를 유일루프로 만들 수도 있다. 예을 들어, Window API의 경우 main()만으로도 된다..

  플랫폼에 따라 이벤트 루프를 손쉽게 로드 아웃 할 수 없다.

 

Sample Code

몇가지 변형과 장점과 단점

 

Run, run as fast as you can

가장 단순한 게임 루프

while (true)
{
  processInput();
  update();
  render();
}

문제는 게임 실행 속도를 제어할 수 없다는 것이다. 실행량, 성능에 따라 속도가 다르다

 

Take a little nap

게임의 frame per second, FPS를 조절하기 위해 코드를 추가한다.

// 가져온 코드, C++
while (true)
{
  double start = getCurrentTime();
  processInput();
  update();
  render();

  sleep(start + MS_PER_FRAME - getCurrentTime());
}

 sleep()를 통해 프레임을 작업하는 속도를 너무 빠르지 않게 보장한다. 만약 게임이 너무 느리다면 도움이 되지 않는다. 

 

One small step, one giant step

  더 정교한 것을 시도해본다. 문제는 다음과 같다

 

  • 각 업데이트에 의해 시간이 일정량 흐른다.
  • 처리하는데 실제 시간이 일정량  걸린다.

  두번째 사항이 첫번째보다 오래걸리면, 게임이 느려진다. 게임시간으로 16ms이 흐르는데, 이것을 처리하는데 16ms보다 오래걸리면,  게임시간이 실제 시간을 따라잡을 수가 없다. 따라서 게임시간(각 Update 사이의 게임 시간)을  16ms 이상으로 늘리면 된다. update 주기는 길어지더라도 따라잡을 수 있다.

  update의 게임시간을 실제 걸린 시간으로 선택한다. variable or fluid timestep

//가져온 코드 c++
double lastTime = getCurrentTime();
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - lastTime;
  processInput();
  update(elapsed);
  render();
  lastTime = current;
}

  update(elapsed)에 의해서, 실제 시간이 얼마나 걸린지 확인한다. 엔진은 그만큼의 게임 시간이 가도록한다.

 

  • 게임은 다양한 하드웨어에서 일관된 속도로 플레이된다.
  • 좋은 성능의 머신을 가진 플레이어는 원할한 게임플레이로 보상 받는다.

  여기에는 큰 문제가 있다. 게임을 비 결정적이고 불안하게 만들었다. 발사된 총알에 대해서 생각해본다. 화면을 가로지르는데, 어떤 컴퓨터에서는 50frame, 어떤 컴퓨터는 5frame이 걸린다고 한다. 컴퓨터는 부동 수소점의 반올림에의해서 매 프레임마다 오류가 누적된다.

  추가적으로 게임 물리 엔진은 실시간으로 run하기 위해, 실제 역학을 "근접한다". 이러한 근사가 터지지 않게, 덤핑이 적용된다. 이러한 덤핑은 특정 시간  단계에 신중히 조정된다. 따라서 프레임 시간이 변경되면, 물리가 불안정 해진다.

%"Deterministic" 결정적이란 프로그램을 돌리는 언제라도, 같은 입력에 대해서 같은 결과를 내는 것이다. 

%"Blowing up" 터지다. 말 그대로 물리 엔진 오류로 물체가 이상한 속도록 하늘에 날라갈 수 있다.

 

Play catch up

  일반적으로 가변 시간에 영향을 받지 않는건 렌더링이다. 렌더링은 순간을 포착하기 때문에, 얼마나 시간이 지났는지 상관하지 않는다. 물체가 어디 있던지 사물을 렌더링한다.

   물리 및 AI 같은 엔진을 안정적으로 가동하기 위해서 고정된 시간을 사용하고, 프로세스 시간을 확보하기 위해 Render()에 유연성을 허용한다.

 

(어느정도의 성능이 뒷받침)

// 가져온 코드 c++
double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - previous;
  previous = current;
  lag += elapsed;

  processInput();

  while (lag >= MS_PER_UPDATE)
  {
    update();
    lag -= MS_PER_UPDATE;
  }

  render();
}

 

lag 는 실제 시간과 게임 시간과의 차이, 지연되면, update()로 게임 시간이 쫓아가게 만든다.

MS_PER_UPDATE를 너무 작게 잡으면 안된다.

 

 Stuck in the middle

   게임 시간(# 어쨋든 실제시간)을 기준으로, update는 정해진 시간에 주기적이지만  

  render 시간은 임의적이다. render는 update보다 빈도가 낮고 꾸준하지도 않는다. 이로 인해,  render 된 오브젝트의 위치가 조금씩 엇나간다.

 따라서 업데이트 타이밍과의 괴리를 전달한다.

render(lag / MS_PER_UPDATE);

 

이것을 통해, 오브젝트의 위치를 추정하여 나타낸다. 이 추정은 틀릴수도 있지만. 추정을 않했을 때보다 눈에 덜 띈다.

 

Design Decision

  이 챕터의 길이에도 불구하고 더 많은 것을 생략했다. 디스플리이 새로 고침 빈도, 멀티쓰레딩, GPU을 동기화하는 것에 대해서 생각하면 Game loop는 더 복잡해진다. 

 

Do you own the game loop, or does the platform?

  플랫폼에 따라 game loop 가 불가능하거나 제공된 것을 사용해야한다.

 

  • 플랫폼의 Event loop 사용
    • 간단하다
    • 플랫폼과 잘 작동된다.
    • 타이밍에 대한 제어권이 없다.
  • 게임 엔진의 loop 사용
    • 작성할 필요없다.
    • 엔진에 맞지않는 요구사항이 있는 경우, 제어권을 상실한다.
    • 직접작성
      • 모든 제어권을 가진다.
      • 플랫폼과 인터페이스해야한다.

How do you manage power consumption?

  휴대용 장치에서 작동하는 경우, 배터리소모에 대해서도 생각해야한다. CPU 를 적게 사용하는 것도 고려해야한다.

  • 프레임 속도를 고정한다.

How do you control gameplay speed?

#위에서 다룬 코드들

  • 동기화되지 않은 고정 시간
    • 간단하다
    • 게임 속도가 하드웨어 및 코드에 영향받는다.
  • 동기화를 통한 고정시간
    • 간단하다
    • 전력친화적이다
    • 게임이 너무 빠르게 진행되지 않는다.
    • 너무 느리게 진행될 수 있다.
  • 가변 시간
    • 너무 느리거나 빠르다
    • 비결정적이고 불안정하다, 물리 엔진과 네트워킹은 훨씬 더 어려워진다.
  • 업데이트 시간, 가변 렌더링
    •  너무 느리거나 빠르다
    • 더 복잡하다. 시간조정을 하이엔드에서는 작게 하여 로우엔드가 너무 느리지 않도록한다.

See Also

  • Fix Your Timestep 게임 루프에 대한 고전적인 기사 등

Update Method

더보기

Intent

독립 객체의 콜렉션을 시뮬레이션한다. 각 객체는 하나의 행동을 한 프레임에 처리한다.

 

Motivation

(게임 루프와 함께) 유닛이 프레임 단위로 한 단계씩 움직여야 한다. 이와 따라, 간단한 좌우 패트롤 기능도 프레임 단위로 구현해야 하므로, (좌우 움직임을 정하기 위한)  여러 변수가 추가되야한다.

 점점 더 많은 변수와 코드가 늘어감에 따라, 각 엔티티의 자체 동작을 캡슐화하여 게임 루프를 깔끔하게 유지하고 엔터티를 쉽게 추가 및 제거 할 수 있게한다. 이를 위해 추상 class를 만들고 update() 추상메서드를 추가한다.

  객체 컬렉션을 통해, 각 객체의 update() 메서드를 호출한다.

 

The Pattern

객채 컬렉션을 유지한다. 각 객체는 Update메서드를 구현하고 Update 메서드는 한 프레임을 시뮬레이션한다. 각 프레임마다 객체 컬렉션의 객체을 Update한다.

 

When to Use It

게임 루프와 함께 사용한다.

  • 동시에 실행해야할 객체가 많이 존재하는 경우
  • 각 객체의 행동이 대부분 다른 객체의 행동과 독립한 경우
  • 객체가 시간에 따라 시뮬레이션해야하는 경우

Keep in Mind

패턴은 간단하고 놀라운 구석은 많지 않지만, 모든 코드는 파급력이 있다.

 

Splitting code into single frame slices makes it more complex

단일 프레임에 동작하도록 코드를 분할하는 것은 더 복잡하다.

 

You have to store state to resume where you left off each frame

행동이 프레임 단위로 실행되기 때문에, 일련의 상태에 따라 행동이 바뀌는 경우, 정보를 손실하지 않기 위해 상태를 추적하는 변수를 사용하거나 State 패턴을 사용한다.

% 필요한 것은  다중 쓰레드를 수행 할 수 있는 시스템이다 . 코드가 일시중지되거나 재개할 수 있으면, 완전히 return 할 필요없이 명시적으로 작성할 수 있다.

% 실제 쓰레드는 무거워서 제대로 동작하지 않지만, 가벼운 동시성 구성을 지원하는 경우, generator, coroutine, fiber 같은 것들을 사용 할 수 있다. Bytecode는 어플리케이션 레벨에서 쓰레드 수행을 만들 수 있는 또다른 옵션이다.

 

Object all simulate each frame but are not truly concurrent

객체가 프레임마다 업데이트 되는데에 순서가 존재한다. 이에 따라, 서로의 상태에 대해 바뀌는 객체들에 대해 업데이트 순서에 따라 참조하는 상태의 위상이 어긋날 수 있다.

이러한 순차성은 모호한 상황(ex 정말 동시에 일어나는 동작에서 동등한 우선권을 가지는 상황)을 조정할 필요없게 만들어 준다.

%만약 이러한 순차성을 원하지 않는 요소의 경우(ex 같은 위상을 참고하여 업데이트하고 싶은 경우), Double Buffer 패턴을 사용한다.

 

Be careful modifying the object list while updating

개체 목록을 신중하게 업데이트 해야한다. 모든 동작이 update에 포함되면서 객체를 생성하거나 지우는 동작도 여기에 포함된다. 객체가 생성되는 경우, 생성된 프레임에 생성된 객체가 동작할 수 있다면 플레이어가 인지도 못한 채 당하는 결과를 초래 할 수도 있다.

 

이를 방지하는 간단한 코드가 있다.

int numObjectsThisTurn = numObjects_;
for (int i = 0; i < numObjectsThisTurn; i++)
{
  objects_[i].update();
}

현 프레임에 존재하는 객체 수를 추적하여, 새로 추가된 객체의 동작을 방지한다.

 

객체가 제거되는 경우 더 큰 문제 있다. 의도치 않게, 객체를 건너 뛸 수 있다. 이를 방지하기 위해, 죽은 개체는 바로 제거하지 않고 한 반복이 끝난 뒤에 제거시킨다.

%저렴한 해결책은 목록을 거꾸로 수행하는 것이다

%업데이트 루프의 항목을 처리하는 쓰레드가 여러개 있는 경우, 비용이 많이 드는 쓰레드 동기화를 피하기 위해 수정을 연기할 가능성이 크다.

Sample Code

# 아마도 한 entity 내에서 entity 모든 동작을 구현하는 경우에 대해 설명

Subclassing entities?!

Component 패턴을 사용해 Update 메서드를 entity 자체가 아닌 구성 요소의 entity에 있게 한다.

 

Defining entities

유닛의 존재 및 일정 동작 별로 entity를 정의하고 각 entity에 필요한 변수는 각 entity가 가지고 관리하게 함으로써 새로운 entity 추가를 용이하게 만든다. 이렇게 게임을 유닛으로 채우는 것과 구현하는 것을 분리하게 하여 결과적으로 별도의 데이터나 레벨 편집기를 사용하여 세계를 채울 수 있게하는 유연성을 제공한다.

 

Passing time

게임 시간(실제 시간)을 추적하고 사용하여라

 

Design Decisions

What class does the update method live on?

  • the entity class
    • 비추천
  • the component class
    • Update method 패턴을 사용해 게임 엔터티를 서로 분리하듯, 단일 엔터티의 일부를 서로 분리 시킨다. 렌더링, 물리, AI는 모두 스스로 관리될 수 있다.
  • A delegate class
    • State 패턴이나 , Type Object 패턴과 같이 동작 일부를 다른 객체에 위임하는 것과 관련된 패턴이다. 이러한 패턴에서도 Update 메서드를 적용한다.

 

How are dormant objects handled?

일시적으로 업데이트할 필요가 없는 개체가 많이 있는 경우가 많다. CPU 주기가 낭비 될 수도 있다. 한 가지 방법은 라이브 객체 별도의 컬렉션을 유지하는 것이다.

  • 비활성 개체가 포함된 단일 컬렉션 사용
    • 시간을 낭비하고 있다. 사용 가능 여부를 확인하거나 빈 메서드를 호출해야한다.
  • 활성 개체만 모아놓은 별도 컬렉션
    • 두번째 컬렉션을 유지하기 위해 추가 메모리 사용. 모든 엔터티에 대한 컬렉션이 이미 존재하므로 기술적으로 중복이긴하다. 속도가 메모리보다 중요한 경우, 가치있는 방법이다. 활성 엔터티, 비활성 엔터티 각각 다른 컬렉션 두개로 보유하는 방법도 있다.
    • 두 컬렉션을 동기화 상태로 유지해야한다.

 

See Also

  • Game Loop나 Component 패턴과 같이 게임 엔진의 핵심이다.
  • 각 프레임마다 업데이트하는 캐싱 성능에 관심이 있다면, Data Locality를 참고한다

Behavioral Patterns

  • Bytecode | Subclass Sandbox |Type Object

Bytecode

더보기

Intent

데이터를 가상 머신에 대한 지침으로 인코딩하여 동작에 데이터 유연성을 제공한다.

 

Motivation, Spell fight!

게임 요소를 조정하기 위해서는 코드 더미를 수정하고 다시 컴파일을 해야한다. 게임 컨텐츠 업데이트가 실행 파일을 패치를 동반한다. 디자이너의수치 조정도 엔지니어가 동반되어야한다. 여기서 더 나아가 게임에 모딩도 지원하고 싶은 경우, 이를 위해 모더에게 소스가 공개되어야 한다.

 

Data > Code

모드가 가능한 부분(샌드박스로 만들 부분)에 대하여, 수정되기 쉽고 로드되기 쉽고 실행 파일의 나머지 부분과 물리적으로 분리되기를 원한다. 즉 데이터 같았으면 좋겠다는 것이다. 데이터를 통해 게임 엔진이 무엇을 로드하고 실행할지 정의할 수 있게 하면 된다. 그리고 이를 Interpreter 패턴과 비교해본다.

 

#프로그래밍 언어계에서는 "virtual machine" 와 "interpreter" 는 동의어이다. 여기서는 상호교환적?으로 사용한다.

 

The Interpreter pattern

(중략) 이진 트리를 구성하여 간단한 수칙 연산(중위 표기법)을 표현하고 처리한다. 예에서, 상위 노드가 하위 노드를 호출하는 방식이기 때문에 메모리 문제, 명령 캐시에 대한 문제가 있다.

 

Machine code, virtually

원하는 건 사실상 기계어 코드와 다름이 없다. 기계어 코드는 데이터 밀도가 높고 선형적이며(순서적으로 실행되며) 낮은 수준으로 구현되며 빠르다. 하지만 실제 기계어 코드를 사용하고자 하는건 아니다. 원하는건 기계어 코드의 성능과 인터프리터 패턴의 안정성 사이의 절충안이다. 가상 머신 코드를 정의하고 작은 에뮬레이터를 작성한다. 이 에뮬레이터를 가상 머신(VM)이라고 부르며, 이에 머신 코드를 bytecode라고 한다.

 

The Pattern

명령어 세트(instruction set)는 하위 수준 작업을 정의한다. 일련의 명령어는 일련의 바이트로 인코딩된다. VM은 중간값용 스택을 사용하여 이러한 명령을 한번에 하나씩 실행한다. 명령어를 결합하여 복잡한 상위 수준 동작을 정의한다.

 

When to Use It

가볍게 적용할 만한 것이 아니다. 다음을 고려한다

  • 너무 low-level(언어적으로?) 적인  부분이라, 구현하기 번거롭거나 에러가 발생하기 쉬운 경우
  • 컴파일 시간이 느려서 반복작업이 느려지는 경우
  • 너무 많은 신뢰를 가진 경우, 정의된 동작으로 게임을 중단시키지 않도록 하려면 해당 동작을 샌드박싱해야한다.

어디까지나 네이티브 코드보다는 느리기 때문에 엔진의 성능이 중요한 경우 적합하지 않는다.

 

Keep in Mind

시스템 내에서 자신의 언어나 시스템을 만드는 것은 매혹적인 일이다. 그래서 덩굴처럼 자라난다. 작은 언어나 스크립팅 시스템을 정의하는 작업은, 완전한 언어가 될 때까지 점점 더 많은 작은 기능을 추가하게된다. 이렇게, 다른 언어와는 달리 임시적이고 유기적으로 판자촌 같은 언어를 만들게 된다.

 

You'll need a front-end, You'll miss your debugger

규모가 큰 경우, 바이트 코드를 작성할 때에 작성 툴, 디버거 등의 기능에 대한 계획도 세운다.

 

 

 

Sample Code

#자료의 c++ 코드를 그대로 가져옴, 이해만 하고 있는다.

 

A magical API

 

마법적인 주문을 구현하기 위해서는 어떤 종류의 API(기능)가 필요한가?

void setHealth(int wizard, int amount);
void setWisdom(int wizard, int amount);
void setAgility(int wizard, int amount);
void playSound(int soundId);
void spawnParticles(int particleType);

 위와 같은 기본적인 기능이 있어야 한다고 하자.

 

A magical instruction set

 이를 제어할 수 있도록 바꾸어 본다.

 

일단 단순히 열거해 본다.

enum Instruction
{
  INST_SET_HEALTH      = 0x00,
  INST_SET_WISDOM      = 0x01,
  INST_SET_AGILITY     = 0x02,
  INST_PLAY_SOUND      = 0x03,
  INST_SPAWN_PARTICLES = 0x04
};

몇가지의 프리미티브만 있기에 열거체의 범위가 바이트에 쉽게 들어맞았다. 바이트 위치에 의해 어떤 프리미티브인지 확인할 수 있다. 이를 올바른 API 메서드로 전달한다.

 

switch (instruction)
{
  case INST_SET_HEALTH:
    setHealth(0, 100);
    break;

  case INST_SET_WISDOM:
    setWisdom(0, 100);
    break;

  case INST_SET_AGILITY:
    setAgility(0, 100);
    break;

  case INST_PLAY_SOUND:
    playSound(SOUND_BANG);
    break;

  case INST_SPAWN_PARTICLES:
    spawnParticles(PARTICLE_FLAME);
    break;
}

 

이렇게 코드와 데이터 간의 다리를 형성한다.

class VM
{
public:
  void interpret(char bytecode[], int size)
  {
    for (int i = 0; i < size; i++)
    {
      char instruction = bytecode[i];
      switch (instruction)
      {
        // Cases for each instruction...
      }
    }
  }
};

 위치에 의해 유형이 정해짐으로 그다지 유연하지는 않지만 가상 머신이다. 여기에 유연성을 추가하기 위해서는 매개변수를 가져올 수 있어야 한다. 

 

A stack machine

복잡한 중첩 표현식을 실행하기 위해 전체 표현식이 계산될 때까지, 하위 표현식이 진행되야 한다.(#후위 표현법을 사용한다) 하위 표현식의 결과가 올바른 주변 표현식으로 흘러가게 해야한다.

class VM
{
public:
  VM()
  : stackSize_(0)
  {}

  // Other stuff...

private:
  static const int MAX_STACK = 128;
  int stackSize_;
  int stack_[MAX_STACK];

  void push(int value)
  {
    // Check for stack overflow.
    assert(stackSize_ < MAX_STACK);
    stack_[stackSize_++] = value;
  }

  int pop()
  {
    // Make sure the stack isn't empty.
    assert(stackSize_ > 0);
    return stack_[--stackSize_];
  }

  // Other stuff...
};

 

스택을 사용해, 푸시 및 팝을 통해 하위 표현식에 팝 값이 전달된 뒤 연산(동작)이 진행되고 스택에 결과값을 push하도록 한다.

 

 

 

switch (instruction)
{
  case INST_SET_HEALTH:
  {
    int amount = pop();
    int wizard = pop();
    setHealth(wizard, amount);
    break;
  }

  case INST_SET_WISDOM:
  case INST_SET_AGILITY:
    // Same as above...

  case INST_PLAY_SOUND:
    playSound(pop());
    break;

  case INST_SPAWN_PARTICLES:
    spawnParticles(pop());
    break;
  
  
  case INST_LITERAL:
  // Read the next byte from the bytecode.
      int value = bytecode[++i];
      push(value);
      break;

}

 스택에 원시 정수 값을 전달하기 위해 LITERAL 명령을 추가한다. 또한 명령어 흐름이 바이트 시퀸스라는 사실을 활용한다. 이렇게 전과 달리 바이트의 위치에 의해 속성값이 고정되지 않는다

 

 

 Behavior = composition

case INST_GET_HEALTH:
{
  int wizard = pop();
  push(getHealth(wizard));
  break;
}

case INST_GET_WISDOM:
case INST_GET_AGILITY:
  // You get the idea...
  
case INST_ADD:
{
  int b = pop();
  int a = pop();
  push(a + b);
  break;
}

 추가적으로 스택에 여러 값을 가져오고 연산을 지원한다면,

 

setHealth(0, getHealth(0) + (getAgility(0) + getWisdom(0)) / 2);

 위와 같은 동작도 바이트코드를 통해 구현이 가능하다.

 

LITERAL 0    [0]            # Wizard index
LITERAL 0    [0, 0]         # Wizard index
GET_HEALTH   [0, 45]        # getHealth()
LITERAL 0    [0, 45, 0]     # Wizard index
GET_AGILITY  [0, 45, 7]     # getAgility()
LITERAL 0    [0, 45, 7, 0]  # Wizard index
GET_WISDOM   [0, 45, 7, 11] # getWisdom()
ADD          [0, 45, 18]    # Add agility and wisdom
LITERAL 2    [0, 45, 18, 2] # Divisor
DIVIDE       [0, 45, 9]     # Average agility and wisdom
ADD          [0, 54]        # Add average to current health
SET_HEALTH   []             # Set health to result

 

 

A virtual machine

스택, 루프, 스위치문 만큼 단순하다는 것을 알 수 있다. 바이트코드는 게임의 나머지 부분에 영향을 미치는 몇가지 명령만 정의했기 때문에 악의적인 작업을 수행하거나 게임엔진의 이상한 부분에 접근할 수 없다.

 

Spellcasting tools

디자이너 친화적인 유용성은 없다. 이 격차를 메우기 위해서는 사용자가 상위 수준 동작을 정의한 다음 적절한 하위 수준 스택 머신 바이트코드를 생성할 수 있는 프로그램이 필요하다. 컴파일러를 만드는 것은 VM 만드는 것보다 휠씬 어렵다. 그 대신, 그래픽 인터페이스를 구축하는 것을 고려한다.

 

 

 

Design Decisions

How do instructions access the stack?

  • 스택 기반 VM
    • 적은 지침
    • 간단한 코드 생성
    • 추가지침
  • 레지스터 기반 VM (값의 주소를 사용한 위치 기반)
    • 지침이 더 크다(각 명령에 스택 오프셋도 있어야 한다)
    • 지침이 더 적다. 각 명령어는 더 많은 작업을 수행 할 수 있으므로 많은 명령어가 필요하지 않다.

 

What instructions do you have?

  • 외부 프리미티브
  • 내부 프리미티브 : 리터럴, 산술, 비교 및 스택을 저글링하는 명령 같이 VM 내부의 값을 조작
  • 제어 프름 : 조건부, 루프 등의 제어흐름, 점프를 사용
  • 추출 : 바이트 코드를 복사하여 붙여넣는 대신, 재사용하기 위해, 호출가능한 프로시저 같은 것을 원할 수도 있다.

How are values represented?

  • 단일 데이터 유형
    • 간단하지만, 다른 데이터 유형으로 작업할 수 없다.
  • 태그가 지정된 변형
    • 동적으로 유형이 지정된 언어에 대한 일반적인 표현이다.
    • 값은 자신의 유형을 알고 있다.
    • 타입을 저장하기위한 더 많은 메모리가 필요하다.
  • 태그가 지정되지 않은 공용체
    • 작은 비트를 사용해 유형을 구분한다. 유형구분은 사용자의 몫이다.
    • 필요한 비트만 저장하는데 사용하므로 효율적이다.
    • 빠르지만 안전하지 않다
  • 인터페이스
    • 다양한 유형 테스트 및 변환을 위한 가상 메서드를 제공한다. 특정 게이터 유형에 대한 구체적인 클래스가 있다.
    • 개방형이다.
    • 객체지향적이다
    • 장황하다
    • 비효율적이다.

How is the bytecode generated?

  • 텍스트 기반 언어를 정의하는 경우
    • 구분을 정의해야 한다.
    • 파서를 구현해야한다.
    • 구문 오류를 처리해야한다.
    • 기술자가 아니라면 다루기 어렵다.
  • 그래픽 저작 도구를 정의하는 경우
    • 사용자 인터페이스를 구현해야한다.
    • 오류 사례가 적다.
    • 휴대성은 더 어렵다

See Also

  • Intetpreter 패턴
  • Lua 프로그래밍 언어, 스크립트 언어, 컴팩트한 바이트코드 VM
  • Kismet, Unreal 엔진용 편집기

Subclass Sandbox

더보기

 Intent

베이스 클래스에서 제공하는 기본 기능으로 서브클래스의 행동을 정의한다.

 

Motivation

게임에서 다양성이 가득한 세계를 구현하기위해, 베이스 클래스를 파생한 수많은 파생 클래스를 만들게 된다.  이 경우 다음과 같은 문제가 발생한다.

  • 수많은 중복된 코드, 비슷한 기능으로 인한 유사한 코드
  • 게임 엔진이 이러한 클래스와 결합된다.
  • 외부 시스템일 변경한가면, 몇몇 코드가 동작하지 못한다. 
  • 수많은 파생 클래스를 아우르는 베이스 클래스를 정의하는 것은 어렵다.

#수많은 파생 클래스를 만들고 있다는 것은, 데이터 기반 접근이 더 나은 경우이다. Type Object, Bytecode, interpreter를 참고해라

 

이를 방지하기 위해, 기본 요소 세트를 제공한다. 즉 몇몇 기본 클래스의 메서드를 통해 구현할 수 있도록한다. 베이스 클래스는 파생 클래스에서 호출될 목적을 가지는 메서드를 가진다. 그리고 이러한 메서드를 통해 동작을 구현할 수 있도록 샌드박스 메서드를 제공한다.

 

이렇게 수많은 파생 클래스에서 생기는 결합은 베이스 클래스로 집중시키고, 외부 시스템과의 결합은 베이스 클래스만 가지도록한다.

 

#최근에는 상속을 비판하는 경우가 있다. 기본 베이스와 하위 클래스의 결합은 강력하다.

#저자는 깊은 상속보다는 넓은 상속이 다루기 편하다고 한다

 

 

The Pattern

베이스 클래스는 추상 샌드박스 메서드와 하위 클래스에 제공할 여러 기능을 가진다.  protected 접근지정자를 통해 이를 명확히 한다. 각 파생 클래스는 샌드박스 메서드에 제공된 기능을 가지고 동작을 구현한다.

 

When to Use It

  • 수많은 파생클래스를 가진 베이스 클래스가 있는 경우
  • 베이스 클래스에서 파생 클래스가 필요한 기능을 제공할 수 있는 경우
  • 파생 클래스 간의 동작이 중복되는 경우
  • 파생 클래스와 시스템의 결합도를 최소화하고 싶은 경우

Keep in Mind

최근에 상속은 나쁜 언어이다. base class는 점점 더 많은 코드를 가지게 되기 때문이다. 특히나 이 패턴은 그것에 취약하다. 베이스 클래스는 파생 클래스에게 필요한 모든 시스템과 결합을 가지고, 각 파생 클래스와 결합을 가지게 되므로, 베이스 클래스는 변경이 매우 힘들어지는 brittle base class problem이 발생한다. 물론, 파생 클래스의 유지 보수는 용이하다.

만약 베이스 클래스가 비대하지는 경우, 책임을 수행하는 별도의 클래스를 고려한다. Component 패턴이 도움이 된다.

 

# 코드는 c++ 그대로 가져옴

Sample Code

기본적인 구성

class Superpower
{
public:
  virtual ~Superpower() {}

protected:
  virtual void activate() = 0;

  void move(double x, double y, double z)
  {
    // Code here...
  }

  void playSound(SoundId sound, double volume)
  {
    // Code here...
  }

  void spawnParticles(ParticleType type, int count)
  {
    // Code here...
  }
  double getHeroX()
  {
    // Code here...
  }

  double getHeroY()
  {
    // Code here...
  }

  double getHeroZ()
  {
    // Code here...
  }

  
}

 

 acrivate() 메서드는 샌드박스 메서드이다. 파생 클래스에서 이 메서드를 override하여 제공된 메서드를 사용해 동작을 구현한다.

 

 

Design Decisions

What operations should be provided?

파생 클래스의 외부시스템과의 결합을 기본 클래스로 중앙 집중화 하면 할수록, 기본 클래스는 커지고 유지관리가 힘들어 진다.

  • 베시스 클래스에서 제공하는 기능이 소수의 하위 클래스만 사용하는 경우, 비용 대비 큰 이익을 얻지 못한다. 기본 클래스에 복잡성이 추가하여 모든 사람이 영향을 받지만 몇가지 클래스만 혜택을 받는다. 특수한 경우 파생클래스에서 직접 호출하는게 더 깔끔할 수 있다.
  •  상태를 수정하지 않는 메서드에 대해 안전한 결합이다. 반면 상태를 수정하는 호출은 기본클래스에 롤업될 수 있는 좋은 후보이다.

#안전함, 멀티스레드, (동기화 유지를 위해) 엄격하게 결정적인 경우

  • 외부 시스템으로 호출만을 하는 경우 외부 메서드에 직접 호출하는 것이 간단할 수 있다. 그러나 기본 클래스가 캡슐화하고 싶은 필드에 대해서 유용할 수 있다.

Should methods be provided directly, or through objects that contain them?

기본 클래스가 매우 많은 메서드를 가지게 된 경우 , 다른 클래스로 이동시켜 관리한다.

ex) 베이스 클래스의 소리 관련 메서드를 SoundPlayer 클래스에 정의한 뒤, 멤버 객체로 가지게 한다. 그 뒤  SoundPlayer의 액세스를 제공한다.

  • 기본 클래스의 메서드 수를 줄인다.
  • 도우미 클래스의 코드는 일반적으로 유지 관리가 더 쉽다.
  • 기본 클래스와 다른 시스템간의 결합도를 낮춘다.

How does the base class get the state that it needs?

기본 클래스에는 캡슐화하고 하위 클래스에 숨겨두려는 일부 데이터가 필요한 경우가 많다. 구현에 특정 외부 클래스 객체가 필요한 경우이다.

  • 기본 클래스 생성자에 전달하는 경우
    • 기본 클래스의 생성자를 통해 인수로 전달하면 파생 클래스를 생성할 때도 외부 클래스 객체를 알고 있어야하는 문제가 있다.(%파생 클래스가 여기에 관여하게 만들고 싶지 않다.)
  • 2단계 초기화 수행, 별도의 초기화 메서드, 기본 생성자에서 이 메서드를 호출하도록 구현
  • 해당 멤버를 정적으로 설정
    • 만약 외부 클래스 객체가 싱글톤인 경우, 기본 클래스의 생성자 및 해당 객체 참조 멤버도 정적으로 설정, 기본 클래스를 전체 게임에 대해 한번만 초기화 하도록한다.
  • 서비스 로케이터 사용

See Also

  • Update Method 패턴도 샌드박스 method 인 경우가 많다.
  • Template Method 패턴의 역할을 뒤집은 것이다.
  • Facade 패턴의 변형으로 간주할 수도 있다.

 

Type Object

더보기

 Intent

  다른 유형을 나타내는 클래스, "유형 클래스"를 생성 함으로써, 새로운 유형 생성을 유연하게 한다.

  새로운 유형을 데이터 형식으로 관리할 수 있다.

 

Motivation, The typical OOP answer, A class for a class

  드래곤, 트롤 등은 몬스터이다(is-a). 이에 트롤, 드래곤을 몬스터 클래스를 상속시켜 구현한다. 그러면 각 드래곤, 트롤의 아종에 대한 파생클래스를 작성하게 된다. 각 품종, 아종이 많아질수록, 상속계층은 커지게 되고, 각 클래스가 추가 될때마다 고유한 품종으로 컴파일된다.

  그 대신 다른 방법이 있다. "몬스터가 품종을 가진다(has-a)"라는 관점 하에, 상속이 아닌 참조되는 되도록 한다. 몬스터는 품종에 정의된 데이터를 참조한다. 각 품종 인스턴스는 유형을 나타내는 Type Object이다.

  이렇게 코드베이스를 복잡하게 만들지 않고 새로운 유형을 정의할 수 있다. 유형 시스템이 하드코딩 되지 않고 일부를 런타임 시 정의할 수 있는 데이터로 만들었다. 품종 인스턴스의 값을 달리해서 다양한 아종을 만들 수 있다.

 

The Pattern

"type object" 와 "typed object"를 정의한다. 각 type object 인스턴스는 서로 다른 논리적 유형을 나타낸다. 각 typed object 는 유형을 묘사해주는 type object의 참조를 가진다.

 

When to Use It

다양한 사물의 종류를 정의하는 데에는 유용하지만, 언어 유형 시스템에 적용하는 것은 너무 엄격하다? 

  • 앞으로 어떤 유형이 필요하게 될지 모르는 경우
  • 코드를 다시 컴파일하거나 수정할 필요없이 새로운 유형을 추가하고 싶은 경우?

keep in Mind

  유형의 정의를 엄격한 코드에서 유연하지만 덜 동작적인 메모리 개체로 옮기는 것이다. 유형성은 좋지만, 유형을 데이터로 끌어올리면 몇가지 손실이 발생한다.

 

The type objects have to be tracked manually

  클래스 선언 시에는 클래스를 알아서 처리해주는 컴파일러가 있었다면, 유형을 인스턴스로 관리하기 때문에 수동으로 관리해야한다.

 

It's harder to define behavior for each type

  파생 클래스 선언과 달리, 재정의 메서드가 아닌 멤버 변수를 가진다. 유형 개체를 사용한 유형별 데이터 정의는 쉽지만 유형별 동작을 정의하기는 어렵다. 이에 따라 몬스터의 다양한 AI 알고리즘 사용을 사용해야 할 경우 더욱 어려워 진다.

  이를 해결하기 위해, 미리 정의된 동작세트를 가지고 선택하게 만드는 방법이 있다. 대기, 추격, 도망과 같은 동작을 구현하는 함수를 정의 할 수 있다. 또 다른 강력한 솔루션은 데이터에서의 동작정의를 지원하는 것이다. interpreter 및 bytecode 패턴을 사용하는 것이다.

 

Sample code

#그대로 가져온 코드 c++

class Breed
{
public:
  Breed(int health, const char* attack)
  : health_(health),
    attack_(attack)
  {}

  int getHealth() { return health_; }
  const char* getAttack() { return attack_; }

private:
  int health_; // Starting health.
  const char* attack_;
};

class Monster
{
public:
  Monster(Breed& breed)
  : health_(breed.getHealth()),
    breed_(breed)
  {}

  const char* getAttack()
  {
    return breed_.getAttack();
  }

private:
  int    health_; // Current health.
  Breed& breed_;
};

 

 Monster class 에서 breed 인스턴스를 참조한다. breed 객체는 품종, 유형을 정의한다. 

 

 

Making type objects more like types: constructors

Monster class 생성자에 breed 인스턴스를 인수로 전달해 monster 인스턴스를 생성하는 것이 아니라, breed 인스턴스가 moster 생성자를 호출하는 메서드를 사용한다.

class Breed
{
public:
  Monster* newMonster() { return new Monster(*this); }

  // Previous Breed code...
};

 이렇게 함으로써, Moster 인스턴스 생성 전에 데이터를 저장할 수 있는 Breed 객체를 확보할 수 있다. 이를 통해 Moster 인스턴스 생성 전, 관련 데이터를 관리할 수 있다. 

 

Sharing data through inheritance

  클래스 상속을 통한 상속이 아니다. 단순한 데이터 공유이다. Breed 간의 상속(데이터 공유)을 통해 상위 폼종을 가질 수 있도록 한다. 이를 통해 다양한 아종이 공통된 스텟을 상위 폼종을 통해 가질수 있도록 한다. 이때, 품종의 속성이 바뀌지 않는다고 믿을 수 있다면, 데이터를 참조할 레퍼런스를 가져오는 것이 아니라 데이터를 복사해서 저장하는 것이 메모리, 속도면에서 좋다.

 

레퍼렌스 유지 시,

class Breed
{
public:
  Breed(Breed* parent, int health, const char* attack)
  : parent_(parent),
    health_(health),
    attack_(attack)
  {}

  int         getHealth();
  const char* getAttack();

private:
  Breed*      parent_;
  int         health_; // Starting health.
  const char* attack_;
};

int Breed::getHealth()
{
  // Override.
  if (health_ != 0 || parent_ == NULL) return health_;

  // Inherit.
  return parent_->getHealth();
}

const char* Breed::getAttack()
{
  // Override.
  if (attack_ != NULL || parent_ == NULL) return attack_;

  // Inherit.
  return parent_->getAttack();
}

 

복사해 가져오기

Breed(Breed* parent, int health, const char* attack)
: health_(health),
  attack_(attack)
{
  // Inherit non-overridden attributes.
  if (parent != NULL)
  {
    if (health == 0) health_ = parent->getHealth();
    if (attack == NULL) attack_ = parent->getAttack();
  }
}

int         getHealth() { return health_; }
const char* getAttack() { return attack_; }

 

유형 데이터가 저장된 JSON파일을 로드하여 새로운 품종을 생성할 수 있을 것이다(게임 엔진이 지원한다면).

 

Design Decisions

Is the type object encapsulated or exposed?

Moster 객체가 breed 객체를 캡슐화 하느냐 안하느냐

  • 유형이 캡슐화된 경우
    • Type Object 패턴의 복잡성은 숨겨져있다. 유형이 지정된 객체, typed object 만 관리하면 된다.
    • typed object는 type object 동작을 선택적으로 재정의할 수 있다. if문을 통해, 선택적으로 type object 동작을 따를것인지 아닌지 구현할 수 있다.
    • type object 가 공개적으로 하려는 것에 대해서, typed object가 별도의 전달 메서드를 가져야한다.
  • 유형 객체가 노출된 경우
    • 외부 코드와 상호작용 할 수 있다.
    • type object 는 공개 API의 일부이다.

 

How are typed objects created?

typed object와 type object 를 어떻게 만들고 결합시킬 것이냐

  • typed object 생성자 인수로  type object 전달
    • 외부 코드가 할당을 제어할 수 있다. 객체를 다양한 메모리 시나리오, 다른 할당자, 스택 등에서 사용할 수 있기를 원하는 경우 유연성이 제공된다.
  • 사type object 에서 typed object 생성자 호출
    • type object 는 메모리 할당을 제어한다. 사용자가 메모리 내 객체가 생성되는 위치를 선택하는 것을 원하지 않는 경우 유형 객체에 대한 팩토리 메서드를 거치도록 요구하면 이를 제어할 수 있다?. 모든 개체가 특정 object pool  이나 다른 메모리 할당자에서 나오는지 확인하려는 경우 유용하다.

Can the type change?

시간이 지남에 따라 유형이 변하도록 허용할 수 있다. ex) 다양한 종족 언데드화

  • 유형이 변경되지 않는 경우
    • 더 간단하다, 디버깅이 쉽다.
  • 유형이 변경될 수 있는 경우
    • 객체 생성이 줄어든다. 새로운 유형 생성이 아니라 변경시키면 되기 떼문에
    • 가정이 충족되는지 주의한다.

What kind of inheritance is supported?

  • 상속 없음
    • 간단하고 단순하지만 중복된 노가다로 이어질 수 있다. 저자도 디자이너가 상속을 원하지 않는 경우를 본적이 없다
  • 단일 상속
    • 여전히 상대적으로 간단하다. 이해하기도 쉽다.
    • 속성 조회 속도가 느려진다.
  • 다중 상속
    • 거의 모든 중복을 피할 수 있따.
    • 복잡하다. 이해하고 추론하기가 어렵다.

See Also

  • Type object가 해결하는 문제를 다른 방식으로 해결하는 Prototype이 있다.
  • Flyweight 와 비슷하다. 인스턴스 간의 데이터 공유를 한다. 하나는 메모리 절약 관점에서 하나는 개념적인 구성과 유연성에 중점을 둔다.
  • State 패턴과 많은 유사점이 있다. 자신을 정의하는 부분을 다른 객체에 위임한다.