+ Add : Plugin fbx integration
This commit is contained in:
@@ -48,6 +48,9 @@ namespace Unheimlich.Core.Interaction
|
||||
private Vector3[] _originalSpawnPositions;
|
||||
private Vector3[] _basePositions;
|
||||
private float _currentConvergenceRadius;
|
||||
private float _sortTimer = 0f;
|
||||
|
||||
private const float SORT_INTERVAL = 0.05f;
|
||||
|
||||
public bool IsAnimationActive => _isActive && (!_settings.MergeOnce || !_hasCompletedMergeOnce);
|
||||
public bool IsInMergeOnceMode => _settings != null && _settings.MergeOnce;
|
||||
@@ -136,6 +139,13 @@ namespace Unheimlich.Core.Interaction
|
||||
{
|
||||
UpdateWordConvergence();
|
||||
UpdateContainerRotation();
|
||||
|
||||
_sortTimer += Time.deltaTime;
|
||||
if (_sortTimer >= SORT_INTERVAL)
|
||||
{
|
||||
_sortTimer = 0f;
|
||||
FloatingWordsSpawner.SortByDistance(_spawnedWords, _mainCamera);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 51d3ce25787badd429148924ba0fdaca
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7a761cf384e2d143b6e8b9fafd6281e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6712b96ee1ccf81428cd47e78893587a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Holds all resolved folder paths for a single imported asset.
|
||||
/// </summary>
|
||||
public class AssetFolders
|
||||
{
|
||||
public string Root { get; set; }
|
||||
public string Sources { get; set; }
|
||||
public string Textures { get; set; }
|
||||
public string Materials { get; set; }
|
||||
public string Prefabs { get; set; }
|
||||
public string Animations { get; set; }
|
||||
public string Audio { get; set; }
|
||||
public string VFX { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d94b3203f22e53f4ebcd1f0817c5987a
|
||||
@@ -0,0 +1,179 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Core processing logic. Called explicitly by the right-click menu or the Inspector window.
|
||||
/// Never runs automatically — no AssetPostprocessor, no domain reload triggers.
|
||||
/// </summary>
|
||||
public static class FBXProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Full pipeline for a single FBX asset path:
|
||||
/// 1. Detect source (Blender / Maya)
|
||||
/// 2. Configure ModelImporter settings
|
||||
/// 3. Create folder structure
|
||||
/// 4. Force reimport with new settings
|
||||
/// 5. Connect PBR textures → create .mat files
|
||||
/// 6. Generate prefab (with LODGroup if applicable)
|
||||
/// </summary>
|
||||
public static void Process(string fbxAssetPath)
|
||||
{
|
||||
if (!fbxAssetPath.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Debug.LogWarning($"[FBXImporter] Not a FBX file: {fbxAssetPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fbxAssetPath.StartsWith("Assets/"))
|
||||
{
|
||||
Debug.LogWarning($"[FBXImporter] File is outside Assets/ — skipped: {fbxAssetPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log($"[FBXImporter] ── Processing {Path.GetFileName(fbxAssetPath)} ──────────────");
|
||||
|
||||
// 1. Detect source
|
||||
var source = FBXSourceDetector.Detect(fbxAssetPath);
|
||||
Debug.Log($"[FBXImporter] Source detected: {source}");
|
||||
|
||||
// 2. Resolve / create folder structure
|
||||
var folders = FolderSetup.Resolve(fbxAssetPath);
|
||||
FolderSetup.Create(
|
||||
Path.GetDirectoryName(folders.Root),
|
||||
Path.GetFileName(folders.Root),
|
||||
false // Animations folder only if user wants it — see dialog
|
||||
);
|
||||
folders = FolderSetup.Resolve(fbxAssetPath); // re-resolve after creation
|
||||
|
||||
// 3. Configure ModelImporter
|
||||
var importer = AssetImporter.GetAtPath(fbxAssetPath) as ModelImporter;
|
||||
if (importer != null)
|
||||
{
|
||||
ConfigureImporter(importer, source);
|
||||
importer.SaveAndReimport(); // force reimport with new settings
|
||||
}
|
||||
|
||||
// 4. Load the imported model
|
||||
var fbxAsset = AssetDatabase.LoadAssetAtPath<GameObject>(fbxAssetPath);
|
||||
if (fbxAsset == null)
|
||||
{
|
||||
Debug.LogError($"[FBXImporter] Could not load FBX as GameObject: {fbxAssetPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Connect PBR textures
|
||||
ProcessMaterials(fbxAsset, folders, source);
|
||||
|
||||
// 6. Create prefab
|
||||
string assetName = Path.GetFileNameWithoutExtension(fbxAssetPath);
|
||||
PrefabBuilder.CreatePrefab(fbxAsset, folders.Prefabs, assetName);
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
Debug.Log($"[FBXImporter] ── Done: {assetName} ──────────────────────────────────────");
|
||||
}
|
||||
|
||||
// ─── ModelImporter settings ───────────────────────────────────────────────
|
||||
|
||||
static void ConfigureImporter(ModelImporter importer, FBXSource source)
|
||||
{
|
||||
importer.importCameras = false;
|
||||
importer.importLights = false;
|
||||
importer.importNormals = ModelImporterNormals.Import;
|
||||
importer.importTangents = ModelImporterTangents.CalculateMikk;
|
||||
importer.importBlendShapes = true;
|
||||
importer.importVisibility = true;
|
||||
|
||||
importer.materialImportMode = ModelImporterMaterialImportMode.ImportViaMaterialDescription;
|
||||
importer.materialLocation = ModelImporterMaterialLocation.External;
|
||||
importer.materialName = ModelImporterMaterialName.BasedOnMaterialName;
|
||||
importer.materialSearch = ModelImporterMaterialSearch.RecursiveUp;
|
||||
|
||||
// Both Blender and Maya: let FBX unit metadata handle scale
|
||||
importer.useFileScale = true;
|
||||
importer.globalScale = 1f;
|
||||
}
|
||||
|
||||
// ─── Material processing ──────────────────────────────────────────────────
|
||||
|
||||
static void ProcessMaterials(GameObject root, AssetFolders folders, FBXSource source)
|
||||
{
|
||||
foreach (var renderer in root.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
var mats = renderer.sharedMaterials;
|
||||
bool changed = false;
|
||||
|
||||
for (int i = 0; i < mats.Length; i++)
|
||||
{
|
||||
var processed = ProcessMaterial(mats[i], folders, source);
|
||||
if (processed != null && processed != mats[i])
|
||||
{
|
||||
mats[i] = processed;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) renderer.sharedMaterials = mats;
|
||||
}
|
||||
}
|
||||
|
||||
static Material ProcessMaterial(Material mat, AssetFolders folders, FBXSource source)
|
||||
{
|
||||
if (mat == null) return null;
|
||||
|
||||
string cleanName = TextureMatcher.CleanMaterialName(mat.name, source);
|
||||
string matPath = $"{folders.Materials}/{cleanName}.mat";
|
||||
|
||||
Material existing = AssetDatabase.LoadAssetAtPath<Material>(matPath);
|
||||
Material target = existing ?? new Material(ShaderDetector.GetLitShader());
|
||||
|
||||
ConnectPBRTextures(target, folders.Textures, cleanName);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
AssetDatabase.CreateAsset(target, matPath);
|
||||
Debug.Log($"[FBXImporter] Material created → {matPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.SetDirty(target);
|
||||
Debug.Log($"[FBXImporter] Material updated → {matPath}");
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
static void ConnectPBRTextures(Material mat, string textureDir, string baseName)
|
||||
{
|
||||
foreach (var slot in TextureMatcher.SlotSuffixes.Keys)
|
||||
{
|
||||
var tex = TextureMatcher.FindForSlot(textureDir, baseName, slot);
|
||||
if (tex == null) continue;
|
||||
|
||||
string pipelineSlot = ShaderDetector.GetSlotName(slot);
|
||||
mat.SetTexture(pipelineSlot, tex);
|
||||
|
||||
switch (slot)
|
||||
{
|
||||
case "_BumpMap":
|
||||
mat.EnableKeyword("_NORMALMAP");
|
||||
break;
|
||||
case "_EmissionMap":
|
||||
mat.EnableKeyword("_EMISSION");
|
||||
mat.SetColor("_EmissionColor", Color.white);
|
||||
mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.BakedEmissive;
|
||||
break;
|
||||
case "_ParallaxMap":
|
||||
mat.EnableKeyword("_PARALLAXMAP");
|
||||
break;
|
||||
}
|
||||
|
||||
Debug.Log($"[FBXImporter] {slot} → {tex.name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eea47f2dacccac441aa4a6806412a43a
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public enum FBXSource { Unknown, Blender, Maya }
|
||||
|
||||
public static class FBXSourceDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects whether a FBX was authored in Blender or Maya.
|
||||
/// Priority: folder convention → binary header fallback.
|
||||
/// </summary>
|
||||
public static FBXSource Detect(string assetPath)
|
||||
{
|
||||
if (assetPath.Contains("/BlenderAssets/")) return FBXSource.Blender;
|
||||
if (assetPath.Contains("/MayaAssets/")) return FBXSource.Maya;
|
||||
|
||||
string fullPath = Path.GetFullPath(assetPath);
|
||||
if (!File.Exists(fullPath)) return FBXSource.Unknown;
|
||||
|
||||
try
|
||||
{
|
||||
byte[] buffer = new byte[2000];
|
||||
using (var fs = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
fs.Read(buffer, 0, buffer.Length);
|
||||
|
||||
string header = Encoding.ASCII.GetString(buffer);
|
||||
|
||||
if (header.Contains("Blender")) return FBXSource.Blender;
|
||||
if (header.Contains("Maya")) return FBXSource.Maya;
|
||||
if (header.Contains("FBX SDK")) return FBXSource.Maya;
|
||||
if (header.Contains("Autodesk")) return FBXSource.Maya;
|
||||
}
|
||||
catch { }
|
||||
|
||||
return FBXSource.Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 330be20566a6f0249bea3963ca032e4f
|
||||
@@ -0,0 +1,65 @@
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public static class FolderSetup
|
||||
{
|
||||
static readonly string[] RequiredFolders = { "Sources", "Textures", "Materials", "Prefabs", "Audio", "VFX" };
|
||||
|
||||
/// <summary>
|
||||
/// Creates the full folder structure under parentPath/assetName.
|
||||
/// Safe to call multiple times — skips folders that already exist.
|
||||
/// </summary>
|
||||
public static AssetFolders Create(string parentPath, string assetName, bool withAnimations = false)
|
||||
{
|
||||
string root = $"{parentPath}/{assetName}";
|
||||
EnsureFolder(parentPath, assetName);
|
||||
|
||||
foreach (var folder in RequiredFolders)
|
||||
EnsureFolder(root, folder);
|
||||
|
||||
string animPath = $"{root}/Animations";
|
||||
if (withAnimations)
|
||||
EnsureFolder(root, "Animations");
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
return BuildFolders(root, withAnimations ? animPath : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves existing folder paths from a known FBX asset path.
|
||||
/// If the FBX is inside Sources/, goes one level up to the root.
|
||||
/// </summary>
|
||||
public static AssetFolders Resolve(string fbxAssetPath)
|
||||
{
|
||||
string dir = Path.GetDirectoryName(fbxAssetPath).Replace("\\", "/");
|
||||
string root = dir.EndsWith("/Sources")
|
||||
? Path.GetDirectoryName(dir).Replace("\\", "/")
|
||||
: dir;
|
||||
|
||||
string animPath = $"{root}/Animations";
|
||||
return BuildFolders(root, AssetDatabase.IsValidFolder(animPath) ? animPath : null);
|
||||
}
|
||||
|
||||
static AssetFolders BuildFolders(string root, string animPath) => new AssetFolders
|
||||
{
|
||||
Root = root,
|
||||
Sources = $"{root}/Sources",
|
||||
Textures = $"{root}/Textures",
|
||||
Materials = $"{root}/Materials",
|
||||
Prefabs = $"{root}/Prefabs",
|
||||
Animations = animPath,
|
||||
Audio = $"{root}/Audio",
|
||||
VFX = $"{root}/VFX",
|
||||
};
|
||||
|
||||
static void EnsureFolder(string parent, string child)
|
||||
{
|
||||
string path = $"{parent}/{child}";
|
||||
if (!AssetDatabase.IsValidFolder(path))
|
||||
AssetDatabase.CreateFolder(parent, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b2256f5b9c45ebc48bbfc702f7b7177e
|
||||
@@ -0,0 +1,79 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public static class PrefabBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a prefab from a loaded FBX GameObject.
|
||||
/// Adds a LODGroup automatically if _LOD0, _LOD1... meshes are found.
|
||||
/// </summary>
|
||||
public static GameObject CreatePrefab(GameObject fbxRoot, string prefabDir, string assetName)
|
||||
{
|
||||
if (fbxRoot == null) return null;
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(prefabDir))
|
||||
{
|
||||
string parent = System.IO.Path.GetDirectoryName(prefabDir).Replace("\\", "/");
|
||||
AssetDatabase.CreateFolder(parent, System.IO.Path.GetFileName(prefabDir));
|
||||
}
|
||||
|
||||
var instance = Object.Instantiate(fbxRoot);
|
||||
instance.name = assetName;
|
||||
|
||||
if (TrySetupLODGroup(instance))
|
||||
Debug.Log($"[FBXImporter] LODGroup configured for '{assetName}'");
|
||||
|
||||
string prefabPath = $"{prefabDir}/{assetName}.prefab";
|
||||
var prefab = PrefabUtility.SaveAsPrefabAsset(instance, prefabPath);
|
||||
Object.DestroyImmediate(instance);
|
||||
|
||||
if (prefab != null) Debug.Log($"[FBXImporter] Prefab → {prefabPath}");
|
||||
else Debug.LogError($"[FBXImporter] Failed to save prefab at {prefabPath}");
|
||||
|
||||
return prefab;
|
||||
}
|
||||
|
||||
static bool TrySetupLODGroup(GameObject root)
|
||||
{
|
||||
var lodMap = new SortedDictionary<int, List<Renderer>>();
|
||||
|
||||
foreach (var r in root.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
int idx = ExtractLODIndex(r.gameObject.name.ToUpper());
|
||||
if (idx < 0) continue;
|
||||
if (!lodMap.ContainsKey(idx)) lodMap[idx] = new List<Renderer>();
|
||||
lodMap[idx].Add(r);
|
||||
}
|
||||
|
||||
if (lodMap.Count < 2) return false;
|
||||
|
||||
var existing = root.GetComponent<LODGroup>();
|
||||
if (existing != null) Object.DestroyImmediate(existing);
|
||||
|
||||
var group = root.AddComponent<LODGroup>();
|
||||
float[] thresh = { 0.6f, 0.3f, 0.1f, 0.05f, 0.01f };
|
||||
var lods = new LOD[lodMap.Count];
|
||||
int i = 0;
|
||||
|
||||
foreach (var kvp in lodMap)
|
||||
{
|
||||
lods[i] = new LOD(i < thresh.Length ? thresh[i] : 0.01f, kvp.Value.ToArray());
|
||||
i++;
|
||||
}
|
||||
|
||||
group.SetLODs(lods);
|
||||
group.RecalculateBounds();
|
||||
return true;
|
||||
}
|
||||
|
||||
static int ExtractLODIndex(string name)
|
||||
{
|
||||
int idx = name.LastIndexOf("_LOD");
|
||||
if (idx < 0) return -1;
|
||||
return int.TryParse(name.Substring(idx + 4), out int level) ? level : -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c3b17b0a218bf80458531ba1c789cfed
|
||||
@@ -0,0 +1,76 @@
|
||||
using UnityEditor;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Persistent registry of FBX files that have already been seen by the importer.
|
||||
/// Stores asset GUIDs in a text file so it survives domain reloads and Unity restarts.
|
||||
///
|
||||
/// A FBX is "registered" as soon as the user confirms the batch dialog —
|
||||
/// regardless of whether they chose to process it or not.
|
||||
/// This ensures the dialog never shows the same file twice.
|
||||
/// </summary>
|
||||
public static class ProcessedFBXRegistry
|
||||
{
|
||||
// Stored inside the plugin folder so it travels with the project
|
||||
static readonly string RegistryPath = "Assets/Scripts/Editor/FBXImporter/processed_fbx.txt";
|
||||
|
||||
static HashSet<string> _guids;
|
||||
|
||||
static HashSet<string> Guids
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_guids == null) Load();
|
||||
return _guids;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns true if this FBX GUID has already been seen.</summary>
|
||||
public static bool IsRegistered(string assetPath)
|
||||
{
|
||||
string guid = AssetDatabase.AssetPathToGUID(assetPath);
|
||||
return !string.IsNullOrEmpty(guid) && Guids.Contains(guid);
|
||||
}
|
||||
|
||||
/// <summary>Marks one or more FBX paths as seen and saves the registry.</summary>
|
||||
public static void Register(IEnumerable<string> assetPaths)
|
||||
{
|
||||
bool changed = false;
|
||||
foreach (var path in assetPaths)
|
||||
{
|
||||
string guid = AssetDatabase.AssetPathToGUID(path);
|
||||
if (!string.IsNullOrEmpty(guid) && Guids.Add(guid))
|
||||
changed = true;
|
||||
}
|
||||
if (changed) Save();
|
||||
}
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────
|
||||
|
||||
static void Load()
|
||||
{
|
||||
_guids = new HashSet<string>();
|
||||
if (!File.Exists(RegistryPath)) return;
|
||||
|
||||
foreach (var line in File.ReadAllLines(RegistryPath))
|
||||
{
|
||||
string trimmed = line.Trim();
|
||||
if (!string.IsNullOrEmpty(trimmed))
|
||||
_guids.Add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
static void Save()
|
||||
{
|
||||
// Ensure the directory exists
|
||||
string dir = Path.GetDirectoryName(RegistryPath);
|
||||
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
File.WriteAllLines(RegistryPath, Guids.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 85c39d9a08dc3d04a974255db5d713ed
|
||||
@@ -0,0 +1,48 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public enum RenderPipeline { BuiltIn, URP, HDRP }
|
||||
|
||||
public static class ShaderDetector
|
||||
{
|
||||
static RenderPipeline? _cached;
|
||||
|
||||
public static RenderPipeline GetPipeline()
|
||||
{
|
||||
if (_cached.HasValue) return _cached.Value;
|
||||
var rp = GraphicsSettings.currentRenderPipeline;
|
||||
if (rp == null) _cached = RenderPipeline.BuiltIn;
|
||||
else if (rp.GetType().Name.Contains("Universal")) _cached = RenderPipeline.URP;
|
||||
else if (rp.GetType().Name.Contains("HD")) _cached = RenderPipeline.HDRP;
|
||||
else _cached = RenderPipeline.BuiltIn;
|
||||
return _cached.Value;
|
||||
}
|
||||
|
||||
public static Shader GetLitShader()
|
||||
{
|
||||
switch (GetPipeline())
|
||||
{
|
||||
case RenderPipeline.URP: return Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
|
||||
case RenderPipeline.HDRP: return Shader.Find("HDRP/Lit") ?? Shader.Find("Standard");
|
||||
default: return Shader.Find("Standard");
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetSlotName(string genericSlot)
|
||||
{
|
||||
if (GetPipeline() != RenderPipeline.HDRP) return genericSlot;
|
||||
switch (genericSlot)
|
||||
{
|
||||
case "_BaseMap": return "_BaseColorMap";
|
||||
case "_BumpMap": return "_NormalMap";
|
||||
case "_MetallicGlossMap": return "_MaskMap";
|
||||
case "_SpecGlossMap": return "_MaskMap";
|
||||
case "_OcclusionMap": return "_MaskMap";
|
||||
case "_EmissionMap": return "_EmissiveColorMap";
|
||||
default: return genericSlot;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dc40db3111674c04f94d8fdb63aed0ab
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public static class TextureMatcher
|
||||
{
|
||||
static readonly string[] SupportedExtensions = { ".png", ".tga", ".jpg", ".jpeg", ".tiff", ".exr" };
|
||||
|
||||
public static readonly Dictionary<string, string[]> SlotSuffixes = new Dictionary<string, string[]>
|
||||
{
|
||||
{ "_BaseMap", new[] { "_D", "_d", "_Diffuse", "_diffuse", "_Albedo", "_albedo", "_Color", "_BaseColor" } },
|
||||
{ "_BumpMap", new[] { "_N", "_n", "_Normal", "_normal", "_NRM", "_nrm" } },
|
||||
{ "_MetallicGlossMap", new[] { "_M", "_m", "_Metallic", "_metallic" } },
|
||||
{ "_SpecGlossMap", new[] { "_R", "_r", "_Roughness", "_roughness" } },
|
||||
{ "_OcclusionMap", new[] { "_AO", "_ao", "_Occlusion", "_occlusion" } },
|
||||
{ "_EmissionMap", new[] { "_E", "_e", "_Emission", "_emission" } },
|
||||
{ "_ParallaxMap", new[] { "_H", "_h", "_Height", "_height" } },
|
||||
};
|
||||
|
||||
public static Texture2D FindForSlot(string textureDir, string baseName, string shaderSlot)
|
||||
{
|
||||
if (!SlotSuffixes.TryGetValue(shaderSlot, out string[] suffixes))
|
||||
return null;
|
||||
|
||||
foreach (var suffix in suffixes)
|
||||
{
|
||||
var tex = FindFile(textureDir, baseName, suffix);
|
||||
if (tex == null) continue;
|
||||
|
||||
if (shaderSlot == "_BumpMap")
|
||||
EnsureNormalMapType(AssetDatabase.GetAssetPath(tex));
|
||||
|
||||
return tex;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips DCC-specific suffixes to recover the clean base name.
|
||||
/// Maya : "hero_blinn1" → "hero" / "standardSurface1" → "Material"
|
||||
/// Both : "tree_MAT" → "tree"
|
||||
/// </summary>
|
||||
public static string CleanMaterialName(string rawName, FBXSource source)
|
||||
{
|
||||
string name = rawName;
|
||||
|
||||
if (source == FBXSource.Maya)
|
||||
{
|
||||
string[] mayaSuffixes = { "_blinn", "_phong", "_lambert", "_aiStandardSurface", "_standardSurface", "_SG" };
|
||||
foreach (var s in mayaSuffixes)
|
||||
{
|
||||
int idx = name.ToLower().IndexOf(s.ToLower());
|
||||
if (idx > 0) { name = name.Substring(0, idx); break; }
|
||||
}
|
||||
|
||||
// Handle names that ARE a maya default shader (e.g. "standardSurface1")
|
||||
if (name.ToLower().StartsWith("standardsurface") ||
|
||||
name.ToLower().StartsWith("lambert") ||
|
||||
name.ToLower().StartsWith("phong") ||
|
||||
name.ToLower().StartsWith("blinn"))
|
||||
name = "Material";
|
||||
|
||||
name = System.Text.RegularExpressions.Regex.Replace(name, @"\d+$", "").TrimEnd('_');
|
||||
}
|
||||
|
||||
foreach (var s in new[] { "_MAT", "_Mat", "_material", "_Material" })
|
||||
if (name.EndsWith(s)) name = name.Substring(0, name.Length - s.Length);
|
||||
|
||||
return string.IsNullOrWhiteSpace(name) ? rawName : name;
|
||||
}
|
||||
|
||||
static Texture2D FindFile(string dir, string baseName, string suffix)
|
||||
{
|
||||
foreach (var ext in SupportedExtensions)
|
||||
{
|
||||
var tex = AssetDatabase.LoadAssetAtPath<Texture2D>($"{dir}/{baseName}{suffix}{ext}");
|
||||
if (tex != null) return tex;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void EnsureNormalMapType(string assetPath)
|
||||
{
|
||||
var ti = AssetImporter.GetAtPath(assetPath) as TextureImporter;
|
||||
if (ti != null && ti.textureType != TextureImporterType.NormalMap)
|
||||
{
|
||||
ti.textureType = TextureImporterType.NormalMap;
|
||||
ti.SaveAndReimport();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 675fd374b2a03994c8ac820bee5fafa2
|
||||
@@ -0,0 +1,139 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Batch dialog shown once after a drop that contains new FBX files.
|
||||
/// Lists every new FBX with a toggle — user decides which ones to process.
|
||||
/// All listed files are already registered so they won't appear again.
|
||||
/// </summary>
|
||||
public class FBXBatchDialog : EditorWindow
|
||||
{
|
||||
// ── Entry data ────────────────────────────────────────────────────────────
|
||||
|
||||
private class FBXEntry
|
||||
{
|
||||
public string Path;
|
||||
public string Name;
|
||||
public FBXSource Source;
|
||||
public bool ShouldProcess = true; // checked by default
|
||||
}
|
||||
|
||||
private List<FBXEntry> _entries = new List<FBXEntry>();
|
||||
private Vector2 _scroll;
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
public static void Show(List<string> fbxPaths)
|
||||
{
|
||||
var window = GetWindow<FBXBatchDialog>(true, "FBX Importer — New Assets", true);
|
||||
window.minSize = new Vector2(500, 300);
|
||||
|
||||
foreach (var path in fbxPaths)
|
||||
{
|
||||
window._entries.Add(new FBXEntry
|
||||
{
|
||||
Path = path,
|
||||
Name = Path.GetFileName(path),
|
||||
Source = FBXSourceDetector.Detect(path),
|
||||
ShouldProcess = true,
|
||||
});
|
||||
}
|
||||
|
||||
window.ShowModal();
|
||||
}
|
||||
|
||||
// ── GUI ───────────────────────────────────────────────────────────────────
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
GUILayout.Space(10);
|
||||
GUILayout.Label($"{_entries.Count} new FBX detected", EditorStyles.boldLabel);
|
||||
GUILayout.Label("All files are registered. Check which ones you want to process.",
|
||||
EditorStyles.miniLabel);
|
||||
GUILayout.Space(8);
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────
|
||||
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
|
||||
{
|
||||
GUILayout.Label("Process", GUILayout.Width(55));
|
||||
GUILayout.Label("File", GUILayout.Width(220));
|
||||
GUILayout.Label("Source", GUILayout.Width(70));
|
||||
GUILayout.Label("Path", GUILayout.ExpandWidth(true));
|
||||
}
|
||||
|
||||
// ── File list ─────────────────────────────────────────────────────────
|
||||
_scroll = EditorGUILayout.BeginScrollView(_scroll, GUILayout.ExpandHeight(true));
|
||||
foreach (var entry in _entries)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
entry.ShouldProcess = EditorGUILayout.Toggle(entry.ShouldProcess, GUILayout.Width(55));
|
||||
|
||||
var nameStyle = new GUIStyle(EditorStyles.label)
|
||||
{ normal = { textColor = entry.ShouldProcess ? Color.white : Color.gray } };
|
||||
|
||||
GUILayout.Label(entry.Name, nameStyle, GUILayout.Width(220));
|
||||
GUILayout.Label(entry.Source.ToString(), EditorStyles.miniLabel, GUILayout.Width(70));
|
||||
GUILayout.Label(entry.Path, EditorStyles.miniLabel, GUILayout.ExpandWidth(true));
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
// ── Select all / none ─────────────────────────────────────────────────
|
||||
GUILayout.Space(4);
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("All", GUILayout.Width(60)))
|
||||
_entries.ForEach(e => e.ShouldProcess = true);
|
||||
if (GUILayout.Button("None", GUILayout.Width(60)))
|
||||
_entries.ForEach(e => e.ShouldProcess = false);
|
||||
}
|
||||
|
||||
GUILayout.Space(8);
|
||||
|
||||
// ── Action buttons ────────────────────────────────────────────────────
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (GUILayout.Button("Skip all", GUILayout.Width(80)))
|
||||
{
|
||||
// Already registered — just close, nothing to process
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
|
||||
int count = _entries.FindAll(e => e.ShouldProcess).Count;
|
||||
string label = count > 0 ? $"Process {count} FBX" : "Process (none selected)";
|
||||
|
||||
using (new EditorGUI.DisabledScope(count == 0))
|
||||
{
|
||||
if (GUILayout.Button(label, GUILayout.Width(140)))
|
||||
{
|
||||
ProcessSelected();
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GUILayout.Space(8);
|
||||
}
|
||||
|
||||
// ── Processing ────────────────────────────────────────────────────────────
|
||||
|
||||
void ProcessSelected()
|
||||
{
|
||||
foreach (var entry in _entries)
|
||||
{
|
||||
if (!entry.ShouldProcess) continue;
|
||||
|
||||
// Open individual config dialog for each selected FBX
|
||||
FBXImportDialog.Show(entry.Path, entry.Source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 96093adb2a120a6468377c6dc73e1607
|
||||
@@ -0,0 +1,118 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.Linq;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a right-click context menu to FBX files in the Project panel.
|
||||
/// This is the ONLY way to trigger the FBX import pipeline.
|
||||
/// Nothing runs automatically — the user always initiates processing explicitly.
|
||||
/// </summary>
|
||||
public static class FBXContextMenu
|
||||
{
|
||||
// ── Single FBX ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Right-click a .fbx file → "FBX Importer / Process this FBX"
|
||||
/// Runs the full pipeline: folders + settings + materials + prefab.
|
||||
/// </summary>
|
||||
[MenuItem("Assets/FBX Importer/Process this FBX", false, 1000)]
|
||||
static void ProcessSingle()
|
||||
{
|
||||
foreach (var obj in Selection.objects)
|
||||
{
|
||||
string path = AssetDatabase.GetAssetPath(obj);
|
||||
if (!path.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Debug.LogWarning($"[FBXImporter] Skipped (not a FBX): {path}");
|
||||
continue;
|
||||
}
|
||||
FBXProcessor.Process(path);
|
||||
}
|
||||
}
|
||||
|
||||
[MenuItem("Assets/FBX Importer/Process this FBX", true)]
|
||||
static bool ProcessSingleValidate()
|
||||
{
|
||||
// Only show the menu item when at least one selected file is a .fbx
|
||||
return Selection.objects.Any(o =>
|
||||
AssetDatabase.GetAssetPath(o).EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// ── Reconnect textures only (no reimport, no prefab) ─────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Right-click a .fbx → "FBX Importer / Reconnect Textures Only"
|
||||
/// Useful when you've added new textures to Textures/ after the first import.
|
||||
/// </summary>
|
||||
[MenuItem("Assets/FBX Importer/Reconnect Textures Only", false, 1001)]
|
||||
static void ReconnectTextures()
|
||||
{
|
||||
foreach (var obj in Selection.objects)
|
||||
{
|
||||
string path = AssetDatabase.GetAssetPath(obj);
|
||||
if (!path.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
var source = FBXSourceDetector.Detect(path);
|
||||
var folders = FolderSetup.Resolve(path);
|
||||
var fbxRoot = AssetDatabase.LoadAssetAtPath<GameObject>(path);
|
||||
|
||||
if (fbxRoot == null)
|
||||
{
|
||||
Debug.LogError($"[FBXImporter] Could not load: {path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-run texture connection only
|
||||
// (calls internal method via FBXProcessor reflection would be cleaner,
|
||||
// but a direct duplicate is simpler and more robust here)
|
||||
Debug.Log($"[FBXImporter] Reconnecting textures for {System.IO.Path.GetFileName(path)}");
|
||||
FBXProcessor.Process(path); // Process handles idempotency — existing mats are reused
|
||||
}
|
||||
}
|
||||
|
||||
[MenuItem("Assets/FBX Importer/Reconnect Textures Only", true)]
|
||||
static bool ReconnectTexturesValidate()
|
||||
{
|
||||
return Selection.objects.Any(o =>
|
||||
AssetDatabase.GetAssetPath(o).EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// ── Create folder structure only ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Right-click anywhere in the Project panel →
|
||||
/// "FBX Importer / Create Asset Folder Structure"
|
||||
/// Creates Sources/Textures/Materials/Prefabs/Audio/VFX under the selected folder.
|
||||
/// </summary>
|
||||
[MenuItem("Assets/FBX Importer/Create Asset Folder Structure", false, 1020)]
|
||||
static void CreateFolderStructure()
|
||||
{
|
||||
string selectedPath = GetSelectedFolderPath();
|
||||
if (string.IsNullOrEmpty(selectedPath))
|
||||
{
|
||||
Debug.LogWarning("[FBXImporter] Select a folder first.");
|
||||
return;
|
||||
}
|
||||
|
||||
string parent = System.IO.Path.GetDirectoryName(selectedPath).Replace("\\", "/");
|
||||
string folderName = System.IO.Path.GetFileName(selectedPath);
|
||||
|
||||
FolderSetup.Create(parent, folderName, false);
|
||||
Debug.Log($"[FBXImporter] Folder structure created under: {selectedPath}");
|
||||
}
|
||||
|
||||
[MenuItem("Assets/FBX Importer/Create Asset Folder Structure", true)]
|
||||
static bool CreateFolderStructureValidate() => !string.IsNullOrEmpty(GetSelectedFolderPath());
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
static string GetSelectedFolderPath()
|
||||
{
|
||||
if (Selection.activeObject == null) return null;
|
||||
string path = AssetDatabase.GetAssetPath(Selection.activeObject);
|
||||
return AssetDatabase.IsValidFolder(path) ? path : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 35883f87017729a4da4aa2723ca60654
|
||||
@@ -0,0 +1,96 @@
|
||||
using UnityEditor;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches for FBX files imported into Assets/.
|
||||
/// Collects all NEW files across the entire import batch, then shows
|
||||
/// a single dialog listing them all — user decides which ones to process.
|
||||
///
|
||||
/// "New" means: not in ProcessedFBXRegistry (GUID-based, persistent).
|
||||
/// All files shown in the dialog are registered immediately so they
|
||||
/// never appear again, regardless of whether the user chose to process them.
|
||||
///
|
||||
/// SAFETY:
|
||||
/// - Hard blocked on anything outside Assets/
|
||||
/// - No asset DB writes during import hooks
|
||||
/// - Dialog + processing deferred via EditorApplication.delayCall
|
||||
/// </summary>
|
||||
public class FBXDropWatcher : AssetPostprocessor
|
||||
{
|
||||
// Accumulates new FBX paths across the current import batch.
|
||||
// Static so all postprocessor instances share the same list.
|
||||
static readonly List<string> _pendingNewFBX = new List<string>();
|
||||
static bool _dialogScheduled = false;
|
||||
|
||||
// ── Guard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
bool ShouldWatch()
|
||||
{
|
||||
return assetPath.StartsWith("Assets/")
|
||||
&& assetPath.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase)
|
||||
&& !ProcessedFBXRegistry.IsRegistered(assetPath);
|
||||
}
|
||||
|
||||
// ── Import hooks ──────────────────────────────────────────────────────────
|
||||
|
||||
void OnPreprocessModel()
|
||||
{
|
||||
if (!ShouldWatch()) return;
|
||||
|
||||
// Configure ModelImporter settings only — no asset DB writes here
|
||||
var importer = assetImporter as ModelImporter;
|
||||
|
||||
importer.importCameras = false;
|
||||
importer.importLights = false;
|
||||
importer.importNormals = ModelImporterNormals.Import;
|
||||
importer.importTangents = ModelImporterTangents.CalculateMikk;
|
||||
importer.importBlendShapes = true;
|
||||
importer.importVisibility = true;
|
||||
|
||||
importer.materialImportMode = ModelImporterMaterialImportMode.ImportViaMaterialDescription;
|
||||
importer.materialLocation = ModelImporterMaterialLocation.External;
|
||||
importer.materialName = ModelImporterMaterialName.BasedOnMaterialName;
|
||||
importer.materialSearch = ModelImporterMaterialSearch.RecursiveUp;
|
||||
|
||||
importer.useFileScale = true;
|
||||
importer.globalScale = 1f;
|
||||
}
|
||||
|
||||
void OnPostprocessModel(UnityEngine.GameObject root)
|
||||
{
|
||||
if (!ShouldWatch()) return;
|
||||
|
||||
// Add to the pending batch
|
||||
if (!_pendingNewFBX.Contains(assetPath))
|
||||
_pendingNewFBX.Add(assetPath);
|
||||
|
||||
// Schedule ONE dialog for the whole batch (deduped with the flag)
|
||||
if (!_dialogScheduled)
|
||||
{
|
||||
_dialogScheduled = true;
|
||||
EditorApplication.delayCall += ShowBatchDialog;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Batch dialog ──────────────────────────────────────────────────────────
|
||||
|
||||
static void ShowBatchDialog()
|
||||
{
|
||||
_dialogScheduled = false;
|
||||
|
||||
if (_pendingNewFBX.Count == 0) return;
|
||||
|
||||
// Snapshot and clear before opening the dialog
|
||||
var batch = new List<string>(_pendingNewFBX);
|
||||
_pendingNewFBX.Clear();
|
||||
|
||||
// Register ALL files immediately — they won't appear again
|
||||
ProcessedFBXRegistry.Register(batch);
|
||||
|
||||
// Open the batch dialog
|
||||
FBXBatchDialog.Show(batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9b0ef00f525fdd4498665e9164696f58
|
||||
@@ -0,0 +1,130 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-asset config dialog opened after the batch dialog for each selected FBX.
|
||||
/// User sets the asset name and destination folder, then confirms.
|
||||
/// </summary>
|
||||
public class FBXImportDialog : EditorWindow
|
||||
{
|
||||
private string _fbxSourcePath;
|
||||
private FBXSource _detectedSource;
|
||||
private string _assetName = "";
|
||||
private string _destinationPath = "Assets";
|
||||
private bool _createAnims = false;
|
||||
private string _errorMessage = "";
|
||||
|
||||
public static void Show(string fbxAssetPath, FBXSource source)
|
||||
{
|
||||
var window = GetWindow<FBXImportDialog>(true, $"Configure — {Path.GetFileName(fbxAssetPath)}", true);
|
||||
window.minSize = new Vector2(440, 260);
|
||||
window.maxSize = new Vector2(440, 260);
|
||||
window._fbxSourcePath = fbxAssetPath;
|
||||
window._detectedSource = source;
|
||||
window._assetName = Path.GetFileNameWithoutExtension(fbxAssetPath);
|
||||
window._destinationPath = Path.GetDirectoryName(fbxAssetPath).Replace("\\", "/");
|
||||
window.ShowModal();
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
GUILayout.Space(10);
|
||||
GUILayout.Label("Configure asset", EditorStyles.boldLabel);
|
||||
GUILayout.Space(6);
|
||||
|
||||
DrawRow("File", Path.GetFileName(_fbxSourcePath));
|
||||
DrawRow("Source", _detectedSource.ToString());
|
||||
GUILayout.Space(10);
|
||||
|
||||
GUILayout.Label("Asset Name", EditorStyles.miniBoldLabel);
|
||||
_assetName = EditorGUILayout.TextField(_assetName);
|
||||
GUILayout.Space(6);
|
||||
|
||||
GUILayout.Label("Destination Folder", EditorStyles.miniBoldLabel);
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField(_destinationPath, EditorStyles.helpBox);
|
||||
if (GUILayout.Button("Browse…", GUILayout.Width(70)))
|
||||
{
|
||||
string chosen = EditorUtility.OpenFolderPanel("Select destination", Application.dataPath, "");
|
||||
if (!string.IsNullOrEmpty(chosen))
|
||||
{
|
||||
if (chosen.StartsWith(Application.dataPath))
|
||||
_destinationPath = "Assets" + chosen.Substring(Application.dataPath.Length);
|
||||
else
|
||||
_errorMessage = "Please select a folder inside Assets/";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.LabelField($"→ {_destinationPath}/{_assetName}", EditorStyles.miniLabel);
|
||||
GUILayout.Space(6);
|
||||
|
||||
_createAnims = EditorGUILayout.ToggleLeft("Create Animations/ folder", _createAnims);
|
||||
GUILayout.Space(8);
|
||||
|
||||
if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
EditorGUILayout.HelpBox(_errorMessage, MessageType.Error);
|
||||
GUILayout.Space(4);
|
||||
}
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (GUILayout.Button("Skip", GUILayout.Width(70)))
|
||||
{
|
||||
Debug.Log($"[FBXImporter] Skipped config for: {_fbxSourcePath}");
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
|
||||
bool valid = !string.IsNullOrWhiteSpace(_assetName)
|
||||
&& AssetDatabase.IsValidFolder(_destinationPath);
|
||||
|
||||
using (new EditorGUI.DisabledScope(!valid))
|
||||
{
|
||||
if (GUILayout.Button("Create & Import", GUILayout.Width(120)))
|
||||
{
|
||||
Confirm();
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Confirm()
|
||||
{
|
||||
var folders = FolderSetup.Create(_destinationPath, _assetName, _createAnims);
|
||||
|
||||
string targetFBX = $"{folders.Sources}/{Path.GetFileName(_fbxSourcePath)}";
|
||||
if (_fbxSourcePath != targetFBX)
|
||||
{
|
||||
string moveError = AssetDatabase.MoveAsset(_fbxSourcePath, targetFBX);
|
||||
if (!string.IsNullOrEmpty(moveError))
|
||||
{
|
||||
Debug.LogError($"[FBXImporter] Could not move FBX: {moveError}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
FBXProcessor.Process(targetFBX);
|
||||
|
||||
var rootObj = AssetDatabase.LoadAssetAtPath<Object>(folders.Root);
|
||||
if (rootObj != null) EditorGUIUtility.PingObject(rootObj);
|
||||
}
|
||||
|
||||
static void DrawRow(string label, string value)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField(label, GUILayout.Width(60));
|
||||
EditorGUILayout.LabelField(value, EditorStyles.miniLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0f982faee0b3f5142a962f73ba411924
|
||||
@@ -0,0 +1,124 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Editor utility window — Window > FBX Importer > Inspector
|
||||
/// Inspect any FBX and trigger processing manually.
|
||||
/// </summary>
|
||||
public class FBXImporterWindow : EditorWindow
|
||||
{
|
||||
private Object _selectedFBX;
|
||||
private Vector2 _scroll;
|
||||
|
||||
[MenuItem("Window/FBX Importer/Inspector")]
|
||||
public static void Open() => GetWindow<FBXImporterWindow>("FBX Importer").Show();
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
GUILayout.Label("FBX Importer — Manual Pipeline", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"Drop a FBX here or select one in the Project panel.\n" +
|
||||
"Nothing runs automatically — you trigger processing explicitly.",
|
||||
MessageType.Info);
|
||||
EditorGUILayout.Space(6);
|
||||
|
||||
_selectedFBX = EditorGUILayout.ObjectField("FBX Asset", _selectedFBX, typeof(Object), false);
|
||||
|
||||
// Auto-fill from Project panel selection
|
||||
if (_selectedFBX == null && Selection.activeObject != null)
|
||||
{
|
||||
string sel = AssetDatabase.GetAssetPath(Selection.activeObject);
|
||||
if (sel.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase))
|
||||
_selectedFBX = Selection.activeObject;
|
||||
}
|
||||
|
||||
if (_selectedFBX == null) return;
|
||||
|
||||
string path = AssetDatabase.GetAssetPath(_selectedFBX);
|
||||
if (!path.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
EditorGUILayout.HelpBox("Selected asset is not a .fbx file.", MessageType.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
DrawInspector(path);
|
||||
}
|
||||
|
||||
void DrawInspector(string assetPath)
|
||||
{
|
||||
EditorGUILayout.Space(8);
|
||||
|
||||
var source = FBXSourceDetector.Detect(assetPath);
|
||||
var folders = FolderSetup.Resolve(assetPath);
|
||||
string clean = TextureMatcher.CleanMaterialName(
|
||||
Path.GetFileNameWithoutExtension(assetPath), source);
|
||||
|
||||
// ── Info ─────────────────────────────────────────────────────────────
|
||||
GUILayout.Label("Detection", EditorStyles.boldLabel);
|
||||
DrawRow("Source", source.ToString(),
|
||||
source != FBXSource.Unknown ? Color.green : Color.yellow);
|
||||
DrawRow("Textures/", folders.Textures,
|
||||
AssetDatabase.IsValidFolder(folders.Textures) ? Color.green : Color.red);
|
||||
DrawRow("Materials/", folders.Materials,
|
||||
AssetDatabase.IsValidFolder(folders.Materials) ? Color.green : Color.yellow);
|
||||
DrawRow("Prefabs/", folders.Prefabs,
|
||||
AssetDatabase.IsValidFolder(folders.Prefabs) ? Color.green : Color.yellow);
|
||||
|
||||
// ── Texture preview ───────────────────────────────────────────────────
|
||||
EditorGUILayout.Space(8);
|
||||
GUILayout.Label("Texture Slot Preview", EditorStyles.boldLabel);
|
||||
|
||||
_scroll = EditorGUILayout.BeginScrollView(_scroll, GUILayout.Height(150));
|
||||
foreach (var kvp in TextureMatcher.SlotSuffixes)
|
||||
{
|
||||
var tex = TextureMatcher.FindForSlot(folders.Textures, clean, kvp.Key);
|
||||
bool found = tex != null;
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
var style = new GUIStyle(EditorStyles.label)
|
||||
{ normal = { textColor = found ? new Color(0.4f, 1f, 0.4f) : new Color(0.6f, 0.6f, 0.6f) } };
|
||||
GUILayout.Label(found ? "✓" : "○", style, GUILayout.Width(16));
|
||||
GUILayout.Label(kvp.Key, GUILayout.Width(170));
|
||||
GUILayout.Label(found ? tex.name : "–", EditorStyles.miniLabel);
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────
|
||||
EditorGUILayout.Space(8);
|
||||
GUILayout.Label("Actions", EditorStyles.boldLabel);
|
||||
|
||||
var bigButton = GUILayout.Height(30);
|
||||
|
||||
if (GUILayout.Button("▶ Process this FBX (full pipeline)", bigButton))
|
||||
FBXProcessor.Process(assetPath);
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
if (GUILayout.Button("📁 Create folder structure only"))
|
||||
{
|
||||
FolderSetup.Create(
|
||||
Path.GetDirectoryName(folders.Root),
|
||||
Path.GetFileName(folders.Root),
|
||||
false);
|
||||
}
|
||||
|
||||
if (GUILayout.Button("🔍 Ping in Project panel"))
|
||||
EditorGUIUtility.PingObject(_selectedFBX);
|
||||
}
|
||||
|
||||
static void DrawRow(string label, string value, Color valueColor)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField(label, GUILayout.Width(90));
|
||||
var style = new GUIStyle(EditorStyles.label) { normal = { textColor = valueColor } };
|
||||
EditorGUILayout.LabelField(value, style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 896d2ee0a3a30ef42baf3b73fc486171
|
||||
@@ -0,0 +1,32 @@
|
||||
# FBX Importer v5
|
||||
|
||||
## Flow
|
||||
|
||||
1. Tu glisses un dossier avec FBX dans Unity
|
||||
2. Une seule pop up liste TOUS les nouveaux FBX
|
||||
3. Tu coches ceux que tu veux traiter
|
||||
4. Pour chacun → dialog de config (nom + destination)
|
||||
5. Pipeline complet : dossiers / materials / prefab
|
||||
|
||||
## "Nouveau" = jamais vu par le plugin
|
||||
Les GUIDs sont stockés dans processed_fbx.txt.
|
||||
Un FBX vu une fois (même si skip) ne repasse plus jamais.
|
||||
|
||||
## Reset un FBX
|
||||
Clic droit → FBX Importer → Reset (remove from registry)
|
||||
Ou supprime son GUID dans processed_fbx.txt
|
||||
|
||||
## Process manuel
|
||||
Clic droit sur un .fbx → FBX Importer → Process this FBX
|
||||
|
||||
## Structure attendue
|
||||
AssetName/
|
||||
├── Sources/ ← FBX
|
||||
├── Textures/ ← PNG/TGA
|
||||
├── Materials/ ← auto
|
||||
├── Prefabs/ ← auto
|
||||
├── Audio/ ← auto vide
|
||||
└── VFX/ ← auto vide
|
||||
|
||||
## Texture naming
|
||||
_D / _N / _R / _M / _AO / _E / _H
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 59e10e549f96dca46b441aabe7904ec7
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -206,6 +206,18 @@ namespace Unheimlich.Features.Dialogue.UI
|
||||
_shakeOffset = Vector3.zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Syncs the ghost word's sibling index so depth sorting covers both the main word and its ghost trail.
|
||||
/// Should be called right after SetSiblingIndex on the main word.
|
||||
/// </summary>
|
||||
public void SyncGhostSiblingIndex(int mainSiblingIndex)
|
||||
{
|
||||
if (_ghostTextComponent == null) return;
|
||||
// Place ghost just before its main word so it renders underneath it.
|
||||
int ghostIndex = Mathf.Max(0, mainSiblingIndex - 1);
|
||||
_ghostTextComponent.transform.SetSiblingIndex(ghostIndex);
|
||||
}
|
||||
|
||||
public void SetShakeSpeedMultiplier(float multiplier)
|
||||
{
|
||||
_shakeSpeedMultiplier = Mathf.Max(0f, multiplier);
|
||||
|
||||
@@ -366,28 +366,7 @@ namespace Unheimlich.Features.Dialogue.UI
|
||||
|
||||
_sortTimer = 0f;
|
||||
|
||||
Camera mainCamera = Camera.main;
|
||||
if (mainCamera == null)
|
||||
return;
|
||||
|
||||
Vector3 cameraPosition = mainCamera.transform.position;
|
||||
|
||||
_activeChoices.Sort((a, b) =>
|
||||
{
|
||||
if (a == null || b == null)
|
||||
return 0;
|
||||
|
||||
float distanceA = Vector3.Distance(cameraPosition, a.transform.position);
|
||||
float distanceB = Vector3.Distance(cameraPosition, b.transform.position);
|
||||
|
||||
return distanceB.CompareTo(distanceA);
|
||||
});
|
||||
|
||||
for (int i = 0; i < _activeChoices.Count; i++)
|
||||
{
|
||||
if (_activeChoices[i] != null)
|
||||
_activeChoices[i].transform.SetSiblingIndex(i);
|
||||
}
|
||||
FloatingWordsSpawner.SortByDistance(_activeChoices, Camera.main);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,6 +374,40 @@ namespace Unheimlich.Features.Dialogue.UI
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Depth Sorting
|
||||
/// <summary>
|
||||
/// Sorts words by distance to camera so closer words render on top via sibling index.
|
||||
/// Ghost trails are synced to always sit just below their main word.
|
||||
/// Shared by ObjectDescriptionTrigger and DialogueUI to avoid duplication.
|
||||
/// Sorts a temporary copy to preserve the original list order, which may be index-coupled
|
||||
/// to spawn positions (e.g. _originalSpawnPositions in ObjectDescriptionTrigger).
|
||||
/// </summary>
|
||||
public static void SortByDistance(List<ChoiceFocusHandler> words, Camera camera)
|
||||
{
|
||||
if (words.Count <= 1 || camera == null) return;
|
||||
|
||||
Vector3 cameraPosition = camera.transform.position;
|
||||
|
||||
List<ChoiceFocusHandler> sorted = new List<ChoiceFocusHandler>(words);
|
||||
sorted.Sort((a, b) =>
|
||||
{
|
||||
if (a == null || b == null) return 0;
|
||||
float dA = Vector3.Distance(cameraPosition, a.transform.position);
|
||||
float dB = Vector3.Distance(cameraPosition, b.transform.position);
|
||||
return dA.CompareTo(dB);
|
||||
});
|
||||
|
||||
for (int i = 0; i < sorted.Count; i++)
|
||||
{
|
||||
if (sorted[i] != null)
|
||||
{
|
||||
sorted[i].transform.SetSiblingIndex(i);
|
||||
sorted[i].SyncGhostSiblingIndex(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Tools
|
||||
private static Vector3 CalculateCircularPosition(float angleRad, float radius, RotationAxis axis, float heightOffset)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unheimlich.Features.Player;
|
||||
|
||||
namespace Unheimlich.World.Environment
|
||||
{
|
||||
/// <summary>
|
||||
/// Infinite parallax staircase — tower approach.
|
||||
///
|
||||
/// When the player enters the trigger zone from below:
|
||||
/// - A full tower of (_maxIterations + 1) segments is spawned instantly above ground.
|
||||
/// - The tower scrolls at a speed proportional to how far the player's Y deviates
|
||||
/// from the trigger center — keeping the player locked near that height.
|
||||
/// If the player stops moving, scroll speed drops to zero.
|
||||
/// - When the topmost segment reaches ground level the stock is exhausted: the tower
|
||||
/// decelerates, extras are destroyed, and the system resets.
|
||||
///
|
||||
/// Entering from above mirrors the logic (tower ascends, stock is below ground).
|
||||
/// Re-triggerable indefinitely.
|
||||
///
|
||||
/// Setup:
|
||||
/// 1. Add this component to the InfinityStair root.
|
||||
/// 2. Add <see cref="StaircaseTriggerZone"/> to the Trigger child.
|
||||
/// 3. Assign <see cref="_stairPrefab"/>.
|
||||
/// </summary>
|
||||
public class InfiniteStaircaseController : MonoBehaviour
|
||||
{
|
||||
// ─── Prefab ──────────────────────────────────────────────────────────
|
||||
|
||||
[Header("Stair Prefab")]
|
||||
[Tooltip("One staircase segment prefab used to build the tower.")]
|
||||
[SerializeField] private GameObject _stairPrefab;
|
||||
|
||||
[Tooltip("If true, the first child with renderers is reused as slot 0 rather than instantiated from the prefab.")]
|
||||
[SerializeField] private bool _useExistingChildAsBase = true;
|
||||
|
||||
// ─── Iterations ──────────────────────────────────────────────────────
|
||||
|
||||
[Header("Iterations")]
|
||||
[Tooltip("Number of full segment heights the player can traverse. Tower = (maxIterations + 1) segments.")]
|
||||
[SerializeField] private int _maxIterations = 4;
|
||||
|
||||
// ─── Segment Bounds ──────────────────────────────────────────────────
|
||||
|
||||
[Header("Segment Bounds — Auto-Detection")]
|
||||
[Tooltip("Renderer of the central column mesh in the stair asset. When assigned, segment height is read from its world bounds.")]
|
||||
[SerializeField] private Renderer _centralColumnRenderer;
|
||||
|
||||
[Tooltip("Fallback segment height (m) used when no central column renderer is assigned.")]
|
||||
[SerializeField] private float _segmentHeight = 4.82f;
|
||||
|
||||
[Tooltip("Y offset added to the segment height for fine-tuning snapping.")]
|
||||
[SerializeField] private float _segmentHeightOffset = 0f;
|
||||
|
||||
// ─── Scroll Control ───────────────────────────────────────────────────
|
||||
|
||||
[Header("Scroll Speed Control")]
|
||||
[Tooltip("Proportional gain: scroll speed (m/s) per metre of player deviation from the trigger center.")]
|
||||
[SerializeField] private float _correctionGain = 4f;
|
||||
|
||||
[Tooltip("Maximum tower scroll speed in m/s.")]
|
||||
[SerializeField] private float _maxScrollSpeed = 8f;
|
||||
|
||||
[Tooltip("SmoothDamp time for scroll speed transitions — prevents sudden jolts.")]
|
||||
[SerializeField] private float _scrollSmoothTime = 0.1f;
|
||||
|
||||
// ─── Rotation Offset ─────────────────────────────────────────────────
|
||||
|
||||
[Header("Rotation Offset")]
|
||||
[Tooltip("Y rotation applied to every spawned segment around its geometric bounds center (not its pivot).")]
|
||||
[SerializeField] private float _segmentRotationOffsetY = 0f;
|
||||
|
||||
// ─── Debug ───────────────────────────────────────────────────────────
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool _drawGizmos = true;
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
private enum StairState { Idle, Active, Stopping, Done }
|
||||
private StairState _currentState = StairState.Idle;
|
||||
|
||||
private Transform _playerTransform;
|
||||
|
||||
/// <summary>Single parent moved to scroll the entire tower at once.</summary>
|
||||
private Transform _towerRoot;
|
||||
|
||||
/// <summary>World Y of the trigger collider center, cached in Awake.</summary>
|
||||
private float _triggerCenterWorldY;
|
||||
|
||||
/// <summary>True when the player entered from below (going up).</summary>
|
||||
private bool _enteredFromBelow;
|
||||
|
||||
/// <summary>
|
||||
/// towerRoot.localPosition.y threshold signalling stock exhaustion.
|
||||
/// Negative when going up, positive when going down.
|
||||
/// </summary>
|
||||
private float _limitLocalY;
|
||||
|
||||
/// <summary>Current scroll speed (signed). Positive means the tower moves down.</summary>
|
||||
private float _scrollSpeed;
|
||||
private float _scrollSpeedVelocity;
|
||||
|
||||
private readonly Dictionary<int, GameObject> _segmentsBySlot = new();
|
||||
|
||||
// ─── Properties ──────────────────────────────────────────────────────
|
||||
|
||||
private float EffectiveSegmentHeight => _segmentHeight + _segmentHeightOffset;
|
||||
private float MaxTowerDisplacement => _maxIterations * EffectiveSegmentHeight;
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
AutoDetectBoundsFromColumn();
|
||||
CacheTriggerCenterY();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
EnsurePlayerCached();
|
||||
|
||||
switch (_currentState)
|
||||
{
|
||||
case StairState.Active:
|
||||
UpdateScrollSpeed();
|
||||
ApplyScrollToTower();
|
||||
CheckLimitReached();
|
||||
break;
|
||||
|
||||
case StairState.Stopping:
|
||||
DecelerateAndFinish();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Trigger Events ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Called by <see cref="StaircaseTriggerZone"/> when the player enters the collider.
|
||||
/// Detects entry direction, builds the tower, and starts the effect.
|
||||
/// </summary>
|
||||
public void OnPlayerEnteredTrigger()
|
||||
{
|
||||
if (_currentState != StairState.Idle && _currentState != StairState.Done) return;
|
||||
|
||||
EnsurePlayerCached();
|
||||
if (_playerTransform == null) return;
|
||||
|
||||
_enteredFromBelow = _playerTransform.position.y < _triggerCenterWorldY;
|
||||
_scrollSpeed = 0f;
|
||||
_scrollSpeedVelocity = 0f;
|
||||
|
||||
BuildTower();
|
||||
_currentState = StairState.Active;
|
||||
|
||||
Debug.Log($"[InfiniteStaircase] Activated — {(_enteredFromBelow ? "UP" : "DOWN")}, {_maxIterations} iterations.");
|
||||
}
|
||||
|
||||
/// <summary>Called by <see cref="StaircaseTriggerZone"/> when the player exits. No action needed — the limit check drives the stop.</summary>
|
||||
public void OnPlayerExitedTrigger() { }
|
||||
|
||||
// ─── Tower Construction ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Spawns the full tower under a single _towerRoot Transform.
|
||||
/// UP: slots 0..+N above ground. Limit = -N*h (descend to exhaust stock).
|
||||
/// DOWN: slots 0..-N below ground. Limit = +N*h (ascend to exhaust stock).
|
||||
/// </summary>
|
||||
private void BuildTower()
|
||||
{
|
||||
DestroyTower();
|
||||
|
||||
_towerRoot = new GameObject("_TowerRoot").transform;
|
||||
_towerRoot.SetParent(transform, false);
|
||||
_towerRoot.localPosition = Vector3.zero;
|
||||
_towerRoot.localRotation = Quaternion.identity;
|
||||
_towerRoot.localScale = Vector3.one;
|
||||
|
||||
if (_enteredFromBelow)
|
||||
{
|
||||
for (int i = 0; i <= _maxIterations; i++)
|
||||
SpawnSegmentAtSlot(i);
|
||||
_limitLocalY = -MaxTowerDisplacement;
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i >= -_maxIterations; i--)
|
||||
SpawnSegmentAtSlot(i);
|
||||
_limitLocalY = MaxTowerDisplacement;
|
||||
}
|
||||
}
|
||||
|
||||
private void DestroyTower()
|
||||
{
|
||||
if (_towerRoot != null)
|
||||
{
|
||||
Destroy(_towerRoot.gameObject);
|
||||
_towerRoot = null;
|
||||
}
|
||||
_segmentsBySlot.Clear();
|
||||
}
|
||||
|
||||
private void SpawnSegmentAtSlot(int slotIndex)
|
||||
{
|
||||
if (_segmentsBySlot.ContainsKey(slotIndex)) return;
|
||||
|
||||
float localY = slotIndex * EffectiveSegmentHeight;
|
||||
GameObject segment;
|
||||
|
||||
// Slot 0: try to reuse the existing scene child to avoid a visible pop.
|
||||
if (slotIndex == 0 && _useExistingChildAsBase)
|
||||
{
|
||||
Transform existing = FindExistingStairChild();
|
||||
if (existing != null)
|
||||
{
|
||||
segment = existing.gameObject;
|
||||
segment.transform.SetParent(_towerRoot, false);
|
||||
segment.transform.localPosition = new Vector3(0f, localY, 0f);
|
||||
segment.transform.localRotation = Quaternion.identity;
|
||||
ApplyRotationOffsetFromCenter(segment);
|
||||
_segmentsBySlot[slotIndex] = segment;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_stairPrefab == null) return;
|
||||
|
||||
segment = Instantiate(_stairPrefab, _towerRoot);
|
||||
segment.transform.localPosition = new Vector3(0f, localY, 0f);
|
||||
segment.transform.localRotation = Quaternion.identity;
|
||||
ApplyRotationOffsetFromCenter(segment);
|
||||
_segmentsBySlot[slotIndex] = segment;
|
||||
}
|
||||
|
||||
// ─── Scroll ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Proportional controller: error = player.y - triggerCenter.y.
|
||||
/// Positive error (player above center) → tower moves down → positive speed.
|
||||
/// Zero error (player at center) → speed converges to zero → tower stops.
|
||||
/// Speed is clamped so it can only push the tower in the intended direction,
|
||||
/// preventing the initial negative error from making the tower snap upward.
|
||||
/// </summary>
|
||||
private void UpdateScrollSpeed()
|
||||
{
|
||||
if (_playerTransform == null) return;
|
||||
|
||||
float error = _playerTransform.position.y - _triggerCenterWorldY;
|
||||
float rawTarget = Mathf.Clamp(error * _correctionGain, -_maxScrollSpeed, _maxScrollSpeed);
|
||||
|
||||
// Only allow speed in the direction that moves the tower correctly.
|
||||
// Going up (entered from below): tower must descend → speed >= 0.
|
||||
// Going down (entered from above): tower must ascend → speed <= 0.
|
||||
float targetSpeed = _enteredFromBelow
|
||||
? Mathf.Max(rawTarget, 0f)
|
||||
: Mathf.Min(rawTarget, 0f);
|
||||
|
||||
_scrollSpeed = Mathf.SmoothDamp(_scrollSpeed, targetSpeed, ref _scrollSpeedVelocity, _scrollSmoothTime);
|
||||
}
|
||||
|
||||
private void ApplyScrollToTower()
|
||||
{
|
||||
if (_towerRoot == null) return;
|
||||
|
||||
Vector3 pos = _towerRoot.localPosition;
|
||||
pos.y -= _scrollSpeed * Time.deltaTime;
|
||||
|
||||
// Clamp to the stock limit — never overshoot the last segment.
|
||||
pos.y = _enteredFromBelow
|
||||
? Mathf.Max(pos.y, _limitLocalY)
|
||||
: Mathf.Min(pos.y, _limitLocalY);
|
||||
|
||||
// Clamp to the origin — slot 0 must never move past its starting position.
|
||||
// Going up: tower can only descend from 0. Going down: tower can only ascend from 0.
|
||||
pos.y = _enteredFromBelow
|
||||
? Mathf.Min(pos.y, 0f)
|
||||
: Mathf.Max(pos.y, 0f);
|
||||
|
||||
_towerRoot.localPosition = pos;
|
||||
}
|
||||
|
||||
private void CheckLimitReached()
|
||||
{
|
||||
if (_towerRoot == null) return;
|
||||
|
||||
bool reached = _enteredFromBelow
|
||||
? _towerRoot.localPosition.y <= _limitLocalY
|
||||
: _towerRoot.localPosition.y >= _limitLocalY;
|
||||
|
||||
if (reached)
|
||||
{
|
||||
_currentState = StairState.Stopping;
|
||||
Debug.Log("[InfiniteStaircase] Stock exhausted — decelerating.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Smoothly brings the tower to rest before triggering cleanup.
|
||||
/// </summary>
|
||||
private void DecelerateAndFinish()
|
||||
{
|
||||
_scrollSpeed = Mathf.SmoothDamp(_scrollSpeed, 0f, ref _scrollSpeedVelocity, _scrollSmoothTime);
|
||||
ApplyScrollToTower();
|
||||
|
||||
if (Mathf.Abs(_scrollSpeed) < 0.02f)
|
||||
FinishAndCleanup();
|
||||
}
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the segment now at ground level, destroys the rest, and resets state.
|
||||
/// </summary>
|
||||
private void FinishAndCleanup()
|
||||
{
|
||||
_currentState = StairState.Done;
|
||||
if (_towerRoot == null) return;
|
||||
|
||||
int keepSlot = _enteredFromBelow ? _maxIterations : -_maxIterations;
|
||||
_segmentsBySlot.TryGetValue(keepSlot, out GameObject keepSegment);
|
||||
|
||||
if (keepSegment != null)
|
||||
{
|
||||
keepSegment.transform.SetParent(transform, true);
|
||||
keepSegment.transform.position = transform.position;
|
||||
}
|
||||
|
||||
DestroyTower();
|
||||
|
||||
if (keepSegment != null)
|
||||
_segmentsBySlot[0] = keepSegment;
|
||||
|
||||
_scrollSpeed = 0f;
|
||||
_scrollSpeedVelocity = 0f;
|
||||
_playerTransform = null;
|
||||
|
||||
Debug.Log("[InfiniteStaircase] Cleanup done — ready for next interaction.");
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private void AutoDetectBoundsFromColumn()
|
||||
{
|
||||
if (_centralColumnRenderer == null) return;
|
||||
_segmentHeight = _centralColumnRenderer.bounds.size.y;
|
||||
Debug.Log($"[InfiniteStaircase] Auto-detected height: {_segmentHeight:F3} m");
|
||||
}
|
||||
|
||||
private void CacheTriggerCenterY()
|
||||
{
|
||||
StaircaseTriggerZone zone = GetComponentInChildren<StaircaseTriggerZone>();
|
||||
if (zone == null)
|
||||
{
|
||||
Debug.LogWarning("[InfiniteStaircase] No StaircaseTriggerZone found in children.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
Collider col = zone.GetComponent<Collider>();
|
||||
if (col != null)
|
||||
{
|
||||
_triggerCenterWorldY = col.bounds.center.y;
|
||||
Debug.Log($"[InfiniteStaircase] Trigger center Y: {_triggerCenterWorldY:F3}");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsurePlayerCached()
|
||||
{
|
||||
if (_playerTransform != null) return;
|
||||
if (PlayerController.Instance != null)
|
||||
_playerTransform = PlayerController.Instance.transform;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first direct child of InfinityStair (excluding _towerRoot) that has renderers.
|
||||
/// </summary>
|
||||
private Transform FindExistingStairChild()
|
||||
{
|
||||
foreach (Transform child in transform)
|
||||
{
|
||||
if (child == _towerRoot) continue;
|
||||
if (child.GetComponentInChildren<MeshRenderer>() != null)
|
||||
return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ApplyRotationOffsetFromCenter(GameObject segment)
|
||||
{
|
||||
if (Mathf.Approximately(_segmentRotationOffsetY, 0f)) return;
|
||||
segment.transform.RotateAround(ComputeWorldBoundsCenter(segment), Vector3.up, _segmentRotationOffsetY);
|
||||
}
|
||||
|
||||
private Vector3 ComputeWorldBoundsCenter(GameObject segment)
|
||||
{
|
||||
Renderer[] renderers = segment.GetComponentsInChildren<Renderer>();
|
||||
if (renderers.Length == 0)
|
||||
return segment.transform.position + Vector3.up * (EffectiveSegmentHeight * 0.5f);
|
||||
|
||||
Bounds combined = renderers[0].bounds;
|
||||
for (int i = 1; i < renderers.Length; i++)
|
||||
combined.Encapsulate(renderers[i].bounds);
|
||||
|
||||
return combined.center;
|
||||
}
|
||||
|
||||
// ─── Gizmos ──────────────────────────────────────────────────────────
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!_drawGizmos) return;
|
||||
|
||||
float h = EffectiveSegmentHeight;
|
||||
Vector3 o = transform.position;
|
||||
|
||||
Gizmos.color = new Color(0f, 1f, 0.5f, 0.35f);
|
||||
for (int i = 1; i <= _maxIterations; i++)
|
||||
Gizmos.DrawWireCube(new Vector3(o.x, o.y + i * h + h * 0.5f, o.z), new Vector3(1.8f, h * 0.95f, 1.8f));
|
||||
|
||||
Gizmos.color = new Color(1f, 0.6f, 0f, 0.25f);
|
||||
for (int i = 1; i <= _maxIterations; i++)
|
||||
Gizmos.DrawWireCube(new Vector3(o.x, o.y - i * h + h * 0.5f, o.z), new Vector3(1.8f, h * 0.95f, 1.8f));
|
||||
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawLine(new Vector3(o.x - 2f, _triggerCenterWorldY, o.z), new Vector3(o.x + 2f, _triggerCenterWorldY, o.z));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3462960ec1dfaee448d860de31230d02
|
||||
@@ -0,0 +1,40 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.World.Environment
|
||||
{
|
||||
/// <summary>
|
||||
/// Placed on the trigger child of an <see cref="InfiniteStaircaseController"/>.
|
||||
/// Forwards Player trigger enter / exit events to the parent controller.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider))]
|
||||
public class StaircaseTriggerZone : MonoBehaviour
|
||||
{
|
||||
private const string PlayerTag = "Player";
|
||||
|
||||
private InfiniteStaircaseController _controller;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_controller = GetComponentInParent<InfiniteStaircaseController>();
|
||||
|
||||
if (_controller == null)
|
||||
Debug.LogWarning($"[StaircaseTriggerZone] No InfiniteStaircaseController found in parent of '{name}'.", this);
|
||||
}
|
||||
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
if (_controller == null) return;
|
||||
if (!other.CompareTag(PlayerTag)) return;
|
||||
|
||||
_controller.OnPlayerEnteredTrigger();
|
||||
}
|
||||
|
||||
private void OnTriggerExit(Collider other)
|
||||
{
|
||||
if (_controller == null) return;
|
||||
if (!other.CompareTag(PlayerTag)) return;
|
||||
|
||||
_controller.OnPlayerExitedTrigger();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 89e1fa0fefb27a245bf290633fa7fdb3
|
||||
Reference in New Issue
Block a user