Present landed impacts: shield ripple, hull reaction and camera kick through one ImpactPresenter

ImpactPresenter (bound on every EntityInstance by SetEntity through the one CapabilityPresentationBinder) renders
feedback-1's absorbs: Feedback.Cue decides intensity and shake, DrawAhead.Carried the drawn point; a hull absorb
spawns ImpactFeedback's pooled effect under the instance, and a cue with shake raises a Cinemachine impulse.
ShieldManager ripples on a Shield-layer absorb. MainMenu gains a Screen Shake slider that persists on Back.
Editor: WireImpactFeedback (scene wiring, scene diff in the next commit) and ImpactPresenterSmoke.
Unity batchmode compile 0 error CS; ImpactPresenterSmoke exit 0. The scene is not yet wired in this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 21:10:04 +02:00
commit 97d94ecfae
11 changed files with 310 additions and 1 deletions

View file

@ -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 <project> -executeMethod ImpactPresenterSmoke.Run -logFile <log>
//
// 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<GameSettings>("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<ItemData>(items.ItemData.GetByName<HullData>("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<ImpactFeedback>();
Awaken(feedback);
var effectObject = new GameObject("Smoke Hull Effect");
feedback.HullEffect = effectObject.AddComponent<Prototype>();
var managerObject = new GameObject("Smoke Game Manager");
var manager = managerObject.AddComponent<ActionGameManager>();
typeof(ActionGameManager).GetProperty("Instance", Any).SetValue(null, manager);
manager.CurrentEntity = ship;
var instanceObject = new GameObject("Smoke Entity Instance");
var instance = instanceObject.AddComponent<EntityInstance>();
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<Prototype>(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<Prototype>(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}"); }
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b1b27e4493b2cfe48805c786a5090cd3

View file

@ -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 <project> -executeMethod WireImpactFeedback.Run -logFile <log>
//
// 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<ActionGameManager>();
if (manager == null || manager.FollowCamera == null) throw new InvalidOperationException("ARPG.unity has no ActionGameManager with a FollowCamera.");
if (manager.FollowCamera.GetComponent<CinemachineImpulseListener>() == null)
manager.FollowCamera.gameObject.AddComponent<CinemachineImpulseListener>();
if (UnityEngine.Object.FindFirstObjectByType<ImpactFeedback>() == null)
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(HullEffectPath);
if (prefab == null) throw new InvalidOperationException($"No prefab at {HullEffectPath}.");
var root = new GameObject("Impact Feedback");
var feedback = root.AddComponent<ImpactFeedback>();
var effect = (GameObject) PrefabUtility.InstantiatePrefab(prefab, root.transform);
feedback.HullEffect = effect.GetComponent<Prototype>();
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");
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9083abd01a947a847847d2f6fc177153

View file

@ -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<ImpactPresenter>() ?? gameObject.AddComponent<ImpactPresenter>();
impacts.Entity = entity;
(gameObject.GetComponent<CapabilityPresentationBinder>() ?? gameObject.AddComponent<CapabilityPresentationBinder>())
.Bind(entity.Capabilities);
foreach (var hullCollider in HullColliders) hullCollider.Entity = entity;
foreach (var item in entity.Equipment)

View file

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

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bfdc38a81d00b0347bb55ad6469535ff

View file

@ -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<Prototype> _live = new List<Prototype>();
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<Prototype>();
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();
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 775c61f785344d54b9f41ad3328cecee

View file

@ -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<ShieldAnimation>();

View file

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