Unity_3dタワーディフェンス
skonishi1125.icon
https://scrapbox.io/files/6974cdcfcdd716e8f550d856.gif
3Dで内積、AIや状態管理、UIなどを学ぼう
はじめに
code:txt
# ゲーム構成
ある程度同じ性能のものをマウスクリックで立てていって、ボーッと眺めていくゲーム
グリッドで区切られていて、指定の場所に施設が置ける。
道があって敵が歩いてくる。
自分にはライフがあって、5回抜かれたらゲームオーバーとかそんな感じ
# どんな施設があるか
・360°攻撃。火力控えめ。
・180°攻撃。基本。
・90°攻撃。高火力。
・連射力が極端に高いもの(メタル系などに有効)
・連射力が極端に低いもの(火力が高い)
・ノックバックして、敵を押し出したりスタンさせて止めてくれるもの
・置くだけで、周囲の施設をパワーアップするもの(範囲2マスとか)
# どんな敵
普通の弱い敵
すばしっこく、連射力の遅い施設ばかりだと通られる
鈍足だがしぶとい, etc
# 難易度
ノーマルとハードとか。ウェーブ制。
作成
地面にマウスクリックでPrefab(タワー)をおけるようにする。
New Input Systemと旧操作どちらも有効にするためにProject Settingで調整。
まず、GroundにマウスクリックしたらPrefabがおけるようにする
おけたら、Place / Demolishで配置 or 削除ができるようにする。
グリッドスナップ(タワーを配置できる場所をグリッドで区切る。)
ray.hitで取得できるマウスポインタの位置をint型で表すようにする。これで(1,0,1)や、(1,0,-3)など、ある程度まとまった値が返るようになる。cell = Vector2Intの値を入れる。
丸めた値をreturn new Vector3(cell.x + 0.5f, y, cell.y + 0.5f);として加工。1:1で区切った四角い座標の真ん中を基準にした座標が返るようになる。instantiate時などにこの座標を基準とすると、グリッドの真ん中に生成してくれる。
ゴーストを作る
座標を指定したら、ほんのりと設置予定のTowerが見えるようにする
グリッドシステム
グリッド上に配置するゲームでは占有管理という概念があり、それが重要になってくる。
占有管理をしないと
同じマスに何回も置けてしまう
どのマスに何が置かれているか、コード側で把握が難しい
解体したい場合でも、そこに配置されたものを消せなかったりする
敵の経路計算やバフ範囲の調整時が難しい
占有管理について、詳しく知る
セル(3,6)にタワーが置かれているなら、(3,6)にTowerAが置かれているということをデータで記録すること。
データが有れば、配置時にセルが埋まっているかどうか、取り壊し時にそのセルに配置されたものだけを壊すなど、操作が用意になる。
DIctionaryで管理する
「セル(3, 6) -> TowerAを配置」というように、辞書で管理するのが代表的。配列でもできるが、C#ならDictionaryが楽。
code:c#
private readonly Dictionary<Vector2Int, GameObject> placedTowersDictionary = new();
// ... 生成処理時に登録, 廃棄時に解除などを済ませる
var tower = Instantiate(towerPrefab, cellCenter, Quaternion.identity);
placedTowersDictionary.Add(cell, tower);
// ...
Destroy(tower);
placedTowersDictionary.Remove(cell);
// 新規に生成するときなどは、そのグリッドに該当するデータがあるかどうかをチェックすれば良い
// グリッドにどんなデータがあるか取りたい場合
Vector2Int cell = WorldToCell(hit.point);
if (!placedTowersDictionary.TryGetValue(cell, out var tower))
Debug.Log($"何も配置されていません: {cell}");
// 何かあるかのチェックの場合
Vector2Int cell = WorldToCell(hit.point);
if (placedTowersDictionary.ContainsKey(cell))
Debug.Log($"その位置にはすでに何かが配置されています: {cell}");
敵を動かす
手段として、WayPointとNavMeshという2通りの手段が代表的。
WayPoint
シーン上にTransformとして通過点を複数配置する。敵は0,1,2...とそちらに向かって移動する。点に十分近づいたら次の点へ進み、最終までたどり着いたらゴールとなる。
ルートが固定でデバッグや調整、敵の挙動が安定しやすい。
障害物が途中で出てきた場合は自前で回避処理を設計する必要がある。
NavMesh
地形自体を歩ける領域として計算(ベイクする)して、ナビゲーション用のMeshをまず作成する。敵をNavMeshAgentとして、目的地を与えると自動的に経路を見つけて移動するように調整。障害物や回避行動もある程度自動でやってくれる。
動的な移動処理が必要な要素に向いている。障害物回避、複雑な地形の経路探索などがしやすい。敵がプレイヤーを追う処理などもある程度自動で設計できる
ある程度の習熟が必要。
タワーディフェンスゲームなら、WayPointで良いかなと思うのでこちらを採用してみる
Waypointのしくみ
まず、Wapointには子要素としてTransformをもたせる。それを配列で保持しておく。
[SerializeField] private Transform[] waypointPath;
スポナーの実装などと似ている
EnemyのMove処理部分で
Waypointの配列0のTransformを呼び出す
そちらに向かって移動。
移動し終えたら、currentIndexを++して、次のTransformを取得する。全部終わったら移動完了
code:c#
private void Update()
{
// Waypoint0,1などのTransformポイント取得 Transform target = Waypoint.get(currentIndex);
Vector3 to = target.position - transform.position;
to.y = 0f; // y軸の高さは考慮させない(平行移動させる)
float sqrDist = to.sqrMagnitude;
if (sqrDist <= arriveDistance * arriveDistance)
{
currentIndex++;
if (currentIndex >= path.Count)
{
ReachedGoal?.Invoke();
enabled = false;
}
return;
}
// 移動
Vector3 dir = to.normalized;
transform.position += dir * moveSpeed * Time.deltaTime;
}
ここもベクトル
敵位置 から Target(Waypoint)に移動するベクトルが必要。 Target - 敵 で、角度ベクトルを取得。何も怒らなければ単位ベクトル1の値にnormalizedして、moveSpeedをかけて移動させる。
sqr周り
sqr: 二乗する(square)という意味
Magnitude: 大きさとか、絶対値 のこと。
取得したベクトルの距離はそのままだと√で表された値なので、二乗することで比較しやすくする。
code:txt
# sqrDist <= arriveDistance * arriveDistance
arriveDistance = .2fのとき、たとえば敵とTargetの距離 と Distance^2が
0.038 と 0.04
とかなら、 <= から、たどり着いたとみなしている
道を表現する
Enemyの通るWaypointに道を作る。考えたいことは、以下の内容。
point0 から 1までの向き(道の行き先)
、、 までの長さ(道の長さ)
、、の中央の値(3Dオブジェクトを置くとき、そこを基準にInstantiateする)
不具合
enemyをspawnさせたが動かない
movement.Initializeで渡していたWaypointの引数が違っていた
生成されるオブジェクト位置が、特定オブジェクトの子要素になる
Instantiate(SpawnEnemies[0], spawnPoint);としていたが、これだとspawnPointの子要素として指定している意味合いになっている。正しく調整したいなら、spanPoint.positionとrotationを渡す。(位置を指定したいなら、2つの指定は必須)
道にはTowerが立たないようにする
道が生成されたときにグリッドシステムに書き込まれるようにする。そもそもグリッドシステムをGroundが現在持っているので、GameObjectとして分割することで、道でも地面でも使えるように改善。
もっとシンプルにする
道Prefabを引き伸ばして使っていたが、最初は大人しく、登録したグリッドに、1*1とした道を配置するという設計で良いと思う。
調整。
建てられたTowerが、Enemyを検知して攻撃する
Towerが飛び跳ねて、攻撃する感じにしよう(一定回数攻撃すると壊れる...とか)
敵の被弾
EnemyVfxを用意して、マテリアルを差し替える形で実装する。Mesh Rendererのマテリアルを差し替えればよい(2DはSprite Rendererだった)
敵の速度をStatus依存にする
Towerの感知をベクトルで作ってみる
敵と、自身のTransformのスカラーで判断するようにする。となると、対象となる敵をまず選ばないといけない。lifeTimeで対応する
lifetimeで対応しようと思ったが、素早い敵ができたとき、優先的に攻撃ができない。なので、進んだ距離という考え方で進めるのがよさそう。
ベクトル整理
Tower A とターゲット B とのベクトルを、 B - Aで出して正規化する(向きだけの単位ベクトルが出る。)
Towerの前方とその向きベクトルで内積を出して、視野角cosθと比較すればわかる。
内積: 0.891768 cosθ: -4.371139E-08 とかの値になるケースがあった。cosθが-1 ~ +1の範囲じゃないのでは?となったが、E-08が10^-8という意味合いなので、ほぼ0という値であるということになる。
code:c#
float angleRad = (status.GetViewingAngle()) * Mathf.Deg2Rad; // こうしていたが
float cos = Mathf.Cos(angleRad);
float halfAngleRad = (status.GetViewingAngle() * .5f) * Mathf.Deg2Rad; // こうした
float cos = Mathf.Cos(halfAngleRad);
Debug.Log($"内積: {dot} cosθ: {cos}");
Inspectorの値に90と入れたとき前者だと合計180°になっていたが、直感的でなかったので後者にして、合計90°となるよう(片方45°)として分けた。に
前者の式では10^-08となっていたが、cos90° = 0 になるが、floatで0を表せずに小さな値になっていた
後者の式で出すと、cosθは45° = 2/√2に近い値の0.707が出てくるようになった
右クリックで立てる方向を回転させたい
enumでtowerに状態をもたせるか。ゴーストか、平常か。ゴーストが攻撃してしまうので、ゴーストの場合は攻撃処理を切るようにする。Ghostで右クリックで右を向くようにして、配置時にその向きを考慮させて配置する
code:c#
# 基本
Quaternion delta = Quaternion.Euler(0f, 90f, 0f); // y軸90°
targetRotation = targetRotation * delta;
# Updateとかで、Slerpで徐々に回す処理
transform.rotation = Quaternion.Slerp(
transform.rotation,
targetRotation,
Time.deltaTime * rotationSpeed
);
攻撃範囲の可視化
Towerの子要素として、攻撃範囲ビジュアルを用意。Mesh FilterとMesh Rendererを持たせる。
半透明な表示は、半透明 赤色のMaterialを作ってMesh Rendererに割り当てれば良い。
扇状のMesh Filterはスクリプトで作る。扇状に作るということを理解してみよう
https://scrapbox.io/files/6974b49ad65bbd2b592b3dc5.png
まず、大前提としてメッシュは「頂点」と「三角形」の集合
vertices: (3D空間上のVector3の点の配列
triangles: vertices(頂点配列)の何番、何番、何番を繋いで三角形にするか、という番号の配列
code:cs
float range = status.GetAttackRange();
float angle = status.GetViewingAngle();
float half = angle * 0.5f;
halfは、forwardを正面(扇形の中心)としたいので、角度範囲を±halfとするために取得。
segments: 円弧の曲線を、何分割の直線で近似させるかの数値。上の画像例は3。
code:cs
int vertCount = 1 + (segments + 1);
vertCount
頂点の数。segmentsが3なら、5つで表せる(図形の特性)。頂点を数えてみると分かる
segmentsが3なら、外周の点は4つ。4なら5つと、増えていく
vertices: 頂点ベクトルを入れる配列
triangles
三角形は3つの頂点で表すので、segmentsの枚数 * 3としておく
vertices[0] = new Vector3(0f, yOffset, 0f);
扇形の中心点を決める。オブジェクトのローカル座標から、すこしだけYを浮かせて地面に浮かび上がらせる
code:cs
for (int i = 0; i <= segments; i++)
{
float t = (float)i / segments; // 0..1
float a = Mathf.Lerp(-half, half, t); // -half..half(度)
Quaternion rot = Quaternion.Euler(0f, a, 0f);
Vector3 dir = rot * Vector3.forward; // forward基準
vertices1 + i = dir * range + new Vector3(0f, yOffset, 0f); }
segmentsの数 + 1だけ、外周の円弧を作る。
3の場合、4つ点が必要なので、0から始める
tに、iがいまセグメントのどのくらいかを正規化した値を入れる。
3なら、0.33まで来てるとかそんな感じ
要するに、現在円弧のどのくらいのところまで来ているかの割合を入れている
a = Mathf.Lerp(-half, half, t)
例えば90度なら、-45から+45まで、tの割合に応じた値を入れる
今回のループで作る角度を決める。
tが0なら-half, 0.5なら0, 1なら+halfの値が入る
クォータニオンでa°に傾く回転を作る
rot * Vector3.forward;で、forwardをa°傾けたベクトルを得る
vertices[1 + i] = dir * range + new Vector3(0f, yOffset, 0f);
vertices[1 + i]: もうi=0の中心の頂点ベクトルは静的に定義しているので、+1から始める
単位ベクトルに、攻撃距離(range)をかけて、オフセット分浮かせる
code:cs
// 三角形:扇(中心0, i, i+1)
for (int i = 0; i < segments; i++)
{
int tri = i * 3;
trianglestri + 0 = 0; // 中心点は固定でvertices0を使うということ }
trianglesは、vertices(頂点配列)の何番、何番、何番を繋いで三角形にするか、という番号の配列(おさらい)
1ループで、配列[0][1][2], [3][4][5]と入れていく形になっている
i = 0のとき
三角形を、0番目の頂点(中心)と1番目の円弧頂点(0)と、2番目の円弧頂点(1)で作る
i = 1のとき
三角形を、0番目の頂点(中心)と、2番目の頂点(1)と、3番目の円弧頂点(2)で作る
敵のy軸スポーン位置
scale * 0.5の高さに湧くので、y軸に + (scale * 0.5) + 床の厚みとしてやればよいだろう。床の厚みは0.02f。
厳密には、敵のスポーンy座標 = (scale * .25f) + (床の厚み * .5f)っぽい
生成時、scale * .5f分埋まっている。
scaleを2にすると、上下に1ずつのびる
今回は上方向に0.5あげたかったので、.25すれば、単純に倍の数だけ上に上がる
というような形で設計したが、scale依存で設定するのは良くないらしい。ちゃんとColliderのbounds.extents.yという高さデータを使うのが定番。
EnemyARootの親にColliderを使って、かぶせる(capsuleを使った。1*1なら、radius0.25, height0.5で上下に伸びるので足りる。)
code:c#
var go = Instantiate(SpawnEnemies0, spawnPoint.position, spawnPoint.rotation); // 敵のスポーンy座標 = (Colliderの高さの半分) + (床の厚み * .5f)
float spawnHeight = 0f;
var col = go.GetComponent<Collider>();
if (col != null)
spawnHeight = col.bounds.extents.y;
spawnHeight += RoadGenerator.RoadThickness * .5f;
go.transform.position = spawnPoint.position + Vector3.up * spawnHeight;
spawnPoint.position + Vector3.up * spawnHeight;
(0, 1, 0)のベクトルに、高さの補正値をかけて(0, 0.5, 1)とかそういった値にしている。
敵が複数Visualを持っている場合の、被弾と移動
被弾
特定の箇所しか光らなくなっているので、すべて差し替えるようにする。
code:c#
mr = GetComponentInChildren<MeshRenderer>();
originalMaterial= mr;
こうしていたが、これをforeachで埋めていく。ただし、C#のforeachは、key => indexというような書き方ができないので、固有の書き方が必要になる
code:c#
private MeshRenderer[] mrs;
private Dictionary<MeshRenderer, Material[]> originalMaterials;
# Awake
foreach (var mr in mrs)
originalMaterialsmr = (Material[])mr.materials.Clone(); # Damage時
## 差し替え
foreach (var mr in mrs)
{
if (mr == null) continue;
var materials = mr.materials;
for (int i = 0; i < materials.Length; i++)
materialsi = onDamageMaterial; mr.materials = materials;
}
yield return new WaitForSeconds(onDamageVfxDuration);
## 元のマテリアルに戻す
foreach (var mr in mrs)
{
if (mr == null) continue;
if (originalMaterials.TryGetValue(mr, out var original))
mr.materials = original;
}
mrに付属するマテリアルを、事前にmrをキーにして格納しておく。今回の場合はキーにした配列に、さらにMaterial[]配列を入れる形になっているので、Cloneしたあとにキャストして入れている(MeshRendererにはマテリアルが複数付与するケースがあるので)。元のマテリアルに戻すときの処理で、originalMaterials.TryGetValue(mr, out var originalとして、foreachで回している要素そのものをキーにして、Dictionaryで管理している配列から値を取得して割り当てている。
移動
速度を出している方向に向いてほしい。仕様を決める。基本的に右から左に向かう。敵の顔は-x軸につけるようにするとよい(このあたりは開発初期から決めておくのが良さそう)。
敵の顔は-x軸につけるようにするとよい
こう書いたがダメ。これは回転処理などで混乱の原因になる。ワールド座標を前提にスクリプトがrotateするので、基本軸に沿った形でモデルは設計するのが基本。環境に依存させず、回したいならスクリプトで制御する
どう移動を表現するのか。WayPointの書き換えのときに、そのWayPointの座標に応じてLookRotationしてやればいいのではないだろうか。
code:c#
// 敵(A)とwaypoint(B)の向きベクトルの取得。 AがBに向かうので、 B - A
Vector3 to = target.position - transform.position;
private void Move(Vector3 to)
{
// 移動
Vector3 dir = to.normalized;
float moveSpeed = 0f;
if (status != null)
moveSpeed = status.GetSpeed();
transform.position += dir * moveSpeed * Time.deltaTime;
transform.rotation = Quaternion.LookRotation(dir);
}
これでいいのでは、と思ったらあらぬ方向を向いてしまった。なぜだろう
https://scrapbox.io/files/69777d00d46812865dbeb7ee.png
EnemyARootを見ると、Rotation.yが-90°となっている(0°が、デフォルト右向き)
https://scrapbox.io/files/6977810a70ed67f87e20ead3.png
モデルが悪い。今回、画像のように作っている
transform.forwardなど、そういった関数を使いたいなら、凸という向きにしなければならない。forwardはワールド座標の↑を向くし、右を向く処理なら→を向く。今回元々右に90°ズレたモデルだったので、向き先をQuaternion.LookRotation(dir)で調整したら、ズレて上に向いてしまった。正しく、↑に凸になったモデリングにする。
https://scrapbox.io/files/69778247514011c6ec257eea.png
これでOK
https://scrapbox.io/files/6977826cdf1b80cff76e3144.png
コードはそのままで向いた!
transform.rotation = Quaternion.LookRotation(dir, Vector3.up);とした
LookRotationは、directionを指定しただけでは回転は1通りには決まらない。Vector3.upを指定することで、「上 = ワールドの上」とする、ということを引数で渡して明示的にしている。なお引数を指定しない場合は内部的にVector3.up相当の値が格納される
Quaternion.LookRotation(dir, up)は、z軸=0(forward)を、dirに向ける。そのとき、transform.upが、指定したupにできるだけ一致するようにねじれを決めるというような意味合いになる。3次元空間で球を3つ作って、クリックしたら呼ぶような処理を書けばもっと分かるかも。
Vector3.rightを指定すると、前進中は変わらないが、下に進むとこんな感じになる
https://scrapbox.io/files/6977862e8576eed5fd037f38.png
回転を滑らかにしてもいい(慣れも兼ねて)
code:c#
private Quaternion targetRotation;
private float rotationSpeed = 10f;
# Updateなど
Vector3 dir = to.normalized;
if (dir != Vector3.zero)
targetRotation = Quaternion.LookRotation(dir);
transform.rotation = Quaternion.Slerp(
transform.rotation,
targetRotation,
Time.deltaTime * rotationSpeed
);
こんな感じで、Quaternionとして目的地を保持(常に変わらないようにする)
バー作成
敵のヘルスバー
Scene全体にCanvasを配置して、ついでにEventSystemも作っておく。敵の子要素に子Canvasをもたせ、HealthBarUIを追加。Sliderで作成。こちらをEnemyHealthから取得して操作すれば良さそうだが。
こちらで問題ない。ただし、敵に持たせたバーが敵の動きに追従しない。理由は敵の子Canvasの設定が正しくないから。World Spaceに変更。また、Event Camera = Main Cameraに設定。
Render Mode: Canvasをどの座標系、描画方式で表示するかを決める設定。
Screen Space - Overlay: スクリーンに直接描画。メニューや固定HUD(ヘッドアップDisplay: スコアとか)
Screen Space - Camera: 指定カメラの全面に張り付く形で描画される。VRとか。
World Space
Canvasをオブジェクトとして扱う。Transformが3Dオブジェクトと同様に効くようになる。
ビルボードの設定などと一緒にして使う。
真横から見たら見えなくなるので、ビルボード設定(カメラを常に向くように)をしよう
? Canvasが大きかったり、3D空間でヘルスバーの設定が難しい
2Dモードにするといい感じかも。
https://scrapbox.io/files/6978c9d7af1d9451580c78ed.png
Towerの攻撃間隔バー
attackTimerを減らして動作させている。5秒なら、-= Time.deltatimeして、0を切ったら攻撃。つまり、0になったらゲージをためておく必要がある。シンプルに考えればよく、1をマイナスしてその結果を渡せばよい
code:c#
# 体力
healthBar.value = currentHp / status.GetMaxHp();
# ゲージ
float calculateValue = 1 - (attackTimer / status.GetAttackInterval());
https://scrapbox.io/files/6978d4857134729878013100.jpeg
GameManager作る
お金と経過時間(elapsedTime)を持たせて、画面に出す。出せたら、建築するとお金が減るように、敵を倒したらお金を得るようにしよう。
BuildControllerを調整して作ってみたが、GameManager.Instance.ReduceMoney()などを建造処理中に呼んでいる。これはGameManager.Instanceという取得方法となり完結するかどうかが別のクラス(GameManager)に依存する。どう呼んでお金の管理をすればきれいに仕上がるのだろう?
今回タワーを建築する処理なので、タワーについてはEconomyManagerというルールで実装。このManagerにはIEconomyというインターフェースを付与する。
今まで
BuildControllerから、建築時点でGameManager.Instance.TrySpend()を実行
インターフェース導入後
BuildControllerでインスペクタからIEconomyをキャスト。支払い要求をする。決済ルールに従ってEconomy側がeconomy.TrySpend()を実行
これによりBuildControllerは、インスペクタの参照切り替えだけで、どういった決済方法でのTrySpendで決済していくか決定できるようになった。
ライフ(10体に抜けられたら終わりとか)
PlayerBaseを作った。地面部分にColliderを用意。OnTriggerEnterで、敵が検知できたらダメージを受けるように。OnTriggerEnterはスクリプトを付与したGameObjectがColliderを持っていないといけないので、Wall_Groundに付与した。またこれだけでは動作しなかったので、RigidbodyをIs Kinematicでつけて、Is Triggerをtrueとした。敵にとってのゴールを管理するのがBaseGoalTrigger。
OnTriggerEnterしたら、またGameManager.Instance.Damage()などをやると↑と同じような懸念が生まれる。なので、
ILifeを作る
LifeManagerを作って割り当てる
BaseGoalTriggerにはLifeManagerをインスペクタで割り当てて、その中からILifeを取り出す
そのILifeに沿って、ダメージ計算をする
こちらの流れで進めた。
ライフが0になったとき、ゲームオーバーにする
LifeManagerでイベントを作り、ゲームオーバーにする側で購読させる。GameManagerとして今まで作っていたが、結構責務を分割しているので、これもStateManagerという名前に改名してよい。そうする。
PlayerBaseをグリッドシステムに登録できるようにする
RegisterGridSystemみたいなスクリプトをつくって設置物に貼り付ける。Start時に接地している地面のグリッドを登録できるようなスクリプトがあれば汎用性が高そう。
作ったグリッドシステムは、RegisterBlockedCell(Vector2Int cell)の通り、座標を渡せば登録できる
つまり設置物では、接触している座標をforなどで回して登録していけば良い
実装してみる
まず、設置場所にColliderを割り当てる
code:cs
Bounds b = targetCollider.bounds;
Debug.Log(b);
// Bounds: コライダーがWorld空間で締める範囲を箱で表したもの
// ex) bのログ => Center: (-8.50, 0.00, 0.00), Extents: (1.50, 0.10, 2.00)
// 中心 -8.5から、xに±1.5, yに±1, zに±2伸ばしたということ
↓をベースにColliderをつくってたので、Scaleと比較すると、きれいに半分ずつになっている
https://scrapbox.io/files/697b080015334def863bf211.png
Boundsの値から、セルの値を知る
code:c#
// boundsのXZの最小/最大から候補セル範囲を得る
Vector2Int minCell = gridSystem.WorldToCell(new Vector3(b.min.x, 0f, b.min.z));
Vector2Int maxCell = gridSystem.WorldToCell(new Vector3(b.max.x, 0f, b.max.z));
Debug.Log(minCell);
Debug.Log(maxCell);
Debug.Log($"{b.min.x} {b.min.y} {b.max.x} {b.max.y}"); // -10 -0.1 -2 -7 0.1 2
https://scrapbox.io/files/697b0aae24fc84c9a4ad7a07.png
Boundsから得られたワールド座標のxとz(左下から右上)を、セルに変換して取得
今回の場合、コライダーとオブジェクトの形をきれいにしているから似た値が入っているが、小数とかの場合はGridSystemのworldToCellで丸め込まれる
https://scrapbox.io/files/697c6691c1936dc6b0c4e2f7.jpeg
こんな感じで判定。z軸も同じベースで。
敵の出現パターンをScriptableObjectで管理する
役割
スポナー
スポーンポイントを持ち、そこに敵をスポーンさせる責務を持つ、敵のスポーン情報は引数からもらう。
Wave
ウェーブ開始 / 終了のdelay時間、ボスウェーブかどうかを持たせる。また、湧く敵を決定できるようにする。
Stage
Waveを配列で所持。Waveが終われば次のWaveを出すという責務を持つ。
STAGE_MANAGERを用意
StageConfig.wavesをforeachで回していく。wavesには敵Aのグループ(3体を1秒おき), 敵Bのグループ(2体を5秒おき)など、配列で管理されているので、foreachの中で、さらにgroupでforeachを回していけばよい。インターバルの数だけcoroutineで待機して回していく
ゲームの流れやUIを考える
タイトル
操作説明(パネルで)
ゲーム開始
ゲーム画面
編集モード。まず、時間が止まって配置画面。好きなだけ配置してもらう
開始。
途中でESCボタンとかを押すと、編集モードと戦闘を切り替えられる
こんな感じかな?
https://scrapbox.io/files/697caaeb2e859afacede5585.png
ガワだけ用意
とりあえずUIは詳細に作るのは後回しにして、ゲームのループ性を作る。
まず最初は、Editから始まる
敵を倒す or 時間が立つとお金がもらえる。ボスウェーブを倒せばクリア。
建てるときのカメラ。
NewInputSystem導入
今まではPlayerが存在したが、今回の場合は存在しない。Inputを受け止めるObjectを用意する
NewInputSystemはコンポーネントに割り当てるものではなく、使うScriptでクラスとして呼んで使うもの
GameInputオブジェクトで、イベントにして処理を進めていく
inputの受付を整理する
BuildController
回転、設置などを決める
StateManager
ゲーム全体の状態を、ESCで変える
StateManagerの整理
Editのとき、ゲーム内時間を停止する
まずはデバッグしやすくするために、Stateを画面に出すように調整。
InputSystem周り その2
Editモード以外は、GhostをDisableにする
Demolish時、タイルの色を赤にする
マウスポインタ位置の取得もInputSystemで定義する
Input ActionsのGlobalに、Pointer Positionとして設定。Action TypeはValue, Control TypeはVector2。
https://scrapbox.io/files/6980c1841239eb34fd95910e.png
code:c#
# GameInput.cs
public Vector2 PointerPosition { get; private set; }
# 使えるようになった
// Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
Ray ray = mainCamera.ScreenPointToRay(gameInput.PointerPosition);
アイコンを選択したら、設置予定のものにPrefab, パネル情報を変える
パネル
アイコンや記載する文章をSerializeFieldで持つ。
ユニットセレクト
現在選択中のユニット情報を持たせるクラス。
ユニット情報(SO)
Definitionでアイコンやホットキーをもたせる
Catalogでそれを一覧で持つ
UnitIcons
カタログをもつ
カタログごとに、GameObjectを作っていくようにする
UnitIcon(単体)
子要素をSerializeFieldで持ち、値をセットするセッターでInstantiateされる時にセットする
InputSystemで数字キーを持つ
https://scrapbox.io/files/6985717192170d43922e2849.png
1つのActionsに複数持たせる設計にしてしまって良い
code:GameInput.cs
# 押下時イベント
public event Action<int> SelectUnitRequested;
# OnEnable
Input.Edit.SelectUnit.performed += OnSelectUnitPerformed;
# SelectUnitで登録された数字キーの数字を返しつつ、イベントを実行
private void OnSelectUnitPerformed(InputAction.CallbackContext ctx)
{
// ctx.control.name には押されたキーの名称("1"や"2")が入る
if (int.TryParse(ctx.control.name, out int slot))
SelectUnitRequested?.Invoke(slot);
}
こんな感じで、購読イベントのInvokeのタイミングで、どのキーが押されたかを一緒にoutで通知
code:UnitIcons(購読側).cs
# OnEnable
gameInput.SelectUnitRequested += OnSelectUnitRequested;
# 購読イベント slotには、押されたキーが格納されている
private void OnSelectUnitRequested(int slot)
{
// カタログの中から、HotkeySlotが一致するものを探す
foreach (var unitDef in catalog.unitDefinitions)
{
if (unitDef.HotkeySlot == slot)
{
selection.Select(unitDef);
break;
}
}
}
これで指定ができる
UI(Canvas)と地面判定の貫通について
↓のように、アイコンにマウスを合わせると、Raycastが地面に反応してしまう
https://scrapbox.io/files/6987200209bbfe60c62121e6.png
code:c#
if (
EventSystem.current != null &&
EventSystem.current.IsPointerOverGameObject()
)
return;
なにか格納されていて、それがCanvasの情報かどうかで判定させればOK
建築時のカメラ
ちょっとズーム + マウス(グリッド?)に沿って動く感じ
ゲーム開始時はeditなので、ズーム
2種類のCinemachineカメラを用意する
Camera Managerを用意して、その2つをSerializeFIeldで管理。Priorityの値で切り替える
全体カメラ
ゲーム中はこっち
ビルド用カメラ
ちょっと近づく。 マウスに沿って追従する
Position Controlで、デッドゾーンなどを考慮させる設計にしておく
https://scrapbox.io/files/69873f7e2e3785448f41f910.png
編集モード中は時間が止まるが、カメラは動かしたい
Main Cameraに付属しているCinemachine Brainで、Late Updateとして、Ignore Time Scaleとすれば良い
カメラを素早く切り替える(ブレンディング)
Cinemachine Brain のDefault Blend のStyleで、秒数を指定できる。
メインカメラのpositionが変わらない理由
Buildカメラを、TargetPositionに追従される形にしていてそれのpriorityが一番高いのが理由。なので、そのままで良い
ユニット情報のパネルを編集モード中だけ出す
Stateが変わったときのイベントとすれば良い
カメラもっと
TrackingTargetに指定していても、Rotationは変更できる(Positionは固定だが)。真上見下ろし視点にして、マウスホイールでズームとかができるようにしよう
マウスホイール
InputSystem。値は2次元の値で保持されるので、Control TypeはVector2にしておく。Scrollを割り当て。
GameInput側でホイールの値を受け取る
code:CameraManager.cs
# Awakeで取得
buildPositionComposer = buildVCam.GetComponent<CinemachinePositionComposer>();
private void OnZoomRequested(float scrollDelta)
{
// 現在の距離を取得して、スクロール量に応じて計算
// scrollDeltaが正(上回転)なら近づける(マイナス)、負なら遠ざける
float currentDistance = buildPositionComposer.CameraDistance;
float targetDistance = currentDistance - (scrollDelta * zoomSpeed);
// 範囲内に収める
buildPositionComposer.CameraDistance = Mathf.Clamp(targetDistance, minDistance, maxDistance);
}
現在のCameraDistanceの値を、スクロールで渡される値に応じて調整すればOK
カメラのTracking速度をもっと素早く
Dampingで調整できる(0にすると、素早くなる)
クリックで選択できるようにする
BuildController側で、発火を避ける処理が書かれているので、UIのクリックイベントだけ発火させるようにする
UnitIconに、Buttonコンポーネントを追加。ターゲットにImage(アイコン画像)を指定。この時点でクリックすると暗くなる設計ができる
code:UnitIcon.cs
public void SetInfo(UnitDefinition def, Action<UnitDefinition> onClickAction)
{
cost.text = $"¥{def.Cost}";
image.sprite = def.Icon;
// リスナー設定
button.onClick.RemoveAllListeners(); // 一度外して
button.onClick.AddListener(() => onClickAction(def)); // 受け取ったアクションを実行
}
アイコン単体は、「クリックされたとき、渡された関数を実行する」という処理になっている
code:UnitIcons.cs
# ...
UnitIcon unitIcon = createIcon.GetComponent<UnitIcon>();
if (unitIcon != null)
{
unitIcon.SetInfo(unitDef, (selectedDef) =>
{
selection.Select(selectedDef);
});
}
UnitIconに、selection.Selectを渡して、クリックされたときはselection.Select()を走らせるという形で実装している。
UnitSelectionで選ばれたデータがGhostとして出るようにする
現在、towerPrefabという変数を固定で持たせて、そちらを運用している
unitSelectionが使えるので、これを変数として用意。
Ghost作成時それをInstantiateしてマテリアルを差し替えていたが、unitSelection.Selected.UnitPrefabを使うことにする。
建築時も同じようにtowerPrefabを建てるのではなく、UnitPrefabをベースに立ち上げを行うようにすればOK
Ghostが出ているマウスカーソルの状態で、数字キーが押されて変わったとき
unitSelectionに、変わったことを通知するイベントがある。そちらにいかにベントを購読させる
Ghostをまず消して、
引数として渡されるUnitDefinitionをチェック
問題なければ、EnsureGhostでゴーストを作る
エラー改善
code:c#
if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject())
return;
Calling IsPointerOverGameObject() from within event processing (such as from InputAction callbacks) will not work as expected; it will query UI state from the last frame
UnityEngine.EventSystems.EventSystem:IsPointerOverGameObject ()
BuildController:PressConfirm () (at Assets/Script/BuildController.cs:308)
IsPointerOverGameObject()
UIの上にマウスがあるかどうかをまだ判別できておらず、動かないかもしれないという警告が出ている
なので、Update中などに、UI上にマウスがあるかどうかを判定するフラグを作ってやると解決する
お金が足りないとき、赤く表示させよう
DOTweenで点滅させる。
配置された瞬間に攻撃処理が発生するのを防ぐ
改善できているっぽい
フリー素材の3Dモデルを、ユニットや敵として使ってみよう
.fbx, .objのモデルを探す。Unityに入れる。
色がついていない場合は、Extract Materialsでマテリアルを抽出。(抽出先フォルダを選ぶ)
そのマテリアルの、Surface Inputsなどに、付属のカラーマップをつけてやると色がつく。
https://scrapbox.io/files/6989fcaf4723b24710d112ef.png
どういう仕組みだろう?
メッシュがただの灰色の塊。テクスチャは画像データ(今回の場合、colormap)。マテリアルというのが、テクスチャをつかってどんな質感でメッシュを包むのかを決めるデータ
UVマッピング
UV(モデルを二次元に展開した図)に、ここはこの画像のこの座標の色を使って塗る、というようにデータを作る。それをカラーマップとして読み込ませることで、正しく色がつくようになる。
フリー素材で空や地面もそれっぽくしてみる
空
Skyboxという概念で呼ばれる。天球の壁紙みたいなもの
今回、UnityPackageで落としてみた。
こちらでインポートして、Skyboxのテクスチャが取得できる。
地面
bmpファイルを落とした(g2)
エラー文章
パネルに出せば良いだろう。
IEconomyに登録した支払い失敗処理をHudで読ませて、更新表示させる
? " その位置には配置できません"みたいなものも欲しい
地面のアセット用意
いい感じ
アイコンをjpgにして用意
ノックバック機能
TowerStatusに、kbPowerを用意する。combat時に、攻撃力と一緒にその力成分も渡す
ダメージを受けたあとに、enemyMovementのノックバック関数を走らせる
code:c#
public void ApplyKnockback(float knockbackDistance)
{
isKnockedBack = true;
Vector3 knockbackDir = -transform.forward;
transform.DOMove(knockbackDir * knockbackDistance, 0.05f)
.SetRelative(true)
.OnComplete(() =>
{
isKnockedBack = false;
});
}
一番先頭にいる敵を攻撃する
TraveledDistanceで判定していたが、ノックバック処理が挟まると、うまく行かなさそう
設計を変えて、ゴールまでの距離をStart()時点で測定して、コース総距離 - ゴールまでの残り距離となるような設計とした
タワーユニットの種類
歩兵
弱めでコスパが高い
弓
遠距離。攻撃速度が遅い。
力系
近距離。攻撃速度が遅い。火力が高い。
範囲系
攻撃速度が早い
特殊
周りバフ
体力低いやつ優先
かたつむり
飛行する敵に威力倍とか?
ダメージ出す
単純にTextMeshProでオブジェクトを作って、BillBoardのスクリプトを割り当てる。
呼ばれたらInstantiateされるような形で作る
背景に置くもの作る(ちゃんとGrid登録されるように)
OK
設置アイコンとかをいい感じにする
OK
街Prefabを用意する
ユニットの最大配置数を決められるようにする
設定。
? const定数でレートを設定したので、Unityの設定でビルドしても更新されないかも。
最大建設上限のときはパネルにエラーを出すようにする
Action Eventで出した。好ましい設計かどうかチェックはしておこう。
所持金エラーと同じように、上限数をゆらす。
⭐️イベント購読おさらい
code:ハンドラで包む場合.cs
# event
event Action<string> OnInsufficientFunds;
# OnEnable, OnDisable
economy.OnInsufficientFunds += HandleAnimateInsufficientFunds;
economy.OnInsufficientFunds -= HandleAnimateInsufficientFunds;
private void HandleAnimateInsufficientFunds(string _)
{
AnimateInsufficientFunds();
}
// お金が足りないとき、赤くして、揺らす
private void AnimateInsufficientFunds()
{
# ...
code:ハンドラで包まない場合.cs
# event
event Action<string, BuildErrorType> BuildErrorOccured;
# OnEnable, OnDisable
buildController.BuildErrorOccured += (message, _) => DisplayErrorMessage(message);
buildController.BuildErrorOccured -= (message, _) => DisplayErrorMessage(message);
private void DisplayErrorMessage(string message)
{
# ...
? ハンドラで包むパターンと、ラムダで購読するパターンどちらが好ましいか?
ハンドラで包むべきである。ラムダは使い捨ての関数なので、+=で購読したものと-=で解除したイベントは内部的に異なりメモリ上では全くの別物として扱われる。つまり、-=でも解除されておらず、メモリリークや多重実行バグの原因になりうる。
ラムダで購読してもいいパターンは存在するが、イベントは基本的にハンドラで包み、同一であることを明示する。
ノックバックの解消
道から外れて吹っ飛ぶのを、なんとかできないだろうか。
1から治すには、移動の処理自体を書き直さなければならない。
→とにかくたくさんふっとばして距離を伸ばすのではなくて、スタンという形で実装すれば同じ意味になりつつ設計もできる。
敵を倒すとお金がもらえる
敵にお金を持たせて死亡イベントを用意し、EconomyManagerが購読するのが考えられるが、動的に生成されるオブジェクトにイベント購読させるにはどのように行うのが綺麗だろう?
敵が経験値を持っていて、GameManagerが仲介してPlayerLevelに渡していた
今回は、SOアーキテクチャパターンを採用してみる
イベントだけ持たせたSOを作る。
code:cs
public event Action<int> OnEventRaised;
public void RaiseEvent(int value)
{
OnEventRaised?.Invoke(value);
}
敵ObjectにそのSOをもたせるSerializeFieldを定義。EnemyHealthで記述する
code:c#
// 金額データを流すためのもの
private void Die()
{
isDead = true;
if (onEnemyDiedChannel != null)
onEnemyDiedChannel.RaiseEvent(status.GetMoney());
Destroy(gameObject);
}
死亡時、RaiseEventを実行
EconomyManagerにもSOをもたせる。その後、イベントに購読。
code:cs
private void OnEnable()
{
if (onEnemyDiedChannel != null)
onEnemyDiedChannel.OnEventRaised += AddMoney;
}
private void OnDisable()
{
if (onEnemyDiedChannel != null)
onEnemyDiedChannel.OnEventRaised -= AddMoney;
}
public void AddMoney(int amount)
{
money += amount;
MoneyChanged?.Invoke(money);
}
Enemy死亡時、SOのメソッドに値を渡して実行
SOのメソッドは、購読イベントを実行するだけ。その際にイベントに値を渡す
Economyは、そのイベントに反応してAddMoneyが行われる。
どう良い設計か
たとえばGameManagerとかEventManagerを作る。EnemyがDieしたとき、EventManager.AddMoney(amount)とすると、ゲーム全体を管理するイベントクラスをEnemyが実行することになり、依存性逆転が発生する。ただ、今回はSOが仲介役を担ってくれているので、それを防いでいる。
お金のエフェクトを出す
+ ¥100 みたいな感じでよいだろう
ダメージと同じように設計したら、Vfxが複数出る不具合に遭遇した。なぜだろう?
Destroy()で敵を消しているが、「フレーム処理がすべて終わったら破棄する」という仕組みになっている。そのため、倒された同フレーム中に別のユニットが攻撃したとき、同様にDie()メソッドが走り死体蹴りが起こっていた。なので、味方の攻撃時と敵の被弾処理にisDeadの死亡フラグチェック処理を追加すれば良い。
クリア / リトライ のGameUIを作りつつ、ループ性をもたせる
ゲームの流れ
タイトル画面
プロローグ or ゲーム開始
ゲーム画面
操作説明UIを用意する。クリックで開く, 閉じる。いつでも出せるように。
Editで出せるようにする。ついでにタイトル画面にも仕込んでおく(同じキャンパスをPrefabで使おう)
ゲーム確定時
クリアのフラッシュ / ゲームオーバー時の画面揺れなどは、使いまわして用意してしまおう。
ゲームオーバー実装
画面を揺らす
とりあえずゲームオーバーイベントにカメラを購読させる。
Impulseの設定
CameraManager自体にSourceをつけて、揺れを受け取るカメラにListenerを付与する形になる。ここが間違っていると揺れない。インスペクタで割り当てる。
ListenerはAdd Extensionから選択してセットすること。
https://scrapbox.io/files/69928b6a3583dfb074d19450.png
画面を揺らす設定は一旦、Default VelocityをScript側で指定する設計で考えた
ゲーム終了時の挙動を調整する
ゲームオーバー時は、リトライ or タイトルでいいと思う
リトライ処理
UIの一部のボタンが、シー全体の要素をリセットすることになる。これは責務の点からあまり好ましい設計とは言えない。UIの責務はユーザーの入力を受け取ること、画面を描画することなので、シーンリセットは責務外になるケースがある(ただし実際は手っ取り早いのでそうすることもある)。
リトライボタンとStateManagerをイベントで接続すれば良い。ただ実際には画面の色々なところにリトライボタンが出ることになるだろう。その場合、1つ1つのリトライボタンとStateManagerを購読させなければならなくなる。なので、staticを使って、どのインスタンスのリトライボタンが押された場合でも対応できるようにしよう。
code:cs
# RetryButton.cs
public static event Action OnRetryRequested;
public void ClickRetry()
{
OnRetryRequested?.Invoke();
}
# StateManager.cs
RetryButton.OnRetryRequested += ReloadScene;
クリア時は、リザルトを出す
クリア条件は、BossWaveで、画面上に敵がいなくなったらクリア。ただし敵がいなくなった瞬間 = 倒した or 陣地に入られてライフが削られた、ということなので、その場合はゲームオーバーにする設計を考えよう。
StageManagerにクリア通知イベントを持たせてステージに存在する敵をチェックさせようと思ったが、懸念がある。ウェーブ進行やスポーンさせることが責務のため、クリアは責務外である。なので、「最終ウェーブの敵がいなくなった」という事実だけをイベントで鳴らしてやるのが良い。Stateなどでそれを受け取り、状況次第でクリア or ゲームオーバーか判断させるという仕組みがきれい。
仕組み
敵がDestroyするとき、破棄イベントを実行
Stage側でスポーンしたときに↑のイベントを購読。
スポーンするときにカウント++して、このイベントが呼ばれたら--とする
ボスウェーブがおわって、カウントが0になったら、すべてのウェーブが終わったというイベントを鳴らす
StateManager側で、すべてのウェーブがおわったときに何をするかを決める。ゲームオーバーか、クリアかなど。
ボスウェーブを判定できていない
スポナーがたまに反映されない不具合と関連している。同じWaveが2回走っているように見える
for文内部の問題だったので修正。
クリア演出
ばん、ばん、ばん、とテンポよく画面に出す。
コルーチンで順々に出していく
お金がだらららら....という演出を作る
ランダムより、増えていくほうが面白いかな。DOTweenで実現できる。
バグ: ノックバックするとき、ちゃんと道に沿うようにする?
ノックバックさせたとき、敵が浮いてるかも
厳しそうなので、スタンで対応。
操作説明を作る
Canvasで、Prefabにしていつでも呼び出せるようにしよう
構成
物語を最初に乗せる
城に雇われた傭兵がいて、貧乏。モンスターの討伐依頼を受けて事前金としてxxx円もらえた。
これをうまくやりくりすれば儲かるのでは。ただ、城が襲われてしまっては元も子もない。なるべくお金を稼いでクリアしよう!
チュートリアルページ1
モード切替と、破壊
ページ2
ウェーブやHUDの説明。ライフの説明。どう減るかとか。
ユニットの紹介。
dimmerで黒くする
Spriteではなく、Imageを付与する。
アイコン用意
ノーマル d6d6d6
普通 76d15a
強い 4383bf
エピック c17cd9
レジェンダり ffef5e
チュートリアルを開いている間は、ESCやマウス操作を遮断する
inputsystem分離, statemanagerでリザルト中に操作とかできるのを制御する
Tutorialというマッピングを有効化して、チュートリアルの開閉で切り分ける
Tutorialが開いたら、CinemachineのTracking Targetなどの設定も調整する。
パネルに攻撃力なども出す
出した
costがstatusとdefinitionで二重管理になっているのでやめる
status側で管理するようにした。あと、floatとintで混在していたのを直した。
次回からはちゃんと、コスト系は最初から定義を明確にしよう
ほめごろす
紙吹雪をいれてみよう。そもそも、どんな設計で作られているのだろう?
code:イメージ
・Start時、三角形のぺらぺらのオブジェクトを作っておく
・クリアしたとき、加速度を渡して発射。
・ひらひら落ちる
ただし、これはパフォーマンスにかなりの懸念がある。オブジェクトの大量発生、Rigidbodyをすべてに割り当てる必要があるなど。
Unityが用意してくれている機能があるのでそれを使おう。Particle System
ヒエラルキーで(GameObjectを作るいつもの場所)右クリック > Effects > Particle System
Renderer を開いて、Mesh, Emissionを開いてBurst, Rotation Lifetimeなどで好みの動きを決めていく
code:c#
if (leftParticle != null) leftParticle.Play();
これで再生できる。timeScale = 0でも再生したい場合は、deltatimeをunscaleとする
https://scrapbox.io/files/699b14513583dfb074dd9727.png
戦闘シーンの調整
いきなりEditから始めるのではなく、軽いDimmerを建てて、とにかく操作説明だけ入れよう
https://scrapbox.io/files/699bf7743583dfb074de6541.png
敵のモデルを探してみる
モーションをつけると大変なので、オブジェクトで
インポートの手順
スライムとか基本キャラを用意して、3 * 3色(青黄赤)で9通り用意すれば手っ取り早い
タイトルぎめ
やとわれ傭兵がお金を稼ぐ
節約軍師 みたいな...
軍師ちゃん とするとちょっとダサくなくなるかもしれない
poverty
tactician
チュートリアル
前金としていくらもらった。出来るだけ予算内に納めれば納めるほど...
ライフがなくなったら当然終わり。
DOTweenのエラー対策
DOTWEEN ► SAFE MODE ► DOTween's safe mode captured 1 errors. This is usually ok (it's what safe mode is there for) but if your game is encountering issues you should set Log Behaviour to Default in DOTween Utility Panel in order to get detailed warnings when an error is captured (consider that these errors are always on the user side).
- 1 missing target or field errors
code:c#
.SetLink(gameObject); // DOTweenエラー対策
これでよい。
ゲームのシチュエーションについて
お金をたくさん使えば、結構簡単にクリアできるようにしよう
そのうえで、つむりんとかで倒せばお金がいっぱいもらえるようにして、クリアできるほど富豪になれる
モンスターから襲撃を受ける。自分たちは傭兵団(けっこうかつかつ)。国からもらったお金をうまく活かして、お金を稼いでクリアしよう
0円
これじゃタダ働きだよ~みたいな
SEつけよう
Componentとして、Audio Sourceを3つずつ持たせる。
MANAGERを作ったら、こんな感じで鳴らす
code:c#
# PlayerSfx
public void PlayAttack()
{
AudioManager.Instance?.PlaySfx(attackSfx);
}
# Trigger
if (entityCombat != null && entityCombat.LastAttackHitEnemy == false)
sfx?.PlayAttack(); // 空振り音
敵のほうが作ってる数が少ないから、敵の被弾時に鳴らすようにする
ボス前アラートとクリア演出は入れとこう
どちらもUIで用意する形になる。結構使いまわせる
倍速ボタン
Waveが進んだとき、色がパッと変わるようにする
WaveChanged があるので、そこに購読させる
クリア画面でユニット配置のエフェクトが出るのでやめさせる ✅️
終了演出にSEを入れる
入れた。まだいくつか入れられると思う
Waveの敵がいなくなったあとに次のWaveに進めたい
した
8方向にできるほうが楽しいかもしれない
そんなことはないかもしれない
敵味方のモデルや調整
敵の個性
よわい
すばしっこい
遅くてしぶとい
めちゃくちゃ集団で出る
ボス
くらいだろうか
これだけでは個性があまり出ないかも...
ダメージが1固定の敵 (に対して2倍のダメージとか)
飛行系とか
味方
鈍足にするとか
ランクについて
これくらい
S: 10000
A: 7500
B: 5000
C: 2000以下
ESCでモードを変更して戻ったとき、timescaleをその時のものに戻す ✅️
ゲームオーバー時に建設モードのghostが出ないようにする✅️
ユニットを選んだら、建設モードに移行するようにしてもよい
UnitSelectionが走る
このときに、Editに変更するようにしてやれば良いだろう。すでにイベント登録があったので追記。
OK
BGM
タイトルと戦闘で同じBGMを鳴らすつもりだが、続けて鳴らすにはどうすればよいだろうか?
チュートリアル
敵についての情報も載せよう
トキンが強い
絶対エイリアン倒せちゃう
リトライしたときにBGMが流れない
直した
wave11のスライムが強いかも
こんなもんかなあ?
お金が少ないかも
序盤中盤に節約する価値がなくなってる感がある
終盤のマネーコントロールをもう少し細かく設計する
ビルド
Unity 6.0
まずWeb用のビルドパッケージを落とす
https://scrapbox.io/files/69a7bd1df1c53c1732227b6b.png
ここでもいいし、Build ProfilesのInstall with Unity Hubから落としても良い(Web)
WebGLで落としたいけど、gzにならない
WebGLで Decompression Fallback にチェックを入れると .gzではなく.unitywebが生成されるのか
https://scrapbox.io/files/69a7bd89f1c53c1732227cdd.png
Gzipにして、メモリ変えて、Decompression Fallbackのチェックを外すこと。
https://scrapbox.io/files/69a69e1bf1c53c173220f3b4.png
Todo
ライフ1つにつき、1000Gとか足せばどうだろう
いいけど、リセットしたくなりそうだなあという感じ。
作るならやり直すボタンも必要かもしれない。工数的に無しで行こう!
優しくするなら、所持金がなくなったときに壊そうとするとダメ!とエラーにしてあげる
バグ:ユニットが浮く
ぴょんと跳ねるモーション中にまた攻撃する不具合が起こると、どんどん上に上がっていくらしい
---
シーンまたぎに対応したい
シーンを引き継ぐデータの勉強。
リアルタイムで組み立てたり、壊したりしていくのが楽しいかも
壊したときはお金がすこし返ってくるとか