diff --git a/Assets/Scripts/Editor/ImpactPresenterSmoke.cs b/Assets/Scripts/Editor/ImpactPresenterSmoke.cs new file mode 100644 index 000000000..42dc9d56d --- /dev/null +++ b/Assets/Scripts/Editor/ImpactPresenterSmoke.cs @@ -0,0 +1,134 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using UnityEditor; +using UnityEngine; +using static CultMath.math; + +// Batch proof of ImpactPresenter's wiring, without a scene: a zone ship is bound to an EntityInstance through the game's +// own SetEntity, and impacts are published on the ship's Capabilities exactly as FireControl does. A hull absorb spawns +// one hull effect under the instance and, for the player's ship at ScreenShake 1, raises one camera impulse; ScreenShake 0 +// or another ship raises none; a shield absorb spawns no hull effect. The shield ripple itself (ShieldManager.ShowHit +// needs a shield material and a rig) is looked at in the editor, not here. +// +// Unity -batchmode -nographics -projectPath -executeMethod ImpactPresenterSmoke.Run -logFile +// +// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure. +public static class ImpactPresenterSmoke +{ + private const BindingFlags Any = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + + public static void Run() + { + var work = Path.Combine(Path.GetTempPath(), $"impact-presenter-smoke-{Guid.NewGuid():N}"); + GameObject[] objects = null; + try + { + Directory.CreateDirectory(work); + objects = Check(work); + Clean(objects, work); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + Clean(objects, work); + EditorApplication.Exit(1); + } + } + + // Edit mode runs no Awake or Start; the smoke runs the ones the game's scene would have. + private static void Awaken(Component component) => component.GetType().GetMethod("Awake", Any)?.Invoke(component, null); + + private static GameObject[] Check(string work) + { + var settings = AssetDatabase.LoadAssetAtPath("Assets/Resources/Settings.asset")?.GameplaySettings + ?? throw new InvalidOperationException("No Settings.asset."); + + var shape = new Shape(5, 4); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var hullData = new HullData + { + Name = "Smoke Hull", HullType = HullType.Ship, Shape = shape, Durability = 1000, Mass = 1000, Armor = 0 + }; + var cache = AetheriaStores.Open(Path.Combine(work, "smoke.cc"), catalogWritable: true); + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(hullData); + cache.FlushAsync().Wait(); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Smoke", Owner = null }, null); + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Smoke Hull")); + var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); + var other = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 2 }, new EntitySettings()); + zone.Entities.Add(ship); + zone.Entities.Add(other); + ship.Activate(); + other.Activate(); + + // The scene's side: the tuning object with a pooled effect, and the game manager that names the player's ship. + var feedbackObject = new GameObject("Smoke Impact Feedback"); + var feedback = feedbackObject.AddComponent(); + Awaken(feedback); + var effectObject = new GameObject("Smoke Hull Effect"); + feedback.HullEffect = effectObject.AddComponent(); + var managerObject = new GameObject("Smoke Game Manager"); + var manager = managerObject.AddComponent(); + typeof(ActionGameManager).GetProperty("Instance", Any).SetValue(null, manager); + manager.CurrentEntity = ship; + + var instanceObject = new GameObject("Smoke Entity Instance"); + var instance = instanceObject.AddComponent(); + Awaken(instance); + instance.HullColliders = new HullCollider[0]; + instance.RadiatorHardpoints = new RadiatorHardpoint[0]; + instance.ThrusterHardpoints = new ThrusterHardpoint[0]; + instance.WeaponHardpoints = new WeaponHardpoint[0]; + instance.ArticulationPoints = new ArticulationPoint[0]; + instance.SetEntity(null, ship); + + int Effects() => instance.GetComponentsInChildren(true).Count(p => p != null && !p.isOriginalPrototype); + void Expect(string what, int effects, int kicks) + { + if (Effects() != effects || feedback.KicksRaised != kicks) + throw new InvalidOperationException($"{what}: {Effects()} hull effects (want {effects}), {feedback.KicksRaised} impulses (want {kicks})."); + } + AbsorbEvent Hit(AbsorbLayer layer) => new AbsorbEvent(float3(1f, 0f, 2f), float3(0f, 0f, 1f), 100f, DamageType.Kinetic, layer); + + ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake = 1f; + ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Hull)); + Expect("player's hull hit", 1, 1); + var effect = instance.GetComponentsInChildren(true).Single(p => !p.isOriginalPrototype); + var drawn = DrawAhead.Carried(ship, float3(1f, 0f, 2f), ActionGameManager.Clock.Lead); + if (abs(effect.transform.position.x - drawn.x) > 1e-3f || abs(effect.transform.position.z - drawn.z) > 1e-3f) + throw new InvalidOperationException("The hull effect is not at the drawn impact point."); + + ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake = 0f; + ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Hull)); + Expect("hull hit at ScreenShake 0", 2, 1); + + ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake = 1f; + manager.CurrentEntity = other; + ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Hull)); + Expect("hull hit on another ship", 3, 1); + + manager.CurrentEntity = ship; + ship.Capabilities.PublishAbsorb(Hit(AbsorbLayer.Shield)); + Expect("player's shield hit", 3, 2); + + Debug.Log("IMPACT_PRESENTER_SMOKE ok effects=3 impulses=2"); + return new[] { feedbackObject, effectObject, managerObject, instanceObject }; + } + + private static void Clean(GameObject[] objects, string work) + { + if (objects != null) + foreach (var go in objects) + if (go != null) UnityEngine.Object.DestroyImmediate(go); + try { if (Directory.Exists(work)) Directory.Delete(work, true); } + catch (Exception error) { Debug.LogWarning($"Impact presenter smoke left {work}: {error.Message}"); } + } +} diff --git a/Assets/Scripts/Editor/ImpactPresenterSmoke.cs.meta b/Assets/Scripts/Editor/ImpactPresenterSmoke.cs.meta new file mode 100644 index 000000000..05df104cd --- /dev/null +++ b/Assets/Scripts/Editor/ImpactPresenterSmoke.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b1b27e4493b2cfe48805c786a5090cd3 \ No newline at end of file diff --git a/Assets/Scripts/Editor/WireImpactFeedback.cs b/Assets/Scripts/Editor/WireImpactFeedback.cs new file mode 100644 index 000000000..650db6981 --- /dev/null +++ b/Assets/Scripts/Editor/WireImpactFeedback.cs @@ -0,0 +1,61 @@ +using System; +using Cinemachine; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +// Adds the impact-feedback tuning object to ARPG.unity and the impulse listener to its follow camera, then saves the +// scene. Idempotent: a scene that already has them is left as it is. +// +// Unity -batchmode -projectPath -executeMethod WireImpactFeedback.Run -logFile +// +// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure. The menu item runs the same wiring. +public static class WireImpactFeedback +{ + private const string ScenePath = "Assets/Scenes/ARPG.unity"; + // The weapons' own hit effect (the nested prefab AC5's Projectile uses as HitEffect): no new art. + private const string HullEffectPath = "Assets/Prefabs/Fire & Explosion Effects/Prefabs/TinyExplosion.prefab"; + + [MenuItem("Aetheria/Wire Impact Feedback")] + public static void Menu() => Wire(); + + public static void Run() + { + try + { + Wire(); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + EditorApplication.Exit(1); + } + } + + private static void Wire() + { + var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single); + var manager = UnityEngine.Object.FindFirstObjectByType(); + if (manager == null || manager.FollowCamera == null) throw new InvalidOperationException("ARPG.unity has no ActionGameManager with a FollowCamera."); + + if (manager.FollowCamera.GetComponent() == null) + manager.FollowCamera.gameObject.AddComponent(); + + if (UnityEngine.Object.FindFirstObjectByType() == null) + { + var prefab = AssetDatabase.LoadAssetAtPath(HullEffectPath); + if (prefab == null) throw new InvalidOperationException($"No prefab at {HullEffectPath}."); + var root = new GameObject("Impact Feedback"); + var feedback = root.AddComponent(); + var effect = (GameObject) PrefabUtility.InstantiatePrefab(prefab, root.transform); + feedback.HullEffect = effect.GetComponent(); + if (feedback.HullEffect == null) throw new InvalidOperationException("The hull effect prefab has no Prototype."); + EditorUtility.SetDirty(feedback); + } + + EditorSceneManager.MarkSceneDirty(scene); + if (!EditorSceneManager.SaveScene(scene)) throw new InvalidOperationException("The scene did not save."); + Debug.Log("WIRE_IMPACT_FEEDBACK ok"); + } +} diff --git a/Assets/Scripts/Editor/WireImpactFeedback.cs.meta b/Assets/Scripts/Editor/WireImpactFeedback.cs.meta new file mode 100644 index 000000000..5d106d375 --- /dev/null +++ b/Assets/Scripts/Editor/WireImpactFeedback.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9083abd01a947a847847d2f6fc177153 \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 53e5dbc71..56617ab5a 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -167,6 +167,13 @@ public class EntityInstance : MonoBehaviour if(Shield) Shield.Entity = entity; + + // Landed impacts reach the screen through the entity's one binding: the shield ripple (ShieldManager) and the + // hull reaction and camera kick (ImpactPresenter) subscribe to the Absorb stream the simulation publishes. + var impacts = gameObject.GetComponent() ?? gameObject.AddComponent(); + impacts.Entity = entity; + (gameObject.GetComponent() ?? gameObject.AddComponent()) + .Bind(entity.Capabilities); foreach (var hullCollider in HullColliders) hullCollider.Entity = entity; foreach (var item in entity.Equipment) diff --git a/Assets/Scripts/Gameplay/ImpactFeedback.cs b/Assets/Scripts/Gameplay/ImpactFeedback.cs new file mode 100644 index 000000000..6c1f43df1 --- /dev/null +++ b/Assets/Scripts/Gameplay/ImpactFeedback.cs @@ -0,0 +1,39 @@ +using Cinemachine; +using UnityEngine; + +// Presentation tuning for landed impacts (ImpactPresenter reads it, nothing in the simulation does): the hull +// reaction's effect prefab and size, how strongly a hit's share of the hull reads (Feedback.Cue's intensityScale), +// and the camera impulse. A scene component, so the scene is where it is tuned; absent in menus and test scenes, +// where ImpactPresenter does nothing. +public class ImpactFeedback : MonoBehaviour +{ + public static ImpactFeedback Instance { get; private set; } + + // A scene instance of a pooled hit effect (Prototype); a hit spawns one under the struck EntityInstance. + public Prototype HullEffect; + + // Multiplies a hit's share of the receiver's max durability before it saturates to 0..1 (Feedback.Cue). + public float IntensityScale = 10f; + + // The effect's scale never drops below this, so the lightest hit still reads. + public float MinEffectScale = .3f; + + public CinemachineImpulseDefinition Impulse = new CinemachineImpulseDefinition(); + + // Impulses raised since the scene loaded; the batchmode smoke reads it. + public int KicksRaised { get; private set; } + + private void Awake() => Instance = this; + + private void OnDestroy() + { + if (Instance == this) Instance = null; + } + + // Raises one camera impulse of the given force, travelling along `direction` from `position`. + public void Kick(Vector3 position, Vector3 direction, float force) + { + KicksRaised++; + Impulse.CreateEvent(position, direction.normalized * force); + } +} diff --git a/Assets/Scripts/Gameplay/ImpactFeedback.cs.meta b/Assets/Scripts/Gameplay/ImpactFeedback.cs.meta new file mode 100644 index 000000000..1d5613e95 --- /dev/null +++ b/Assets/Scripts/Gameplay/ImpactFeedback.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bfdc38a81d00b0347bb55ad6469535ff \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/ImpactPresenter.cs b/Assets/Scripts/Gameplay/ImpactPresenter.cs new file mode 100644 index 000000000..f8c68c6db --- /dev/null +++ b/Assets/Scripts/Gameplay/ImpactPresenter.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using CultMath.UnityBridge; +using UnityEngine; + +// How one entity's landed impacts look and feel: the hull reaction at the drawn impact point, and the player's camera +// kick. The simulation publishes each impact once on the entity's Capabilities (FireControl); Feedback.Cue decides how +// hard it reads and whether the camera feels it, DrawAhead.Carried decides where it is drawn; this decides nothing. +// The shield ripple is ShieldManager's. Everything here is display-only and runs in real time. +public class ImpactPresenter : MonoBehaviour, IAbsorbPresenter +{ + public Entity Entity; + + private readonly List _live = new List(); + + public void Absorb(AbsorbEvent e) + { + var feedback = ImpactFeedback.Instance; + if (feedback == null || Entity == null) return; + + var manager = ActionGameManager.Instance; + var cue = Feedback.Cue(e, Entity.EquippedHull.Data.Durability, + manager != null && Entity == manager.CurrentEntity, + ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake, feedback.IntensityScale); + var point = DrawAhead.Carried(Entity, cue.Position, ActionGameManager.Clock.Lead).ToUnity(); + + if (e.Layer == AbsorbLayer.Hull && feedback.HullEffect != null) + { + _live.RemoveAll(live => live == null || !live.gameObject.activeSelf); + var effect = feedback.HullEffect.Instantiate(); + var size = Mathf.Max(feedback.MinEffectScale, cue.Intensity); + effect.transform.SetParent(transform, false); + effect.transform.position = point; + effect.transform.localScale = Vector3.one * (size / Mathf.Max(transform.lossyScale.x, 1e-3f)); + _live.Add(effect); + } + + if (cue.Shake > 0f) + feedback.Kick(point, new Vector3(cue.Direction.x, 0f, cue.Direction.z), cue.Shake); + } + + // A live effect is a pooled child of this instance; hand it back before the instance takes it down with it. + private void OnDestroy() + { + foreach (var live in _live) + if (live != null && live.gameObject.activeSelf) live.ReturnToPool(); + } +} diff --git a/Assets/Scripts/Gameplay/ImpactPresenter.cs.meta b/Assets/Scripts/Gameplay/ImpactPresenter.cs.meta new file mode 100644 index 000000000..4c69429bc --- /dev/null +++ b/Assets/Scripts/Gameplay/ImpactPresenter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 775c61f785344d54b9f41ad3328cecee \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/ShieldManager.cs b/Assets/Scripts/Gameplay/ShieldManager.cs index 46d0b36a5..7e380c17b 100644 --- a/Assets/Scripts/Gameplay/ShieldManager.cs +++ b/Assets/Scripts/Gameplay/ShieldManager.cs @@ -8,7 +8,7 @@ using static CultMath.math; using UniRx; using UnityEngine; -public class ShieldManager : MonoBehaviour +public class ShieldManager : MonoBehaviour, IAbsorbPresenter { public Prototype ShieldPrototype; public float CollisionHitDuration = 3; @@ -86,6 +86,14 @@ public class ShieldManager : MonoBehaviour } } + // A shot the shield took ripples it where the shot landed, drawn on the hull as it is drawn. The hull layer is + // ImpactPresenter's. + public void Absorb(AbsorbEvent e) + { + if (e.Layer != AbsorbLayer.Shield || Entity == null || Entity.Shield == null || !Entity.Shield.Item.Active.Value) return; + ShowHit(DrawAhead.Carried(Entity, e.Position, ActionGameManager.Clock.Lead).ToUnity(), CollisionHitDuration); + } + public void ShowHit(Vector3 point, float duration) { var shield = ShieldPrototype.Instantiate(); diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index 7c2666420..bd019ab21 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -299,9 +299,14 @@ public class MainMenu : MonoBehaviour _nextMenu.panel.AddField("Show Asteroids in Minimap", () => ActionGameManager.PlayerSettings.GraphicsSettings.ShowAsteroidsInMinimap, b => ActionGameManager.PlayerSettings.GraphicsSettings.ShowAsteroidsInMinimap = b); + _nextMenu.panel.AddField("Screen Shake", + () => ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake, + f => ActionGameManager.PlayerSettings.GraphicsSettings.ScreenShake = f, + 0, 1); _nextMenu.panel.AddButton("Back", () => { + ActionGameManager.SavePlayerSettings(); ShowSettings(); Fade(false); });