Blog に戻る
『Astral Days!』の企画・開発秘話

[AD] 005. (0.1.0) Movement Foundation System - 1 (基本的なキャラクター移動)

『アスデイ』のキャラクターの水平移動の実装

しばらくの間、学業やコンテストなどが重なり、『アスデイ』の開発を進めることができずにいた。そしてようやく一段落したため、本格的に『アスデイ』の開発へ取りかかることにした。


開発計画では、0.1バージョンでFoundation Systemsを実装する予定である。その中でも、0.1.0ではCharacter Movementに関する開発を進めていく。

そのため、0.1.0 - 1ではCharacter Movementの中でも、最も基本となるキャラクターのWASDによる水平移動を実装することにした。

移動方式の選択

一般的な3DゲームをUnityで開発する場合、RigidbodyやCharacterControllerコンポーネントを利用してキャラクターの移動を実装することが多い。

しかし、『アスデイ』が目指すゲームプレイスタイル、特に回避などによって急な方向転換や加速・減速が頻繁に発生するという特徴を考えると、既存の実装方式は適していないと考えた。


Rigidbodyを利用する場合、物理効果を実装する上では利点があるものの、『アスデイ』の非現実的かつダイナミックな動きを実装するには限界がある。

また、CharacterControllerを利用する場合も、物理効果などの面で制限があると判断した。


そこで、キャラクターの移動方式そのものから直接実装することにした。

今回の作業目標

まず、0.1.0ではFoundation Systemsのうち、Character Movementに関する内容を開発する予定である。

その最初のバージョンとなる0.1.0 - 1では、他の機能をすべて除外し、最も基本的な水平移動のみを実装することを目標とした。

今回の段階での目標をもう少し具体的にすると、次の4点である。


  • WASD入力を受け取れること
  • ワールド座標ではなく、カメラが向いている方向を基準に移動すること
  • 斜め移動で速度が上がらないこと
  • 開始と停止が瞬間的に切り替わらず、加速・減速すること

今後、この基盤をもとにGround Detection、Gravity、Jump、Dash、Forced Movementなどを追加していく構造になるため、今回はその基礎となるhorizontalVelocityを作ることに集中した。

水平移動の実装

Input入力の方法

入力にはUnityの新しいInput Systemを使用した。 これは、将来的に複数のデバイスへ対応することを考えて選択したものである。

生成されたInput Action WrapperであるGameInputPlayerMovementが保持するようにした。

private GameInput _gameInput;

そして、Awake()で生成する。

private void Awake()
{
    _gameInput = new GameInput();
}

Input Mapは、コンポーネントが有効になっている間だけ有効化するようにした。

private void OnEnable()
{
    _gameInput.Player.Enable();
}

private void OnDisable()
{
    _gameInput.Player.Disable();
}

その後、Move ActionからVector2の値を読み取る。

Vector2 inputVec = _gameInput.Player.Move.ReadValue<Vector2>();

WASDを基準にすると、おおよそ次のようになる。

W → ( 0,  1)
S → ( 0, -1)
A → (-1,  0)
D → ( 1,  0)

この時点では、まだ2次元の入力値にすぎない。

これを実際のゲーム内で使用する3次元の移動方向へ変換する必要がある。

なぜそのままVector3に変換しなかったのか

最初に考えられる方法は、次のようなものだ。

Vector3 moveDir = new Vector3(inputVec.x, 0f, inputVec.y);

これでも確かにWASDによる移動自体はできる。


しかし問題は、移動方向がワールド座標系に固定されてしまうことである。

例えば、Wが常にワールド座標の+Zだとすると、カメラを90度回転させても、Wを押したときに画面基準では右方向へ歩いてしまう。


しかし『アスデイ』の設計では、キャラクターはカメラを基準に移動する必要がある。そのため、次のような関係が必要になる。

W/S → Camera Forward
A/D → Camera Right

つまり、プレイヤーから見れば常に、

W = 画面の前方向
S = 画面の後ろ方向
A = 画面の左方向
D = 画面の右方向

と感じられる必要がある。

カメラのForwardとRightを求める

そこで、まずカメラの方向を取得した。

[SerializeField] private Camera _camera;

カメラには、すでにforwardrightが存在している。

_camera.transform.forward
_camera.transform.right

しかし、カメラは上下方向を見ることもできる。

例えばカメラが下を向いている場合、forwardにはY軸方向の値も含まれる。

Camera Forward

(x, -0.5, z)

   Y軸を含む

この値をそのまま使用すると、Wを押すだけでキャラクターが地面の下へ移動してしまう可能性がある。

そのため、Yの値を取り除いた。

Vector3 cameraForward = new Vector3(_camera.transform.forward.x, 0f, _camera.transform.forward.z).normalized;
Vector3 cameraRight = new Vector3(_camera.transform.right.x, 0f, _camera.transform.right.z).normalized;

これによって、カメラがどこを向いていても、XZ平面上の方向だけを使用することになる。

WASD入力とカメラ方向を組み合わせる

次に、入力値と方向ベクトルを組み合わせた。

Vector3 moveDir =(cameraForward * inputVec.y + cameraRight * inputVec.x).normalized;

この式は最初だけ見ると少し複雑に見えるが、実際の意味は単純である。


例えばWを押した場合、

inputVec = (0, 1)

となるため、

cameraForward × 1 + cameraRight × 0 = cameraForward

となる。

W+Dの場合は、

cameraForward + cameraRight

となり、二つの方向の間にある斜め方向へ移動する。

斜め移動で速度が上がる問題

ここで重要になるのが.normalizedである。

正面方向と右方向のベクトルの長さがそれぞれ1だとすると、

Forward = (0, 1)
Right   = (1, 0)

二つを足した斜め方向のベクトルは、

(1, 1)

となり、その大きさは、

√(1² + 1²) = √2 ≈ 1.414

となる。

つまり、このまま移動させると、Wだけを押している場合よりもW+Dを押している場合の方が約41%速くなってしまう。

そこで、

(...).normalized

を使用し、最終的なmoveDirの長さを再び1に合わせた。

W      → speed 1
D      → speed 1
W + D  → speed 1

こうすることで、方向と速度を分けたことになる。


ただし、実装している途中で一つ注意すべき点にも気づいた。

キーボードでは問題ないものの、ゲームパッドのアナログスティックまで考えた場合、すべての入力をすぐにnormalizeしてしまうと、

スティックを20%だけ倒す
→ normalized
→ 100%の速度

となってしまう。

現段階ではキーボード・マウスを開発基準としているため問題はないが、今後ゲームパッドへ対応する場合には、入力の大きさを別に保持する必要がありそうだ。

最初の基本移動コード

移動方向を作った後、最も単純な移動は次のようになる。

transform.position += moveDir * moveSpeed * Time.deltaTime;

『アスデイ』の基本移動速度は企画上5m/sとしているため、

[SerializeField] private float moveSpeed = 5f;

から始めた。

Time.deltaTimeを掛けている理由は、フレーム数に関係なく1秒あたりの移動量を一定にするためである。

例えば、

moveSpeed = 5m/s

であれば、60FPSでも120FPSでも、1秒間で最終的に約5m移動することが目標となる。

しかし、動きがあまりにも即時的だった

ここで最初の操作感に関する問題が発生した。

単純に、

transform.position += moveDir * moveSpeed * Time.deltaTime;

で移動させると、入力した瞬間に、

0m/s

5m/s

となる。

キーから指を離すと、

5m/s

0m/s

となる。

機能としては正しいものの、実際のキャラクター移動として見ると、かなり機械的な印象になってしまう。

そこで、現在の移動速度を別の状態として保持する方式へ変更した。

private Vector3 horizontalVelocity;

ここで、考え方として重要な変化があった。


最初は、

入力 → 位置移動

という構造だったが、ここからは、

入力

目標速度を計算

現在速度を目標速度へ変化

現在速度を使って位置を移動

という構造になった。

この構造が、今後の移動システムの基盤となった。

targetSpeed変数の追加

まず、入力から最終的に目指す速度を作る。

Vector3 targetSpeed = moveDir * moveSpeed;

例えばWを押しており、moveSpeed = 5の場合、

targetSpeed = Forward × 5

となる。

つまり、現在の速度を前方向へ5m/sまで上げることが目標となる。


入力がない場合、moveDirは0になるため、自然に、

targetSpeed = Vector3.zero;

となる。


つまり、入力がないこと自体が、目標速度0という状態になる。

Vector3.MoveTowardsで加速・減速を実装

現在の速度を目標速度へ少しずつ近づけるため、Vector3.MoveTowards()を使用した。

horizontalVelocity = Vector3.MoveTowards(horizontalVelocity, targetSpeed, accSpeed * Time.deltaTime);

構造としては、

現在値
horizontalVelocity

目標値
targetSpeed

1フレームあたりの最大変化量
accSpeed × deltaTime

となる。

例えば、

現在速度 = 0
目標速度 = 5
accSpeed = 20

であれば、1フレームごとに少しずつ、

0
→ 0.3
→ 0.6
→ ...
→ 5

と近づいていく。

逆に、Wから指を離すと、

targetSpeed = 0

となるため、

5
→ 4.7
→ 4.4
→ ...
→ 0

のように自然に減速する。

そのため、現在のコードでは減速専用の処理を別に用意する必要がない。

もし反対方向へ急加速したら?

ここで追加で確認したのが、Wを押している状態からすぐにSを押した場合の動作である。

例えば、現在速度が、

+5m/s

であり、そこでSを押すと、新しい目標は、

-5m/s

となる。

MoveTowards()は、すぐに-5へ瞬間移動するのではなく、

+5
+4
+3
+2
+1
 0
-1
-2
-3
-4
-5

のように変化していく。

そのため、自然に、

前進
→ 減速
→ 停止
→ 後退方向へ加速

という流れが作られる。

最終的な位置移動

最終的な位置移動には、moveDirtargetSpeedではなく、実際の現在速度であるhorizontalVelocityを使用する。

transform.position += horizontalVelocity * Time.deltaTime;

ここで、三つの変数の役割を分けて考えると理解しやすい。

moveDir
→ どこへ移動したいのか?(方向)

targetSpeed
→ その方向へ最終的にどれほど速く移動したいのか?(最大移動速度)

horizontalVelocity
→ 現在、実際にどれほど速く動いているのか?(現在移動速度)

この区別は、今後GravityやForced Movementを実装するときにも重要になるだろう。

今回の実装の最終コード

Ground Detectionへ進む前の段階では、主要なコードはおおよそ次のような状態となった。

using UnityEngine;
using System.Collections;
using UnityEngine.InputSystem;

public class PlayerMovement : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private Camera _camera; //메인 카메라
    [SerializeField] private float gravity = -10f;
    [SerializeField] private float accSpeed = 20f; //감가속 시간 조절용

    private Collider _collider;
    private CollisionDetector _collisionDetector;
    private GameInput _gameInput;

    private Vector3 horizontalVelocity;

    private void Awake()
    {
        _collider = GetComponent<Collider>();
        _collisionDetector = GetComponent<CollisionDetector>();
        _gameInput = new GameInput();
    }

    private void Update()
    {
       HorizontalMove();
    }

    void OnEnable()
    {
        _gameInput.Player.Enable();
    }

    void OnDisable()
    {
        _gameInput.Player.Disable();
    }

    private void HorizontalMove()
    {
        Vector2 inputVec = _gameInput.Player.Move.ReadValue<Vector2>();

        Vector3 cameraForward = new Vector3(_camera.transform.forward.x, 0f, _camera.transform.forward.z).normalized;
        Vector3 cameraRight = new Vector3(_camera.transform.right.x, 0f, _camera.transform.right.z).normalized;

        Vector3 moveDir = (cameraForward * inputVec.y + cameraRight * inputVec.x).normalized;
        Vector3 targetSpeed = moveDir * moveSpeed;

        horizontalVelocity = Vector3.MoveTowards(horizontalVelocity, targetSpeed, accSpeed * Time.deltaTime);

        transform.position += horizontalVelocity * Time.deltaTime;
    }



}

上のコードには、次の段階の実装に使用するColliderCollisionDetectorgravityなどのフィールドも含まれているが、基本的にはこのようなコードが完成した。

開発中に気づいたUnityの注意点

SerializeFieldの値とコード上の初期値の違い

途中で、accSpeedのコード上の初期値は、

[SerializeField] private float accSpeed = 20f;

となっていたが、TestSceneにすでにシリアライズされていた値は6だった。

Unityでは、[SerializeField]の値が一度Inspectorへ保存されると、コード側を、

20f

へ変更しても、既存のSceneやPrefabに保存されている値が自動で20へ変更されるわけではない。

そのため、実際の動作は、

コード上では20
Inspector上では6

→ 実際のゲームでは6を使用

となっていた。


これは今後、バランス調整などで各パラメータを変更するときにも注意する必要がありそうだ。


参考までに、おおよそ、

accSpeed = 20
0 → 5m/s ≈ 0.25秒

accSpeed = 6
0 → 5m/s ≈ 0.83秒

ほど操作感に差が生まれる。

Vector3の名前空間の衝突

C#で誤って、

using System.Numerics;

を追加すると、System.Numerics.Vector3UnityEngine.Vector3が同時に存在することになり、Vector3がどちらを指しているのか曖昧になる場合がある。

Unityの移動コードでは、

UnityEngine.Vector3

を使用するため、不要なSystem.Numericsは削除する形で整理した。

なぜTransformを直接動かしているのか?

ここには、少し矛盾して見える部分がある。

現在の『アスデイ』の長期開発計画では、複数のシステムが直接PlayerAvatarRoot.transformへ触れる構造を禁止し、一つのMovement Runtimeが移動を管理するように設計している。

しかし、現在のコードでは、

transform.position += ...

を使用している。

これは、現在の0.1初期段階における一時的な実装境界である。

最初から、

InputIntent
→ MovementCommand
→ CustomMovementRuntime
→ CollisionPhysicsBackend
→ MovementResult
→ PlayerAvatarRoot

という全体構造を一度に作るのではなく、まず実際の移動ルールを小さく実装して検証し、その後、機能が追加される段階で構造を分離することにした。

重要なのは、今の段階から移動コードを複数のシステムへ分散させないことである。

つまり、現在は、

PlayerMovement

Transform

という一つの経路だけを存在させ、

将来的には、

PlayerMovement / Input

Movement Runtime

Physics Backend

PlayerAvatarRoot

という構造へ移行する予定である。

今回の実装で感じたこと

最初は、キャラクターの移動を、

キーを押す
→ 方向を求める
→ その方向へ移動する

程度のものとして考えやすい。

しかし、加速・減速まで実装してみると、実際には、

入力
→ 意図した方向
→ 目標速度
→ 現在速度
→ 位置

という段階が存在することを実感した。

特に、

Vector3 moveDir;
Vector3 targetSpeed;
Vector3 horizontalVelocity;

という三つの変数を区別することが重要だった。

状況ごとの動きを作るためにifを追加し続けるのではなく、現在の状態と目標の状態を定め、その間を変化させる方式として考えることで、コードはかなり単純になった。

例えば、キーから指を離した場合、

if (키를 뗐으면)
{
    감속;
}

という処理を別に作るのではなく、

入力がない
→ moveDir = 0
→ targetSpeed = 0
→ 現在速度が0へ近づく

という形にする。

反対方向への入力も特別な例外ではなく、

目標速度が+5から-5へ変化する

という同じルールだけで処理される。


つまり、動作ごとに条件文を追加していくことよりも、状態と目標を定義することの重要性を感じることができた。

Git作業

基本移動の実装は、feat/player-controller機能ブランチで進めた。

途中で、機能実装とIDE・Editor設定の変更が一つのコミットに混ざってしまったことがあり、後から履歴を分離した。

最終的には、おおよそ、

set: Update Editor Settings
set: Ignore Rider Project Files
feat: Implement Basic Character Movement

のように、プロジェクト設定の変更と実際のゲームプレイ機能を別々のコミットへ分離した。

このとき、単純にコミットメッセージだけを変更したわけではない。最後のコミットを戻して変更内容をworking treeへ残した後、再び分割してコミットした。また、すでにリモートへPushされていたブランチだったため、--force-with-leaseを使用して安全に更新した。

このとき使用しているコミットルールやコミットタグなどは、『アスデイ』のプロジェクトページで定めたルールに従っている。

このルールによって、後から機能そのものだけでなく、その後の作業についても追跡しやすいようにしている。

結論

今回の作業で完成したものは、決して派手な機能ではない。

まだキャラクターは水平移動しかできず、地面すら認識できない。

しかし、今後実装する予定の、

Ground Detection
Gravity
Jump
Slope / Wall Collision
Dash
Forced Movement
Skill Movement
Knockback

は、すべて最終的にキャラクターの現在速度を、どのようなルールで変化させるのかという基盤の上に成り立つ。

そう考えると、今回の作業は単純なWASD移動の実装というよりも、『Astral Days!』のCustom Movement Foundationにおいて、初めて実際の「移動状態」を作り始めた段階だと言える。

次の作業では、このキャラクターが単純に空間を移動するだけではなく、今、地面の上に立っているのか?、そして立っている地面の傾斜はどの程度なのか? を判定するGround Detectionを実装する予定である。


0.1.0 - 1バージョン テスト動画

한동안 학업, 공모전 등으로 인해 아스데이의 개발을 진행하지 못하고 있었다. 그리고 드디어 일들이 얼추 끝나, 본격적인 아스데이의 개발에 돌입하기로 했다.


개발 계획에 따르면, 0.1 버전에서는 Foundation Systems를 구현할 계획이다. 이 중, 0.1.0에서는 Character Movement와 관련된 개발을 진행하고자 한다.

따라서 0.1.0 - 1에서는 Character Movement 중, 가장 기초적인 캐릭터의 WASD 수평 이동을 구현하고자 한다.

이동 방식의 선택

일반적인 3D 게임을 Unity로 개발하는 경우, Rigidbody를 이용하거나, CharacterController 컴포넌트를 이용하여 캐릭터 움직임을 구현하곤 한다.

하지만 아스데이가 추구하는 게임 플레이 스타일, 특히 회피 등으로 인해 방향 전환이 가파르며 가감속이 빈번하다는 특성을 고려하였을 때, 기존의 구현 방식은 적합하지 않다고 생각하였다.


Rigidbody를 이용하는 경우 물리 효과 구현에 있어서는 이점이 있겠지만, 아스데이의 비현실적이고 역동적인 움직임을 구현하는 데 한계가 있다.

또한 CharacterController를 이용하는 경우, 물리 효과 등에 있어 한계가 있다고 판단하였다.


따라서 직접 캐릭터의 이동 방식부터 구현하기로 결정하였다.

이번 작업의 목표

일단 0.1.0에서는 Foundation Systems 중 Character Movement 관련 내용을 개발할 계획이다.

그 중, 첫 번째 버전인 0.1.0 - 1에서는 다른 기능을 모두 제외하고, 가장 기본적인 수평 이동만 구현하는 것을 목표로 하였다.

이번 단계의 목표를 조금 더 구체화하면 다음과 같은 네 가지였다.


  • WASD 입력을 받을 수 있을 것
  • 월드 좌표가 아니라 카메라가 바라보는 방향을 기준으로 이동할 것
  • 대각선 이동에서 속도가 빨라지지 않을 것
  • 시작과 정지가 즉각적이지 않고 가속·감속될 것

추후 이 기반을 토대로, Ground Detection, Gravity, Jump, Dash, Forced Movement 등이 추가될 구조이기 때문에, 이번 단계에서는 그 기반이 되는 horizontalVelocity를 만드는 데 집중했다.

수평이동 구현

Input 입력의 방법

입력은 Unity의 새로운 Input System을 사용했다. 이는 추후 여러 기기에서 지원할 것을 고려하여 내린 선택이었다.

생성된 Input Action Wrapper인 GameInputPlayerMovement가 가지고 있도록 했다.

private GameInput _gameInput;

그리고 Awake()에서 생성한다.

private void Awake()
{
    _gameInput = new GameInput();
}

입력 맵은 컴포넌트가 활성화되어 있을 때만 활성화하도록 했다.

private void OnEnable()
{
    _gameInput.Player.Enable();
}

private void OnDisable()
{
    _gameInput.Player.Disable();
}

이후 Move Action에서 Vector2 값을 읽는다.

Vector2 inputVec = _gameInput.Player.Move.ReadValue<Vector2>();

WASD를 기준으로 보면 대략 다음과 같다.

W → ( 0,  1)
S → ( 0, -1)
A → (-1,  0)
D → ( 1,  0)

여기까지만 보면 아직 2차원 입력값일 뿐이다.

이걸 실제 게임의 3차원 이동 방향으로 바꿔야 한다.

왜 그냥 Vector3로 바꾸지 않았는가

가장 처음 생각할 수 있는 방식은 이런 방식이다.

Vector3 moveDir = new Vector3(inputVec.x, 0f, inputVec.y);

이렇게 하면 분명 WASD 이동은 된다.


그러나 문제는 이동 방향이 월드 좌표계에 고정된다는 것이다.

예를 들어 W가 항상 월드의 +Z라면 카메라를 90도 돌려도 W를 눌렀을 때 화면 기준 오른쪽으로 걸어가게 된다.


그러나 아스데이의 기획에 따르면, 캐릭터는 카메라를 기준으로 이동해야 하기 때문에 다음 관계가 필요했다.

W/S → Camera Forward
A/D → Camera Right

즉 플레이어 입장에서는 항상:

W = 화면 앞쪽
S = 화면 뒤쪽
A = 화면 왼쪽
D = 화면 오른쪽

으로 느껴져야 한다.

카메라 Forward와 Right 구하기

그래서 먼저 카메라 방향을 가져왔다.

[SerializeField] private Camera _camera;

카메라에는 forwardright가 이미 존재한다.

_camera.transform.forward
_camera.transform.right

그런데 카메라는 위아래로도 바라볼 수 있다.

예를 들어 카메라가 아래를 보고 있을 때 forward에는 Y축 값도 들어간다.

Camera Forward

(x, -0.5, z)

   Y축 포함

그 값을 그대로 사용하면 W를 누르는 것만으로 캐릭터가 지면 아래쪽으로 이동할 수도 있다.

따라서 Y값을 제거했다.

Vector3 cameraForward = new Vector3(_camera.transform.forward.x, 0f, _camera.transform.forward.z).normalized;
Vector3 cameraRight = new Vector3(_camera.transform.right.x, 0f, _camera.transform.right.z).normalized;

결과적으로 카메라가 어디를 바라보고 있든 XZ 평면상의 방향만 사용하게 된다.

WASD 입력과 카메라 방향 합치기

이제 입력값과 방향 벡터를 조합했다.

Vector3 moveDir =(cameraForward * inputVec.y + cameraRight * inputVec.x).normalized;

이 식은 처음 보면 조금 복잡하지만 실제 의미는 단순하다.


예를 들어 W를 누르면:

inputVec = (0, 1)

이므로:

cameraForward × 1 + cameraRight × 0 = cameraForward

W+D라면:

cameraForward + cameraRight

가 되어 두 방향 사이의 대각선으로 움직인다.

대각선 이동이 빨라지는 문제

여기서 .normalized가 중요했다.

정면과 오른쪽 벡터의 길이가 각각 1이라고 하면:

Forward = (0, 1)
Right   = (1, 0)

둘을 더한 대각선 벡터는:

(1, 1)

이고 크기는:

√(1² + 1²) = √2 ≈ 1.414

가 된다.

즉 그대로 이동시키면 W만 누를 때보다 W+D를 눌렀을 때 약 41% 빨라진다.

그래서:

(...).normalized

를 사용해 최종 moveDir의 길이를 다시 1로 맞췄다.

W      → speed 1
D      → speed 1
W + D  → speed 1

이렇게 방향과 속도를 분리한 셈이다.


다만, 구현하는 과정에서 알게 된 작은 주의점도 있다.

키보드에서는 문제가 없지만 게임패드 아날로그 스틱까지 생각하면 모든 입력을 바로 normalize할 경우:

스틱을 20%만 기울임
→ normalized
→ 100% 속도

가 되어버린다.

현재는 키보드·마우스가 개발 기준이므로 문제없지만, 나중에 패드 대응을 하게 되는 경우에는 입력 크기를 따로 보존해야 할 것 같다.

첫 기본 이동 코드

이동 방향을 만들고 나면 가장 단순한 이동은 다음과 같다.

transform.position += moveDir * moveSpeed * Time.deltaTime;

아스데의 기본 이동 속도는 기획안 상 5m/s이므로:

[SerializeField] private float moveSpeed = 5f;

로 시작했다.

Time.deltaTime을 곱한 이유는 프레임 수와 관계없이 초당 이동량을 일정하게 만들기 위해서다.

예를 들어:

moveSpeed = 5m/s

라면 60FPS에서도, 120FPS에서도 1초 동안 최종적으로 약 5m를 움직이는 것이 목표다.

그런데 움직임이 너무 즉각적이었다

여기서 첫 번째 체감 문제가 생겼다.

단순히:

transform.position += moveDir * moveSpeed * Time.deltaTime;

로 이동시키면 입력과 동시에:

0m/s

5m/s

가 된다.

키에서 손을 떼면:

5m/s

0m/s

가 된다.

기능적으로는 정확하지만, 실제 캐릭터 이동으로 보면 굉장히 기계적인 느낌이 난다.

그래서 현재 이동 속도를 별도의 상태로 보존하는 방식으로 변경했다.

private Vector3 horizontalVelocity;

여기서 개념적으로 중요한 변화가 있었다.


처음에는:

입력 → 위치 이동

이었다면 이제는:

입력

목표 속도 계산

현재 속도를 목표 속도로 변화

현재 속도를 이용해 위치 이동

이 된 것이다.

이 구조가 이후 이동 시스템의 기반이 됐다.

targetSpeed 변수 추가

먼저 입력으로부터 원하는 최종 속도를 만든다.

Vector3 targetSpeed = moveDir * moveSpeed;

예를 들어 W를 누르고 있고 moveSpeed = 5라면:

targetSpeed = Forward × 5

즉 목표는 현재 속도를 앞으로 5m/s까지 올리는 것이다.


입력이 없다면 moveDir이 0이므로, 자연스럽게

targetSpeed = Vector3.zero;

가 된다.


즉, 입력이 없다는 것 자체가 목표 속도 0이라는 상태가 된다.

Vector3.MoveTowards로 가속·감속 구현

현재 속도를 목표 속도 쪽으로 조금씩 이동시키기 위해 Vector3.MoveTowards()를 사용했다.

horizontalVelocity = Vector3.MoveTowards(horizontalVelocity, targetSpeed, accSpeed * Time.deltaTime);

구조는:

현재값
horizontalVelocity

목표값
targetSpeed

한 프레임 최대 변화량
accSpeed × deltaTime

이다.

예를 들어:

현재 속도 = 0
목표 속도 = 5
accSpeed = 20

이면 한 프레임마다 조금씩:

0
→ 0.3
→ 0.6
→ ...
→ 5

로 접근한다.

반대로 W에서 손을 떼면:

targetSpeed = 0

이 되므로:

5
→ 4.7
→ 4.4
→ ...
→ 0

처럼 자연스럽게 감속한다.

그래서 현재 코드에서는 별도의 감속을 위한 코드가 필요하지 않다.

만약 반대 방향으로 급가속을 한다면?

여기서 한 번 확인했던 게 W를 누르다가 바로 S를 눌렀을 때의 동작이었다.

예를 들어 현재 속도가:

+5m/s

이고 S를 누르면 새로운 목표는:

-5m/s

가 된다.

MoveTowards()는 곧바로 -5로 순간이동시키는 게 아니라:

+5
+4
+3
+2
+1
 0
-1
-2
-3
-4
-5

처럼 변화시킨다.

따라서 자연스럽게:

전진
→ 감속
→ 정지
→ 후진 가속

이 만들어진다.

최종 위치 이동

최종적으로 위치는 moveDir이나 targetSpeed가 아니라 실제 현재 속도horizontalVelocity를 사용한다.

transform.position += horizontalVelocity * Time.deltaTime;

여기서 세 변수의 역할을 구분하면 이해하기 쉽다.

moveDir
→ 어디로 가고 싶은가? (방향)

targetSpeed
→ 그 방향으로 최종적으로 얼마나 빠르게 가고 싶은가? (최고 이동 속도)

horizontalVelocity
→ 지금 실제로 얼마나 빠르게 움직이고 있는가? (현재 이동 속도)

이 구분이 이후 중력이나 강제 이동을 구현할 때도 꽤 중요하게 작용할 것이다.

이번 구현의 최종 코드

Ground Detection에 들어가기 전 기준으로 핵심 코드는 대략 이런 상태였다.

using UnityEngine;
using System.Collections;
using UnityEngine.InputSystem;

public class PlayerMovement : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private Camera _camera; //메인 카메라
    [SerializeField] private float gravity = -10f;
    [SerializeField] private float accSpeed = 20f; //감가속 시간 조절용

    private Collider _collider;
    private CollisionDetector _collisionDetector;
    private GameInput _gameInput;

    private Vector3 horizontalVelocity;

    private void Awake()
    {
        _collider = GetComponent<Collider>();
        _collisionDetector = GetComponent<CollisionDetector>();
        _gameInput = new GameInput();
    }

    private void Update()
    {
       HorizontalMove();
    }

    void OnEnable()
    {
        _gameInput.Player.Enable();
    }

    void OnDisable()
    {
        _gameInput.Player.Disable();
    }

    private void HorizontalMove()
    {
        Vector2 inputVec = _gameInput.Player.Move.ReadValue<Vector2>();

        Vector3 cameraForward = new Vector3(_camera.transform.forward.x, 0f, _camera.transform.forward.z).normalized;
        Vector3 cameraRight = new Vector3(_camera.transform.right.x, 0f, _camera.transform.right.z).normalized;

        Vector3 moveDir = (cameraForward * inputVec.y + cameraRight * inputVec.x).normalized;
        Vector3 targetSpeed = moveDir * moveSpeed;

        horizontalVelocity = Vector3.MoveTowards(horizontalVelocity, targetSpeed, accSpeed * Time.deltaTime);
        
        transform.position += horizontalVelocity * Time.deltaTime;
    }

        

}

위 코드에는 다음 단계 구현을 위한 Collider, CollisionDetector, gravity 같은 필드도 포함되어 있지만, 기본적으로는 위와 같은 코드가 완성되었다.

개발하면서 발견한 Unity 유의점

SerializeField 값과 코드 기본 값 차이

중간에 accSpeed의 코드 기본값은:

[SerializeField] private float accSpeed = 20f;

였는데 TestScene에 이미 직렬화되어 있던 값은 6이었다.

Unity에서는 [SerializeField] 값이 한 번 Inspector에 저장된 이후에는 코드를:

20f

로 바꿔도 기존 Scene/Prefab 값이 자동으로 20으로 바뀌지 않는다.

그래서 실제 동작은:

코드상 20
Inspector상 6

→ 실제 게임에서는 6 사용

이었다.


이건 앞으로 밸런스 값 조정할 때도 계속 조심해야 할 것같다.


참고로 대략:

accSpeed = 20
0 → 5m/s ≈ 0.25초

accSpeed = 6
0 → 5m/s ≈ 0.83초

정도로 체감이 달라진다.

Vector3 네임스페이스 충돌

C#에서 실수로:

using System.Numerics;

를 넣게 되면 System.Numerics.Vector3UnityEngine.Vector3가 동시에 존재해서 Vector3가 무엇인지 모호해질 수 있다.

Unity 이동 코드에서는:

UnityEngine.Vector3

를 사용하는 것이므로 불필요한 System.Numerics는 제거하는 방향으로 정리했다.

Transform을 직접 움직이는 이유?

여기서 약간 모순처럼 보이는 부분이 있다.

현재 아스데의 장기 개발 계획 상, 여러 시스템이 직접 PlayerAvatarRoot.transform을 건드리는 구조를 금지하고, Movement Runtime 하나가 이동을 소유하도록 설계되어 있다.

그런데 지금 코드는:

transform.position += ...

를 사용하고 있다.

이건 현재 0.1 초기 단계의 임시 구현 경계다.

처음부터:

InputIntent
→ MovementCommand
→ CustomMovementRuntime
→ CollisionPhysicsBackend
→ MovementResult
→ PlayerAvatarRoot

전체를 한 번에 만드는 대신, 우선 실제 이동 규칙을 작게 구현하고 검증한 다음 이후 기능이 추가될 때 구조를 분리하기로 했다.

중요한 건 지금부터 이동 코드를 여러 시스템에 흩뿌리지 않는 것이다.

즉 현재는:

PlayerMovement

Transform

이라는 하나의 경로만 존재하게 하고,

이후에는:

PlayerMovement / Input

Movement Runtime

Physics Backend

PlayerAvatarRoot

으로 옮기는 계획이다.

이번 구현에서 느낀점

처음에는 이동을:

키를 누른다
→ 방향을 구한다
→ 그 방향으로 움직인다

정도로 생각하기 쉽다.

그런데 가속·감속까지 넣으면서 실제로는:

입력
→ 의도한 방향
→ 목표 속도
→ 현재 속도
→ 위치

라는 단계가 있다는 걸 체감했다.

특히:

Vector3 moveDir;
Vector3 targetSpeed;
Vector3 horizontalVelocity;

세 변수를 구별하는 게 중요했다.

if를 계속 추가해서 상황별 움직임을 만드는 게 아니라, 현재 상태와 목표 상태를 정하고 그 사이를 변화시키는 방식으로 생각하면 코드가 훨씬 단순해졌다.

예를 들어 키를 떼었을 때:

if (키를 뗐으면)
{
    감속;
}

을 따로 만드는 것이 아니라,

입력이 없음
→ moveDir = 0
→ targetSpeed = 0
→ 현재 속도가 0으로 접근

하게 만든다.

반대 방향 역시 별도의 예외가 아니라:

목표 속도가 +5에서 -5로 바뀜

이라는 동일한 규칙 하나로 처리된다.


즉, 매 번 행동 별로 조건문을 다는 것보다, 상태와 목표를 정의하는 것의 중요성을 느낄 수 있었다.

Git 작업

기본 이동 구현은 feat/player-controller 기능 브랜치에서 진행했다.

중간에 기능 구현과 IDE/Editor 설정 변경이 한 커밋에 섞인 적이 있어서, 나중에 히스토리를 다시 분리했다.

최종적으로는 대략:

set: Update Editor Settings
set: Ignore Rider Project Files
feat: Implement Basic Character Movement

처럼 프로젝트 설정 변경과 실제 플레이 기능을 별도 커밋으로 분리했다.

이때 단순히 커밋 메시지만 바꾸는 게 아니라 마지막 커밋을 되돌려 변경사항을 working tree에 남긴 뒤 다시 나눠 커밋하고, 이미 원격에 올라간 브랜치였기 때문에 --force-with-lease로 안전하게 갱신했다.

이 때의 커밋 규칙 및 커밋 태그 등은 아스데이 프로젝트 페이지의 규칙을 따라 진행하고 있다.

이 규칙을 통해 나중에라도 기능 자체뿐 아니라 이후 작업을 추적하기 쉽도록 하였다.

결론

이번 작업으로 완성한 건 화려한 기능은 아니다.

아직 캐릭터는 수평 이동만 할 수 있고, 바닥조차 인식하지 못한다.

하지만 앞으로 구현하게 될:

Ground Detection
Gravity
Jump
Slope / Wall Collision
Dash
Forced Movement
Skill Movement
Knockback

모두 결국 캐릭터의 현재 속도를 어떤 규칙으로 변화시킬 것인가라는 기반 위에 올라간다.

그런 의미에서 이번 작업은 단순 WASD 구현보다는, Astral Days!의 Custom Movement Foundation에서 처음으로 실제 이동 상태를 만들기 시작한 단계라고 볼 수 있다.

다음 작업에서는 이 캐릭터가 단순히 공간을 움직이는 것을 넘어, 지금 바닥 위에 서 있는가?, 서 있는 바닥의 기울기는 어떤가를 판단하는 Ground Detection을 구현할 것이다.


0.1.0 - 1 버전 테스트 영상

RELATED RECORDS

同じプロジェクト、または近いテーマの記録です。