using UnityEngine;
using UnityEditor;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using System.IO;
///
/// One-click demo scene: orbit camera, lights, a box tank and a few inflated
/// paper fish. Menu: AFX > Build Paper Fish Demo. Safe to run again.
/// Sprites are pulled from http://78.110.173.123/sprites/ (AFX > Sync Sprites) and
/// Build runs the sync first.
///
public static class AFXDemoSetup
{
const string FishDir = "Assets/Sprites/Fish/";
const string MatDir = "Assets/Materials";
const string MeshDir = "Assets/Meshes";
// Fish thickness as a fraction of its height
const float Thickness = 0.22f;
const int MeshStep = 4; // pixels per mesh vertex
// species key, world height at full grown size, sprite faces right?, how many to spawn
static readonly (string key, float height, bool facesRight, int count)[] Fish =
{
("blazing", 2.4f, true, 2),
("blue", 1.3f, false, 5),
("lesser", 2.8f, true, 1),
};
const float WaterDepthBelowRim = 1.2f; // waterline sits this far below the top of the glass
const float GravelAmplitude = 0.8f; // world-unit height of the tallest sand dunes (about 4.5 cm)
const float SandBase = 0.9f; // even depth of sand under the dunes (about 5 cm)
const string TexDir = "Assets/Textures/";
// Tank interior, centred on origin
// Tank box in world units. Sized at build time to sit on the tank stand at the furniture pack's own scale.
static Vector3 TankMin = new Vector3(-14f, -6f, -10f);
static Vector3 TankMax = new Vector3( 14f, 6f, 6f);
static float StandScale = 0f;
const int FishLayer = 30; // fish live on their own layer so the room reflection probe leaves them out (it sits in the middle of the tank)
static Vector3 LobbyPivot; static float LobbyDistance;
const float LobbyShiftLeft = 0.15f; // metres the tank stand and desk sit left of the lobby view's centre (half a foot) // uniform scale for Low Poly Interior Pack prefabs (desk = 76 cm tall)
[MenuItem("AFX/Build Paper Fish Demo")]
public static void Build()
{
// Phase 1: get every asset imported and settled (downloads, texture
// settings, generated maps). Scene objects made in the same tick as an
// import can be lost, so the scene is built in a later editor tick.
SyncSprites();
foreach (var f in Fish)
{
if (LoadTex(FishDir + f.key + ".png", false) == null) continue;
string nPath = FishDir + f.key + "_n.png", aoPath = FishDir + f.key + "_ao.png";
if (AssetImporter.GetAtPath(nPath) == null) InflatedMaps.Generate(FishDir + f.key + ".png", nPath, aoPath);
LoadTex(nPath, true); LoadTex(aoPath, false);
}
AssetDatabase.SaveAssets();
// Run the scene build on the next editor update. (delayCall has been seen to stall when the editor lacks OS focus.)
void RunOnce() { EditorApplication.update -= RunOnce; BuildScene(); }
EditorApplication.update += RunOnce;
}
static void BuildScene()
{
foreach (var name in new[] { "AFX Tank", "AFX Fish", "AFX Fill Light", "AFX Furniture", "AFX Room" })
for (var old = GameObject.Find(name); old != null; old = GameObject.Find(name))
Object.DestroyImmediate(old);
bool urp = Shader.Find("Universal Render Pipeline/Lit") != null;
Shader lit = urp ? Shader.Find("Universal Render Pipeline/Lit") : Shader.Find("Standard");
if (urp) ConfigurePipeline();
SizeTankToStand();
// Camera with orbit
var cam = Camera.main;
if (cam == null)
{
var go = new GameObject("Main Camera"); go.tag = "MainCamera";
cam = go.AddComponent(); go.AddComponent();
}
// Framed wider than the tank so the table below and the lamp above are in shot.
var camPivot = new Vector3(15f, 1.5f, -2f); const float camDistance = 66f; // pivot raised so the clock above the tank is in view; about 2 ft further back than before
var camRot = Quaternion.Euler(6f, 0f, 0f); // nearly face on to the wall
cam.transform.position = camPivot + camRot * new Vector3(0f, 0f, -camDistance);
cam.transform.rotation = camRot;
cam.fieldOfView = 40; // narrower view: gentler size change with distance
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = Color.black; // nothing outside the tank for now
// Underwater haze: far fish go soft and blue, near ones stay crisp. Main depth cue.
RenderSettings.fog = true;
RenderSettings.fogMode = FogMode.Linear;
RenderSettings.fogColor = new Color(0.03f, 0.22f, 0.38f);
RenderSettings.fogStartDistance = 38f;
RenderSettings.fogEndDistance = 80f;
// Kill Unity's default bright sky: the camera's black clear colour only covers the
// empty void, it does NOT stop the scene's ambient/skybox lighting from still washing
// light onto every surface (this was why the backdrop and glass read too light-blue).
RenderSettings.skybox = null;
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat;
RenderSettings.ambientLight = new Color(0.05f, 0.09f, 0.13f);
// Only the lobby camera should draw to the screen: switch off any stray screen cameras (e.g. left over from captures)
foreach (var other in Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None))
if (other != cam && other.targetTexture == null && other.enabled)
{
other.enabled = false;
Debug.LogWarning("AFX: switched off extra camera '" + other.name + "' so the Game view shows the lobby camera");
}
// Fixed lobby view: no orbiting; clicking the tank or the monitor glides in, right click glides back
var oldOrbit = cam.GetComponent(); if (oldOrbit != null) Object.DestroyImmediate(oldOrbit);
if (cam.GetComponent() == null) cam.gameObject.AddComponent();
cam.farClipPlane = 3000f;
LobbyPivot = camPivot; LobbyDistance = camDistance;
if (urp)
{
var camData = cam.GetUniversalAdditionalCameraData();
camData.antialiasing = AntialiasingMode.SubpixelMorphologicalAntiAliasing;
camData.antialiasingQuality = AntialiasingQuality.High;
// The water shader samples the depth and opaque colour textures for
// its depth fade and refraction; force them on for this camera.
camData.requiresDepthOption = CameraOverrideOption.On;
camData.requiresColorOption = CameraOverrideOption.On;
camData.renderPostProcessing = true; // off by default on a scripted camera
}
// Key light
var key = Object.FindAnyObjectByType();
if (key == null) { key = new GameObject("Directional Light").AddComponent(); key.type = LightType.Directional; }
key.transform.rotation = Quaternion.Euler(88, 0, 0); // straight down from above the tank
key.shadows = LightShadows.None; // shadows come only from the desk lamp and the right-hand floor lamp
key.intensity = 0.75f;
key.color = new Color(0.9f, 0.97f, 1f);
var caustic = LoadTex(TexDir + "caustics.png", false);
if (caustic != null && urp)
{
key.cookie = caustic;
var ld = key.GetUniversalAdditionalLightData();
ld.lightCookieSize = new Vector2(9f, 9f);
if (key.GetComponent() == null) key.gameObject.AddComponent();
}
// Fill
var fill = new GameObject("AFX Fill Light").AddComponent();
fill.type = LightType.Directional;
fill.transform.rotation = Quaternion.Euler(-15, -60, 0);
fill.shadows = LightShadows.None;
fill.intensity = 0.55f;
fill.color = new Color(0.4f, 0.7f, 1f);
// Tank box: back, floor, left, right (open front and top)
var tank = new GameObject("AFX Tank");
{
var tb0 = new Bounds((TankMin + TankMax) * 0.5f, TankMax - TankMin + Vector3.one * 0.7f);
AddViewTarget(tank, "Fish tank", tb0, 1.12f);
}
Vector3 size = TankMax - TankMin, c = (TankMin + TankMax) * 0.5f;
var wall = new Color(0.05f, 0.40f, 0.62f);
// No painted backdrop: the back is clear glass, so the room wall shows through behind the fish
// Sand floor: soft dunes and ripple marks, banked up toward the back glass and the sides (textures from research/gen_sand.py)
{
var sand = new GameObject("Sand"); sand.transform.SetParent(tank.transform);
sand.transform.position = new Vector3(c.x, TankMin.y + SandBase, c.z);
// Surface and walls as separate objects: the tank's caustic key light shines straight down, and on the
// vertical sand walls its pattern smeared into vertical stripes, so the walls sit on the room light layer instead
var built = GravelTerrain.Build(TexDir + "sand_height.png", size.x, size.z, GravelAmplitude, 96, 48, SandBase + 0.1f);
var wallOnly = Object.Instantiate(built);
wallOnly.subMeshCount = 1; wallOnly.SetTriangles(built.GetTriangles(1), 0); wallOnly.RecalculateBounds();
built.subMeshCount = 1;
var sandMesh = SaveAsset(built, MeshDir + "/SandTerrain.asset");
var wallMesh = SaveAsset(wallOnly, MeshDir + "/SandWalls.asset");
sand.AddComponent().sharedMesh = sandMesh;
var smr = sand.AddComponent();
var sAlb = LoadTex(TexDir + "sand_albedo.png", false); var sNrm = LoadTex(TexDir + "sand_normal.png", true);
var sMat = new Material(lit);
if (urp)
{
if (sAlb != null) sMat.SetTexture("_BaseMap", sAlb);
if (sNrm != null) { sMat.SetTexture("_BumpMap", sNrm); sMat.SetFloat("_BumpScale", 0.8f); sMat.EnableKeyword("_NORMALMAP"); }
sMat.SetFloat("_Smoothness", 0.08f);
}
sMat = SaveAsset(sMat, MatDir + "/Sand.mat");
var wallMat = new Material(lit);
if (urp) { if (sAlb != null) wallMat.SetTexture("_BaseMap", sAlb); wallMat.SetColor("_BaseColor", new Color(0.9f, 0.88f, 0.84f)); wallMat.SetFloat("_Smoothness", 0.05f); }
wallMat = SaveAsset(wallMat, MatDir + "/SandWall.mat");
smr.sharedMaterial = sMat;
var walls = new GameObject("Sand Walls"); walls.transform.SetParent(sand.transform, false);
walls.AddComponent().sharedMesh = wallMesh;
var wmr = walls.AddComponent();
wmr.sharedMaterial = wallMat;
wmr.renderingLayerMask = 2; // lit by the room lamps, not the caustics
smr.receiveShadows = true;
// The terrain's own skirts form the sand wall against the glass (no separate bed: its stretched texture showed as a streaky band)
}
// Blue water filling the tank below the waterline, tinting by how much water you look through
{
var volShader = Shader.Find("AFX/WaterVolume");
if (volShader != null)
{
float top = TankMax.y - WaterDepthBelowRim, bottom = TankMin.y;
var vol = GameObject.CreatePrimitive(PrimitiveType.Cube);
vol.name = "Water Volume"; Object.DestroyImmediate(vol.GetComponent());
vol.transform.SetParent(tank.transform);
vol.transform.position = new Vector3(c.x, (top + bottom) * 0.5f, c.z);
vol.transform.localScale = new Vector3(size.x - 0.1f, top - bottom, size.z - 0.1f);
var vm = new Material(volShader);
vm.SetColor("_Color", new Color(0.04f, 0.36f, 0.62f)); // clear aquarium blue
vm.SetFloat("_Density", 0.12f); vm.SetFloat("_MaxOpacity", 0.62f); vm.SetFloat("_MinOpacity", 0.2f);
var vr = vol.GetComponent();
vr.sharedMaterial = SaveAsset(vm, MatDir + "/WaterVolume.mat");
vr.shadowCastingMode = ShadowCastingMode.Off; vr.receiveShadows = false;
vol.layer = FishLayer; // keep the reflection probe from capturing a blue box
}
else Debug.LogWarning("AFX: AFX/WaterVolume shader not found; tank water stays clear.");
}
try { BuildTankDecor(tank, size, c); }
catch (System.Exception ex) { Debug.LogWarning("AFX: tank decor failed (" + ex.Message + "). Tank is unaffected."); }
// Glass: front and both sides (seen through to black), faint tint, glossy
var glass = new Material(lit);
if (urp)
{
glass.SetColor("_BaseColor", new Color(0.55f, 0.8f, 0.95f, 0.06f));
glass.SetFloat("_Surface", 1f); glass.SetFloat("_Blend", 0f);
glass.SetFloat("_SrcBlend", (float)BlendMode.SrcAlpha); glass.SetFloat("_DstBlend", (float)BlendMode.OneMinusSrcAlpha);
glass.SetFloat("_ZWrite", 0f); glass.SetFloat("_Cull", (float)CullMode.Off);
glass.SetFloat("_Smoothness", 0.95f); glass.SetFloat("_Metallic", 0f);
// No per-light hotspots on every pane: the lamps show up through the room reflection probe instead, where real glass would show them
glass.SetFloat("_SpecularHighlights", 0f); glass.EnableKeyword("_SPECULARHIGHLIGHTS_OFF");
glass.SetFloat("_EnvironmentReflections", 1f); glass.DisableKeyword("_ENVIRONMENTREFLECTIONS_OFF");
glass.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
glass.SetOverrideTag("RenderType", "Transparent"); glass.renderQueue = (int)RenderQueue.Transparent + 10;
}
else glass.SetColor("_Color", new Color(0.55f, 0.8f, 0.95f, 0.10f));
glass = SaveAsset(glass, MatDir + "/Glass.mat");
foreach (var g in new[] {
("Front Glass", new Vector3(c.x, c.y, TankMin.z), Quaternion.identity, new Vector3(size.x, size.y, 1)),
("Back Glass", new Vector3(c.x, c.y, TankMax.z), Quaternion.identity, new Vector3(size.x, size.y, 1)),
("Bottom Glass", new Vector3(c.x, TankMin.y, c.z), Quaternion.Euler(90, 0, 0), new Vector3(size.x, size.z, 1)),
("Left Glass", new Vector3(TankMin.x, c.y, c.z), Quaternion.Euler(0, -90, 0), new Vector3(size.z, size.y, 1)),
("Right Glass", new Vector3(TankMax.x, c.y, c.z), Quaternion.Euler(0, 90, 0), new Vector3(size.z, size.y, 1)) })
{
var q = GameObject.CreatePrimitive(PrimitiveType.Quad);
q.name = g.Item1; q.transform.SetParent(tank.transform);
q.transform.position = g.Item2; q.transform.rotation = g.Item3; q.transform.localScale = g.Item4;
Object.DestroyImmediate(q.GetComponent());
var mr = q.GetComponent(); mr.sharedMaterial = glass; mr.shadowCastingMode = ShadowCastingMode.Off;
}
// Frame: dark bars along the vertical corners and the bottom and top rims
var frameMat = new Material(lit);
frameMat.SetColor(urp ? "_BaseColor" : "_Color", new Color(0.05f, 0.05f, 0.06f));
if (urp) frameMat.SetFloat("_Smoothness", 0.4f);
frameMat = SaveAsset(frameMat, MatDir + "/Frame.mat");
float bar = 0.35f;
void Bar(string n, Vector3 p, Vector3 sc)
{
var b = GameObject.CreatePrimitive(PrimitiveType.Cube); b.name = n; b.transform.SetParent(tank.transform);
Object.DestroyImmediate(b.GetComponent());
b.transform.position = p; b.transform.localScale = sc; b.GetComponent().sharedMaterial = frameMat;
}
// Modern rimless look: a low black plinth under the glass and a black hood on top, both overhanging a touch,
// no vertical trims: the glass corners are bare (the glass box itself keeps its size)
float over = 0.25f, baseH = 0.9f, lidH = 1.1f;
Bar("Base", new Vector3(c.x, TankMin.y - baseH * 0.5f + 0.02f, c.z), new Vector3(size.x + 2f * over, baseH, size.z + 2f * over));
Bar("Lid", new Vector3(c.x, TankMax.y + lidH * 0.5f - 0.02f, c.z), new Vector3(size.x + 2f * over, lidH, size.z + 2f * over));
Bar("Lid Lip", new Vector3(c.x, TankMax.y - 0.15f, c.z), new Vector3(size.x + 0.06f, 0.3f, size.z + 0.06f)); // thin band where the hood meets the glass
// Water surface: hand-written shader (depth fade, refraction, fresnel,
// foam-line, vertex waves + scrolling ripple normals, specular glint).
float waterY = TankMax.y - WaterDepthBelowRim;
var water = GameObject.CreatePrimitive(PrimitiveType.Quad);
water.name = "Water Surface"; water.transform.SetParent(tank.transform);
water.transform.position = new Vector3(c.x, waterY, c.z);
water.transform.rotation = Quaternion.Euler(-90, 0, 0);
water.transform.localScale = new Vector3(size.x, size.z, 1);
Object.DestroyImmediate(water.GetComponent());
var waterShader = Shader.Find("AFX/Water");
var wNrm = LoadTex(TexDir + "water_normal.png", true);
if (waterShader != null)
{
var wm = new Material(waterShader);
if (wNrm != null) wm.SetTexture("_NormalMap", wNrm);
wm.SetVector("_NormalScaleA", new Vector4(size.x / 4f, size.z / 4f, 0, 0));
wm.SetVector("_NormalScaleB", new Vector4(size.x / 1.7f, size.z / 1.7f, 0, 0));
// Set every tunable explicitly: an existing Water.mat asset keeps whatever
// values it already had even after the shader's own defaults change, so
// relying on the .shader Properties block alone left this stale before.
wm.SetColor("_ShallowColor", new Color(0.5f, 0.8f, 0.92f, 0.22f));
wm.SetColor("_DeepColor", new Color(0.02f, 0.16f, 0.30f, 0.85f));
wm.SetFloat("_DepthFadeDistance", 6f);
wm.SetFloat("_NormalStrength", 0.18f);
wm.SetFloat("_RefractionStrength", 0.03f);
wm.SetColor("_FoamColor", new Color(1f, 1f, 1f, 0.5f));
wm.SetFloat("_FoamDistance", 0.4f);
wm.SetColor("_FresnelColor", new Color(0.03f, 0.07f, 0.11f, 1f));
wm.SetFloat("_FresnelPower", 4.5f);
wm.SetFloat("_FresnelStrength", 0.2f);
wm.SetFloat("_Smoothness", 240f);
wm.SetFloat("_SpecularStrength", 0.3f);
wm.SetFloat("_WaveAmplitude", 0.03f);
wm.SetFloat("_Wave2Amplitude", 0.012f);
wm = SaveAsset(wm, MatDir + "/Water.mat");
var mr = water.GetComponent(); mr.sharedMaterial = wm; mr.shadowCastingMode = ShadowCastingMode.Off;
if (water.GetComponent() == null) water.AddComponent();
}
else Debug.LogWarning("AFX: AFX/Water shader not found; water surface will use the default material.");
// Real furniture from the Asset Store: a table under the tank, a lamp hanging above it.
try { BuildFurniture(size, c, bar, frameMat, lit); }
catch (System.Exception ex) { Debug.LogWarning("AFX: furniture setup failed (" + ex.Message + "). Tank is unaffected."); }
// The room around it: a square room furnished from the low poly furniture pack.
try { BuildRoom(key, fill); }
catch (System.Exception ex) { Debug.LogWarning("AFX: room setup failed (" + ex.Message + "). Tank is unaffected."); }
// Reflection probe: without one the glass and gravel reflect nothing (matches the
// black backdrop, so glass reads as tinted plastic). A real-time probe centred on
// the tank makes them genuinely reflect the water, gravel and fish inside it.
// Wrapped: a probe failure (e.g. a Quality Settings/engine quirk on a fresh
// component) must never take the rest of the tank/fish build down with it.
try
{
QualitySettings.realtimeReflectionProbes = true; // off by default; Realtime probes silently never refresh without this
var probeGO = GameObject.Find("AFX Reflection Probe");
if (probeGO == null) probeGO = new GameObject("AFX Reflection Probe");
probeGO.transform.position = c;
var probe = probeGO.GetComponent();
if (probe == null) probe = probeGO.AddComponent();
probe.mode = UnityEngine.Rendering.ReflectionProbeMode.Realtime;
probe.refreshMode = UnityEngine.Rendering.ReflectionProbeRefreshMode.EveryFrame;
probe.timeSlicingMode = UnityEngine.Rendering.ReflectionProbeTimeSlicingMode.AllFacesAtOnce;
probe.boxProjection = true;
// Box-project onto the room's walls so reflections of the lamps, clock and painting land where they really are
var roomGO = GameObject.Find("AFX Room");
var box = roomGO != null ? WorldBounds(roomGO) : new Bounds(c, size + Vector3.one * 2f);
probe.size = box.size;
probe.center = box.center - c;
probe.intensity = 1f;
probe.hdr = true;
probe.resolution = 512;
probe.cullingMask = ~(1 << FishLayer); // a probe centred in the tank would blow the fish up huge across every reflection
probe.clearFlags = ReflectionProbeClearFlags.SolidColor; // never capture a stray skybox
probe.backgroundColor = Color.black;
}
catch (System.Exception ex)
{
Debug.LogWarning("AFX: reflection probe setup failed (" + ex.Message + "). Glass/gravel just won't reflect the tank interior; nothing else is affected.");
}
ConfigurePostProcessing();
// Fish
Directory.CreateDirectory(MatDir);
var fishRoot = new GameObject("AFX Fish");
int made = 0;
foreach (var f in Fish)
{
var tex = LoadTex(FishDir + f.key + ".png", false);
if (tex == null) { Debug.LogWarning("AFX: missing sprite " + f.key); continue; }
string nPath = FishDir + f.key + "_n.png", aoPath = FishDir + f.key + "_ao.png";
if (AssetImporter.GetAtPath(nPath) == null) InflatedMaps.Generate(FishDir + f.key + ".png", nPath, aoPath);
var nrm = LoadTex(nPath, true);
var ao = LoadTex(aoPath, false);
var mat = new Material(lit);
if (urp)
{
mat.SetTexture("_BaseMap", tex);
mat.SetFloat("_Smoothness", 0.35f);
mat.SetFloat("_Metallic", 0f);
if (nrm != null) { mat.SetTexture("_BumpMap", nrm); mat.SetFloat("_BumpScale", 1.0f); mat.EnableKeyword("_NORMALMAP"); }
if (ao != null) { mat.SetTexture("_OcclusionMap", ao); mat.SetFloat("_OcclusionStrength", 0.8f); mat.EnableKeyword("_OCCLUSIONMAP"); }
}
else
{
mat.SetTexture("_MainTex", tex);
mat.SetFloat("_Glossiness", 0.35f);
if (nrm != null) { mat.SetTexture("_BumpMap", nrm); mat.EnableKeyword("_NORMALMAP"); }
if (ao != null) mat.SetTexture("_OcclusionMap", ao);
}
PaperCardShadows.ConfigureSoftCutout(mat, 0.5f);
mat = SaveAsset(mat, MatDir + "/Fish_" + f.key + ".mat");
float aspect = (float)tex.width / tex.height;
Directory.CreateDirectory(MeshDir);
var mesh = SaveAsset(InflatedMaps.BuildMesh(FishDir + f.key + ".png", Thickness, MeshStep), MeshDir + "/Fish_" + f.key + ".asset");
var species = AFXSpecies.Get(f.key);
for (int n = 1; n <= f.count; n++)
{
var fish = new GameObject((species != null ? species.name : f.key) + " " + n);
fish.transform.SetParent(fishRoot.transform);
fish.AddComponent().sharedMesh = mesh;
fish.AddComponent().sharedMaterial = mat;
// Demo: random age, so sizes range from newborn to fully grown (size follows cube root of weight)
float weight = species != null && species.targetWeight > 0
? Mathf.Lerp(species.birthWeight, species.targetWeight, Random.value) : 1f;
float sizeFactor = species != null ? species.SizeFactor(weight) : 1f;
float height = f.height * sizeFactor;
fish.transform.localScale = new Vector3(height * aspect, height, height);
fish.transform.position = new Vector3(Random.Range(TankMin.x + 3f, TankMax.x - 3f), Random.Range(TankMin.y + SandBase + GravelAmplitude + 1f, TankMax.y - 1f), Random.Range(TankMin.z + 2f, TankMax.z - 2f));
fish.layer = FishLayer;
fish.AddComponent();
var wig = fish.AddComponent();
wig.spriteFacesRight = f.facesRight;
var ctl = fish.AddComponent();
ctl.boundsMin = TankMin + new Vector3(0.4f, SandBase + GravelAmplitude + 0.6f, 0.2f); // clear of the sand dunes // front glass (min Z) barely inset
ctl.boundsMax = TankMax - new Vector3(0.4f, WaterDepthBelowRim + 0.5f, 0.4f);
ctl.cruiseSpeed = Random.Range(0.7f, 2.6f) * Mathf.Lerp(1.3f, 0.8f, sizeFactor); // wide personality range; small fish dart, big ones glide
ctl.spriteFacesRight = f.facesRight;
ctl.size = height;
// Fish Alive swim presets: small fish tend to be agile, big ones clumsy, most in between
float roll = Random.value - (sizeFactor - 0.5f) * 0.4f;
ctl.style = roll < 0.25f ? PaperFishController.SwimStyle.Agile : roll > 0.8f ? PaperFishController.SwimStyle.Clumsy : PaperFishController.SwimStyle.Normal;
made++;
}
}
AssetDatabase.SaveAssets();
Debug.Log("AFX demo built with " + made + " fish. Press Play: click the tank or the computer to look closer, right click to go back.");
}
/// Create the asset, or copy the new object's data over the existing asset so references stay valid.
static T SaveAsset(T fresh, string path) where T : Object
{
var existing = AssetDatabase.LoadAssetAtPath(path);
if (existing != null && fresh is Mesh) { AssetDatabase.DeleteAsset(path); existing = null; } // CopySerialized doesn't replace mesh geometry
if (existing == null) { AssetDatabase.CreateAsset(fresh, path); return fresh; }
string keep = existing.name;
EditorUtility.CopySerialized(fresh, existing);
existing.name = keep; // CopySerialized also copies the name; keep the asset's filename-matching one
EditorUtility.SetDirty(existing);
Object.DestroyImmediate(fresh);
return existing;
}
const string TableFolder = "Assets/Tables and Chairs"; // old table pack, removed on build
const string LampFolder = "Assets/New Solution Studio";
///
/// Places the Eight Corners Table1 under the tank (a bit bigger than its footprint) and
/// hangs the New Solution Studio small roof lamp above it on a cord with a real light inside.
/// Both packs ship Built-in materials, so they're converted to URP/Lit first.
///
static void BuildFurniture(Vector3 size, Vector3 c, float bar, Material darkMat, Shader lit)
{
// Packs no longer wanted: the Apartment Kit room and the old Tables and Chairs table
foreach (var old in new[] { OldApartmentKit, TableFolder })
if (AssetDatabase.IsValidFolder(old)) { AssetDatabase.DeleteAsset(old); Debug.Log("AFX: removed " + old); }
ConvertMaterialsToURP(LampFolder);
var root = new GameObject("AFX Furniture");
// ---- Table (from the low poly pack: a dining or plain table, not a coffee/side table) ----
// Tank stand: the Low Poly Interior Pack TV stand (two open shelves underneath)
var tablePath = FindPackPrefab(new[] { "tv_stand", "tvstand" });
if (tablePath != null) { var parts = tablePath.Split('/'); if (parts.Length > 2) ConvertMaterialsToURP(parts[0] + "/" + parts[1]); }
var tablePrefab = tablePath != null ? AssetDatabase.LoadAssetAtPath(tablePath) : null;
if (tablePrefab == null)
{
// The pack has no table big enough for a tank: a simple low poly wooden cabinet stand in its flat-colour style
var stand = new GameObject("Table"); stand.transform.SetParent(root.transform);
var woodMat = new Material(lit); woodMat.SetColor("_BaseColor", new Color(0.45f, 0.29f, 0.17f)); woodMat.SetFloat("_Smoothness", 0.25f);
woodMat = SaveAsset(woodMat, MatDir + "/TankStand_Wood.mat");
var doorMat = new Material(lit); doorMat.SetColor("_BaseColor", new Color(0.38f, 0.24f, 0.14f)); doorMat.SetFloat("_Smoothness", 0.2f);
doorMat = SaveAsset(doorMat, MatDir + "/TankStand_Door.mat");
float topY = TankMin.y - bar * 0.5f, standH = 0.8f * RoomScale;
float sw = (size.x + bar * 2f) * 1.06f, sd = (size.z + bar * 2f) * 1.1f, topT = 0.05f * RoomScale;
void Part(string n, Vector3 centre, Vector3 sc, Material m)
{
var g = GameObject.CreatePrimitive(PrimitiveType.Cube);
g.name = n; g.transform.SetParent(stand.transform);
g.transform.position = centre; g.transform.localScale = sc;
g.GetComponent().sharedMaterial = m;
}
Part("Top", new Vector3(c.x, topY - topT * 0.5f, c.z), new Vector3(sw, topT, sd), woodMat);
Part("Body", new Vector3(c.x, topY - topT - (standH - topT - 1.5f) * 0.5f, c.z), new Vector3(sw * 0.96f, standH - topT - 1.5f, sd * 0.92f), woodMat);
Part("Plinth", new Vector3(c.x, topY - standH + 0.75f, c.z + 0.3f), new Vector3(sw * 0.9f, 1.5f, sd * 0.85f), doorMat);
float doorW = sw * 0.46f, doorH = standH - topT - 3.2f, doorY = topY - topT - 0.8f - doorH * 0.5f, doorZ = c.z - sd * 0.46f - 0.08f;
Part("Door Left", new Vector3(c.x - sw * 0.24f, doorY, doorZ), new Vector3(doorW, doorH, 0.16f), doorMat);
Part("Door Right", new Vector3(c.x + sw * 0.24f, doorY, doorZ), new Vector3(doorW, doorH, 0.16f), doorMat);
Part("Handle Left", new Vector3(c.x - 0.9f, doorY, doorZ - 0.15f), new Vector3(0.25f, 2.2f, 0.2f), darkMat);
Part("Handle Right", new Vector3(c.x + 0.9f, doorY, doorZ - 0.15f), new Vector3(0.25f, 2.2f, 0.2f), darkMat);
foreach (var col in stand.GetComponentsInChildren()) Object.DestroyImmediate(col);
}
else
{
var table = (GameObject)PrefabUtility.InstantiatePrefab(tablePrefab);
table.name = "Table"; table.transform.SetParent(root.transform);
table.transform.position = Vector3.zero; table.transform.rotation = Quaternion.identity; table.transform.localScale = Vector3.one; // pack models face -Z, toward the room already
if (StandScale > 0f) table.transform.localScale = Vector3.one * StandScale; // original proportions; the tank was sized to fit it
var b = WorldBounds(table);
float topY = TankMin.y - 0.9f; // the tank stands on its 0.9 tall black base, which rests on the stand
table.transform.position += new Vector3(c.x - b.center.x, topY - b.max.y, c.z - b.center.z);
// Same wood as the desk, and on the room's rendering layer so the tank's caustic light (meant for inside the tank) doesn't pattern its shelves
Material deskWood = null;
var deskPath = FindPackPrefab(new[] { "desk" }, new[] { "lamp", "chair" });
if (deskPath != null)
{
var dr = AssetDatabase.LoadAssetAtPath(deskPath).GetComponentInChildren();
if (dr != null) deskWood = dr.sharedMaterial;
}
foreach (var r in table.GetComponentsInChildren())
{
r.shadowCastingMode = ShadowCastingMode.On; r.receiveShadows = true; r.renderingLayerMask = 2;
if (deskWood != null) { var mats = r.sharedMaterials; for (int k = 0; k < mats.Length; k++) mats[k] = deskWood; r.sharedMaterials = mats; }
}
}
// Lighting now comes from the two tall floor lamps either side of the cabinet (see BuildRoom)
}
const float RoomScale = 18f; // world units per metre (the tank is ~28 units, about a 1.5 m tank)
const float RoomSize = 8f; // square room, metres per side
const float RoomHeight = 2.8f;
const string OldApartmentKit = "Assets/Brick Project Studio";
static readonly string[] NotRoomPacks = { "Assets/Tables and Chairs", "Assets/New Solution Studio", OldApartmentKit, "Assets/Sprites", "Assets/Materials", "Assets/Meshes", "Assets/Scenes", "Assets/Settings", "Assets/Samples", "Assets/TextMesh Pro", "Assets/TutorialInfo", "Assets/Editor", "Assets/Scripts" };
///
/// A plain square room around the tank (floor, four walls, ceiling, a ceiling light), furnished from
/// whichever low poly furniture pack is in the project: prefabs are found by name (sofa, armchair,
/// coffee table, rug, floor lamp, shelf, plant, picture) and set against the walls. The tank's table
/// stands centred against the back wall. Room renderers sit on rendering layer 2 so the tank's
/// caustic key and blue fill (layer 1) stay inside the tank.
///
static void BuildRoom(Light key, Light fill)
{
var tableGO = GameObject.Find("AFX Furniture/Table");
if (tableGO == null) { Debug.LogWarning("AFX: no table, so no room"); return; }
var tb = WorldBounds(tableGO);
float S = RoomScale, half = RoomSize * S * 0.5f, floorY = tb.min.y, ceilY = floorY + RoomHeight * S;
float backZ = tb.max.z + 0.5f, frontZ = backZ - RoomSize * S, midZ = (backZ + frontZ) * 0.5f;
float cx = tb.center.x + LobbyShiftLeft * RoomScale; // the room stays put; the furniture group sits 1 ft left of it
var room = new GameObject("AFX Room");
var lit = Shader.Find("Universal Render Pipeline/Lit");
Material Flat(string name, Color col, float smooth)
{
var m = new Material(lit); m.SetColor("_BaseColor", col); m.SetFloat("_Smoothness", smooth);
return SaveAsset(m, MatDir + "/Room_" + name + ".mat");
}
var wallMat = Flat("Wall", new Color(0.86f, 0.80f, 0.70f), 0.1f);
var floorMat = Flat("Floor", new Color(0.55f, 0.38f, 0.24f), 0.35f);
var ceilMat = Flat("Ceiling", new Color(0.93f, 0.92f, 0.89f), 0.05f);
var skirtMat = Flat("Skirting", new Color(0.95f, 0.94f, 0.91f), 0.3f);
void Box(string name, Vector3 centre, Vector3 size, Material m)
{
var b = GameObject.CreatePrimitive(PrimitiveType.Cube);
b.name = name; b.transform.SetParent(room.transform);
b.transform.position = centre; b.transform.localScale = size;
b.GetComponent().sharedMaterial = m;
}
float t = 0.2f * S, h = RoomHeight * S, w = RoomSize * S;
Box("Floor", new Vector3(cx, floorY - t * 0.5f, midZ), new Vector3(w + 2 * t, t, w + 2 * t), floorMat);
Box("Ceiling", new Vector3(cx, ceilY + t * 0.5f, midZ), new Vector3(w + 2 * t, t, w + 2 * t), ceilMat);
Box("Back Wall", new Vector3(cx, floorY + h * 0.5f, backZ + t * 0.5f), new Vector3(w + 2 * t, h, t), wallMat);
Box("Front Wall", new Vector3(cx, floorY + h * 0.5f, frontZ - t * 0.5f), new Vector3(w + 2 * t, h, t), wallMat);
Box("Left Wall", new Vector3(cx - half - t * 0.5f, floorY + h * 0.5f, midZ), new Vector3(t, h, w), wallMat);
Box("Right Wall", new Vector3(cx + half + t * 0.5f, floorY + h * 0.5f, midZ), new Vector3(t, h, w), wallMat);
float sk = 0.1f * S, skd = 0.02f * S;
Box("Skirting Back", new Vector3(cx, floorY + sk * 0.5f, backZ - skd * 0.5f), new Vector3(w, sk, skd), skirtMat);
Box("Skirting Front", new Vector3(cx, floorY + sk * 0.5f, frontZ + skd * 0.5f), new Vector3(w, sk, skd), skirtMat);
Box("Skirting Left", new Vector3(cx - half + skd * 0.5f, floorY + sk * 0.5f, midZ), new Vector3(skd, sk, w), skirtMat);
Box("Skirting Right", new Vector3(cx + half - skd * 0.5f, floorY + sk * 0.5f, midZ), new Vector3(skd, sk, w), skirtMat);
string Find(string[] want, string[] avoid = null) => FindPackPrefab(want, avoid);
var sofaPath = Find(new[] { "sofa", "couch", "armchair", "chair" }, new[] { "table" });
if (sofaPath == null) Debug.LogWarning("AFX: no seating prefab found; is the low poly pack imported? The room is built bare.");
// Pack scale: make its sofa a real sofa height, and apply the same factor to everything in the pack
float packScale = 1f;
var packFolders = new System.Collections.Generic.HashSet();
if (sofaPath != null)
{
var probe = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(sofaPath));
float sh = WorldBounds(probe).size.y;
Object.DestroyImmediate(probe);
if (sh > 0.001f) packScale = Mathf.Clamp(0.95f / sh, 0.001f, 1000f); // an armchair or sofa is roughly 0.95 m to the top of its back
var parts = sofaPath.Split('/');
string packRoot = parts.Length > 2 ? parts[0] + "/" + parts[1] : "Assets";
packFolders.Add(packRoot);
ConvertMaterialsToURP(packRoot);
Debug.Log("AFX: furnishing from " + packRoot + " (scale x" + packScale.ToString("0.###") + ")");
}
var placed = new System.Text.StringBuilder();
// Place a prefab: yaw turns its front (+Z) to face the room; 'wall' pushes it flush to that side; y lifts it
GameObject Place(string path, string label, Vector3 at, float yaw, Vector3 wall = default, float lift = 0f, float fitHeight = 0f, float fitWidth = 0f)
{
if (path == null) return null;
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(path));
go.name = label; go.transform.SetParent(room.transform);
go.transform.localScale = Vector3.one * packScale * S;
go.transform.rotation = Quaternion.Euler(0f, yaw, 0f);
go.transform.position = Vector3.zero;
var b = WorldBounds(go);
// Packs use different units: size by a real-world height or width (metres) when given
if (fitHeight > 0f || fitWidth > 0f)
{
float k = fitHeight > 0f ? fitHeight * S / Mathf.Max(0.0001f, b.size.y) : fitWidth * S / Mathf.Max(0.0001f, Mathf.Max(b.size.x, b.size.z));
go.transform.localScale *= k; b = WorldBounds(go);
}
if (wall.z != 0f && b.size.x < b.size.z) { go.transform.rotation = Quaternion.Euler(0f, yaw + 90f, 0f); b = WorldBounds(go); } // wall hangings: flat side against the wall
var pos = new Vector3(at.x - b.center.x, floorY + lift - b.min.y, at.z - b.center.z);
if (wall.x < 0) pos.x = cx - half + 0.05f * S - b.min.x;
if (wall.x > 0) pos.x = cx + half - 0.05f * S - b.max.x;
if (wall.z > 0) pos.z = backZ - 0.05f * S - b.max.z;
if (wall.z < 0) pos.z = frontZ + 0.05f * S - b.min.z;
go.transform.position = pos;
placed.Append(label + "=" + Path.GetFileNameWithoutExtension(path) + " ");
return go;
}
float M(float metres) => metres * S;
// Layout for The PolynestLab living room props: a carpet with a coffee table in front of the tank,
// armchairs either side facing it, a tall lamp and a plant in the corners, a painting and clock on the back wall.
float Height(string path)
{
if (path == null) return 0f;
var g = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(path));
g.transform.localScale = Vector3.one * packScale * S;
float hh = WorldBounds(g).size.y; Object.DestroyImmediate(g); return hh;
}
float seatZ = backZ - M(2.5f);
Place(Find(new[] { "carpet", "rug" }), "Carpet", new Vector3(cx, 0, seatZ), 0f, default, 0.003f * S);
var coffeePath = Find(new[] { "coffee" });
Place(coffeePath, "Coffee Table", new Vector3(cx, 0, seatZ), 0f, default, 0.005f * S);
float coffeeTop = Height(coffeePath) + 0.005f * S;
var chair = Find(new[] { "armchair", "chair", "sofa" });
Place(chair, "Armchair Left", new Vector3(cx - M(1.6f), 0, seatZ - M(0.2f)), 60f);
Place(chair, "Armchair Right", new Vector3(cx + M(1.6f), 0, seatZ - M(0.2f)), -60f);
if (coffeePath != null)
{
Place(Find(new[] { "gamepad", "controller" }), "Gamepad", new Vector3(cx - M(0.15f), 0, seatZ), 20f, default, coffeeTop);
Place(Find(new[] { "dvd", "player" }), "DVD Player", new Vector3(cx + M(0.2f), 0, seatZ + M(0.05f)), 0f, default, coffeeTop);
}
// Two tall floor lamps either side of the tank cabinet, each with a warm light in its shade
var tallLamp = Find(new[] { "lamp_tall", "floor_lamp", "lamp" });
foreach (var side in new[] { -1f, 1f })
{
var lampGO = Place(tallLamp, side < 0 ? "Tall Lamp Left" : "Tall Lamp Right", new Vector3(cx + side * (half - M(0.4f)), 0, backZ - M(0.4f)), side < 0 ? 20f : -20f); // back corners
if (lampGO == null) continue;
// Real floor lamp height, standing where it was placed
var lbnd = WorldBounds(lampGO);
var anchor = new Vector3(lbnd.center.x, lbnd.min.y, lbnd.center.z);
lampGO.transform.localScale *= M(1.8f) / Mathf.Max(0.001f, lbnd.size.y);
lbnd = WorldBounds(lampGO);
lampGO.transform.position += anchor - new Vector3(lbnd.center.x, lbnd.min.y, lbnd.center.z);
lbnd = WorldBounds(lampGO);
var bulb = new GameObject("Lamp Light").AddComponent();
bulb.transform.SetParent(lampGO.transform, true);
bulb.transform.position = new Vector3(lbnd.center.x, lbnd.min.y + lbnd.size.y * 0.82f, lbnd.center.z);
bulb.type = LightType.Point;
bulb.range = M(9f);
bulb.intensity = side > 0 ? 910f : 1820f; // right-hand lamp at half brightness
bulb.color = new Color(1f, 0.83f, 0.6f);
bulb.shadows = side > 0 ? LightShadows.Soft : LightShadows.None; // only the right-hand lamp (with the desk lamp) casts shadows
if (side > 0) bulb.enabled = false; // right-hand lamp starts off; the desk lamp lights that side
AddLampSwitch(lampGO, bulb);
}
Place(Find(new[] { "box" }), "Box", new Vector3(cx + half - M(0.5f), 0, frontZ + M(0.6f)), 15f);
Place(Find(new[] { "night_light_1", "night_light" }), "Night Light", new Vector3(cx - half + M(0.5f), 0, frontZ + M(0.5f)), 0f);
float tankTop = TankMax.y + 0.35f;
// A matching pair of black picture frames either side of the tank, each showing the Alien Fish Exchange logo
var paintingPath = Find(new[] { "painting", "picture", "poster" });
var logoTex = LoadTex(TexDir + "afx_logo.png", false);
var frameMat = Flat("PictureFrame", new Color(0.03f, 0.03f, 0.03f), 0.45f);
foreach (var side in new[] { 0f }) // a single picture, centred over the tank
{
if (paintingPath == null || logoTex == null) break;
float lh = M(0.5f), lw = lh * logoTex.width / logoTex.height, border = M(0.05f);
var frameGO = Place(paintingPath, "Picture", new Vector3(tb.center.x, 0, 0), 0f, Vector3.forward, PictureBottomY - floorY); // fixed wall height, independent of the tank's size
if (frameGO == null) break;
// Stretch the frame to the logo plus a border, keeping its depth, and keep it on the wall at the same height
var fb = WorldBounds(frameGO);
var anchorPt = new Vector3(fb.center.x, fb.min.y, fb.max.z);
var sc = frameGO.transform.localScale;
var world = frameGO.transform.rotation;
// frame axes: work out which local axis is world X / Y after its yaw
Vector3 want = new Vector3((lw + 2 * border) / fb.size.x, (lh + 2 * border) / fb.size.y, 1f);
var localWant = Quaternion.Inverse(world) * want;
frameGO.transform.localScale = new Vector3(sc.x * Mathf.Abs(localWant.x), sc.y * Mathf.Abs(localWant.y), sc.z * Mathf.Abs(localWant.z));
fb = WorldBounds(frameGO);
frameGO.transform.position += anchorPt - new Vector3(fb.center.x, fb.min.y, fb.max.z);
fb = WorldBounds(frameGO);
foreach (var r in frameGO.GetComponentsInChildren()) { var mats = r.sharedMaterials; for (int k = 0; k < mats.Length; k++) mats[k] = frameMat; r.sharedMaterials = mats; }
var logo = GameObject.CreatePrimitive(PrimitiveType.Quad);
logo.name = "AFX Logo"; Object.DestroyImmediate(logo.GetComponent());
logo.transform.SetParent(room.transform, false);
logo.transform.rotation = Quaternion.identity; // quad faces -Z, into the room
logo.transform.position = new Vector3(fb.center.x, fb.center.y, fb.min.z - 0.03f);
var rs = room.transform.lossyScale;
logo.transform.localScale = new Vector3(lw / rs.x, lh / rs.y, 1f);
var lm = new Material(lit); lm.SetTexture("_BaseMap", logoTex); lm.SetColor("_BaseColor", Color.white); lm.SetFloat("_Smoothness", 0.35f);
logo.GetComponent().sharedMaterial = SaveAsset(lm, MatDir + "/Room_AFXLogo.mat");
}
// Wall clock centred above the tank, with working hands on the player's local time
var clock = Place(Find(new[] { "clock" }), "Wall Clock", new Vector3(cx, 0, 0), 0f, Vector3.forward, tankTop + M(0.12f) - floorY, 0.63f); // a 63 cm wall clock
if (clock != null)
{
var cb = WorldBounds(clock);
float radius = Mathf.Min(cb.size.x, cb.size.y) * 0.5f;
var face = new GameObject("Clock Face");
face.transform.SetParent(clock.transform, true);
face.transform.position = new Vector3(cb.center.x, cb.center.y, cb.min.z - 0.05f);
face.transform.rotation = Quaternion.identity;
var faceMat = Flat("ClockFace", new Color(0.96f, 0.93f, 0.85f), 0.2f);
var inkMat = Flat("ClockInk", new Color(0.12f, 0.1f, 0.09f), 0.3f);
var redMat = Flat("ClockRed", new Color(0.75f, 0.12f, 0.1f), 0.3f);
GameObject Piece(string n, PrimitiveType type, Transform parent, Vector3 localPos, Vector3 worldSize, Quaternion localRot, Material m)
{
var g = GameObject.CreatePrimitive(type);
g.name = n; Object.DestroyImmediate(g.GetComponent());
g.transform.SetParent(parent, false);
g.transform.localPosition = localPos; g.transform.localRotation = localRot;
var ps = parent.lossyScale;
g.transform.localScale = new Vector3(worldSize.x / ps.x, worldSize.y / ps.y, worldSize.z / ps.z);
g.GetComponent().sharedMaterial = m;
return g;
}
var fs = face.transform.lossyScale;
Vector3 L(float x, float y, float z) => new Vector3(x / fs.x, y / fs.y, z / fs.z); // world offsets to face-local
// Dial: a flat disc (cylinder turned to face the room)
Piece("Dial", PrimitiveType.Cylinder, face.transform, L(0, 0, 0.04f), new Vector3(radius * 1.64f, 0.02f, radius * 1.64f), Quaternion.Euler(90, 0, 0), faceMat);
for (int hr = 0; hr < 12; hr++)
{
var ang = Quaternion.Euler(0, 0, -hr * 30f);
var tick = new GameObject("Tick " + hr); tick.transform.SetParent(face.transform, false);
tick.transform.localRotation = ang;
bool major = hr % 3 == 0;
Piece("Mark", PrimitiveType.Cube, tick.transform, L(0, radius * 0.68f, -0.02f), new Vector3(radius * (major ? 0.07f : 0.04f), radius * (major ? 0.16f : 0.1f), 0.03f), Quaternion.identity, inkMat);
}
Transform Hand(string n, float length, float width, float z, Material m)
{
var pivot = new GameObject(n); pivot.transform.SetParent(face.transform, false);
Piece("Blade", PrimitiveType.Cube, pivot.transform, L(0, length * 0.4f, z), new Vector3(width, length, 0.03f), Quaternion.identity, m);
return pivot.transform;
}
var wc = face.AddComponent();
wc.hourHand = Hand("Hour Hand", radius * 0.5f, radius * 0.08f, -0.05f, inkMat);
wc.minuteHand = Hand("Minute Hand", radius * 0.72f, radius * 0.05f, -0.09f, inkMat);
wc.secondHand = Hand("Second Hand", radius * 0.78f, radius * 0.02f, -0.13f, redMat);
Piece("Cap", PrimitiveType.Cylinder, face.transform, L(0, 0, -0.16f), new Vector3(radius * 0.1f, 0.02f, radius * 0.1f), Quaternion.Euler(90, 0, 0), redMat);
}
// Computer desk (two drawers) to the right of the tank, against the back wall, with a monitor, keyboard and mouse
var deskGO = Place(Find(new[] { "desk" }, new[] { "lamp", "chair" }), "Computer Desk", new Vector3(tb.max.x + M(1f), 0, 0), 0f, Vector3.forward, 0f, 0.76f);
if (deskGO != null)
{
var db = WorldBounds(deskGO);
deskGO.transform.position += Vector3.right * (tb.max.x + M(0.3f) - db.min.x);
db = WorldBounds(deskGO);
float top = db.max.y - floorY;
// The drawer unit takes the left ~30% of the desk; the computer sits over the knee space to its right
float drawersX = db.min.x + db.size.x * 0.15f, seatX = db.min.x + db.size.x * 0.64f;
GameObject mon = Place(Find(new[] { "monitor" }), "Monitor", new Vector3(seatX, 0, db.max.z - M(0.2f)), 0f, default, top, 0.63f);
if (mon != null)
{
// The pack's monitor has 3:2 glass; flatten it to 16:9 so a widescreen stream fills it exactly
var before = WorldBounds(mon);
var sc = mon.transform.localScale;
mon.transform.localScale = new Vector3(sc.x, sc.y * (1.48f / (16f / 9f)), sc.z);
var after = WorldBounds(mon);
mon.transform.position += Vector3.up * (before.min.y - after.min.y); // keep its foot on the desk
AddMonitorScreen(mon);
}
var keyboardGO = Place(Find(new[] { "keyboard" }), "Keyboard", new Vector3(seatX, 0, db.center.z - M(0.1f)), 0f, default, top, 0f, 0.66f);
var mouseGO = Place(Find(new[] { "mouse" }), "Mouse", new Vector3(seatX + M(0.46f), 0, db.center.z - M(0.1f)), 0f, default, top, 0f, 0.165f);
// The "Computer" close-up frames the monitor, keyboard and mouse together (not just the monitor),
// so the Mouse prop is actually inside the zoomed view and clickable there, not off in a corner.
ViewTarget computerView = null;
if (mon != null)
{
// Click/hover collider stays tight around the Monitor itself; the camera frames a wider box
// around Monitor+Keyboard+Mouse via SetFramingBounds so the Mouse is actually in shot when
// zoomed in, without also enlarging what the wide-view raycast treats as "the Computer" (which
// would otherwise sit in front of, and swallow clicks meant for, the Mouse's own small collider).
AddViewTarget(mon, "Computer", WorldBounds(mon), 1.2f);
computerView = mon.GetComponentInChildren();
if (computerView != null)
{
var deskItemsBounds = WorldBounds(mon);
if (keyboardGO != null) deskItemsBounds.Encapsulate(WorldBounds(keyboardGO));
if (mouseGO != null) deskItemsBounds.Encapsulate(WorldBounds(mouseGO));
computerView.SetFramingBounds(deskItemsBounds);
}
}
// Choosing an OS: click the Mouse for Windows 95, the Keyboard for Windows 3.1. Clicking the
// other one while an OS is running saves it and swaps to it (MonitorBrowserLauncher handles the swap).
// Both boot from OUR mirror on the server, not pcjs.org: the mirrored machine.xml files carry the
// hidden Save control and a second data disk (272Mb for Win95, 32Mb for Win31), and saved states are
// stored per player by afx-api on the same host, none of which the live pcjs.org pages have.
if (mon != null)
{
var monScreen = mon.GetComponentInChildren();
if (monScreen != null)
{
void AddOsLauncher(GameObject propGO, string name, string osName, string machineId, MonitorBrowserLauncher.ResumeMode resume, string liveUrl, string offlineUrl)
{
if (propGO == null) return;
var b = WorldBounds(propGO);
var click = new GameObject(name);
click.transform.SetParent(propGO.transform, true);
click.transform.position = b.center; click.transform.rotation = Quaternion.identity;
var ls2 = click.transform.lossyScale;
var box2 = click.AddComponent();
box2.size = new Vector3(b.size.x / ls2.x, b.size.y / ls2.y, b.size.z / ls2.z);
var mbl = click.AddComponent();
mbl.screen = monScreen.screen;
mbl.computerView = computerView;
mbl.osName = osName;
mbl.url = liveUrl;
mbl.offlineUrl = offlineUrl;
mbl.machineId = machineId;
mbl.resumeMode = resume;
// UWB's picture arrives upside down for the screen quad: vertical flip only (confirmed on the monitor)
mbl.screenFlipScale = new Vector2(1f, -1f);
mbl.screenFlipOffset = new Vector2(0f, 1f);
}
// Both machines behave like real PCs: files persist on their hard disks between sessions, but each
// boot is a normal boot (Paul's choice; PCjs also can't restore a protected-mode Win95 snapshot anyway).
// The Mouse boots Windows 98 SE in v86 (our own page, replaces the PCjs Windows 95: its Explorer bug
// and no networking). {user} = the player id; the page keeps the player's disk changes on the server.
AddOsLauncher(mouseGO, "Mouse Click", "Windows 98", "v86.win98.", MonitorBrowserLauncher.ResumeMode.Disk,
"http://78.110.173.123/VM/v86/win98.html?user={user}&api=http://78.110.173.123", "http://78.110.173.123/VM/v86/win98.html?user={user}&api=http://78.110.173.123");
AddOsLauncher(keyboardGO, "Keyboard Click", "Windows 3.1", "ibm5170.", MonitorBrowserLauncher.ResumeMode.Disk,
"http://78.110.173.123/VM/win31/software/pcx86/sys/windows/3.10/", "http://78.110.173.123/VM/win31/software/pcx86/sys/windows/3.10/");
}
}
// Table lamp on the left of the desk, centred over the drawers, switched on
var deskLamp = Place(Find(new[] { "tablelamp", "table_lamp" }), "Desk Lamp", new Vector3(drawersX, 0, db.center.z + M(0.1f)), 0f, default, top, 0.5f);
if (deskLamp != null)
{
var dlb = WorldBounds(deskLamp);
var dl = new GameObject("Lamp Light").AddComponent();
dl.transform.SetParent(deskLamp.transform, true);
dl.transform.position = new Vector3(dlb.center.x, dlb.min.y + dlb.size.y * 0.72f, dlb.center.z);
dl.type = LightType.Point;
dl.range = M(5f);
dl.intensity = 225f;
dl.color = new Color(1f, 0.82f, 0.58f);
dl.shadows = LightShadows.Soft;
AddLampSwitch(deskLamp, dl);
}
AddDeskDrawers(deskGO, db);
}
// Clock centred above the monitor; camera centred between the tank stand and the desk
var clockT = room.transform.Find("Wall Clock");
var monT = room.transform.Find("Monitor");
if (clockT != null && monT != null)
{
var mb = WorldBounds(monT.gameObject); var ckb = WorldBounds(clockT.gameObject);
clockT.position += new Vector3(mb.center.x - ckb.center.x, mb.max.y + M(0.4f) - ckb.min.y, 0f);
}
if (deskGO != null)
{
// Lobby view centred on where the stand-to-desk span was before the furniture moved 1 ft left
float midX = (tb.min.x + WorldBounds(deskGO).max.x) * 0.5f + LobbyShiftLeft * RoomScale;
if (Camera.main != null)
{
var pivot = new Vector3(midX, LobbyPivot.y, LobbyPivot.z);
Camera.main.transform.position = pivot + Camera.main.transform.rotation * new Vector3(0f, 0f, -LobbyDistance);
var lobby = Camera.main.GetComponent();
if (lobby != null)
{
lobby.hasHomePose = true;
lobby.homePosition = Camera.main.transform.position;
lobby.homeRotation = Camera.main.transform.rotation;
EditorUtility.SetDirty(lobby);
}
}
}
// Rendering layers and a warm ceiling light for the room
foreach (var r in room.GetComponentsInChildren(true)) { r.renderingLayerMask = 2; r.shadowCastingMode = ShadowCastingMode.On; r.receiveShadows = true; }
foreach (var n in new[] { "Tall Lamp Left", "Tall Lamp Right", "Desk Lamp" }) // the shade would otherwise block its own bulb
{
var lampT = room.transform.Find(n);
if (lampT != null) foreach (var r in lampT.GetComponentsInChildren(true)) r.shadowCastingMode = ShadowCastingMode.Off;
}
foreach (var l in room.GetComponentsInChildren(true))
{
if (l.name == "Lamp Light") { l.GetUniversalAdditionalLightData().renderingLayers = 3; continue; }
l.range *= S; l.intensity *= S * S * 2f; l.shadows = LightShadows.None;
l.GetUniversalAdditionalLightData().renderingLayers = 3;
}
var ceiling = new GameObject("Ceiling Light").AddComponent();
ceiling.transform.SetParent(room.transform);
ceiling.transform.position = new Vector3(cx, ceilY - M(0.3f), midZ);
ceiling.type = LightType.Point; ceiling.range = w * 1.2f; ceiling.intensity = 2500f;
ceiling.color = new Color(1f, 0.9f, 0.76f); ceiling.shadows = LightShadows.None;
ceiling.GetUniversalAdditionalLightData().renderingLayers = 3;
var rpAsset = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset;
if (rpAsset != null)
{
var so = new SerializedObject(rpAsset);
var layers = so.FindProperty("m_SupportsLightLayers"); if (layers != null) layers.boolValue = true;
var perObj = so.FindProperty("m_AdditionalLightsPerObjectLimit"); if (perObj != null) perObj.intValue = 8;
so.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(rpAsset);
}
if (key != null) key.GetUniversalAdditionalLightData().renderingLayers = 1;
if (fill != null) fill.GetUniversalAdditionalLightData().renderingLayers = 1;
foreach (var l in GameObject.Find("AFX Furniture").GetComponentsInChildren(true)) l.GetUniversalAdditionalLightData().renderingLayers = 3;
RenderSettings.ambientLight = new Color(0.2f, 0.18f, 0.15f);
RenderSettings.fogColor = new Color(0.07f, 0.08f, 0.09f);
RenderSettings.fogStartDistance = 60f;
RenderSettings.fogEndDistance = 400f;
Debug.Log("AFX ROOM: square room built. Placed: " + (placed.Length > 0 ? placed.ToString() : "nothing (no furniture pack found)"));
}
///
/// The desk model is one solid mesh with its two drawer fronts carved in. This finds each carved front
/// (by ray-casting the mesh) and lays a matching drawer over it: a front panel in the desk's wood with a
/// handle, and a box behind it hidden inside the desk until the drawer slides out on a click.
///
static void AddDeskDrawers(GameObject desk, Bounds db)
{
var mr = desk.GetComponentInChildren();
var mf = desk.GetComponentInChildren();
if (mr == null || mf == null) return;
var mats = mr.sharedMaterials;
Material wood = mats.Length > 0 ? mats[0] : null, handleMat = mats.Length > 1 ? mats[1] : wood;
// Drawer stack: left side of the desk (measured off the model: 10-31% across, 27-83% up)
float x0 = db.min.x + db.size.x * 0.10f, x1 = db.min.x + db.size.x * 0.31f;
float[,] rows = { { 0.57f, 0.83f }, { 0.27f, 0.53f } };
var probeCol = mf.gameObject.AddComponent();
probeCol.sharedMesh = mf.sharedMesh;
Physics.SyncTransforms();
float S = RoomScale;
var urpLit = Shader.Find("Universal Render Pipeline/Lit");
var holeMat = new Material(urpLit); holeMat.SetColor("_BaseColor", new Color(0.05f, 0.035f, 0.025f)); holeMat.SetFloat("_Smoothness", 0f);
holeMat = SaveAsset(holeMat, MatDir + "/Room_DrawerHole.mat");
float recess = 0.03f * S; // clears the carved drawer's handle
for (int i = 0; i < 2; i++)
{
float y0 = db.min.y + db.size.y * rows[i, 0], y1 = db.min.y + db.size.y * rows[i, 1];
var centre = new Vector3((x0 + x1) * 0.5f, (y0 + y1) * 0.5f, db.min.z - 1f);
float faceZ = db.min.z + 0.02f * S;
if (probeCol.Raycast(new Ray(centre, Vector3.forward), out var hit, db.size.z + 2f)) faceZ = hit.point.z;
float w = (x1 - x0) * 0.97f, h = (y1 - y0) * 0.94f, thick = 0.02f * S, depth = Mathf.Min(0.3f * S, db.size.z * 0.6f);
var drawerRoot = new GameObject("Drawer " + (i + 1));
drawerRoot.transform.SetParent(desk.transform, true);
drawerRoot.transform.position = new Vector3(centre.x, centre.y, faceZ - recess);
var body = new GameObject("Body");
body.transform.SetParent(drawerRoot.transform, true);
body.transform.position = drawerRoot.transform.position;
void Part(string n, Vector3 worldOffset, Vector3 worldSize, Material m)
{
var g = GameObject.CreatePrimitive(PrimitiveType.Cube);
g.name = n; Object.DestroyImmediate(g.GetComponent());
g.transform.SetParent(body.transform, false);
g.transform.position = body.transform.position + worldOffset;
g.transform.rotation = Quaternion.identity;
var ls = body.transform.lossyScale;
g.transform.localScale = new Vector3(worldSize.x / ls.x, worldSize.y / ls.y, worldSize.z / ls.z);
g.GetComponent().sharedMaterial = m;
g.GetComponent().renderingLayerMask = 2;
}
float side = 0.012f * S;
// Dark opening in front of the carved drawer (hides it and its handle once the new drawer slides out); doesn't move
var hole = GameObject.CreatePrimitive(PrimitiveType.Cube);
hole.name = "Opening"; Object.DestroyImmediate(hole.GetComponent());
hole.transform.SetParent(drawerRoot.transform, false);
hole.transform.position = drawerRoot.transform.position + new Vector3(0, 0, recess * 0.5f);
hole.transform.rotation = Quaternion.identity;
var hls = drawerRoot.transform.lossyScale;
hole.transform.localScale = new Vector3(w * 1.02f / hls.x, h * 1.04f / hls.y, recess / hls.z);
hole.GetComponent().sharedMaterial = holeMat;
hole.GetComponent().renderingLayerMask = 2;
Part("Front", new Vector3(0, 0, -thick * 0.5f - 0.05f), new Vector3(w, h, thick), wood);
Part("Handle", new Vector3(0, h * 0.12f, -thick - 0.012f * S - 0.05f), new Vector3(w * 0.3f, 0.016f * S, 0.024f * S), handleMat);
// Box: sides and back a little lower than the front, joined at the corners, so it reads as a real drawer when pulled out
float boxW = w * 0.92f, boxH = h * 0.78f, boxY = -h * 0.5f + boxH * 0.5f + h * 0.04f;
Part("Bottom", new Vector3(0, boxY - boxH * 0.5f + side * 0.5f, depth * 0.5f), new Vector3(boxW, side, depth), wood);
Part("Side L", new Vector3(-boxW * 0.5f + side * 0.5f, boxY, depth * 0.5f), new Vector3(side, boxH, depth), wood);
Part("Side R", new Vector3(boxW * 0.5f - side * 0.5f, boxY, depth * 0.5f), new Vector3(side, boxH, depth), wood);
Part("Back", new Vector3(0, boxY, depth - side * 0.5f), new Vector3(boxW, boxH, side), wood);
// Click box over the front; pulls straight out toward the room
// The click box rides on the moving body, so an open drawer is clicked where it now is (not the drawer below)
var click = body.AddComponent();
var rs = body.transform.lossyScale;
click.center = new Vector3(0, 0, -thick / rs.z);
click.size = new Vector3(w / rs.x, h / rs.y, thick * 3f / rs.z);
var drawer = body.AddComponent();
drawer.body = body.transform;
drawer.openOffset = new Vector3(0f, 0f, -depth * 0.95f); // out far enough to show the back of the box
}
Object.DestroyImmediate(probeCol);
}
///
/// Rocks and coral-like plants on the sand: a rock cluster left of centre (HQ Big Rock + Realistic rock pack),
/// a low rock to the right, reef heads and branching plants from Splash of Color in front of and between them,
/// leaving open water across the middle and top for the fish. Positions are in tank fractions
/// (x 0 = left, z 0 = front glass); each piece is scaled to a target height as a fraction of the tank height.
///
static void BuildTankDecor(GameObject tank, Vector3 size, Vector3 c)
{
var decor = new GameObject("Decor"); decor.transform.SetParent(tank.transform);
var heightTex = AssetDatabase.LoadAssetAtPath(TexDir + "sand_height.png");
int placed = 0;
string Prefab(string folder, string nameContains)
{
if (!AssetDatabase.IsValidFolder(folder)) return null;
string best = null;
foreach (var guid in AssetDatabase.FindAssets("t:Prefab", new[] { folder }))
{
var path = AssetDatabase.GUIDToAssetPath(guid);
if (path.Contains("Level Of Detail")) continue;
var file = Path.GetFileNameWithoutExtension(path);
if (file.IndexOf(nameContains, System.StringComparison.OrdinalIgnoreCase) < 0) continue;
if (best == null || file.Length < Path.GetFileNameWithoutExtension(best).Length) best = path; // plainest variant
}
return best;
}
float SandAt(float x, float z) => TankMin.y + SandBase + (heightTex != null ? heightTex.GetPixelBilinear(Mathf.InverseLerp(TankMin.x, TankMax.x, x), Mathf.InverseLerp(TankMin.z, TankMax.z, z)).grayscale * GravelAmplitude : 0f);
float SandMax(float x, float z, float ex, float ez)
{
float m = float.MinValue;
for (int i = -2; i <= 2; i++) for (int j = -2; j <= 2; j++) m = Mathf.Max(m, SandAt(x + ex * i / 2f, z + ez * j / 2f));
return m;
}
float SandAvg(float x, float z, float ex, float ez)
{
float t = 0f;
for (int i = -2; i <= 2; i++) for (int j = -2; j <= 2; j++) t += SandAt(x + ex * i / 2f, z + ez * j / 2f);
return t / 25f;
}
void Put(string path, string label, float fx, float fz, float heightFrac, float yaw, bool obstacle, Color tint = default, float emptyBaseFrac = 0f)
{
if (path == null) { Debug.LogWarning("AFX decor: missing prefab for " + label); return; }
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(path));
go.name = label; go.transform.SetParent(decor.transform, true);
go.transform.position = Vector3.zero; go.transform.rotation = Quaternion.Euler(0f, yaw, 0f); go.transform.localScale = Vector3.one;
var b = WorldBounds(go);
go.transform.localScale = Vector3.one * (size.y * heightFrac / Mathf.Max(0.001f, b.size.y));
b = WorldBounds(go);
// Wide, flat species would otherwise span the tank at their target height: cap the footprint
float maxW = size.x * 0.22f, maxD = size.z * 0.34f;
// Plants only (rocks keep their size), and only across: the plant keeps its height
float shrink = Mathf.Min(1f, maxW / Mathf.Max(0.001f, b.size.x), maxD / Mathf.Max(0.001f, b.size.z));
if (shrink < 1f && path.StartsWith(PlantsFolder))
{
var sc = go.transform.localScale;
go.transform.localScale = new Vector3(sc.x * shrink, sc.y, sc.z * shrink);
b = WorldBounds(go);
}
// Keep it inside the glass
float x = TankMin.x + size.x * fx, z = TankMin.z + size.z * fz;
x = Mathf.Clamp(x, TankMin.x + b.extents.x + 0.2f, TankMax.x - b.extents.x - 0.2f);
z = Mathf.Clamp(z, TankMin.z + b.extents.z + 0.2f, TankMax.z - b.extents.z - 0.2f);
// Sand height under the piece: plants sit on the highest dune point under their footprint (so no leaves vanish
// into a dune); rocks use the average and bed in a little
bool plant = path.StartsWith(PlantsFolder);
float sandY = plant ? SandMax(x, z, b.extents.x * 0.8f, b.extents.z * 0.8f) : SandAvg(x, z, b.extents.x * 0.6f, b.extents.z * 0.6f);
// emptyBaseFrac: some leaf cards have fully transparent space under the painted stem; sink that part into the sand
go.transform.position += new Vector3(x - b.center.x, sandY - (plant ? 0.03f : 0.25f) - b.size.y * emptyBaseFrac - b.min.y, z - b.center.z);
foreach (var r in go.GetComponentsInChildren())
{
r.shadowCastingMode = ShadowCastingMode.On; r.receiveShadows = true;
// Splash of Color plants are leaf cards: their pack materials ship opaque, so the card edges render black. Cut them out by texture alpha.
if (path.StartsWith(PlantsFolder))
{
var mats = r.sharedMaterials;
for (int k = 0; k < mats.Length; k++)
{
MakeCutout(mats[k]);
// The pack only has a handful of colours; tinted copies add pinks, reds and blues
if (tint != default && mats[k] != null && mats[k].HasProperty("_BaseColor"))
{
var tinted = new Material(mats[k]);
tinted.SetColor("_BaseColor", mats[k].GetColor("_BaseColor") * tint);
mats[k] = SaveAsset(tinted, MatDir + "/Plant_" + label.Replace(" ", "") + "_" + k + ".mat");
}
}
r.sharedMaterials = mats;
}
}
foreach (var col in go.GetComponentsInChildren()) col.enabled = false; // don't block clicks on the tank
if (obstacle) go.AddComponent();
if (path.StartsWith(PlantsFolder))
{
// Sway from the base: wrap the plant in a pivot sitting on the sand
var gb = WorldBounds(go);
var pivot = new GameObject(label + " Sway");
pivot.transform.SetParent(decor.transform, true);
pivot.transform.position = new Vector3(gb.center.x, gb.min.y, gb.center.z);
go.transform.SetParent(pivot.transform, true);
var sway = pivot.AddComponent();
sway.wakeRadius = Mathf.Max(4f, gb.size.y * 1.2f);
}
placed++;
}
const string rocks = "Assets/Pizza&Games/Realistic Rocks/Prefabs";
const string bigRock = "Assets/NOT_Lonely/HQ_BigRock";
string plants = PlantsFolder + "/Prefabs";
// Rock cluster, left of centre, set back
Put(Prefab(rocks, "SM_LittleRock_05"), "Rock Tall", 0.30f, 0.62f, 0.55f, 20f, true);
Put(Prefab(rocks, "SM_LittleRock_04"), "Rock Side", 0.44f, 0.70f, 0.38f, -35f, true);
Put(Prefab(bigRock, "HQBigRock"), "Rock Flat", 0.20f, 0.35f, 0.16f, 60f, true);
// Low rock to the right
Put(Prefab(rocks, "SM_LittleRock_03"), "Rock Long", 0.76f, 0.66f, 0.18f, 80f, true);
// Reef heads and branching plants
Put(Prefab(plants, "Spin Reef"), "Reef Head", 0.62f, 0.30f, 0.16f, 140f, true);
Put(Prefab(plants, "Spin Reef"), "Spin Reef", 0.12f, 0.72f, 0.2f, 30f, true);
Put(Prefab(plants, "Antler Sprout"), "Antler Coral", 0.86f, 0.78f, 0.42f, 0f, false);
Put(Prefab(plants, "Antler Sprout"), "Antler Coral 2", 0.38f, 0.30f, 0.26f, 120f, false);
Put(Prefab(plants, "Sheen Stalk"), "Sea Stalk", 0.52f, 0.78f, 0.3f, 45f, false);
Put(Prefab(plants, "Filter Fruit"), "Filter Plant", 0.9f, 0.3f, 0.24f, 200f, false);
// Colour: the pack's most vivid species (purple, orange fan, mustard), plus tinted copies for pink, red and blue
Put(Prefab(plants, "Basin Bush"), "Purple Bush", 0.08f, 0.42f, 0.2f, 0f, true);
Put(Prefab(plants, "Padder Pod"), "Pink Pods", 0.56f, 0.52f, 0.13f, 70f, false, new Color(1.6f, 0.75f, 1.1f));
Put(Prefab(plants, "Sickle Wort"), "Purple Sickles", 0.95f, 0.55f, 0.15f, 30f, false);
Put(Prefab(plants, "Peacock Plant"), "Orange Fan", 0.72f, 0.16f, 0.09f, 15f, false);
Put(Prefab(plants, "Peacock Plant"), "Orange Fan 2", 0.42f, 0.12f, 0.08f, 140f, false);
// Tall slender plants along the back right: silvery-blue Steel Sprig, a magenta-tipped one, and maroon Fuzz Brush stems
Put(Prefab(plants, "Steel Sprig 1A"), "Silver Sprig", 0.63f, 0.9f, 0.5f, 10f, false, default, 0.42f); // Steel Sprig's painted stem starts ~40% up its card (measured opaque texels)
Put(Prefab(plants, "Steel Sprig 1D"), "Magenta Sprig", 0.73f, 0.86f, 0.58f, 100f, false, default, 0.42f);
Put(Prefab(plants, "Fuzz Brush 1A"), "Maroon Stem", 0.81f, 0.9f, 0.52f, 0f, false, default, 0.37f); // Fuzz Brush card: same empty base
Put(Prefab(plants, "Fuzz Brush 1D"), "Pale Stem", 0.94f, 0.86f, 0.46f, 60f, false, default, 0.37f);
// Green trailing vines hanging from just under the waterline down the back glass
float waterTop = TankMax.y - WaterDepthBelowRim;
void Hang(string path, string label, float fx, float lengthFrac, float yaw, bool stretchToSand = true)
{
if (path == null) { Debug.LogWarning("AFX decor: missing prefab for " + label); return; }
var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(path));
go.name = label;
go.transform.position = Vector3.zero; go.transform.rotation = Quaternion.Euler(0f, yaw, 0f); go.transform.localScale = Vector3.one;
var b = WorldBounds(go);
go.transform.localScale = Vector3.one * (size.y * lengthFrac / Mathf.Max(0.001f, b.size.y));
b = WorldBounds(go);
float x = Mathf.Clamp(TankMin.x + size.x * fx, TankMin.x + b.extents.x + 0.2f, TankMax.x - b.extents.x - 0.2f);
// Stretch the vine down so it reaches from just under the waterline to the sand along the back glass
float backZ = TankMax.z - 0.15f - b.extents.z;
float top = waterTop - 0.3f, bottom = SandMax(x, backZ, b.extents.x * 0.8f, 0.2f);
var ls = go.transform.localScale;
if (stretchToSand) go.transform.localScale = new Vector3(ls.x, ls.y * Mathf.Max(1f, (top - bottom) / Mathf.Max(0.001f, b.size.y)), ls.z);
b = WorldBounds(go);
go.transform.position += new Vector3(x - b.center.x, top - b.max.y, TankMax.z - 0.15f - b.max.z);
foreach (var r in go.GetComponentsInChildren())
{
r.shadowCastingMode = ShadowCastingMode.On; r.receiveShadows = true;
foreach (var m in r.sharedMaterials) MakeCutout(m);
}
foreach (var col in go.GetComponentsInChildren()) col.enabled = false;
// Sways from its top, like a vine trailing in the current
b = WorldBounds(go);
var pivot = new GameObject(label + " Sway");
pivot.transform.SetParent(decor.transform, true);
pivot.transform.position = new Vector3(b.center.x, b.max.y, b.center.z);
go.transform.SetParent(pivot.transform, true);
var sway = pivot.AddComponent();
sway.idleDegrees = 1.5f; sway.wakeDegrees = 10f; sway.wakeRadius = Mathf.Max(4f, b.size.y);
placed++;
}
Put(Prefab(plants, "Blot Leaf"), "Yellow Caps", 0.26f, 0.14f, 0.07f, 90f, false);
Put(Prefab(plants, "Tanza Cabbage"), "Red Cabbage", 0.47f, 0.9f, 0.1f, 45f, false, new Color(1.5f, 0.55f, 0.5f));
Put(Prefab(plants, "Sickle Wort"), "Blue Sickles", 0.66f, 0.86f, 0.14f, 200f, false, new Color(0.6f, 1.0f, 1.8f));
// Bushy backdrop behind the rock cluster, at least a third of the tank's height
Put(Prefab(plants, "Two Faced Tuber 1A"), "Back Bush Tuber", 0.08f, 0.92f, 0.42f, 30f, true);
Put(Prefab(plants, "Sparrow Pile 1A"), "Back Bush Sparrow", 0.24f, 0.93f, 0.46f, 160f, true);
Put(Prefab(plants, "Staright 1A"), "Back Bush Staright", 0.4f, 0.92f, 0.38f, 80f, true);
Put(Prefab(plants, "Basin Bush"), "Back Bush Basin", 0.54f, 0.93f, 0.36f, 210f, true, new Color(0.8f, 1.1f, 0.8f));
// Greenery draped over the front of the tallest rock: find the rock's front face near its top and hang a vine from there
var tallRock = decor.transform.Find("Rock Tall");
var drapePath = Prefab(plants, "Hanging Luna Vine A Wall");
if (tallRock != null && drapePath != null)
{
var rb = WorldBounds(tallRock.gameObject);
var cols = new System.Collections.Generic.List();
foreach (var mf in tallRock.GetComponentsInChildren()) { var mc = mf.gameObject.AddComponent(); mc.sharedMesh = mf.sharedMesh; cols.Add(mc); }
Physics.SyncTransforms();
Hang(drapePath, "Rock Drape", 0f, 0.3f, 180f, false); // Hang places it at the back glass; moved onto the rock below
var drape = decor.transform.Find("Rock Drape Sway");
if (drape != null)
{
var db = WorldBounds(drape.gameObject);
float x = rb.center.x;
// Top: the rock's surface height near its front edge at this x, so the vine starts at the rock's lip, not above it
float topY = rb.max.y;
float frontZ = rb.min.z + rb.size.z * 0.25f;
foreach (var mc in cols)
if (mc.Raycast(new Ray(new Vector3(x, rb.max.y + 5f, frontZ), Vector3.down), out var down, rb.size.y + 10f)) { topY = down.point.y; break; }
drape.position += new Vector3(x - db.center.x, topY + 0.15f - db.max.y, 0f);
db = WorldBounds(drape.gameObject);
// Depth: find the front-most bit of rock behind the vine's whole area and keep the vine just in front of it
float nearest = float.MaxValue;
for (int ix = 0; ix <= 4; ix++)
for (int iy = 0; iy <= 4; iy++)
{
var from = new Vector3(db.min.x + db.size.x * ix / 4f, db.min.y + db.size.y * iy / 4f, rb.min.z - 5f);
foreach (var mc in cols)
if (mc.Raycast(new Ray(from, Vector3.forward), out var h, rb.size.z + 10f)) nearest = Mathf.Min(nearest, h.point.z);
}
if (nearest == float.MaxValue) nearest = rb.min.z;
drape.position += new Vector3(0f, 0f, nearest - 0.1f - db.max.z);
}
foreach (var mc in cols) Object.DestroyImmediate(mc);
}
// Nothing growing out of a rock: nudge any plant whose base overlaps a rock out to the sand beside it
var rockCols = new System.Collections.Generic.List();
foreach (Transform t in decor.transform)
if (t.name.StartsWith("Rock ") && t.GetComponent() != null)
foreach (var mf in t.GetComponentsInChildren()) { var mc = mf.gameObject.AddComponent(); mc.sharedMesh = mf.sharedMesh; rockCols.Add(mc); }
Physics.SyncTransforms();
int RockHits(Bounds pb)
{
int n = 0;
for (int i = -1; i <= 1; i++) for (int j = -1; j <= 1; j++)
{
var from = new Vector3(pb.center.x + pb.extents.x * 0.5f * i, pb.max.y + 20f, pb.center.z + pb.extents.z * 0.5f * j);
foreach (var mc in rockCols)
if (mc.Raycast(new Ray(from, Vector3.down), out var hit, 60f) && hit.point.y > pb.min.y + 0.3f) { n++; break; }
}
return n;
}
foreach (Transform t in decor.transform)
{
if (!t.name.EndsWith(" Sway") || t.name.StartsWith("Rock Drape")) continue;
var pb = WorldBounds(t.gameObject);
if (RockHits(pb) < 4) continue; // only plants genuinely growing out of a rock, not ones brushing an edge
var start = t.position;
bool cleared = false;
for (int moves = 1; moves <= 6 && !cleared; moves++)
{
Vector3 away = Vector3.left; float best = float.MaxValue;
foreach (var mc in rockCols)
{
var d = pb.center - mc.bounds.center; d.y = 0f;
if (d.magnitude < best) { best = d.magnitude; away = d.sqrMagnitude > 0.001f ? d.normalized : Vector3.left; }
}
float sandBefore = SandMax(pb.center.x, pb.center.z, pb.extents.x * 0.8f, pb.extents.z * 0.8f);
t.position += away * 0.6f;
pb = WorldBounds(t.gameObject);
if (pb.min.x < TankMin.x + 0.2f || pb.max.x > TankMax.x - 0.2f || pb.min.z < TankMin.z + 0.2f || pb.max.z > TankMax.z - 0.2f) break;
// Follow the dunes by the change in sand height, keeping any deliberate sink (emptyBaseFrac)
float sy = SandMax(pb.center.x, pb.center.z, pb.extents.x * 0.8f, pb.extents.z * 0.8f);
t.position += Vector3.up * (sy - sandBefore);
pb = WorldBounds(t.gameObject);
if (RockHits(pb) < 2) { cleared = true; Debug.Log("AFX decor: moved " + t.name + " out of a rock (" + moves + " steps)"); }
}
if (!cleared) { t.position = start; Debug.Log("AFX decor: left " + t.name + " in place (couldn't clear a rock nearby)"); }
}
foreach (var mc in rockCols) Object.DestroyImmediate(mc);
Debug.Log("AFX decor: placed " + placed + " rocks and plants in the tank");
}
const float PictureBottomY = 10.05f; // world Y of the picture frame's bottom edge (where it hung before the tank was resized)
const string PlantsFolder = "Assets/Splash of Color - Unique Photogrammetry Plants";
/// URP/Lit alpha-clipped, two-sided: for leaf cards whose transparency lives in the texture alpha.
static void MakeCutout(Material m)
{
if (m == null || !m.HasProperty("_AlphaClip")) return;
if (m.GetFloat("_AlphaClip") > 0.5f && m.IsKeywordEnabled("_ALPHATEST_ON")) return;
m.SetFloat("_Surface", 0f);
m.SetFloat("_AlphaClip", 1f); m.EnableKeyword("_ALPHATEST_ON");
m.SetFloat("_Cutoff", 0.5f);
if (m.HasProperty("_Cull")) m.SetFloat("_Cull", (float)CullMode.Off); // leaves seen from both sides
m.SetOverrideTag("RenderType", "TransparentCutout");
m.renderQueue = (int)RenderQueue.AlphaTest;
EditorUtility.SetDirty(m);
}
///
/// A 16:9 screen surface on the front of the monitor showing a live HLS stream (HlsStreamPlayer).
/// The screen face is found by ray-casting the monitor mesh at screen height, so the picture sits on the glass
/// rather than in front of the stand's foot.
///
static void AddMonitorScreen(GameObject monitor)
{
var mb = WorldBounds(monitor);
// Glass area: 93% of the monitor's width, 16:9 (the monitor is flattened to that shape in BuildRoom)
float w = mb.size.x * 0.93f, h = w * 9f / 16f;
float centreY = mb.max.y - mb.size.x * 0.035f - h * 0.5f;
float faceZ = mb.min.z;
var cols = new System.Collections.Generic.List();
foreach (var mf in monitor.GetComponentsInChildren()) { var mc = mf.gameObject.AddComponent(); mc.sharedMesh = mf.sharedMesh; cols.Add(mc); }
Physics.SyncTransforms();
foreach (var mc in cols)
if (mc.Raycast(new Ray(new Vector3(mb.center.x, centreY, mb.min.z - 5f), Vector3.forward), out var hit, mb.size.z + 10f)) { faceZ = hit.point.z; break; }
foreach (var mc in cols) Object.DestroyImmediate(mc);
var screen = GameObject.CreatePrimitive(PrimitiveType.Quad);
screen.name = "Monitor Screen";
Object.DestroyImmediate(screen.GetComponent());
screen.transform.SetParent(monitor.transform, true);
screen.transform.rotation = Quaternion.identity; // quad faces -Z, toward the room
screen.transform.position = new Vector3(mb.center.x, centreY, faceZ - 0.02f);
var ls = screen.transform.parent.lossyScale;
screen.transform.localScale = new Vector3(w / ls.x, h / ls.y, 1f / ls.z);
var unlit = Shader.Find("Universal Render Pipeline/Unlit");
var mat = new Material(unlit != null ? unlit : Shader.Find("Unlit/Texture"));
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", Color.white);
if (mat.HasProperty("_BaseMap")) { var black = Texture2D.blackTexture; mat.SetTexture("_BaseMap", black); } // bars outside the picture stay black
var mr = screen.GetComponent();
mr.sharedMaterial = SaveAsset(mat, MatDir + "/MonitorScreen.mat");
mr.shadowCastingMode = ShadowCastingMode.Off;
var player = screen.AddComponent();
player.screen = mr;
// The click trigger for Windows 95 lives on the Mouse prop, not here (see BuildFurniture): the screen
// quad's own collider sat directly behind the Monitor's outer (zoom) collider along the camera ray, so
// a click there was always intercepted by the zoom-in collider first and never reached this one.
}
/// Click-to-toggle collider around a lamp.
static void AddLampSwitch(GameObject lampGO, Light light)
{
var b = WorldBounds(lampGO);
var t = new GameObject("Lamp Switch");
t.transform.SetParent(lampGO.transform, false);
t.transform.position = b.center; t.transform.rotation = Quaternion.identity;
var ls = t.transform.lossyScale;
var box = t.AddComponent();
box.size = new Vector3(b.size.x / ls.x, b.size.y / ls.y, b.size.z / ls.z);
t.AddComponent().lamp = light;
}
/// A clickable close-up target: a world-aligned box collider around the given bounds, on a child so the object's own scale doesn't matter.
static void AddViewTarget(GameObject go, string label, Bounds worldBounds, float padding)
{
var t = new GameObject("View Target");
t.transform.SetParent(go.transform, false);
t.transform.position = worldBounds.center;
t.transform.rotation = Quaternion.identity;
var ls = t.transform.lossyScale;
var box = t.AddComponent();
box.size = new Vector3(worldBounds.size.x / ls.x, worldBounds.size.y / ls.y, worldBounds.size.z / ls.z);
var vt = t.AddComponent();
vt.label = label + ": click to look closer";
vt.padding = padding;
}
///
/// The tank stand keeps its original proportions (the pack's scale, set by its desk being a real 76 cm tall),
/// and the tank is built to fit its top: a little narrower and shallower than the stand, bottom at the same height as before.
///
static void SizeTankToStand()
{
Vector3 RawSize(string path)
{
if (path == null) return Vector3.zero;
var g = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(path));
g.transform.position = Vector3.zero; g.transform.rotation = Quaternion.identity; g.transform.localScale = Vector3.one;
var sz = WorldBounds(g).size; Object.DestroyImmediate(g); return sz;
}
var stand = RawSize(FindPackPrefab(new[] { "tv_stand", "tvstand" }));
var desk = RawSize(FindPackPrefab(new[] { "desk" }, new[] { "lamp", "chair" }));
if (stand == Vector3.zero) { StandScale = 0f; return; }
StandScale = desk.y > 0.0001f ? 0.76f * RoomScale / desk.y : 0.55f * RoomScale / stand.y;
Vector3 top = stand * StandScale;
const float bottom = -6f, centreZ = -2f, frameBar = 0.35f;
float centreX = -LobbyShiftLeft * RoomScale;
const float TankShrink = 0.8f; // 20% smaller than the stand's top allows, centred on it
float w = (top.x - 2f * frameBar - 0.6f) * TankShrink, d = (top.z - 2f * frameBar - 0.6f) * TankShrink;
float h = Mathf.Clamp(w * 0.5f, 6f, 16f) * TankShrink / 0.8f * 0.8f;
TankMin = new Vector3(centreX - w * 0.5f, bottom, centreZ - d * 0.5f);
TankMax = new Vector3(centreX + w * 0.5f, bottom + h, centreZ + d * 0.5f);
Debug.Log("AFX: tank sized to stand " + top + " -> tank " + (TankMax - TankMin));
}
/// Prefabs from the low poly furniture pack (anything not in a known non-furniture folder), matched by file name.
static string FindPackPrefab(string[] want, string[] avoid = null)
{
var all = new System.Collections.Generic.List<(string name, string path)>();
foreach (var guid in AssetDatabase.FindAssets("t:Prefab", new[] { "Assets" }))
{
var path = AssetDatabase.GUIDToAssetPath(guid);
bool skip = false; foreach (var np in NotRoomPacks) if (path.StartsWith(np)) skip = true;
if (!skip) all.Add((Path.GetFileNameWithoutExtension(path).ToLower(), path));
}
// The living room demo pack was laid out first; prefer its pieces when both packs have a match
all.Sort((x, y) => (y.path.Contains("LowPolyLivingRoom") ? 1 : 0).CompareTo(x.path.Contains("LowPolyLivingRoom") ? 1 : 0));
foreach (var w in want)
foreach (var pf in all)
{
if (!pf.name.Contains(w)) continue;
bool bad = false; if (avoid != null) foreach (var a in avoid) if (pf.name.Contains(a)) bad = true;
if (!bad) return pf.path;
}
return null;
}
static Bounds WorldBounds(GameObject go)
{
var rs = go.GetComponentsInChildren();
if (rs.Length == 0) return new Bounds(go.transform.position, Vector3.one);
var b = rs[0].bounds;
foreach (var r in rs) b.Encapsulate(r.bounds);
return b;
}
/// Built-in (Standard / Autodesk Interactive) materials show pink in URP; remap them onto URP/Lit.
[MenuItem("AFX/Convert Furniture Pack Materials")]
public static void ConvertFurniturePacks()
{
foreach (var guid in AssetDatabase.FindAssets("t:Prefab", new[] { "Assets" }))
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var parts = path.Split('/');
if (parts.Length > 2) ConvertMaterialsToURP(parts[0] + "/" + parts[1]);
}
}
static void ConvertMaterialsToURP(string folder)
{
var urpLit = Shader.Find("Universal Render Pipeline/Lit");
if (urpLit == null || !AssetDatabase.IsValidFolder(folder)) return;
foreach (var guid in AssetDatabase.FindAssets("t:Material", new[] { folder }))
{
var mat = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid));
if (mat == null || mat.shader == null || mat.shader.name.StartsWith("Universal Render Pipeline")) continue;
string old = mat.shader.name;
Texture Tex(params string[] names) { foreach (var n in names) if (mat.HasProperty(n) && mat.GetTexture(n) != null) return mat.GetTexture(n); return null; }
Color Col(string n, Color d) => mat.HasProperty(n) ? mat.GetColor(n) : d;
float Flt(string n, float d) => mat.HasProperty(n) ? mat.GetFloat(n) : d;
var main = Tex("_MainTex", "_BaseMap", "_BaseColorMap");
var normal = Tex("_BumpMap", "_NormalMap");
var metal = Tex("_MetallicGlossMap");
var spec = Tex("_SpecGlossMap");
var occl = Tex("_OcclusionMap");
var emis = Tex("_EmissionMap");
var color = Col("_Color", Color.white);
var emisCol = Col("_EmissionColor", Color.black);
bool autodesk = old.Contains("Autodesk");
bool specular = !autodesk && (old.Contains("Specular") || spec != null);
float smooth = autodesk ? 1f - Flt("_Glossiness", 0.6f) : Flt("_Glossiness", 0.5f);
mat.shader = urpLit;
if (main != null) mat.SetTexture("_BaseMap", main);
mat.SetColor("_BaseColor", color);
if (normal != null) { mat.SetTexture("_BumpMap", normal); mat.EnableKeyword("_NORMALMAP"); }
if (occl != null) { mat.SetTexture("_OcclusionMap", occl); mat.EnableKeyword("_OCCLUSIONMAP"); }
if (specular)
{
mat.SetFloat("_WorkflowMode", 0f); mat.EnableKeyword("_SPECULAR_SETUP");
if (spec != null) { mat.SetTexture("_SpecGlossMap", spec); mat.EnableKeyword("_SPECGLOSSMAP"); }
}
else if (metal != null) { mat.SetTexture("_MetallicGlossMap", metal); mat.EnableKeyword("_METALLICSPECGLOSSMAP"); }
mat.SetFloat("_Smoothness", Mathf.Clamp01(smooth));
if (emis != null || emisCol.maxColorComponent > 0.01f)
{
if (emis != null) mat.SetTexture("_EmissionMap", emis);
mat.SetColor("_EmissionColor", emisCol.maxColorComponent > 0.01f ? emisCol : Color.white * 2f);
mat.EnableKeyword("_EMISSION");
mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
}
EditorUtility.SetDirty(mat);
Debug.Log("AFX: converted " + mat.name + " (" + old + ") to URP/Lit");
}
AssetDatabase.SaveAssets();
}
static void ConfigurePipeline()
{
var rp = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset;
if (rp == null) return;
rp.msaaSampleCount = 4;
rp.supportsHDR = true;
rp.shadowDistance = 60f;
rp.shadowCascadeCount = 1;
rp.supportsCameraDepthTexture = true; // AFX/Water reads the depth texture for depth fade
rp.supportsCameraOpaqueTexture = true; // ...and the opaque colour texture for refraction
// The hanging lamp is a spot light (an "additional light"); make sure those render per pixel with shadows.
var so = new SerializedObject(rp);
var addMode = so.FindProperty("m_AdditionalLightsRenderingMode"); if (addMode != null) addMode.intValue = 1; // PerPixel
var addShadows = so.FindProperty("m_AdditionalLightShadowsSupported"); if (addShadows != null) addShadows.boolValue = true;
var perObj = so.FindProperty("m_AdditionalLightsPerObjectLimit"); if (perObj != null && perObj.intValue < 4) perObj.intValue = 4;
so.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(rp);
}
///
/// Post-processing: bloom, ACES tonemapping, a touch of colour grading, a
/// cool white balance and a vignette. This is usually the single biggest
/// step from "flat render" toward "photo".
///
static void ConfigurePostProcessing()
{
var volGO = GameObject.Find("Global Volume");
Volume vol = volGO != null ? volGO.GetComponent() : null;
if (vol == null)
{
volGO = volGO ?? new GameObject("Global Volume");
vol = volGO.AddComponent();
}
vol.isGlobal = true;
var profile = vol.sharedProfile;
if (profile == null)
{
profile = ScriptableObject.CreateInstance();
Directory.CreateDirectory(MatDir);
AssetDatabase.DeleteAsset(MatDir + "/AFXPostProcess.asset");
AssetDatabase.CreateAsset(profile, MatDir + "/AFXPostProcess.asset");
vol.sharedProfile = profile;
}
if (!profile.TryGet(out Bloom bloom)) bloom = profile.Add(true);
bloom.threshold.value = 1.15f; bloom.intensity.value = 0.22f; bloom.scatter.value = 0.65f;
if (!profile.TryGet(out Tonemapping tone)) tone = profile.Add(true);
tone.mode.value = TonemappingMode.ACES;
if (!profile.TryGet(out ColorAdjustments ca)) ca = profile.Add(true);
ca.postExposure.value = 0.1f; ca.contrast.value = 8f; ca.saturation.value = 6f;
if (!profile.TryGet(out WhiteBalance wb)) wb = profile.Add(true);
wb.temperature.value = -8f; // slightly cool, aquatic
if (!profile.TryGet(out Vignette vig)) vig = profile.Add(true);
vig.intensity.value = 0.25f; vig.smoothness.value = 0.6f;
EditorUtility.SetDirty(profile);
}
const string SpriteBase = "http://78.110.173.123/sprites/";
/// Download sprites/index.txt from the AFX server and fetch every listed PNG that is missing or changed.
[MenuItem("AFX/Sync Sprites from Server")]
public static void SyncSprites()
{
Directory.CreateDirectory(FishDir);
PlayerSettings.insecureHttpOption = InsecureHttpOption.AlwaysAllowed; string index = Fetch(SpriteBase + "index.txt");
if (index == null) { Debug.LogError("AFX: could not fetch sprite index from " + SpriteBase); return; }
int got = 0;
foreach (var raw in index.Split('\n'))
{
// lines: name.png
var parts = raw.Trim().Split(' ');
if (parts.Length < 1 || parts[0].Length == 0) continue;
string name = parts[0];
long size = parts.Length > 1 && long.TryParse(parts[1], out var sz) ? sz : -1;
string local = FishDir + name;
if (File.Exists(local) && size >= 0 && new FileInfo(local).Length == size) continue;
var bytes = FetchBytes(SpriteBase + name);
if (bytes == null) { Debug.LogWarning("AFX: failed " + name); continue; }
File.WriteAllBytes(local, bytes);
string stem = local.Substring(0, local.Length - 4);
AssetDatabase.DeleteAsset(stem + "_n.png");
AssetDatabase.DeleteAsset(stem + "_ao.png");
got++;
Debug.Log("AFX: fetched " + name + " (" + bytes.Length + " bytes)");
}
AssetDatabase.Refresh();
Debug.Log("AFX: sprite sync done, " + got + " file(s) updated.");
}
static string Fetch(string url)
{
var b = FetchBytes(url);
return b == null ? null : System.Text.Encoding.UTF8.GetString(b);
}
static byte[] FetchBytes(string url)
{
using (var req = UnityEngine.Networking.UnityWebRequest.Get(url))
{
req.timeout = 30;
var op = req.SendWebRequest();
while (!op.isDone) System.Threading.Thread.Sleep(10);
if (req.result != UnityEngine.Networking.UnityWebRequest.Result.Success) { Debug.LogWarning("AFX: " + url + " -> " + req.error); return null; }
return req.downloadHandler.data;
}
}
static Texture2D LoadTex(string path, bool normalMap)
{
var importer = AssetImporter.GetAtPath(path) as TextureImporter;
if (importer == null) return null;
bool dirty = false;
if (normalMap && importer.textureType != TextureImporterType.NormalMap) { importer.textureType = TextureImporterType.NormalMap; dirty = true; }
bool sprite = path.StartsWith(FishDir);
if (sprite && !normalMap && !importer.alphaIsTransparency) { importer.alphaIsTransparency = true; dirty = true; }
if (!sprite && importer.wrapMode != TextureWrapMode.Repeat) { importer.wrapMode = TextureWrapMode.Repeat; dirty = true; }
if (!normalMap && importer.sRGBTexture == false) { importer.sRGBTexture = true; dirty = true; }
if (importer.npotScale != TextureImporterNPOTScale.None) { importer.npotScale = TextureImporterNPOTScale.None; dirty = true; }
if (importer.mipmapEnabled == false) { importer.mipmapEnabled = true; dirty = true; }
if (importer.filterMode != FilterMode.Trilinear) { importer.filterMode = FilterMode.Trilinear; dirty = true; }
if (importer.anisoLevel < 4) { importer.anisoLevel = 4; dirty = true; }
if (dirty) importer.SaveAndReimport();
return AssetDatabase.LoadAssetAtPath(path);
}
static GameObject MakeQuad(string name, Transform parent, Vector3 pos, Quaternion rot, Vector3 scale, Color col, Shader lit, bool urp)
{
var q = GameObject.CreatePrimitive(PrimitiveType.Quad);
q.name = name; q.transform.SetParent(parent);
q.transform.position = pos; q.transform.rotation = rot; q.transform.localScale = scale;
Object.DestroyImmediate(q.GetComponent());
var m = new Material(lit);
m.SetColor(urp ? "_BaseColor" : "_Color", col);
if (urp) m.SetFloat("_Smoothness", 0.05f);
Directory.CreateDirectory(MatDir);
m = SaveAsset(m, MatDir + "/" + name.Replace(" ", "") + ".mat");
q.GetComponent().sharedMaterial = m;
q.GetComponent().receiveShadows = true;
return q;
}
}
///
/// Builds a real displaced terrain mesh (dunes, banked edges) from a grayscale
/// heightmap, instead of faking bumps with a normal map on a flat plane.
/// Y-up, centred at the local origin: worldW along X, worldD along Z.
///
public static class GravelTerrain
{
public static Mesh Build(string heightPath, float worldW, float worldD, float amplitude, int resX, int resZ, float skirtDepth = 0f)
{
var imp = AssetImporter.GetAtPath(heightPath) as TextureImporter;
if (imp != null && (!imp.isReadable || imp.textureCompression != TextureImporterCompression.Uncompressed))
{
imp.isReadable = true;
imp.textureCompression = TextureImporterCompression.Uncompressed;
imp.SaveAndReimport();
}
var tex = AssetDatabase.LoadAssetAtPath(heightPath);
int w = resX + 1, h = resZ + 1;
var verts = new Vector3[w * h];
var uvs = new Vector2[w * h];
for (int zi = 0; zi < h; zi++)
for (int xi = 0; xi < w; xi++)
{
float u = xi / (float)resX, v = zi / (float)resZ;
float height = tex != null ? tex.GetPixelBilinear(u, v).grayscale : 0f;
int idx = zi * w + xi;
verts[idx] = new Vector3((u - 0.5f) * worldW, height * amplitude, (v - 0.5f) * worldD);
uvs[idx] = new Vector2(u, v);
}
var tris = new System.Collections.Generic.List();
for (int zi = 0; zi < resZ; zi++)
for (int xi = 0; xi < resX; xi++)
{
int a = zi * w + xi, b = a + 1, cIdx = a + w, d = cIdx + 1;
tris.Add(a); tris.Add(cIdx); tris.Add(b);
tris.Add(b); tris.Add(cIdx); tris.Add(d);
}
var mesh = new Mesh { name = "GravelTerrain", indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 };
mesh.SetVertices(verts); mesh.SetUVs(0, uvs); mesh.SetTriangles(tris, 0);
mesh.RecalculateNormals();
// Surface must face +Y (up); flip winding if RecalculateNormals came out facing down.
var nrm = mesh.normals; float sumY = 0f;
foreach (var n in nrm) sumY += n.y;
if (sumY < 0f)
{
for (int i = 0; i < tris.Count; i += 3) { int t = tris[i + 1]; tris[i + 1] = tris[i + 2]; tris[i + 2] = t; }
mesh.SetTriangles(tris, 0);
mesh.RecalculateNormals();
}
if (skirtDepth > 0f)
{
// Skirts: walls hanging from every edge of the surface down to skirtDepth below it, so the dunes
// join the solid sand bed underneath with no gap showing through the glass. Two-sided (separate vertices per side).
var skirtTris = new System.Collections.Generic.List();
var allV = new System.Collections.Generic.List(verts);
var allUV = new System.Collections.Generic.List(uvs);
void Edge(System.Func index, int count, Vector2 uvDown)
{
for (int side = 0; side < 2; side++)
{
int start = allV.Count;
for (int i = 0; i < count; i++)
{
var top = verts[index(i)];
allV.Add(top); allUV.Add(uvs[index(i)]);
allV.Add(new Vector3(top.x, -skirtDepth, top.z)); allUV.Add(uvs[index(i)] + uvDown * (top.y + skirtDepth)); // texture continues down the wall at the same grain size
}
for (int i = 0; i < count - 1; i++)
{
int a = start + i * 2, b = a + 1, cc = a + 2, d = a + 3;
if (side == 0) { skirtTris.Add(a); skirtTris.Add(b); skirtTris.Add(cc); skirtTris.Add(cc); skirtTris.Add(b); skirtTris.Add(d); }
else { skirtTris.Add(a); skirtTris.Add(cc); skirtTris.Add(b); skirtTris.Add(cc); skirtTris.Add(d); skirtTris.Add(b); }
}
}
}
Edge(i => i, w, new Vector2(0f, -1f / worldD)); // front
Edge(i => (h - 1) * w + i, w, new Vector2(0f, 1f / worldD)); // back
Edge(i => i * w, h, new Vector2(-1f / worldW, 0f)); // left
Edge(i => i * w + (w - 1), h, new Vector2(1f / worldW, 0f)); // right
// Walls are a second submesh: they get a sand material without the ripple normal map (ripples read as vertical ribs on a wall)
mesh.SetVertices(allV); mesh.SetUVs(0, allUV);
mesh.subMeshCount = 2;
mesh.SetTriangles(tris, 0); mesh.SetTriangles(skirtTris, 1);
mesh.RecalculateNormals();
}
mesh.RecalculateTangents();
mesh.RecalculateBounds();
return mesh;
}
}
///
/// From a cutout sprite's alpha: a pillow-profile height field, used to make
/// a normal map + edge AO map (flat card shading) and an inflated mesh
/// (real thickness). Editor-only.
///
public static class InflatedMaps
{
class Field { public int w, h; public Color[] px; public bool[] mask; public float[] height, dist; public float maxd; }
static Field Compute(string srcPath)
{
var imp = AssetImporter.GetAtPath(srcPath) as TextureImporter;
if (imp == null) return null;
if (!imp.isReadable || imp.textureCompression != TextureImporterCompression.Uncompressed)
{
imp.isReadable = true;
imp.textureCompression = TextureImporterCompression.Uncompressed;
imp.SaveAndReimport();
}
var tex = AssetDatabase.LoadAssetAtPath(srcPath);
var F = new Field { w = tex.width, h = tex.height, px = tex.GetPixels() };
int w = F.w, h = F.h;
F.mask = new bool[w * h];
var lum = new float[w * h];
for (int i = 0; i < F.px.Length; i++)
{
F.mask[i] = F.px[i].a > 0.5f;
lum[i] = F.px[i].r * 0.3f + F.px[i].g * 0.59f + F.px[i].b * 0.11f;
}
// Distance to edge (two-pass chamfer 3-4)
var dist = new float[w * h];
const float INF = 1e9f;
for (int i = 0; i < dist.Length; i++) dist[i] = F.mask[i] ? INF : 0f;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
int i = y * w + x; if (dist[i] == 0f) continue;
float d = dist[i];
if (x > 0) d = Mathf.Min(d, dist[i - 1] + 3);
if (y > 0) d = Mathf.Min(d, dist[i - w] + 3);
if (x > 0 && y > 0) d = Mathf.Min(d, dist[i - w - 1] + 4);
if (x < w - 1 && y > 0) d = Mathf.Min(d, dist[i - w + 1] + 4);
dist[i] = d;
}
for (int y = h - 1; y >= 0; y--)
for (int x = w - 1; x >= 0; x--)
{
int i = y * w + x; if (dist[i] == 0f) continue;
float d = dist[i];
if (x < w - 1) d = Mathf.Min(d, dist[i + 1] + 3);
if (y < h - 1) d = Mathf.Min(d, dist[i + w] + 3);
if (x < w - 1 && y < h - 1) d = Mathf.Min(d, dist[i + w + 1] + 4);
if (x > 0 && y < h - 1) d = Mathf.Min(d, dist[i + w - 1] + 4);
dist[i] = d;
}
F.maxd = 1f;
for (int i = 0; i < dist.Length; i++) { dist[i] /= 3f; if (dist[i] > F.maxd) F.maxd = dist[i]; }
F.dist = dist;
// Height: sqrt profile (rounded), smoothed, plus faint art detail (outlines as grooves)
var height = new float[w * h];
for (int i = 0; i < height.Length; i++) height[i] = F.mask[i] ? Mathf.Sqrt(Mathf.Clamp01(dist[i] / F.maxd)) : 0f;
height = Blur(height, w, h, 3); height = Blur(height, w, h, 3);
var lumS = Blur(lum, w, h, 1); var lumB = Blur(Blur(lum, w, h, 4), w, h, 4);
for (int i = 0; i < height.Length; i++) if (F.mask[i]) height[i] += 0.12f * (lumS[i] - lumB[i]);
// Taper to exactly zero over the last few pixels so the inflated front and back
// surfaces meet at the silhouette instead of leaving a seam.
for (int i = 0; i < height.Length; i++) height[i] = Mathf.Max(0f, height[i]) * Mathf.Clamp01(dist[i] / 8f);
F.height = height;
return F;
}
public static void Generate(string srcPath, string normalPath, string aoPath)
{
var F = Compute(srcPath); if (F == null) return;
int w = F.w, h = F.h; var height = F.height;
float strength = 6f * F.maxd / 40f;
var n = new Color[w * h];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
int i = y * w + x;
if (!F.mask[i]) { n[i] = new Color(0.5f, 0.5f, 1f, 1f); continue; }
float gx = (height[Mathf.Min(x + 1, w - 1) + y * w] - height[Mathf.Max(x - 1, 0) + y * w]) * 0.5f * strength;
float gy = (height[x + Mathf.Min(y + 1, h - 1) * w] - height[x + Mathf.Max(y - 1, 0) * w]) * 0.5f * strength;
var v = new Vector3(-gx, -gy, 1f).normalized;
n[i] = new Color(v.x * 0.5f + 0.5f, v.y * 0.5f + 0.5f, v.z * 0.5f + 0.5f, 1f);
}
var ao = new Color[w * h];
for (int i = 0; i < ao.Length; i++)
{
float a = 0.55f + 0.45f * Mathf.Sqrt(Mathf.Clamp01(F.dist[i] / (0.25f * F.maxd)));
ao[i] = new Color(a, a, a, F.px[i].a);
}
Save(n, w, h, normalPath);
Save(ao, w, h, aoPath);
AssetDatabase.Refresh();
}
///
/// Inflated mesh: front surface at -height*thickness, back at +height*thickness,
/// meeting at the outline. Unit square in X/Y (like Unity's Quad), Z in
/// units of height, so scale it (h*aspect, h, h).
///
public static Mesh BuildMesh(string srcPath, float thickness, int step)
{
var F = Compute(srcPath);
int w = F.w, h = F.h;
int gw = w / step + 2, gh = h / step + 2;
bool[] solid = new bool[gw * gh];
float[] gz = new float[gw * gh];
for (int gy = 0; gy < gh; gy++)
for (int gx = 0; gx < gw; gx++)
{
int cx = Mathf.Min(gx * step, w - 1), cy = Mathf.Min(gy * step, h - 1);
bool any = false; float hh = 0f;
for (int dy = -step; dy <= step && !any; dy++)
for (int dx = -step; dx <= step; dx++)
{
int x = cx + dx, y = cy + dy;
if (x < 0 || y < 0 || x >= w || y >= h) continue;
if (F.mask[y * w + x]) { any = true; break; }
}
if (any) hh = F.height[cy * w + cx];
solid[gy * gw + gx] = any;
gz[gy * gw + gx] = Mathf.Max(0f, hh) * thickness;
}
var verts = new System.Collections.Generic.List();
var uvs = new System.Collections.Generic.List();
var tris = new System.Collections.Generic.List();
int[] front = new int[gw * gh], back = new int[gw * gh];
for (int i = 0; i < front.Length; i++) { front[i] = -1; back[i] = -1; }
int V(int[] map, int gx, int gy, float zsign)
{
int k = gy * gw + gx;
if (map[k] >= 0) return map[k];
float u = Mathf.Min(gx * step, w) / (float)w, v = Mathf.Min(gy * step, h) / (float)h;
// Rim vertices sit on transparent texels; sample the nearest opaque texel
// instead so the closing ring is drawn (the outline colour wraps the edge).
int px = Mathf.Min(gx * step, w - 1), py = Mathf.Min(gy * step, h - 1);
if (!F.mask[py * w + px])
{
int best = int.MaxValue, bx = px, by = py;
for (int dy = -step * 2; dy <= step * 2; dy++)
for (int dx = -step * 2; dx <= step * 2; dx++)
{
int x = px + dx, y = py + dy;
if (x < 0 || y < 0 || x >= w || y >= h || !F.mask[y * w + x]) continue;
int d2 = dx * dx + dy * dy;
if (d2 < best) { best = d2; bx = x; by = y; }
}
// step 2 texels further inside so bilinear filtering stays fully opaque
int ix = bx + (bx > px ? 2 : bx < px ? -2 : 0), iy = by + (by > py ? 2 : by < py ? -2 : 0);
ix = Mathf.Clamp(ix, 0, w - 1); iy = Mathf.Clamp(iy, 0, h - 1);
if (F.mask[iy * w + ix]) { bx = ix; by = iy; }
u = (bx + 0.5f) / w; v = (by + 0.5f) / h;
}
verts.Add(new Vector3(Mathf.Min(gx * step, w) / (float)w - 0.5f, Mathf.Min(gy * step, h) / (float)h - 0.5f, zsign * gz[k]));
uvs.Add(new Vector2(u, v));
map[k] = verts.Count - 1;
return map[k];
}
for (int gy = 0; gy < gh - 1; gy++)
for (int gx = 0; gx < gw - 1; gx++)
{
if (!(solid[gy * gw + gx] && solid[gy * gw + gx + 1] && solid[(gy + 1) * gw + gx] && solid[(gy + 1) * gw + gx + 1])) continue;
int a = V(front, gx, gy, -1f), b = V(front, gx + 1, gy, -1f), c = V(front, gx, gy + 1, -1f), d = V(front, gx + 1, gy + 1, -1f);
tris.Add(a); tris.Add(c); tris.Add(b); tris.Add(b); tris.Add(c); tris.Add(d);
int a2 = V(back, gx, gy, 1f), b2 = V(back, gx + 1, gy, 1f), c2 = V(back, gx, gy + 1, 1f), d2 = V(back, gx + 1, gy + 1, 1f);
tris.Add(a2); tris.Add(b2); tris.Add(c2); tris.Add(b2); tris.Add(d2); tris.Add(c2);
}
var mesh = new Mesh { name = "Inflated", indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 };
mesh.SetVertices(verts); mesh.SetUVs(0, uvs); mesh.SetTriangles(tris, 0);
mesh.RecalculateNormals();
// Front surface must face -Z (toward the default camera); flip winding if not
var nrm = mesh.normals; float sumZ = 0f;
for (int i = 0; i < front.Length; i++) if (front[i] >= 0) sumZ += nrm[front[i]].z;
if (sumZ > 0f)
{
for (int i = 0; i < tris.Count; i += 3) { int t = tris[i + 1]; tris[i + 1] = tris[i + 2]; tris[i + 2] = t; }
mesh.SetTriangles(tris, 0); mesh.RecalculateNormals();
}
mesh.RecalculateTangents();
mesh.RecalculateBounds();
return mesh;
}
static float[] Blur(float[] src, int w, int h, int r)
{
var tmp = new float[w * h]; var dst = new float[w * h];
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
float s = 0; int c = 0;
for (int k = -r; k <= r; k++) { int xx = x + k; if (xx >= 0 && xx < w) { s += src[y * w + xx]; c++; } }
tmp[y * w + x] = s / c;
}
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
float s = 0; int c = 0;
for (int k = -r; k <= r; k++) { int yy = y + k; if (yy >= 0 && yy < h) { s += tmp[yy * w + x]; c++; } }
dst[y * w + x] = s / c;
}
return dst;
}
static void Save(Color[] c, int w, int h, string path)
{
var t = new Texture2D(w, h, TextureFormat.RGBA32, false);
t.SetPixels(c); t.Apply();
File.WriteAllBytes(path, t.EncodeToPNG());
Object.DestroyImmediate(t);
}
}