Bolt 튜토리얼 - 챕터 5

<< 이전 챕터

신뢰성있는 지연 보상 슈팅

제목은 매우 복잡해 보이지만, 우리도 구현하려고 합니다. 다음의 챕터들에서는 네트워크 프로그램의 "holy grail" 스타일 중 하나를 다룰 것 입니다. 몇 가지 주요 사항을 설명하도록 하겠습니다.

  1. 신뢰성있는 무기 변경
  2. 신뢰성있는 발사
  3. 클라이언트에서 지연 보상

총, 총 그리고 더 많은 총들!

첫 번째로 할일은 일반적인 Weapon 컴포넌트를 약간 설정하는 것입니다. tutorial/Scripts 폴더에서 새로운 Weapons 폴더를 생성하고 이 폴더 안에 TutorialWeapon.cs C# 스크립트를 생성합니다.

이 스크립트 안에서 표준 유니티 mono behaviour 를 작성할 것으로 무기를 구성할 몇 개의 변수들을 가지게 할 것입니다. 이 것은 표준 유니티와 거의 같기 때문에 이에 대한 상세사항에 대해서는 설명하지 않을 것 입니니다. 튜토리얼을 진행하면서 모든 변수들을 사용할 것이므로, 변수들에 대해서는 개별적으로 설명될 것 입니다.

using UnityEngine;

public abstract class TutorialWeapon : MonoBehaviour {
  [SerializeField]
  public GameObject shellPrefab;

  [SerializeField]
  public GameObject impactPrefab;

  [SerializeField]
  public GameObject trailPrefab;

  [SerializeField]
  public Transform muzzleFlash;

  [SerializeField]
  public Transform shellEjector;

  [SerializeField]
  public AudioClip fireSound;

  [SerializeField]
  public byte damage = 25;

  [SerializeField]
  public int rpm = 600;

  public int fireIntervall {
    get {
      // calculate rounds per second
      int rps = (rpm / 60);

      // calculate frames between each round
      return BoltNetwork.framesPerSecond / rps;
    }
  }

  public int fireFrame {
    get;
    set;
  }

  public virtual void HitDetection(TutorialPlayerCommand cmd, BoltEntity entity) {

  }

  public virtual void DisplayEffects(BoltEntity entity) {

  }
}

다음으로 해야할 일은 하나의 스크립트를 생성하는 것으로 TutorialWeapon 옆에 TutorialWeaponRifle 라고하는 스크립트를 생성합니다.

지금까지는 거의 비어있을 것이고 TutorialWeapon 클래스로 부터 상속을 받습니다.

using UnityEngine;
using System.Collections;

public class TutorialWeaponRifle : TutorialWeapon {

}

bolt_tutorial/prefabs/weapons 로 이동하여 Rifle 프리팹을 찾아 이 프리팹의 복사본을 생성(Windows 에서 CTRL+D, OS X 에서 CMD+D)합니다. 이 복사본을 "Rifle 1" 라고 부르며 tutorial/Prefabs 폴더로 끌어놓아 TutorialRifle 라고 이름을 변경합니다.

TutorialRifle 프리팹을 선택하고 여기에 TutorialWeaponRifle 스크립트를 추가합니다.

인스펙터를 통해 TutorialWeaponRifle 의 모든 공용 변수들을 연결시켜봅니다. 정확히 무엇인지를 설명하는 대신, 여기에 모든 것이 올바르게 연결되어 있는지 그림을 보여주겠습니다.

꼬마 군인에게 무기를 쥐어줘야 할 시간입니다. 소총은 올바르게 회전해야만 하고, 아래와 같이(빨간 화산표) TutorialRifle 프리팹을 꼬마 군인의 오른손 아래에 떨어뜨려 놓아야 합니다. Apply 또는 Project 윈도우안의 상단으로 그것 자체를 다시 끌어놓아 TutorialPlayer 프리팹 저장하는 것을 잊지 마세요.

게임을 플레이한다면 캐릭터의 손에 소총이 있는 것을 볼 수 있어야 합니다. 회전이 잘 되지 않으면 TutorialPlayer 프리팹으로 돌아가서 재조정을 해주세요.

눈치 채셨겠지만 카메라를 피치하는 것이 가능하지 않습니다. 작업하기 충분한 미완성된 상태인 튜토리얼 코드로 PlayerCamera 클래스로 만들었기 때문입니다. 먼저 TutorialPlayerState 에셋에 pitch 속성을 추가할 필요가 있습니다.

속성을 "pitch" 로 이름을 부여하고 float형 인지 확인해주세요. 각각을 구성해주세요

  1. Replicate WhenValue Changed 로 설정
  2. Replicate To Everyone Except Controller 로 설정
  3. Interpolate 사용가능

Assets/Compile Bolt Assets 를 클릭하여 Bolt를 컴파일합니다. Bolt가 마법을 부리도록 하고 TutorialPlayerController 스크립트를 엽니다. ExecuteCommand 메소드를 변경할 것 입니다. cmd.isFirstExecution 를 체크하는 if-블록 내에서 명령어로써 우리의 상태로 피치하는 복사하는 라인을 추가할 것 입니다.

// ... 

if (cmd.isFirstExecution) {
  AnimatePlayer(cmd);

  // set state pitch
  state.pitch = cmd.input.pitch;
}

// ...

이제 TutorialPlayerCallbacks 스크립트로 가서 ControlOfEntityGained 를 다음과 같이 변경해줍니다.

// ... 

public override void ControlOfEntityGained(BoltEntity arg) {
    // give the camera our players pitch
    PlayerCamera.instance.getPitch = () => arg.GetBoltState<ITutorialPlayerState>().pitch;

    // this tells the player camera to look at the entity we are controlling
    PlayerCamera.instance.SetTarget(arg);

    // add an audio listener for our character
    arg.gameObject.AddComponent<AudioListener>();
}

// ...

이 작은 우회적 방식으로 카메라에 피치를 얻는 이유는 처음부터 모든 상태들을 컴파일 하지 않고도 카메라가 동작 할 수 있기 때문에 보다 자연스러운 방법으로 튜토리얼을 진행할 수 있습니다.

우리의 엔티티 게임 오브젝트에 AudioListener 컴포넌트도 추가하여 캐릭터의 관점에서 들을 수 있습니다.

이제 무기를 연결하여 무엇인가를 쏘아야 하는 때로, 우선 ExecuteCommand 안에서 무기를 찾는 방식이 필요합니다. TutorialPlayerController 스크립트에서 weapons 라는 인스펙터 변수를 추가하고 다음과 같이 TutorialWeapon 객체들의 배열을 가지도록 해주세요:

// ...

public class TutorialPlayerController : BoltEntityBehaviour<ITutorialPlayerState> {
  const float MOUSE_SENSEITIVITY = 2f;

  TutorialPlayerMotor _motor;
  TutorialPlayerCommand.Input _input;

  [SerializeField]
  TutorialWeapon[] weapons;

// ...

유니티 인스펙터에서 TutorialRifle 객체를 TutorialPlayerController 위의 새로운 weapons 에 캐릭터의 오른손에 붙입니다. 변경사항 적용하는 것을 잊지 마세요.

무기를 발사하는 것과 올바르게 통신 하기 위해서 커맨드에 몇 개의 입력이 필요합니다. TutorialPlayerCommand 를 열고 aimingfire 속성을 Input 부분에 추가합니다. 두 개 속성 모두 bool이 되어야 합니다.

다시 Bolt를 컴파일하고 TutorialPlayerController 스크립트를 다시 엽니다 . PollKeys 안에서 왼쪽과 오른쪽 마우스 버튼의 상태를 질의할 것 입니다.

// ...

  void PollKeys(bool mouse) {
    _input.forward = Input.GetKey(KeyCode.W);
    _input.backward = Input.GetKey(KeyCode.S);
    _input.left = Input.GetKey(KeyCode.A);
    _input.right = Input.GetKey(KeyCode.D);
    _input.jump = Input.GetKeyDown(KeyCode.Space);

    // mouse buttons
    _input.fire = Input.GetMouseButton(0);
    _input.aiming = Input.GetMouseButton(1);

    if (mouse) {
      _input.yaw += (Input.GetAxisRaw("Mouse X") * MOUSE_SENSEITIVITY);
      _input.yaw %= 360f;

      _input.pitch += (-Input.GetAxisRaw("Mouse Y") * MOUSE_SENSEITIVITY);
      _input.pitch = Mathf.Clamp(_input.pitch, -85f, +85f);
    }
  }

// ...

TutorialPlayerController 안의 ExecuteCommand 함수로 이동합니다. ExecuteCommand 이후에 명령어 입력 피치에서 상태 속성으로 복사하고 FireWeapon 함수(생성 예정)를 호출했다면 조준과 발사가 동시에 눌렸는지를 체크하는 것을 추가합니다.

  // ...

  public override void ExecuteCommand(BoltCommand c, bool resetState) {
    TutorialPlayerCommand cmd = (TutorialPlayerCommand)c;

    if (resetState) {
      // we got a correction from the server, reset (this only runs on the client)
      _motor.SetState(cmd.state);
    }
    else {
      // apply movement (this runs on both server and client)
      cmd.state = _motor.Move(cmd.input);

      if (cmd.isFirstExecution) {
        AnimatePlayer(cmd);

        // set state pitch
        state.pitch = cmd.input.pitch;

        // check if we should try to fire our weapon
        if (cmd.input.aiming && cmd.input.fire) {
          FireWeapon(cmd);
        }
      }
    }
  }

  // ...

아규먼트로 TutorialPlayerCommand 을 받는 FireWeapon 이라고하는 함수를 생성합니다.


  void FireWeapon(TutorialPlayerCommand cmd) {
    if (weapons[0].fireFrame + weapons[0].fireIntervall <= BoltNetwork.serverFrame) {
      weapons[0].fireFrame = BoltNetwork.serverFrame;

      state.mecanim.Fire();
    }
  }

현재 우리는 단 하나의 무기만을 가지고 있기 때문에 무기 배열에 곧바로 인덱스를 붙일 수 있습니다. 마지막으로 발사한 시점과 각 총알 사이에 통과해야하는 프레임 수를 확인합니다(fireinterval 은 무기의 RPM 설정에서 계산됩니다). 설정한 충분한 프레임이 지나갔다면 다시 fileFrame 속성을 설정하고 메카님 Fire() 트리거를 호출 합니다.

커뮤니케이션에서 메카님 트리거를 사용하는 이유는 무기를 발사하는 것이 엄청나게 가벼웠다는 것입니다. 사실 2비트만 사용하기 때문에 상대적으로 큰 이벤트보다 수시로 발사를 전송하는 것은 크게 부하가 없습니다.

따라서, 이 메카님 트리거를 연결하는 법이 필요합니다. 다행스럽게도 Bolt는 메카님을 연결하여 트리거가 발생했을 때마다 콜백을 받을 수 있도록 해줍니다. TutorialPlayerController 스크립트내에서는 여전히 Bolt가 제공하는 Attached 메소드를 오버라이드합니다.

  // ...

  public override void Attached() {
    state.mecanim.onFire += () => {
      weapons[0].DisplayEffects(entity);
    };
  }

  // ...

C# 람바다 메소드를 메카님 상태의 onFire 콜백에 붙였고 무기의 DisplayEffects 메소드를 호출했습니다. 아직은 완료되지 않았으나 참조용으로 TutorialPlayerController 전체 스크립트를 놓아 두었습니다.

using UnityEngine;

public class TutorialPlayerController : BoltEntityBehaviour<ITutorialPlayerState> {
  const float MOUSE_SENSEITIVITY = 2f;

  TutorialPlayerMotor _motor;
  TutorialPlayerCommand.Input _input;

  [SerializeField]
  TutorialWeapon[] weapons;

  void Awake() {
    _motor = GetComponent<TutorialPlayerMotor>();
  }

  void Update() {
    PollKeys(true);
  }

  void PollKeys(bool mouse) {
    _input.forward = Input.GetKey(KeyCode.W);
    _input.backward = Input.GetKey(KeyCode.S);
    _input.left = Input.GetKey(KeyCode.A);
    _input.right = Input.GetKey(KeyCode.D);
    _input.jump = Input.GetKeyDown(KeyCode.Space);

    // mouse buttons
    _input.fire = Input.GetMouseButton(0);
    _input.aiming = Input.GetMouseButton(1);

    if (mouse) {
      _input.yaw += (Input.GetAxisRaw("Mouse X") * MOUSE_SENSEITIVITY);
      _input.yaw %= 360f;

      _input.pitch += (-Input.GetAxisRaw("Mouse Y") * MOUSE_SENSEITIVITY);
      _input.pitch = Mathf.Clamp(_input.pitch, -85f, +85f);
    }
  }

  public override void Attached() {
    state.mecanim.onFire += () => {
      weapons[0].DisplayEffects(entity);
    };
  }

  public override void SimulateController() {
    PollKeys(false);

    TutorialPlayerCommand cmd;

    cmd = BoltFactory.NewCommand<TutorialPlayerCommand>();
    cmd.input = this._input;

    entity.QueueCommand(cmd);
  }

  public override void ExecuteCommand(BoltCommand c, bool resetState) {
    TutorialPlayerCommand cmd = (TutorialPlayerCommand)c;

    if (resetState) {
      // we got a correction from the server, reset (this only runs on the client)
      _motor.SetState(cmd.state);
    }
    else {
      // apply movement (this runs on both server and client)
      cmd.state = _motor.Move(cmd.input);

      if (cmd.isFirstExecution) {
        AnimatePlayer(cmd);

        // set state pitch
        state.pitch = cmd.input.pitch;

        // check if we should try to fire our weapon
        if (cmd.input.aiming && cmd.input.fire) {
          FireWeapon(cmd);
        }
      }
    }
  }

  void FireWeapon(TutorialPlayerCommand cmd) {
    if (weapons[0].fireFrame + weapons[0].fireIntervall <= BoltNetwork.serverFrame) {
      weapons[0].fireFrame = BoltNetwork.serverFrame;

      state.mecanim.Fire();
    }
  }

  void AnimatePlayer(TutorialPlayerCommand cmd) {
    // FWD <> BWD movement
    if (cmd.input.forward ^ cmd.input.backward) {
      state.mecanim.MoveZ = cmd.input.forward ? 1 : -1;
    }
    else {
      state.mecanim.MoveZ = 0;
    }

    // LEFT <> RIGHT movement
    if (cmd.input.left ^ cmd.input.right) {
      state.mecanim.MoveX = cmd.input.right ? 1 : -1;
    }
    else {
      state.mecanim.MoveX = 0;
    }

    // JUMP
    if (_motor.jumpStartedThisFrame) {
      state.mecanim.Jump();
    }
  }
}

지금 유일하게 남은 것은 TutorialRifle 스크립트의 DisplayEffects 함수를 구현하는 것입니다. 이 함수는 꽤 길지만, Bolt에 특수화된 것이 아닌 일반 유니티 메소드를 사용하여 화려한 효과를 보여줍니다.

using UnityEngine;
using System.Collections;

public class TutorialWeaponRifle : TutorialWeapon {
  public override void DisplayEffects(BoltEntity entity) {
    // This calculates the current direction the player that is controlling the entity passed in is aiming.
    Vector3 pos;
    Vector3 fwd;
    Quaternion rot;

    // This lets us replicate camera conditions anywhere in code
    PlayerCamera.instance.CalculateCameraAimTransform(entity.transform, entity.GetBoltState<ITutorialPlayerState>().pitch, out pos, out rot);

    // Calculate forward vector of aiming camera 
    fwd = rot * Vector3.forward;

    RaycastHit rh;

    // Check to see if we hit anything (only for effects)
    if (Physics.Raycast(new Ray(pos, fwd), out rh)) {
      // if we have an impact prefab
      if (impactPrefab) {
        // and we did *not* hit another player, play our impact fx
        if (!rh.transform.GetComponent<BoltEntity>()) {
          GameObject.Instantiate(impactPrefab, rh.point, Quaternion.LookRotation(rh.normal));
        }
      }

      // if we have a trail prefab, use it!
      if (trailPrefab) {
        var trailGo = GameObject.Instantiate(trailPrefab, muzzleFlash.position, Quaternion.identity) as GameObject;
        var trail = trailGo.GetComponent<LineRenderer>();

        trail.SetPosition(0, muzzleFlash.position);
        trail.SetPosition(1, rh.point);
      }

    }

    // if we have a shell/casings prefab, eject it along the forward vector of our shell ejector
    if (shellPrefab) {
      GameObject go = (GameObject)GameObject.Instantiate(shellPrefab, shellEjector.position, shellEjector.rotation);
      go.rigidbody.AddRelativeForce(0, 0, 2, ForceMode.VelocityChange);
      go.rigidbody.AddTorque(new Vector3(Random.Range(-32f, +32f), Random.Range(-32f, +32f), Random.Range(-32f, +32f)), ForceMode.VelocityChange);
    }

    // if we have a muzzle flash, show it.
    if (muzzleFlash) {
      muzzleFlash.gameObject.SetActive(true);
    }

    // if we have an audio source AND we have a fire sound
    if (audio && fireSound) {
      audio.PlayOneShot(fireSound);
    }
  }
}

이제 서버와 수 많은 클라이언트들이 플레이 할 수 있으며 총을 쏘며 효과를 내는 것이 올바르게 복제될 것 입니다.

기술문서 TOP으로 돌아가기