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;
}
}
}