100点満点中90点位の移動システムと接地判定を統合、VerUpして安定性の増した『真・Character Controller2D』!
- 斜面対応(45度まで確認)。
- ジャンプは高さ(ユニット単位)で指定可能。
- 移動床に対応。
画像素材は、無料アセットの[Ansimuz] Sunny Landを使用。
- 関連記事: 『真・CharacterController (タッチ移動式)』
(ボタンUIへのタッチ無視も追加!) - 関連記事: 『特定タイルを移動床に変換&移動床自体』
- 2022/05/09: ピボット(原点)について説明するのを完全に忘れていたので追加。また、足元へ原点を合わせたパターンにコードを変更。
- 2023/01/25: 安定性の向上と、移動床に対応。記事を分かり易く清書。
サンプル動画
坂道や、移動床等を一通りチェックしたサンプル動画。
(音声なし)
設定方法
Playerサイド
当たり判定等を調整。
- 空のオブジェクトを作成、「Player」と命名。
- Rigidbody 2Dをアタッチ。
- Constraints -> Freeze Rotation -> Z: 有効化
- Collision Detection: Continuous
- 子の位置に、Spriteを作成して、キャラクター画像を設定。
- キャラ画像の位置を調整し、原点の位置を足元に合わせる(後述)。
- Capsule Collider 2Dをアタッチして、サイズや位置を調整。
- 新規スクリプト「Player」を作成し、コードを全文コピペ。
- 「Player」に「Player」スクリプトをアタッチ。
- インスペクターから各種コンポーネントを紐付けしておく。
【重要】プレイヤーのピボット(原点)の位置の調整
ピボットの位置を足元に合わせる。
- 左上のメニューバー下のアイコン群 -> オブジェクト移動モードに切り替え。
- 同アイコン群 -> Pivot表示モードに切り替え。
(Pivotと表示されている時 = Pivot表示モード) - ヒエラルキー -> Playerを選択し、Sceneビューでピボット(原点)を確認。
- ヒエラルキー -> 子のキャラ画像オブジェクトを選択。
- 原点の位置が足元になるように、子のキャラ画像のlocalPositionをズラす。
地面サイド
当たり判定を設定して、判定用にタグとレイヤーを変更。
- (Tilemapの場合) Tilemapへ、Tilemap Collider 2Dをアタッチ。
- (Spriteの場合) Polygon Collider 2D等をアタッチ。
- 「Ground」と命名したタグとレイヤーを設定しておく。
【コード】Player
詳細はコード内にコメントとして残しております。
//使わなかった為、一応コメントアウト。これをメインのスクリプトとして機能を追加していく場合は戻しておいてください。
//using System.Collections;
//using System.Collections.Generic;
using UnityEngine;
public class Player : MonoBehaviour
{
//各コンポーネントをキャッシュしておく(インスペクターから紐付けしておけばAwake内の取得部分を消しても良い)。
public Transform tf;
[SerializeField]
Rigidbody2D rb2d;
//【任意の値】移動力。蓄積しないので、最大速度はmoveSpeed * Time.fixedDeltaTime(0.02)になる(つまり、この設定では0.2)。
float moveSpeed = 10.0f;
//【任意の値】横方向への移動速度の上限(Time.fixedDeltaTimeが掛かった後の値)。
static readonly float HorizontalMoveSpeedLimit = 1.0f;
//【任意の値】(ジャンプを除く)縦方向への移動速度の上限(Time.fixedDeltaTimeが掛かった後の値)。
static readonly float VerticalMoveSpeedLimit = 1.0f;
//【任意の値】horizontalInputValueの加算率。
float accelerateRate = 0.1f;
//【任意の値】horizontalInputValueの減衰率。
float decayRate = 0.2f;
//【要調整】接地判定をプレイヤーの原点からズラす値(原点が足元の場合)。
Vector2 offset = new Vector2(0, Radius);
//【要調整】接地判定の円の半径。プレイヤーのCollider(の幅の半分)より一回り小さい値(壁を床と判定しない為)。
static readonly float Radius = 0.2f;
//【任意の値】ジャンプの高さ(ユニット単位)。
static readonly float JumpHeight = 3.0f;
static readonly float HighJumpHeight = 5.0f;
//【任意の値】歩行可能な傾斜の制限(度単位)。歩行不可能な角度の坂は重力によって滑り落ちる。
float slopeAngleLimit = 45.0f;
//接地判定の衝突結果が入る。OverlapCircleNonAllocでの使用なので、宣言時の初期化が必須。
Collider2D[] results = new Collider2D[1];
//プレイヤーColliderの衝突結果が入る。要素数は一応大きめに設定。宣言時の初期化が必須。
ContactPoint2D[] groundContacts = new ContactPoint2D[5];
//地面のレイヤーマスク。[SerializeField]を設定して、インスペクターからレイヤーを指定しても良い。
LayerMask groundLayerMask;
//地面のコンタクトフィルター(レイヤーマスクと大体同じ)。[SerializeField]を設定して、インスペクターからレイヤーを指定しても良い。
ContactFilter2D groundContactFilter;
//地面のタグ。
static readonly string GroundTag = "Ground";
//メインの接地フラグ(falseで空中)。
bool isGrounded;
//Colliderが地面に接触しているかのフラグ(キャラクターが地面からoffsetの分浮くのを防ぐ)。
bool isHitGround;
//ジャンプが発生したフレームに接地判定をしない為のフラグ。
bool isJumpHappened;
//プレイヤーが左を向いた時の角度(使用する画像によっては左右逆かも)。
Quaternion leftRotation = Quaternion.Euler(0, 180, 0);
//プレイヤーが右を向いた時の角度。
Quaternion rightRotation = Quaternion.Euler(0, 0, 0);
//Rigidbodyのvelocity相当の値。
Vector2 velocity;
//移動(左右)の入力度合い。
float horizontalInputValue = 0;
//縦方向への移動量(ジャンプと重力)。
float verticalMovement = 0;
//ジャンプの初期加速度。平方根の計算は負荷が掛かるので、ジャンプ毎に計算せず、宣言時に結果を保持しておく。
//複数で出てくる敵キャラ等で使う場合は、GameManagerとかに出しておくべし。電卓で計算して定数化しておいても良いかも。
float jumpInitialVelocity = Mathf.Sqrt(2 * DefaultGravityAbs * JumpHeight);
float highJumpInitialVelocity = Mathf.Sqrt(2 * DefaultGravityAbs * HighJumpHeight);
//Physics2D.gravity.yを使うと宣言時に計算出来ないので、正数にして定数化。
static readonly float DefaultGravityAbs = 9.81f;
//重力適用のタイミング故か誤差が出るので、若干補正。
float jumpVelocityModifier = DefaultGravityAbs * 0.01f;
//地面とのノーマル(垂直なベクトル)。
Vector2 groundNormal;
//地面とのノーマル(垂直なベクトル)を90度回転させたベクトル、斜面での進行方向。
Vector2 projectOnPlane;
//(プレイヤーColliderとの)衝突数。
int contactCount;
//その他、細々とした物。
Vector2 previousPosition;
Vector2 currentPosition;
Vector2 nextMovement;
Vector2 tempNormal;
//乗っている移動床の移動速度。
[System.NonSerialized]
public Vector2 platformVelocity;
Vector2 previousNormal;
//上部に当たった床を天井と判定する角度。
static readonly float CeilingAngleThreshold = 45.0f;
//横に当たった床を壁と判定する角度。
static readonly float WallAngleThreshold = 45.0f;
//一応、Time.fixedDeltaTimeを定数化。
static readonly float FixedDeltaTime = 0.02f;
//【PC用】
//頻繁に使うStringは定数化しておくべし。
/*
static readonly string MoveKey = "Horizontal";
static readonly string JumpKey = "z";
*/
void Awake()
{
tf = transform;
rb2d = GetComponent<Rigidbody2D>();
//地面のレイヤーを設定。
groundLayerMask = 1 << LayerMask.NameToLayer(GroundTag);
groundContactFilter.SetLayerMask(1 << LayerMask.NameToLayer(GroundTag));
}
//【PC用】ジャンプの入力取得。
/*
void Update()
{
if (Input.GetKeyDown(JumpKey)) {
Jump();
}
}
*/
void FixedUpdate()
{
//【PC用】左右への移動入力の取得。上書きする為、コレを有効にするとスマホ用の方は機能しなくなるので注意。
// horizontalInputValue = Input.GetAxis(MoveKey);
//重力を先に適用し、若干めり込ます事で、移動床での挙動が安定。
//空中ならば重力を加算し、地面とのノーマルをリセット。地上ならば縦方向への移動量をリセット。
if (!isGrounded || !isHitGround) {
//重力の加算は移動量の適用より前に実行。
verticalMovement += Physics2D.gravity.y * FixedDeltaTime;
groundNormal = Vector2.up;
} else if (!isJumpHappened) {
verticalMovement = 0;
}
if (!isJumpHappened) {
//Colliderが衝突しているかのチェック(OnCollisionEnter2DとOnCollisionStay2Dを合わせたような感じ)。
contactCount = rb2d.GetContacts(groundContactFilter, groundContacts);
if (0 < contactCount) {
isHitGround = false;
groundNormal = Vector2.up;
//タイルマップのような複数の正方形Colliderで出来た地面上でガタつくのを補正。
if (contactCount == 1) {
tempNormal = groundContacts[0].normal;
//(歩行可能な角度の)地上な場合、地面とのノーマル(垂直なベクトル)を保持しておく。
if (Vector2.Angle(Vector2.up, tempNormal) <= slopeAngleLimit) {
isHitGround = true;
groundNormal = tempNormal;
previousNormal = tempNormal;
} else if (0 < verticalMovement && Vector2.Angle(Vector2.down, tempNormal) <= CeilingAngleThreshold) {
//ジャンプ中に天井へ頭をぶつけた時。
verticalMovement = 0;
} else if (0 < horizontalInputValue && Vector2.Angle(Vector2.left, tempNormal) <= WallAngleThreshold) {
//右への移動中に壁にぶつかった時。
horizontalInputValue = 0;
} else if (horizontalInputValue < 0 && Vector2.Angle(Vector2.right, tempNormal) <= WallAngleThreshold) {
//左への移動中に壁にぶつかった時。
horizontalInputValue = 0;
}
} else {
for (int i = 0; i < contactCount; i++) {
tempNormal = groundContacts[i].normal;
//(歩行可能な角度の)地上な場合、地面とのノーマル(垂直なベクトル)を保持しておく。
if ((previousNormal == Vector2.zero || tempNormal == previousNormal) && Vector2.Angle(Vector2.up, tempNormal) <= slopeAngleLimit) {
isHitGround = true;
groundNormal = tempNormal;
previousNormal = tempNormal;
break;
} else if (0 < verticalMovement && Vector2.Angle(Vector2.down, tempNormal) <= CeilingAngleThreshold) {
//ジャンプ中に天井へ頭をぶつけた時。
verticalMovement = 0;
} else if (0 < horizontalInputValue && Vector2.Angle(Vector2.left, tempNormal) <= WallAngleThreshold) {
//右への移動中に壁にぶつかった時。
horizontalInputValue = 0;
} else if (horizontalInputValue < 0 && Vector2.Angle(Vector2.right, tempNormal) <= WallAngleThreshold) {
//左への移動中に壁にぶつかった時。
horizontalInputValue = 0;
}
}
}
} else {
previousNormal = Vector2.zero;
}
//指定位置を中心として、円形に衝突判定(ゴミが出ない版)。Groundとの接触判定が1つでもあれば、isGroundedをtrueに。
//GetContactsでは足元だけの判定が出来ないので、これで二重にチェック。
isGrounded = 0 < Physics2D.OverlapCircleNonAlloc(rb2d.position + offset, Radius, results, groundLayerMask);
} else {
isJumpHappened = false;
}
//地面が平らな場合は通常通りに移動。傾いている場合は、坂道に沿ったベクトルに変える。
if (groundNormal == Vector2.up) {
//Clampでスピード制限。
nextMovement = Vector2.right * Mathf.Clamp(horizontalInputValue * moveSpeed * FixedDeltaTime, -HorizontalMoveSpeedLimit, HorizontalMoveSpeedLimit);
} else {
//地面とのノーマル(垂直なベクトル)を90度傾けた方向に向かって、横の移動を適用。
projectOnPlane.Set(groundNormal.y, -groundNormal.x);
nextMovement = projectOnPlane * Mathf.Clamp(horizontalInputValue * moveSpeed * FixedDeltaTime, -HorizontalMoveSpeedLimit, HorizontalMoveSpeedLimit);
}
nextMovement.y += verticalMovement * FixedDeltaTime;
nextMovement.y = Mathf.Clamp(nextMovement.y, -VerticalMoveSpeedLimit, VerticalMoveSpeedLimit);
previousPosition = rb2d.position;
currentPosition = rb2d.position + nextMovement;
velocity = currentPosition - previousPosition;
if (platformVelocity == Vector2.zero) {
rb2d.position = currentPosition;
} else {
rb2d.position = currentPosition + platformVelocity;
platformVelocity = Vector2.zero;
}
nextMovement = Vector2.zero;
//Xの移動量が0の場合は今の向きのまま、移動量があれば向きを合わせる(本体の向きを変えたくないなら、spriteRenderer.flipXを切り替えても良い)。
if (velocity.x != 0) {
if (velocity.x < 0) {
tf.rotation = leftRotation;
} else {
tf.rotation = rightRotation;
}
}
//X方向の入力値の減衰。
horizontalInputValue = Mathf.Lerp(horizontalInputValue, 0, decayRate);
}
//【スマホ用】左移動ボタン(UI)にリスナー登録しておく。
public void MoveLeft()
{
horizontalInputValue = Mathf.Lerp(horizontalInputValue, -1.0f, accelerateRate);
}
//【スマホ用】右移動ボタン(UI)にリスナー登録しておく。
public void MoveRight()
{
horizontalInputValue = Mathf.Lerp(horizontalInputValue, 1.0f, accelerateRate);
}
//【共通】スマホ時には、ジャンプボタン(UI)にリスナー登録しておく。
public void Jump()
{
if (!isGrounded)
return;
isGrounded = false;
isHitGround = false;
isJumpHappened = true;
//JumpHeight = 1で実行すると、Y = 0.9996482に到達する精度のジャンプ(バラツキあり)。
verticalMovement = jumpInitialVelocity + jumpVelocityModifier;
}
}
既知のバグ
- 坂道の角部分では斜めのノーマルが取れないので、普通に横の移動になる。
- 移動床に下からギリギリのジャンプで飛び乗ると、若干浮いて移動床の動きと連動しなくなる。
(移動床にトリガーモードの当たり判定を足せば対応可能)
2D Tips
プレイヤー画像のサイズを修正
- 対象の画像 -> インスペクター -> Pixel Per Unit: 大体の画像サイズ(32等)に変更。
スマホでの移動ボタン(UI)押しっ放しの判定
通常のボタン(UI)だと、押しっぱなしがチェック出来ないので、一工夫いる。
詳細は以下。
[Ansimuz] Sunny Land使用時の注意
坂道のチップへTilemap Colliderを適用しても綺麗な斜面にならない事に注意。
(ドットが荒いのが原因?)