Calmery.me

みっかぼうずにならないようがんばる

Unityで3DのRPGっぽいものを作る

Unity を使って 3D の RPG っぽいものを作ったのでメモ.殴り書きに注意されたし.自分の勉強がてらにやったことなのでよくわからず無理やりやっているところが多い.言ってしまえばチュートリアルを見ながらの方がいいと思う.

参考にした Web サイトは一番下にまとめています.

アセットを準備する

前から気になっていた Tower Defense and MOBA を買ってみた.少し高い気もするが幅広く使うことができそうなので買って損ではないと思う.PayPal でポチッと購入できる.ただ,今回は地形部分でしか使わないので別になくても問題はない.
次は,プレイヤーとスライム(モンスター)がモーションとセットで入っている便利な Animated Knight and Slime Monster を準備する.これで無料って本当に凄い.
最後に Standard Assets にある ParticleSystems をインポートする.

Terrain を作成する

元となる地形を作る.今回はまっさらな状態から作るのが面倒だったので Tower Defense and MOBA の Demo1 をそのまま使った.

プレイヤーを配置する

Animated Knight and Slime Monster の Character の中にあるモデルを配置する.でかい.めちゃくちゃでかい.
f:id:calmery:20160423193203p:plain
ちょっと Scale をいじった.この時点で既に見た目は完璧な気がする.
f:id:calmery:20160423193344p:plain
なんかプレイヤーがやたらと輝いている.というか発光してるレベルで光を反射してしまっている.
仕方がないので配置したモデルの Shader を Standard に変更する.今回のモデルで言えば Cha_Knight の Group Locator にある Knight の Inspector から変更ができる.
f:id:calmery:20160423193727p:plain
次にカメラを配置する.カメラをいい感じに配置する.これはもういい感じにとしか言えない.
f:id:calmery:20160423193955p:plain
配置ができたらプレイヤーの子要素にする.これでプレイヤーを動かしたときカメラも勝手についていってくれるようになる.便利.

次にプレイヤーに Rigidbody と Capsule Collider を追加する.Collider をいい感じに調節した後 Rigidbody の Constraints にある Freeze Rotation にチェックを入れる.
f:id:calmery:20160423212442p:plain
そうでないと倒れるわ回転するわでなかなか大変なことになる(なった)

最後に歩ける,ジャンプができる,攻撃ができるようにスクリプトを追加する.今はモーションをつけるだけという方が正しいかもしれない.C# は恐くて使えないので JavaScript を使う.でも検索するとほとんど C# の解説しか出てこない.なんと肩身の狭い.

まずモデルとモーションを関連付ける.プレイヤーの Inspector に Animation という項目があるがそこにモーションをドラッグアンドドロップする.Animations のリストに入ったらいい.
f:id:calmery:20160423200252p:plain

その後 PlayerMove.js を作成し以下のように記述した.

#pragma strict

private var jumpFlg = false;
private var jumpCounter = 0;

var downRotateSpeed = 0.5;
var downSpeed = 0.05;
var upSpeed = 0.1;

private var up;
private var down;
private var left;
private var right;
private var vertical : int;

private var attackFlg = false;

function Start () {
}

function Update () {

	if( !attackFlg ){

		GetComponent.<Animation>().CrossFade( 'Wait', 0.01 );

		vertical = Input.GetAxis( 'Vertical' );

		up    = Input.GetKey( 'up' ) || Input.GetKey( KeyCode.W );
		down  = Input.GetKey( 'down' ) || Input.GetKey( KeyCode.S );
		left  = Input.GetKey( 'left' ) || Input.GetKey( KeyCode.A );
		right = Input.GetKey( 'right' ) || Input.GetKey( KeyCode.D );

		Debug.Log( vertical );

		if( left )
			transform.Rotate( 0, -1.5 * ( down ? downRotateSpeed : 1 ), 0 );
		else if( right )
			transform.Rotate( 0, 1.5 * ( down ? downRotateSpeed : 1 ), 0 );

		if( up ){
			GetComponent.<Animation>().CrossFade( 'Walk', 0.01 );
			transform.Translate( 0, 0, vertical*upSpeed );
		}else if( down ){
			GetComponent.<Animation>().CrossFade( 'Walk', 0.01 );
			transform.Translate( 0, 0, vertical*downSpeed );
		}

		if( jumpFlg ){
			GetComponent.<Animation>().CrossFade( 'Wait', 0.01 );
			if( jumpCounter <= 10 )
				transform.Translate( 0, 0.1 , 0 );
			jumpCounter++;
		}

		if( Input.GetKey( 'space' ) )
			jumpFlg = true;

	}

	if( !attackFlg && Input.GetKey( KeyCode.Z ) ){
		attackFlg = true;
		GetComponent.<Animation>().CrossFade( 'Attack', 0.01 );
	} else if( !GetComponent.<Animation>().IsPlaying( 'Attack' ) )
		attackFlg = false;
	
}

function OnCollisionEnter(collision:Collision){

	jumpFlg = false;
	jumpCounter = 0;

}

キーの入力をとって移動させたりさせなかったり.関数外で定義した変数はメンバ変数になり Inspector から変更できてしまうので変更されたくないものには private をつける.これで Inspector から見ることができない.だが逆に移動速度などはメンバ変数にした方が都合がいい.

モーションの切り替えだがアニメーションコントローラーを作らずとも関数で切り替えができた.アニメーションコントローラーってなんですか状態なのでとても助かった.ループで再生する方法もきっとあるのだろうがよくわからなかったので毎回再生している.うまく動いているのでこれでよし.

GetComponent.<Animation>().CrossFade( モーション名, 切り替わる時間 );

ジャンプの処理は思いつかなかったのでカウンタを作った.0.1 ずつ 10 回上昇する.あとは落ちる.そして OnCollisionEnter を使って何かしらの物体,主に地面にあたったときにジャンプを終了させる.

transform.Rotate で回転,transform.Translate で移動ができる.後ろ向きの移動だけ少し遅くして遊んでみた.ついでに Input.GetAxis( 'Vertical' ) で縦方向の向きが 1,0,-1 で取得できる.当たり前なのかもしれないが忘れそうなのでメモ.

あと攻撃中は操作させたくなかったので,フラグを作って操作できないようにした.

GetComponent.<Animation>().IsPlaying( モーション名 ); 

モーション中かどうかはこれで取得できる.モーションが終了したらフラグを偽にして操作していいよという感じになっている.
f:id:calmery:20160423201602p:plain
これでプレイヤーの基本部分は完成したので次はスライム(モンスター)を作る.

スライムを配置する

同じように配置する.その後,モーションも同じようにすべて関連付ける.モーションはデフォルトで Wait にしておくと勝手にプニプニしだす.
f:id:calmery:20160423201657p:plain
スライムはプレイヤーを追いかけるようにしたい.そこで Navigation を使ってみる.
Inspector の横にある Navigation を開き Bake を選択,いろいろな項目があるがここではデフォルトのままで問題ないので右下の Bake を選択する.
f:id:calmery:20160423201843p:plain
移動可能な場所が青く表示されている.いやこれ凄い.
f:id:calmery:20160423202018p:plain
次にスライムに Add Component から Nav Mesh Agent を追加.speed など適当にいじる.
f:id:calmery:20160423202117p:plain
最後に Enemy.js を作成しスライムに追加後,以下のように記述した.

#pragma strict

var destination : Transform;
private var nav : NavMeshAgent;

function Start () {
	nav = GetComponent( NavMeshAgent );
}

function Update () {
	nav.SetDestination( destination.position );
}

変数 destination はメンバ変数となる.Inspector から移動の対象となるオブジェクトを指定する.今回はプレイヤーを移動の対象にしたいので Hierarchy からプレイヤーをドラッグアンドドロップDestination に指定する.
f:id:calmery:20160423202510p:plain
これで移動可能な範囲で勝手に追いかけてくれる.なんと便利な.いろいろできそうな気がする.

プレイヤーに攻撃させる

Inspector からスライムに Enemy タグ,プレイヤーに Player タグをつける.さらにプレイヤーの攻撃範囲をブロックで作成した.Mesh をオフにすれば見えなくなる.Box Collider で判定をつけ, Is Trigger にチェックをいれる.その後プレイヤーの攻撃範囲にあわせて配置し子要素にする.Is Trigger は,あたっているかの判定に使う OnTrigger 系関数を呼び出すために必要,Unity のドキュメントに書いてある.

トリガーにすると、Rigidbody の衝突判定を行わなくなります。ですが、Rigidbody がトリガーの範囲内に「入り」、「出る」ときにコールバックとして OnTriggerEnter、OnTriggerExit、OnTriggerStay が呼び出されます。

f:id:calmery:20160423203146p:plain
ここでは作成したブロックの名前を AttackSpace としておく.ついでにタグも AttackSpace とつけた.また攻撃したときにエフェクトを出したいので ParticleSystems の ExplosionMobile を邪魔にならない位置に配置しておいた(プレハブ?いやちょっと違う?)
ついでに ExplosionMobile の Inspector から Explosion Physics Force を 0 に Particle System Multiplier の Multiplier を 1 にしておくといい.爆発で吹き飛ばされなくなる.
Attack.js を作成し AttackSpace に追加,記述する.

#pragma strict

function Start () {
}

function Update () {
}

function OnTriggerStay( collider:Collider ){

	if( collider.gameObject.CompareTag('Enemy') ){
		var player = GameObject.FindGameObjectsWithTag("Player");
		if( player[0].gameObject.GetComponent.<Animation>().IsPlaying("Attack") ){
			Destroy(collider.gameObject);
			var obj = GameObject.Find("ExplosionMobile");
			var position = player[0].transform.position;
			Instantiate(obj, position, Quaternion.identity);
		}
	}
	
}

OnTriggerStay 関数は何かにあたっているときに発生する.そのときそれが Enemy タグを持っていてプレイヤーが攻撃モーションを行っていれば壊していいよという感じ.ついでに ExplosionMobile のインスタンスを目の前に作成し閃光が走ったのようなエフェクトもつけている.
f:id:calmery:20160423204024p:plain
ちゃんと攻撃ができるようになった.

スライムからの攻撃を受ける

スライムに攻撃させたい.

でもその前にプレイヤーに体力を追加する.PlayerMove.js に以下を追加する.

var health = 100;

function setHealth( val:int ){
	health = health - val;
	if( health < 0 ) health = 0;
}

setHealth は敵から攻撃を受けたときに呼び出す.

次に ExplosionMobile を複製して Attacked にした.Inspector から Explosion Physics Force を 2 に Particle System Multiplier の Multiplier を 1 に変更しエフェクトとして使う.これで攻撃されたときちょっと後ろに吹き飛ばされるようになる.
f:id:calmery:20160423205833p:plain

Enemy.js を開き以下を追加する.

function OnTriggerEnter( collider:Collider ){

	if( collider.gameObject.CompareTag('Player') || collider.gameObject.CompareTag('AttackSpace')  ){

		var player = GameObject.FindGameObjectsWithTag("Player");
		if( player[0].gameObject.GetComponent.<PlayerMove>().health>0 && !player[0].gameObject.GetComponent.<Animation>().IsPlaying("Attack") && !GetComponent.<Animation>().IsPlaying("Attack") ){
			Debug.Log( "Enemy Attack !" );
			player[0].gameObject.GetComponent.<PlayerMove>().setHealth( 10 + Random.Range( 0, 11 ) );
			Debug.Log( "Player health : " + player[0].gameObject.GetComponent.<PlayerMove>().health );
			GetComponent.<Animation>().CrossFade( 'Attack', 0.01 );
			player[0].gameObject.GetComponent.<Animation>().CrossFade( 'Damage', 0.01 );
			var obj = GameObject.Find("Attacked");
			var position = transform.position;
			Instantiate(obj, position, Quaternion.identity);
		}
	}
	
}

だんだんと雑になっていっている.気にしては駄目だ.OnTriggerEnter 関数は何かとあたったときに呼び出される.敵は Player タグを持つオブジェクトか AttackSpace タグを持つオブジェクトにあたったとき,プレイヤーのオブジェクトを取得,PlayMove.js の setHealth に与えたダメージを渡しつつ呼び出す.
GetComponent でスクリプトの中身が取得できるというのは初めて知った.便利だ.さっきから便利だしか言ってない気がする.

player[0].gameObject.GetComponent.<PlayerMove>().setHealth( '与えたダメージ' ); // Random.Range( 0, 11 ) で 0 から 10 までの整数をランダムで作成
player[0].gameObject.GetComponent.<PlayerMove>().health; // 残りの体力

このときプレイヤーはモーションを Damage に切り替える.だがここで問題なのが PlayerMove.js で攻撃中以外は毎回 Wait のモーションを呼び出しているところだ.なので PlayerMove.js を修正する.

if( !attackFlg  ){ // Before
if( !attackFlg && !GetComponent.<Animation>().IsPlaying("Damage") ){ // After

これで攻撃を受けているように見える.

今更だけどプレイヤーかスライムの Collider にも Is Trigger つけないといけない.Is Trigger がついてないオブジェクト同士では OnTrigger 系の関数が呼び出されず,判定が AttackSpace でしか行われていない.
どちらか片方に Is Trigger がついていればいい.ということでプレイヤーの Collider に Is Trigger を追加した.
f:id:calmery:20160423205904p:plain
攻撃を受けると体力が減るようになった.
f:id:calmery:20160423205926p:plain

体力を表示する

フォントは BrookeShappell8 font を使う.
Unity に取り込んだ後,Project から右クリック,Create を選び GUI Skin を作成する.GUI Skin の Inspector から Label 部分の Font,Font Size を変更する.
f:id:calmery:20160423210252p:plain
その後,PlayerMove.js に変更を加える.

var guiSkin : GUISkin;
var healthLabel = "HP 100";

function OnGUI(){
	GUILayout.BeginArea(new Rect(30,30,500,500));
	GUILayout.Label(healthLabel, guiSkin.label); 
	GUILayout.EndArea();
}

先ほど作成した GUI Skin をメンバ変数に追加すればいい.
f:id:calmery:20160423210332p:plain
表示された.
f:id:calmery:20160423210521p:plain

healthLabel 変数を表示に使っているので,この変数の内容を変えれば勝手に描写内容が変更される.要するにダメージ受けたとき healthLabel = "HP : 85"; にすればいい.でもいきなり数値が変わると面白くない.カウントダウンするようにしたい.
PlayerMove.js に以下を追加する.

private var nowDown = 100;

Update 関数に

if( nowDown > health ){
	nowDown--;
	healthLabel = "HP " + nowDown;
}

を追加し,setHealth 関数を,

function setHealth(val:int){
	if( nowDown == health ) nowDown = health;
	health = health - val;
	if( health < 0 ) health = 0;
}

に変更する.

これでフレーム?毎に数値が下がる.それっぽい.
ついでに体力が 0 になったときの動作も追加する.同じく PlayerMove.js に以下を追加する.

private var Deaded = false;

攻撃されたときは吹き飛ばされて空中にいるというのが前提になる.OnCollisopnEnter に以下を追加する.

if( !Deaded && health <= 0 ) {
	Deaded = true;
	GetComponent.<Animation>().CrossFade( 'Dead', 0.01 );
}

また,倒れたときは操作されたくないので Update 関数の中を以下のように変更する.

if( !attackFlg && !GetComponent.<Animation>().IsPlaying("Damage")  ){ // Before
if( !Deaded && !attackFlg && !GetComponent.<Animation>().IsPlaying("Damage") ){ // After

ヤラレタ...
f:id:calmery:20160423211428p:plain

あとは適当にスライムの数を増やしたりすればよさそう.

まとめ

なんか動作がおかしいけど楽しかった.あとアセットって凄い.

f:id:calmery:20160423211532p:plain
f:id:calmery:20160423211539p:plain

最終的な状態は以下のようになった.とても少ない.
PlayerMove.js

#pragma strict

private var jumpFlg = false;
private var jumpCounter = 0;

var downRotateSpeed = 0.5;
var downSpeed = 0.05;
var upSpeed = 0.1;

private var up;
private var down;
private var left;
private var right;
private var vertical : int;

private var attackFlg = false;

var health = 100;
var guiSkin : GUISkin;
private var healthLabel = "HP 100";
private var nowDown = 100;

private var Deaded = false;

function OnGUI(){
	GUILayout.BeginArea(new Rect(30,30,500,500));
	GUILayout.Label(healthLabel, guiSkin.label); 
	GUILayout.EndArea();
}

function setHealth(val:int){
	if( nowDown == health ) nowDown = health;
	health = health - val;
	if( health < 0 ) health = 0;
}

function Start () {
}

function Update () {

	if( nowDown > health ){
		nowDown--;
		healthLabel = "HP " + nowDown;
	}

	if( !Deaded && !attackFlg && !GetComponent.<Animation>().IsPlaying("Damage") ){

		GetComponent.<Animation>().CrossFade( 'Wait', 0.01 );

		vertical = Input.GetAxis( 'Vertical' );

		up    = Input.GetKey( 'up' ) || Input.GetKey( KeyCode.W );
		down  = Input.GetKey( 'down' ) || Input.GetKey( KeyCode.S );
		left  = Input.GetKey( 'left' ) || Input.GetKey( KeyCode.A );
		right = Input.GetKey( 'right' ) || Input.GetKey( KeyCode.D );

		if( left )
			transform.Rotate( 0, -1.5 * ( down ? downRotateSpeed : 1 ), 0 );
		else if( right )
			transform.Rotate( 0, 1.5 * ( down ? downRotateSpeed : 1 ), 0 );

		if( up ){
			GetComponent.<Animation>().CrossFade( 'Walk', 0.01 );
			transform.Translate( 0, 0, vertical*upSpeed );
		}else if( down ){
			GetComponent.<Animation>().CrossFade( 'Walk', 0.01 );
			transform.Translate( 0, 0, vertical*downSpeed );
		}

		if( jumpFlg ){
			GetComponent.<Animation>().CrossFade( 'Wait', 0.01 );
			if( jumpCounter <= 10 )
				transform.Translate( 0, 0.1 , 0 );
			jumpCounter++;
		}

		if( Input.GetKey( 'space' ) )
			jumpFlg = true;

	}

	if( !attackFlg && Input.GetKey( KeyCode.Z ) ){
		attackFlg = true;
		GetComponent.<Animation>().CrossFade( 'Attack', 0.01 );
	} else if( !GetComponent.<Animation>().IsPlaying( 'Attack' ) )
		attackFlg = false;

	if( health <= 0 ){
		if( !ifDead ){
			ifDead = true;
		}
	}

}

function OnCollisionEnter(collision:Collision){

	jumpFlg = false;
	jumpCounter = 0;

	if( !Deaded && health <= 0 ) {
		Deaded = true;
		GetComponent.<Animation>().CrossFade( 'Dead', 0.01 );
	}

}

Enemy.js

#pragma strict

var destination : Transform;
private var nav : NavMeshAgent;

function Start () {
	nav = GetComponent( NavMeshAgent );
}

function Update () {
	nav.SetDestination( destination.position );
}

function OnTriggerEnter( collider:Collider ){

	if( collider.gameObject.CompareTag('Player') || collider.gameObject.CompareTag('AttackSpace')  ){

		var player = GameObject.FindGameObjectsWithTag("Player");
		if( player[0].gameObject.GetComponent.<PlayerMove>().health>0 && !player[0].gameObject.GetComponent.<Animation>().IsPlaying("Attack") && !GetComponent.<Animation>().IsPlaying("Attack") ){
			Debug.Log( "Enemy Attack !" );
			player[0].gameObject.GetComponent.<PlayerMove>().setHealth( 10 + Random.Range(0, 11) );
			Debug.Log( "Player health : " + player[0].gameObject.GetComponent.<PlayerMove>().health );
			GetComponent.<Animation>().CrossFade( 'Attack', 0.01 );
			player[0].gameObject.GetComponent.<Animation>().CrossFade( 'Damage', 0.01 );
			var obj = GameObject.Find("Attacked");
			var position = transform.position;
			Instantiate(obj, position, Quaternion.identity);
		}
	}
	
}

Attack.js

#pragma strict

function Start () {
}

function Update () {
}

function OnTriggerStay( collider:Collider ){

	if( collider.gameObject.CompareTag('Enemy') ){
		var player = GameObject.FindGameObjectsWithTag("Player");
		if( player[0].gameObject.GetComponent.<Animation>().IsPlaying("Attack") ){
			Destroy(collider.gameObject);
			var obj = GameObject.Find("ExplosionMobile");
			var position = player[0].transform.position;
			Instantiate(obj, position, Quaternion.identity);
		}
	}
	
}