Project_WL/Assets/WL/Editor/MonsterPlacer.cs

655 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_&lt;Type&gt;@&lt;Clip&gt;.fbx 파일명에서 추출
/// 모델 = Prefab/&lt;Type&gt;_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);
}
}
}