概要
ゲーム「フォートナイト」の重要な要素である"建築"を、完全にではありませんがUnityで再現してみました。再現したのは壁・床・階段の建築のみで、編集は再現していません。この記事では、プレイヤーを三人称視点で見ながらXboxコントローラーで操作していく前提で話を進めていきます(Xboxコントローラーを持っていない方も、実装で参考になるところはあると思うので読んでいただけたら嬉しいです)。
結果
[こちら](https://unityroom.com/games/resemble_fortnite)のサイトで実際にプレイヤーを動かすことができます。良ければ遊んでみてください!"Unityでフォートナイトの建築を再現してみる"の進捗です#Unity #Fortnite pic.twitter.com/27e4eTidiL
— みこし (@Mikoshi_prog) October 27, 2019
方法
シーンに置いているのは、プレイヤー・カメラ・光源・地面のみです。また、シーンには置いてありませんが、プレハブとして建築の材料である壁・床・階段を作ってあります。
プレイヤー
プレイヤーにはユニティちゃんを使いました。ユニティちゃんにつけているコンポーネントは以下になります。コンポーネント | 変更点 |
---|---|
Animator | Controller: ThirdPersonController Avatar: unitychanAvatar |
Rigidbody | |
Capsule Collider | Center: (0, 0.8, 0), Radius: 0.3, Height: 1.61 |
ThirdPersonUserControl.cs | |
ThirdPersonCharacter.cs | Jump Power: 7, Ground Check Distance: 0.3 |
ObjectOfCamera.cs |
です。変更点に何も書いていないコンポーネントはデフォルトの値のままでOKです。このうち、unitychanAvatarはこちらから、ThirdPersonUserControl.csとThirdPersonCharacter.csはこちらからダウンロードする必要があります。
また、ObjectCamera.csは以下のスクリプトになります。これは以前どこかのサイトから引用させていただいたものなのですが、どのサイトから引用したか忘れました。申し訳ありません。
using UnityEngine;
public class ObjectOfCamera : MonoBehaviour {
float inputHorizontal;
float inputVertical;
Rigidbody rb;
float moveSpeed = 3f;
void Start() {
rb = GetComponent<Rigidbody>();
}
void Update() {
inputHorizontal = Input.GetAxisRaw("Horizontal");
inputVertical = Input.GetAxisRaw("Vertical");
}
void FixedUpdate() {
// カメラの方向から、X-Z平面の単位ベクトルを取得
Vector3 cameraForward = Vector3.Scale(Camera.main.transform.forward, new Vector3(1, 0, 1)).normalized;
// 方向キーの入力値とカメラの向きから、移動方向を決定
Vector3 moveForward = cameraForward * inputVertical + Camera.main.transform.right * inputHorizontal;
// 移動方向にスピードを掛ける。ジャンプや落下がある場合は、別途Y軸方向の速度ベクトルを足す。
rb.velocity = moveForward * moveSpeed + new Vector3(0, rb.velocity.y, 0);
// キャラクターの向きを進行方向に
if (moveForward != Vector3.zero) {
transform.rotation = Quaternion.LookRotation(moveForward);
}
}
}
建築物
建築物は壁(Wall)・床(Floor)・階段(Stair)の3種類作りました。3つの建築はすべて3D ObjectのPlaneから作っており、さらに"Brown"という茶色のマテリアルで色付けしてあります。各建築のScaleは以下のようになっています。建築 | Scale |
---|---|
Floor | (0.6, 1, 0.6) |
Stair | (0.721, 1, 0.6) |
Wall | (0.4, 0.4, 0.6) |
Stairは斜めに設置される建築なので、Scaleの値が中途半端になっています。PositionとRotationの値は後からスクリプトで定めるので、今はデフォルト値のままでOKです。
カメラ
Main Cameraにはデフォルトでついているコンポーネントに加えてTPSCamera.csとBuild.csというスクリプトを付ける必要があります。TPSCamera.csは以下になります。using UnityEngine;
public class TPScamera : MonoBehaviour {
public GameObject targetObj;
public float rotatespeed, playerHeight;
private Vector3 targetPos;
private void Start() {
playerHeight = 1.3f;
targetPos = targetObj.transform.position;
}
private void Update() {
// targetの移動量分、自分(カメラ)も移動する
transform.position += targetObj.transform.position - targetPos;
targetPos = targetObj.transform.position;
// マウスの移動量
float mouseInputX = Input.GetAxis("R_Stick_H");
float mouseInputY = Input.GetAxis("R_Stick_V");
// targetの位置のY軸を中心に、回転(公転)する
transform.RotateAround(targetPos, Vector3.up, mouseInputX * Time.deltaTime * rotatespeed);
// カメラの垂直移動(※角度制限なし、必要が無ければコメントアウト)
transform.RotateAround(targetPos, transform.right, mouseInputY * Time.deltaTime * rotatespeed);
/*RTで拡大、LTで縮小*/
float L_Trigger = Input.GetAxis("L_Trigger");
float R_Trigger = Input.GetAxis("R_Trigger");
Vector3 distance_between_camera_and_target = targetObj.transform.position + new Vector3(0f, playerHeight, 0f) - transform.position;
if (R_Trigger > 0) {
transform.position += distance_between_camera_and_target / 50;
}
if (L_Trigger > 0) {
transform.position += -distance_between_camera_and_target / 50;
}
}
}
TPSCamera.csもほぼ引用させていただいたものなのですが、引用先のページを忘れてしまいました。申し訳ありません。TPSCamera.csをカメラにアタッチすると、Xboxコントローラーの右スティックでカメラの角度の変更、LT, RTボタンでシーンの拡大、縮小ができます。
続いてBuild.csです。
using UnityEngine;
public class Build : MonoBehaviour {
public GameObject floor, wall, stair, player;
private float xLength, yLength, zLength; //1つの直方体の各辺の長さ
private float bigger_x, smaller_x, bigger_y, smaller_y, bigger_z, smaller_z;
private Vector3[] points = new Vector3[24];
private void Start() {
xLength = 6f; yLength = 4f; zLength = 6f;
}
private void Update() {
Craft();
}
//24個の頂点の座標を求める
private void Calculate24Coordinates() {
Vector3 playerPos = player.transform.position;
float x_rest = playerPos.x % xLength;
if (playerPos.x < 0) {
bigger_x = playerPos.x - x_rest;
smaller_x = bigger_x - xLength;
}
else smaller_x = playerPos.x - x_rest;
float y_rest = playerPos.y % yLength;
if (playerPos.y < 0) {
bigger_y = playerPos.y - y_rest;
smaller_y = bigger_y - yLength;
}
else smaller_y = playerPos.y - y_rest;
float z_rest = playerPos.z % zLength;
if (playerPos.z < 0) {
bigger_z = playerPos.z - z_rest;
smaller_z = bigger_z - zLength;
}
else smaller_z = playerPos.z - z_rest;
points[0] = new Vector3(smaller_x, smaller_y, smaller_z);
points[1] = points[0] + new Vector3(xLength, 0f, 0f);
points[2] = points[1] + new Vector3(0f, 0f, zLength);
points[3] = points[0] + new Vector3(0f, 0f, zLength);
points[4] = points[0] - new Vector3(0f, 0f, zLength);
points[5] = points[4] + new Vector3(xLength, 0f, 0f);
points[6] = points[1] + new Vector3(xLength, 0f, 0f);
points[7] = points[2] + new Vector3(xLength, 0f, 0f);
points[8] = points[2] + new Vector3(0f, 0f, zLength);
points[9] = points[3] + new Vector3(0f, 0f, zLength);
points[10] = points[3] - new Vector3(xLength, 0f, 0f);
points[11] = points[0] - new Vector3(xLength, 0f, 0f);
for (int i = 12; i < 24; i++) points[i] = points[i - 12] + new Vector3(0f, yLength, 0f);
}
//このメソッドでは、vec1,vec2,vec3の3点を通る平面の法線ベクトルが求められる
private Vector3 CalculateOuterProduct(Vector3 vec1, Vector3 vec2, Vector3 vec3) {
Vector3 tmp1 = vec1 - vec2;
Vector3 tmp2 = vec1 - vec3;
return Vector3.Cross(tmp1, tmp2); //Vector3.Crossは外積を求めるメソッド
}
//このメソッドは、vec1,vec2,vec3の3点を通る平面の方程式ax+by+cz+d=0のa,b,c,dを配列で返す
private float[] CalculateEquationOfPlane(Vector3 vec1, Vector3 vec2, Vector3 vec3, Vector3 normal) {
float[] ans = new float[]{
normal.x,
normal.y,
normal.z,
-normal.x * vec1.x - normal.y * vec1.y - normal.z * vec1.z
};
return ans;
}
//このメソッドでは、カメラの視線とメッシュとの交点の座標が求められる
private Vector3 CalculateCoordinateOfIntersection(float[] plane, Vector3 angle, Vector3 position) {
float parameter = -(plane[0] * position.x + plane[1] * position.y + plane[2] * position.z + plane[3]) / (plane[0] * angle.x + plane[1] * angle.y + plane[2] * angle.z);
float x = angle.x * parameter + position.x;
float y = angle.y * parameter + position.y;
float z = angle.z * parameter + position.z;
return new Vector3(x, y, z);
}
//建てたい建築がカメラの正面にあるときにだけ建築できるようにする
private bool WhetherParameterIsPositive(float[] plane, Vector3 angle, Vector3 position) {
float parameter = -(plane[0] * position.x + plane[1] * position.y + plane[2] * position.z + plane[3]) / (plane[0] * angle.x + plane[1] * angle.y + plane[2] * angle.z);
return parameter > 0;
}
//カメラとプレイヤーとの間に建築されないようにする
private bool WhetherDistanceIsProper(Vector3 vec0, Vector3 vec1, Vector3 vec2, Vector3 vec3) {
Vector3 fromCameraToPlayer = player.transform.position - gameObject.transform.position;
Vector3 fromCameraToBuilding = (vec0 + vec1 + vec2 + vec3) / 4 - gameObject.transform.position;
return fromCameraToBuilding.magnitude > fromCameraToPlayer.magnitude;
}
//このメソッドは引用させていただきました
private bool WhetherIntersectionIsInsidePolygon(Vector3[] vertices, Vector3 intersection, Vector3 normal) {
float angle_sum = 0f;
for (int i = 0; i < vertices.Length; i++) {
Vector3 tmp1 = vertices[i] - intersection;
Vector3 tmp2 = vertices[(i + 1) % vertices.Length] - intersection;
float angle = Vector3.Angle(tmp1, tmp2);
Vector3 cross = Vector3.Cross(tmp1, tmp2);
if (Vector3.Dot(cross, normal) < 0) angle *= -1;
angle_sum += angle;
}
angle_sum /= 360f;
return Mathf.Abs(angle_sum) >= 0.1f;
}
//壁と階段が建築できる条件
private bool CanBuildWallAndStair(Vector3 vertex0, Vector3 vertex1, Vector3 vertex2, Vector3 vertex3) {
Vector3[] vertices = new Vector3[] { vertex0, vertex1, vertex2, vertex3 };
Vector3 normal = CalculateOuterProduct(vertices[0], vertices[1], vertices[2]);
float[] abcd = CalculateEquationOfPlane(vertices[0], vertices[1], vertices[2], normal);
Vector3 intersection = CalculateCoordinateOfIntersection(abcd, gameObject.transform.rotation * Vector3.forward, gameObject.transform.position);
return WhetherIntersectionIsInsidePolygon(vertices, intersection, normal) &&
WhetherDistanceIsProper(vertex0, vertex1, vertex2, vertex3) &&
WhetherParameterIsPositive(abcd, gameObject.transform.rotation * Vector3.forward, gameObject.transform.position);
}
//床が建築できる条件
private bool CanBuildFloor(Vector3 vec0, Vector3 vec1, Vector3 vec2, Vector3 vec3) {
Vector3 target = (vec0 + vec1 + vec2 + vec3) / 4;
Vector3 targetToCameraDirection = (target - gameObject.transform.position).normalized;
if (Vector3.Dot(targetToCameraDirection, gameObject.transform.forward.normalized) > 0.97) return true;
else return false;
}
private void Craft() {
//床の建築(RB)
if (Input.GetKeyDown("joystick button 5")) {
Calculate24Coordinates();
//カメラが上の方を向いているか、下の方を向いているかを取得する
float x = gameObject.transform.localEulerAngles.x;
/*下の床*/
if (Mathf.Abs(x - 90) < 90f) {
Instantiate(floor, (points[0] + points[1] + points[2] + points[3]) / 4, Quaternion.identity);
if (CanBuildFloor(points[0], points[1], points[2], points[3])) Instantiate(floor, (points[0] + points[1] + points[2] + points[3]) / 4, Quaternion.identity);
else if (CanBuildFloor(points[0], points[1], points[5], points[4])) Instantiate(floor, (points[0] + points[1] + points[5] + points[4]) / 4, Quaternion.identity);
else if (CanBuildFloor(points[1], points[2], points[7], points[6])) Instantiate(floor, (points[1] + points[2] + points[7] + points[6]) / 4, Quaternion.identity);
else if (CanBuildFloor(points[2], points[3], points[9], points[8])) Instantiate(floor, (points[2] + points[3] + points[9] + points[8]) / 4, Quaternion.identity);
else if (CanBuildFloor(points[0], points[3], points[10], points[11])) Instantiate(floor, (points[0] + points[3] + points[10] + points[11]) / 4, Quaternion.identity);
}
/*上の床*/
else if (Mathf.Abs(x - 270) < 90f) {
Instantiate(floor, (points[12] + points[13] + points[14] + points[15]) / 4, Quaternion.identity);
if (CanBuildFloor(points[12], points[13], points[14], points[15])) Instantiate(floor, (points[12] + points[13] + points[14] + points[15]) / 4, Quaternion.identity);
else if (CanBuildFloor(points[12], points[13], points[17], points[16])) Instantiate(floor, (points[12] + points[13] + points[17] + points[16]) / 4, Quaternion.identity);
else if (CanBuildFloor(points[13], points[14], points[19], points[18])) Instantiate(floor, (points[13] + points[14] + points[19] + points[18]) / 4, Quaternion.identity);
else if (CanBuildFloor(points[14], points[15], points[21], points[20])) Instantiate(floor, (points[14] + points[15] + points[21] + points[20]) / 4, Quaternion.identity);
else if (CanBuildFloor(points[12], points[15], points[22], points[23])) Instantiate(floor, (points[12] + points[15] + points[22] + points[23]) / 4, Quaternion.identity);
}
}
//壁の建築(LB)
else if (Input.GetKeyDown("joystick button 4")) {
Calculate24Coordinates();
//pointsの数字によって壁を回転させなければならない
if (CanBuildWallAndStair(points[0], points[3], points[15], points[12])) Instantiate(wall, (points[0] + points[3] + points[15] + points[12]) / 4, Quaternion.Euler(0f, 0f, 90f));
else if (CanBuildWallAndStair(points[1], points[2], points[14], points[13])) Instantiate(wall, (points[1] + points[2] + points[14] + points[13]) / 4, Quaternion.Euler(0f, 0f, 90f));
else if (CanBuildWallAndStair(points[0], points[1], points[13], points[12])) Instantiate(wall, (points[0] + points[1] + points[13] + points[12]) / 4, Quaternion.Euler(0f, 90f, 90f));
else if (CanBuildWallAndStair(points[2], points[3], points[15], points[14])) Instantiate(wall, (points[2] + points[3] + points[15] + points[14]) / 4, Quaternion.Euler(0f, 90f, 90f));
}
//階段の建築(Y)
else if (Input.GetKeyDown("joystick button 3")) {
Calculate24Coordinates();
//カメラが前後左右のどこを向いているか取得
float cameraRotationY = gameObject.transform.localEulerAngles.y;
Vector3 pos = (points[0] + points[1] + points[2] + points[3] + points[12] + points[13] + points[14] + points[15]) / 8;
if (Mathf.Abs(cameraRotationY - 90f) < 40f) {
if (CanBuildWallAndStair(points[1], points[2], points[19], points[18])) {
Instantiate(stair, (points[1] + points[2] + points[19] + points[18]) / 4, Quaternion.Euler(0f, 0f, 33.69f));
}
else Instantiate(stair, pos, Quaternion.Euler(0f, 0f, 33.69f));
}
else if (Mathf.Abs(cameraRotationY - 180f) < 40f) {
if (CanBuildWallAndStair(points[0], points[1], points[17], points[16])) {
Instantiate(stair, (points[0] + points[1] + points[17] + points[16]) / 4, Quaternion.Euler(0f, 90f, 33.69f));
}
else Instantiate(stair, pos, Quaternion.Euler(0f, 90f, 33.69f));
}
else if (Mathf.Abs(cameraRotationY - 270f) < 40f) {
if (CanBuildWallAndStair(points[0], points[3], points[22], points[23])) {
Instantiate(stair, (points[0] + points[3] + points[22] + points[23]) / 4, Quaternion.Euler(0f, 180f, 33.69f));
}
else Instantiate(stair, pos, Quaternion.Euler(0f, 180f, 33.69f));
}
else if (Mathf.Abs(cameraRotationY - 360f) < 40f || Mathf.Abs(cameraRotationY) < 40f) {
if (CanBuildWallAndStair(points[1], points[2], points[19], points[18])) {
Instantiate(stair, (points[1] + points[2] + points[19] + points[18]) / 4, Quaternion.Euler(0f, 270f, 33.69f));
}
else Instantiate(stair, pos, Quaternion.Euler(0f, 270f, 33.69f));
}
}
}
}
NewBuild.csでは実際にXboxコントローラの対応するボタンを押したら建築が現れるように実装しています。このスクリプトに含まれるメソッドについて説明していきます。
Calculate24Coordinatesメソッド
これは24個の点の座標を求めるメソッドです。
空間中にあらかじめx軸方向, y軸方向, z軸方向の辺の長さがそれぞれ6, 4, 6の見えない直方体が隙間なく敷き詰められていると想像してください。プレイヤーがいる座標は、空間中のいずれかの直方体の内部に必ず入っています(直方体の辺の上にちょうど乗っかることはまずあり得ないでしょう)。プレイヤーが入っている直方体を中央に考えて、その周りにx軸方向とz軸方向に隣接する4個の直方体を考えます。この計5個の直方体の24個の頂点の座標を使えば、建築をどこに建てればいいかが決まります。例えば床だったら0, 1, 2, 3の4点を頂点とする正方形を作ればよいですし、壁だったら0, 1, 13, 12の4点を頂点とする長方形、階段だったら0, 1, 14, 15の4点を頂点とする長方形を作ればよいです。プレイヤーが入っている直方体だけでなく、その周りにも4つ直方体を配置するのは、階段や床が視線の先の方まで建築されるようにするためです。
さて、このメソッドではまずsmaller_x, smaller_y, smaller_zの3つの値を求めています。これらの値は、プレイヤーが入っている直方体の小さな方のx座標, y座標, z座標の値を表しています。直方体の各辺の長さはあらかじめxLength(=6), yLength(=4), zLength(=6)という3つの値で決めてあるので、smaller_x, smaller_y, smaller_zの値が求められます。24個の点の座標のうち"0"の座標を最初に求めて、あとは辺の長さを足していけばすべての点の座標が分かります。
CalculateOuterProductメソッド
このメソッドは、3点vec1, vec2, vec3を通る平面の法線ベクトルを求めます。 ### CalculateEquationOfPlaneメソッド このメソッドは3点vec1, vec2, vec3を通る平面の方程式を求めます。詳しい説明は[こちら](https://qiita.com/Mikoshi/items/9bc6215347c00fd849b3)の記事を参考にしてください。 ### CalculateCoordinateOfIntersectionメソッド カメラの視線と方程式が分かっている平面との交点を求めるメソッドです。詳しい説明は[こちら](https://qiita.com/Mikoshi/items/9bc6215347c00fd849b3)の記事を参考にしてください。 ### WhetherParameterIsPositiveメソッド これは、建築がカメラの正面の方向にのみ作られるようにするためのメソッドです。このメソッド内で定義している"parameter"という変数はCalculateCoordinateOfIntersectionメソッドの中で定義している"parameter"と全く同じものです。parameter>0のときカメラの正面にあり、parameter<0のときカメラの背後にあるので、このメソッドがないと建築がカメラの後ろ側にもできてしまうことがあります。 ### WhetherDistanceIsProperメソッド カメラとプレイヤーとの間に建築ができないようにするメソッドです。(カメラから建築物までの距離)>(カメラからプレイヤーまでの距離)だったらtrueを返すようにしています。 ### WhetherIntersectionIsInsidePolygonメソッド このメソッドは[こちら](http://edom18.hateblo.jp/entry/2018/11/28/200032)のサイトから、ほぼ完全に引用させていただきました。座標がintersectionで表される点がverticesで囲まれる多角形の内側にあればtrueを返します。 ### CanBuildWallAndStairメソッド 引数にとっているvertex0~vertex3の4点を頂点とする壁と階段が建築できるかどうかを判定します。ここまでに説明してきたメソッドを連結しているだけです。 ### CanBuildFloorメソッド vec0~vec3の4点を頂点とする床が建築できるかどうかを判定しています。カメラの視線を表すベクトルとカメラから床の中央に向かうベクトルとの内積が0.97より大きい、すなわちカメラの視線を表すベクトルとカメラから床の中央に向かうベクトルとのなす角が約14°未満のときに建築可能というように実装しています。床の建築可能な条件を壁や階段の建築可能な条件と同じにすると床に建築がしづらくなるように感じたので、実装を変えました。 ### Craftメソッド このメソッドでは、コントローラーの対応するボタンを押すと実際に建築ができるように実装してあります。 #### 床 床の建築では、カメラが水平よりも上を向いているのか下を向いているのかを取得する必要があります。なぜなら、水平よりも上を向いているならプレイヤーより上に位置する床しか建築されず、またその逆も然りにするためです。カメラが真上を向いているときのxの値が90、真下を向いているときのxの値が270になるので、差の絶対値をとって判定しています。 #### 壁 CanBuildWallAndStairメソッドを使って簡潔に実装しています。 #### 階段 カメラがある程度下を向いているときは、カメラの視界に入るのはプレイヤーが入っている直方体の内部の階段のみですが、カメラがある程度水平方向を向いていれば、さらにその奥に隣接している直方体の内部にある階段も視界に入ります。つまり、カメラがある程度水平を向いていると1度に2つの階段ができてしまうというわけです。 これだと意図しない場所に階段が出来かねないので修正しなければなりません。そこで、カメラの視線に2つの階段が入っているときはより遠くの方の階段だけが建築されるように実装しました。手前の階段を建築したいときは視線を下に向けた状態で建築すればOKです。まずカメラが前後左右のどこを向いているのかを"y"という変数に入れておきます。yの値によって階段の向きを指定していきます。if文の中にさらにif文が入っていますが、ここでカメラの視線に2つの階段が入っているかどうかを判定しています。
終わり
現在のところ、ここまでしかフォートナイトの建築の実装は進めていませんが、これからも実装を進めていきたいと考えているので、その都度Qiitaで記事を書いていきたいと思います。本記事を読んでいただきありがとうございました。