Unityゲーム役立ち小ネタスクリプト7:ノード型のダンジョン
テキストADVみたいなゲームを簡単に作れるように、ノードダンジョン…ノード形式のマップデータが欲しいなーてなったのです。マス目をWASDで前後左右に移動するのでなく、純粋に選択肢で移動するタイプのマップ構成ですね。
さらに、そのマップデータを手打ちで作るのも面倒なので自動生成できるアセットがあったらいいなって思ったけど、アセットストアにはなさそうだったので自作しました。AIの手も借りて。
まず基本となる、一つのノードの情報を収める自作クラスがこんなんです
DungeonNode.cs
using UnityEngine;
using System.Collections.Generic;
namespace FUNSET
{
public class DungeonNode
{
//DungeonNodeは配列やリストで使用する前提です。説明を簡素化するためにNodeIDは配列やリストで使う要素数と同一にします。
public int NodeID;
//セルの位置情報です。全体マップやセル同士の位置関係などを可視化する場合に使います
public Vector2 position;
// "battle", "treasure", "shop" など、セルの情報です。用途に合わせてカスタムします
public string nodeType;
// ルートノードからの深さです。マップを自動生成するさいに使います
public int depth;
//ノードの接続先です。NodeID=要素数を控えます
public List<int> connections = new List<int>();
}
}
これを使いノードダンジョンを自動生成するコードがこう
NodeDungeonGenerator.cs
using UnityEngine;
using System.Collections.Generic;
namespace FUNSET
{
public class NodeDungeonGenerator : MonoBehaviour
{
[Header("Generation Settings")]
[SerializeField] private int maxNodes = 20;
[SerializeField] private int minConnectionsPerNode = 1;
[SerializeField] private int maxConnectionsPerNode = 4;
[SerializeField] private float minNodeDistance = 4f;
[Header("Node Type Weights")]
[SerializeField] private float battleRoomWeight = 0.6f;
[SerializeField] private float treasureRoomWeight = 0.2f;
[SerializeField] private float shopRoomWeight = 0.1f;
[SerializeField] private float eventRoomWeight = 0.1f;
private List<DungeonNode> allNodes = new List<DungeonNode>();
private DungeonNode rootNode;
public List<DungeonNode> GenerateDungeon()
{
allNodes.Clear();
// ルートノード(開始地点)を作成
rootNode = new DungeonNode();
rootNode.NodeID = 0;
Vector2 position = new Vector2(
Random.Range(maxNodes * -2.5f - minNodeDistance, maxNodes * 2.5f + minNodeDistance),
Random.Range(maxNodes * -2.5f - minNodeDistance, maxNodes * 2.5f + minNodeDistance));
rootNode.position = position;
rootNode.nodeType = "start";
rootNode.depth = 0;
allNodes.Add(rootNode);
// ダンジョンの生成
GenerateNodes();
ConnectNodes();
ValidateConnections();
return allNodes;
}
private void GenerateNodes()
{
for (int i = 1; i < maxNodes; i++)
{
bool positionsafe = false;
while (!positionsafe)
{
// ランダムな位置を生成
// maxNodesとminNodeDistanceの設定次第では、
// IsPositionValid()が必ずfalseしか返さなくなり無限ループする可能性がある
Vector2 position = new Vector2(
Random.Range(maxNodes *-2.5f-minNodeDistance, maxNodes* 2.5f + minNodeDistance),
Random.Range(maxNodes * -2.5f - minNodeDistance, maxNodes* 2.5f + minNodeDistance)
);
// 他のノードとの最小距離を確認,falseなら位置を見直し
//ここでフリーズする可能性がある
if (IsPositionValid(position))
{
// ノードタイプをウェイトに基づいて決定
string nodeType = DetermineNodeType();
DungeonNode newNode = new DungeonNode();
newNode.NodeID = i;
newNode.position = position;
newNode.nodeType = nodeType;
allNodes.Add(newNode);
positionsafe = true;
Debug.Log("座標"+ newNode .position+ " 他のノードとの最小距離を確認:"+ i );
}
else
{
positionsafe = false;
}
}
}
}
/// <summary>
/// 他のノードとの最小距離を確認する。すべてのノードとに距離をVector2.Distanceで総当たりチェック
/// </summary>
/// <param name="position">新規ノードの設置予定位置</param>
/// <returns></returns>
private bool IsPositionValid(Vector2 position)
{
//minNodeDistanceがmaxNodesに対して大きすぎたら補正値でそれする(未完成)
var minNodeDistance_ = minNodeDistance;
foreach (var node in allNodes)
{
if (Vector2.Distance(position, node.position) < minNodeDistance_)
{
return false;
}
}
return true;
}
/// <summary>
/// ノード=その場所の属性情報をランダムで決める。
/// </summary>
/// <returns></returns>
private string DetermineNodeType()
{
float random = Random.value;
float currentWeight = 0f;
currentWeight += battleRoomWeight;
if (random <= currentWeight) return "battle";
currentWeight += treasureRoomWeight;
if (random <= currentWeight) return "treasure";
currentWeight += shopRoomWeight;
if (random <= currentWeight) return "shop";
return "event";
}
string GetPlaceText(int NameID)
{
int r = Random.Range(0, 4);
string Gtext = "";
float NowStrength = Random.Range(0, 0.7f);
switch (r)
{
case (0):
Gtext = ADVTextDatabase.GenerateTextPower(3, NameID, NowStrength * 0.1f, 0);
break;
case (1):
Gtext = ADVTextDatabase.GenerateTextPower(3, NameID, NowStrength * 0.1f, 3);
break;
case (2):
Gtext = ADVTextDatabase.GenerateTextPower(3, NameID, NowStrength * 0.1f, 9);
break;
case (3):
Gtext = ADVTextDatabase.GenerateTextPower(3, NameID, NowStrength * 0.1f, 10);
break;
}
return Gtext;
}
/// <summary>
/// 近接するノードをランダムに接続する。
///
/// </summary>
private void ConnectNodes()
{
foreach (var node in allNodes)
{
// 接続数を決定
int connectionsNeeded = Random.Range(minConnectionsPerNode, maxConnectionsPerNode + 1);
// 最も近いノードを見つけて接続候補リストを得る
var nearestNodes = FindNearestNodes(node, connectionsNeeded,true);
List<int> nodeID= node.connections;
//接続候補リストの内容を検分し、connectionsがconnectionsNeeded以下なら接続する。
foreach (var nearNodeID in nearestNodes)
{
DungeonNode nearNodeone = allNodes[nearNodeID];
if ((!node.connections.Contains(nearNodeID)) && (node.NodeID != nearNodeID) && (node.connections.Count< connectionsNeeded) && (allNodes[ nearNodeID].connections.Count < connectionsNeeded))
{
node.connections.Add(nearNodeID);
allNodes[nearNodeID].connections.Add(node.NodeID);
}
Debug.Log(node.nodeType+">ノードを接続>"+ allNodes[nearNodeID].nodeType);
}
}
}
/// <summary>
/// 近隣のノードを探して、接続候補リストに入れて返す。
/// </summary>
private List<int> FindNearestNodes(DungeonNode currentNode, int count,bool Forced)
{
var nodesList = new List<int>();
var sortedNodes = new List<DungeonNode>(allNodes);
//allNodesをcurrentNodeからの距離順でソート
sortedNodes.Sort((a, b) =>
Vector2.Distance(currentNode.position, a.position)
.CompareTo(Vector2.Distance(currentNode.position, b.position)));
for (int i = 0; i < count && i < sortedNodes.Count; i++)
{
if (sortedNodes[i] != currentNode)
{
nodesList.Add(sortedNodes[i].NodeID);
}
}
//Forced=もし接続候補リストが空なら距離制限を無視してallNodesから強制選択
if (Forced)
{
if (nodesList.Count == 0) { nodesList.Add(Random.Range(0,allNodes.Count)); }
}
return nodesList;
}
private void ValidateConnections()
{
// 深さを計算し、到達不能なノードを接続
CalculateDepths();
ConnectIsolatedNodes();
}
/// <summary>
/// rootNodeからの接続ノードをたどり、未接続のノードがないか判定する
/// 接続されたノードはdepthを-1から0にする
/// </summary>
private void CalculateDepths()
{
// すべてのノードの深さを-1に初期化
foreach (var node in allNodes)
{
node.depth = -1;
}
// 幅優先探索で深さを計算
Queue<DungeonNode> queue = new Queue<DungeonNode>();
rootNode.depth = 0;
queue.Enqueue(rootNode);
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var connected in current.connections)
{
if (allNodes[connected].depth == -1)
{
allNodes[connected].depth = current.depth + 1;
queue.Enqueue(allNodes[connected]);
}
}
}
}
/// <summary>
/// 孤立してるノードがないか判定し、あったら(強引に)接続する
/// </summary>
private void ConnectIsolatedNodes()
{
// 到達不能なノードを近くのノードと接続
foreach (var node in allNodes)
{
if (node.depth == -1)
{
var nearestNode = FindNearestNodes(node, 1,true);
node.connections.Add(nearestNode[0]);
allNodes[nearestNode[0]].connections.Add(node.NodeID);
}
}
CalculateDepths();
}
}
}
このコードを実行するには、更に別のコードを作ります。
NodeDungeonMake.cs
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
using TMPro;
namespace FUNSET
{
public class NodeDungeonMake : MonoBehaviour
{
//ノードマップ
public List<DungeonNode> allNodes = new List<DungeonNode>();
//NodeDungeonGeneratorを指定
NodeDungeonGenerator NodeDungeonGenerator;//
private void Start()
{
NodeDungeonGenerator = GetComponent<NodeDungeonGenerator>();
// ノードマップ=ダンジョンを生成
GenerateDungeon();
}
private void GenerateDungeon()
{
allNodes = NodeDungeonGenerator.GenerateDungeon();
Debug.Log("allNodes generated "+allNodes.Count);
}
}
}
シーンに空オブジェクトを作りNodeDungeonGenerator とNodeDungeonMake をアタッチし、実行します。
するとノードダンジョンがNodeDungeonMake 内のList<DungeonNode> allNodesに生成されます。
なおこれだけでは直感的にわかりやすく視覚化することはできず、終了したら消えます。更に別のコードを作って、保存するにはScriptableObjectやJsonやCSVに書き込む、可視化するにはGUIやアイコンやテクスチャなどのグラフィックアセットの用意して描画する、などの工程が必要です。
それについては長くなるので作り方は割愛します。
NodeDungeonGeneratorの説明
maxNodes ノードの総数
minConnectionsPerNode、maxConnectionsPerNode ノードをつなぐ接続数の最小・最大数
minNodeDistance ノード間の最小距離
この辺の数値はインスペクタからいじってノードマップをカスタムできますが、雑な数を入力すると不具合を起こすでしょう。特にmaxNodesとminNodeDistanceは不適切だと実行時=生成時にUnityがフリーズします。
(なんでそうなるかは後述)
battleRoomWeight、treasureRoomWeight、shopRoomWeight、eventRoomWeight
ノードはnodeTypeにbattle,tresure,shop,eventの4種類の情報をランダムに盛り込めますが、それぞれの強度を設定できます。
生成の流れとしては、
GenerateNodes();、
まずスタート地点となるrootNode を作成し、rootNode.NodeID =0,とし、ノードの座標をランダムに設定。座標はシーンのワールド座標やUIカンバスなどのそれではなく、単純なX,Yの数値です。
それからmaxNodes の数だけノードを作り、1つ作るたびに座標をランダムに決め、それが既存のノードに密接しすぎてたらやり直し、を繰り返します。可視化されたとき見やすくするための工程ですが、ノード総数=maxNodes が少ないと座標の範囲が狭まるといういらん計算処理してるせいで、maxNodes とminNodeDistanceの数値の組み合わせ次第ではループが終わらずフリーズする可能性があります。
ConnectNodes();
minConnectionsPerNode, maxConnectionsPerNodeの間からノード接続数をランダムに決め、距離が最も近いノードをソートして,node.connectionsにノードのIDを接続数の数だけ入れます。これを全てにノードに対して行います。
ValidateConnections()
ノードがすべてつながって孤立してないか判定します。
前の工程では近接ノードがすでに接続数マックスになってるとこばかりで孤立するノードが出る可能性が生じます。
その検知には (int)node.depthを使います。
まず全てのノードのnode.depthの初期値を-1にします。
全ノードのList<int>node.connectionsに含まれてるノードのnode.depthを+1することで、-1のまま=孤立してる、0以上=接続済みと判定できます。
node.depthが-1のままのノードがあったら適当に(強引に)接続します。
今回のコードはClaudeに作ってもらったコードを手直ししたものです。
https://claude.ai
ノード接続判定にqueueを使うのは思いつかなかったので なるほどなーってなりました。
でも無料だと記憶できる設定に制限があるのか、別のメソッドて使ってるローカル変数を扱おうとしたりフリーズする不具合も生成されたので、きちんと動作するまでに半分ぐらい手直ししてます。
手元のプロジェクトでは、今回のノードマップをさらに改良して、可視化とノード間を移動できるとこまで作ってます。
UIプレハブの作成とセットアップまではAIには作らせれないし解説も難しいので、今回は省略します。(まあ自信持って見せられるほど立派な出来ではないので、AIと相談して作るほうがいいかも。)
FANBOXにランタイムデモをアップしますので(有料)興味がある方はそちらをどうぞお試しください。
私の創作活動の進捗、当記事のご意見・ご感想はDiscordをぜひご登録ください。
Discord Kelorin Jo https://discord.gg/wbUJVdJcga