using System.Collections.Generic; using System.IO; using System.Linq; using ColbyO.VNTG.CRT; using ColbyO.VNTG.PSX; using UnityEditor; using UnityEditor.Animations; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using UnityEngine.SceneManagement; namespace IllOmen.EditorTools { /// Builds the visual test scene: the mill counter, a candle and one villager. public static class MillTestSceneBuilder { private const string KitPath = "Assets/ThirdParty/Models/RetroMedievalKit/3D Retro Medieval Fantasy Kit/3D Retro Medieval Fantasy Kit.blend"; private const string VillagerPath = "Assets/ThirdParty/Models/VillagerWoman/PSX Villager Woman/PSX_Villager_Woman_Model_And_Animations.fbx"; private const string VillagerTexturePath = "Assets/ThirdParty/Models/VillagerWoman/PSX Villager Woman/VillageWomanTexture.png"; private const string BaseMaterialPath = "Assets/ColbyO/VNTG/Example/Material/PSX/WoodMat.mat"; private const string GeneratedFolder = "Assets/IllOmen/Generated"; private const string ScenePath = "Assets/IllOmen/Scenes/MillTest.unity"; private const string LineupScenePath = "Assets/IllOmen/Scenes/CharacterLineup.unity"; private static readonly Dictionary MaterialCache = new Dictionary(); private static GameObject kit; private static Material baseMaterial; [MenuItem("Ill Omen/Build Mill Test Scene")] public static void Build() { if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return; MaterialCache.Clear(); kit = AssetDatabase.LoadAssetAtPath(KitPath); baseMaterial = AssetDatabase.LoadAssetAtPath(BaseMaterialPath); if (kit == null || baseMaterial == null) { Debug.LogError($"[IllOmen] Missing asset. Kit found: {kit != null}, base material found: {baseMaterial != null}"); return; } Directory.CreateDirectory(GeneratedFolder + "/Materials"); Directory.CreateDirectory(Path.GetDirectoryName(ScenePath)); AssetDatabase.Refresh(); Scene scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); RenderSettings.skybox = null; RenderSettings.ambientMode = AmbientMode.Flat; RenderSettings.ambientLight = new Color(0.02f, 0.02f, 0.03f); BuildRoom(); BuildCounter(); BuildProps(); BuildVillager(); BuildCandleLight(); BuildCamera(); BuildVolume(); BuildAmbience(); EditorSceneManager.SaveScene(scene, ScenePath); AssetDatabase.SaveAssets(); Debug.Log($"[IllOmen] Built {ScenePath}"); } [MenuItem("Ill Omen/Build Character Lineup")] public static void BuildLineup() { if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return; MaterialCache.Clear(); kit = AssetDatabase.LoadAssetAtPath(KitPath); baseMaterial = AssetDatabase.LoadAssetAtPath(BaseMaterialPath); Directory.CreateDirectory(GeneratedFolder + "/Materials"); Directory.CreateDirectory(Path.GetDirectoryName(LineupScenePath)); AssetDatabase.Refresh(); Scene scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); RenderSettings.skybox = null; RenderSettings.ambientMode = AmbientMode.Flat; RenderSettings.ambientLight = new Color(0.25f, 0.25f, 0.28f); Transform stage = new GameObject("Stage").transform; for (int x = -6; x < 6; x++) { for (int z = -3; z < 3; z++) { Place("Floor Thin", new Vector3(x + 0.5f, 0f, z + 0.5f), 0f, stage, "Wood-Dark", "Wood-Dark", "Wood-Dark"); } } for (int i = 0; i < 6; i++) { Place("Wall A Plain", new Vector3(-5f + i * 2f, 0f, 3f), 0f, stage, "Wood-Dark", "Wood-Mid", "Wood-Pale"); } string men = "Assets/ThirdParty/Models/VillagerMen/"; string adult = men + "adultman__/FBX_mixamo/AdultMan___.fbx"; string ionDirty = "Assets/IllOmen/Textures/Characters/Ion_Dirty.png"; string ionClean = "Assets/IllOmen/Textures/Characters/Ion_Clean.png"; string young = men + "youngman__/FBX_Mixamo/YoungMan___.fbx"; string youngTexture = men + "youngman__/textures/Texture_LOWRES.jpg"; Color pale = new Color(0.62f, 0.62f, 0.66f); PlaceCharacter(adult, ionDirty, "Ion Night 1", -3.75f, 1.76f, Color.white, "Ion, night 1\n(dirty)"); PlaceCharacter(adult, ionClean, "Ion Night 2", -2.25f, 1.76f, Color.white, "Ion, night 2\n(clean)"); PlaceCharacter(young, youngTexture, "Young Man", -5.25f, 1.75f, Color.white, "Young man\n(Toma)"); PlaceCharacter("Assets/IllOmen/Models/Characters/Andrei.fbx", "Assets/IllOmen/Textures/Characters/Andrei.png", "Andrei", -0.75f, 1.75f, Color.white, "Andrei"); PlaceCharacter("Assets/IllOmen/Models/Characters/FatherIlie.fbx", "Assets/IllOmen/Textures/Characters/FatherIlie.png", "Father Ilie", 0.75f, 1.82f, Color.white, "Father Ilie"); PlaceCharacter(VillagerPath, "Assets/IllOmen/Textures/Characters/Ana_TestOnly.png", "Ana", 2.25f, 1.70f, Color.white, "Ana, test only"); PlaceCharacter("Assets/IllOmen/Models/Characters/Safta.fbx", "Assets/IllOmen/Textures/Characters/Safta.png", "Safta", 3.75f, 1.58f, Color.white, "Safta"); // Bright, even light: this scene exists to judge clothing, not mood. RenderSettings.ambientLight = new Color(0.45f, 0.45f, 0.47f); foreach (Vector3 position in new[] { new Vector3(-3f, 2.6f, -1.2f), new Vector3(3f, 2.6f, -1.2f), new Vector3(-3f, 0.8f, -1.2f), new Vector3(3f, 0.8f, -1.2f) }) { GameObject lamp = new GameObject("Stage Light"); lamp.transform.position = position; Light light = lamp.AddComponent(); light.type = LightType.Point; light.color = new Color(1f, 0.9f, 0.78f); light.range = 10f; light.intensity = 8f; light.shadows = LightShadows.None; } BuildCamera(); // Starts right in front of Safta, with Ana beside her. Camera.main.transform.SetPositionAndRotation(new Vector3(3.75f, 1.5f, -0.9f), Quaternion.identity); Camera.main.fieldOfView = 70f; Object.DestroyImmediate(Camera.main.GetComponent()); Camera.main.gameObject.AddComponent(); BuildVolume(); EditorSceneManager.SaveScene(scene, LineupScenePath); AssetDatabase.SaveAssets(); Debug.Log($"[IllOmen] Built {LineupScenePath}"); } private static void PlaceCharacter(string modelPath, string texturePath, string name, float x, float height, Color tint, string label) { GameObject model = AssetDatabase.LoadAssetAtPath(modelPath); Texture2D texture = AssetDatabase.LoadAssetAtPath(texturePath); if (model == null || texture == null) { Debug.LogError($"[IllOmen] Missing character asset for {name}. Model: {model != null}, texture: {texture != null}"); return; } GameObject character = (GameObject)PrefabUtility.InstantiatePrefab(model); character.name = name; Material material = GetMaterial(texture, name); foreach (string colorProperty in new[] { "_BaseColor", "_Color", "_TintColor" }) { if (material.HasProperty(colorProperty)) material.SetColor(colorProperty, tint); } EditorUtility.SetDirty(material); foreach (Renderer renderer in character.GetComponentsInChildren()) { renderer.sharedMaterials = Enumerable.Repeat(material, renderer.sharedMaterials.Length).ToArray(); } Bounds bounds = GetBounds(character); if (bounds.size.y > 0.01f) character.transform.localScale *= height / bounds.size.y; bounds = GetBounds(character); character.transform.position += new Vector3(x - bounds.center.x, -bounds.min.y, 0.8f - bounds.center.z); character.transform.Rotate(0f, 180f, 0f, Space.World); bool lowered = LowerArm(character, "UpperArm.L", "LowerArm.L") & LowerArm(character, "UpperArm.R", "LowerArm.R"); if (modelPath == VillagerPath) AttachIdle(character); Debug.Log($"[IllOmen] Character {name}: size {bounds.size}, arms lowered: {lowered}"); GameObject text = new GameObject(name + " Label"); text.transform.position = new Vector3(x, height + 0.32f, 0.8f); TextMesh mesh = text.AddComponent(); mesh.text = label; mesh.font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); mesh.fontSize = 48; mesh.characterSize = 0.022f; mesh.anchor = TextAnchor.LowerCenter; mesh.alignment = TextAlignment.Center; text.GetComponent().sharedMaterial = mesh.font.material; } /// The men have no animations, so we pose them out of the T-pose by pointing each arm down. private static bool LowerArm(GameObject character, string upperName, string lowerName) { Transform[] bones = character.GetComponentsInChildren(true); Transform upper = bones.FirstOrDefault(bone => bone.name == upperName); Transform lower = bones.FirstOrDefault(bone => bone.name == lowerName); if (upper == null || lower == null) return false; Vector3 current = (lower.position - upper.position).normalized; float side = Mathf.Sign(Vector3.Dot(current, character.transform.right)); Vector3 target = (Vector3.down + character.transform.right * side * 0.12f).normalized; upper.rotation = Quaternion.FromToRotation(current, target) * upper.rotation; return true; } private static void BuildRoom() { Transform room = new GameObject("Room").transform; // The room is 6 x 6 metres: each wall piece is 2 metres wide. string[] back = { "Wall A Window Small", "Wall A Door", "Wall A Plain" }; for (int i = 0; i < 3; i++) { float offset = -2f + i * 2f; PlaceWall(back[i], new Vector3(offset, 0f, 3f), 0f, room); PlaceWall("Wall A Plain", new Vector3(offset, 0f, -3f), 180f, room); PlaceWall("Wall A Plain", new Vector3(-3f, 0f, offset), 90f, room); PlaceWall("Wall A Plain", new Vector3(3f, 0f, offset), 90f, room); } Place("Door", new Vector3(0f, 0f, 3f), 0f, room, "Wood-Dark", "Wood-Dark", "Wood-Dark"); for (int x = 0; x < 6; x++) { for (int z = 0; z < 6; z++) { Vector3 tile = new Vector3(x - 2.5f, 0f, z - 2.5f); Place("Floor Thin", tile, 0f, room, "Wood-Dark", "Wood-Dark", "Wood-Dark"); Place("Floor Thin", tile + Vector3.up * 3.45f, 0f, room, "Wood-Mid", "Wood-Mid", "Wood-Mid"); } } for (int i = -1; i <= 1; i++) { for (int j = 0; j < 3; j++) { Place("Beam Long", new Vector3(-2f + j * 2f, 3.25f, i * 2f), 0f, room, "Wood-Dark", "Wood-Dark", "Wood-Dark"); } } } private static void PlaceWall(string piece, Vector3 position, float yaw, Transform parent) { Place(piece, position, yaw, parent, "Wood-Dark", "Wood-Mid", "Wood-Pale"); } private static void BuildCounter() { Transform counter = new GameObject("Counter").transform; Place("Table Large", new Vector3(-0.75f, 0f, -0.5f), 90f, counter, "Wood-Pale", "Wood-Mid", "Wood-Mid"); Place("Table Large", new Vector3(0.75f, 0f, -0.5f), 90f, counter, "Wood-Pale", "Wood-Mid", "Wood-Mid"); Place("Candle", new Vector3(0.5f, 0.68f, -0.5f), 0f, counter, "Wood-Mid", "Fabric-Cotton", "Wood-Pale"); Place("Cooking Bowl", new Vector3(-0.5f, 0.68f, -0.4f), 0f, counter, "Wood-Mid", "Wood-Mid", "Wood-Mid"); } private static void BuildProps() { Transform props = new GameObject("Props").transform; Place("Crate", new Vector3(2.3f, 0f, 2.2f), 15f, props, "Wood-Mid", "Wood-Dark", "Wood-Dark"); Place("Crate", new Vector3(2.3f, 0.5f, 2.2f), -10f, props, "Wood-Pale", "Wood-Dark", "Wood-Dark"); Place("Trunk", new Vector3(-2.4f, 0f, 1.8f), 90f, props, "Wood-Dark", "Fabric-Cord", "Wood-Mid"); Place("Logs", new Vector3(2.4f, 0f, -1.2f), 30f, props, "Wood-Dark", "Wood-Dark", "Wood-Dark"); // Placeholders for the millstones until we model real ones. Material stone = GetMaterial("Brick-Stone"); CreateMillstone("Millstone Lower", new Vector3(-2f, 0.13f, -1.8f), stone, props); CreateMillstone("Millstone Upper", new Vector3(-2f, 0.39f, -1.8f), stone, props); } private static void CreateMillstone(string name, Vector3 position, Material material, Transform parent) { GameObject stone = GameObject.CreatePrimitive(PrimitiveType.Cylinder); stone.name = name; Object.DestroyImmediate(stone.GetComponent()); stone.transform.SetParent(parent); stone.transform.position = position; stone.transform.localScale = new Vector3(1.2f, 0.13f, 1.2f); stone.GetComponent().sharedMaterial = material; } private static void BuildVillager() { GameObject model = AssetDatabase.LoadAssetAtPath(VillagerPath); if (model == null) { Debug.LogError("[IllOmen] Villager model not found"); return; } MakeClipsLoop(); GameObject villager = (GameObject)PrefabUtility.InstantiatePrefab(model); villager.name = "Villager (private test only)"; Material material = GetMaterial(AssetDatabase.LoadAssetAtPath(VillagerTexturePath), "Villager"); foreach (Renderer renderer in villager.GetComponentsInChildren()) { renderer.sharedMaterials = Enumerable.Repeat(material, renderer.sharedMaterials.Length).ToArray(); } Bounds bounds = GetBounds(villager); Debug.Log($"[IllOmen] Villager size: {bounds.size}"); if (bounds.size.y > 0.01f) { villager.transform.localScale *= 1.65f / bounds.size.y; } bounds = GetBounds(villager); villager.transform.position += new Vector3(-bounds.center.x, -bounds.min.y, 0.6f - bounds.center.z); villager.transform.Rotate(0f, 180f, 0f); AttachIdle(villager); } private static void AttachIdle(GameObject villager) { MakeClipsLoop(); AnimationClip idle = FindIdleClip(); if (idle == null) return; Animator animator = villager.GetComponent(); if (animator == null) animator = villager.AddComponent(); string controllerPath = GeneratedFolder + "/VillagerTest.controller"; AnimatorController controller = AssetDatabase.LoadAssetAtPath(controllerPath); if (controller == null) controller = AnimatorController.CreateAnimatorControllerAtPathWithClip(controllerPath, idle); animator.runtimeAnimatorController = controller; Debug.Log($"[IllOmen] Villager idle clip: {idle.name}"); } private static void MakeClipsLoop() { ModelImporter importer = (ModelImporter)AssetImporter.GetAtPath(VillagerPath); ModelImporterClipAnimation[] clips = importer.defaultClipAnimations; if (clips == null || clips.Length == 0 || importer.clipAnimations.Length > 0) return; foreach (ModelImporterClipAnimation clip in clips) clip.loopTime = true; importer.clipAnimations = clips; importer.SaveAndReimport(); } private static AnimationClip FindIdleClip() { AnimationClip[] clips = AssetDatabase.LoadAllAssetsAtPath(VillagerPath) .OfType() .Where(clip => !clip.name.StartsWith("__preview__")) .ToArray(); Debug.Log("[IllOmen] Villager clips: " + string.Join(", ", clips.Select(clip => clip.name))); return clips.FirstOrDefault(clip => clip.name.ToLowerInvariant().Contains("idle")) ?? clips.FirstOrDefault(); } private static void BuildCandleLight() { GameObject flame = new GameObject("Candle Light"); flame.transform.position = new Vector3(0.5f, 1.05f, -0.5f); Light light = flame.AddComponent(); light.type = LightType.Point; light.color = new Color(1f, 0.78f, 0.55f); light.range = 9f; light.intensity = 6f; light.shadows = LightShadows.None; flame.AddComponent(); // A dim, cold light so the far walls and the door stay readable. GameObject fill = new GameObject("Fill Light"); fill.transform.position = new Vector3(0f, 2.6f, 1.6f); Light fillLight = fill.AddComponent(); fillLight.type = LightType.Point; fillLight.color = new Color(0.55f, 0.65f, 0.9f); fillLight.range = 8f; fillLight.intensity = 1.5f; fillLight.shadows = LightShadows.None; } private static void BuildCamera() { GameObject cameraObject = new GameObject("Main Camera") { tag = "MainCamera" }; cameraObject.transform.position = new Vector3(0f, 1.5f, -1.6f); cameraObject.transform.rotation = Quaternion.Euler(4f, 0f, 0f); Camera camera = cameraObject.AddComponent(); camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = Color.black; camera.fieldOfView = 60f; camera.nearClipPlane = 0.05f; camera.GetUniversalAdditionalCameraData().renderPostProcessing = true; cameraObject.AddComponent(); cameraObject.AddComponent(); } private static void BuildVolume() { // Reuse the same asset so other scenes keep pointing at it after a rebuild. string profilePath = GeneratedFolder + "/MillVolumeProfile.asset"; VolumeProfile profile = AssetDatabase.LoadAssetAtPath(profilePath); if (profile == null) { profile = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(profile, profilePath); } foreach (VolumeComponent component in profile.components.ToArray()) { AssetDatabase.RemoveObjectFromAsset(component); Object.DestroyImmediate(component, true); } profile.components.Clear(); PSXEffectSettings psx = profile.Add(true); psx.name = "PSX"; psx.AmbientColor.value = new Color(0.1f, 0.1f, 0.13f); psx.PixelResolution.value = new Vector2(640f, 360f); // The filter rounds each colour channel in linear space, which turns dark greys into purple blotches. psx.EnableColorPrecision.value = false; psx.ColorPrecision.value = 256f; psx.EnableFog.value = false; // Readable baseline: only soft scanlines and a vignette. VHS effects return one at a time. CRTSettings crt = profile.Add(true); crt.name = "CRT"; crt.EnableScreenBend.value = false; crt.EnableInterlacedRendering.value = false; crt.VignetteOpacity.value = 0.3f; crt.ScanLineStrength.value = 0.05f; crt.EnableNoise.value = false; crt.EnableVhs.value = false; crt.EnableTrackerLine.value = false; crt.EnableGlitch.value = false; crt.EnableSignalInterference.value = false; crt.EnableChromaticAberration.value = false; crt.EnableSubPixels.value = false; crt.EnableUnsharp.value = false; crt.ClampBlack.value = 0f; crt.ClampWhite.value = 1f; crt.Gamma.value = 0.5f; AssetDatabase.AddObjectToAsset(psx, profile); AssetDatabase.AddObjectToAsset(crt, profile); EditorUtility.SetDirty(profile); AssetDatabase.SaveAssets(); GameObject volumeObject = new GameObject("Global Volume"); Volume volume = volumeObject.AddComponent(); volume.isGlobal = true; volume.sharedProfile = profile; } private static void BuildAmbience() { string guid = AssetDatabase.FindAssets("Those_Who_Wait t:AudioClip").FirstOrDefault(); if (guid == null) { Debug.LogWarning("[IllOmen] Ambience clip not found"); return; } GameObject ambience = new GameObject("Ambience"); AudioSource source = ambience.AddComponent(); source.clip = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid)); source.loop = true; source.volume = 0.35f; source.playOnAwake = true; } /// Places a kit piece by its base centre, whatever pivot the model has. private static GameObject Place(string piece, Vector3 position, float yaw, Transform parent, string primary, string secondary, string tertiary) { Transform source = kit.GetComponentsInChildren(true).FirstOrDefault(t => t.name == piece); if (source == null) { Debug.LogError($"[IllOmen] Kit piece not found: {piece}"); return null; } GameObject model = Object.Instantiate(source.gameObject); model.name = "Model"; model.transform.SetPositionAndRotation(Vector3.zero, source.rotation); model.transform.localScale = source.lossyScale; foreach (Renderer renderer in model.GetComponentsInChildren()) { Material[] materials = renderer.sharedMaterials; for (int i = 0; i < materials.Length; i++) { string slot = materials[i] != null ? materials[i].name : string.Empty; string texture = slot.Contains("Plum") ? secondary : slot.Contains("LightGreen") ? tertiary : primary; materials[i] = GetMaterial(texture); } renderer.sharedMaterials = materials; } Bounds bounds = GetBounds(model); model.transform.position = new Vector3(-bounds.center.x, -bounds.min.y, -bounds.center.z); GameObject holder = new GameObject(piece); model.transform.SetParent(holder.transform, true); holder.transform.SetPositionAndRotation(position, Quaternion.Euler(0f, yaw, 0f)); holder.transform.SetParent(parent, true); return holder; } /// The example material cuts holes by texture alpha, which our solid surfaces must not do. private static void MakeOpaque(Material material) { material.DisableKeyword("_ALPHATEST_ON"); material.SetFloat("_AlphaClip", 0f); material.SetFloat("_AlphaToMask", 0f); material.SetOverrideTag("RenderType", "Opaque"); material.renderQueue = (int)RenderQueue.Geometry; } private static Bounds GetBounds(GameObject target) { Renderer[] renderers = target.GetComponentsInChildren(); Bounds bounds = renderers[0].bounds; foreach (Renderer renderer in renderers) bounds.Encapsulate(renderer.bounds); return bounds; } private static Material GetMaterial(string kitTexture) { if (MaterialCache.TryGetValue(kitTexture, out Material cached)) return cached; string guid = AssetDatabase.FindAssets($"{kitTexture} t:Texture2D", new[] { "Assets/ThirdParty/Models/RetroMedievalKit" }).FirstOrDefault(); if (guid == null) Debug.LogError($"[IllOmen] Kit texture not found: {kitTexture}"); Texture2D texture = guid == null ? null : AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid)); return GetMaterial(texture, kitTexture); } private static Material GetMaterial(Texture2D texture, string name) { if (MaterialCache.TryGetValue(name, out Material cached)) return cached; // Update existing materials in place so other scenes keep their references. string path = $"{GeneratedFolder}/Materials/{name}.mat"; Material existing = AssetDatabase.LoadAssetAtPath(path); Material material = existing != null ? existing : new Material(baseMaterial) { name = name }; if (existing != null) material.CopyPropertiesFromMaterial(baseMaterial); material.SetTexture("_MainTex", texture); if (material.HasProperty("_BaseMap")) material.SetTexture("_BaseMap", texture); MakeOpaque(material); // Finer vertices and milder texture warping keep large, close surfaces readable. material.SetFloat("_VertexPrecision", 64f); material.SetFloat("_AffineWrapStrength", 0.25f); // The example material rounds colours to 16 levels in linear space: dark surfaces turn // pure black, with noisy purple and green stripes wherever they cross a level. material.SetFloat("_UseColorQuantization", 0f); if (existing == null) AssetDatabase.CreateAsset(material, path); EditorUtility.SetDirty(material); MaterialCache[name] = material; return material; } } }