655 lines
33 KiB
C#
655 lines
33 KiB
C#
using System.Collections.Generic;
|
||
using System.IO;
|
||
using UnityEditor;
|
||
using UnityEditor.Animations;
|
||
using Unity.AI.Navigation;
|
||
using UnityEngine;
|
||
using UnityEngine.AI;
|
||
using UnityEngine.SceneManagement;
|
||
using WL.Combat;
|
||
using WL.Player;
|
||
|
||
namespace WL.EditorTools
|
||
{
|
||
/// <summary>
|
||
/// Suriyun "Monster Pack Forest 2" 몬스터를 전투용 프리팹으로 만들고 맵에 골고루 배치하는 에디터 도구.
|
||
///
|
||
/// 하드코딩 금지(C45) — 몬스터 종류·클립은 에셋 폴더에서 실측해서 읽는다.
|
||
/// 종류 = Animations/*/Anim_<Type>@<Clip>.fbx 파일명에서 추출
|
||
/// 모델 = Prefab/<Type>_A.prefab
|
||
/// 전투 수치는 CombatSettings.asset 을 프리팹에 연결해서 쓴다.
|
||
///
|
||
/// 배치 규칙(PD 지시 ① "맵내 임의의 위치에 골고루"):
|
||
/// ① 지면 바운즈를 격자로 나눠 셀마다 지터 좌표 후보를 뽑고
|
||
/// ② 위에서 아래로 레이캐스트해 지면 콜라이더에 착지시킨 뒤
|
||
/// ③ 물·폭포 머티리얼 / 경사 초과 / 해수면 아래 / StartPoint 근접 / 장애물 겹침을 제외하고
|
||
/// ④ 남은 후보 중 서로 가장 멀리 떨어지도록(farthest-point sampling) 고른다.
|
||
/// </summary>
|
||
public static class MonsterPlacer
|
||
{
|
||
// ─────────── 경로 (프로젝트 구조 · 값이 아니라 위치)
|
||
private const string SourceRoot = "Assets/Suriyun/Monster Pack Forest 2/";
|
||
private const string PrefabOutDir = "Assets/WL/Prefabs/Monsters";
|
||
private const string AnimatorOutDir = "Assets/WL/Animators";
|
||
private const string CombatSettingsPath = "Assets/WL/Settings/CombatSettings.asset";
|
||
private const string SceneRootName = "Monsters";
|
||
private const string EnemyTag = "Enemy";
|
||
private const string NavMeshRootName = "WL_NavMesh";
|
||
private const string NavMeshOutDir = "Assets/WL/Settings";
|
||
|
||
// ─────────── 배치 도구 파라미터 (게임 데이터가 아닌 도구 설정 · 메뉴 실행 시 이 기본값을 쓴다)
|
||
public class PlaceOptions
|
||
{
|
||
public int perType = 2; // 종류당 배치 수
|
||
public int gridCols = 12; // 후보 격자 열
|
||
public int gridRows = 12; // 후보 격자 행
|
||
public int attemptsPerCell = 40; // 셀당 후보 시도 횟수
|
||
public float maxSlopeDeg = 35f; // 허용 경사 상한
|
||
public float minSeaLevelY = -5f; // 해수면(오션 평면 y). 이 아래는 제외
|
||
public float startPointClearRadius = 8f;// StartPoint 반경 제외
|
||
public float minSeparation = 7f; // 몬스터 간 최소 간격
|
||
public float clearanceHeight = 1.0f; // 장애물 겹침 검사 구 중심 높이
|
||
public float clearanceRadius = 0.4f; // 장애물 겹침 검사 구 반지름
|
||
public float rayStartHeight = 120f; // 레이캐스트 시작 높이
|
||
public int randomSeed = 20260902; // 재현용 시드
|
||
public string[] waterMaterialNames = { "WL_Water_Ocean", "WL_Water_River", "Water_Fall", "Splash", "ToonWaterU" };
|
||
|
||
// 주변 지형 검사 — 바위 꼭대기·좁은 턱처럼 플레이어가 갈 수 없는 지점을 걸러낸다
|
||
public float[] neighborRadii = { 1.5f, 3.0f }; // 주변을 훑는 반지름(m)
|
||
public int neighborSamples = 8; // 반지름마다 8방향
|
||
public int neighborMinHits = 6; // 이 개수 이상이 조건을 만족해야 통과
|
||
public float neighborMaxStep = 1.2f; // 중심과의 높이차 허용(m)
|
||
public float neighborMaxSlopeDeg = 45f; // 주변 허용 경사
|
||
|
||
// 배치 영역 사전 스캔 — 실제 지면이 있는 곳만 골라 바운즈를 좁힌다(오션·먼 해안 제외)
|
||
public float areaScanStep = 4f;
|
||
|
||
// NavMesh 스폰 (PD #702-③ · 힘민지 DSUtil.Get_RandomPos_onNavMesh:934 규약)
|
||
[Tooltip("NavMesh 위에서만 스폰한다. 끄면 레이캐스트 지면 판정만 쓴다")]
|
||
public bool useNavMesh = true;
|
||
[Tooltip("후보 좌표를 NavMesh 로 끌어당길 때 허용하는 최대 거리(m)")]
|
||
public float navSampleRadius = 1.5f;
|
||
[Tooltip("StartPoint 에서 완전한 경로(PathComplete)가 나오는 지점만 채택한다 — '플레이어가 갈 수 있는 길'")]
|
||
public bool requirePathFromStart = true;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════ 메뉴
|
||
|
||
[MenuItem("WL/몬스터 - ① 프리팹 생성 (Monster Pack Forest 2)")]
|
||
public static void MenuBuildPrefabs()
|
||
{
|
||
var made = BuildAllPrefabs();
|
||
Debug.Log("[MonsterPlacer] 프리팹 " + made.Count + "종 생성/갱신 완료:\n" + string.Join("\n", made.ToArray()));
|
||
}
|
||
|
||
[MenuItem("WL/몬스터 - ⓪ NavMesh 베이크 (플레이어 이동 가능 길)")]
|
||
public static void MenuBakeNavMesh()
|
||
{
|
||
Debug.Log(BakeNavMesh());
|
||
}
|
||
|
||
/// <summary>
|
||
/// 씬에 NavMeshSurface 를 세우고 베이크한다 (PD #702-③).
|
||
/// 물(강·폭포·물보라)은 콜라이더가 있어 그냥 두면 걸어다닐 수 있는 바닥이 되므로
|
||
/// 머티리얼 이름으로 실측해 NavMeshModifier(Not Walkable)를 붙여 제외한다.
|
||
/// 오션 평면은 콜라이더가 없어 PhysicsColliders 수집에서 자동 제외된다.
|
||
/// 나무·바위·건물은 콜라이더가 있으므로 복셀화 단계에서 자연스럽게 구멍이 뚫린다.
|
||
/// </summary>
|
||
public static string BakeNavMesh()
|
||
{
|
||
var o = new PlaceOptions();
|
||
var sb = new System.Text.StringBuilder();
|
||
|
||
// ① 물 오브젝트에 Not Walkable 표시
|
||
int marked = 0;
|
||
foreach (var name in new[] { "Asset/LandMass", "Asset/RiverRoadFallPadding" })
|
||
{
|
||
var parent = GameObject.Find(name);
|
||
if (parent == null) continue;
|
||
foreach (var rend in parent.GetComponentsInChildren<Renderer>(true))
|
||
{
|
||
bool water = false;
|
||
foreach (var m in rend.sharedMaterials)
|
||
{
|
||
if (m == null) continue;
|
||
foreach (var w in o.waterMaterialNames) if (m.name == w) water = true;
|
||
}
|
||
if (!water) continue;
|
||
var mod = rend.GetComponent<NavMeshModifier>();
|
||
if (mod == null) mod = rend.gameObject.AddComponent<NavMeshModifier>();
|
||
mod.overrideArea = true;
|
||
mod.area = 1; // 1 = Not Walkable (Unity 기본 영역)
|
||
mod.ignoreFromBuild = false;
|
||
marked++;
|
||
}
|
||
}
|
||
sb.AppendLine("물 머티리얼 Not Walkable 표시 " + marked + "개");
|
||
|
||
// ② NavMeshSurface 준비
|
||
var surfGo = GameObject.Find(NavMeshRootName);
|
||
if (surfGo == null) surfGo = new GameObject(NavMeshRootName);
|
||
surfGo.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
|
||
var surf = surfGo.GetComponent<NavMeshSurface>();
|
||
if (surf == null) surf = surfGo.AddComponent<NavMeshSurface>();
|
||
surf.collectObjects = CollectObjects.All;
|
||
surf.useGeometry = NavMeshCollectGeometry.PhysicsColliders;
|
||
surf.layerMask = ~0;
|
||
surf.defaultArea = 0; // Walkable
|
||
surf.agentTypeID = 0; // Humanoid 기본 (반경 0.5 · 높이 2 · 경사 45 · 계단 0.4)
|
||
|
||
// ③ 베이크 + 에셋 저장 (기본 경로는 씬 폴더 — WL 폴더 규약에 맞춰 직접 저장한다)
|
||
surf.BuildNavMesh();
|
||
var data = surf.navMeshData;
|
||
if (data == null) return sb + "BuildNavMesh 결과가 비었습니다.";
|
||
|
||
EnsureFolder(NavMeshOutDir);
|
||
string path = NavMeshOutDir + "/WL_NavMesh.asset";
|
||
var existing = AssetDatabase.LoadAssetAtPath<NavMeshData>(path);
|
||
if (existing != null) { EditorUtility.CopySerialized(data, existing); data = existing; }
|
||
else { AssetDatabase.CreateAsset(data, path); }
|
||
surf.navMeshData = data;
|
||
surf.AddData();
|
||
EditorUtility.SetDirty(surf);
|
||
AssetDatabase.SaveAssets();
|
||
EditorSceneMarkDirty();
|
||
|
||
var tri = NavMesh.CalculateTriangulation();
|
||
sb.AppendLine("NavMesh 베이크 완료 · 정점 " + tri.vertices.Length + " · 삼각형 " + (tri.indices.Length / 3)
|
||
+ " · 에셋 " + path);
|
||
return sb.ToString();
|
||
}
|
||
|
||
[MenuItem("WL/몬스터 - ② 맵에 배치")]
|
||
public static void MenuPlace()
|
||
{
|
||
Debug.Log(PlaceMonsters(new PlaceOptions()));
|
||
}
|
||
|
||
[MenuItem("WL/몬스터 - ③ 배치 제거")]
|
||
public static void MenuClear()
|
||
{
|
||
Debug.Log("[MonsterPlacer] 제거 " + ClearMonsters() + "개");
|
||
}
|
||
|
||
// ══════════════════════════════════════════════ 몬스터 종류 실측
|
||
|
||
/// <summary>Animations 폴더의 클립 파일명에서 몬스터 종류를 실측한다. key = 종류명, value = 클립명→클립.</summary>
|
||
public static SortedDictionary<string, Dictionary<string, AnimationClip>> ScanTypes()
|
||
{
|
||
var result = new SortedDictionary<string, Dictionary<string, AnimationClip>>();
|
||
var guids = AssetDatabase.FindAssets("t:Model", new[] { SourceRoot + "Animations" });
|
||
foreach (var g in guids)
|
||
{
|
||
string path = AssetDatabase.GUIDToAssetPath(g);
|
||
string file = Path.GetFileNameWithoutExtension(path); // Anim_Porin@Idle
|
||
if (!file.StartsWith("Anim_") || file.IndexOf('@') < 0) continue;
|
||
int at = file.IndexOf('@');
|
||
string type = file.Substring("Anim_".Length, at - "Anim_".Length);
|
||
string clipName = file.Substring(at + 1);
|
||
|
||
AnimationClip clip = null;
|
||
foreach (var o in AssetDatabase.LoadAllAssetsAtPath(path))
|
||
{
|
||
var c = o as AnimationClip;
|
||
if (c != null && !c.name.StartsWith("__preview")) { clip = c; break; }
|
||
}
|
||
if (clip == null) continue;
|
||
|
||
if (!result.ContainsKey(type)) result[type] = new Dictionary<string, AnimationClip>();
|
||
result[type][clipName] = clip;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════ 프리팹 생성
|
||
|
||
public static List<string> BuildAllPrefabs()
|
||
{
|
||
EnsureFolder(PrefabOutDir);
|
||
EnsureFolder(AnimatorOutDir);
|
||
EnsureTag(EnemyTag);
|
||
|
||
var settings = AssetDatabase.LoadAssetAtPath<CombatSettings>(CombatSettingsPath);
|
||
if (settings == null) Debug.LogWarning("[MonsterPlacer] CombatSettings.asset 을 찾지 못했습니다: " + CombatSettingsPath);
|
||
|
||
var made = new List<string>();
|
||
foreach (var kv in ScanTypes())
|
||
{
|
||
string type = kv.Key;
|
||
string src = SourceRoot + "Prefab/" + type + "_A.prefab";
|
||
var srcPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(src);
|
||
if (srcPrefab == null) { Debug.LogWarning("[MonsterPlacer] 모델 프리팹 없음: " + src); continue; }
|
||
|
||
var ctrl = BuildController(type, kv.Value);
|
||
string outPath = PrefabOutDir + "/" + type + ".prefab";
|
||
var info = BuildOnePrefab(srcPrefab, ctrl, settings, outPath);
|
||
made.Add(info);
|
||
}
|
||
AssetDatabase.SaveAssets();
|
||
AssetDatabase.Refresh();
|
||
return made;
|
||
}
|
||
|
||
private static AnimatorController BuildController(string type, Dictionary<string, AnimationClip> clips)
|
||
{
|
||
string path = AnimatorOutDir + "/Monster_" + type + ".controller";
|
||
if (File.Exists(path)) AssetDatabase.DeleteAsset(path);
|
||
var ctrl = AnimatorController.CreateAnimatorControllerAtPath(path);
|
||
var sm = ctrl.layers[0].stateMachine;
|
||
|
||
// 상태 이름은 EnemyController 의 직렬화 필드와 일치해야 한다.
|
||
// 이동 클립은 팩마다 Move / Walk 로 이름이 다르므로(Platopo = Walk) 실측해서 "Move" 상태에 넣는다.
|
||
var idle = Pick(clips, "Idle");
|
||
var move = Pick(clips, "Move", "Walk", "Run");
|
||
var attack = Pick(clips, "Attack");
|
||
var damage = Pick(clips, "Damage", "Hit");
|
||
var die = Pick(clips, "Die", "Death");
|
||
|
||
var sIdle = AddState(sm, "Idle", idle, new Vector3(300f, 0f, 0f));
|
||
AddState(sm, "Move", move, new Vector3(300f, 60f, 0f));
|
||
AddState(sm, "Attack", attack, new Vector3(300f, 120f, 0f));
|
||
AddState(sm, "Damage", damage, new Vector3(300f, 180f, 0f));
|
||
AddState(sm, "Die", die, new Vector3(300f, 240f, 0f));
|
||
sm.defaultState = sIdle;
|
||
// 상태 전이는 전부 코드(EnemyController.CrossFade)가 제어한다 — 자동 전이 없음
|
||
EditorUtility.SetDirty(ctrl);
|
||
return ctrl;
|
||
}
|
||
|
||
private static AnimatorState AddState(AnimatorStateMachine sm, string name, AnimationClip clip, Vector3 pos)
|
||
{
|
||
var st = sm.AddState(name, pos);
|
||
st.motion = clip;
|
||
st.speed = 1f;
|
||
return st;
|
||
}
|
||
|
||
private static AnimationClip Pick(Dictionary<string, AnimationClip> clips, params string[] names)
|
||
{
|
||
foreach (var n in names) if (clips.ContainsKey(n)) return clips[n];
|
||
return null;
|
||
}
|
||
|
||
private static string BuildOnePrefab(GameObject srcPrefab, AnimatorController ctrl, CombatSettings settings, string outPath)
|
||
{
|
||
var inst = (GameObject)PrefabUtility.InstantiatePrefab(srcPrefab);
|
||
inst.name = Path.GetFileNameWithoutExtension(outPath);
|
||
inst.tag = EnemyTag;
|
||
|
||
// 콜라이더 크기는 렌더러 바운즈 실측으로 정한다
|
||
Bounds b = new Bounds(); bool first = true;
|
||
foreach (var r in inst.GetComponentsInChildren<Renderer>(true))
|
||
{
|
||
if (first) { b = r.bounds; first = false; } else b.Encapsulate(r.bounds);
|
||
}
|
||
float top = first ? 0.5f : Mathf.Max(b.max.y - inst.transform.position.y, 0.2f);
|
||
float wide = first ? 0.4f : Mathf.Max(b.size.x, b.size.z);
|
||
float radius = Mathf.Clamp(wide * 0.4f, 0.08f, top * 0.5f);
|
||
float height = Mathf.Max(top, radius * 2f);
|
||
|
||
var cc = inst.GetComponent<CharacterController>();
|
||
if (cc == null) cc = inst.AddComponent<CharacterController>();
|
||
cc.height = height;
|
||
cc.radius = radius;
|
||
cc.center = new Vector3(0f, height * 0.5f, 0f);
|
||
cc.slopeLimit = 50f;
|
||
cc.stepOffset = Mathf.Min(0.3f, height * 0.4f);
|
||
cc.skinWidth = Mathf.Max(0.008f, radius * 0.08f);
|
||
cc.minMoveDistance = 0f;
|
||
|
||
var animator = inst.GetComponent<Animator>();
|
||
if (animator == null) animator = inst.AddComponent<Animator>();
|
||
animator.runtimeAnimatorController = ctrl;
|
||
animator.applyRootMotion = false;
|
||
animator.cullingMode = AnimatorCullingMode.AlwaysAnimate; // 사망 연출이 화면 밖에서도 정상 진행되도록
|
||
|
||
var enemy = inst.GetComponent<Enemy>();
|
||
if (enemy == null) enemy = inst.AddComponent<Enemy>();
|
||
var ec = inst.GetComponent<EnemyController>();
|
||
if (ec == null) ec = inst.AddComponent<EnemyController>();
|
||
var flash = inst.GetComponent<HitFlash>();
|
||
if (flash == null) flash = inst.AddComponent<HitFlash>();
|
||
|
||
var so = new SerializedObject(enemy);
|
||
so.FindProperty("combatSettings").objectReferenceValue = settings;
|
||
so.FindProperty("bodyCenterHeight").floatValue = height * 0.5f;
|
||
so.ApplyModifiedPropertiesWithoutUndo();
|
||
|
||
var so2 = new SerializedObject(ec);
|
||
so2.FindProperty("settings").objectReferenceValue = settings;
|
||
so2.FindProperty("animator").objectReferenceValue = animator;
|
||
so2.ApplyModifiedPropertiesWithoutUndo();
|
||
|
||
var so3 = new SerializedObject(flash);
|
||
so3.FindProperty("settings").objectReferenceValue = settings;
|
||
var rendProp = so3.FindProperty("renderers");
|
||
var rends = inst.GetComponentsInChildren<Renderer>(true);
|
||
rendProp.arraySize = rends.Length;
|
||
for (int i = 0; i < rends.Length; i++) rendProp.GetArrayElementAtIndex(i).objectReferenceValue = rends[i];
|
||
so3.ApplyModifiedPropertiesWithoutUndo();
|
||
|
||
PrefabUtility.SaveAsPrefabAsset(inst, outPath);
|
||
Object.DestroyImmediate(inst);
|
||
|
||
return string.Format("{0} : CC h={1:F2} r={2:F2} · ctrl={3}", Path.GetFileNameWithoutExtension(outPath), height, radius, ctrl.name);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════ 배치
|
||
|
||
public static int ClearMonsters()
|
||
{
|
||
var root = GameObject.Find(SceneRootName);
|
||
if (root == null) return 0;
|
||
int n = root.transform.childCount;
|
||
Object.DestroyImmediate(root);
|
||
EditorSceneMarkDirty();
|
||
return n;
|
||
}
|
||
|
||
public static string PlaceMonsters(PlaceOptions o)
|
||
{
|
||
var prefabs = new List<GameObject>();
|
||
foreach (var guid in AssetDatabase.FindAssets("t:Prefab", new[] { PrefabOutDir }))
|
||
{
|
||
var p = AssetDatabase.LoadAssetAtPath<GameObject>(AssetDatabase.GUIDToAssetPath(guid));
|
||
if (p != null && p.GetComponent<Enemy>() != null) prefabs.Add(p);
|
||
}
|
||
prefabs.Sort((a, b) => string.CompareOrdinal(a.name, b.name));
|
||
if (prefabs.Count == 0) return "[MonsterPlacer] " + PrefabOutDir + " 에 몬스터 프리팹이 없습니다. ① 먼저 실행.";
|
||
|
||
int want = prefabs.Count * o.perType;
|
||
|
||
// 지면 바운즈 실측 — 콜라이더가 있는 지형(LandMass · RiverRoadFallPadding)만 대상으로 하고,
|
||
// 실제로 지면이 잡히는 지점만 훑어 오션·먼 해안을 잘라낸다
|
||
Vector3 startPoint = ResolveStartPoint();
|
||
Bounds raw = MeasureGroundBounds();
|
||
Bounds area = NarrowAreaToGround(raw, startPoint, o);
|
||
|
||
Random.State prev = Random.state;
|
||
Random.InitState(o.randomSeed);
|
||
|
||
var candidates = CollectCandidates(area, startPoint, o);
|
||
int beforePath = candidates.Count;
|
||
|
||
// StartPoint 에서 실제로 걸어갈 수 있는 지점만 남긴다 (PD #702-③)
|
||
int startOk = 0;
|
||
if (o.useNavMesh && o.requirePathFromStart)
|
||
{
|
||
NavMeshHit sh;
|
||
Vector3 navStart = startPoint;
|
||
if (NavMesh.SamplePosition(startPoint, out sh, Mathf.Max(o.navSampleRadius, 4f), NavMesh.AllAreas))
|
||
{ navStart = sh.position; startOk = 1; }
|
||
if (startOk == 1)
|
||
{
|
||
var reachable = new List<Candidate>();
|
||
for (int i = 0; i < candidates.Count; i++)
|
||
if (IsReachable(navStart, candidates[i].pos)) reachable.Add(candidates[i]);
|
||
candidates = reachable;
|
||
}
|
||
}
|
||
|
||
var chosen = FarthestPointPick(candidates, want, o.minSeparation);
|
||
|
||
Random.state = prev;
|
||
|
||
if (chosen.Count == 0)
|
||
return "[MonsterPlacer] 유효한 배치 후보를 찾지 못했습니다. (NavMesh 후보 " + beforePath
|
||
+ " · StartPoint NavMesh 스냅 " + (startOk == 1 ? "성공" : "실패") + " — NavMesh 베이크 여부를 확인하세요)";
|
||
|
||
ClearMonsters();
|
||
var root = new GameObject(SceneRootName);
|
||
root.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
|
||
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine("[MonsterPlacer] 배치 " + chosen.Count + "마리 (NavMesh 후보 " + beforePath
|
||
+ " → 경로 도달 가능 " + candidates.Count + " · 종류 " + prefabs.Count + " × " + o.perType
|
||
+ " · NavMesh 사용=" + o.useNavMesh + ")");
|
||
sb.AppendLine("콜라이더 바운즈 " + raw.min.ToString("F1") + " ~ " + raw.max.ToString("F1"));
|
||
sb.AppendLine("배치 영역(지면 실측) " + area.min.ToString("F1") + " ~ " + area.max.ToString("F1") + " · StartPoint " + startPoint.ToString("F2"));
|
||
sb.AppendLine("이름\t종류\t좌표(x, y, z)\t경사(도)\tStartPoint 거리(m)");
|
||
|
||
for (int i = 0; i < chosen.Count; i++)
|
||
{
|
||
var src = prefabs[i % prefabs.Count];
|
||
var go = (GameObject)PrefabUtility.InstantiatePrefab(src, root.transform);
|
||
go.name = src.name + "_" + ((i / prefabs.Count) + 1);
|
||
go.transform.position = chosen[i].pos;
|
||
go.transform.rotation = Quaternion.Euler(0f, Random.Range(0f, 360f), 0f);
|
||
Vector3 flat = chosen[i].pos - startPoint; flat.y = 0f;
|
||
sb.AppendLine(string.Format("{0}\t{1}\t({2:F2}, {3:F2}, {4:F2})\t{5:F1}\t{6:F1}",
|
||
go.name, src.name, chosen[i].pos.x, chosen[i].pos.y, chosen[i].pos.z, chosen[i].slope, flat.magnitude));
|
||
}
|
||
|
||
EditorSceneMarkDirty();
|
||
return sb.ToString();
|
||
}
|
||
|
||
private struct Candidate
|
||
{
|
||
public Vector3 pos;
|
||
public float slope;
|
||
}
|
||
|
||
private static List<Candidate> CollectCandidates(Bounds area, Vector3 startPoint, PlaceOptions o)
|
||
{
|
||
var list = new List<Candidate>();
|
||
float cw = area.size.x / o.gridCols;
|
||
float ch = area.size.z / o.gridRows;
|
||
|
||
for (int gz = 0; gz < o.gridRows; gz++)
|
||
for (int gx = 0; gx < o.gridCols; gx++)
|
||
{
|
||
float x0 = area.min.x + gx * cw;
|
||
float z0 = area.min.z + gz * ch;
|
||
for (int a = 0; a < o.attemptsPerCell; a++)
|
||
{
|
||
float x = x0 + Random.Range(0.08f, 0.92f) * cw;
|
||
float z = z0 + Random.Range(0.08f, 0.92f) * ch;
|
||
Candidate c;
|
||
if (!TryGround(new Vector2(x, z), startPoint, o, out c)) continue;
|
||
list.Add(c);
|
||
break; // 셀당 후보 1개
|
||
}
|
||
}
|
||
return list;
|
||
}
|
||
|
||
private static bool TryGround(Vector2 xz, Vector3 startPoint, PlaceOptions o, out Candidate c)
|
||
{
|
||
c = new Candidate();
|
||
RaycastHit hit;
|
||
if (!Physics.Raycast(new Vector3(xz.x, o.rayStartHeight, xz.y), Vector3.down, out hit,
|
||
o.rayStartHeight * 2f, ~0, QueryTriggerInteraction.Ignore)) return false;
|
||
|
||
if (hit.point.y < o.minSeaLevelY) return false; // 해수면 아래
|
||
if (IsWater(hit.collider, o.waterMaterialNames)) return false; // 물·폭포
|
||
float slope = Vector3.Angle(hit.normal, Vector3.up);
|
||
if (slope > o.maxSlopeDeg) return false; // 경사 초과
|
||
|
||
Vector3 point = hit.point;
|
||
|
||
// PD #702-③: "플레이어가 이동 가능한 길에서만" — NavMesh 위로 끌어당긴다.
|
||
// 힘민지 `Util/DSUtil.cs:934 Get_RandomPos_onNavMesh` 규약(SamplePosition → CalculatePath) 이식.
|
||
if (o.useNavMesh)
|
||
{
|
||
NavMeshHit nav;
|
||
if (!NavMesh.SamplePosition(point, out nav, o.navSampleRadius, NavMesh.AllAreas)) return false;
|
||
point = nav.position;
|
||
}
|
||
|
||
Vector3 flat = point - startPoint; flat.y = 0f;
|
||
if (flat.magnitude < o.startPointClearRadius) return false; // StartPoint 근접
|
||
|
||
// 장애물(나무·바위·건물) 겹침 — 스폰 지점 위 공간이 비어 있어야 한다
|
||
if (Physics.CheckSphere(point + Vector3.up * o.clearanceHeight, o.clearanceRadius, ~0, QueryTriggerInteraction.Ignore))
|
||
return false;
|
||
|
||
// 주변 지형 — 바위 꼭대기·좁은 턱이면 플레이어가 도달할 수 없으므로 제외한다
|
||
if (!o.useNavMesh && !HasWalkableNeighborhood(point, o)) return false;
|
||
|
||
c.pos = point;
|
||
c.slope = slope;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>StartPoint 에서 완전한 경로가 나오는가 (힘민지 DSUtil.cs:945~955 의 CalculatePath 검사 규약).</summary>
|
||
private static bool IsReachable(Vector3 from, Vector3 to)
|
||
{
|
||
var path = new NavMeshPath();
|
||
if (!NavMesh.CalculatePath(from, to, NavMesh.AllAreas, path)) return false;
|
||
return path.status == NavMeshPathStatus.PathComplete;
|
||
}
|
||
|
||
/// <summary>중심 주변을 여러 반지름·방향으로 훑어, 높이차가 크지 않은 지면이 충분히 이어져 있는지 본다.</summary>
|
||
private static bool HasWalkableNeighborhood(Vector3 center, PlaceOptions o)
|
||
{
|
||
for (int r = 0; r < o.neighborRadii.Length; r++)
|
||
{
|
||
float radius = o.neighborRadii[r];
|
||
int ok = 0;
|
||
for (int i = 0; i < o.neighborSamples; i++)
|
||
{
|
||
float ang = (i / (float)o.neighborSamples) * Mathf.PI * 2f;
|
||
Vector3 probe = center + new Vector3(Mathf.Cos(ang) * radius, 0f, Mathf.Sin(ang) * radius);
|
||
RaycastHit h;
|
||
if (!Physics.Raycast(new Vector3(probe.x, center.y + o.neighborMaxStep + 0.5f, probe.z), Vector3.down, out h,
|
||
o.neighborMaxStep * 2f + 1f, ~0, QueryTriggerInteraction.Ignore)) continue;
|
||
if (Mathf.Abs(h.point.y - center.y) > o.neighborMaxStep) continue;
|
||
if (Vector3.Angle(h.normal, Vector3.up) > o.neighborMaxSlopeDeg) continue;
|
||
if (IsWater(h.collider, o.waterMaterialNames)) continue;
|
||
ok++;
|
||
}
|
||
if (ok < o.neighborMinHits) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/// <summary>실제 지면이 있는 지점만 훑어 배치 영역 바운즈를 좁힌다(오션 평면·먼 해안 제외).</summary>
|
||
private static Bounds NarrowAreaToGround(Bounds raw, Vector3 startPoint, PlaceOptions o)
|
||
{
|
||
Bounds b = new Bounds(); bool first = true;
|
||
for (float x = raw.min.x; x <= raw.max.x; x += o.areaScanStep)
|
||
for (float z = raw.min.z; z <= raw.max.z; z += o.areaScanStep)
|
||
{
|
||
RaycastHit h;
|
||
if (!Physics.Raycast(new Vector3(x, o.rayStartHeight, z), Vector3.down, out h, o.rayStartHeight * 2f, ~0, QueryTriggerInteraction.Ignore)) continue;
|
||
if (h.point.y < o.minSeaLevelY) continue;
|
||
if (IsWater(h.collider, o.waterMaterialNames)) continue;
|
||
if (Vector3.Angle(h.normal, Vector3.up) > o.maxSlopeDeg) continue;
|
||
if (first) { b = new Bounds(h.point, Vector3.zero); first = false; } else b.Encapsulate(h.point);
|
||
}
|
||
if (first) return raw;
|
||
b.Encapsulate(new Vector3(startPoint.x, b.center.y, startPoint.z));
|
||
b.Expand(new Vector3(o.areaScanStep, 0f, o.areaScanStep));
|
||
return b;
|
||
}
|
||
|
||
private static bool IsWater(Collider col, string[] waterNames)
|
||
{
|
||
if (col == null) return false;
|
||
var r = col.GetComponent<Renderer>();
|
||
if (r == null) r = col.GetComponentInParent<Renderer>();
|
||
if (r == null) return false;
|
||
foreach (var m in r.sharedMaterials)
|
||
{
|
||
if (m == null) continue;
|
||
foreach (var w in waterNames) if (m.name == w) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>후보 중 서로 가장 멀리 떨어지도록 고른다(골고루 · farthest-point sampling).</summary>
|
||
private static List<Candidate> FarthestPointPick(List<Candidate> src, int want, float minSep)
|
||
{
|
||
var picked = new List<Candidate>();
|
||
if (src.Count == 0) return picked;
|
||
|
||
var pool = new List<Candidate>(src);
|
||
// 첫 점 = 후보 무게중심에서 가장 먼 점 (가장자리부터 시작해 전체를 덮는다)
|
||
Vector3 mean = Vector3.zero;
|
||
foreach (var c in pool) mean += c.pos;
|
||
mean /= pool.Count;
|
||
int firstIdx = 0; float bestD = -1f;
|
||
for (int i = 0; i < pool.Count; i++)
|
||
{
|
||
float d = (pool[i].pos - mean).sqrMagnitude;
|
||
if (d > bestD) { bestD = d; firstIdx = i; }
|
||
}
|
||
picked.Add(pool[firstIdx]); pool.RemoveAt(firstIdx);
|
||
|
||
while (picked.Count < want && pool.Count > 0)
|
||
{
|
||
int best = -1; float bestMin = -1f;
|
||
for (int i = 0; i < pool.Count; i++)
|
||
{
|
||
float nearest = float.MaxValue;
|
||
for (int j = 0; j < picked.Count; j++)
|
||
{
|
||
float d = (pool[i].pos - picked[j].pos).sqrMagnitude;
|
||
if (d < nearest) nearest = d;
|
||
}
|
||
if (nearest > bestMin) { bestMin = nearest; best = i; }
|
||
}
|
||
if (best < 0) break;
|
||
if (bestMin < minSep * minSep) break; // 남은 후보가 전부 너무 가깝다
|
||
picked.Add(pool[best]); pool.RemoveAt(best);
|
||
}
|
||
return picked;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════ 유틸
|
||
|
||
/// <summary>콜라이더가 있는 지형 오브젝트의 합산 바운즈. 오션 평면(콜라이더 없음)은 자동 제외된다.</summary>
|
||
private static Bounds MeasureGroundBounds()
|
||
{
|
||
Bounds b = new Bounds(); bool first = true;
|
||
foreach (var name in new[] { "Asset/LandMass", "Asset/RiverRoadFallPadding" })
|
||
{
|
||
var go = GameObject.Find(name);
|
||
if (go == null) continue;
|
||
foreach (var col in go.GetComponentsInChildren<Collider>(true))
|
||
{
|
||
if (first) { b = col.bounds; first = false; } else b.Encapsulate(col.bounds);
|
||
}
|
||
}
|
||
if (first) b = new Bounds(Vector3.zero, new Vector3(100f, 20f, 100f));
|
||
return b;
|
||
}
|
||
|
||
private static Vector3 ResolveStartPoint()
|
||
{
|
||
var sp = GameObject.Find("StartPoint");
|
||
if (sp != null) return sp.transform.position;
|
||
var pc = Object.FindFirstObjectByType<PlayerController>();
|
||
return pc != null ? pc.transform.position : Vector3.zero;
|
||
}
|
||
|
||
private static void EnsureFolder(string path)
|
||
{
|
||
if (AssetDatabase.IsValidFolder(path)) return;
|
||
string parent = Path.GetDirectoryName(path).Replace('\\', '/');
|
||
string leaf = Path.GetFileName(path);
|
||
EnsureFolder(parent);
|
||
AssetDatabase.CreateFolder(parent, leaf);
|
||
}
|
||
|
||
private static void EnsureTag(string tag)
|
||
{
|
||
foreach (var t in UnityEditorInternal.InternalEditorUtility.tags) if (t == tag) return;
|
||
var asset = AssetDatabase.LoadAllAssetsAtPath("ProjectSettings/TagManager.asset");
|
||
if (asset == null || asset.Length == 0) return;
|
||
var so = new SerializedObject(asset[0]);
|
||
var tags = so.FindProperty("tags");
|
||
tags.InsertArrayElementAtIndex(tags.arraySize);
|
||
tags.GetArrayElementAtIndex(tags.arraySize - 1).stringValue = tag;
|
||
so.ApplyModifiedProperties();
|
||
}
|
||
|
||
private static void EditorSceneMarkDirty()
|
||
{
|
||
var scene = SceneManager.GetActiveScene();
|
||
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(scene);
|
||
}
|
||
}
|
||
}
|