using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; using System.Collections.Generic; using Terraria; using Terraria.GameContent; using Terraria.Graphics; using Terraria.ModLoader; using Terraria.UI; namespace SoulHarvest.Common; /// /// Client-only compositor for short, screen-space Reaper impacts. Gameplay remains /// server authoritative; this class only combines camera impulses and capped flashes. /// [Autoload(Side = ModSide.Client)] public sealed class ReaperVfxDirector : ModSystem { private const float MaximumShakeStrength = 12f; private const float MaximumFlashOpacity = 0.78f; private const float MaximumCinematicZoom = 1.11f; private const float MinimumCinematicZoom = 0.985f; private const float MaximumCinematicBias = 22f; private static int shakeFrames; private static int shakeDuration; private static float shakeStrength; private static Vector2 shakeDirection = Vector2.UnitY; private static float shakePhase; private static int flashFrames; private static int flashDuration; private static float flashOpacity; private static Color flashColor = Color.White; private static float edgeDarkness; private static CinematicCameraState[] cinematicStates = []; public override void OnWorldUnload() => Clear(); public override void Unload() => Clear(); public override void PostUpdateEverything() { if (Main.gameMenu) { Clear(); return; } if (shakeFrames > 0) shakeFrames--; else shakeStrength = 0f; if (flashFrames > 0) flashFrames--; else { flashOpacity = 0f; edgeDarkness = 0f; } EnsureCinematicStorage(); for (int index = 0; index < cinematicStates.Length; index++) { ref CinematicCameraState state = ref cinematicStates[index]; if (state.Active && Main.GameUpdateCount > state.LastReportTick + 2UL) state = default; } } public override void ModifyTransformMatrix(ref SpriteViewMatrix transform) { if (Main.dedServ || Main.gameMenu) return; ComposeCinematicCamera(out float zoomScale, out _); if (Math.Abs(zoomScale - 1f) <= 0.0001f) return; transform.Zoom *= zoomScale; } public override void ModifyInterfaceLayers(List layers) { int index = layers.FindIndex(layer => layer.Name == "Vanilla: Resource Bars"); if (index < 0) index = layers.FindIndex(layer => layer.Name == "Vanilla: Mouse Text"); if (index < 0) index = layers.Count; layers.Insert(index, new LegacyGameInterfaceLayer( "SoulHarvest: Reaper Impact Composite", DrawImpactOverlay, InterfaceScaleType.None)); } /// /// Adds a global impact without distance falloff. Ultimate timelines use this so /// every client which receives the action sees the same full-strength punctuation. /// Concurrent calls use maxima instead of unbounded addition. /// public static void TriggerGlobalImpact( Vector2 direction, float strength, int cameraFrames, Color color, float opacity, int colorFrames, float vignette = 0.28f) { if (Main.dedServ || Main.gameMenu) return; direction = direction.SafeNormalize(Vector2.UnitY); strength = MathHelper.Clamp(strength, 0f, MaximumShakeStrength); cameraFrames = Math.Clamp(cameraFrames, 0, 30); opacity = MathHelper.Clamp(opacity, 0f, MaximumFlashOpacity); colorFrames = Math.Clamp(colorFrames, 0, 30); // Bright full-viewport overlays read as a white-screen defect when rapid // melee or passive events overlap. Keep their camera punch and vignette, // but suppress the bright color plane entirely. Vector3 rgb = color.ToVector3(); float luminance = rgb.X * 0.2126f + rgb.Y * 0.7152f + rgb.Z * 0.0722f; if (luminance >= 0.64f) { opacity = 0f; colorFrames = 0; } if (strength >= shakeStrength || shakeFrames <= 1) { shakeStrength = strength; shakeDirection = direction; shakeDuration = Math.Max(1, cameraFrames); shakeFrames = cameraFrames; shakePhase += 1.731f; } else { shakeFrames = Math.Max(shakeFrames, cameraFrames); shakeDuration = Math.Max(shakeDuration, shakeFrames); shakeDirection = (shakeDirection + direction * 0.45f).SafeNormalize(shakeDirection); } // A camera-only impact must not create, extend or recolor an unrelated // full-screen flash that is already active (for example an overlapping // ultimate). Unique-skill cues intentionally pass zero here. if (opacity > 0.001f && colorFrames > 0) { if (opacity >= flashOpacity || flashFrames <= 1) { flashColor = color; flashOpacity = opacity; flashDuration = Math.Max(1, colorFrames); flashFrames = colorFrames; } else { flashFrames = Math.Max(flashFrames, colorFrames); flashDuration = Math.Max(flashDuration, flashFrames); flashColor = Color.Lerp(flashColor, color, 0.25f); } } edgeDarkness = Math.Max(edgeDarkness, MathHelper.Clamp(vignette, 0f, 0.62f)); } /// /// Reports an ultimate's normalized visual clock. The resulting camera move is /// a push-in, held composition and capped rebound; it never pauses or changes /// gameplay time. Concurrent ultimates use the strongest safe zoom instead of /// multiplying their transforms together. /// public static void ReportCinematicCamera( int owner, int actionId, float normalizedTime, Vector2 direction, float intensity = 1f) { if (Main.dedServ || Main.gameMenu || owner < 0 || owner >= Main.maxPlayers) return; EnsureCinematicStorage(); ref CinematicCameraState state = ref cinematicStates[owner]; if (state.Active && state.ActionId == actionId && state.LastReportTick == Main.GameUpdateCount && normalizedTime < state.NormalizedTime) { return; } state.Active = true; state.ActionId = actionId; state.NormalizedTime = MathHelper.Clamp(float.IsFinite(normalizedTime) ? normalizedTime : 0f, 0f, 1f); state.Direction = direction.SafeNormalize(Vector2.UnitX); state.Intensity = MathHelper.Clamp(float.IsFinite(intensity) ? intensity : 1f, 0f, 1f); state.LastReportTick = Main.GameUpdateCount; } /// /// Dispatches a synchronized visual description without granting it authority /// to create damage. Skill cues and Void direct-hit cues are supported now; the /// same event shape is kept for other normal, special and ultimate storyboards. /// internal static bool Submit(in ReaperVisualEvent visualEvent) { if (Main.dedServ || Main.gameMenu || visualEvent.Owner < 0 || visualEvent.Owner >= Main.maxPlayers) { return false; } if (visualEvent.ActionKind == ReaperVisualActionKind.NormalAttack && visualEvent.Form == ReaperFormId.Void) { ReaperVoidHitVisualSystem.ReportHit( visualEvent.Owner, visualEvent.Stage, visualEvent.ActionId, visualEvent.Target, visualEvent.Origin, visualEvent.Direction, visualEvent.Seed); return true; } if (visualEvent.ActionKind != ReaperVisualActionKind.Skill) return false; Vector2 center = visualEvent.Origin; if (visualEvent.Target >= 0 && visualEvent.Target < Main.maxNPCs) { NPC target = Main.npc[visualEvent.Target]; if (target.active) center = target.Center; } ReaperSkillVisualSystem.ReportSkill( visualEvent.Owner, visualEvent.Form, visualEvent.Stage, visualEvent.Skill, visualEvent.SkillLevel, visualEvent.TimelinePhase, center, visualEvent.Direction, visualEvent.ActionId, visualEvent.Seed); return true; } /// Applies the current composite shake to the local camera. public static void ApplyCameraImpulse(ref Vector2 screenPosition) { if (Main.dedServ) return; ComposeCinematicCamera(out _, out Vector2 cinematicBias); screenPosition += cinematicBias; if (shakeFrames <= 0 || shakeStrength <= 0.01f) return; float remaining = shakeFrames / (float)Math.Max(1, shakeDuration); float envelope = remaining * remaining; float tick = (float)Main.GameUpdateCount + shakePhase; Vector2 tangent = shakeDirection.RotatedBy(MathHelper.PiOver2); float along = (float)Math.Sin(tick * 2.31f) * shakeStrength * envelope; float across = (float)Math.Sin(tick * 3.77f + 0.9f) * shakeStrength * envelope * 0.52f; screenPosition += shakeDirection * along + tangent * across; } private static bool DrawImpactOverlay() { if (Main.gameMenu || flashFrames <= 0 || flashOpacity <= 0.001f) return true; float remaining = flashFrames / (float)Math.Max(1, flashDuration); float opacity = flashOpacity * remaining * remaining; Rectangle viewport = new( 0, 0, Math.Max(1, Main.screenWidth), Math.Max(1, Main.screenHeight)); Texture2D pixel = TextureAssets.MagicPixel.Value; SpriteBatch batch = Main.spriteBatch; batch.Draw(pixel, viewport, flashColor * opacity); float edge = edgeDarkness * MathHelper.Clamp(remaining * 1.45f, 0f, 1f); if (edge > 0.001f) batch.Draw(pixel, viewport, Color.Black * (edge * 0.12f)); return true; } private static void Clear() { shakeFrames = 0; shakeDuration = 0; shakeStrength = 0f; shakeDirection = Vector2.UnitY; shakePhase = 0f; flashFrames = 0; flashDuration = 0; flashOpacity = 0f; flashColor = Color.White; edgeDarkness = 0f; cinematicStates = []; } private static void ComposeCinematicCamera(out float zoomScale, out Vector2 cameraBias) { zoomScale = 1f; cameraBias = Vector2.Zero; if (cinematicStates.Length == 0) return; float strongestPositiveZoom = 0f; float strongestRebound = 0f; float strongestBias = 0f; for (int index = 0; index < cinematicStates.Length; index++) { ref CinematicCameraState state = ref cinematicStates[index]; if (!state.Active || Main.GameUpdateCount > state.LastReportTick + 2UL) continue; EvaluateCinematicCurve(state.NormalizedTime, out float zoomDelta, out float bias); zoomDelta *= state.Intensity; bias *= state.Intensity; if (zoomDelta >= 0f) strongestPositiveZoom = Math.Max(strongestPositiveZoom, zoomDelta); else strongestRebound = Math.Min(strongestRebound, zoomDelta); float biasMagnitude = Math.Abs(bias); if (biasMagnitude > strongestBias) { strongestBias = biasMagnitude; cameraBias = state.Direction * bias; } } float selectedZoom = strongestPositiveZoom > 0.0001f ? strongestPositiveZoom : strongestRebound; zoomScale = MathHelper.Clamp(1f + selectedZoom, MinimumCinematicZoom, MaximumCinematicZoom); if (cameraBias.LengthSquared() > MaximumCinematicBias * MaximumCinematicBias) cameraBias = cameraBias.SafeNormalize(Vector2.Zero) * MaximumCinematicBias; } private static void EvaluateCinematicCurve(float progress, out float zoomDelta, out float bias) { progress = MathHelper.Clamp(progress, 0f, 1f); if (progress < 0.12f) { float amount = Smooth(progress / 0.12f); zoomDelta = MathHelper.Lerp(0f, 0.060f, amount); bias = MathHelper.Lerp(0f, 9f, amount); return; } if (progress < 0.76f) { zoomDelta = 0.060f; bias = 9f; return; } if (progress < 0.90f) { float amount = Smooth((progress - 0.76f) / 0.14f); zoomDelta = MathHelper.Lerp(0.060f, 0.105f, amount); bias = MathHelper.Lerp(9f, 19f, amount); return; } if (progress < 0.965f) { float amount = Smooth((progress - 0.90f) / 0.065f); zoomDelta = MathHelper.Lerp(0.105f, -0.015f, amount); bias = MathHelper.Lerp(19f, -7f, amount); return; } float rebound = Smooth((progress - 0.965f) / 0.035f); zoomDelta = MathHelper.Lerp(-0.015f, 0f, rebound); bias = MathHelper.Lerp(-7f, 0f, rebound); } private static float Smooth(float value) { value = MathHelper.Clamp(value, 0f, 1f); return value * value * (3f - 2f * value); } private static void EnsureCinematicStorage() { if (cinematicStates.Length != Main.maxPlayers) cinematicStates = new CinematicCameraState[Main.maxPlayers]; } private struct CinematicCameraState { public bool Active; public int ActionId; public float NormalizedTime; public Vector2 Direction; public float Intensity; public ulong LastReportTick; } }