【GameMaker:Studio2】ステルスゲームの作り方 (GML)

# はじめに
今回はステルスゲームの作り方を解説します。
最近のステルスゲームには色々な要素がありますが、ここでは「敵の視界」を作り、それとの内外判定の作り方を紹介します。
<img width="400" alt="001.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/9ab85277-1271-8a34-1481-c55f915ac688.png">

このように敵の視界を表示し、それをかいくぐって、ゴールにたどり着くゲームを作ります。

# 素材データ
今回使用する素材データはこちらにアップロードしているので、あらかじめダウンロードしておきます。
http://syun777.sakura.ne.jp/tmp/gms2/gms2_sneaking.zip

# 使用する機能
視界の判定を行うために以下の関数を使用します

* dot_product_normalized()：2つのベクトルの内積を求める　→内積によりプレイヤーへの角度差を求め、視界の内外判定を行います
* collision_line(): 線分上に存在するオブジェクトを調べる　→プレイヤーとの間に遮蔽物がないかどうかをチェックします

# プロジェクトの新規作成
GMLを使用するので、「GameMaker Language」でプロジェクトを作成します。
<img width="381" alt="002.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/c52f09cd-fdbb-4062-3c3a-4bad86c7b763.png">

# プロジェクトの設定
歯車アイコンをクリックして、 `Game Options`  を表示し、 `Game frames per second` を 60に変更します。
<img width="486" alt="003.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/d4b26504-1a5a-3257-42fa-5615c3d554c5.png">

# プレイヤーの作成
オブジェクトを作成し、名前は `objPlayer` とします。
<img width="212" alt="004.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/b282f5a3-8814-415e-857f-e4c882b831be.png">

さらにスプライトを作成し、名前を `sprPlayer` とします。
`Import` ボタンをクリックし、あらかじめダウンロードした画像 `player_strip2.png` をインポートします。
<img width="296" alt="005.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/acfbe023-32c5-8dea-2fdd-59de8825bcc2.png">

アニメーションフレーム( `Frames Per Second` )を `3` とし、スプライトの原点を中央にしたいので、 `Middle Centre` にします。
<img width="466" alt="006.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/2f87bbe2-f4e1-41a4-fa61-66f5a764ea0e.png">

# プレイヤーの移動処理を作成する
`objPlayer` に戻って `Add Event > Step > Step` でStepイベントを作成します。
<img width="306" alt="007.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/bbbc1a6e-4636-1174-6c6c-da2a999f1615.png">

コードは以下のように入力します。

```js:objPlayer>Stepイベント
// 移動速度を初期化
speed = 0;

var vx = 0;
var vy = 0;
if(keyboard_check(vk_left)) {
	vx = -1; // 左移動
}
if(keyboard_check(vk_right)) {
	vx = 1; // 右移動
}
if(keyboard_check(vk_up)) {
	vy = -1; // 上移動
}
if(keyboard_check(vk_down)) {
	vy = 1; // 下移動	
}

if(vx == 0 && vy == 0) {
	return; // 移動していないので何もしない
}

// 移動方向を求める
var dir = point_direction(0, 0, vx, vy);

// 移動速度を設定
motion_add(dir, 3);

// 画面外に出ないようにする
x = median(16, x, room_width-16);
y = median(16, y, room_height-16);
```

カーソルキーの上下左右で移動するコードとなります。
キー入力に対して、それをそのまま移動値として使うのではなく、いったん `point_direction()` で方向を求めてから移動させています。
これは斜め移動を正規化して、移動速度を一定にするためとなります。
移動量を 3 とした場合、移動方向それぞれを3となるため、斜め移動の移動量は `√(3^2+3^2)` = 3√2 となり、3よりも速くなってしまいます。
<img width="305" alt="008.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/8aae5dc6-4402-8e5d-55f3-126a9c20528f.png">

移動方向を角度として扱うことで、斜めであっても一定の速度で動かすことができるようになります。
<img width="345" alt="009.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/630014ca-12f8-9ee9-b678-7c606312ebde.png">

# プレイヤーをルームに配置する
room0 を開いて、 `Properties > Room Settings` からルームサイズを `640 x 480` に変更します。
<img width="190" alt="010.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/3d1490bb-0cac-4544-78e7-47afa18cd0d1.png">

そして、 `objPlayer` を配置します。
<img width="628" alt="011.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/f7609b81-c74a-d6a1-ecfe-7f1dc0cbf62b.png">

実行して、上下左右のカーソルキーで移動できることを確認します。

# 敵の作成
オブジェクトを新規作成して、名前を `objEnemy` とします。
<img width="183" alt="012.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/9e35107f-601e-b14e-f778-886deaa132fe.png">

スプライトを作成して、名前を `sprEnemy` とし、 `Import` ボタンを押して、素材画像 `chabu_strip2.png` をインポートします。
<img width="300" alt="013.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/8428c8e7-a10e-e262-1093-3f927c031b5a.png">

アニメーションフレーム( `Frames Per Second` )を `3` とし、スプライトの原点を中央にしたいので、 `Middle Centre` にします。
<img width="426" alt="014.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/5e2ec6ff-92d6-e9e3-43ad-c0a62ea40554.png">

# 敵の視界の実装
`objEnemy` に戻って、 `Add Event > Create` を選び Createイベントを作成します。
<img width="235" alt="015.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/70ad8160-55a7-1e7e-432c-44cbb74dea9e.png">

コードは以下のように記述します。

```js:objEnemy>Createイベント
// 視界の距離
view_distance = 128;
// 視界の範囲 (角度)
view_range = 30;
// プレイヤーを見つけたかどうか
found_player = false;
// 視界の回転速度
rotate_speed = choose(-1, 1);
```

視界は距離と範囲で定義します。距離(distance)と範囲(range)の関係は以下のものと定義します。
<img width="679" alt="016.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/7f98f9d8-49fd-6aad-8c18-a1efdb65d8c9.png">

円の一部分をくり抜いたもの(薄いピンク色の部分)が、視界となります。

次に、 `Add Event > Step > Step` でStepイベントを作成します。
<img width="291" alt="017.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/0fbbd891-ccf7-eaf6-69e9-8e2f4b631e78.png">

視界内にプレイヤーが存在するかどうかをチェックするコードは以下の通りです。

```js:objEnemy>Stepイベント
// ①視界の向きを回転する
direction += rotate_speed;

// ②未発見で初期化
found_player = false;

// ③プレイヤーまでの距離を求める
var dist = point_distance(x, y, objPlayer.x, objPlayer.y);
if(dist > view_distance) {
	// 範囲外
	return;
}

// ④視線ベクトルを求める
var x1 = lengthdir_x(1, direction);
var y1 = lengthdir_y(1, direction);
// ⑤プレイヤーへの方向ベクトルを求める
var x2 = objPlayer.x - x;
var y2 = objPlayer.y - y;

// ⑥視線ベクトルとプレイヤーへの方向ベクトルとの内積を求める
var dot = dot_product_normalized(x1, y1, x2, y2);
// ⑦アークコサインで角度差が求められる
var deg = darccos(dot);
if(view_range > deg) {
	// 角度差が視野角の範囲内なので発見したことになる
	found_player = true;
}
```

①で視線の角度を動かしています。 `direction` は本来移動方向を指しますが、ここでは視線の向きとして扱います。
③は objEnemy から objPlayer への距離を求めています。
<img width="635" alt="018.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/553d4c88-8439-663c-9b55-9b10e3b43310.png">

このように敵の周りに円があり、その範囲内にプレイヤーがいるかどうかをチェックしています。

④は敵の視線となる方向ベクトルを作り、⑤はプレイヤーへの方向ベクトルを求めています。
⑥で2つのベクトルの内積を求めて、⑦によりベクトルの角度差を計算することで、視野角の範囲内にプレイヤーが存在するかどうかをチェックしています。
<img width="573" alt="019.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/a1eaca25-5476-a62e-3c81-7378da70b706.png">

これで視界内にプレイヤーが存在するかどうかをチェックできるようになりました。

# 視界の描画
視界を明示的に描画します。
`objEnemy` の `Add Event > Draw > Draw` を選び、Drawイベントを作成します。
<img width="372" alt="020.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/f331219a-ade9-8d0c-05ac-ae54bfda5d67.png">

コードは以下のように記述します。

```js:objEnemy>Drawイベント
var col = c_white;
if(found_player) {
	// プレイヤーを見つけたら色を赤くする
	col = c_red;
}

// 敵スプライトの描画
draw_sprite_ext(sprite_index, image_index, x, y, 1, 1, 0, col, 1);

// 視界範囲の右側を線分で描画
var dx1 = lengthdir_x(view_distance, direction-view_range);
var dy1 = lengthdir_y(view_distance, direction-view_range);
draw_line_colour(x, y, x+dx1, y+dy1, col, col);

// 視界範囲の左側を線分で描画
var dx2 = lengthdir_x(view_distance, direction+view_range);
var dy2 = lengthdir_y(view_distance, direction+view_range);
draw_line_colour(x, y, x+dx2, y+dy2, col, col);

// 視界範囲の先端の左右をつなぐ
draw_line_colour(x+dx1, y+dy1, x+dx2, y+dy2, col, col);
```

本来であれば、視界範囲は円弧を描くべきですが、GameMakerには円弧の一部分を各機能は存在しないので、ひとまず三角形を描くようしました。

では、room0 を開いて、 `objEnemy` を配置します。
<img width="668" alt="021.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/2911bafe-7b12-dea6-4d04-7b4403cde22f.png">

実行して、敵の視界範囲が描画されており、それにプレイヤーが入ると赤く表示されることを確認します。
<img width="192" alt="022.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/1bc15459-dfaf-aa6b-63b6-eba85ad3cb6d.png">

# 壁がある場合は視界を遮るようにする
プレイヤーと敵の間に壁があった場合は、視界を遮るようにします。
まず壁のオブジェクトを作成します。
新規にオブジェクトを作成して、名前を `objWall` とします。
そして、 `Solid` にチェックを入れます。
<img width="252" alt="023.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/85ede371-9bbb-65b4-4c3f-b1c742fce59c.png">

スプライトを作成し、名前を `sprWall` とします。
`Import` ボタンを押して、素材 `wall.png` をインポートします。
<img width="278" alt="024.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/ed698905-4514-5e84-9d93-de0e05f872dd.png">

`objEnemy` の Stepイベントを開き、コードを修正します。

```js:objEnemy>Stepイベント
// ①視界の向きを回転する
direction += rotate_speed;

// ②未発見で初期化
found_player = false;

// ③プレイヤーまでの距離を求める
var dist = point_distance(x, y, objPlayer.x, objPlayer.y);
if(dist > view_distance) {
	// 範囲外
	return;
}

// ④視線ベクトルを求める
var x1 = lengthdir_x(1, direction);
var y1 = lengthdir_y(1, direction);
// ⑤プレイヤーへの方向ベクトルを求める
var x2 = objPlayer.x - x;
var y2 = objPlayer.y - y;

// ⑥視線ベクトルとプレイヤーへの方向ベクトルとの内積を求める
var dot = dot_product_normalized(x1, y1, x2, y2);
// ⑦アークコサインで角度差が求められる
var deg = darccos(dot);
if(view_range > deg) {
	// 角度差が視野角の範囲内なので発見したことになる
	// ※ここを修正
	// 敵とプレイヤーの間にobjwall が存在するかどうかチェック
	var inst = collision_line(x, y, objPlayer.x, objPlayer.y, objWall, false, true);
	if(inst == noone) {
		// 何もないので発見したことになる
		found_player = true;
	}
}
```

最後の部分を修正しています。 `collision_line()` を使って敵とプレイヤーの間を線分で結び、その線分上に `objWall` が存在するかどうかをチェックしています。
<img width="436" alt="025.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/a6bc29d6-5a72-2250-e342-eb4b949c3fdc.png">

これで視界の判定はできました。
room0 を開いて、敵の近くに `objWall` をいくつか置いてみて壁を作ります。
実行して、壁の後ろに隠れている場合には、敵に発見されないことを確認します。
<img width="204" alt="026.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/f7e3f247-3145-2a6a-5a42-e1a961fe8618.png">

# 壁をすり抜けられないようにする
プレイヤーが壁をすり抜けてしまうので、壁をすり抜けられないようにします。
`objPlayer` を開いて、Stepイベントを編集します。

```js:objPlayer>Stepイベント
// 移動速度を初期化
speed = 0;

var vx = 0;
var vy = 0;
if(keyboard_check(vk_left)) {
	vx = -1; // 左移動
}
if(keyboard_check(vk_right)) {
	vx = 1; // 右移動
}
if(keyboard_check(vk_up)) {
	vy = -1; // 上移動
}
if(keyboard_check(vk_down)) {
	vy = 1; // 下移動	
}

if(vx == 0 && vy == 0) {
	return; // 移動していないので何もしない
}

// 移動方向を求める
var dir = point_direction(0, 0, vx, vy);

// 移動速度を設定
motion_add(dir, 3);

// 画面外に出ないようにする
x = median(16, x, room_width-16);
y = median(16, y, room_height-16);

// —————————————
// ※ここから追加
// —————————————
// 壁との衝突をチェックする
if(place_free(x+hspeed, y) == false) {
	var dir = point_direction(0, 0, hspeed, 0);
	move_contact_solid(dir, abs(hspeed)); // 壁にぴったりくっつける
	hspeed = 0;
}
if(place_free(x, y+vspeed) == false) {
	var dir = point_direction(0, 0, 0, vspeed);
	move_contact_solid(dir, abs(vspeed)); // 壁にぴったりくっつける
	vspeed = 0;
}
```

「※ここから追加」より下の部分が追加した処理となります。
X方向への移動を事前にチェックして、壁があったら壁ぴったりまで移動して、 `hspeed` を 0 にし、Y方向への移動を事前にチェックして、壁があったら壁ぴったりまで移動して、 `vspeed` を 0 にする、ということをしています。

実行して壁にめり込まないことを確認します。

# ゴールを作成する
ゴールに接触したらゲームクリアとします。
オブジェクトを作成して、名前を `objGoal` とします。
<img width="203" alt="027.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/8c752d1e-9a9a-786e-7623-f766dada4502.png">

スプライトを作成し、名前を `sprGoal` とし、 `Import` を押して、素材画像 `goal_strip4.png` をインポートします。
<img width="271" alt="028.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/c48077c2-7ffd-1a5b-7e17-9a8b87afa1c4.png">

アニメーション速度を調整します。 `Frames per second` を 6 に変更します。
<img width="367" alt="029.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/a24c7e6b-b64c-85b5-687a-b126b4ce6002.png">

ゴールに接触したらゲームクリアとします。
`objPlayer` を開いて、 `Add Event > Collision > objGoal` を選び、 `objGoal` との当たり判定イベントを作成します。
<img width="318" alt="030.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/edd7d62e-521f-4cea-2925-8f7bb34e5b32.png">

コードは以下のように記述します。

```js:objPlayer>Collision>objGoalイベント
// “GAME CLEAR” のメッセージを表示
show_message("GAME CLEAR!");
// ゲーム終了
game_end();
```

room0 を開いて、 `objGoal` を右下あたりに配置します。
<img width="476" alt="031.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/f6f674d3-df7d-9c03-9826-743ba4f74c95.png">

ルームの背景色が黒で見づらくなっているので、 `Background Layer Properties` から、Colour の値を灰色にします。 
<img width="311" alt="032.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/2f082263-27fc-5478-595d-fcd05d7b5263.png">

そして、HSVの `Val` の値を `50` に変更します。
<img width="538" alt="033.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/28c04a02-f47f-1037-8d74-77e238daac8c.png">

では実行して、プレイヤーがゴールに入ると “GAME CLEAR” のメッセージが表示され、ゲームが終了することを確認します。
<img width="539" alt="034.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/d84bc358-a3f1-be71-5a25-8b2ced3c4fdf.png">

なお、選択レイヤーが `Background`  になっていると、オブジェクトが配置できなくなってしまうので、`Instances` を選択しておきます。
<img width="429" alt="035.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/5c143910-1941-d7a1-b13b-20b0c18d25ed.png">

# ゲームオーバー判定を実装する
プレイヤーが敵に見つかったらゲームオーバーになるようにします。

オブジェクトを作成し、名前を `objGame` に変更します。
<img width="134" alt="036.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/cc55771e-5333-90f4-657b-f397a6f3fa7a.png">

`Add Event > Create` を選び、Createイベントを作成します。
<img width="228" alt="037.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/6aae1d6c-f294-ea97-5b3d-36196eb4c94b.png">

コードは以下のように記述します。

```js:objGame>Craeteイベント
// ゲームオーバーフラグを初期化
is_gameover = false;
```

次に、 `Add Event > Step > Step` で `objGame` のStepイベントを作成します。
<img width="293" alt="038.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/b83fe987-66a3-46eb-d016-79e21b8724e8.png">

コードは以下のようにします。

```js:objGame>Stepイベント
with(objEnemy) {
	// objEnemyを全て調べる
	if(found_player) {
		// プレイヤー発見フラグがONになっている
		with(objPlayer) {
			// プレイヤーを破棄する
			instance_destroy();
		}
		// ゲームオーバーフラグをONにする
		other.is_gameover = true;
	}
}

if(is_gameover) {
	if(keyboard_check_pressed(vk_space)) {
		// ゲームオーバーフラグが立っていて、SPACEキーを押したらリスタートする
		game_restart();
	}
}
```

`objEnemy` を全て調べて、発見状態になっているかどうかをチェックします。
もし発見していたら、プレイヤーを消滅させて、ゲームオーバーフラグをONにします。
そして、ゲームオーバー状態であれば、SPACEキーでリスタートできるようにしています。

最後に、 `Add Event > Draw > Draw GUI` で、 `objGame` の Draw GUIイベントを作成します。
<img width="324" alt="039.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/a5e269c2-46f5-d14e-7d80-a0341e9a0d90.png">

コードは以下の通りです。

```js:objGame>DrawGUIイベント
if(is_gameover == false) {
	// ゲームオーバーでなければ何も描画しない
	return;
}

// ゲームオーバーの場合、SPACE でリスタートできることを描画する.
var px = room_width/2;
var py = room_height/2;
draw_rectangle_color(0, py, room_width, py+24, c_black, c_black, c_black, c_black, false);
draw_text(px, py, "SPACE to restart");
```

ゲームオーバー時のみ、”SPACR to restart” の文字を画面に表示するようにしました。

では、 `objGame` を room0 に配置します。
<img width="406" alt="040.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/58997144-30e8-2d03-adc0-8fe7c4a6fafb.png">

実行して動作を確認します。しかし敵の視界に入ると以下のようなエラーが出ます。
<img width="435" alt="041.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/9e76b6a8-71e6-6176-34d8-b02e20d4c631.png">

```
ERROR!!! :: ############################################################################################
FATAL ERROR in
action number 1
of  Step Event0
for object objEnemy:


Unable to find any instance for object index '0' name 'objPlayer'
 at gml_Object_objEnemy_Step_0 (line 11) - var dist = point_distance(x, y, objPlayer.x, objPlayer.y);
############################################################################################
--------------------------------------------------------------------------------------------
stack frame is
gml_Object_objEnemy_Step_0 (line 11)
```

エラーが出ているのは、 `objEnemy` のStepイベントです。
エラーの原因は、 `objPlayer` が消滅してしまっても視界チェックで `objPlayer` を見ているためとなります。
なので、視界内のチェックをする際に、 `objPlayer` が消滅している場合は、 `objPlayer` を見ないようします。
`objEnemy` を開いて、Stepイベントを開きます。

```js:objEnemy>Stepイベント
// ※objPlayerが存在しない場合、視界のチェックをしない
if(instance_exists(objPlayer) == false) {
	return;
}

// ①視界の向きを回転する
direction += rotate_speed;

// ②未発見で初期化
found_player = false;

// … (以下省略)
```

最初のところで、 `instance_exists()` を使用して `objPlayer` が存在しない場合は、視界のチェックをしないようします。

では、実行して動作を確認します。
敵に見つかった場合は、SPACEキーでリスタートできるようになりました。
<img width="332" alt="042.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/8a41d3f3-a0ca-c095-ea7c-3f0f406894af.png">

プレイヤーが消滅した時の演出を入れ忘れていたので、 `objPlayer` を開いて、 `Add Event > Destroy` でDestroyイベントを追加して、コードは以下の記述でエフェクトを発生させるようにします。

```js:objPlayer>Destroyイベント
effect_create_above(ef_explosion, x, y, 1, c_white);
```

# 最後に
最後に好きに敵や壁を配置してみます。
敵の視界の動き方のパターンが少ないので、たくさん敵を配置した方が手応えのあるゲームになりそうです。
<img width="329" alt="099.png" src="https://qiita-image-store.s3.amazonaws.com/0/43772/ca409dd3-0652-ed37-545b-fa4fbd06e9e8.png">

以上、ステルスゲームの作り方でした。