diff --git a/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorNormalMapProvider.cs b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorNormalMapProvider.cs
new file mode 100644
index 000000000..e269d80cd
--- /dev/null
+++ b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorNormalMapProvider.cs
@@ -0,0 +1,89 @@
+namespace Everglow.Commons.Mechanics.MirrorReflection.Core;
+
+public enum MirrorNormalPattern
+{
+ Wave,
+ HorizontalWave,
+ VerticalWave,
+ Bulge,
+ Pinch,
+ Twist,
+}
+
+///
+/// 提供并缓存法线纹理;只负责图案生成和资源生命周期,不访问墙格或组织反射绘制。
+///
+public sealed class MirrorNormalMapProvider : IDisposable
+{
+ private readonly Dictionary normalMaps = [];
+
+ /// 取得当前客户端的法线纹理。纹理由本提供者持有,调用方不得释放。
+ public Texture2D GetTexture(MirrorNormalPattern pattern)
+ {
+ if (Main.dedServ)
+ {
+ throw new InvalidOperationException("Normal textures are only available on the client.");
+ }
+ if (normalMaps.TryGetValue(pattern, out Texture2D normalMap) && !normalMap.IsDisposed)
+ {
+ return normalMap;
+ }
+ const int size = 128;
+ Color[] pixels = new Color[size * size];
+ for (int y = 0; y < size; y++)
+ {
+ for (int x = 0; x < size; x++)
+ {
+ Vector3 normal = SampleNormal((x + 0.5f) / size, (y + 0.5f) / size, pattern);
+ pixels[y * size + x] = new Color(normal * 0.5f + new Vector3(0.5f));
+ }
+ }
+ normalMap = new Texture2D(Main.graphics.GraphicsDevice, size, size, false, SurfaceFormat.Color);
+ normalMap.SetData(pixels);
+ normalMaps[pattern] = normalMap;
+ return normalMap;
+ }
+
+ /// 可平铺双向曲面的单位法线,供运行时生成 RGB 法线贴图。
+ public static Vector3 SampleNormal(float u, float v) => Vector3.Normalize(new Vector3(
+ 0.5f * MathF.Sin(MathHelper.TwoPi * u), 0.5f * MathF.Sin(MathHelper.TwoPi * v), 1));
+
+ /// 可平铺的哈哈镜法线;径向曲面在周期边界平滑归零,避免重复图案的接缝。
+ public static Vector3 SampleNormal(float u, float v, MirrorNormalPattern pattern)
+ {
+ u -= MathF.Floor(u);
+ v -= MathF.Floor(v);
+ if (pattern == MirrorNormalPattern.Wave)
+ {
+ return SampleNormal(u, v);
+ }
+ if (pattern == MirrorNormalPattern.HorizontalWave)
+ {
+ return Vector3.Normalize(new Vector3(0.8f * MathF.Sin(MathHelper.TwoPi * u), 0, 1));
+ }
+ if (pattern == MirrorNormalPattern.VerticalWave)
+ {
+ return Vector3.Normalize(new Vector3(0, 0.8f * MathF.Sin(MathHelper.TwoPi * v), 1));
+ }
+ Vector2 radial = new(u * 2 - 1, v * 2 - 1);
+ float window = (1 - radial.X * radial.X) * (1 - radial.Y * radial.Y);
+ Vector2 slope = pattern switch
+ {
+ MirrorNormalPattern.Bulge => -radial,
+ MirrorNormalPattern.Pinch => radial,
+ MirrorNormalPattern.Twist => new Vector2(-radial.Y, radial.X),
+ _ => throw new ArgumentOutOfRangeException(nameof(pattern)),
+ };
+ return Vector3.Normalize(new Vector3(slope * (1.5f * window * window), 1));
+ }
+
+ /// 释放本提供者生成的纹理;图形资源在主线程释放。
+ public void Dispose()
+ {
+ foreach (Texture2D texture in normalMaps.Values)
+ {
+ Main.QueueMainThreadAction(texture.Dispose);
+ }
+ normalMaps.Clear();
+ }
+}
diff --git a/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorReflectionDefinition.cs b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorReflectionDefinition.cs
new file mode 100644
index 000000000..d0f39e29d
--- /dev/null
+++ b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorReflectionDefinition.cs
@@ -0,0 +1,146 @@
+namespace Everglow.Commons.Mechanics.MirrorReflection.Core;
+
+///
+/// 定义镜面反射的覆盖率、亮度、采样和投影参数;不持有纹理或执行绘制。
+///
+///
+/// 倒影的不透明度,范围为 0 到 1。0 表示完全透明,1 表示使用倒影纹理本身的完整覆盖率。
+/// 此值不改变原墙纹理的不透明度。
+///
+///
+/// 倒影的 RGB 亮度系数,范围为 0 到 1,默认值为 1。此值不影响 Alpha;
+/// 设为 0 会保留黑色倒影的覆盖率,关闭倒影应将 设为 0。
+///
+///
+/// 构造和初始化时不校验或截断数值;调用方应提供有限浮点数和有效枚举值。
+/// 墙面收集阶段限制数值范围,并将覆盖率和亮度转换为绘制颜色;二者不参与倒影采样分组。
+/// 客户端配置只控制反射开关和滤波精度。使用 创建的结构体覆盖率为 0,不产生倒影。
+///
+public readonly record struct MirrorReflectionDefinition(float Opacity, float Strength = 1)
+{
+ ///
+ /// 获取或初始化倒影的世界像素偏移,正值向右、向下。
+ ///
+ /// X 范围为 -100 到 100,Y 范围为 -30 到 30;默认值为 (12, 0)。
+ public Vector2 Offset { get; init; } = new(12, 0);
+
+ ///
+ /// 获取或初始化倒影在水平和垂直方向上的统一缩放系数。
+ ///
+ ///
+ /// 范围为 0.5 到 1.5,1 表示原始大小。
+ ///
+ ///
+ /// 通用反射系统以本地玩家脚底为缩放基准,不以连续墙面的中心为基准。
+ ///
+ public float ReflectionScale { get; init; } = 1;
+
+ ///
+ /// 获取或初始化是否围绕反射基准进行左右翻转。
+ ///
+ ///
+ /// 表示左右翻转, 表示保持方向。
+ ///
+ public bool FlipHorizontally { get; init; }
+
+ ///
+ /// 获取或初始化高画质下高斯模糊的基准采样间距,单位为世界像素。
+ ///
+ ///
+ /// 范围为 0 到 4,0 表示跳过模糊。
+ ///
+ ///
+ /// 此值控制采样间距,不表示采样点数量或核的标准差。
+ /// 低、中、高画质分别执行每方向三点、五点、九点采样;较低画质会增大实际采样间距,
+ /// 各档位单方向最远采样位置距中心均为此值的四倍。
+ ///
+ public float Blur { get; init; } = 0.5f;
+
+ ///
+ /// 获取或初始化高斯采样核的标准差,用于控制各采样点的权重分布。
+ ///
+ ///
+ /// 范围为 0.25 到 3,以高画质的基准采样点索引间距为单位。
+ ///
+ ///
+ /// 值越大,远离中心的采样点权重越高。实际采样位置由 和画质档位共同决定。
+ ///
+ public float Sigma { get; init; } = 1.5f;
+
+ ///
+ /// 获取或初始化用于生成法线贴图的曲面类型,例如波纹、鼓起、凹陷或扭转。
+ ///
+ ///
+ /// 有效的 枚举值。
+ ///
+ public MirrorNormalPattern NormalPattern { get; init; }
+
+ ///
+ /// 获取或初始化法线扭曲的位移系数,单位为世界像素。
+ ///
+ ///
+ /// 范围为 0 到 24,0 表示不产生法线扭曲。
+ ///
+ ///
+ /// 最终位移还取决于法线斜率和镜面角度,此值不是最大位移距离。
+ ///
+ public float NormalStrength { get; init; }
+
+ ///
+ /// 获取或初始化法线图案在世界坐标中的平铺周期,单位为世界像素。
+ ///
+ ///
+ /// 范围为 8 到 256,允许材质使用小于演示面板下限的细密图案。
+ ///
+ ///
+ /// 数值越大,单个曲面图案覆盖的区域越大;图案位置随世界坐标固定。
+ ///
+ public float NormalPeriod { get; init; } = 96;
+
+ ///
+ /// 获取或初始化反射投影绕 X 轴旋转的俯仰角,单位为度。
+ ///
+ ///
+ /// 范围为 -60 到 60。
+ ///
+ ///
+ /// 角度作用于倒影投影及法线位移方向,墙面网格保持原位。
+ ///
+ public float Pitch { get; init; }
+
+ ///
+ /// 获取或初始化反射投影绕 Y 轴旋转的偏航角,单位为度。
+ ///
+ ///
+ /// 范围为 -60 到 60。
+ ///
+ ///
+ /// 角度作用于倒影投影及法线位移方向,墙面网格保持原位。
+ ///
+ public float Yaw { get; init; }
+
+ ///
+ /// 获取或初始化反射投影绕 Z 轴旋转的滚转角,单位为度。
+ ///
+ ///
+ /// 范围为 -90 到 90。
+ ///
+ ///
+ /// 角度作用于倒影投影及法线位移方向,墙面网格保持原位。
+ ///
+ public float Roll { get; init; }
+
+ ///
+ /// 获取或初始化反射透视投影使用的虚拟观察距离,单位为世界像素。
+ ///
+ ///
+ /// 范围为 240 到 1200。
+ ///
+ ///
+ /// 倒影倾斜时,较小的距离产生更强的透视变化;此值不表示玩家与墙之间的实际距离。
+ ///
+ public float Distance { get; init; } = 480;
+
+ /// 获取按 X、Y、Z 轴排列的旋转角,单位为弧度。
+ public Vector3 Angles => new(MathHelper.ToRadians(Pitch), MathHelper.ToRadians(Yaw), MathHelper.ToRadians(Roll));
+}
diff --git a/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorReflectionRenderer.cs b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorReflectionRenderer.cs
new file mode 100644
index 000000000..92637e02f
--- /dev/null
+++ b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorReflectionRenderer.cs
@@ -0,0 +1,194 @@
+using Everglow.Commons.Utilities;
+
+namespace Everglow.Commons.Mechanics.MirrorReflection.Core;
+
+///
+/// 客户端倒影绘制器:处理投影、法线扭曲和模糊,并将结果裁剪到墙格。
+/// 接收组织者提供的法线纹理和渲染目标;只缓存高斯权重,不管理捕获或纹理生命周期。
+///
+internal sealed class MirrorReflectionRenderer
+{
+ private float cachedSigma = float.NaN;
+ private int cachedSampleCount;
+ private float[] blurWeights;
+
+ public void ProcessForeground(Texture2D foreground, RenderTarget2D projected, RenderTarget2D processed, MirrorReflectionDefinition definition, Texture2D normalMap, int sampleCount)
+ {
+ Player viewer = Main.LocalPlayer;
+ // 整张前景使用同一个观察基准,不按对象或镜墙拼接范围分别翻转。
+ Vector2 anchor = viewer.Bottom + new Vector2(0, viewer.gfxOffY) - Main.screenPosition;
+ Matrix projection = CreateScreenProjection(anchor, definition.Offset, definition.ReflectionScale,
+ definition.FlipHorizontally, definition.Angles, definition.Distance, Main.GameViewMatrix.TransformationMatrix);
+ Main.graphics.GraphicsDevice.SetRenderTarget(projected);
+ Main.graphics.GraphicsDevice.Clear(Color.Transparent);
+ Main.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.Opaque, SamplerState.LinearClamp,
+ DepthStencilState.None, RasterizerState.CullNone, null, projection);
+ try
+ {
+ Main.spriteBatch.Draw(foreground, Vector2.Zero, Color.White);
+ }
+ finally
+ {
+ Main.spriteBatch.End();
+ }
+ ProcessReflection(projected, processed, definition, normalMap, sampleCount);
+ }
+
+ public void DrawReflection(RenderTarget2D[] reflections, IReadOnlyList groups)
+ {
+ Main.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp,
+ DepthStencilState.None, RasterizerState.CullNone);
+ try
+ {
+ for (int index = 0; index < groups.Count; index++)
+ {
+ foreach (var wall in groups[index].Walls)
+ {
+ DrawWallReflection(wall.Position, reflections[index], wall.Tint);
+ }
+ }
+ }
+ finally
+ {
+ Main.spriteBatch.End();
+ }
+ }
+
+ private void ProcessReflection(RenderTarget2D captured, RenderTarget2D processed, MirrorReflectionDefinition definition, Texture2D normalMap, int sampleCount)
+ {
+ Effect effect = ModAsset.MirrorReflectionShader.Value;
+ Matrix view = Main.GameViewMatrix.TransformationMatrix;
+ Vector3 angles = definition.Angles;
+ effect.Parameters["uTransform"].SetValue(Matrix.CreateOrthographicOffCenter(0, captured.Width, captured.Height, 0, 0, 1));
+ effect.Parameters["uSize"].SetValue(captured.Size());
+ effect.Parameters["uScreenToWorld"].SetValue(Matrix.Invert(view) * Matrix.CreateTranslation(Main.screenPosition.X, Main.screenPosition.Y, 0));
+ effect.Parameters["uWorldToScreen"].SetValue(view);
+ effect.Parameters["uSurfaceRotation"].SetValue(Matrix.CreateRotationX(angles.X) * Matrix.CreateRotationY(angles.Y) * Matrix.CreateRotationZ(angles.Z));
+ effect.Parameters["uNormalStrength"].SetValue(definition.NormalStrength);
+ effect.Parameters["uNormalPeriod"].SetValue(definition.NormalPeriod);
+ RenderPass(captured, processed, effect, "NormalDistortion", normalMap);
+ if (definition.Blur > 0)
+ {
+ if (cachedSigma != definition.Sigma || cachedSampleCount != sampleCount)
+ {
+ cachedSigma = definition.Sigma;
+ cachedSampleCount = sampleCount;
+ blurWeights = CreateGaussianKernel(cachedSigma, sampleCount);
+ }
+ effect.Parameters["uWeights"].SetValue(blurWeights);
+ // 各档位保持相同采样范围;减少采样点只降低滤波精度,不覆盖材质参数。
+ float spacing = definition.Blur * 8f / (sampleCount - 1);
+ string technique = sampleCount switch
+ {
+ 3 => "GaussianBlurLow",
+ 5 => "GaussianBlurMedium",
+ _ => "GaussianBlur",
+ };
+ Vector2 horizontal = Vector2.TransformNormal(new Vector2(spacing, 0), view);
+ Vector2 vertical = Vector2.TransformNormal(new Vector2(0, spacing), view);
+ effect.Parameters["uBlurStep"].SetValue(horizontal / captured.Size());
+ RenderPass(processed, captured, effect, technique, normalMap);
+ effect.Parameters["uBlurStep"].SetValue(vertical / captured.Size());
+ RenderPass(captured, processed, effect, technique, normalMap);
+ }
+ }
+
+ private static void RenderPass(Texture2D source, RenderTarget2D target, Effect effect, string technique, Texture2D normalMap)
+ {
+ GraphicsDevice device = Main.graphics.GraphicsDevice;
+ device.SetRenderTarget(target);
+ device.Clear(Color.Transparent);
+ effect.CurrentTechnique = effect.Techniques[technique];
+ Main.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.Opaque, SamplerState.LinearClamp,
+ DepthStencilState.None, RasterizerState.CullNone, effect);
+ try
+ {
+ device.Textures[1] = normalMap;
+ device.SamplerStates[1] = SamplerState.LinearWrap;
+ Main.spriteBatch.Draw(source, target.Bounds, Color.White);
+ }
+ finally
+ {
+ Main.spriteBatch.End();
+ }
+ }
+
+ private static void DrawWallReflection(Point point, Texture2D reflection, Color tint)
+ {
+ Rectangle cell = GetScreenRectangle(point.ToVector2() * 16, new Vector2(16), Main.screenPosition, Main.GameViewMatrix.TransformationMatrix);
+ Rectangle viewport = new(0, 0, reflection.Width, reflection.Height);
+ Rectangle slice = Rectangle.Intersect(cell, viewport);
+ if (slice.Width > 0 && slice.Height > 0)
+ {
+ Main.spriteBatch.Draw(reflection, slice, slice, tint);
+ }
+ }
+
+ ///
+ /// 先还原前景已有的相机变换,再围绕共同基准投影倒影;墙网格保持原位。
+ /// angles 为俯仰、偏航、滚转弧度。
+ ///
+ private static Matrix CreateScreenProjection(Vector2 anchor, Vector2 offset, float scale, bool flip, Vector3 angles, float distance, Matrix view)
+ {
+ if (!float.IsFinite(distance) || distance <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(distance));
+ }
+ Matrix perspective = Matrix.Identity;
+ perspective.M34 = -1 / distance;
+ // 保留透视 W,但将输出深度归零,避免 SpriteBatch 的二维裁剪面截掉倾斜图像。
+ perspective.M33 = 0;
+ Matrix projection = Matrix.CreateTranslation(-anchor.X, -anchor.Y, 0)
+ * Matrix.CreateScale(flip ? -scale : scale, scale, 1)
+ * Matrix.CreateRotationX(angles.X) * Matrix.CreateRotationY(angles.Y) * Matrix.CreateRotationZ(angles.Z)
+ * perspective * Matrix.CreateTranslation(anchor.X + offset.X, anchor.Y + offset.Y, 0) * view;
+ return Matrix.Invert(view) * projection;
+ }
+
+ ///
+ /// 创建归一化高斯权重,各画质档位均覆盖原九点核的 [-4, 4] 采样范围。
+ ///
+ /// 以原采样间距为单位的正标准差。
+ /// 每个方向的采样数量,支持 3、5、9。
+ /// 供 Shader 使用的九元素数组;前 sampleCount 项有效,其余为零。
+ private static float[] CreateGaussianKernel(float sigma, int sampleCount)
+ {
+ if (!float.IsFinite(sigma) || sigma <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(sigma));
+ }
+ if (sampleCount is not (3 or 5 or 9))
+ {
+ throw new ArgumentOutOfRangeException(nameof(sampleCount));
+ }
+ float sampleSpacing = 8f / (sampleCount - 1);
+ float[] weights = new float[9];
+ float sum = 0;
+ for (int i = 0; i < sampleCount; i++)
+ {
+ float distance = (i - sampleCount / 2) * sampleSpacing;
+ weights[i] = MathF.Exp(-distance * distance / (2 * sigma * sigma));
+ sum += weights[i];
+ }
+ for (int i = 0; i < sampleCount; i++)
+ {
+ weights[i] /= sum;
+ }
+ return weights;
+ }
+
+ ///
+ /// 将世界矩形映射到屏幕并向下取整共用边界,使相邻墙格在相机缩放时保持连续。
+ ///
+ private static Rectangle GetScreenRectangle(Vector2 worldPosition, Vector2 size, Vector2 screenPosition, Matrix view)
+ {
+ Vector2 first = Vector2.Transform(worldPosition - screenPosition, view);
+ Vector2 second = Vector2.Transform(worldPosition + size - screenPosition, view);
+ Vector2 topLeft = Vector2.Min(first, second);
+ Vector2 bottomRight = Vector2.Max(first, second);
+ // 共用边界取整,缩放时相邻墙格既不留缝也不重复混合。
+ return new Rectangle((int)MathF.Floor(topLeft.X), (int)MathF.Floor(topLeft.Y),
+ (int)MathF.Floor(bottomRight.X) - (int)MathF.Floor(topLeft.X),
+ (int)MathF.Floor(bottomRight.Y) - (int)MathF.Floor(topLeft.Y));
+ }
+}
diff --git a/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorReflectionShader.fx b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorReflectionShader.fx
new file mode 100644
index 000000000..5f37a8a0f
--- /dev/null
+++ b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorReflectionShader.fx
@@ -0,0 +1,119 @@
+sampler2D uImage : register(s0);
+sampler2D uNormalMap : register(s1);
+float4x4 uTransform;
+float4x4 uScreenToWorld;
+float4x4 uWorldToScreen;
+float4x4 uSurfaceRotation;
+float2 uSize;
+float2 uBlurStep;
+float uWeights[9];
+float uNormalStrength;
+float uNormalPeriod;
+
+struct VSInput
+{
+ float4 Position : POSITION0;
+ float4 Color : COLOR0;
+ float2 UV : TEXCOORD0;
+};
+
+struct PSInput
+{
+ float4 Position : SV_POSITION;
+ float4 Color : COLOR0;
+ float2 UV : TEXCOORD0;
+};
+
+PSInput VertexShaderFunction(VSInput input)
+{
+ PSInput output;
+ output.Position = mul(input.Position, uTransform);
+ output.Color = input.Color;
+ output.UV = input.UV;
+ return output;
+}
+
+float4 SampleTransparent(float2 uv)
+{
+ float inside = step(0, uv.x) * step(uv.x, 1) * step(0, uv.y) * step(uv.y, 1);
+ return tex2D(uImage, uv) * inside;
+}
+
+float4 Distort(PSInput input) : COLOR0
+{
+ float2 world = mul(float4(input.UV * uSize, 0, 1), uScreenToWorld).xy;
+ float3 normal = normalize(tex2D(uNormalMap, world / uNormalPeriod).rgb * 2 - 1);
+ float3 tangent = float3(normal.xy / max(normal.z, 0.25), 0);
+ float2 displacement = mul(float4(tangent, 0), uSurfaceRotation).xy * uNormalStrength;
+ displacement = mul(float4(displacement, 0, 0), uWorldToScreen).xy;
+ return SampleTransparent(input.UV + displacement / uSize) * input.Color;
+}
+
+float4 Gaussian(PSInput input) : COLOR0
+{
+ // 两次可分离采样构成 9×9 高斯矩阵;RGBA 一起卷积,保留预乘透明度。
+ float4 color = SampleTransparent(input.UV - 4 * uBlurStep) * uWeights[0];
+ color += SampleTransparent(input.UV - 3 * uBlurStep) * uWeights[1];
+ color += SampleTransparent(input.UV - 2 * uBlurStep) * uWeights[2];
+ color += SampleTransparent(input.UV - uBlurStep) * uWeights[3];
+ color += SampleTransparent(input.UV) * uWeights[4];
+ color += SampleTransparent(input.UV + uBlurStep) * uWeights[5];
+ color += SampleTransparent(input.UV + 2 * uBlurStep) * uWeights[6];
+ color += SampleTransparent(input.UV + 3 * uBlurStep) * uWeights[7];
+ color += SampleTransparent(input.UV + 4 * uBlurStep) * uWeights[8];
+ return color * input.Color;
+}
+
+float4 GaussianLow(PSInput input) : COLOR0
+{
+ float4 color = SampleTransparent(input.UV - uBlurStep) * uWeights[0];
+ color += SampleTransparent(input.UV) * uWeights[1];
+ color += SampleTransparent(input.UV + uBlurStep) * uWeights[2];
+ return color * input.Color;
+}
+
+float4 GaussianMedium(PSInput input) : COLOR0
+{
+ float4 color = SampleTransparent(input.UV - 2 * uBlurStep) * uWeights[0];
+ color += SampleTransparent(input.UV - uBlurStep) * uWeights[1];
+ color += SampleTransparent(input.UV) * uWeights[2];
+ color += SampleTransparent(input.UV + uBlurStep) * uWeights[3];
+ color += SampleTransparent(input.UV + 2 * uBlurStep) * uWeights[4];
+ return color * input.Color;
+}
+
+technique NormalDistortion
+{
+ pass Pass0
+ {
+ VertexShader = compile vs_3_0 VertexShaderFunction();
+ PixelShader = compile ps_3_0 Distort();
+ }
+}
+
+technique GaussianBlur
+{
+ pass Pass0
+ {
+ VertexShader = compile vs_3_0 VertexShaderFunction();
+ PixelShader = compile ps_3_0 Gaussian();
+ }
+}
+
+technique GaussianBlurLow
+{
+ pass Pass0
+ {
+ VertexShader = compile vs_3_0 VertexShaderFunction();
+ PixelShader = compile ps_3_0 GaussianLow();
+ }
+}
+
+technique GaussianBlurMedium
+{
+ pass Pass0
+ {
+ VertexShader = compile vs_3_0 VertexShaderFunction();
+ PixelShader = compile ps_3_0 GaussianMedium();
+ }
+}
diff --git a/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorScreenCapture.cs b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorScreenCapture.cs
new file mode 100644
index 000000000..438e53b0b
--- /dev/null
+++ b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorScreenCapture.cs
@@ -0,0 +1,202 @@
+using Everglow.Commons.ObjectPool;
+using Everglow.Commons.Utilities;
+
+namespace Everglow.Commons.Mechanics.MirrorReflection.Core;
+
+///
+/// 保存背景、前景和地图分层,并按调用方要求恢复画面;不注册绘制钩子或驱动反射流程。
+///
+internal sealed class MirrorScreenCapture
+{
+ private ResourceLocker targets;
+ private ResourceLocker mapLayers;
+ private RenderTarget2D screen;
+ private bool mapSeparated;
+
+ private RenderTarget2D Background => targets.Resource[0];
+
+ /// 获取是否已保存背景并开始捕获前景。
+ public bool IsCapturing { get; private set; }
+
+ /// SaveForeground 后至 Release 前有效的借用纹理;调用方不得释放。
+ public RenderTarget2D Foreground => targets.Resource[1];
+
+ /// 在原版屏幕目标上保存背景并清空前景区域;目标不匹配时不开始捕获。
+ public void Begin()
+ {
+ GraphicsDevice device = Main.graphics.GraphicsDevice;
+ RenderTargetBinding[] bindings = device.GetRenderTargets();
+ if (bindings.Length != 1 || bindings[0].RenderTarget != Main.screenTarget)
+ {
+ return;
+ }
+ var state = Main.spriteBatch.GetState();
+ if (state.HasValue)
+ {
+ Main.spriteBatch.End();
+ }
+ bool backgroundSaved = false;
+ try
+ {
+ screen = Main.screenTarget;
+ targets = Ins.RenderTargetPool.GetRenderTarget2DArray(2);
+ CopyTo(Background, screen);
+ backgroundSaved = true;
+ device.SetRenderTarget(screen);
+ device.Clear(Color.Transparent);
+ IsCapturing = true;
+ }
+ finally
+ {
+ try
+ {
+ if (!IsCapturing)
+ {
+ try
+ {
+ if (backgroundSaved)
+ {
+ RestoreBackground();
+ }
+ }
+ finally
+ {
+ Release();
+ }
+ }
+ }
+ finally
+ {
+ if (state.HasValue)
+ {
+ Main.spriteBatch.Begin(state.Value);
+ }
+ }
+ }
+ }
+
+ /// 保存地图前的前景并清空目标;返回是否需要在原版地图绘制后调用 EndMap。
+ public bool BeginMap()
+ {
+ if (!IsCapturing || mapLayers != null || !IsDrawingToScreenTarget())
+ {
+ return false;
+ }
+ var state = Main.spriteBatch.GetState();
+ if (state.HasValue)
+ {
+ Main.spriteBatch.End();
+ }
+ try
+ {
+ mapLayers = Ins.RenderTargetPool.GetRenderTarget2DArray(3);
+ CopyTo(mapLayers.Resource[0], screen);
+ Main.graphics.GraphicsDevice.SetRenderTarget(screen);
+ Main.graphics.GraphicsDevice.Clear(Color.Transparent);
+ return true;
+ }
+ finally
+ {
+ if (state.HasValue)
+ {
+ Main.spriteBatch.Begin(state.Value);
+ }
+ }
+ }
+
+ /// 保存地图层并清空目标,供后续投射物和玩家继续绘制。
+ public void EndMap()
+ {
+ var state = Main.spriteBatch.GetState();
+ if (state.HasValue)
+ {
+ Main.spriteBatch.End();
+ }
+ try
+ {
+ CopyTo(mapLayers.Resource[1], screen);
+ Main.graphics.GraphicsDevice.SetRenderTarget(screen);
+ Main.graphics.GraphicsDevice.Clear(Color.Transparent);
+ mapSeparated = true;
+ }
+ finally
+ {
+ if (state.HasValue)
+ {
+ Main.spriteBatch.Begin(state.Value);
+ }
+ }
+ }
+
+ /// 保存完整前景用于倒影采样,排除叠加地图;调用方须先结束外层 SpriteBatch。
+ public void SaveForeground()
+ {
+ if (mapSeparated)
+ {
+ CopyTo(mapLayers.Resource[2], screen);
+ CopyTo(Foreground, mapLayers.Resource[0]);
+ DrawTexture(mapLayers.Resource[2], BlendState.AlphaBlend);
+ }
+ else
+ {
+ CopyTo(Foreground, screen);
+ }
+ }
+
+ /// 重新绑定屏幕目标并恢复背景,覆盖中间绘制结果;调用方须先结束外层 SpriteBatch。
+ public void RestoreBackground() => CopyTo(screen, Background);
+
+ /// 在当前屏幕目标上恢复原始前景及地图层级;调用方须先结束外层 SpriteBatch。
+ public void RestoreForeground()
+ {
+ if (mapSeparated)
+ {
+ DrawTexture(mapLayers.Resource[0], BlendState.AlphaBlend);
+ DrawTexture(mapLayers.Resource[1], BlendState.AlphaBlend);
+ DrawTexture(mapLayers.Resource[2], BlendState.AlphaBlend);
+ }
+ else
+ {
+ DrawTexture(Foreground, BlendState.AlphaBlend);
+ }
+ }
+
+ /// 检查当前绘制目标是否仍为本次捕获的原版屏幕目标。
+ public bool IsDrawingToScreenTarget()
+ {
+ RenderTargetBinding[] bindings = Main.graphics.GraphicsDevice.GetRenderTargets();
+ return screen != null && !screen.IsDisposed && bindings.Length == 1 && bindings[0].RenderTarget == screen;
+ }
+
+ private static void CopyTo(RenderTarget2D destination, Texture2D source)
+ {
+ Main.graphics.GraphicsDevice.SetRenderTarget(destination);
+ DrawTexture(source, BlendState.Opaque);
+ }
+
+ private static void DrawTexture(Texture2D texture, BlendState blend)
+ {
+ Main.spriteBatch.Begin(SpriteSortMode.Immediate, blend, SamplerState.PointClamp,
+ DepthStencilState.None, RasterizerState.CullNone);
+ try
+ {
+ Main.spriteBatch.Draw(texture, Vector2.Zero, Color.White);
+ }
+ finally
+ {
+ Main.spriteBatch.End();
+ }
+ }
+
+ /// 归还捕获纹理并清空分层状态;不绘制或安排下一阶段。
+ public void Release()
+ {
+ IsCapturing = false;
+ mapSeparated = false;
+ screen = null;
+ mapLayers?.Release();
+ mapLayers = null;
+ targets?.Release();
+ targets = null;
+ }
+}
diff --git a/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorScreenCaptureRenderHooks.cs b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorScreenCaptureRenderHooks.cs
new file mode 100644
index 000000000..4fcff94a1
--- /dev/null
+++ b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorScreenCaptureRenderHooks.cs
@@ -0,0 +1,91 @@
+using Everglow.Commons.Graphics;
+using Everglow.Commons.Interfaces;
+using Everglow.Commons.VFX.Pipelines;
+
+namespace Everglow.Commons.Mechanics.MirrorReflection.Core;
+
+///
+/// 捕获期间的混合状态与 VFX 绘制钩子:保留加法光照的覆盖率,将依赖完整场景的绘制延后执行。
+///
+internal sealed class MirrorScreenCaptureRenderHooks : IDisposable
+{
+ private readonly List deferred = [];
+ private readonly BlendState additiveLight = CustomBlendStates.NewBlendState(
+ "Mirror additive light", Blend.SourceAlpha, Blend.Zero, Blend.One, Blend.One);
+
+ private readonly IHookHandler blendHook;
+ private readonly IHookHandler warpHook;
+ private readonly IHookHandler screenReflectionHook;
+
+ public MirrorScreenCaptureRenderHooks()
+ {
+ blendHook = Ins.HookManager.AddHook(
+ typeof(GraphicsDevice).GetProperty(nameof(GraphicsDevice.BlendState)).SetMethod,
+ (Action, GraphicsDevice, BlendState>)SetBlendState);
+ warpHook = Ins.HookManager.AddHook(
+ typeof(WarpPipeline).GetMethod(nameof(WarpPipeline.Render)),
+ (Action>, WarpPipeline, IEnumerable>)DrawDeferred);
+ screenReflectionHook = Ins.HookManager.AddHook(
+ typeof(ScreenReflectionPipeline).GetMethod(nameof(ScreenReflectionPipeline.Render)),
+ (Action>, ScreenReflectionPipeline, IEnumerable>)DrawDeferred);
+ }
+
+ public bool Capturing { get; set; }
+
+ public void Flush()
+ {
+ Capturing = false;
+ try
+ {
+ foreach (var call in deferred)
+ {
+ call();
+ }
+ }
+ finally
+ {
+ deferred.Clear();
+ }
+ }
+
+ public void Reset()
+ {
+ Capturing = false;
+ deferred.Clear();
+ }
+
+ public void Dispose()
+ {
+ Reset();
+ Ins.HookManager.RemoveHook(screenReflectionHook);
+ Ins.HookManager.RemoveHook(warpHook);
+ Ins.HookManager.RemoveHook(blendHook);
+ Main.QueueMainThreadAction(additiveLight.Dispose);
+ }
+
+ private void SetBlendState(Action orig, GraphicsDevice device, BlendState blend)
+ {
+ if (Capturing && ReferenceEquals(blend, BlendState.Additive))
+ {
+ RenderTargetBinding[] bindings = device.GetRenderTargets();
+ if (bindings.Length == 1 && (bindings[0].RenderTarget == Main.screenTarget || bindings[0].RenderTarget == Main.screenTargetSwap))
+ {
+ // 保持原 Additive 的 RGB 公式;发光不能增加前景对背景的遮挡。
+ blend = additiveLight;
+ }
+ }
+ orig(device, blend);
+ }
+
+ private void DrawDeferred(Action> orig, T pipeline, IEnumerable visuals)
+ {
+ if (Capturing)
+ {
+ // 仅推迟读取完整屏幕的两条管线;普通 TileVFX 保留原来的层级和反射。
+ IVisual[] snapshot = visuals.ToArray();
+ deferred.Add(() => orig(pipeline, snapshot));
+ return;
+ }
+ orig(pipeline, visuals);
+ }
+}
diff --git a/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorWallReflectionCollector.cs b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorWallReflectionCollector.cs
new file mode 100644
index 000000000..887e4a68f
--- /dev/null
+++ b/Sources/Everglow.Function/Mechanics/MirrorReflection/Core/MirrorWallReflectionCollector.cs
@@ -0,0 +1,108 @@
+namespace Everglow.Commons.Mechanics.MirrorReflection.Core;
+
+///
+/// 管理墙面材质注册,收集当前视口中的反光墙并按采样参数分组;不申请 GPU 资源或执行绘制。
+///
+internal sealed class MirrorWallReflectionCollector
+{
+ private readonly Dictionary> profiles = [];
+
+ internal sealed class ReflectionGroup(MirrorReflectionDefinition definition)
+ {
+ public readonly MirrorReflectionDefinition Definition = definition;
+ public readonly List<(Point Position, Color Tint)> Walls = [];
+ }
+
+ public MirrorWallReflectionCollector(IReadOnlyDictionary defaults)
+ {
+ foreach (var (wallType, profile) in defaults)
+ {
+ Register(wallType, profile);
+ }
+ }
+
+ public void Register(int wallType, MirrorReflectionDefinition profile) => Register(wallType, () => profile);
+
+ /// 同类型注册替换旧值;动态材质每次收集只读取一次。
+ public void Register(int wallType, Func provider)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(wallType);
+ ArgumentNullException.ThrowIfNull(provider);
+ profiles[wallType] = provider;
+ }
+
+ /// 读取每类墙的定义一次,返回独立的参数与乘色快照。
+ public Dictionary ResolveMaterials()
+ {
+ Dictionary materials = [];
+ foreach (var (wallType, provider) in profiles)
+ {
+ MirrorReflectionDefinition profile = provider();
+ float opacity = Math.Clamp(profile.Opacity, 0, 1);
+ float strength = Math.Clamp(profile.Strength, 0, 1);
+ Color tint = new(new Vector4(new Vector3(opacity * strength), opacity));
+ if (tint.A != 0)
+ {
+ // 覆盖率和亮度已写入乘色;统一这两个字段,使相同采样定义能够共享倒影。
+ var sampling = profile with
+ {
+ Opacity = 1,
+ Strength = 1,
+ Offset = new(Math.Clamp(profile.Offset.X, -100, 100), Math.Clamp(profile.Offset.Y, -30, 30)),
+ ReflectionScale = Math.Clamp(profile.ReflectionScale, 0.5f, 1.5f),
+ Blur = Math.Clamp(profile.Blur, 0, 4),
+ Sigma = Math.Clamp(profile.Sigma, 0.25f, 3),
+ NormalStrength = Math.Clamp(profile.NormalStrength, 0, 24),
+ NormalPeriod = Math.Clamp(profile.NormalPeriod, 8, 256),
+ Pitch = Math.Clamp(profile.Pitch, -60, 60),
+ Yaw = Math.Clamp(profile.Yaw, -60, 60),
+ Roll = Math.Clamp(profile.Roll, -90, 90),
+ Distance = Math.Clamp(profile.Distance, 240, 1200),
+ };
+ materials.Add(wallType, (sampling, tint));
+ }
+ }
+ return materials;
+ }
+
+ /// 使用给定材质快照收集可见墙格;每次调用产生独立分组。
+ public IReadOnlyList CollectVisibleWalls(IReadOnlyDictionary materials)
+ {
+ Dictionary parameterGroups = [];
+ Dictionary wallMaterials = [];
+ foreach (var (wallType, material) in materials)
+ {
+ if (!parameterGroups.TryGetValue(material.Definition, out ReflectionGroup group))
+ {
+ group = new ReflectionGroup(material.Definition);
+ parameterGroups.Add(material.Definition, group);
+ }
+ // 每类墙只查找一次参数分组,逐格扫描时直接追加墙格。
+ wallMaterials.Add(wallType, (group, material.Tint));
+ }
+
+ Matrix inverseView = Matrix.Invert(Main.GameViewMatrix.TransformationMatrix);
+ Vector2 first = Vector2.Transform(Vector2.Zero, inverseView) + Main.screenPosition;
+ Vector2 second = Vector2.Transform(new Vector2(Main.screenWidth, Main.screenHeight), inverseView) + Main.screenPosition;
+ Vector2 topLeft = Vector2.Min(first, second);
+ Vector2 bottomRight = Vector2.Max(first, second);
+ int startX = Math.Max(0, (int)(topLeft.X / 16) - 1);
+ int startY = Math.Max(0, (int)(topLeft.Y / 16) - 1);
+ int endX = Math.Min(Main.maxTilesX - 1, (int)(bottomRight.X / 16) + 1);
+ int endY = Math.Min(Main.maxTilesY - 1, (int)(bottomRight.Y / 16) + 1);
+ for (int x = startX; x <= endX; x++)
+ {
+ for (int y = startY; y <= endY; y++)
+ {
+ Tile tile = Main.tile[x, y];
+ if (tile.IsWallInvisible || !wallMaterials.TryGetValue(tile.WallType, out var wall))
+ {
+ continue;
+ }
+ // 不按 HasTile 丢弃整格;随后绘制的前景纹理负责逐像素遮挡。
+ wall.Group.Walls.Add((new Point(x, y), wall.Tint));
+ }
+ }
+ return parameterGroups.Values.Where(group => group.Walls.Count > 0).ToArray();
+ }
+}
diff --git a/Sources/Everglow.Function/Mechanics/MirrorReflection/MirrorReflectionConfig.cs b/Sources/Everglow.Function/Mechanics/MirrorReflection/MirrorReflectionConfig.cs
new file mode 100644
index 000000000..e0c2ad81f
--- /dev/null
+++ b/Sources/Everglow.Function/Mechanics/MirrorReflection/MirrorReflectionConfig.cs
@@ -0,0 +1,57 @@
+using System.ComponentModel;
+using Terraria.ModLoader.Config;
+
+namespace Everglow.Commons.Mechanics.MirrorReflection;
+
+///
+/// 模糊反射的采样精度档位;较低档位减少采样次数,保留材质指定的采样范围。
+///
+public enum MirrorReflectionQuality
+{
+ ///
+ /// 低开销滤波。
+ ///
+ Low,
+
+ ///
+ /// 平衡滤波精度与开销。
+ ///
+ Medium,
+
+ ///
+ /// 使用完整的九点高斯滤波。
+ ///
+ High,
+}
+
+///
+/// 客户端反射画质设置;墙面的形状、位置、颜色和反射强度由各自材质定义。
+///
+public class MirrorReflectionConfig : ModConfig
+{
+ public override ConfigScope Mode => ConfigScope.ClientSide;
+
+ ///
+ /// 获取或设置是否启用反射;关闭时跳过场景捕获和倒影绘制。
+ ///
+ [DefaultValue(true)]
+ public bool Enabled = true;
+
+ ///
+ /// 获取或设置模糊反射的滤波精度,不覆盖墙面材质参数。
+ ///
+ [DefaultValue(MirrorReflectionQuality.High)]
+ [DrawTicks]
+ public MirrorReflectionQuality Quality = MirrorReflectionQuality.High;
+
+ ///
+ /// 获取每个方向的模糊采样数量;未知配置值回退到当前默认的高画质。
+ ///
+ /// 低、中、高画质分别对应 3、5、9 个采样点。
+ public int GetBlurSampleCount() => Quality switch
+ {
+ MirrorReflectionQuality.Low => 3,
+ MirrorReflectionQuality.Medium => 5,
+ _ => 9,
+ };
+}
diff --git a/Sources/Everglow.Function/Mechanics/MirrorReflection/MirrorReflectionSystem.cs b/Sources/Everglow.Function/Mechanics/MirrorReflection/MirrorReflectionSystem.cs
new file mode 100644
index 000000000..5fdc32ac6
--- /dev/null
+++ b/Sources/Everglow.Function/Mechanics/MirrorReflection/MirrorReflectionSystem.cs
@@ -0,0 +1,254 @@
+using Everglow.Commons.Mechanics.MirrorReflection.Core;
+using Everglow.Commons.ObjectPool;
+using Everglow.Commons.Utilities;
+
+namespace Everglow.Commons.Mechanics.MirrorReflection;
+
+///
+/// 反射系统入口:加载原版材质、接受内容注册,并管理墙面收集、场景捕获和绘制的生命周期。
+///
+public class MirrorReflectionSystem : ModSystem
+{
+ private MirrorWallReflectionCollector wallCollector;
+ private MirrorScreenCapture sceneCapture;
+ private MirrorScreenCaptureRenderHooks captureRenderHooks;
+ private bool drawingWorld;
+ private MirrorReflectionRenderer renderer;
+ private MirrorNormalMapProvider normalMaps;
+ private IReadOnlyList groups = [];
+
+ ///
+ /// 在内容的 PostSetupContent 中调用,附加或覆盖一种墙的反射材质。
+ ///
+ /// 已分配的墙类型编号。
+ /// 该类型墙面使用的材质。
+ public void RegisterWall(int wallType, MirrorReflectionDefinition profile) => wallCollector.Register(wallType, profile);
+
+ ///
+ /// 在内容的 PostSetupContent 中注册动态材质,例如演示面板参数。
+ ///
+ /// 已分配的墙类型编号。
+ /// 每次收集可见墙面时调用一次的参数提供者。
+ public void RegisterWall(int wallType, Func provider) => wallCollector.Register(wallType, provider);
+
+ public override void Load()
+ {
+ wallCollector = new MirrorWallReflectionCollector(VanillaMirrorWallProfiles.Create());
+ if (!Main.dedServ)
+ {
+ renderer = new MirrorReflectionRenderer();
+ normalMaps = new MirrorNormalMapProvider();
+ sceneCapture = new MirrorScreenCapture();
+ captureRenderHooks = new MirrorScreenCaptureRenderHooks();
+ // 原版绘制接入由 System 持有,所有阶段均由下方钩子主动调用。
+ On_Main.DoDraw += DrawFrame;
+ On_Main.DoDraw_WallsTilesNPCs += DrawWorld;
+ On_Main.DrawWoF += BeginForeground;
+ On_Main.DrawItems += EndForeground;
+ On_Main.DrawMap += DrawMap;
+ }
+ }
+
+ public override void Unload()
+ {
+ if (!Main.dedServ)
+ {
+ On_Main.DoDraw -= DrawFrame;
+ On_Main.DoDraw_WallsTilesNPCs -= DrawWorld;
+ On_Main.DrawWoF -= BeginForeground;
+ On_Main.DrawItems -= EndForeground;
+ On_Main.DrawMap -= DrawMap;
+ }
+ sceneCapture?.Release();
+ captureRenderHooks?.Dispose();
+ captureRenderHooks = null;
+ drawingWorld = false;
+ sceneCapture = null;
+ normalMaps?.Dispose();
+ normalMaps = null;
+ renderer = null;
+ wallCollector = null;
+ groups = [];
+ }
+
+ public override void OnWorldLoad() => groups = [];
+
+ public override void OnWorldUnload() => groups = [];
+
+ private void DrawFrame(On_Main.orig_DoDraw orig, Main self, GameTime gameTime)
+ {
+ try
+ {
+ orig(self, gameTime);
+ }
+ finally
+ {
+ try
+ {
+ // 异常或其它模组跳过 DrawItems 时,恢复原画面,不绘制倒影。
+ if (sceneCapture.IsCapturing && sceneCapture.IsDrawingToScreenTarget())
+ {
+ FinishCapture(false);
+ }
+ }
+ finally
+ {
+ sceneCapture.Release();
+ captureRenderHooks.Reset();
+ }
+ }
+ }
+
+ private void DrawWorld(On_Main.orig_DoDraw_WallsTilesNPCs orig, Main self)
+ {
+ drawingWorld = true;
+ try
+ {
+ orig(self);
+ }
+ finally
+ {
+ drawingWorld = false;
+ }
+ }
+
+ private void BeginForeground(On_Main.orig_DrawWoF orig, Main self)
+ {
+ // DrawWoF 紧接墙面绘制;即使没有血肉墙,原版也会调用此入口。
+ if (drawingWorld && !sceneCapture.IsCapturing && PrepareCapture())
+ {
+ // Begin 恢复外层批次时就应启用捕获混合规则;未开始捕获则立即关闭。
+ captureRenderHooks.Capturing = true;
+ try
+ {
+ sceneCapture.Begin();
+ }
+ finally
+ {
+ captureRenderHooks.Capturing = sceneCapture.IsCapturing;
+ }
+ }
+ orig(self);
+ }
+
+ private void EndForeground(On_Main.orig_DrawItems orig, Main self)
+ {
+ orig(self);
+ if (sceneCapture.IsCapturing && sceneCapture.IsDrawingToScreenTarget())
+ {
+ FinishCapture(true);
+ }
+ }
+
+ private void DrawMap(On_Main.orig_DrawMap orig, Main self, GameTime gameTime)
+ {
+ if (!sceneCapture.BeginMap())
+ {
+ orig(self, gameTime);
+ return;
+ }
+ try
+ {
+ orig(self, gameTime);
+ }
+ finally
+ {
+ sceneCapture.EndMap();
+ }
+ }
+
+ private bool PrepareCapture()
+ {
+ if (Main.dedServ || Main.gameMenu || Main.mapFullscreen || Main.drawToScreen || !Ins.VisualQuality.High)
+ {
+ return false;
+ }
+ MirrorReflectionConfig config = ModContent.GetInstance();
+ if (!config.Enabled)
+ {
+ return false;
+ }
+ // 1. 解析材质定义与参数;2. 收集可见墙格并完成分组。
+ var materials = wallCollector.ResolveMaterials();
+ groups = wallCollector.CollectVisibleWalls(materials);
+ return groups.Count > 0;
+ }
+
+ /// 主动完成前景捕获、倒影处理、场景合成与特效恢复;失败时恢复原画面。
+ private void FinishCapture(bool drawReflection)
+ {
+ var state = Main.spriteBatch.GetState();
+ if (state.HasValue)
+ {
+ Main.spriteBatch.End();
+ }
+ bool foregroundSaved = false;
+ bool composed = false;
+ ResourceLocker projectionTarget = null;
+ ResourceLocker reflectionTargets = null;
+ try
+ {
+ sceneCapture.SaveForeground();
+ foregroundSaved = true;
+ captureRenderHooks.Capturing = false;
+
+ if (drawReflection)
+ {
+ projectionTarget = Ins.RenderTargetPool.GetRenderTarget2D();
+ reflectionTargets = Ins.RenderTargetPool.GetRenderTarget2DArray(groups.Count);
+ ProcessReflections(projectionTarget.Resource, reflectionTargets.Resource);
+ }
+
+ sceneCapture.RestoreBackground();
+ if (drawReflection)
+ {
+ renderer.DrawReflection(reflectionTargets.Resource, groups);
+ }
+ sceneCapture.RestoreForeground();
+ composed = true;
+
+ // Warp/屏幕采样反射使用完整场景;普通 VFX 留在原层并参与倒影。
+ captureRenderHooks.Flush();
+ }
+ finally
+ {
+ try
+ {
+ if (foregroundSaved && !composed)
+ {
+ sceneCapture.RestoreBackground();
+ sceneCapture.RestoreForeground();
+ }
+ }
+ finally
+ {
+ try
+ {
+ reflectionTargets?.Release();
+ projectionTarget?.Release();
+ sceneCapture.Release();
+ captureRenderHooks.Reset();
+ }
+ finally
+ {
+ if (state.HasValue)
+ {
+ Main.spriteBatch.Begin(state.Value);
+ }
+ }
+ }
+ }
+ }
+
+ /// 取得法线纹理,并为各组生成共用的倒影。
+ private void ProcessReflections(RenderTarget2D projectionTarget, RenderTarget2D[] reflectionTargets)
+ {
+ int sampleCount = ModContent.GetInstance().GetBlurSampleCount();
+ for (int i = 0; i < groups.Count; i++)
+ {
+ var definition = groups[i].Definition;
+ Texture2D normal = normalMaps.GetTexture(definition.NormalPattern);
+ renderer.ProcessForeground(sceneCapture.Foreground, projectionTarget, reflectionTargets[i], definition, normal, sampleCount);
+ }
+ }
+}
diff --git a/Sources/Everglow.Function/Mechanics/MirrorReflection/README.md b/Sources/Everglow.Function/Mechanics/MirrorReflection/README.md
new file mode 100644
index 000000000..5e66a3927
--- /dev/null
+++ b/Sources/Everglow.Function/Mechanics/MirrorReflection/README.md
@@ -0,0 +1,133 @@
+# 镜墙反射
+
+系统把前景保存为纹理,经过投影、法线扰动和模糊处理,再裁剪绘制到注册的背景墙格子里。原墙照常绘制,玩家和物块仍能挡住倒影。
+
+反射只使用捕获到的屏幕图像,不会从镜子位置重新绘制世界。镜面角度只改变倒影,墙格保持原位。当前支持完整的 16×16 Wall 区域,不提供 Tile 镜面或自定义遮罩。
+
+## 阅读路线
+
+- 熟悉 tModLoader:看[注册墙](#register)和[绘制钩子](#hooks)。
+- 初次阅读:按画面分层、执行顺序、文件职责往下读。
+- 修改代码:从[文件表](#files)定位。
+
+## 画面与执行顺序
+
+RenderTarget2D 是供 GPU 绘制的临时画布。保存背景后,前景在透明底上继续绘制;合成时,透明区域露出后面的墙和倒影。箭头表示接着画在上面;地图单独保存,恢复时保持原有遮挡顺序。
+
+```mermaid
+flowchart TB
+ B["背景与原墙纹理"] --> R["倒影:只画在注册墙的格子里"]
+ R --> F["原始前景:物块、NPC、玩家、物品等"]
+ F --> E["需要完整画面的延迟特效"]
+```
+
+[System](MirrorReflectionSystem.cs) 决定调用顺序:
+
+1. 解析定义,收集可见墙格并分组;没有反光墙就不捕获。
+2. 保存背景,清为透明,让原版继续绘制前景。
+3. 保存前景,排除单独保存的地图层。
+4. 每组取得法线纹理,执行投影、扰动及可选的横纵高斯模糊。
+5. 恢复背景,绘制倒影,再恢复原前景与地图。
+6. 执行延迟特效,归还临时纹理,恢复 SpriteBatch 状态。
+
+```mermaid
+sequenceDiagram
+ participant G as 原版绘制
+ participant S as System
+ participant W as Collector
+ participant C as Capture
+ participant N as Normals
+ participant R as Renderer
+
+ G->>S: DrawWoF 钩子
+ S->>W: ResolveMaterials / CollectVisibleWalls
+ W-->>S: 可见墙格与分组
+ S->>C: Begin:保存背景,清为透明
+ S->>G: orig:继续原版绘制
+ Note over G: 继续绘制前景
+ G->>S: DrawItems 钩子
+ S->>G: orig:绘制物品
+ G-->>S: 返回
+ S->>C: SaveForeground
+ loop 每个采样分组
+ S->>N: GetTexture
+ N-->>S: 法线纹理
+ S->>R: ProcessForeground
+ end
+ S->>C: RestoreBackground
+ S->>R: DrawReflection
+ S->>C: RestoreForeground
+ Note over S: 执行延迟特效,释放临时资源
+```
+
+
+
+### 绘制钩子
+
+System 在客户端 `Load()` 注册五个 `On_Main` 钩子,`Unload()` 对应注销。`FinishCapture()` 集中写明倒影处理和最终绘制顺序。
+
+| 原版方法 | System 方法 | 用途 |
+| --- | --- | --- |
+| `DoDraw` | `DrawFrame` | 帧末检查未完成捕获并清理 |
+| `DoDraw_WallsTilesNPCs` | `DrawWorld` | 标记正常世界绘制 |
+| `DrawWoF` | `BeginForeground` | orig 前保存已画好的背景与墙 |
+| `DrawItems` | `EndForeground` | orig 后保存前景并合成 |
+| `DrawMap` | `DrawMap` | orig 前后保存地图分层 |
+
+没有血肉墙时也会调用 `DrawWoF`;这里利用它紧接墙面绘制的位置。升级 tModLoader 时需核对原版顺序。Capture 不注册钩子,也不回调 System。
+
+
+
+## 各文件负责什么
+
+| 文件 | 职责与修改内容 |
+| --- | --- |
+| [System](MirrorReflectionSystem.cs) | 原版钩子、调用顺序、失败恢复 |
+| [Definition](Core/MirrorReflectionDefinition.cs) | 反射设置;完整范围见 XML 注释 |
+| [Collector](Core/MirrorWallReflectionCollector.cs) | 注册、限制参数范围、计算颜色、收集墙格和分组 |
+| [Capture](Core/MirrorScreenCapture.cs) | 保存和恢复背景、前景、地图 |
+| [Normals](Core/MirrorNormalMapProvider.cs) | 生成、缓存和释放法线纹理 |
+| [Renderer](Core/MirrorReflectionRenderer.cs) | 处理倒影、按墙格裁剪绘制 |
+| [Shader](Core/MirrorReflectionShader.fx) | GPU 采样;投影、边界与高斯权重计算在 Renderer 内 |
+| [ScreenCaptureRenderHooks](Core/MirrorScreenCaptureRenderHooks.cs) | 捕获期间的混合状态与 VFX 绘制钩子,由 System 控制执行时机 |
+| [VanillaProfiles](VanillaMirrorWallProfiles.cs) / [Config](MirrorReflectionConfig.cs) | 原版材质列表 / 全局开关与画质 |
+
+两面墙透明度不同、其他采样设置相同时,可以共用一张倒影。Collector 先把 `Opacity`、`Strength` 换算为每墙颜色,再将分组副本的两值统一为 1;原定义不变。
+
+分组不按墙面连通范围计算。投影基准是本地玩家脚底加 `gfxOffY`,墙面大小不会改变它。每组处理整张前景,因此不同采样定义越多,全屏处理和纹理开销越大。
+
+
+
+## 注册墙与调整效果
+
+将下面的方法放入所属模块现有 `ModSystem`,覆盖原版玻璃墙设置:
+
+```csharp
+using Everglow.Commons.Mechanics.MirrorReflection;
+using Everglow.Commons.Mechanics.MirrorReflection.Core;
+using Terraria.ID;
+using Terraria.ModLoader;
+
+public override void PostSetupContent()
+{
+ ModContent.GetInstance().RegisterWall(
+ WallID.Glass,
+ new MirrorReflectionDefinition(0.35f, 1) { Blur = 0 });
+}
+```
+
+自定义墙改用 `ModContent.WallType<你的墙类型>()`;同类型后注册覆盖先注册。墙的 `PreDraw()` 无需调用倒影绘制,示例见 [YggdrasilWorldSystem](../../../Modules/Yggdrasil/YggdrasilWorldSystem.cs)。
+
+动态重载接受 `Func`。每次解析会对所有注册类型各调用一次,不逐格调用,参考 [AdvancedMirrorWallProfiles](../../../Modules/Example/AdvancedMirror/AdvancedMirrorWallProfiles.cs)。
+
+| 参数 | 含义 |
+| --- | --- |
+| `Opacity` / `Strength` | 覆盖率 / RGB 亮度;Strength 为 0 仍有黑色覆盖,关闭倒影用 Opacity 为 0 |
+| `Blur` / `Sigma` | 模糊采样间距 / 权重分布;Blur 为 0 跳过模糊 |
+| `NormalPattern` / `NormalPeriod` / `NormalStrength` | 法线图案 / 世界像素周期 / 位移系数 |
+| `Offset` / `ReflectionScale` / `FlipHorizontally` | 世界像素偏移 / 缩放 / 左右翻转,默认不翻转 |
+| `Pitch` / `Yaw` / `Roll` / `Distance` | 投影角度 / 虚拟透视距离 |
+
+全局画质只选择每方向 3、5、9 点模糊采样,保持相同最远采样范围。各墙自行定义材质。输入仍须是有限浮点数和有效枚举。
+
+[Advanced 面板](../../../Modules/Example/AdvancedMirror/AdvancedMirrorConfigUI.cs) 只影响演示墙。[演示墙 PreDraw](../../../Modules/Example/AdvancedMirror/AdvancedMirrorWall.cs) 用 TileBatch 和角点光照绘制底色;Surface RGB 调的是这层表面,当前倒影没有独立 RGB 调色。
diff --git a/Sources/Everglow.Function/Mechanics/MirrorReflection/VanillaMirrorWallProfiles.cs b/Sources/Everglow.Function/Mechanics/MirrorReflection/VanillaMirrorWallProfiles.cs
new file mode 100644
index 000000000..4d1a5aa3f
--- /dev/null
+++ b/Sources/Everglow.Function/Mechanics/MirrorReflection/VanillaMirrorWallProfiles.cs
@@ -0,0 +1,76 @@
+using Everglow.Commons.Mechanics.MirrorReflection.Core;
+using Terraria.ID;
+
+namespace Everglow.Commons.Mechanics.MirrorReflection;
+
+/// 原版墙的初始反射材质。覆盖率优先保留墙面纹理,不改变其原版绘制。
+public static class VanillaMirrorWallProfiles
+{
+ public static Dictionary Create()
+ {
+ Dictionary profiles = [];
+ // 普通玻璃使用清晰倒影;花窗玻璃、冰雪和镒墙使用细密强扰动。
+ Add(0.5f, 1, 0, WallID.Glass);
+ var stainedGlass = profiles[WallID.Glass] with { Blur = 1, NormalPeriod = 12, NormalStrength = 24 };
+ int[] stainedGlassWalls =
+ [
+ WallID.PurpleStainedGlass, WallID.YellowStainedGlass, WallID.BlueStainedGlass,
+ WallID.GreenStainedGlass, WallID.RedStainedGlass, WallID.OrangeStainedGlass, WallID.RainbowStainedGlass,
+ WallID.IceEcho, WallID.IceUnsafe, WallID.ShimmerBlockWall, WallID.ShimmerBrickWall,
+ ];
+ foreach (int wall in stainedGlassWalls)
+ {
+ profiles.Add(wall, stainedGlass);
+ }
+ // 外层玻璃负责反光,内部的瀑布、沙尘、雪花、彩纸和星光不改变玻璃材质。
+ Add(0.35f, 1, 0, WallID.SandFall, WallID.SnowFall, WallID.Confetti, WallID.ConfettiBlack,
+ WallID.GoldStarryGlassWall, WallID.BlueStarryGlassWall, WallID.Waterfall, WallID.Lavafall, WallID.Honeyfall);
+ Add(0.32f, 1, 0, WallID.Crystal);
+ Add(0.24f, 0.95f, 0, WallID.IceBrick);
+
+ // 自发光宝石保留较多底色;未通电版本略增强倒影。
+ Add(0.22f, 1, 0, WallID.AmberGemspark, WallID.AmethystGemspark, WallID.DiamondGemspark,
+ WallID.EmeraldGemspark, WallID.RubyGemspark, WallID.SapphireGemspark, WallID.TopazGemspark);
+ Add(0.28f, 0.95f, 0, WallID.AmberGemsparkOff, WallID.AmethystGemsparkOff, WallID.DiamondGemsparkOff,
+ WallID.EmeraldGemsparkOff, WallID.RubyGemsparkOff, WallID.SapphireGemsparkOff, WallID.TopazGemsparkOff);
+
+ // 光面大理石使用清晰倒影;花岗岩与金属砖保留柔和反射。
+ Add(0.24f, 0.95f, 1, WallID.GraniteBlock);
+ Add(0.20f, 0.9f, 0, WallID.MarbleBlock);
+ Add(0.26f, 1, 1, WallID.CopperPlating, WallID.TinPlating, WallID.ShroomitePlating, WallID.MartianConduit);
+ Add(0.18f, 0.95f, 1, WallID.GoldBrick, WallID.SilverBrick, WallID.PlatinumBrick);
+ Add(0.14f, 0.9f, 1, WallID.CopperBrick, WallID.TinBrick, WallID.TungstenBrick,
+ WallID.IronBrick, WallID.LeadBrick, WallID.CobaltBrick, WallID.MythrilBrick);
+ Add(0.16f, 0.9f, 1, WallID.ObsidianBrick, WallID.IridescentBrick);
+
+ // 糖衣与气球使用轻微模糊,凝胶保留清晰倒影。
+ Add(0.18f, 0.9f, 0.5f, WallID.CandyCane, WallID.GreenCandyCane,
+ WallID.SillyBalloonPinkWall, WallID.SillyBalloonGreenWall, WallID.SillyBalloonPurpleWall);
+ Add(0.22f, 0.9f, 0, WallID.Slime);
+
+ // 柱梁、夜明砖及其月相变体使用普通模糊。
+ Add(0.24f, 0.95f, 1, WallID.PalladiumColumn, WallID.AdamantiteBeam);
+ Add(0.20f, 0.95f, 1, WallID.LunarBrickWall);
+ Add(0.18f, 0.95f, 1, WallID.LunarRustBrickWall, WallID.DarkCelestialBrickWall,
+ WallID.AstraBrickWall, WallID.CosmicEmberBrickWall, WallID.CryocoreBrickWall,
+ WallID.MercuryBrickWall, WallID.StarRoyaleBrickWall, WallID.HeavenforgeBrickWall);
+
+ // 远古金属砖沿用对应现代砖的反射强度。
+ Add(0.18f, 0.95f, 1, WallID.AncientGoldBrickWall, WallID.AncientSilverBrickWall);
+ Add(0.14f, 0.9f, 1, WallID.AncientCopperBrickWall, WallID.AncientCobaltBrickWall, WallID.AncientMythrilBrickWall);
+
+ return profiles;
+
+ void Add(float opacity, float strength, float blur, params int[] walls)
+ {
+ var profile = new MirrorReflectionDefinition(opacity, strength)
+ {
+ Blur = blur,
+ };
+ foreach (int wall in walls)
+ {
+ profiles.Add(wall, profile);
+ }
+ }
+ }
+}
diff --git a/Sources/Everglow.Function/UI/UISystem.cs b/Sources/Everglow.Function/UI/UISystem.cs
index 77bab6516..16898b38b 100644
--- a/Sources/Everglow.Function/UI/UISystem.cs
+++ b/Sources/Everglow.Function/UI/UISystem.cs
@@ -217,6 +217,7 @@ private void PrepareUIRenderTarget()
sb.End();
gd.SetRenderTarget(Main.screenTarget);
+ gd.Clear(Color.Transparent); // RenderTarget2D 重绑后,内容是不透明黑底,需要清为透明再恢复前景
sb.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointWrap, DepthStencilState.None, RasterizerState.CullNone, null, Main.GameViewMatrix.TransformationMatrix);
sb.Draw(screenSwap, Vector2.zeroVector, Color.White);
sb.End();
diff --git a/Sources/Everglow.UnitTests/Function/MirrorMathTest.cs b/Sources/Everglow.UnitTests/Function/MirrorMathTest.cs
new file mode 100644
index 000000000..90a47be53
--- /dev/null
+++ b/Sources/Everglow.UnitTests/Function/MirrorMathTest.cs
@@ -0,0 +1,96 @@
+using System.Reflection;
+using Everglow.Commons.Mechanics.MirrorReflection.Core;
+using Microsoft.Xna.Framework;
+
+namespace Everglow.UnitTests.Function;
+
+[TestClass]
+public class MirrorMathTest
+{
+ // 直接绑定 Renderer 的纯计算方法,保留测试且不扩大生产代码的可见范围。
+ internal static readonly Func CreateScreenProjection =
+ Bind>("CreateScreenProjection");
+
+ internal static readonly Func CreateGaussianKernel =
+ Bind>("CreateGaussianKernel");
+
+ internal static readonly Func GetScreenRectangle =
+ Bind>("GetScreenRectangle");
+
+ [TestMethod]
+ public void GaussianKernel_IsNormalizedSymmetricAndPeaksAtCenter()
+ {
+ float[] kernel = CreateGaussianKernel(1.5f, 9);
+ Assert.AreEqual(1f, kernel.Sum(), 0.00001f);
+ for (int i = 0; i < 4; i++)
+ {
+ Assert.AreEqual(kernel[i], kernel[8 - i], 0.00001f);
+ Assert.IsTrue(kernel[i] < kernel[i + 1]);
+ }
+ }
+
+ [TestMethod]
+ [DataRow(3)]
+ [DataRow(5)]
+ [DataRow(9)]
+ public void GaussianQuality_PreservesEnergySymmetryAndShaderBufferSize(int sampleCount)
+ {
+ float[] kernel = CreateGaussianKernel(1.5f, sampleCount);
+ Assert.AreEqual(9, kernel.Length);
+ Assert.AreEqual(1f, kernel.Sum(), 0.00001f);
+ for (int i = 0; i < sampleCount; i++)
+ {
+ Assert.IsTrue(kernel[i] > 0);
+ Assert.AreEqual(kernel[i], kernel[sampleCount - 1 - i], 0.00001f);
+ }
+ Assert.IsTrue(kernel.Skip(sampleCount).All(weight => weight == 0));
+ }
+
+ [TestMethod]
+ public void GaussianQuality_RejectsUnsupportedSampleBudgets()
+ {
+ Assert.ThrowsExactly(() => CreateGaussianKernel(1.5f, 1));
+ Assert.ThrowsExactly(() => CreateGaussianKernel(1.5f, 4));
+ }
+
+ [TestMethod]
+ public void GaussianKernel_WiderSigmaSpreadsWeight()
+ {
+ float[] sharp = CreateGaussianKernel(0.5f, 9);
+ float[] soft = CreateGaussianKernel(3, 9);
+ Assert.IsTrue(sharp[4] > soft[4]);
+ Assert.IsTrue(sharp[0] < soft[0]);
+ }
+
+ [TestMethod]
+ public void GaussianKernel_RejectsInvalidSigma()
+ {
+ Assert.ThrowsExactly(() => CreateGaussianKernel(0, 9));
+ Assert.ThrowsExactly(() => CreateGaussianKernel(float.NaN, 9));
+ }
+
+ [TestMethod]
+ public void Projection_ZeroAnglesPreserveOldTuningAndFootAnchor()
+ {
+ Matrix matrix = CreateScreenProjection(new(50, 144), new(12, -10), 1.5f, true, Vector3.Zero, 480, Matrix.Identity);
+ Assert.AreEqual(new Vector4(77, 104, 0, 1), Vector4.Transform(new Vector4(40, 124, 0, 1), matrix));
+ Assert.AreEqual(new Vector4(62, 134, 0, 1), Vector4.Transform(new Vector4(50, 144, 0, 1), matrix));
+ }
+
+ [TestMethod]
+ public void Projection_YawCreatesPerspectiveWithoutMovingAnchor()
+ {
+ Matrix matrix = CreateScreenProjection(Vector2.Zero, Vector2.Zero, 1, false, new(0, MathHelper.Pi / 3, 0), 480, Matrix.Identity);
+ Vector4 projected = Vector4.Transform(new Vector4(100, 0, 0, 1), matrix);
+ Assert.AreEqual(50f, projected.X, 0.0001f);
+ Assert.AreEqual(1.18042195f, projected.W, 0.00001f);
+ Assert.AreEqual(Vector4.UnitW, Vector4.Transform(Vector4.UnitW, matrix));
+ }
+
+ private static T Bind(string name)
+ where T : Delegate
+ => typeof(MirrorReflectionDefinition).Assembly
+ .GetType("Everglow.Commons.Mechanics.MirrorReflection.Core.MirrorReflectionRenderer", throwOnError: true)!
+ .GetMethod(name, BindingFlags.Static | BindingFlags.NonPublic)!
+ .CreateDelegate();
+}
diff --git a/Sources/Everglow.UnitTests/Function/MirrorNormalMapProviderTest.cs b/Sources/Everglow.UnitTests/Function/MirrorNormalMapProviderTest.cs
new file mode 100644
index 000000000..1735d2195
--- /dev/null
+++ b/Sources/Everglow.UnitTests/Function/MirrorNormalMapProviderTest.cs
@@ -0,0 +1,70 @@
+using Everglow.Commons.Mechanics.MirrorReflection.Core;
+using Microsoft.Xna.Framework;
+
+namespace Everglow.UnitTests.Function;
+
+[TestClass]
+public class MirrorNormalMapProviderTest
+{
+ [TestMethod]
+ public void NormalMap_IsPeriodicUnitLengthAndPointsOutward()
+ {
+ Vector3 normal = MirrorNormalMapProvider.SampleNormal(0.25f, 0);
+ Assert.AreEqual(0.4472136f, normal.X, 0.00001f);
+ Assert.AreEqual(0.8944272f, normal.Z, 0.00001f);
+ Assert.AreEqual(1f, normal.Length(), 0.00001f);
+ Assert.IsTrue(Vector3.Distance(normal, MirrorNormalMapProvider.SampleNormal(1.25f, 1)) < 0.00001f);
+ }
+
+ [TestMethod]
+ public void FunhouseNormals_ArePeriodicUnitLengthWithPositiveDepth()
+ {
+ foreach (MirrorNormalPattern pattern in Enum.GetValues())
+ {
+ for (int i = 0; i < 8; i++)
+ {
+ float u = i / 7f;
+ float v = 1 - u;
+ Vector3 normal = MirrorNormalMapProvider.SampleNormal(u, v, pattern);
+ Assert.AreEqual(1f, normal.Length(), 0.00001f, pattern.ToString());
+ Assert.IsTrue(normal.Z > 0.25f, pattern.ToString());
+ Assert.IsTrue(Vector3.Distance(normal, MirrorNormalMapProvider.SampleNormal(u + 1, v - 2, pattern)) < 0.00001f, pattern.ToString());
+ }
+ }
+ }
+
+ [TestMethod]
+ public void FunhouseWaves_DistortOnlyTheirSelectedAxis()
+ {
+ Vector3 horizontal = MirrorNormalMapProvider.SampleNormal(0.25f, 0.25f, MirrorNormalPattern.HorizontalWave);
+ Vector3 vertical = MirrorNormalMapProvider.SampleNormal(0.25f, 0.25f, MirrorNormalPattern.VerticalWave);
+ Assert.AreEqual(0f, horizontal.Y);
+ Assert.IsTrue(horizontal.X > 0);
+ Assert.AreEqual(0f, vertical.X);
+ Assert.IsTrue(vertical.Y > 0);
+ }
+
+ [TestMethod]
+ public void FunhouseRadialPatterns_SampleInwardOutwardAndTangentially()
+ {
+ Vector3 bulge = MirrorNormalMapProvider.SampleNormal(0.65f, 0.5f, MirrorNormalPattern.Bulge);
+ Vector3 pinch = MirrorNormalMapProvider.SampleNormal(0.65f, 0.5f, MirrorNormalPattern.Pinch);
+ Vector3 twist = MirrorNormalMapProvider.SampleNormal(0.65f, 0.5f, MirrorNormalPattern.Twist);
+ Assert.IsTrue(bulge.X < 0);
+ Assert.IsTrue(pinch.X > 0);
+ Assert.AreEqual(-bulge.X, pinch.X, 0.00001f);
+ Assert.AreEqual(0f, twist.X, 0.00001f);
+ Assert.IsTrue(twist.Y > 0);
+ }
+
+ [TestMethod]
+ public void FunhouseRadialPatterns_JoinWithoutSeamsAtCellEdges()
+ {
+ foreach (var pattern in new[] { MirrorNormalPattern.Bulge, MirrorNormalPattern.Pinch, MirrorNormalPattern.Twist })
+ {
+ Assert.IsTrue(Vector3.Distance(Vector3.UnitZ, MirrorNormalMapProvider.SampleNormal(0, 0.3f, pattern)) < 0.00001f);
+ Assert.IsTrue(Vector3.Distance(Vector3.UnitZ, MirrorNormalMapProvider.SampleNormal(0.3f, 1, pattern)) < 0.00001f);
+ Assert.IsTrue(Vector3.Distance(MirrorNormalMapProvider.SampleNormal(0.9999f, 0.3f, pattern), MirrorNormalMapProvider.SampleNormal(0.0001f, 0.3f, pattern)) < 0.00001f);
+ }
+ }
+}
diff --git a/Sources/Everglow.UnitTests/Function/MirrorReflectionConfigTest.cs b/Sources/Everglow.UnitTests/Function/MirrorReflectionConfigTest.cs
new file mode 100644
index 000000000..29e017485
--- /dev/null
+++ b/Sources/Everglow.UnitTests/Function/MirrorReflectionConfigTest.cs
@@ -0,0 +1,155 @@
+using Everglow.Commons.Mechanics.MirrorReflection;
+using Terraria.ID;
+using Everglow.Commons.Mechanics.MirrorReflection.Core;
+
+namespace Everglow.UnitTests.Function;
+
+[TestClass]
+public class MirrorReflectionConfigTest
+{
+ [TestMethod]
+ [DataRow(MirrorReflectionQuality.Low, 3)]
+ [DataRow(MirrorReflectionQuality.Medium, 5)]
+ [DataRow(MirrorReflectionQuality.High, 9)]
+ public void Quality_SelectsBlurSampleBudget(MirrorReflectionQuality quality, int samples)
+ {
+ var config = new MirrorReflectionConfig { Quality = quality };
+ Assert.AreEqual(samples, config.GetBlurSampleCount());
+ }
+
+ [TestMethod]
+ public void InvalidQuality_FallsBackToCurrentHighQuality()
+ {
+ var config = new MirrorReflectionConfig { Quality = (MirrorReflectionQuality)99 };
+ Assert.AreEqual(9, config.GetBlurSampleCount());
+ }
+
+ [TestMethod]
+ public void OldConfig_RetainsSwitchAndIgnoresRemovedMaterialOverrides()
+ {
+ var config = Newtonsoft.Json.JsonConvert.DeserializeObject(
+ "{\"Enabled\":false,\"OffsetX\":100,\"OpacityMultiplier\":0,\"FlipHorizontally\":true}")!;
+ Assert.IsFalse(config.Enabled);
+ Assert.AreEqual(MirrorReflectionQuality.High, config.Quality);
+ }
+
+ [TestMethod]
+ public void VanillaMaterials_KeepIndependentDefinitions()
+ {
+ var demo = new MirrorReflectionDefinition(1) { NormalPattern = MirrorNormalPattern.Twist, NormalStrength = 20, Yaw = 30, ReflectionScale = 1.4f };
+ var profiles = VanillaMirrorWallProfiles.Create();
+ foreach (var profile in profiles.Values)
+ {
+ var parameters = profile;
+ Assert.AreNotEqual(demo, parameters);
+ Assert.AreEqual(0f, parameters.Yaw);
+ Assert.AreEqual(1f, parameters.ReflectionScale);
+ }
+ var materials = MirrorWallRegistryTest.ResolveMaterials(profiles);
+ Assert.AreEqual(materials[WallID.Glass].Definition, materials[WallID.Lavafall].Definition);
+ Assert.AreEqual(materials[WallID.Glass].Definition, materials[WallID.SnowFall].Definition);
+ }
+
+ [TestMethod]
+ public void VanillaMaterials_CoverRequestedWallsWithFourSamplingGroups()
+ {
+ var profiles = VanillaMirrorWallProfiles.Create();
+ int[] required =
+ {
+ WallID.RainbowStainedGlass, WallID.Waterfall, WallID.Lavafall, WallID.Honeyfall,
+ WallID.AmberGemspark, WallID.AmethystGemspark, WallID.DiamondGemspark, WallID.EmeraldGemspark,
+ WallID.RubyGemspark, WallID.SapphireGemspark, WallID.TopazGemspark,
+ WallID.GraniteBlock, WallID.MarbleBlock, WallID.Crystal, WallID.SandFall, WallID.SnowFall,
+ WallID.Confetti, WallID.ConfettiBlack, WallID.GoldStarryGlassWall, WallID.BlueStarryGlassWall,
+ WallID.PalladiumColumn, WallID.AdamantiteBeam, WallID.CandyCane, WallID.GreenCandyCane,
+ WallID.Slime, WallID.LunarBrickWall, WallID.SillyBalloonPinkWall, WallID.SillyBalloonGreenWall,
+ WallID.SillyBalloonPurpleWall, WallID.IceBrick, WallID.IceEcho, WallID.IceUnsafe, WallID.ShimmerBlockWall, WallID.ShimmerBrickWall,
+ WallID.LunarRustBrickWall, WallID.DarkCelestialBrickWall, WallID.AstraBrickWall,
+ WallID.CosmicEmberBrickWall, WallID.CryocoreBrickWall, WallID.MercuryBrickWall,
+ WallID.StarRoyaleBrickWall, WallID.HeavenforgeBrickWall, WallID.AncientGoldBrickWall,
+ WallID.AncientSilverBrickWall, WallID.AncientCopperBrickWall, WallID.AncientCobaltBrickWall,
+ WallID.AncientMythrilBrickWall,
+ };
+ foreach (int wall in required)
+ {
+ Assert.IsTrue(profiles.ContainsKey(wall), $"Missing WallID {wall}");
+ }
+ // 清晰、轻微模糊、普通模糊、强扰动各共用一次倒影处理。
+ Assert.AreEqual(4, MirrorWallRegistryTest.ResolveMaterials(profiles).Values.Select(material => material.Definition).Distinct().Count());
+ Assert.IsFalse(profiles.ContainsKey(WallID.DirtUnsafe));
+ }
+
+ [TestMethod]
+ public void ClearMaterials_SkipGaussianBlur()
+ {
+ var profiles = VanillaMirrorWallProfiles.Create();
+ int[] walls =
+ [
+ WallID.Glass, WallID.Waterfall, WallID.Lavafall, WallID.Honeyfall, WallID.SandFall,
+ WallID.SnowFall, WallID.Confetti, WallID.ConfettiBlack, WallID.GoldStarryGlassWall,
+ WallID.BlueStarryGlassWall, WallID.Crystal, WallID.IceBrick, WallID.MarbleBlock, WallID.Slime,
+ WallID.AmberGemspark, WallID.AmethystGemspark, WallID.DiamondGemspark,
+ WallID.EmeraldGemspark, WallID.RubyGemspark, WallID.SapphireGemspark, WallID.TopazGemspark,
+ WallID.AmberGemsparkOff, WallID.AmethystGemsparkOff, WallID.DiamondGemsparkOff,
+ WallID.EmeraldGemsparkOff, WallID.RubyGemsparkOff, WallID.SapphireGemsparkOff, WallID.TopazGemsparkOff,
+ ];
+ foreach (int wall in walls)
+ {
+ Assert.AreEqual(0f, profiles[wall].Blur, $"Unexpected blur for WallID {wall}");
+ }
+ }
+
+ [TestMethod]
+ public void DistortedMaterials_UseDenseStrongDistortionWithSoftenedReflection()
+ {
+ var profiles = VanillaMirrorWallProfiles.Create();
+ int[] walls =
+ [
+ WallID.PurpleStainedGlass, WallID.YellowStainedGlass, WallID.BlueStainedGlass,
+ WallID.GreenStainedGlass, WallID.RedStainedGlass, WallID.OrangeStainedGlass, WallID.RainbowStainedGlass,
+ WallID.IceEcho, WallID.IceUnsafe, WallID.ShimmerBlockWall, WallID.ShimmerBrickWall,
+ ];
+ foreach (int wall in walls)
+ {
+ var parameters = profiles[wall];
+ Assert.AreEqual(0.5f, parameters.Opacity);
+ Assert.AreEqual(12f, parameters.NormalPeriod);
+ Assert.AreEqual(24f, parameters.NormalStrength);
+ Assert.AreEqual(1f, parameters.Blur);
+ }
+ Assert.AreEqual(0f, profiles[WallID.Glass].NormalStrength);
+ }
+
+ [TestMethod]
+ public void Glass_HasStrongerReflectionThanGlassEnclosedWalls()
+ {
+ var profiles = VanillaMirrorWallProfiles.Create();
+ Assert.AreEqual(0.5f, profiles[WallID.Glass].Opacity);
+ int[] walls =
+ [
+ WallID.Waterfall, WallID.Lavafall, WallID.Honeyfall, WallID.SandFall,
+ WallID.SnowFall, WallID.Confetti, WallID.ConfettiBlack,
+ WallID.GoldStarryGlassWall, WallID.BlueStarryGlassWall,
+ ];
+ foreach (int wall in walls)
+ {
+ Assert.AreEqual(0.35f, profiles[wall].Opacity, $"Unexpected opacity for WallID {wall}");
+ }
+ }
+
+ [TestMethod]
+ public void CandyAndBalloonWalls_UseLightBlurWithoutDistortion()
+ {
+ var profiles = VanillaMirrorWallProfiles.Create();
+ int[] walls =
+ [
+ WallID.CandyCane, WallID.GreenCandyCane,
+ WallID.SillyBalloonPinkWall, WallID.SillyBalloonGreenWall, WallID.SillyBalloonPurpleWall,
+ ];
+ foreach (int wall in walls)
+ {
+ Assert.AreEqual(0.5f, profiles[wall].Blur, $"Unexpected blur for WallID {wall}");
+ Assert.AreEqual(0f, profiles[wall].NormalStrength, $"Unexpected distortion for WallID {wall}");
+ }
+ }
+}
diff --git a/Sources/Everglow.UnitTests/Function/MirrorSceneProjectionTest.cs b/Sources/Everglow.UnitTests/Function/MirrorSceneProjectionTest.cs
new file mode 100644
index 000000000..de9f6234c
--- /dev/null
+++ b/Sources/Everglow.UnitTests/Function/MirrorSceneProjectionTest.cs
@@ -0,0 +1,60 @@
+using Everglow.Commons.Mechanics.MirrorReflection.Core;
+using Microsoft.Xna.Framework;
+
+namespace Everglow.UnitTests.Function;
+
+[TestClass]
+public class MirrorSceneProjectionTest
+{
+ [TestMethod]
+ public void OrdinaryMirror_PreservesRelativePositionsInAlreadyZoomedScene()
+ {
+ Matrix view = Matrix.CreateScale(2, 2, 1) * Matrix.CreateTranslation(10, 20, 0);
+ Matrix projection = MirrorMathTest.CreateScreenProjection(new(150, 100), Vector2.Zero, 1, false, Vector3.Zero, 480, view);
+ Assert.AreEqual(new Vector2(210, 80), Vector2.Transform(new Vector2(210, 80), projection));
+ Assert.AreEqual(new Vector2(410, 180), Vector2.Transform(new Vector2(410, 180), projection));
+ }
+
+ [TestMethod]
+ public void SideMirror_SwapsObjectsAcrossCommonAnchor()
+ {
+ Matrix projection = MirrorMathTest.CreateScreenProjection(new(150, 100), Vector2.Zero, 1, true, Vector3.Zero, 480, Matrix.Identity);
+ Assert.AreEqual(new Vector2(200, 80), Vector2.Transform(new Vector2(100, 80), projection));
+ Assert.AreEqual(new Vector2(100, 80), Vector2.Transform(new Vector2(200, 80), projection));
+ }
+
+ [TestMethod]
+ public void SceneOffset_IsInWorldPixelsWithoutApplyingCameraZoomTwice()
+ {
+ Matrix view = Matrix.CreateScale(2, 2, 1) * Matrix.CreateTranslation(10, 20, 0);
+ Matrix projection = MirrorMathTest.CreateScreenProjection(new(150, 100), new(12, -5), 1, false, Vector3.Zero, 480, view);
+ Assert.AreEqual(new Vector2(234, 70), Vector2.Transform(new Vector2(210, 80), projection));
+ }
+
+ [TestMethod]
+ public void DefinitionVariants_ReplaceSelectedValuesWithoutMutatingTheirSource()
+ {
+ var original = new MirrorReflectionDefinition(0.4f, 0.8f) { Offset = new(18, 6), Blur = 2, Yaw = 15, FlipHorizontally = true };
+ var variant = original with { Offset = new(-12, original.Offset.Y), Blur = 0, FlipHorizontally = false };
+ Assert.AreEqual(new Vector2(-12, 6), variant.Offset);
+ Assert.AreEqual(0f, variant.Blur);
+ Assert.AreEqual(15f, variant.Yaw);
+ Assert.IsFalse(variant.FlipHorizontally);
+ Assert.AreEqual(0.4f, variant.Opacity);
+ Assert.AreEqual(0.8f, variant.Strength);
+ Assert.AreEqual(2f, original.Blur);
+ }
+
+ [TestMethod]
+ public void OrdinaryDefinition_UsesExpectedDefaults()
+ {
+ var definition = new MirrorReflectionDefinition(1);
+ Assert.AreEqual(new Vector2(12, 0), definition.Offset);
+ Assert.AreEqual(1f, definition.ReflectionScale);
+ Assert.AreEqual(0.5f, definition.Blur);
+ Assert.AreEqual(0f, definition.NormalStrength);
+ Assert.AreEqual(96f, definition.NormalPeriod);
+ Assert.AreEqual(480f, definition.Distance);
+ Assert.AreEqual(MirrorNormalPattern.Wave, definition.NormalPattern);
+ }
+}
diff --git a/Sources/Everglow.UnitTests/Function/MirrorWallGeometryTest.cs b/Sources/Everglow.UnitTests/Function/MirrorWallGeometryTest.cs
new file mode 100644
index 000000000..ebd2d015c
--- /dev/null
+++ b/Sources/Everglow.UnitTests/Function/MirrorWallGeometryTest.cs
@@ -0,0 +1,21 @@
+using Microsoft.Xna.Framework;
+
+namespace Everglow.UnitTests.Function;
+
+[TestClass]
+public class MirrorWallGeometryTest
+{
+ [TestMethod]
+ public void ScreenRectangle_AdjacentWallsShareEdgesAtFractionalZoom()
+ {
+ Matrix view = Matrix.CreateScale(1.3f, 1.3f, 1) * Matrix.CreateTranslation(7.5f, -8.25f, 0);
+ Vector2 camera = new(3.25f, 4.75f);
+ Rectangle first = MirrorMathTest.GetScreenRectangle(new(160, 320), new(16), camera, view);
+ Rectangle right = MirrorMathTest.GetScreenRectangle(new(176, 320), new(16), camera, view);
+ Rectangle below = MirrorMathTest.GetScreenRectangle(new(160, 336), new(16), camera, view);
+ Assert.AreEqual(first.Right, right.Left);
+ Assert.AreEqual(first.Bottom, below.Top);
+ Assert.AreEqual(first.Top, right.Top);
+ Assert.AreEqual(first.Left, below.Left);
+ }
+}
diff --git a/Sources/Everglow.UnitTests/Function/MirrorWallRegistryTest.cs b/Sources/Everglow.UnitTests/Function/MirrorWallRegistryTest.cs
new file mode 100644
index 000000000..a2ac396cd
--- /dev/null
+++ b/Sources/Everglow.UnitTests/Function/MirrorWallRegistryTest.cs
@@ -0,0 +1,227 @@
+using System.Reflection;
+using Everglow.Commons.Mechanics.MirrorReflection;
+using Microsoft.Xna.Framework;
+using Terraria.ID;
+using Everglow.Commons.Mechanics.MirrorReflection.Core;
+
+namespace Everglow.UnitTests.Function;
+
+[TestClass]
+public class MirrorWallRegistryTest
+{
+ [TestMethod]
+ public void AddingDemo_PreservesBuiltInWalls()
+ {
+ var defaults = VanillaMirrorWallProfiles.Create();
+ var system = CreateSystem(defaults);
+ int demoType = WallID.Count;
+ system.RegisterWall(demoType, new MirrorReflectionDefinition(0.85f));
+ var profiles = ReadProfiles(system);
+ Assert.AreEqual(defaults[WallID.Glass], profiles[WallID.Glass]);
+ Assert.AreEqual(defaults.Count + 1, profiles.Count);
+ }
+
+ [TestMethod]
+ public void DynamicRegistration_ReadsCurrentPanelValuesWithoutReimportingDefaults()
+ {
+ var defaults = VanillaMirrorWallProfiles.Create();
+ var system = CreateSystem(defaults);
+ float opacity = 0.85f;
+ int demoType = WallID.Count;
+ system.RegisterWall(demoType, () => new MirrorReflectionDefinition(opacity));
+ Assert.AreEqual(0.85f, ReadProfiles(system)[demoType].Opacity);
+ opacity = 0.4f;
+ var profiles = ReadProfiles(system);
+ Assert.AreEqual(0.4f, profiles[demoType].Opacity);
+ Assert.AreEqual(defaults[WallID.Glass], profiles[WallID.Glass]);
+ }
+
+ [TestMethod]
+ public void ReplacingRegistration_ProducesOneProfilePerWallType()
+ {
+ var system = CreateSystem(VanillaMirrorWallProfiles.Create());
+ system.RegisterWall(WallID.Glass, () => new MirrorReflectionDefinition(0.6f));
+ system.RegisterWall(WallID.Glass, new MirrorReflectionDefinition(0.2f));
+ var profiles = ReadProfiles(system);
+ Assert.AreEqual(0.2f, profiles[WallID.Glass].Opacity);
+ Assert.AreEqual(VanillaMirrorWallProfiles.Create().Count, profiles.Count);
+ }
+
+ [TestMethod]
+ public void ChangingWorlds_PreservesRegisteredMaterials()
+ {
+ var defaults = VanillaMirrorWallProfiles.Create();
+ var system = CreateSystem(defaults);
+ int demoType = WallID.Count;
+ system.RegisterWall(demoType, new MirrorReflectionDefinition(0.7f));
+ system.OnWorldUnload();
+ system.OnWorldLoad();
+ var profiles = ReadProfiles(system);
+ Assert.AreEqual(0.7f, profiles[demoType].Opacity);
+ Assert.AreEqual(defaults[WallID.Glass], profiles[WallID.Glass]);
+ Assert.AreEqual(defaults.Count + 1, profiles.Count);
+ }
+
+ [TestMethod]
+ public void ResolveMaterials_UsesSameSamplingKeyForDifferentOpacitiesAndStrengths()
+ {
+ var system = CreateSystem(new Dictionary
+ {
+ [WallID.Glass] = new(0.35f) { Blur = 0 },
+ [WallID.MarbleBlock] = new(0.2f, 0.9f) { Blur = 0 },
+ [WallID.GoldBrick] = new(0.18f) { Blur = 1 },
+ });
+ var materials = ResolveMaterials(system);
+ Assert.AreEqual(materials[WallID.Glass].Definition, materials[WallID.MarbleBlock].Definition);
+ Assert.AreNotEqual(materials[WallID.Glass].Tint, materials[WallID.MarbleBlock].Tint);
+ var groups = materials.Values.Select(material => material.Definition).Distinct().ToArray();
+ Assert.AreEqual(2, groups.Length);
+ CollectionAssert.AreEquivalent(new[] { 0f, 1f }, groups.Select(group => group.Blur).ToArray());
+ }
+
+ [TestMethod]
+ public void ResolveMaterials_ReadsDynamicDefinitionOnceAndReturnsIndependentSnapshots()
+ {
+ var system = CreateSystem(new Dictionary());
+ int calls = 0;
+ float opacity = 0.5f;
+ system.RegisterWall(WallID.Glass, () =>
+ {
+ calls++;
+ return new MirrorReflectionDefinition(opacity);
+ });
+ var first = ResolveMaterials(system);
+ Assert.AreEqual(1, first.Count);
+ Assert.AreEqual(1, calls);
+
+ opacity = 0;
+ Assert.AreEqual(0, ResolveMaterials(system).Count);
+ Assert.AreEqual(1, first.Count);
+ Assert.AreEqual(new Color(0.5f, 0.5f, 0.5f, 0.5f), first[WallID.Glass].Tint);
+ Assert.AreEqual(2, calls);
+ }
+
+ [TestMethod]
+ [DataRow(0.4f, 1f, 0.4f, 0.4f)]
+ [DataRow(1f, 1f, 1f, 1f)]
+ [DataRow(0.8f, 0.5f, 0.4f, 0.8f)]
+ [DataRow(0.8f, 0f, 0f, 0.8f)]
+ [DataRow(2f, 2f, 1f, 1f)]
+ [DataRow(0.8f, -1f, 0f, 0.8f)]
+ public void ResolveMaterials_PremultipliesBrightnessWithoutChangingCoverage(float opacity, float strength, float rgb, float alpha)
+ {
+ var materials = ResolveMaterials(new Dictionary
+ {
+ [WallID.Glass] = new(opacity, strength),
+ });
+ Assert.AreEqual(new Color(rgb, rgb, rgb, alpha), materials[WallID.Glass].Tint);
+ }
+
+ [TestMethod]
+ public void ResolveMaterials_SkipsInvisibleAndDefaultDefinitions()
+ {
+ var materials = ResolveMaterials(new Dictionary
+ {
+ [WallID.Glass] = new(0),
+ [WallID.MarbleBlock] = new(-1),
+ [WallID.GoldBrick] = default,
+ });
+ Assert.AreEqual(0, materials.Count);
+ }
+
+ [TestMethod]
+ public void ResolveMaterials_ClampsSamplingWithoutMutatingRegisteredDefinition()
+ {
+ var definition = new MirrorReflectionDefinition(0.5f)
+ {
+ Offset = new(-200, 50),
+ ReflectionScale = 0,
+ Blur = 10,
+ Sigma = 0,
+ NormalStrength = 50,
+ NormalPeriod = 0,
+ Distance = 0,
+ Pitch = 90,
+ Yaw = -90,
+ Roll = 180,
+ };
+ var system = CreateSystem(new Dictionary { [WallID.Glass] = definition });
+ var resolved = ResolveMaterials(system)[WallID.Glass].Definition;
+ Assert.AreEqual(0.5f, resolved.ReflectionScale);
+ Assert.AreEqual(0.25f, resolved.Sigma);
+ Assert.AreEqual(8f, resolved.NormalPeriod);
+ Assert.AreEqual(240f, resolved.Distance);
+ Assert.AreEqual(60f, resolved.Pitch);
+ Assert.AreEqual(new Vector2(-100, 30), resolved.Offset);
+ Assert.AreEqual(4f, resolved.Blur);
+ Assert.AreEqual(24f, resolved.NormalStrength);
+ Assert.AreEqual(-60f, resolved.Yaw);
+ Assert.AreEqual(90f, resolved.Roll);
+ Assert.AreEqual(definition, ReadProfiles(system)[WallID.Glass]);
+ }
+
+ [TestMethod]
+ [DataRow(8f)]
+ [DataRow(12f)]
+ [DataRow(16f)]
+ public void ResolveMaterials_PreservesFineNormalPeriods(float period)
+ {
+ var materials = ResolveMaterials(new Dictionary
+ {
+ [WallID.Glass] = new(0.35f) { NormalPeriod = period, NormalStrength = 24 },
+ });
+ Assert.AreEqual(period, materials[WallID.Glass].Definition.NormalPeriod);
+ Assert.AreEqual(24f, materials[WallID.Glass].Definition.NormalStrength);
+ }
+
+ [TestMethod]
+ public void ResolveMaterials_DistinguishesEverySamplingSetting()
+ {
+ var original = new MirrorReflectionDefinition(1);
+ MirrorReflectionDefinition[] definitions =
+ [
+ original,
+ original with { Offset = new(20, 0) },
+ original with { ReflectionScale = 1.2f },
+ original with { FlipHorizontally = true },
+ original with { Blur = 2 },
+ original with { Sigma = 2 },
+ original with { NormalPattern = MirrorNormalPattern.Twist },
+ original with { NormalStrength = 20 },
+ original with { NormalPeriod = 128 },
+ original with { Pitch = 20 },
+ original with { Yaw = 20 },
+ original with { Roll = 20 },
+ original with { Distance = 600 },
+ ];
+ var materials = ResolveMaterials(definitions.Select((definition, index) => (definition, index))
+ .ToDictionary(entry => entry.index + 1, entry => entry.definition));
+ Assert.AreEqual(definitions.Length, materials.Values.Select(material => material.Definition).Distinct().Count());
+ }
+
+ internal static Dictionary ResolveMaterials(IReadOnlyDictionary definitions)
+ => ResolveMaterials(CreateSystem(definitions));
+
+ private static Dictionary ResolveMaterials(MirrorReflectionSystem system)
+ {
+ var collector = typeof(MirrorReflectionSystem).GetField("wallCollector", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(system)!;
+ return (Dictionary)collector.GetType().GetMethod("ResolveMaterials")!.Invoke(collector, null)!;
+ }
+
+ // 只初始化材质存储,避免调用 Load 安装图形钩子或启动 Terraria.Main。
+ private static MirrorReflectionSystem CreateSystem(IReadOnlyDictionary defaults)
+ {
+ var system = new MirrorReflectionSystem();
+ var field = typeof(MirrorReflectionSystem).GetField("wallCollector", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ field.SetValue(system, Activator.CreateInstance(field.FieldType, new object[] { defaults }));
+ return system;
+ }
+
+ private static Dictionary ReadProfiles(MirrorReflectionSystem system)
+ {
+ var collector = typeof(MirrorReflectionSystem).GetField("wallCollector", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(system)!;
+ var providers = (Dictionary>)collector.GetType()
+ .GetField("profiles", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(collector)!;
+ return providers.ToDictionary(pair => pair.Key, pair => pair.Value());
+ }
+}
diff --git a/Sources/Everglow/Localization/en-US/Mods.Everglow.Configs.hjson b/Sources/Everglow/Localization/en-US/Mods.Everglow.Configs.hjson
new file mode 100644
index 000000000..dd1ef08da
--- /dev/null
+++ b/Sources/Everglow/Localization/en-US/Mods.Everglow.Configs.hjson
@@ -0,0 +1,140 @@
+MirrorReflectionConfig: {
+ DisplayName: Mirror Reflections (Client)
+ Enabled: {
+ Label: Enable reflections
+ Tooltip: "Skip reflection capture and drawing for reflective vanilla walls and the Advanced Mirror demo when disabled. Wall surfaces remain visible. Client only; requires High visual quality and Color/White lighting."
+ }
+ Quality: {
+ Label: Reflection quality
+ Tooltip: "Controls the smoothness of blurred reflections. Low reduces filtering cost, Medium balances quality and cost, and High keeps full filtering quality. Does not change wall offsets, flipping, colors, or reflection strength."
+ }
+}
+
+MirrorReflectionQuality: {
+ Tooltip: Controls filtering quality for blurred reflections.
+ Low.Label: Low
+ Medium.Label: Medium
+ High.Label: High
+}
+
+EverglowClientConfig: {
+ DisplayName: Everglow Client Config
+
+ Headers: {
+ AudioReplace: Audio Replace Header
+ TextureReplace: Texture Replace Header
+ }
+
+ ItemPickSoundReplace: {
+ Label: Item Pick Sound Replace
+ Tooltip: ""
+ }
+
+ TextureReplace: {
+ Label: Texture Replace
+ Tooltip: ""
+ }
+
+ TuskAudioReplace: {
+ Label: Tusk Audio Replace
+ Tooltip: ""
+ }
+}
+
+EverglowConfig: {
+ DisplayName: Everglow Config
+
+ debugMode: {
+ Label: debug Mode
+ Tooltip: ""
+ }
+}
+
+FogConfigs: {
+ DisplayName: Fog Configs
+
+ BloomIntensity: {
+ Label: Bloom Intensity
+ Tooltip: ""
+ }
+
+ EnableLightUpload: {
+ Label: Enable Light Upload
+ Tooltip: ""
+ }
+
+ EnableScattering: {
+ Label: Enable Scattering
+ Tooltip: ""
+ }
+
+ EnableTemporalInterp: {
+ Label: Enable Temporal Interp
+ Tooltip: ""
+ }
+
+ FogAbsorptionB: {
+ Label: Fog Absorption B
+ Tooltip: ""
+ }
+
+ FogAbsorptionG: {
+ Label: Fog Absorption G
+ Tooltip: ""
+ }
+
+ FogAbsorptionR: {
+ Label: Fog Absorption R
+ Tooltip: ""
+ }
+
+ FogBloomAbsorptionFactor: {
+ Label: Fog Bloom Absorption Factor
+ Tooltip: ""
+ }
+
+ FogBloomRate: {
+ Label: Fog Bloom Rate
+ Tooltip: ""
+ }
+
+ FogScatterWithDistance: {
+ Label: Fog Scatter With Distance
+ Tooltip: ""
+ }
+
+ GaussianKernel: {
+ Label: Gaussian Kernel
+ Tooltip: ""
+ }
+
+ LightLuminanceThreashold: {
+ Label: Light Luminance Threashold
+ Tooltip: ""
+ }
+
+ MaxBloomRadius: {
+ Label: Max Bloom Radius
+ Tooltip: ""
+ }
+
+ OffscreenTiles: {
+ Label: Offscreen Tiles
+ Tooltip: ""
+ }
+}
+
+TextureReplaceMode: {
+ Tooltip: ""
+ Default.Label: Default
+ EternalResolve.Label: Eternal Resolve
+ Everglow.Label: Everglow
+ Myth.Label: Myth
+ Terraria.Label: Terraria
+}
+
+TuskAudioReplaceMode: {
+ Tooltip: ""
+ OldTuskFighting.Label: Old Tusk Fighting
+ TuskFighting.Label: Tusk Fighting
+}
diff --git a/Sources/Everglow/Localization/en-US/Mods.Everglow.Examples.hjson b/Sources/Everglow/Localization/en-US/Mods.Everglow.Examples.hjson
new file mode 100644
index 000000000..8d8e38166
--- /dev/null
+++ b/Sources/Everglow/Localization/en-US/Mods.Everglow.Examples.hjson
@@ -0,0 +1,38 @@
+AdvancedMirror: {
+ Name: Advanced Mirror Wall Demo
+ Tooltip: "Tintable translucent mirror reflecting tiles, players, NPCs and dropped items\nHold to tune; ordinary mirrors do not flip by default\nRequires Color/White lighting; settings affect only this client"
+ Panel: {
+ Title: Advanced mirror — drag to move
+ OffsetX: X offset
+ OffsetY: Y offset
+ Scale: Scale
+ Blur: Blur spacing
+ Sigma: Gaussian sigma
+ NormalPattern: Funhouse (click to cycle)
+ PatternWave: Original ripples
+ PatternHorizontalWave: Wide / narrow
+ PatternVerticalWave: Tall / short
+ PatternBulge: Bulge
+ PatternPinch: Pinch
+ PatternTwist: Swirl
+ NormalStrength: Normal strength
+ NormalPeriod: Normal period
+ Pitch: Pitch (degrees)
+ Yaw: Yaw (degrees)
+ Roll: Roll (degrees)
+ Distance: Perspective depth
+ SurfaceRed: Surface R
+ SurfaceGreen: Surface G
+ SurfaceBlue: Surface B
+ SurfaceOpacity: Surface opacity
+ ReflectionRed: Reflection R
+ ReflectionGreen: Reflection G
+ ReflectionBlue: Reflection B
+ ReflectionOpacity: Reflection strength
+ Flip: Flip entire foreground
+ On: On
+ Off: Off
+ Reset: Reset
+ ScrollHint: Scroll here for more parameters
+ }
+}
diff --git a/Sources/Everglow/Localization/en-US/Mods.Everglow.hjson b/Sources/Everglow/Localization/en-US/Mods.Everglow.hjson
index 8c239c998..8c616b672 100644
--- a/Sources/Everglow/Localization/en-US/Mods.Everglow.hjson
+++ b/Sources/Everglow/Localization/en-US/Mods.Everglow.hjson
@@ -7,130 +7,6 @@ Bestiary: {
Common.Message.HasAwoken: has awoken!
-Configs: {
- EverglowClientConfig: {
- DisplayName: Everglow Client Config
-
- Headers: {
- AudioReplace: Audio Replace Header
- TextureReplace: Texture Replace Header
- }
-
- ItemPickSoundReplace: {
- Label: Item Pick Sound Replace
- Tooltip: ""
- }
-
- TextureReplace: {
- Label: Texture Replace
- Tooltip: ""
- }
-
- TuskAudioReplace: {
- Label: Tusk Audio Replace
- Tooltip: ""
- }
- }
-
- EverglowConfig: {
- DisplayName: Everglow Config
-
- debugMode: {
- Label: debug Mode
- Tooltip: ""
- }
- }
-
- FogConfigs: {
- DisplayName: Fog Configs
-
- BloomIntensity: {
- Label: Bloom Intensity
- Tooltip: ""
- }
-
- EnableLightUpload: {
- Label: Enable Light Upload
- Tooltip: ""
- }
-
- EnableScattering: {
- Label: Enable Scattering
- Tooltip: ""
- }
-
- EnableTemporalInterp: {
- Label: Enable Temporal Interp
- Tooltip: ""
- }
-
- FogAbsorptionB: {
- Label: Fog Absorption B
- Tooltip: ""
- }
-
- FogAbsorptionG: {
- Label: Fog Absorption G
- Tooltip: ""
- }
-
- FogAbsorptionR: {
- Label: Fog Absorption R
- Tooltip: ""
- }
-
- FogBloomAbsorptionFactor: {
- Label: Fog Bloom Absorption Factor
- Tooltip: ""
- }
-
- FogBloomRate: {
- Label: Fog Bloom Rate
- Tooltip: ""
- }
-
- FogScatterWithDistance: {
- Label: Fog Scatter With Distance
- Tooltip: ""
- }
-
- GaussianKernel: {
- Label: Gaussian Kernel
- Tooltip: ""
- }
-
- LightLuminanceThreashold: {
- Label: Light Luminance Threashold
- Tooltip: ""
- }
-
- MaxBloomRadius: {
- Label: Max Bloom Radius
- Tooltip: ""
- }
-
- OffscreenTiles: {
- Label: Offscreen Tiles
- Tooltip: ""
- }
- }
-
- TextureReplaceMode: {
- Tooltip: ""
- Default.Label: Default
- EternalResolve.Label: Eternal Resolve
- Everglow.Label: Everglow
- Myth.Label: Myth
- Terraria.Label: Terraria
- }
-
- TuskAudioReplaceMode: {
- Tooltip: ""
- OldTuskFighting.Label: Old Tusk Fighting
- TuskFighting.Label: Tusk Fighting
- }
-}
-
InfoDisplays: {
FoodSatietyInfoDisplay.DisplayName: Food Satiety Info Display
ThirstystateInfoDisplay.DisplayName: Thirstystate Info Display
diff --git a/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.Configs.hjson b/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.Configs.hjson
new file mode 100644
index 000000000..ba8a02efc
--- /dev/null
+++ b/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.Configs.hjson
@@ -0,0 +1,140 @@
+MirrorReflectionConfig: {
+ DisplayName: 镜面反射(客户端)
+ Enabled: {
+ Label: 启用镜面反射
+ Tooltip: "关闭后跳过原版反光墙和 Advanced 演示镜墙的反射捕获与绘制,保留墙面本身。仅影响本机。反射需要高画质及彩色/白色光照模式。"
+ }
+ Quality: {
+ Label: 反射质量
+ Tooltip: "控制模糊倒影的平滑度。低档开销较小,中档兼顾画质,高档保留完整滤波效果。不会改变墙面的偏移、翻转、颜色或反射强度。"
+ }
+}
+
+MirrorReflectionQuality: {
+ Tooltip: 控制模糊反射的滤波精度。
+ Low.Label: 低
+ Medium.Label: 中
+ High.Label: 高
+}
+
+EverglowClientConfig: {
+ DisplayName: Everglow 客户端设置
+
+ Headers: {
+ AudioReplace: 音效替换
+ TextureReplace: 贴图替换
+ }
+
+ ItemPickSoundReplace: {
+ Label: 替换物品拾取音效
+ Tooltip: ""
+ }
+
+ TextureReplace: {
+ Label: 贴图替换
+ Tooltip: ""
+ }
+
+ TuskAudioReplace: {
+ Label: 替换 Tusk 音乐
+ Tooltip: ""
+ }
+}
+
+EverglowConfig: {
+ DisplayName: Everglow 设置
+
+ debugMode: {
+ Label: 调试模式
+ Tooltip: ""
+ }
+}
+
+FogConfigs: {
+ DisplayName: 雾效设置
+
+ BloomIntensity: {
+ Label: 光效强度
+ Tooltip: ""
+ }
+
+ EnableLightUpload: {
+ Label: 开启光源数据上传
+ Tooltip: ""
+ }
+
+ EnableScattering: {
+ Label: 开启雾效散射
+ Tooltip: ""
+ }
+
+ EnableTemporalInterp: {
+ Label: 开启帧间插值
+ Tooltip: ""
+ }
+
+ FogAbsorptionB: {
+ Label: 雾吸收率(蓝色通道)
+ Tooltip: ""
+ }
+
+ FogAbsorptionG: {
+ Label: 雾吸收率(绿色通道)
+ Tooltip: ""
+ }
+
+ FogAbsorptionR: {
+ Label: 雾吸收率(红色通道)
+ Tooltip: ""
+ }
+
+ FogBloomAbsorptionFactor: {
+ Label: 雾散射能见度影响系数
+ Tooltip: ""
+ }
+
+ FogBloomRate: {
+ Label: 雾散射扩散率
+ Tooltip: ""
+ }
+
+ FogScatterWithDistance: {
+ Label: 扩散率随距离增大
+ Tooltip: ""
+ }
+
+ GaussianKernel: {
+ Label: 启用高斯卷积核
+ Tooltip: ""
+ }
+
+ LightLuminanceThreashold: {
+ Label: 光源亮度阈值
+ Tooltip: ""
+ }
+
+ MaxBloomRadius: {
+ Label: 最大光晕半径
+ Tooltip: ""
+ }
+
+ OffscreenTiles: {
+ Label: 离屏物块渲染数量
+ Tooltip: ""
+ }
+}
+
+TextureReplaceMode: {
+ Tooltip: ""
+ Default.Label: 默认
+ EternalResolve.Label: Eternal Resolve
+ Everglow.Label: Everglow
+ Myth.Label: Myth
+ Terraria.Label: Terraria
+}
+
+TuskAudioReplaceMode: {
+ Tooltip: ""
+ OldTuskFighting.Label: 旧版 Tusk 战斗音乐
+ TuskFighting.Label: Tusk 战斗音乐
+}
diff --git a/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.Examples.hjson b/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.Examples.hjson
new file mode 100644
index 000000000..c9932486c
--- /dev/null
+++ b/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.Examples.hjson
@@ -0,0 +1,38 @@
+AdvancedMirror: {
+ Name: 扩展镜墙演示
+ Tooltip: "可调色和透明度的镜墙,反射方块、玩家、NPC 和掉落物\n手持时显示独立调参面板;普通镜面默认不翻转\n需要彩色或白色光照;参数仅影响本客户端"
+ Panel: {
+ Title: 扩展镜墙调参 · 拖动标题移动
+ OffsetX: X 偏移
+ OffsetY: Y 偏移
+ Scale: 缩放
+ Blur: 模糊采样间距
+ Sigma: 高斯 Sigma
+ NormalPattern: 哈哈镜(点击切换)
+ PatternWave: 原始双向波纹
+ PatternHorizontalWave: 横向胖瘦
+ PatternVerticalWave: 纵向高矮
+ PatternBulge: 鼓包放大
+ PatternPinch: 凹陷缩小
+ PatternTwist: 旋涡扭曲
+ NormalStrength: 法线扰动强度
+ NormalPeriod: 法线贴图周期
+ Pitch: 俯仰角(度)
+ Yaw: 偏航角(度)
+ Roll: 滚转角(度)
+ Distance: 透视距离
+ SurfaceRed: 底色 R
+ SurfaceGreen: 底色 G
+ SurfaceBlue: 底色 B
+ SurfaceOpacity: 底色不透明度
+ ReflectionRed: 倒影 R
+ ReflectionGreen: 倒影 G
+ ReflectionBlue: 倒影 B
+ ReflectionOpacity: 倒影强度
+ Flip: 前景整体左右翻转
+ On: 开启
+ Off: 关闭
+ Reset: 恢复默认
+ ScrollHint: 在面板内滚轮查看其余参数
+ }
+}
diff --git a/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.hjson b/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.hjson
index 80e054be1..68014110b 100644
--- a/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.hjson
+++ b/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.hjson
@@ -7,130 +7,6 @@ Bestiary: {
Common.Message.HasAwoken: 已苏醒!
-Configs: {
- EverglowClientConfig: {
- DisplayName: Everglow 客户端设置
-
- Headers: {
- AudioReplace: 音效替换
- TextureReplace: 贴图替换
- }
-
- ItemPickSoundReplace: {
- Label: 替换物品拾取音效
- Tooltip: ""
- }
-
- TextureReplace: {
- Label: 贴图替换
- Tooltip: ""
- }
-
- TuskAudioReplace: {
- Label: 替换 Tusk 音乐
- Tooltip: ""
- }
- }
-
- EverglowConfig: {
- DisplayName: Everglow 设置
-
- debugMode: {
- Label: 调试模式
- Tooltip: ""
- }
- }
-
- FogConfigs: {
- DisplayName: 雾效设置
-
- BloomIntensity: {
- Label: 光效强度
- Tooltip: ""
- }
-
- EnableLightUpload: {
- Label: 开启光源数据上传
- Tooltip: ""
- }
-
- EnableScattering: {
- Label: 开启雾效散射
- Tooltip: ""
- }
-
- EnableTemporalInterp: {
- Label: 开启帧间插值
- Tooltip: ""
- }
-
- FogAbsorptionB: {
- Label: 雾吸收率(蓝色通道)
- Tooltip: ""
- }
-
- FogAbsorptionG: {
- Label: 雾吸收率(绿色通道)
- Tooltip: ""
- }
-
- FogAbsorptionR: {
- Label: 雾吸收率(红色通道)
- Tooltip: ""
- }
-
- FogBloomAbsorptionFactor: {
- Label: 雾散射能见度影响系数
- Tooltip: ""
- }
-
- FogBloomRate: {
- Label: 雾散射扩散率
- Tooltip: ""
- }
-
- FogScatterWithDistance: {
- Label: 扩散率随距离增大
- Tooltip: ""
- }
-
- GaussianKernel: {
- Label: 启用高斯卷积核
- Tooltip: ""
- }
-
- LightLuminanceThreashold: {
- Label: 光源亮度阈值
- Tooltip: ""
- }
-
- MaxBloomRadius: {
- Label: 最大光晕半径
- Tooltip: ""
- }
-
- OffscreenTiles: {
- Label: 离屏物块渲染数量
- Tooltip: ""
- }
- }
-
- TextureReplaceMode: {
- Tooltip: ""
- Default.Label: 默认
- EternalResolve.Label: Eternal Resolve
- Everglow.Label: Everglow
- Myth.Label: Myth
- Terraria.Label: Terraria
- }
-
- TuskAudioReplaceMode: {
- Tooltip: ""
- OldTuskFighting.Label: 旧版 Tusk 战斗音乐
- TuskFighting.Label: Tusk 战斗音乐
- }
-}
-
InfoDisplays: {
FoodSatietyInfoDisplay.DisplayName: 饱食度
ThirstystateInfoDisplay.DisplayName: 口渴状态
diff --git a/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorConfigUI.cs b/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorConfigUI.cs
new file mode 100644
index 000000000..975dd7c7c
--- /dev/null
+++ b/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorConfigUI.cs
@@ -0,0 +1,277 @@
+using Everglow.Commons.DataStructures;
+using Terraria.GameInput;
+using Terraria.Localization;
+using Everglow.Commons.Mechanics.MirrorReflection.Core;
+
+namespace Everglow.Example.AdvancedMirror;
+
+public class AdvancedMirrorConfigUI : ModSystem
+{
+ private enum Parameter
+ {
+ OffsetX,
+ OffsetY,
+ Scale,
+ Blur,
+ Sigma,
+ NormalStrength,
+ NormalPeriod,
+ Pitch,
+ Yaw,
+ Roll,
+ Distance,
+ SurfaceRed,
+ SurfaceGreen,
+ SurfaceBlue,
+ SurfaceOpacity,
+ ReflectionOpacity,
+ }
+
+ private sealed class Slider(string key, float min, float max, float initial, float step)
+ {
+ public readonly string Key = key;
+ public readonly float Min = min;
+ public readonly float Max = max;
+ public readonly float Initial = initial;
+ public readonly float Step = step;
+ public float Value = initial;
+ }
+
+ private readonly Slider[] sliders =
+ [
+ new("OffsetX", -100, 100, 12, 1), new("OffsetY", -30, 30, 0, 1), new("Scale", 0.5f, 1.5f, 1, 0.01f),
+ new("Blur", 0, 4, 1, 0.05f), new("Sigma", 0.25f, 3, 1.5f, 0.05f),
+ new("NormalStrength", 0, 24, 4, 0.25f), new("NormalPeriod", 32, 256, 96, 1),
+ new("Pitch", -60, 60, 0, 1), new("Yaw", -60, 60, 0, 1), new("Roll", -90, 90, 0, 1),
+ new("Distance", 240, 1200, 480, 10),
+ new("SurfaceRed", 0, 255, 75, 1), new("SurfaceGreen", 0, 255, 95, 1), new("SurfaceBlue", 0, 255, 115, 1),
+ new("SurfaceOpacity", 0, 1, 1, 0.01f),
+ new("ReflectionOpacity", 0, 1, 0.85f, 0.01f),
+ ];
+
+ private static readonly MirrorNormalPattern[] NormalPatterns = Enum.GetValues();
+
+ private const int Width = 412;
+ private const int TrackLeft = 174;
+ private const int TrackWidth = 150;
+ private Vector2 position;
+ private Vector2 dragOffset;
+ private int dragging = -1;
+ private int topRow;
+ private int lastWheel;
+ private bool positioned;
+ private bool wasDown;
+
+ public Vector2 Offset => new(Value(Parameter.OffsetX), Value(Parameter.OffsetY));
+
+ public float ReflectionScale => Value(Parameter.Scale);
+
+ public bool FlipHorizontally { get; private set; }
+
+ public float Blur => Value(Parameter.Blur);
+
+ public float Sigma => Value(Parameter.Sigma);
+
+ public MirrorNormalPattern NormalPattern { get; private set; }
+
+ public float NormalStrength => Value(Parameter.NormalStrength);
+
+ public float NormalPeriod => Value(Parameter.NormalPeriod);
+
+ public float Distance => Value(Parameter.Distance);
+
+ public Color SurfaceTint => new((int)Value(Parameter.SurfaceRed), (int)Value(Parameter.SurfaceGreen), (int)Value(Parameter.SurfaceBlue));
+
+ public float SurfaceOpacity => Value(Parameter.SurfaceOpacity);
+
+ public float ReflectionOpacity => Value(Parameter.ReflectionOpacity);
+
+ internal MirrorReflectionDefinition Definition => new(ReflectionOpacity)
+ {
+ Offset = Offset,
+ ReflectionScale = ReflectionScale,
+ FlipHorizontally = FlipHorizontally,
+ Blur = Blur,
+ Sigma = Sigma,
+ NormalPattern = NormalPattern,
+ NormalStrength = NormalStrength,
+ NormalPeriod = NormalPeriod,
+ Pitch = Value(Parameter.Pitch),
+ Yaw = Value(Parameter.Yaw),
+ Roll = Value(Parameter.Roll),
+ Distance = Distance,
+ };
+
+ private float Value(Parameter parameter) => sliders[(int)parameter].Value;
+
+ private static bool Visible => !Main.dedServ && !Main.gameMenu && !Main.mapFullscreen && Main.LocalPlayer.active
+ && !Main.LocalPlayer.dead && Main.LocalPlayer.HeldItem.ModItem is AdvancedMirrorItem;
+
+ private static Vector2 Mouse => Vector2.Transform(new Vector2(PlayerInput.MouseInfo.X, PlayerInput.MouseInfo.Y), Matrix.Invert(Main.UIScaleMatrix));
+
+ private Rectangle Panel
+ {
+ get
+ {
+ Vector2 size = Vector2.Transform(PlayerInput.OriginalScreenSize, Matrix.Invert(Main.UIScaleMatrix));
+ int height = (int)Math.Clamp(size.Y - 16, 182, 450);
+ if (!positioned)
+ {
+ position = new Vector2(size.X - Width - 24, 180);
+ positioned = true;
+ }
+ position.X = Math.Clamp(position.X, 8, Math.Max(8, size.X - Width - 8));
+ position.Y = Math.Clamp(position.Y, 8, Math.Max(8, size.Y - height - 8));
+ return new Rectangle((int)position.X, (int)position.Y, Width, height);
+ }
+ }
+
+ public bool CapturesMouse => Visible && (dragging != -1 || Panel.Contains(Mouse.ToPoint()));
+
+ private int VisibleRows(Rectangle panel) => Math.Clamp((panel.Height - 136) / 30, 1, sliders.Length);
+
+ private static Rectangle Track(Rectangle panel, int row) => new(panel.X + TrackLeft, panel.Y + 53 + row * 30, TrackWidth, 8);
+
+ private static Rectangle PatternButton(Rectangle panel) => new(panel.X + 12, panel.Bottom - 96, Width - 24, 26);
+
+ private static Rectangle FlipButton(Rectangle panel) => new(panel.X + 12, panel.Bottom - 38, 240, 26);
+
+ private static Rectangle ResetButton(Rectangle panel) => new(panel.Right - 132, panel.Bottom - 38, 120, 26);
+
+ public override void UpdateUI(GameTime gameTime)
+ {
+ if (Main.dedServ)
+ {
+ return;
+ }
+ bool down = Main.mouseLeft;
+ int wheel = PlayerInput.MouseInfo.ScrollWheelValue;
+ if (!Visible)
+ {
+ dragging = -1;
+ wasDown = down;
+ lastWheel = wheel;
+ return;
+ }
+ Rectangle panel = Panel;
+ Vector2 mouse = Mouse;
+ int count = VisibleRows(panel);
+ topRow = Math.Clamp(topRow, 0, sliders.Length - count);
+ if (CapturesMouse)
+ {
+ Main.LocalPlayer.mouseInterface = true;
+ PlayerInput.LockVanillaMouseScroll("Everglow: Advanced mirror tuning");
+ if (dragging == -1 && wheel != lastWheel)
+ {
+ topRow = Math.Clamp(topRow - Math.Sign(wheel - lastWheel), 0, sliders.Length - count);
+ }
+ }
+ if (down && !wasDown)
+ {
+ if (new Rectangle(panel.X, panel.Y, Width, 34).Contains(mouse.ToPoint()))
+ {
+ dragging = -2;
+ dragOffset = mouse - position;
+ }
+ else if (PatternButton(panel).Contains(mouse.ToPoint()))
+ {
+ CycleNormalPattern();
+ }
+ else if (FlipButton(panel).Contains(mouse.ToPoint()))
+ {
+ FlipHorizontally = !FlipHorizontally;
+ }
+ else if (ResetButton(panel).Contains(mouse.ToPoint()))
+ {
+ foreach (Slider slider in sliders)
+ {
+ slider.Value = slider.Initial;
+ }
+ FlipHorizontally = false;
+ NormalPattern = MirrorNormalPattern.Wave;
+ }
+ else
+ {
+ for (int row = 0; row < count; row++)
+ {
+ Rectangle hit = Track(panel, row);
+ hit.Inflate(6, 8);
+ if (hit.Contains(mouse.ToPoint()))
+ {
+ dragging = topRow + row;
+ break;
+ }
+ }
+ }
+ }
+ if (down && dragging == -2)
+ {
+ position = mouse - dragOffset;
+ }
+ else if (down && dragging >= 0)
+ {
+ Slider slider = sliders[dragging];
+ float fraction = Math.Clamp((mouse.X - panel.X - TrackLeft) / TrackWidth, 0, 1);
+ slider.Value = Math.Clamp(MathF.Round(MathHelper.Lerp(slider.Min, slider.Max, fraction) / slider.Step) * slider.Step, slider.Min, slider.Max);
+ }
+ if (!down)
+ {
+ dragging = -1;
+ }
+ wasDown = down;
+ lastWheel = wheel;
+ }
+
+ public override void PostDrawInterface(SpriteBatch spriteBatch)
+ {
+ if (!Visible)
+ {
+ return;
+ }
+ Rectangle panel = Panel;
+ int count = VisibleRows(panel);
+ topRow = Math.Clamp(topRow, 0, sliders.Length - count);
+ using var scope = (SpriteBatchState.Defered with { TransformMatrix = Main.UIScaleMatrix }).BeginScope(spriteBatch);
+ Texture2D pixel = Commons.ModAsset.White.Value;
+ spriteBatch.Draw(pixel, panel, new Color(18, 24, 34, 235));
+ spriteBatch.Draw(pixel, new Rectangle(panel.X, panel.Y, Width, 34), new Color(39, 58, 80));
+ Utils.DrawBorderString(spriteBatch, Text("Title"), new Vector2(panel.X + 12, panel.Y + 8), Color.White, 0.8f);
+ for (int row = 0; row < count; row++)
+ {
+ Slider slider = sliders[topRow + row];
+ Rectangle track = Track(panel, row);
+ spriteBatch.Draw(Commons.ModAsset.SlideTrack_black.Value, track, Color.White);
+ Texture2D tick = Commons.ModAsset.SlideDefaultCut.Value;
+ Texture2D block = Commons.ModAsset.SlideBlock.Value;
+ float initialX = track.X + (slider.Initial - slider.Min) / (slider.Max - slider.Min) * TrackWidth;
+ float blockX = track.X + (slider.Value - slider.Min) / (slider.Max - slider.Min) * TrackWidth;
+ spriteBatch.Draw(tick, new Vector2(initialX, track.Center.Y), null, Color.SandyBrown, 0, tick.Size() / 2, 1, SpriteEffects.None, 0);
+ spriteBatch.Draw(block, new Vector2(blockX, track.Center.Y), null, dragging == topRow + row ? Color.LightSkyBlue : Color.White, 0, block.Size() / 2, 1, SpriteEffects.None, 0);
+ Utils.DrawBorderString(spriteBatch, Text(slider.Key), new Vector2(panel.X + 12, track.Y - 7), Color.White, 0.7f);
+ Utils.DrawBorderString(spriteBatch, slider.Value.ToString(slider.Step < 1 ? "0.00" : "0"), new Vector2(track.Right + 12, track.Y - 7), Color.White, 0.7f);
+ }
+ Utils.DrawBorderString(spriteBatch, Text("ScrollHint"), new Vector2(panel.X + 12, panel.Bottom - 64), Color.LightGray, 0.65f);
+ DrawButton(spriteBatch, PatternButton(panel), Text("NormalPattern") + ": " + Text("Pattern" + NormalPattern));
+ DrawButton(spriteBatch, FlipButton(panel), Text("Flip") + ": " + Text(FlipHorizontally ? "On" : "Off"));
+ DrawButton(spriteBatch, ResetButton(panel), Text("Reset"));
+ }
+
+ private void CycleNormalPattern()
+ {
+ int index = Array.IndexOf(NormalPatterns, NormalPattern);
+ NormalPattern = NormalPatterns[(index + 1) % NormalPatterns.Length];
+ // 切换时提供明显的预设效果,之后仍可通过原有滑条调节。
+ bool original = NormalPattern == MirrorNormalPattern.Wave;
+ sliders[(int)Parameter.NormalStrength].Value = original ? 4 : 20;
+ sliders[(int)Parameter.NormalPeriod].Value = original ? 96 : 160;
+ sliders[(int)Parameter.Blur].Value = original ? 1 : 0.25f;
+ }
+
+ private static void DrawButton(SpriteBatch batch, Rectangle bounds, string label)
+ {
+ batch.Draw(Commons.ModAsset.White.Value, bounds, bounds.Contains(Mouse.ToPoint()) ? new Color(70, 96, 125) : new Color(42, 59, 78));
+ Utils.DrawBorderString(batch, label, new Vector2(bounds.X + 8, bounds.Y + 4), Color.White, 0.7f);
+ }
+
+ private static string Text(string key) => Language.GetTextValue(AdvancedMirrorItem.TextKey + "Panel." + key);
+}
diff --git a/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorItem.cs b/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorItem.cs
new file mode 100644
index 000000000..136cc470e
--- /dev/null
+++ b/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorItem.cs
@@ -0,0 +1,29 @@
+using Everglow.Commons.Utilities;
+using Terraria.Localization;
+
+namespace Everglow.Example.AdvancedMirror;
+
+public class AdvancedMirrorItem : ModItem
+{
+ internal const string TextKey = "Mods.Everglow.Examples.AdvancedMirror.";
+
+ public override string LocalizationCategory => LocalizationUtils.Categories.Placeables;
+
+ public override string Texture => $"Terraria/Images/Item_{ItemID.IceMirror}";
+
+ public override LocalizedText DisplayName => Language.GetText(TextKey + "Name");
+
+ public override LocalizedText Tooltip => Language.GetText(TextKey + "Tooltip");
+
+ public override void SetDefaults()
+ {
+ Item.DefaultToPlaceableWall(ModContent.WallType());
+ Item.width = 24;
+ Item.height = 28;
+ }
+
+ public override void AddRecipes() => CreateRecipe(24).AddIngredient(ItemID.Glass, 6).AddTile(TileID.WorkBenches).Register();
+
+ public override bool CanUseItem(Player player) => Main.dedServ || player.whoAmI != Main.myPlayer
+ || !ModContent.GetInstance().CapturesMouse;
+}
diff --git a/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorSystem.cs b/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorSystem.cs
new file mode 100644
index 000000000..b3681b758
--- /dev/null
+++ b/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorSystem.cs
@@ -0,0 +1,7 @@
+namespace Everglow.Example.AdvancedMirror;
+
+/// 只将演示墙附加到通用反射系统。
+public class AdvancedMirrorSystem : ModSystem
+{
+ public override void PostSetupContent() => AdvancedMirrorWallProfiles.Register();
+}
diff --git a/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorWall.cs b/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorWall.cs
new file mode 100644
index 000000000..e5bacc1f3
--- /dev/null
+++ b/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorWall.cs
@@ -0,0 +1,64 @@
+using Everglow.Commons.Utilities;
+using Terraria.Graphics;
+using Terraria.Localization;
+
+namespace Everglow.Example.AdvancedMirror;
+
+public class AdvancedMirrorWall : ModWall
+{
+ public override string LocalizationCategory => LocalizationUtils.Categories.Placeables;
+
+ public override string Texture => Commons.ModAsset.White_Mod;
+
+ public override void SetStaticDefaults()
+ {
+ Main.wallHouse[Type] = true;
+ DustType = DustID.Glass;
+ AddMapEntry(new Color(140, 180, 205), Language.GetText(AdvancedMirrorItem.TextKey + "Name"));
+ }
+
+ public override bool PreDraw(int i, int j, SpriteBatch spriteBatch)
+ {
+ if (!Main.dedServ)
+ {
+ AdvancedMirrorConfigUI settings = ModContent.GetInstance();
+ DrawSurface(i, j, settings.SurfaceTint,
+ Math.Clamp(settings.SurfaceOpacity, 0, 1));
+ }
+ return false;
+ }
+
+ ///
+ /// 在墙的 PreDraw 中调用;TileBatch 的 Begin/End 由原版墙绘制阶段管理。
+ ///
+ private static void DrawSurface(int i, int j, Color tint, float opacity)
+ {
+ if (Main.dedServ || opacity <= 0)
+ {
+ return;
+ }
+ // 每个角平均周围四格光照,相邻墙格因此使用同一组边界采样。
+ Lighting.GetCornerColors(i, j, out VertexColors light);
+ Vector2 offset = Main.drawToScreen ? Vector2.Zero : new Vector2(Main.offScreenRange);
+ Vector2 position = new Vector2(i * 16 - (int)Main.screenPosition.X, j * 16 - (int)Main.screenPosition.Y) + offset;
+ Texture2D pixel = Commons.ModAsset.White.Value;
+ Main.tileBatch.Draw(pixel, new Vector4(position.X, position.Y, 16, 16), Tint(light, tint, opacity));
+ }
+
+ ///
+ /// 将光照与表面颜色相乘,并为 AlphaBlend 预乘透明度。
+ ///
+ /// 顶点光照颜色,仅使用 RGB 分量。
+ /// 表面颜色,仅使用 RGB 分量。
+ /// 表面不透明度,会限制到 0 到 1。
+ /// 已预乘透明度的顶点颜色。
+ private static Color ApplyLight(Color light, Color tint, float opacity)
+ {
+ opacity = Math.Clamp(opacity, 0, 1);
+ return new Color(new Vector4(light.ToVector3() * tint.ToVector3() * opacity, opacity));
+ }
+
+ private static VertexColors Tint(VertexColors light, Color tint, float opacity) => new(
+ ApplyLight(light.TopLeftColor, tint, opacity), ApplyLight(light.TopRightColor, tint, opacity),
+ ApplyLight(light.BottomRightColor, tint, opacity), ApplyLight(light.BottomLeftColor, tint, opacity));
+}
diff --git a/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorWallProfiles.cs b/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorWallProfiles.cs
new file mode 100644
index 000000000..0a8fae3b2
--- /dev/null
+++ b/Sources/Modules/Example/AdvancedMirror/AdvancedMirrorWallProfiles.cs
@@ -0,0 +1,13 @@
+using Everglow.Commons.Mechanics.MirrorReflection;
+using Everglow.Commons.Mechanics.MirrorReflection.Core;
+
+namespace Everglow.Example.AdvancedMirror;
+
+/// 只注册演示墙;原版墙列表由 Function 中的主系统加载。
+internal static class AdvancedMirrorWallProfiles
+{
+ public static void Register() => ModContent.GetInstance()
+ .RegisterWall(ModContent.WallType(), ReadPanel);
+
+ private static MirrorReflectionDefinition ReadPanel() => ModContent.GetInstance().Definition;
+}
diff --git a/Sources/Modules/Example/AdvancedMirror/MirrorTile.png b/Sources/Modules/Example/AdvancedMirror/MirrorTile.png
new file mode 100644
index 000000000..fec4a7c93
Binary files /dev/null and b/Sources/Modules/Example/AdvancedMirror/MirrorTile.png differ
diff --git a/Sources/Modules/Example/AdvancedMirror/MirrorTileItem.png b/Sources/Modules/Example/AdvancedMirror/MirrorTileItem.png
new file mode 100644
index 000000000..bdad9d8ea
Binary files /dev/null and b/Sources/Modules/Example/AdvancedMirror/MirrorTileItem.png differ
diff --git a/Sources/Modules/Example/AdvancedMirror/MirrorTile_Reflector.png b/Sources/Modules/Example/AdvancedMirror/MirrorTile_Reflector.png
new file mode 100644
index 000000000..31c7e516a
Binary files /dev/null and b/Sources/Modules/Example/AdvancedMirror/MirrorTile_Reflector.png differ
diff --git a/Sources/Modules/Yggdrasil/YggdrasilWorldSystem.cs b/Sources/Modules/Yggdrasil/YggdrasilWorldSystem.cs
index 347272277..0c84b4fae 100644
--- a/Sources/Modules/Yggdrasil/YggdrasilWorldSystem.cs
+++ b/Sources/Modules/Yggdrasil/YggdrasilWorldSystem.cs
@@ -1,8 +1,9 @@
-using System.IO;
+using Everglow.Commons.Mechanics.MirrorReflection;
+using Everglow.Commons.Mechanics.MirrorReflection.Core;
using Everglow.Commons.Netcode;
-using Everglow.Commons.Utilities;
-using Everglow.Yggdrasil.Netcode;
using Everglow.Yggdrasil.KelpCurtain;
+using Everglow.Yggdrasil.Netcode;
+using Everglow.Yggdrasil.YggdrasilTown.Walls;
using SubworldLibrary;
using Terraria.ModLoader.IO;
@@ -17,6 +18,15 @@ public class YggdrasilWorldSystem : ModSystem, ICopyWorldData
public static bool DownedKingJellyBall;
public static bool EnteredKelpCurtain;
+ public override void PostSetupContent()
+ {
+ var reflections = ModContent.GetInstance();
+ reflections.RegisterWall(ModContent.WallType(), new MirrorReflectionDefinition(0.22f, 0.9f) { Blur = 0 });
+ reflections.RegisterWall(ModContent.WallType(), new MirrorReflectionDefinition(0.26f) { Blur = 1 });
+ reflections.RegisterWall(ModContent.WallType(), new MirrorReflectionDefinition(0.30f) { Blur = 1 });
+ reflections.RegisterWall(ModContent.WallType(), new MirrorReflectionDefinition(0.20f, 0.9f) { Blur = 0 });
+ }
+
public override void OnWorldLoad()
{
if (NetUtils.IsSubServer)