+ Add : Plugin fbx integration

This commit is contained in:
Ferlat Thibaud
2026-03-24 20:11:39 +01:00
parent a16a9b1837
commit b1c1b31354
169 changed files with 18357 additions and 1242 deletions
@@ -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());
}
}
}
@@ -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