diff --git a/Assets/Scenes/Game.unity b/Assets/Scenes/Game.unity index 706f90a1a..fbf25bdbf 100644 --- a/Assets/Scenes/Game.unity +++ b/Assets/Scenes/Game.unity @@ -40011,6 +40011,68 @@ Transform: m_CorrespondingSourceObject: {fileID: 6078297801174817557, guid: 66d4f7c006b77084da3929add6d06f24, type: 3} m_PrefabInstance: {fileID: 1002930408} m_PrefabAsset: {fileID: 0} +--- !u!1 &1007206781 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1007206783} + - component: {fileID: 1007206782} + m_Layer: 0 + m_Name: AmbientMotion + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1007206782 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1007206781} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: c5eaa0f5b116ec34695d5e4646784462, type: 3} + m_Name: + m_EditorClassIdentifier: ProjectM.Client::ProjectM.Client.AmbientMotionConfig + Enabled: 1 + DriftEnabled: 1 + DriftRate: 28 + DriftSize: 0.09 + MeadowMoteColor: {r: 0.62, g: 0.85, b: 0.6, a: 0.35} + AridDustColor: {r: 0.85, g: 0.68, b: 0.45, a: 0.28} + CavernMoteColor: {r: 0.55, g: 0.65, b: 0.95, a: 0.33} + BlightSporeColor: {r: 0.72, g: 0.5, b: 0.85, a: 0.33} + AridWind: {x: 2.2, y: -0.15, z: 0.5} + SwayEnabled: 1 + SwayRadius: 30 + SwayAmpDeg: 1.4 + SwayHz: 0.8 + RustleEnabled: 1 + RustleRadius: 1.7 + RustleAmpDeg: 7 + RustleHz: 9 + RustleDecay: 2.5 +--- !u!4 &1007206783 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1007206781} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1001 &1010024963 PrefabInstance: m_ObjectHideFlags: 0 @@ -77186,6 +77248,7 @@ GameObject: m_Component: - component: {fileID: 2045829909} - component: {fileID: 2045829910} + - component: {fileID: 2045829911} m_Layer: 0 m_Name: CabinLight m_TagString: Untagged @@ -77273,6 +77336,35 @@ Light: m_LightUnit: 1 m_LuxAtDistance: 1 m_EnableSpotReflector: 1 +--- !u!114 &2045829911 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2045829908} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 474bcb49853aa07438625e644c072ee6, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalAdditionalLightData + m_UsePipelineSettings: 1 + m_AdditionalLightsShadowResolutionTier: 2 + m_CustomShadowLayers: 0 + m_LightCookieSize: {x: 1, y: 1} + m_LightCookieOffset: {x: 0, y: 0} + m_SoftShadowQuality: 0 + m_RenderingLayersMask: + serializedVersion: 0 + m_Bits: 1 + m_ShadowRenderingLayersMask: + serializedVersion: 0 + m_Bits: 1 + m_Version: 4 + m_LightLayerMask: 1 + m_ShadowLayerMask: 1 + m_RenderingLayers: 1 + m_ShadowRenderingLayers: 1 --- !u!1001 &2047680188 PrefabInstance: m_ObjectHideFlags: 0 @@ -81293,3 +81385,4 @@ SceneRoots: - {fileID: 1851179016} - {fileID: 70737666} - {fileID: 1276835916} + - {fileID: 1007206783} diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientMotionConfig.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionConfig.cs new file mode 100644 index 000000000..5073895c9 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionConfig.cs @@ -0,0 +1,48 @@ +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Live-tunable knobs for — the Phase 1.5 "world alive" ambient layer + /// (per-biome drift particles + idle foliage sway + walk-through rustle). Mirrors the + /// / bridge idiom: a MonoBehaviour in + /// the gameplay scene with a static the client presentation system reads (falling + /// back to these same defaults when absent). Pure presentation — never touches the simulation. + /// + public sealed class AmbientMotionConfig : MonoBehaviour + { + public static AmbientMotionConfig Instance; + + [Header("Master")] + public bool Enabled = true; + + [Header("Biome drift particles (emitter follows the camera; sim space stays world)")] + public bool DriftEnabled = true; + [Min(0f)] public float DriftRate = 28f; + [Min(0.01f)] public float DriftSize = 0.09f; + public Color MeadowMoteColor = new Color(0.62f, 0.85f, 0.60f, 0.35f); + public Color AridDustColor = new Color(0.85f, 0.68f, 0.45f, 0.28f); + public Color CavernMoteColor = new Color(0.55f, 0.65f, 0.95f, 0.33f); + public Color BlightSporeColor = new Color(0.72f, 0.50f, 0.85f, 0.33f); + public Vector3 AridWind = new Vector3(2.2f, -0.15f, 0.5f); + + [Header("Idle foliage sway (flora prop roots within view of the camera)")] + public bool SwayEnabled = true; + [Min(1f)] public float SwayRadius = 30f; + [Min(0f)] public float SwayAmpDeg = 1.4f; + [Min(0.05f)] public float SwayHz = 0.8f; + + [Header("Walk-through rustle (local player brushing flora)")] + public bool RustleEnabled = true; + [Min(0.2f)] public float RustleRadius = 1.7f; + [Min(0f)] public float RustleAmpDeg = 7f; + [Min(0.5f)] public float RustleHz = 9f; + [Min(0.2f)] public float RustleDecay = 2.5f; + + void OnEnable() { Instance = this; } + void OnDisable() { if (Instance == this) Instance = null; } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetStatics() { Instance = null; } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientMotionConfig.cs.meta b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionConfig.cs.meta new file mode 100644 index 000000000..39f99d28c --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c5eaa0f5b116ec34695d5e4646784462 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs new file mode 100644 index 000000000..d727d47ef --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs @@ -0,0 +1,254 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; +using Unity.Transforms; +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Client-only AMBIENT MOTION layer (Phase 1.5 "world alive" — the anti-static beat, zero netcode): + /// - per-biome DRIFT particles (meadow motes / arid wind-blown dust / cavern motes / blight spores) from + /// one world-space emitter that follows the camera; palette keys on camera-X region + the replicated + /// per-room biome, mirroring 's switch + /// - idle SWAY on the cosmetic flora prop roots near the camera (Game.unity classic layer; probed + /// 2026-07-09: 212 flora roots, none static-batched, LOD children follow the root) + /// - walk-through RUSTLE: flora brushed by the LOCAL player (the GhostOwnerIsLocal idiom) gets an + /// energy pulse that shakes and decays + /// Observe-only in ; wall-clock time is fine + /// (presentation). Rotations write around a cached base so the layer is lossless when toggled off. + /// Knobs live in (defaults when absent). + /// + [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] + [UpdateInGroup(typeof(PresentationSystemGroup))] + public partial class AmbientMotionSystem : SystemBase + { + static readonly System.Text.RegularExpressions.Regex FloraName = new System.Text.RegularExpressions.Regex( + "bush|grass|flower|fern|groundcover|ground_cover|wildflower|clover|reed|succulent|cactus", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + struct Flora + { + public Transform T; + public Vector3 Pos; + public Quaternion BaseRot; + public float Phase; + public float RustleEnergy; + } + + readonly List _flora = new List(256); + bool _floraCached; + string _sceneKey; + Camera _cam; + GameObject _root; + ParticleSystem _drift; + int _biomeKey = -1; + + protected override void OnDestroy() + { + RestoreFlora(); + if (_root != null) Object.Destroy(_root); + } + + protected override void OnUpdate() + { + var scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); + if (scene.name != "Game") { RestoreFlora(); _floraCached = false; return; } + if (_cam == null) _cam = Camera.main; + if (_cam == null) return; + + var cfg = AmbientMotionConfig.Instance; + bool enabled = cfg == null || cfg.Enabled; + if (!enabled) + { + RestoreFlora(); + if (_drift != null && _drift.isPlaying) _drift.Stop(); + return; + } + + if (!_floraCached || _sceneKey != scene.path) CacheFlora(scene); + + UpdateDrift(cfg); + UpdateSwayAndRustle(cfg); + } + + // ---------- flora cache ---------- + + void CacheFlora(UnityEngine.SceneManagement.Scene scene) + { + RestoreFlora(); + _flora.Clear(); + foreach (var root in scene.GetRootGameObjects()) + { + if (root.name != "BaseBiome" && !root.name.StartsWith("ExpeditionBiome")) continue; + foreach (var t in root.GetComponentsInChildren(false)) + { + if (!FloraName.IsMatch(t.gameObject.name)) continue; + if (t.gameObject.name.Contains("LOD")) continue; // prop roots only; LOD children follow + if (t.GetComponent() == null && t.GetComponent() == null) continue; + var p = t.position; + _flora.Add(new Flora + { + T = t, + Pos = p, + BaseRot = t.localRotation, + Phase = (p.x * 12.9898f + p.z * 78.233f) % 6.2831f, + RustleEnergy = 0f, + }); + } + } + _floraCached = true; + _sceneKey = scene.path; + } + + void RestoreFlora() + { + for (int i = 0; i < _flora.Count; i++) + if (_flora[i].T != null) _flora[i].T.localRotation = _flora[i].BaseRot; + } + + // ---------- biome drift particles ---------- + + void UpdateDrift(AmbientMotionConfig cfg) + { + bool driftOn = cfg == null || cfg.DriftEnabled; + if (!driftOn) + { + if (_drift != null && _drift.isPlaying) _drift.Stop(); + return; + } + if (_root == null) + { + _root = new GameObject("~AmbientMotion"); + Object.DontDestroyOnLoad(_root); + } + if (_drift == null) + { + var mat = FeedbackFx.MakeParticleMaterial("AmbientDrift"); + _drift = FeedbackFx.MakeBurst(_root.transform, "Drift", mat, Color.white, + 0.09f, 0.15f, 9f, 600, gravity: 0f, shapeRadius: 1f, sizeTail: 0.9f); + var main = _drift.main; + main.simulationSpace = ParticleSystemSimulationSpace.World; + main.startLifetime = 9f; + var shape = _drift.shape; + shape.enabled = true; + shape.shapeType = ParticleSystemShapeType.Box; + shape.scale = new Vector3(36f, 7f, 24f); + var noise = _drift.noise; + noise.enabled = true; + noise.strength = 0.45f; + noise.frequency = 0.25f; + var emission = _drift.emission; + emission.enabled = true; + } + + // palette key: base hub vs the replicated per-room biome (WorldAtmosphereSystem's switch shape) + int key = 0; // 0 = meadow/base + if (_cam.transform.position.x > 500f) + { + key = 1; // arid default + if (SystemAPI.TryGetSingleton(out var ri) && ri.Lifecycle != RunLifecycle.Staging) + { + switch (ri.CurrentBiome) + { + case RoomBiomeId.Meadow: key = 0; break; + case RoomBiomeId.Cavern: key = 2; break; + case RoomBiomeId.Blight: key = 3; break; + } + } + } + if (key != _biomeKey) + { + _biomeKey = key; + var main = _drift.main; + main.startSize = cfg != null ? cfg.DriftSize : 0.09f; + Color c; + Vector3 wind; + switch (key) + { + case 1: c = cfg != null ? cfg.AridDustColor : new Color(0.85f, 0.68f, 0.45f, 0.28f); wind = cfg != null ? cfg.AridWind : new Vector3(2.2f, -0.15f, 0.5f); break; + case 2: c = cfg != null ? cfg.CavernMoteColor : new Color(0.55f, 0.65f, 0.95f, 0.33f); wind = new Vector3(0f, 0.18f, 0f); break; + case 3: c = cfg != null ? cfg.BlightSporeColor : new Color(0.72f, 0.50f, 0.85f, 0.33f); wind = new Vector3(0f, 0.28f, 0f); break; + default: c = cfg != null ? cfg.MeadowMoteColor : new Color(0.62f, 0.85f, 0.60f, 0.35f); wind = new Vector3(0f, 0.22f, 0f); break; + } + main.startColor = c; + var vel = _drift.velocityOverLifetime; + vel.enabled = true; + vel.space = ParticleSystemSimulationSpace.World; + vel.x = wind.x; vel.y = wind.y; vel.z = wind.z; + var emission = _drift.emission; + emission.rateOverTime = cfg != null ? cfg.DriftRate : 28f; + } + if (!_drift.isPlaying) _drift.Play(); + // follow the camera's ground focus so the volume always blankets the view + var camPos = _cam.transform.position; + _drift.transform.position = new Vector3(camPos.x, 3.5f, camPos.z); + } + + // ---------- sway + rustle ---------- + + void UpdateSwayAndRustle(AmbientMotionConfig cfg) + { + bool swayOn = cfg == null || cfg.SwayEnabled; + bool rustleOn = cfg == null || cfg.RustleEnabled; + if (!swayOn && !rustleOn) { RestoreFlora(); return; } + + float swayRadius = cfg != null ? cfg.SwayRadius : 30f; + float swayAmp = cfg != null ? cfg.SwayAmpDeg : 1.4f; + float swayHz = cfg != null ? cfg.SwayHz : 0.8f; + float rustleRadius = cfg != null ? cfg.RustleRadius : 1.7f; + float rustleAmp = cfg != null ? cfg.RustleAmpDeg : 7f; + float rustleHz = cfg != null ? cfg.RustleHz : 9f; + float rustleDecay = cfg != null ? cfg.RustleDecay : 2.5f; + + // local player position (rustle source); missing (menu/no ghost yet) -> far away + Vector3 playerPos = new Vector3(1e9f, 0f, 1e9f); + foreach (var lt in SystemAPI.Query>().WithAll()) + playerPos = new Vector3(lt.ValueRO.Position.x, 0f, lt.ValueRO.Position.z); + + float t = UnityEngine.Time.time; + float dt = UnityEngine.Time.deltaTime; + var camPos = _cam.transform.position; + float swayR2 = swayRadius * swayRadius; + float rustleR2 = rustleRadius * rustleRadius; + + for (int i = 0; i < _flora.Count; i++) + { + var f = _flora[i]; + if (f.T == null) continue; + + float dxc = f.Pos.x - camPos.x, dzc = f.Pos.z - camPos.z; + bool nearCam = dxc * dxc + dzc * dzc < swayR2; + + if (rustleOn) + { + float dxp = f.Pos.x - playerPos.x, dzp = f.Pos.z - playerPos.z; + if (dxp * dxp + dzp * dzp < rustleR2) f.RustleEnergy = 1f; + else if (f.RustleEnergy > 0f) f.RustleEnergy = Mathf.Max(0f, f.RustleEnergy - rustleDecay * dt); + } + + if (!nearCam && f.RustleEnergy <= 0f) + { + _flora[i] = f; + continue; + } + + float ax = 0f, az = 0f; + if (swayOn && nearCam) + { + ax += swayAmp * Mathf.Sin(t * swayHz * 6.2831f + f.Phase); + az += swayAmp * 0.8f * Mathf.Sin(t * swayHz * 5.5f + f.Phase * 1.7f); + } + if (f.RustleEnergy > 0f) + { + float e = f.RustleEnergy; + ax += rustleAmp * e * Mathf.Sin(t * rustleHz * 6.2831f + f.Phase); + az += rustleAmp * 0.7f * e * Mathf.Sin(t * rustleHz * 5.1f + f.Phase * 2.3f); + } + f.T.localRotation = f.BaseRot * Quaternion.Euler(ax, 0f, az); + _flora[i] = f; + } + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs.meta new file mode 100644 index 000000000..1009a0bc2 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientMotionSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a4a8e8f6d3c4d95428cf227b39f5fc30 \ No newline at end of file diff --git a/Assets/_Project/Shaders/PixelOutline.mat b/Assets/_Project/Shaders/PixelOutline.mat index ce0565c9f..8e4427809 100644 --- a/Assets/_Project/Shaders/PixelOutline.mat +++ b/Assets/_Project/Shaders/PixelOutline.mat @@ -28,16 +28,16 @@ Material: - _Brightness: 1.1 - _ColorLevels: 12 - _Contrast: 1.05 - - _DepthEdgesEnabled: 0 + - _DepthEdgesEnabled: 1 - _DepthThreshold: 0.08 - _EdgeStrength: 1 - - _MasterEnabled: 1 - - _NormalEdgesEnabled: 0 + - _MasterEnabled: 0 + - _NormalEdgesEnabled: 1 - _NormalThreshold: 1 - _OutlineThickness: 1 - - _PixelHeight: 200 - - _PixelateEnabled: 0 - - _PosterizeEnabled: 0 + - _PixelHeight: 399.82455 + - _PixelateEnabled: 1 + - _PosterizeEnabled: 1 - _Saturation: 1.35 m_Colors: - _OutlineColorInner: {r: 0, g: 0, b: 0, a: 1}