diff --git a/source/MonoGame.Extended.Content.Pipeline/MonoGame.Extended.Content.Pipeline.csproj b/source/MonoGame.Extended.Content.Pipeline/MonoGame.Extended.Content.Pipeline.csproj index 9797b4dd..d2292011 100644 --- a/source/MonoGame.Extended.Content.Pipeline/MonoGame.Extended.Content.Pipeline.csproj +++ b/source/MonoGame.Extended.Content.Pipeline/MonoGame.Extended.Content.Pipeline.csproj @@ -13,9 +13,7 @@ - + diff --git a/source/MonoGame.Extended/Math/FastRandom.cs b/source/MonoGame.Extended/Math/FastRandom.cs index dd0184c0..1d6f8628 100644 --- a/source/MonoGame.Extended/Math/FastRandom.cs +++ b/source/MonoGame.Extended/Math/FastRandom.cs @@ -11,7 +11,7 @@ namespace MonoGame.Extended /// /// /// This implementation uses the same constants as Microsoft Visual C++ rand() function: - /// + /// /// a=214013 /// c=2531011 /// m=2^31 @@ -183,6 +183,15 @@ namespace MonoGame.Extended _impl.NextUnitVector(out vector); } + /// + /// Gets a random unit vector. + /// + /// A pointer to the Vector2 where the random unit vector will be stored. + public unsafe void NextUnitVector(Vector2* vector) + { + _impl.NextUnitVector(vector); + } + #region IFastRandomImplementation private interface IFastRandomImpl { @@ -196,6 +205,7 @@ namespace MonoGame.Extended float NextSingle(Range range); float NextAngle(); void NextUnitVector(out Vector2 vector); + unsafe void NextUnitVector(Vector2* vector); } #endregion @@ -270,6 +280,13 @@ namespace MonoGame.Extended vector.X = MathF.Cos(angle); vector.Y = MathF.Sin(angle); } + + public unsafe void NextUnitVector(Vector2* vector) + { + float angle = NextAngle(); + vector->X = MathF.Cos(angle); + vector->Y = MathF.Sin(angle); + } } #endregion @@ -336,6 +353,11 @@ namespace MonoGame.Extended { LocalRandom.NextUnitVector(out vector); } + + public unsafe void NextUnitVector(Vector2* vector) + { + LocalRandom.NextUnitVector(vector); + } } #endregion } diff --git a/source/MonoGame.Extended/Particles/Data/Particle.cs b/source/MonoGame.Extended/Particles/Data/Particle.cs new file mode 100644 index 00000000..9f650c05 --- /dev/null +++ b/source/MonoGame.Extended/Particles/Data/Particle.cs @@ -0,0 +1,84 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System.Runtime.InteropServices; + +namespace MonoGame.Extended.Particles.Data; + +/// +/// Represents an individual particle within the particle system. +/// +/// +/// The struct uses sequential layout with tight packing to optimize memory usage and performance. +/// The fixed arrays are used to store positional, velocity, and color data efficiently in unmanaged memory. +/// +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public unsafe struct Particle +{ + /// + /// The time (in seconds) when this particle was created. + /// + public float Inception; + + /// + /// The current age (in seconds) of this particle. + /// + public float Age; + + /// + /// The current position of this particle in 2D space [X, Y]. + /// + public fixed float Position[2]; + + /// + /// The current velocity vector of this particle [X, Y]. + /// + public fixed float Velocity[2]; + + /// + /// The color of this particle in RGB format [R, G, B]. + /// + public fixed float Color[3]; + + /// + /// The scale factor applied to this particle's visual representation. + /// + public float Scale; + + /// + /// The position where this particle was triggered or emitted from [X, Y]. + /// + public fixed float TriggeredPos[2]; + + /// + /// The opacity (alpha) value of this particle, ranging from 0.0 (transparent) to 1.0 (opaque). + /// + public float Opacity; + + /// + /// The rotation of this particle in radians. + /// + public float Rotation; + + /// + /// The mass of this particle used during physics calculations. + /// + public float Mass; + + /// + /// The depth at which this particle is rendered, used for layering particles. + /// + /// + /// Values range from 0.0 (front) to 1.0 (back). + /// + public float LayerDepth; + + /// + /// The size of the struct in bytes. + /// + /// + /// Used for memory allocations and buffer operations. + /// + public static readonly int SizeInBytes = Marshal.SizeOf(); +} diff --git a/source/MonoGame.Extended/Particles/Data/ParticleColorParameter.cs b/source/MonoGame.Extended/Particles/Data/ParticleColorParameter.cs new file mode 100644 index 00000000..37e5dc85 --- /dev/null +++ b/source/MonoGame.Extended/Particles/Data/ParticleColorParameter.cs @@ -0,0 +1,181 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Particles.Data; + +/// +/// Represents a color parameter for particle properties that can be either a constant color value +/// or a randomly generated color within a specified range. +/// +/// +/// This struct uses to represent color values, the HSL color space. +/// +public struct ParticleColorParameter : IEquatable +{ + /// + /// The that determines whether this parameter uses a constant value or a randomly + /// generated value. + /// + public ParticleValueKind Kind; + + /// + /// The constant color value when is set to . + /// + public Vector3 Constant; + + /// + /// The minimum color values of the range when is set to . + /// + public Vector3 RandomMin; + + /// + /// The maximum color values of the range when is set to . + /// + public Vector3 RandomMax; + + /// + /// Gets the current color value of this parameter based on its . + /// + /// + /// If is , returns . + /// If is , returns a random color where each component + /// is a random value between the corresponding components of and . + /// The vector components represent HSL. + /// + public Vector3 Value + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + if (Kind == ParticleValueKind.Constant) + { + return Constant; + } + else + { + Vector3 hsl; + hsl.X = FastRandom.Shared.NextSingle(RandomMin.X, RandomMax.X); + hsl.Y = FastRandom.Shared.NextSingle(RandomMin.Y, RandomMax.Y); + hsl.Z = FastRandom.Shared.NextSingle(RandomMin.Z, RandomMax.Z); + return hsl; + } + } + } + + /// + /// Initializes a new instance of the struct with a constant color value. + /// + /// The constant color value for this parameter. + public ParticleColorParameter(Vector3 value) + { + Kind = ParticleValueKind.Constant; + + Constant = value; + + RandomMin = default; + RandomMax = default; + } + + /// + /// Initializes a new instance of the struct with a random color range. + /// + /// The minimum color values of the random range. + /// The maximum color values of the random range. + public ParticleColorParameter(Vector3 rangeStart, Vector3 rangeEnd) + { + Kind = ParticleValueKind.Random; + Constant = default; + + RandomMin = rangeStart; + RandomMax = rangeEnd; + } + + /// + /// Determines whether the specified object is equal to the current parameter. + /// + /// The object to compare with the current parameter. + /// + /// if the specified object is equal tot he current parameter; + /// otherwise, . + /// + public override readonly bool Equals([NotNullWhen(true)] object obj) + { + return obj is ParticleColorParameter other && + Equals(other); + } + + /// + /// Determines whether the specified parameter is equal to the current parameter. + /// + /// The parameter to compare with the current parameter. + /// + /// if the specified parameter is equal to the current parameter; + /// otherwise, . + /// + /// + /// When is only the values are + /// compared. + /// When is , both and + /// values are compared. + /// + public readonly bool Equals(ParticleColorParameter other) + { + if (Kind == ParticleValueKind.Constant) + { + return Constant.Equals(other.Constant); + } + + return RandomMin.Equals(other.RandomMin) && RandomMax.Equals(other.RandomMax); + } + + /// + /// Returns the hash code for this parameter. + /// + /// A 32-bit signed integer that is the hash code for this instance. + /// + /// When is , returns the hash of . + /// When is returns the combined has of + /// and . + /// + public override readonly int GetHashCode() + { + if (Kind == ParticleValueKind.Constant) + { + return Constant.GetHashCode(); + } + + return HashCode.Combine(RandomMin, RandomMax); + } + + /// + /// Determines whether two parameters are equal. + /// + /// The first parameter to compare. + /// The second parameter to compare. + /// + /// if the parameters are equal; otherwise, . + /// + public static bool operator ==(ParticleColorParameter lhs, ParticleColorParameter rhs) + { + return lhs.Equals(rhs); + } + + /// + /// Determines whether two parameters are not equal. + /// + /// The first parameter to compare. + /// The second parameter to compare. + /// + /// if the parameters are not equal; otherwise, . + /// + public static bool operator !=(ParticleColorParameter lhs, ParticleColorParameter rhs) + { + return !lhs.Equals(rhs); + } +} diff --git a/source/MonoGame.Extended/Particles/Data/ParticleFloatParameter.cs b/source/MonoGame.Extended/Particles/Data/ParticleFloatParameter.cs new file mode 100644 index 00000000..71245dd9 --- /dev/null +++ b/source/MonoGame.Extended/Particles/Data/ParticleFloatParameter.cs @@ -0,0 +1,189 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace MonoGame.Extended.Particles.Data; + +/// +/// Represents an floating-point number parameter for particle properties that can be either a constant value or a +/// randomly generated value within a specified range. +/// +public struct ParticleFloatParameter : IEquatable +{ + /// + /// The that determines whether this parameter uses a constant value or a randomly + /// generated value. + /// + public ParticleValueKind Kind; + + /// + /// The constant value when is . + /// + public float Constant; + + /// + /// The minimum value of the range when is . + /// + public float RandomMin; + + /// + /// The maximum value of the range when is . + /// + public float RandomMax; + + /// + /// Gets the current value of this parameter based on its + /// + /// + /// If is , returns . + /// If is , returns a random value between + /// and . + /// + public float Value + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + if (Kind == ParticleValueKind.Constant) + { + return Constant; + } + + return FastRandom.Shared.NextSingle(RandomMin, RandomMax); + } + } + + /// + /// Initializes a new value with a constant value. + /// + /// The constant value for this parameter. + public ParticleFloatParameter(float value) + { + Kind = ParticleValueKind.Constant; + Constant = value; + RandomMin = default; + RandomMax = default; + } + + /// + /// Initializes a new value with a random range. + /// + /// The minimum value of the random range. + /// The maximum value of the random range. + public ParticleFloatParameter(float rangeStart, float rangeEnd) + { + Kind = ParticleValueKind.Random; + Constant = default; + RandomMin = rangeStart; + RandomMax = rangeEnd; + } + + /// + /// Determines whether the specified object is equal to the current parameter. + /// + /// The object to compare with the current parameter. + /// + /// if the specified object is equal tot he current parameter; + /// otherwise, . + /// + public override readonly bool Equals([NotNullWhen(true)] object obj) + { + return obj is ParticleFloatParameter other && + Equals(other); + } + + /// + /// Determines whether the specified parameter is equal to the current parameter. + /// + /// The parameter to compare with the current parameter. + /// + /// if the specified parameter is equal to the current parameter; + /// otherwise, . + /// + /// + /// When is only the values are + /// compared. + /// When is , both and + /// values are compared. + /// + public readonly bool Equals(ParticleFloatParameter other) + { + if (Kind == ParticleValueKind.Constant) + { + return Constant.Equals(other.Constant); + } + + return RandomMin.Equals(other.RandomMin) && + RandomMax.Equals(other.RandomMax); + } + + /// + /// Returns the hash code for this parameter. + /// + /// A 32-bit signed integer that is the hash code for this instance. + /// + /// When is , returns the hash of . + /// When is returns the combined has of + /// and . + /// + public override readonly int GetHashCode() + { + base.GetHashCode(); + if (Kind == ParticleValueKind.Constant) + { + return Constant.GetHashCode(); + } + + return HashCode.Combine(RandomMin, RandomMax); + } + + /// + /// Returns a string representation of this parameter. + /// + /// + /// When is , returns the string representation of + /// . + /// When is , returns a string in the format + /// "MinValue, MaxValue". + /// + public override readonly string ToString() + { + if (Kind == ParticleValueKind.Constant) + { + return Constant.ToString(CultureInfo.InvariantCulture); + } + + return string.Format(NumberFormatInfo.InvariantInfo, "{0}{1}{2}", RandomMin, NumberFormatInfo.InvariantInfo.NumberGroupSeparator, RandomMax); + } + + /// + /// Determines whether two parameters are equal. + /// + /// The first parameter to compare. + /// The second parameter to compare. + /// + /// if the parameters are equal; otherwise, . + /// + public static bool operator ==(ParticleFloatParameter lhs, ParticleFloatParameter rhs) + { + return lhs.Equals(rhs); + } + + /// + /// Determines whether two parameters are not equal. + /// + /// The first parameter to compare. + /// The second parameter to compare. + /// + /// if the parameters are not equal; otherwise, . + /// + public static bool operator !=(ParticleFloatParameter lhs, ParticleFloatParameter rhs) + { + return !lhs.Equals(rhs); + } +} diff --git a/source/MonoGame.Extended/Particles/Data/ParticleInt32Parameter.cs b/source/MonoGame.Extended/Particles/Data/ParticleInt32Parameter.cs new file mode 100644 index 00000000..9e530c59 --- /dev/null +++ b/source/MonoGame.Extended/Particles/Data/ParticleInt32Parameter.cs @@ -0,0 +1,189 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace MonoGame.Extended.Particles.Data; + +/// +/// Represents an integer parameter for particle properties that can be either a constant value or a randomly generated +/// value within a specified range. +/// +public struct ParticleInt32Parameter : IEquatable +{ + /// + /// The that determines whether this parameter uses a constant value or a randomly + /// generated value. + /// + public ParticleValueKind Kind; + + /// + /// The constant value when is . + /// + public int Constant; + + /// + /// The minimum value of the range when is . + /// + public int RandomMin; + + /// + /// The maximum value of the range when is . + /// + public int RandomMax; + + /// + /// Gets the current value of this parameter based on its + /// + /// + /// If is , returns . + /// If is , returns a random value between + /// and . + /// + public int Value + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + if (Kind == ParticleValueKind.Constant) + { + return Constant; + } + + return FastRandom.Shared.Next(RandomMin, RandomMax); + } + } + + /// + /// Initializes a new value with a constant value. + /// + /// The constant value for this parameter. + public ParticleInt32Parameter(int value) + { + Kind = ParticleValueKind.Constant; + Constant = value; + RandomMin = default; + RandomMax = default; + } + + /// + /// Initializes a new value with a random range. + /// + /// The minimum value of the random range. + /// The maximum value of the random range. + public ParticleInt32Parameter(int rangeStart, int rangeEnd) + { + Kind = ParticleValueKind.Random; + Constant = default; + RandomMin = rangeStart; + RandomMax = rangeEnd; + } + + /// + /// Determines whether the specified object is equal to the current parameter. + /// + /// The object to compare with the current parameter. + /// + /// if the specified object is equal tot he current parameter; + /// otherwise, . + /// + public override readonly bool Equals([NotNullWhen(true)] object obj) + { + return obj is ParticleInt32Parameter other && + Equals(other); + } + + /// + /// Determines whether the specified parameter is equal to the current parameter. + /// + /// The parameter to compare with the current parameter. + /// + /// if the specified parameter is equal to the current parameter; + /// otherwise, . + /// + /// + /// When is only the values are + /// compared. + /// When is , both and + /// values are compared. + /// + public readonly bool Equals(ParticleInt32Parameter other) + { + if (Kind == ParticleValueKind.Constant) + { + return Constant.Equals(other.Constant); + } + + return RandomMin.Equals(other.RandomMin) && + RandomMax.Equals(other.RandomMax); + } + + /// + /// Returns the hash code for this parameter. + /// + /// A 32-bit signed integer that is the hash code for this instance. + /// + /// When is , returns the hash of . + /// When is returns the combined has of + /// and . + /// + public override readonly int GetHashCode() + { + base.GetHashCode(); + if (Kind == ParticleValueKind.Constant) + { + return Constant.GetHashCode(); + } + + return HashCode.Combine(RandomMin, RandomMax); + } + + /// + /// Returns a string representation of this parameter. + /// + /// + /// When is , returns the string representation of + /// . + /// When is , returns a string in the format + /// "MinValue, MaxValue". + /// + public override readonly string ToString() + { + if (Kind == ParticleValueKind.Constant) + { + return Constant.ToString(CultureInfo.InvariantCulture); + } + + return string.Format(NumberFormatInfo.InvariantInfo, "{0}{1}{2}", RandomMin, NumberFormatInfo.InvariantInfo.NumberGroupSeparator, RandomMax); + } + + /// + /// Determines whether two parameters are equal. + /// + /// The first parameter to compare. + /// The second parameter to compare. + /// + /// if the parameters are equal; otherwise, . + /// + public static bool operator ==(ParticleInt32Parameter lhs, ParticleInt32Parameter rhs) + { + return lhs.Equals(rhs); + } + + /// + /// Determines whether two parameters are not equal. + /// + /// The first parameter to compare. + /// The second parameter to compare. + /// + /// if the parameters are not equal; otherwise, . + /// + public static bool operator !=(ParticleInt32Parameter lhs, ParticleInt32Parameter rhs) + { + return !lhs.Equals(rhs); + } +} diff --git a/source/MonoGame.Extended/Particles/Data/ParticleReleaseParameters.cs b/source/MonoGame.Extended/Particles/Data/ParticleReleaseParameters.cs new file mode 100644 index 00000000..0917bd80 --- /dev/null +++ b/source/MonoGame.Extended/Particles/Data/ParticleReleaseParameters.cs @@ -0,0 +1,119 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Particles.Data; + +/// +/// Defines the parameters used when releasing particles from an emitter. +/// +/// +/// This class encapsulates all the configurable properties that control how particles are initialized when they are +/// created by the particle system. Each property can be set as either a constant value or a random range. +/// +public class ParticleReleaseParameters +{ + /// + /// Gets or sets the number of particles to release in a single emission. + /// + /// + /// Defaults to a random value between 5 and 100 particles per emission. + /// + public ParticleInt32Parameter Quantity = new ParticleInt32Parameter(5, 100); + + /// + /// Gets or sets the initial speed of particles when released. + /// + /// + /// Defaults to a random value between 50.0 and 100.0 units per second. + /// + public ParticleFloatParameter Speed = new ParticleFloatParameter(50.0f, 100.0f); + + /// + /// Gets or sets the initial color of particles when released. + /// + /// + /// Defaults to white (1.0f, 1.0f, 1.0f). + /// + public ParticleColorParameter Color = new ParticleColorParameter(new Vector3(1.0f, 1.0f, 1.0f)); + + /// + /// Gets or sets the initial opacity of particles when released. + /// + /// + /// Defaults to a random value between 0.0 (transparent) and 1.0 (opaque). + /// + public ParticleFloatParameter Opacity = new ParticleFloatParameter(0.0f, 1.0f); + + /// + /// Gets or sets the initial scale of particles when released. + /// + /// + /// Defaults to a random value between 0.0 (half scale) and 1.0 (full scale) + /// + public ParticleFloatParameter Scale = new ParticleFloatParameter(0.5f, 1.0f); + + /// + /// Gets or sets the initial rotation (in radians) of particles when released. + /// + /// + /// Defaults to a random value between -π and π radians (a full 360° range). + /// + public ParticleFloatParameter Rotation = new ParticleFloatParameter(-MathF.PI, MathF.PI); + + /// + /// Gets or sets the mass of particles when released. + /// + /// + /// Defaults to a constant value of 1.0. + /// + public ParticleFloatParameter Mass = new ParticleFloatParameter(1.0f); + + + /// + /// Initializes a new instance of the class with default values. + /// + /// + /// Default values for properties: + /// + /// + /// + /// Property + /// Default Value + /// + /// + /// Quantity + /// Random: 5-100 particles + /// + /// + /// Speed + /// Random: 50.0-100.0 units/second + /// + /// + /// Color + /// Constant: White (1.0, 1.0, 1.0) + /// + /// + /// Opacity + /// Random: 0.0-1.0 + /// + /// + /// Scale + /// Random: 0.5-1.0 + /// + /// + /// Rotation + /// Random: -π to π radians + /// + /// + /// Mass + /// Constant: 1.0 + /// + /// + /// + /// + public ParticleReleaseParameters() { } +} diff --git a/source/MonoGame.Extended/Particles/Data/ParticleValueKind.cs b/source/MonoGame.Extended/Particles/Data/ParticleValueKind.cs new file mode 100644 index 00000000..41f64c92 --- /dev/null +++ b/source/MonoGame.Extended/Particles/Data/ParticleValueKind.cs @@ -0,0 +1,33 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +namespace MonoGame.Extended.Particles.Data; + +/// +/// Defines how particle property values are determined during particle creation and simulation. +/// +/// +/// This enum is used throughout the particle system to specify whether values should be constant or randomly generated +/// within a specified range. +/// +public enum ParticleValueKind +{ + /// + /// Indicates that a particle property should maintain a constant value. + /// + /// + /// WHen a particle property uses this kind, all particles will have the same value for that property, which remains + /// unchanged unless explicitly modified. + /// + Constant, + + /// + /// Indicates that a particle property should be randomly generated within a specified range. + /// + /// + /// When a particle property uses this kind, each particle will receive a unique random value within the defined + /// minimum and maximum bounds when the particle is created. + /// + Random +} diff --git a/source/MonoGame.Extended/Particles/Data/ParticleVector2Parameter.cs b/source/MonoGame.Extended/Particles/Data/ParticleVector2Parameter.cs new file mode 100644 index 00000000..6877fe3f --- /dev/null +++ b/source/MonoGame.Extended/Particles/Data/ParticleVector2Parameter.cs @@ -0,0 +1,193 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Particles.Data; + +/// +/// Represents an vector parameter for particle properties that can be either a constant value or a +/// randomly generated value within a specified range. +/// +public struct ParticleVector2Parameter : IEquatable +{ + /// + /// The that determines whether this parameter uses a constant value or a randomly + /// generated value. + /// + public ParticleValueKind Kind; + + /// + /// The constant value when is . + /// + public Vector2 Constant; + + /// + /// The minimum value of the range when is . + /// + public Vector2 RandomMin; + + /// + /// The maximum value of the range when is . + /// + public Vector2 RandomMax; + + /// + /// Gets the current value of this parameter based on its + /// + /// + /// If is , returns . + /// If is , returns a random value between + /// and . + /// + public Vector2 Value + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + if (Kind == ParticleValueKind.Constant) + { + return Constant; + } + + Vector2 v; + v.X = FastRandom.Shared.NextSingle(RandomMin.X, RandomMax.X); + v.Y = FastRandom.Shared.NextSingle(RandomMin.Y, RandomMax.Y); + return v; + } + } + + /// + /// Initializes a new value with a constant value. + /// + /// The constant value for this parameter. + public ParticleVector2Parameter(Vector2 value) + { + Kind = ParticleValueKind.Constant; + Constant = value; + RandomMin = default; + RandomMax = default; + } + + /// + /// Initializes a new value with a random range. + /// + /// The minimum value of the random range. + /// The maximum value of the random range. + public ParticleVector2Parameter(Vector2 rangeStart, Vector2 rangeEnd) + { + Kind = ParticleValueKind.Random; + Constant = default; + RandomMin = rangeStart; + RandomMax = rangeEnd; + } + + /// + /// Determines whether the specified object is equal to the current parameter. + /// + /// The object to compare with the current parameter. + /// + /// if the specified object is equal tot he current parameter; + /// otherwise, . + /// + public override readonly bool Equals([NotNullWhen(true)] object obj) + { + return obj is ParticleVector2Parameter other && + Equals(other); + } + + /// + /// Determines whether the specified parameter is equal to the current parameter. + /// + /// The parameter to compare with the current parameter. + /// + /// if the specified parameter is equal to the current parameter; + /// otherwise, . + /// + /// + /// When is only the values are + /// compared. + /// When is , both and + /// values are compared. + /// + public readonly bool Equals(ParticleVector2Parameter other) + { + if (Kind == ParticleValueKind.Constant) + { + return Constant.Equals(other.Constant); + } + + return RandomMin.Equals(other.RandomMin) && + RandomMax.Equals(other.RandomMax); + } + + /// + /// Returns the hash code for this parameter. + /// + /// A 32-bit signed integer that is the hash code for this instance. + /// + /// When is , returns the hash of . + /// When is returns the combined has of + /// and . + /// + public override readonly int GetHashCode() + { + base.GetHashCode(); + if (Kind == ParticleValueKind.Constant) + { + return Constant.GetHashCode(); + } + + return HashCode.Combine(RandomMin, RandomMax); + } + + /// + /// Returns a string representation of this parameter. + /// + /// + /// When is , returns the string representation of + /// . + /// When is , returns a string in the format + /// "MinValue, MaxValue". + /// + public override readonly string ToString() + { + if (Kind == ParticleValueKind.Constant) + { + return Constant.ToString(); + } + + return string.Format(NumberFormatInfo.InvariantInfo, "{0}{1}{2}", RandomMin, NumberFormatInfo.InvariantInfo.NumberGroupSeparator, RandomMax); + } + + /// + /// Determines whether two parameters are equal. + /// + /// The first parameter to compare. + /// The second parameter to compare. + /// + /// if the parameters are equal; otherwise, . + /// + public static bool operator ==(ParticleVector2Parameter lhs, ParticleVector2Parameter rhs) + { + return lhs.Equals(rhs); + } + + /// + /// Determines whether two parameters are not equal. + /// + /// The first parameter to compare. + /// The second parameter to compare. + /// + /// if the parameters are not equal; otherwise, . + /// + public static bool operator !=(ParticleVector2Parameter lhs, ParticleVector2Parameter rhs) + { + return !lhs.Equals(rhs); + } +} diff --git a/source/MonoGame.Extended/Particles/FastRandomExtensions.cs b/source/MonoGame.Extended/Particles/FastRandomExtensions.cs deleted file mode 100644 index 66c99e7e..00000000 --- a/source/MonoGame.Extended/Particles/FastRandomExtensions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using MonoGame.Extended; - -namespace MonoGame.Extended.Particles -{ - public static class FastRandomExtensions - { - public static void NextColor(this FastRandom random, out HslColor color, Range range) - { - var maxH = range.Max.H >= range.Min.H - ? range.Max.H - : range.Max.H + 360; - color = new HslColor(random.NextSingle(range.Min.H, maxH), - random.NextSingle(range.Min.S, range.Max.S), - random.NextSingle(range.Min.L, range.Max.L)); - } - } -} \ No newline at end of file diff --git a/source/MonoGame.Extended/Particles/LineSegment.cs b/source/MonoGame.Extended/Particles/LineSegment.cs deleted file mode 100644 index a3c14226..00000000 --- a/source/MonoGame.Extended/Particles/LineSegment.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using Microsoft.Xna.Framework; - -namespace MonoGame.Extended.Particles -{ - /// - /// Defines a part of a line that is bounded by two distinct end points. - /// - [StructLayout(LayoutKind.Sequential)] - public struct LineSegment : IEquatable - { - internal readonly Vector2 _point1; - internal readonly Vector2 _point2; - - /// - /// Initializes a new instance of the structure. - /// - /// - /// - public LineSegment(Vector2 point1, Vector2 point2) - { - _point1 = point1; - _point2 = point2; - } - - public static LineSegment FromPoints(Vector2 p1, Vector2 p2) => new LineSegment(p1, p2); - public static LineSegment FromOrigin(Vector2 origin, Vector2 vector) => new LineSegment(origin, origin + vector); - - public Vector2 Origin => _point1; - - public Vector2 Direction - { - get - { - var coord = _point2 - _point1; - return new Vector2(coord.X, coord.Y); - } - } - - public LineSegment Translate(Vector2 t) - { - return new LineSegment(_point1 + t, _point2 + t); - } - - public Vector2 ToVector() - { - var t = _point2 - _point1; - return new Vector2(t.X, t.Y); - } - - public bool Equals(LineSegment other) - { - // ReSharper disable ImpureMethodCallOnReadonlyValueField - return _point1.Equals(other._point1) && _point2.Equals(other._point2); - // ReSharper restore ImpureMethodCallOnReadonlyValueField - } - - public override bool Equals(object obj) - { - if (ReferenceEquals(null, obj)) - return false; - - return obj is LineSegment & Equals((LineSegment) obj); - } - - public override int GetHashCode() - { - return (_point1.GetHashCode()*397) ^ _point2.GetHashCode(); - } - - public override string ToString() - { - return $"({_point1.X}:{_point1.Y} {_point2.X}:{_point2.Y})"; - } - } -} \ No newline at end of file diff --git a/source/MonoGame.Extended/Particles/Modifiers/AgeModifier.cs b/source/MonoGame.Extended/Particles/Modifiers/AgeModifier.cs index fac85f06..6f0fabe1 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/AgeModifier.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/AgeModifier.cs @@ -1,26 +1,47 @@ -using System.Collections.Generic; -using System.ComponentModel; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using MonoGame.Extended.Particles.Data; using MonoGame.Extended.Particles.Modifiers.Interpolators; -namespace MonoGame.Extended.Particles.Modifiers -{ - public class AgeModifier : Modifier - { - [EditorBrowsable(EditorBrowsableState.Always)] - public List Interpolators { get; set; } = new List(); +namespace MonoGame.Extended.Particles.Modifiers; - public override unsafe void Update(float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) +/// +/// A modifier that applies multiple interpolators to particles based on their age. +/// +/// +/// The controls how particle properties change over their lifetime +/// by applying a collection of objects to each particle. Each interpolator +/// in the collection operates on a different property of the particle (such as color, scale, or opacity), +/// creating complex, time-based transformations. +/// +/// Unlike other modifiers that apply incremental changes each frame, interpolators directly compute +/// the target property values based on the particle's current age as a fraction of its total lifespan. +/// This provides more predictable and consistent results regardless of frame rate. +/// +public class AgeModifier : Modifier +{ + /// + /// Gets or sets the collection of interpolators that will be applied to particles. + /// + public List Interpolators { get; set; } = new List(); + + /// + /// Updates all particles by applying each interpolator in the collection to each particle. + /// + /// + public override unsafe void Update(float elapsedSeconds, ParticleIterator iterator) + { + while (iterator.HasNext) { - var n = Interpolators.Count; - while (iterator.HasNext) + Particle* particle = iterator.Next(); + + for (int i = 0; i < Interpolators.Count; i++) { - var particle = iterator.Next(); - for (var i = 0; i < n; i++) - { - var interpolator = Interpolators[i]; - interpolator.Update(particle->Age, particle); - } + Interpolators[i].Update(particle->Age, particle); } } } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Containers/CircleContainerModifier.cs b/source/MonoGame.Extended/Particles/Modifiers/Containers/CircleContainerModifier.cs index 30d28020..32327a5a 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/Containers/CircleContainerModifier.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/Containers/CircleContainerModifier.cs @@ -1,52 +1,110 @@ -using System; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; using Microsoft.Xna.Framework; +using MonoGame.Extended.Particles.Data; -namespace MonoGame.Extended.Particles.Modifiers.Containers +namespace MonoGame.Extended.Particles.Modifiers.Containers; + +/// +/// A modifier that constrains particles within or outside of a circular boundary. +/// +/// +/// The keeps particles either inside or outside a circular area, reflecting them +/// at the boundary based on a configurable restitution coefficient. The circle is centered at each particle's trigger +/// position (where it was emitted), creating local containment areas. +/// +public class CircleContainerModifier : Modifier { - public class CircleContainerModifier : Modifier + /// + /// The radius of the circular container. + /// + public float Radius; + + /// + /// Indicates whether particles should be contained inside the circle. + /// + /// + /// + /// + /// When , particles are kept inside the circle (bouncing inward at the boundary). + /// + /// + /// When , particles are kept outside the circle (bouncing outward at the boundary). + /// + /// + /// + /// The default value is . + /// + public bool Inside = true; + + /// + /// Gets or sets the coefficient of restitution (bounciness) for particle collisions with the boundary. + /// + /// + /// + /// + /// A value of 1.0 creates a perfectly elastic collision where particles maintain their energy. + /// + /// + /// Values less than 1.0 create inelastic collisions where particles lose energy with each bounce. + /// + /// + /// Values greater than 1.0 create super-elastic collisions where particles gain energy. + /// + /// + /// + /// The default value is 1.0. + /// + public float RestitutionCoefficient = 1; + + /// + /// Updates all particles by constraining them to the circular boundary. + /// + /// + public override unsafe void Update(float elapsedSeconds, ParticleIterator iterator) { - public float Radius { get; set; } - public bool Inside { get; set; } = true; - public float RestitutionCoefficient { get; set; } = 1; + float radiusSq = Radius * Radius; - public override unsafe void Update(float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) + while (iterator.HasNext) { - var radiusSq = Radius*Radius; - while (iterator.HasNext) + Particle* particle = iterator.Next(); + + Vector2 localPos; + localPos.X = particle->Position[0] - particle->TriggeredPos[0]; + localPos.Y = particle->Position[1] - particle->TriggeredPos[1]; + + float distSq = localPos.LengthSquared(); + Vector2 normal = Vector2.Normalize(localPos); + + if (Inside) { - var particle = iterator.Next(); - var localPos = particle->Position - particle->TriggerPos; - - var distSq = localPos.LengthSquared(); - var normal = localPos; - normal.Normalize(); - - if (Inside) - { - if (distSq < radiusSq) continue; - - SetReflected(distSq, particle, normal); - } - else - { - if (distSq > radiusSq) continue; - - SetReflected(distSq, particle, -normal); - } + if (distSq < radiusSq) { continue; } + SetReflected(distSq, particle, normal); + } + else + { + if (distSq > radiusSq) { continue; } + SetReflected(distSq, particle, -normal); } } - - private unsafe void SetReflected(float distSq, Particle* particle, Vector2 normal) - { - var dist = (float) Math.Sqrt(distSq); - var d = dist - Radius; // how far outside the circle is the particle - - var twoRestDot = 2*RestitutionCoefficient* - Vector2.Dot(particle->Velocity, normal); - particle->Velocity -= twoRestDot*normal; - - // exact computation requires sqrt or goniometrics - particle->Position -= normal*d; - } } -} \ No newline at end of file + + private unsafe void SetReflected(float distSq, Particle* particle, Vector2 normal) + { + float dist = MathF.Sqrt(distSq); + float d = dist - Radius; + + float twoRestDot = 2 * RestitutionCoefficient * + Vector2.Dot(new Vector2(particle->Velocity[0], particle->Velocity[1]), normal); + + particle->Velocity[0] -= twoRestDot * normal.X; + particle->Velocity[1] -= twoRestDot * normal.Y; + + // exact computation requires sqrt or goniometrics + particle->Position[0] -= normal.X * d; + particle->Position[1] -= normal.Y * d; + } +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Containers/RectangleContainerModifier.cs b/source/MonoGame.Extended/Particles/Modifiers/Containers/RectangleContainerModifier.cs index a56e0721..9a6de728 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/Containers/RectangleContainerModifier.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/Containers/RectangleContainerModifier.cs @@ -1,59 +1,104 @@ -using Microsoft.Xna.Framework; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Modifiers.Containers +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers.Containers; + +/// +/// A modifier that constrains particles within a rectangular boundary. +/// +/// +/// The keeps particles inside a rectangular area, reflecting them off the +/// boundaries based on a configurable restitution coefficient. The rectangle is centered at each particle's trigger +/// position (where it was emitted), creating local containment areas. +/// +public sealed class RectangleContainerModifier : Modifier { - public sealed class RectangleContainerModifier : Modifier + /// + /// Gets or sets the width of the rectangular container. + /// + public int Width; + + /// + /// Gets or sets the height of the rectangular container, in units. + /// + public int Height; + + /// + /// Gets or sets the coefficient of restitution (bounciness) for particle + /// collisions with the boundary. + /// + /// + /// + /// + /// A value of 1.0 creates a perfectly elastic collision where particles maintain their energy. + /// + /// + /// Values less than 1.0 create inelastic collisions where particles lose energy with each bounce. + /// + /// + /// Values greater than 1.0 create super-elastic collisions where particles gain energy. + /// + /// + /// + /// The default value is 1.0. + /// + public float RestitutionCoefficient = 1.0f; + + /// + /// Updates all particles by constraining them to the rectangular boundary. + /// + /// + public override unsafe void Update(float elapsedSeconds, ParticleIterator iterator) { - public int Width { get; set; } - public int Height { get; set; } - public float RestitutionCoefficient { get; set; } = 1; - - public override unsafe void Update(float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) + while (iterator.HasNext) { - while (iterator.HasNext) + Particle* particle = iterator.Next(); + + float left = particle->TriggeredPos[0] + Width * -0.5f; + float right = particle->TriggeredPos[0] + Width * 0.5f; + float top = particle->TriggeredPos[1] + Height * -0.5f; + float bottom = particle->TriggeredPos[1] + Height * 0.5f; + + float xPos = particle->Position[0]; + float xVel = particle->Velocity[0]; + float yPos = particle->Position[1]; + float yVel = particle->Velocity[1]; + + if ((int)particle->Position[0] < left) { - var particle = iterator.Next(); - - var left = particle->TriggerPos.X + Width*-0.5f; - var right = particle->TriggerPos.X + Width*0.5f; - var top = particle->TriggerPos.Y + Height*-0.5f; - var bottom = particle->TriggerPos.Y + Height*0.5f; - - var xPos = particle->Position.X; - var xVel = particle->Velocity.X; - var yPos = particle->Position.Y; - var yVel = particle->Velocity.Y; - - if ((int) particle->Position.X < left) - { - xPos = left + (left - xPos); - xVel = -xVel*RestitutionCoefficient; - } - else - { - if (particle->Position.X > right) - { - xPos = right - (xPos - right); - xVel = -xVel*RestitutionCoefficient; - } - } - - if (particle->Position.Y < top) - { - yPos = top + (top - yPos); - yVel = -yVel*RestitutionCoefficient; - } - else - { - if ((int) particle->Position.Y > bottom) - { - yPos = bottom - (yPos - bottom); - yVel = -yVel*RestitutionCoefficient; - } - } - particle->Position = new Vector2(xPos, yPos); - particle->Velocity = new Vector2(xVel, yVel); + xPos = left + (left - xPos); + xVel = -xVel * RestitutionCoefficient; } + else + { + if (particle->Position[0] > right) + { + xPos = right - (xPos - right); + xVel = -xVel * RestitutionCoefficient; + } + } + + if (particle->Position[1] < top) + { + yPos = top + (top - yPos); + yVel = -yVel * RestitutionCoefficient; + } + else + { + if ((int)particle->Position[1] > bottom) + { + yPos = bottom - (yPos - bottom); + yVel = -yVel * RestitutionCoefficient; + } + } + + particle->Position[0] = xPos; + particle->Position[1] = yPos; + particle->Velocity[0] = xVel; + particle->Velocity[1] = yVel; } } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Containers/RectangleLoopContainerModifier.cs b/source/MonoGame.Extended/Particles/Modifiers/Containers/RectangleLoopContainerModifier.cs index 4da6efa9..9f7efb6c 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/Containers/RectangleLoopContainerModifier.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/Containers/RectangleLoopContainerModifier.cs @@ -1,42 +1,75 @@ -using Microsoft.Xna.Framework; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Modifiers.Containers +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers.Containers; + +/// +/// A modifier that constrains particles within a rectangular boundary by wrapping them around to the opposite side. +/// +/// +/// The creates a looping effect by teleporting particles that exit the +/// boundary to the opposite side, similar to classic arcade games where objects wrap around the screen edges. The +/// rectangle is centered at each particle's trigger position (where it was emitted), creating local containment areas. +/// +public class RectangleLoopContainerModifier : Modifier { - public class RectangleLoopContainerModifier : Modifier + /// + /// Gets or sets the width of the rectangular container, in units. + /// + public int Width; + + /// + /// Gets or sets the height of the rectangular container, in units. + /// + public int Height; + + /// + /// Updates all particles by wrapping them around to the opposite side when they cross the rectangular boundary. + /// + /// + public override unsafe void Update(float elapsedSeconds, ParticleIterator iterator) { - public int Width { get; set; } - public int Height { get; set; } - - public override unsafe void Update(float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) + while (iterator.HasNext) { - while (iterator.HasNext) + Particle* particle = iterator.Next(); + + var left = particle->TriggeredPos[0] + Width * -0.5f; + var right = particle->TriggeredPos[0] + Width * 0.5f; + var top = particle->TriggeredPos[1] + Height * -0.5f; + var bottom = particle->TriggeredPos[1] + Height * 0.5f; + + var xPos = particle->Position[0]; + var yPos = particle->Position[1]; + + if ((int)particle->Position[0] < left) { - var particle = iterator.Next(); - var left = particle->TriggerPos.X + Width*-0.5f; - var right = particle->TriggerPos.X + Width*0.5f; - var top = particle->TriggerPos.Y + Height*-0.5f; - var bottom = particle->TriggerPos.Y + Height*0.5f; - - var xPos = particle->Position.X; - var yPos = particle->Position.Y; - - if ((int) particle->Position.X < left) - xPos = particle->Position.X + Width; - else - { - if ((int) particle->Position.X > right) - xPos = particle->Position.X - Width; - } - - if ((int) particle->Position.Y < top) - yPos = particle->Position.Y + Height; - else - { - if ((int) particle->Position.Y > bottom) - yPos = particle->Position.Y - Height; - } - particle->Position = new Vector2(xPos, yPos); + xPos = particle->Position[0] + Width; } + else + { + if ((int)particle->Position[0] > right) + { + xPos = particle->Position[0] - Width; + } + } + + if ((int)particle->Position[1] < top) + { + yPos = particle->Position[1] + Height; + } + else + { + if ((int)particle->Position[1] > bottom) + { + yPos = particle->Position[1] - Height; + } + } + + particle->Position[0] = xPos; + particle->Position[1] = yPos; } } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/DragModifier.cs b/source/MonoGame.Extended/Particles/Modifiers/DragModifier.cs index f2d6d091..923c5649 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/DragModifier.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/DragModifier.cs @@ -1,23 +1,64 @@ -using Microsoft.Xna.Framework; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Modifiers +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers; + +/// +/// A modifier that applies fluid resistance (drag) to particles, gradually reducing their velocity. +/// +/// +/// The simulates the effect of particles moving through a fluid medium +/// such as air, water, or another substance. This creates a damping effect that slows particles over time, +/// with the slowdown proportional to their velocity, mass, and the properties of the medium. +/// +public class DragModifier : Modifier { - public class DragModifier : Modifier + /// + /// Gets or sets the drag coefficient, representing the aerodynamic or hydrodynamic properties of particles. + /// + /// + /// The drag coefficient is a dimensionless quantity used in fluid dynamics to model + /// the resistance of an object moving through a fluid. Higher values create stronger + /// drag effects, causing particles to slow down more quickly. + /// + /// For reference to approximate real-world drag cooeficients, see + /// . + /// + /// The default value is 0.47. + /// + public float DragCoefficient = 0.47f; + + /// + /// Gets or sets the density of the fluid medium, affecting the strength of the drag force. + /// + /// + /// This value represents the density of the fluid through which particles are moving. + /// Higher values create stronger drag effects, simulating denser media like water or oil. + /// + /// For reference to approximate real-world density values for various fluids, see + /// . + /// + /// The default value is 0.5. + /// + public float Density = .5f; + + /// + /// Updates all particles by applying drag forces based on their velocity. + /// + /// + public override unsafe void Update(float elapsedSeconds, ParticleIterator iterator) { - public float DragCoefficient { get; set; } = 0.47f; - public float Density { get; set; } = .5f; - - public override unsafe void Update(float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) + while (iterator.HasNext) { - while (iterator.HasNext) - { - var particle = iterator.Next(); - var drag = -DragCoefficient*Density*particle->Mass*elapsedSeconds; + Particle* particle = iterator.Next(); - particle->Velocity = new Vector2( - particle->Velocity.X + particle->Velocity.X*drag, - particle->Velocity.Y + particle->Velocity.Y*drag); - } + var drag = -DragCoefficient * Density * particle->Mass * elapsedSeconds; + + particle->Velocity[0] = particle->Velocity[0] + particle->Velocity[0] * drag; + particle->Velocity[1] = particle->Velocity[1] + particle->Velocity[1] * drag; } } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/ColorInterpolator.cs b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/ColorInterpolator.cs index f94c6897..172bf896 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/ColorInterpolator.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/ColorInterpolator.cs @@ -1,13 +1,40 @@ -namespace MonoGame.Extended.Particles.Modifiers.Interpolators +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using Microsoft.Xna.Framework; +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers.Interpolators; + +/// +/// An interpolator that gradually changes particle color properties over their lifetime. +/// +/// +/// +/// The transitions a particle's color from the inherited +/// to based on the +/// provided interpolation amount +/// +/// +/// Color values are represented in the HSL (Hue, Saturation, Lightness) color space as a Vector3. +/// +/// +public sealed class ColorInterpolator : Interpolator { /// - /// Defines a modifier which interpolates the color of a particle over the course of its lifetime. + /// Updates a particle's color by interpolating between the start and end values. /// - public sealed class ColorInterpolator : Interpolator + /// The normalized interpolation amount (from 0.0 to 1.0). + /// A pointer to the particle to update. + public override unsafe void Update(float amount, Particle* particle) { - public override unsafe void Update(float amount, Particle* particle) - { - particle->Color = HslColor.Lerp(StartValue, EndValue, amount); - } + float h = StartValue.X + (EndValue.X - StartValue.X) * amount; + float s = StartValue.Y + (EndValue.Y - StartValue.Y) * amount; + float l = StartValue.Z + (EndValue.Z - StartValue.Z) * amount; + + particle->Color[0] = h; + particle->Color[1] = s; + particle->Color[2] = l; } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/HueInterpolator.cs b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/HueInterpolator.cs index 8b58a8ea..ccc03b19 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/HueInterpolator.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/HueInterpolator.cs @@ -1,12 +1,29 @@ -using MonoGame.Extended; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Modifiers.Interpolators +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers.Interpolators; + +/// +/// An interpolator that gradually changes only the hue component of particle colors over their lifetime. +/// +/// +/// Unlike which changes all HSL components, this interpolator +/// affects only the hue component, preserving the particle's existing saturation and lightness values. +/// This allows for color cycling effects while maintaining consistent saturation and brightness. +/// +public class HueInterpolator : Interpolator { - public class HueInterpolator : Interpolator + /// + /// Updates a particle's hue by interpolating between the start and end values. + /// + /// The normalized interpolation amount (from 0.0 to 1.0). + /// A pointer to the particle to update. + public override unsafe void Update(float amount, Particle* particle) { - public override unsafe void Update(float amount, Particle* particle) - { - particle->Color = new HslColor((EndValue - StartValue) * amount + StartValue, particle->Color.S, particle->Color.L); - } + float h = StartValue + (EndValue - StartValue) * amount; + particle->Color[0] = h; } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/Interpolator.cs b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/Interpolator.cs index 0ec98666..4e31d7aa 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/Interpolator.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/Interpolator.cs @@ -1,26 +1,43 @@ -namespace MonoGame.Extended.Particles.Modifiers.Interpolators +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers.Interpolators; + +/// +/// Represents a base class for all particle interpolators. +/// +/// +/// Interpolators are specialized modifiers that gradually change particle properties +/// based on a normalized time value (between 0.0 and 1.0) over the particle's lifetime. +/// This enables smooth transitions between initial and final states, such as color fades, +/// size changes, or opacity adjustments. +/// +public abstract class Interpolator { - public abstract class Interpolator - { - protected Interpolator() - { - Name = GetType().Name; - } + /// + /// Gets or sets the display name of this interpolator. + /// + public string Name; - public string Name { get; set; } - public abstract unsafe void Update(float amount, Particle* particle); + /// + /// Initializes a new instance of the class. + /// + protected Interpolator() + { + Name = GetType().Name; } - public abstract class Interpolator : Interpolator - { - /// - /// Gets or sets the intial value when the particles are created. - /// - public virtual T StartValue { get; set; } - - /// - /// Gets or sets the final value when the particles are retired. - /// - public virtual T EndValue { get; set; } - } -} \ No newline at end of file + /// + /// Updates a single particle property based on the interpolation amount. + /// + /// The normalized interpolation amount (from 0.0 to 1.0). + /// A pointer to the particle to update. + /// + /// This method is called for each particle during its lifetime. The + /// parameter represents the particle's age as a fraction of its total lifespan. + /// + public abstract unsafe void Update(float amount, Particle* particle); +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/InterpolatorOfT.cs b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/InterpolatorOfT.cs new file mode 100644 index 00000000..4ab056a4 --- /dev/null +++ b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/InterpolatorOfT.cs @@ -0,0 +1,22 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +namespace MonoGame.Extended.Particles.Modifiers.Interpolators; + +/// +/// Represents a generic base class for particle interpolators that work with specific value types. +/// +/// The type of value being interpolated. Must be a value type. +public abstract class Interpolator : Interpolator where T : struct +{ + /// + /// Gets or sets the starting value for the interpolation. + /// + public T StartValue; + + /// + /// Gets or sets the ending value for the interpolation. + /// + public T EndValue; +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/OpacityInterpolator.cs b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/OpacityInterpolator.cs index 0a26fcda..2694e450 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/OpacityInterpolator.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/OpacityInterpolator.cs @@ -1,10 +1,33 @@ -namespace MonoGame.Extended.Particles.Modifiers.Interpolators +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers.Interpolators; + +/// +/// An interpolator that gradually changes the opacity of particles over their lifetime. +/// +/// +/// +/// The transitions a particle's opacity (alpha) value from the inherited +/// to based on the +/// provided interpolation amount. +/// +/// +/// Valid opacity values range from 0.0 (completely transparent) to 1.0 (completely opaque). +/// +/// +public class OpacityInterpolator : Interpolator { - public class OpacityInterpolator : Interpolator + /// + /// Updates a particle's opacity by interpolating between the start and end values. + /// + /// The normalized interpolation amount (from 0.0 to 1.0). + /// A pointer to the particle to update. + public override unsafe void Update(float amount, Particle* particle) { - public override unsafe void Update(float amount, Particle* particle) - { - particle->Opacity = (EndValue - StartValue) * amount + StartValue; - } + particle->Opacity = StartValue + (EndValue - StartValue) * amount; } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/RotationInterpolator.cs b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/RotationInterpolator.cs index 44a72de8..c37c49f0 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/RotationInterpolator.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/RotationInterpolator.cs @@ -1,10 +1,28 @@ -namespace MonoGame.Extended.Particles.Modifiers.Interpolators +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers.Interpolators; + +/// +/// An interpolator that gradually changes the rotation angle of particles over their lifetime. +/// +/// +/// The transitions a particle's rotation value from the inherited +/// to based on the +/// provided interpolation amount. +/// +public class RotationInterpolator : Interpolator { - public class RotationInterpolator : Interpolator + /// + /// Updates a particle's rotation by interpolating between the start and end values. + /// + /// The normalized interpolation amount (from 0.0 to 1.0). + /// A pointer to the particle to update. + public override unsafe void Update(float amount, Particle* particle) { - public override unsafe void Update(float amount, Particle* particle) - { - particle->Rotation = (EndValue - StartValue) * amount + StartValue; - } + particle->Rotation = StartValue + (EndValue - StartValue) * amount; } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/ScaleInterpolator.cs b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/ScaleInterpolator.cs index aff92449..dd198917 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/ScaleInterpolator.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/ScaleInterpolator.cs @@ -1,12 +1,28 @@ -using Microsoft.Xna.Framework; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Modifiers.Interpolators +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers.Interpolators; + +/// +/// An interpolator that gradually changes the size of particles over their lifetime. +/// +/// +/// The transitions a particle's scale factor from the inherited +/// to based on the +/// provided interpolation amount (typically representing the particle's normalized age). +/// +public class ScaleInterpolator : Interpolator { - public class ScaleInterpolator : Interpolator + /// + /// Updates a particle's scale by interpolating between the start and end values. + /// + /// The normalized interpolation amount (from 0.0 to 1.0). + /// A pointer to the particle to update. + public override unsafe void Update(float amount, Particle* particle) { - public override unsafe void Update(float amount, Particle* particle) - { - particle->Scale = (EndValue - StartValue) * amount + StartValue; - } + particle->Scale = StartValue + (EndValue - StartValue) * amount; } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/VelocityInterpolator b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/VelocityInterpolator deleted file mode 100644 index bad04b19..00000000 --- a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/VelocityInterpolator +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.Xna.Framework; - -namespace MonoGame.Extended.Particles.Modifiers.Interpolators -{ - public class VelocityInterpolator : Interpolator - { - public override unsafe void Update(float amount, Particle* particle) - { - particle->Velocity = (EndValue - StartValue) * amount + StartValue; - } - } -} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Interpolators/VelocityInterpolator.cs b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/VelocityInterpolator.cs new file mode 100644 index 00000000..4e0cd2c3 --- /dev/null +++ b/source/MonoGame.Extended/Particles/Modifiers/Interpolators/VelocityInterpolator.cs @@ -0,0 +1,30 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using Microsoft.Xna.Framework; +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers.Interpolators; + +/// +/// An interpolator that gradually changes the velocity vector of particles over their lifetime. +/// +/// +/// The transitions a particle's velocity from the inherited +/// to based on the +/// provided interpolation amount (typically representing the particle's normalized age). +/// +public class VelocityInterpolator : Interpolator +{ + /// + /// Updates a particle's velocity by interpolating between the start and end values. + /// + /// The normalized interpolation amount (from 0.0 to 1.0). + /// A pointer to the particle to update. + public override unsafe void Update(float amount, Particle* particle) + { + particle->Velocity[0] = StartValue.X + (EndValue.X - StartValue.X) * amount; + particle->Velocity[1] = StartValue.Y + (EndValue.Y - StartValue.Y) * amount; + } +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/LinearGravityModifier.cs b/source/MonoGame.Extended/Particles/Modifiers/LinearGravityModifier.cs index e9ed216e..23d7aa90 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/LinearGravityModifier.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/LinearGravityModifier.cs @@ -1,23 +1,56 @@ -using Microsoft.Xna.Framework; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Modifiers +using Microsoft.Xna.Framework; +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers; + +/// +/// A modifier that applies a constant directional force to particles, simulating gravity or wind. +/// +/// +/// The applies a uniform acceleration in a specified direction +/// to all particles, creating effects such as gravity, wind, or other constant forces. The force +/// is applied proportionally to each particle's mass, simulating realistic physical behavior. +/// +/// Note that this modifier only changes particle velocities; the actual position changes +/// occur during the standard particle update cycle. +/// +public class LinearGravityModifier : Modifier { - public class LinearGravityModifier : Modifier + /// + /// Gets or sets the direction vector of the gravitational force. + /// + /// + /// This vector defines both the direction and the relative magnitude of the force. + /// + public Vector2 Direction; + + /// + /// Gets or sets the strength of the gravitational force, in units per second squared. + /// + /// + /// This value scales the overall magnitude of the force. Higher values create + /// stronger acceleration effects, causing particles to change velocity more rapidly. + /// + public float Strength; + + /// + /// Updates all particles by applying a linear gravitational force. + /// + /// + public override unsafe void Update(float elapsedSeconds, ParticleIterator iterator) { - public Vector2 Direction { get; set; } - public float Strength { get; set; } + Vector2 vector = Direction * (Strength * elapsedSeconds); - public override unsafe void Update(float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) + while (iterator.HasNext) { - var vector = Direction*(Strength*elapsedSeconds); + Particle* particle = iterator.Next(); - while (iterator.HasNext) - { - var particle = iterator.Next(); - particle->Velocity = new Vector2( - particle->Velocity.X + vector.X*particle->Mass, - particle->Velocity.Y + vector.Y*particle->Mass); - } + particle->Velocity[0] = particle->Velocity[0] + vector.X * particle->Mass; + particle->Velocity[1] = particle->Velocity[1] + vector.Y * particle->Mass; } } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/Modifier.cs b/source/MonoGame.Extended/Particles/Modifiers/Modifier.cs index bd62d255..b0e95ac5 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/Modifier.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/Modifier.cs @@ -1,18 +1,66 @@ -namespace MonoGame.Extended.Particles.Modifiers +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +namespace MonoGame.Extended.Particles.Modifiers; + +/// +/// Represents a base class for all particle modifiers. +/// +/// +/// Particle modifiers are used to alter the behavior or properties of particles during their lifetime. +/// Each modifier applies changes to particles at a configurable frequency, optimizing performance by +/// spreading updates across frames when appropriate. +/// Custom modifiers should inherit from this class and implement the method. +/// +public abstract class Modifier { - public abstract class Modifier + private const float DEFAULT_MODIFIER_FREQUENCY = 60.0f; + + private int _particlesUpdatedThisCycle; + + /// + /// Gets or sets the display name of this modifier. + /// + public string Name; + + /// + /// Gets or sets the update frequency of this modifier. + /// + /// + /// This value defines how often, in times per second, the modifier attempts to update + /// the entire particle buffer. For example, a value of 60.0f means that all particles + /// will be updated collectively approximately 60 times per second. + /// + /// To improve performance, updates are distributed across frames. Rather than updating + /// every particle in every frame, the modifier mathematically distributes updates by + /// processing a portion of the particles each frame based on the elapsed time and the + /// desired frequency. Over time, this results in all particles being updated at the + /// specified frequency on average, regardless of the actual frame rate. + /// + /// Higher values result in more frequent updates and smoother particle behavior, at the + /// cost of performance. Lower values reduce CPU usage but may make particle changes appear + /// less fluid. + /// + public float Frequency; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The default constructor sets the property to the name of the derived class + /// and initializes to . + /// + protected Modifier() { - protected Modifier() - { - Name = GetType().Name; - } - - public string Name { get; set; } - public abstract void Update(float elapsedSeconds, ParticleBuffer.ParticleIterator iterator); - - public override string ToString() - { - return $"{Name} [{GetType().Name}]"; - } + Name = GetType().Name; + Frequency = DEFAULT_MODIFIER_FREQUENCY; } -} \ No newline at end of file + + /// + /// Updates the properties of particles according to this modifier's specific behavior. + /// + /// The elapsed time, in seconds, since the last update. + /// The iterator used to iterate the particles ot update. + public abstract void Update(float elapsedSeconds, ParticleIterator iterator); +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/ModifierExecutionStrategy.cs b/source/MonoGame.Extended/Particles/Modifiers/ModifierExecutionStrategy.cs new file mode 100644 index 00000000..35974e06 --- /dev/null +++ b/source/MonoGame.Extended/Particles/Modifiers/ModifierExecutionStrategy.cs @@ -0,0 +1,91 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using TPL = System.Threading.Tasks.Parallel; + +namespace MonoGame.Extended.Particles.Modifiers; + +/// +/// Defines different strategies for executing particle modifiers within a particle system. +/// +/// +/// The strategy pattern implemented by this class allows the particle system to switch +/// between serial (single-threaded) and parallel (multi-threaded) execution methods +/// to optimize performance based on the execution environment and particle workload. +/// +public abstract class ModifierExecutionStrategy +{ + /// + /// Gets a singleton instance of the serial execution strategy. + /// + /// + /// The serial strategy processes each modifier one after another on a single thread. + /// This may be more efficient for small particle counts or when thread synchronization + /// overhead outweighs the benefits of parallelism. + /// + static public ModifierExecutionStrategy Serial = new SerialModifierExecutionStrategy(); + + /// + /// Gets a singleton instance of the parallel execution strategy. + /// + /// + /// The parallel strategy processes modifiers concurrently using multiple threads. + /// This can significantly improve performance for systems with many particles and + /// multiple modifiers, especially on multi-core processors. + /// + static public ModifierExecutionStrategy Parallel = new ParallelModifierExecutionStrategy(); + + /// + /// Executes all modifiers in the collection on the particle buffer using the implemented strategy. + /// + /// The collection of modifiers to execute. + /// The elapsed time, in seconds, since the last update. + /// The iterator used to iterate the particles. + internal abstract unsafe void ExecuteModifiers(List modifiers, float elapsedSeconds, ParticleIterator iterator); + + /// + /// Implements a serial (single-threaded) execution strategy for particle modifiers. + /// + /// + /// This strategy processes each modifier sequentially on a single thread, + /// which can be more efficient for smaller particle counts or when the + /// overhead of thread synchronization would outweigh the benefits of parallelism. + /// + internal class SerialModifierExecutionStrategy : ModifierExecutionStrategy + { + internal override unsafe void ExecuteModifiers(List modifiers, float elapsedSeconds, ParticleIterator iterator) + { + for (int i = 0; i < modifiers.Count; i++) + { + modifiers[i].Update(elapsedSeconds, iterator.Reset()); + } + } + + public override string ToString() + { + return nameof(Serial); + } + } + + /// + /// Implements a parallel (multi-threaded) execution strategy for particle modifiers. + /// + /// + /// This strategy processes modifiers concurrently. It can significantly improve + /// performance for systems with many particles and multiple modifiers. + /// + internal class ParallelModifierExecutionStrategy : ModifierExecutionStrategy + { + internal override unsafe void ExecuteModifiers(List modifiers, float elapsedSeconds, ParticleIterator iterator) + { + TPL.ForEach(modifiers, modifier => modifier.Update(elapsedSeconds, iterator.Reset())); + } + + public override string ToString() + { + return nameof(Parallel); + } + } +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/OpacityFastFadeModifier.cs b/source/MonoGame.Extended/Particles/Modifiers/OpacityFastFadeModifier.cs index 9cc44d83..f6afeb91 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/OpacityFastFadeModifier.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/OpacityFastFadeModifier.cs @@ -1,14 +1,44 @@ -namespace MonoGame.Extended.Particles.Modifiers +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers; + +/// +/// A modifier that rapidly decreases particle opacity based on their age. +/// +/// +/// The creates a linear fade-out effect where particles +/// become more transparent as they age. +/// +/// Important notes: +/// +/// +/// This modifier assumes particles have a standard lifespan of 1.0 second. For particles +/// with different lifespans, the fade effect may not complete before the particle is removed, +/// or may become fully transparent before the particle's actual end of life. +/// +/// +/// Unlike other modifiers that accumulate changes over time, this modifier directly sets +/// the opacity value each frame based solely on the particle's age. +/// +/// +/// +public sealed class OpacityFastFadeModifier : Modifier { - public sealed class OpacityFastFadeModifier : Modifier + /// + /// Updates all particles by setting their opacity based on their age. + /// + /// + public override unsafe void Update(float elapsedSeconds, ParticleIterator iterator) { - public override unsafe void Update(float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) + while (iterator.HasNext) { - while (iterator.HasNext) - { - var particle = iterator.Next(); - particle->Opacity = 1.0f - particle->Age; - } + Particle* particle = iterator.Next(); + + particle->Opacity = 1.0f - particle->Age; } } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/RotationModifier.cs b/source/MonoGame.Extended/Particles/Modifiers/RotationModifier.cs index 2b17058f..1519196c 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/RotationModifier.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/RotationModifier.cs @@ -1,18 +1,46 @@ -namespace MonoGame.Extended.Particles.Modifiers +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers; + +/// +/// A modifier that applies a constant rotational velocity to particles. +/// +/// +/// The changes the orientation of particles over time +/// by applying a continuous rotation at a specified rate. +/// +/// The rotation is applied uniformly to all particles, but can be combined with other modifiers +/// to create more complex behaviors. For non-uniform rotation, consider using multiple particle +/// emitters with different rotation rates or implementing a custom modifier. +/// +public class RotationModifier : Modifier { - public class RotationModifier : Modifier + /// + /// Gets or sets the rate at which particles rotate, in radians per second. + /// + /// + /// Positive values cause clockwise rotation, while negative values cause + /// counter-clockwise rotation. + /// + public float RotationRate; + + /// + /// Updates all particles by applying rotation based on the elapsed time. + /// + /// + public override unsafe void Update(float elapsedSeconds, ParticleIterator iterator) { - public float RotationRate { get; set; } + float rotationRateDelta = RotationRate * elapsedSeconds; - public override unsafe void Update(float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) + while (iterator.HasNext) { - var rotationRateDelta = RotationRate*elapsedSeconds; + Particle* particle = iterator.Next(); - while (iterator.HasNext) - { - var particle = iterator.Next(); - particle->Rotation += rotationRateDelta; - } + particle->Rotation += rotationRateDelta; } } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/VelocityColorModifier.cs b/source/MonoGame.Extended/Particles/Modifiers/VelocityColorModifier.cs index ae9dc7b7..8ac790c3 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/VelocityColorModifier.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/VelocityColorModifier.cs @@ -1,36 +1,93 @@ -using System; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Modifiers +using System; +using Microsoft.Xna.Framework; +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles.Modifiers; + +/// +/// A modifier that changes particle colors based on their velocity. +/// +/// +/// The adjusts particle colors dynamically according to their +/// movement speed. Particles can transition smoothly between two defined colors: +/// +/// A color for stationary or slow-moving particles () +/// A color for fast-moving particles () +/// +/// +/// The color values are represented in HSL (Hue, Saturation, Lightness) format as a Vector3. +/// +public class VelocityColorModifier : Modifier { - public class VelocityColorModifier : Modifier + /// + /// Gets or sets the color for particles that are stationary or moving slowly. + /// + /// + /// This color is applied to particles with zero velocity and serves as the starting + /// point for color interpolation. The Vector3 components represent HSL values + /// (Hue, Saturation, Lightness). + /// + public Vector3 StationaryColor; + + /// + /// Gets or sets the color for particles that have reached or exceeded the velocity threshold. + /// + /// + /// This color is applied to fast-moving particles and serves as the end point + /// for color interpolation. The Vector3 components represent HSL values + /// (Hue, Saturation, Lightness). + /// + public Vector3 VelocityColor; + + /// + /// Gets or sets the velocity magnitude at which particles fully transition to the velocity color. + /// + /// + /// This value defines the speed threshold that determines when a particle should + /// display the full . Particles moving slower than this + /// threshold will display a color interpolated between and + /// based on their speed relative to this threshold. + /// + public float VelocityThreshold; + + /// + /// Updates all particles by changing their colors based on their current velocity. + /// + /// + public override unsafe void Update(float elapsedSeconds, ParticleIterator iterator) { - public HslColor StationaryColor { get; set; } - public HslColor VelocityColor { get; set; } - public float VelocityThreshold { get; set; } + float velocityThreshold2 = VelocityThreshold * VelocityThreshold; - public override unsafe void Update(float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) + while (iterator.HasNext) { - var velocityThreshold2 = VelocityThreshold*VelocityThreshold; + Particle* particle = iterator.Next(); - while (iterator.HasNext) + float velocitySquared = particle->Velocity[0] * particle->Velocity[0] + + particle->Velocity[1] * particle->Velocity[1]; + + if (velocitySquared >= velocityThreshold2) { - var particle = iterator.Next(); - var velocity2 = particle->Velocity.X*particle->Velocity.X + - particle->Velocity.Y*particle->Velocity.Y; - var deltaColor = VelocityColor - StationaryColor; + particle->Color[0] = VelocityColor.X; + particle->Color[1] = VelocityColor.Y; + particle->Color[2] = VelocityColor.Z; + } + else + { + Vector3 deltaColor = VelocityColor - StationaryColor; + float t = MathF.Sqrt(velocitySquared) / VelocityThreshold; - if (velocity2 >= velocityThreshold2) - VelocityColor.CopyTo(out particle->Color); - else - { - var t = (float) Math.Sqrt(velocity2)/VelocityThreshold; + float h = deltaColor.X * t + StationaryColor.X; + float s = deltaColor.Y * t + StationaryColor.Y; + float l = deltaColor.Z * t + StationaryColor.Z; - particle->Color = new HslColor( - deltaColor.H*t + StationaryColor.H, - deltaColor.S*t + StationaryColor.S, - deltaColor.L*t + StationaryColor.L); - } + particle->Color[0] = h; + particle->Color[1] = s; + particle->Color[2] = l; } } } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/VelocityModifier.cs b/source/MonoGame.Extended/Particles/Modifiers/VelocityModifier.cs index f089abcc..8a849841 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/VelocityModifier.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/VelocityModifier.cs @@ -1,43 +1,72 @@ -using System; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; using System.Collections.Generic; +using MonoGame.Extended.Particles.Data; using MonoGame.Extended.Particles.Modifiers.Interpolators; -namespace MonoGame.Extended.Particles.Modifiers +namespace MonoGame.Extended.Particles.Modifiers; + +/// +/// A modifier that applies interpolators to particles based on their velocity magnitude. +/// +/// +/// The controls how particle properties change based on their speed +/// by applying a collection of objects to each particle. The intensity +/// of the effect is determined by comparing the particle's velocity magnitude to a threshold value. +/// +public class VelocityModifier : Modifier { - public class VelocityModifier : Modifier + /// + /// Gets or sets the collection of interpolators that will be applied to particles. + /// + public List Interpolators { get; set; } = new List(); + + /// + /// Gets or sets the velocity magnitude at which particles reach the maximum interpolation effect. + /// + /// + /// This value defines the speed threshold that determines when a particle should + /// receive the full interpolation effect (amount = 1.0). Particles moving slower than this + /// threshold will receive a proportionally reduced effect based on their velocity magnitude. + /// + public float VelocityThreshold; + + /// + /// Updates all particles by applying interpolators with an amount based on each particle's velocity. + /// + /// + public override unsafe void Update(float elapsedSeconds, ParticleIterator iterator) { - public List Interpolators { get; set; } = new List(); + float velocityThreshold2 = VelocityThreshold * VelocityThreshold; - public float VelocityThreshold { get; set; } - - public override unsafe void Update(float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) + while (iterator.HasNext) { - var velocityThreshold2 = VelocityThreshold*VelocityThreshold; - var n = Interpolators.Count; + Particle* particle = iterator.Next(); - while (iterator.HasNext) + float velocitySquared = particle->Velocity[0] * particle->Velocity[0] + + particle->Velocity[1] * particle->Velocity[1]; + + if (velocitySquared >= velocityThreshold2) { - var particle = iterator.Next(); - var velocity2 = particle->Velocity.LengthSquared(); - - if (velocity2 >= velocityThreshold2) + for (int i = 0; i < Interpolators.Count; i++) { - for (var i = 0; i < n; i++) - { - var interpolator = Interpolators[i]; - interpolator.Update(1, particle); - } + Interpolator interpolator = Interpolators[i]; + interpolator.Update(1, particle); } - else + } + else + { + float t = (float)Math.Sqrt(velocitySquared) / VelocityThreshold; + + for (int i = 0; i < Interpolators.Count; i++) { - var t = (float) Math.Sqrt(velocity2)/VelocityThreshold; - for (var i = 0; i < n; i++) - { - var interpolator = Interpolators[i]; - interpolator.Update(t, particle); - } + Interpolator interpolator = Interpolators[i]; + interpolator.Update(t, particle); } } } } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Modifiers/VortexModifier.cs b/source/MonoGame.Extended/Particles/Modifiers/VortexModifier.cs index 5011d198..7b573959 100644 --- a/source/MonoGame.Extended/Particles/Modifiers/VortexModifier.cs +++ b/source/MonoGame.Extended/Particles/Modifiers/VortexModifier.cs @@ -1,32 +1,77 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + using System; using Microsoft.Xna.Framework; +using MonoGame.Extended.Particles.Data; -namespace MonoGame.Extended.Particles.Modifiers +namespace MonoGame.Extended.Particles.Modifiers; + +/// +/// A modifier that creates a gravitational vortex effect, pulling particles toward a central point. +/// +/// +/// The simulates a gravitational attraction between a central point and +/// each particle. The strength of the attraction is based on gravitational principles, where the force +/// is proportional to the masses and inversely proportional to the square of the distance. +/// +/// The actual acceleration applied to each particle is clamped to prevent particles from moving +/// too quickly when they get close to the center of the vortex. +/// +public unsafe class VortexModifier : Modifier { - public unsafe class VortexModifier : Modifier + private const float GRAVITY = 100000f; + + /// + /// Gets or sets the center position of the vortex in 2D space. + /// + /// + /// Particles will be attracted toward this point. + /// + public Vector2 Position; + + /// + /// Gets or sets the mass of the vortex center. + /// + /// + /// Higher mass values create stronger attraction forces. + /// + public float Mass; + + /// + /// Gets or sets the maximum speed that the vortex can impart on particles. + /// + /// + /// This value limits how quickly particles can be accelerated by the vortex, + /// preventing extreme velocities when particles get very close to the center. + /// + public float MaxSpeed; + + /// + /// Updates all particles by applying a gravitational force towards the vortex center. + /// + /// + public override unsafe void Update(float elapsedSeconds, ParticleIterator iterator) { - // Note: not the real-life one - private const float _gravConst = 100000f; - - public Vector2 Position { get; set; } - public float Mass { get; set; } - public float MaxSpeed { get; set; } - - public override void Update(float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) + while (iterator.HasNext) { - while (iterator.HasNext) - { - var particle = iterator.Next(); - var diff = Position + particle->TriggerPos - particle->Position; + Particle* particle = iterator.Next(); - var distance2 = diff.LengthSquared(); + float distx = Position.X - particle->Position[0]; + float disty = Position.Y - particle->Position[1]; - var speedGain = _gravConst*Mass/distance2; - speedGain = Math.Max(Math.Min(speedGain, MaxSpeed), -MaxSpeed) * elapsedSeconds; - // normalize distances and multiply by speedGain - diff.Normalize(); - particle->Velocity += diff*speedGain; - } + float distance2 = (distx * distx) + (disty * disty); + float distance = (float)Math.Sqrt(distance2); + + float m = (GRAVITY * Mass * particle->Mass) / distance2; + m = Math.Max(Math.Min(m, MaxSpeed), -MaxSpeed) * elapsedSeconds; + + distx = (distx / distance) * m; + disty = (disty / distance) * m; + + particle->Velocity[0] += distx; + particle->Velocity[1] += disty; } } } diff --git a/source/MonoGame.Extended/Particles/Particle.cs b/source/MonoGame.Extended/Particles/Particle.cs deleted file mode 100644 index 042d8cef..00000000 --- a/source/MonoGame.Extended/Particles/Particle.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Runtime.InteropServices; -using Microsoft.Xna.Framework; -using MonoGame.Extended; - -namespace MonoGame.Extended.Particles -{ - [StructLayout(LayoutKind.Sequential, Pack = 1)] - public struct Particle - { - public float Inception; - public float Age; - public Vector2 Position; - public Vector2 TriggerPos; - public Vector2 Velocity; - public HslColor Color; - public float Opacity; - public Vector2 Scale; - public float Rotation; - public float Mass; - public float LayerDepth; - - public static readonly int SizeInBytes = Marshal.SizeOf(typeof(Particle)); - } -} \ No newline at end of file diff --git a/source/MonoGame.Extended/Particles/ParticleBuffer.cs b/source/MonoGame.Extended/Particles/ParticleBuffer.cs index e361b8b7..c3b4cbb9 100644 --- a/source/MonoGame.Extended/Particles/ParticleBuffer.cs +++ b/source/MonoGame.Extended/Particles/ParticleBuffer.cs @@ -1,157 +1,203 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + using System; using System.Runtime.InteropServices; +using MonoGame.Extended.Particles.Data; -namespace MonoGame.Extended.Particles +namespace MonoGame.Extended.Particles; + +/// +/// Represents a circular memory buffer that efficiently stores and manages particles in contiguous unmanaged memory. +/// +/// +/// The class provides high-performance memory management for particle systems by +/// allocating an unmanaged memory block to store particle data in a circular buffer arrangement. This implementation +/// uses head and tail pointers to manage particle allocation and deallocation without requiring memory copying +/// operations, making it more efficient than a linear buffer approach. +/// +public unsafe class ParticleBuffer : IDisposable { - public class ParticleBuffer : IDisposable + private readonly ParticleIterator _iterator; + + /// + /// Gets the native pointer to the beginning of the unmanaged memory buffer that stores particle data. + /// + /// + /// An pointing to the start of the allocated unmanaged memory block. + /// + /// + /// + /// This property provides direct access to the underlying unmanaged memory used by the particle buffer. + /// The memory pointed to by this value contains particle data stored in a contiguous block, with each + /// particle occupying bytes. + /// + /// + /// Warning: This pointer should be used with extreme caution and only within unsafe code blocks. + /// Improper use of this pointer can lead to memory corruption, access violations, or other undefined behavior. + /// The memory is automatically managed by this instance and should not be + /// manually freed or modified outside of the provided safe methods. + /// + /// + /// The memory layout is arranged as a circular buffer where particles are stored sequentially. + /// Use and the for safe access to active particles rather than + /// directly manipulating this pointer. + /// + /// + /// This pointer becomes invalid after the is disposed. Accessing it after + /// disposal will result in undefined behavior. + /// + /// + public IntPtr NativePointer { get; } + + /// + /// Gets a pointer to the current tail position in the circular buffer where new particles are allocated. + /// + public unsafe Particle* Tail { get; private set; } + + /// + /// Gets a pointer to the end fo the allocated buffer memory, used for circular buffer bounds checking. + /// + public Particle* BufferEnd { get; } + + /// + /// Gets the maximum number of particles that can be stored in this buffer. + /// + public int Size { get; } + + /// + /// Gets an iterator for traversing the active particles in the buffer. + /// + /// + /// The iterator is reset each time this property is accessed, starting from the current head position. + /// Use this to safely iterate through all active particles in the correct order. + /// + public ParticleIterator Iterator => _iterator.Reset(); + + /// + /// Gets a pointer to the current head position in the circular buffer where the oldest active particle is located. + /// + /// + /// This pointer should be used carefully and only within unsafe code blocks. The memory it points to is + /// automatically freed when the is disposed. + /// + public unsafe Particle* Head { get; private set; } + + /// + /// Gets the number of additional particles that can be added to the buffer before it is full. + /// + public int Available => Size - Count; + + /// + /// Gets the current number of active particles in the buffer. + /// + public int Count { get; private set; } + + /// + /// Gets the total size of the buffer in bytes. + /// + /// + /// The size includes space for one additional particle beyond the specified capacity to facilitate + /// circular buffer operations. + /// + public int SizeInBytes => Particle.SizeInBytes * (Size + 1); + + /// + /// Gets the size of the currently active portion of the buffer in bytes. + /// + public int ActiveSizeInBytes => Particle.SizeInBytes * Count; + + /// + /// Gets a value indicating whether this has been disposed. + /// + public bool IsDisposed { get; private set; } + + /// + /// Initializes a new instance of the class with the specified capacity. + /// + /// The maximum number of particles that can be stored in the buffer. + public unsafe ParticleBuffer(int size) { - private readonly ParticleIterator _iterator; - private readonly IntPtr _nativePointer; + Size = size; + NativePointer = Marshal.AllocHGlobal(SizeInBytes); - // points to the first memory pos after the buffer - protected readonly unsafe Particle* BufferEnd; + BufferEnd = (Particle*)(NativePointer + SizeInBytes); + Head = (Particle*)NativePointer; + Tail = (Particle*)NativePointer; - // points to the particle after the last active particle. - protected unsafe Particle* Tail; + _iterator = new ParticleIterator(this); - public unsafe ParticleBuffer(int size) + GC.AddMemoryPressure(SizeInBytes); + } + + /// + ~ParticleBuffer() => Dispose(); + + /// + /// Allocates space in the circular buffer for a specified number of particles to be released. + /// + /// The number of particles to allocate space for. + /// + /// A positioned at the start of the newly allocated particles, + /// allowing iteration over the allocated particle slots. + /// + /// + /// Thrown if this method is called after the buffer has been disposed. + /// + public unsafe ParticleIterator Release(int releaseQuantity) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + + int numToRelease = Math.Min(releaseQuantity, Available); + + int prevCount = Count; + Count += numToRelease; + + Tail += numToRelease; + + if (Tail >= BufferEnd) { - Size = size; - _nativePointer = Marshal.AllocHGlobal(SizeInBytes); - - BufferEnd = (Particle*) (_nativePointer + SizeInBytes); - Head = (Particle*) _nativePointer; - Tail = (Particle*) _nativePointer; - - _iterator = new ParticleIterator(this); - - GC.AddMemoryPressure(SizeInBytes); + Tail -= Size + 1; } - public int Size { get; } + return Iterator.Reset(prevCount); + } - public ParticleIterator Iterator => _iterator.Reset(); - // pointer to the first particle - public unsafe Particle* Head { get; private set; } + /// + /// Removes a specified number of particles from the beginning of the circular buffer. + /// + /// The number of particles to remove from the buffer. + /// + /// Thrown if this method is called after the buffer has been disposed. + /// + public unsafe void Reclaim(int number) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); - // Number of available particle spots in the buffer - public int Available => Size - Count; - // current number of particles - public int Count { get; private set; } - // total size of the buffer (add one extra spot in memory for margin between head and tail so the iterator can see that it's at the end) - public int SizeInBytes => Particle.SizeInBytes*(Size + 1); - // total size of active particles - public int ActiveSizeInBytes => Particle.SizeInBytes*Count; + Count -= number; - /// - /// Gets a value that indicates whether this instance of the class has been - /// disposed. - /// - public bool IsDisposed { get; set; } + Head += number; - public void Dispose() + if (Head >= BufferEnd) { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if(IsDisposed) - { - return; - } - - Marshal.FreeHGlobal(_nativePointer); - GC.RemoveMemoryPressure(SizeInBytes); - IsDisposed = true; - } - - /// - /// Release the given number of particles or the most available. - /// Returns a started iterator to iterate over the new particles. - /// - public unsafe ParticleIterator Release(int releaseQuantity) - { - ThrowIfDisposed(); - - var numToRelease = Math.Min(releaseQuantity, Available); - - var prevCount = Count; - Count += numToRelease; - - Tail += numToRelease; - if (Tail >= BufferEnd) Tail -= Size + 1; - - return Iterator.Reset(prevCount); - } - - public unsafe void Reclaim(int number) - { - ThrowIfDisposed(); - - Count -= number; - - Head += number; - if (Head >= BufferEnd) - Head -= Size + 1; - } - - private void ThrowIfDisposed() - { - if(IsDisposed) - { - throw new ObjectDisposedException(nameof(ParticleBuffer)); - } - } - - ~ParticleBuffer() - { - Dispose(false); - } - - public class ParticleIterator - { - private readonly ParticleBuffer _buffer; - - private unsafe Particle* _current; - - public int Total; - - public ParticleIterator(ParticleBuffer buffer) - { - _buffer = buffer; - } - - public unsafe bool HasNext => _current != _buffer.Tail; - - public unsafe ParticleIterator Reset() - { - _current = _buffer.Head; - Total = _buffer.Count; - return this; - } - - internal unsafe ParticleIterator Reset(int offset) - { - Total = _buffer.Count; - - _current = _buffer.Head + offset; - if (_current >= _buffer.BufferEnd) - _current -= _buffer.Size + 1; - - return this; - } - - public unsafe Particle* Next() - { - var p = _current; - _current++; - if (_current == _buffer.BufferEnd) - _current = (Particle*) _buffer._nativePointer; - - return p; - } + Head -= Size + 1; } } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + if (IsDisposed) + { + return; + } + + Marshal.FreeHGlobal(NativePointer); + GC.RemoveMemoryPressure(SizeInBytes); + IsDisposed = true; + GC.SuppressFinalize(this); + } } diff --git a/source/MonoGame.Extended/Particles/ParticleEffect.cs b/source/MonoGame.Extended/Particles/ParticleEffect.cs index e4091107..ad1c76ee 100644 --- a/source/MonoGame.Extended/Particles/ParticleEffect.cs +++ b/source/MonoGame.Extended/Particles/ParticleEffect.cs @@ -1,123 +1,270 @@ -using System; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; -using System.Runtime.CompilerServices; -using System.Text.Json; using Microsoft.Xna.Framework; -using MonoGame.Extended.Particles.Serialization; -using MonoGame.Extended.Serialization.Json; +using MonoGame.Extended.Particles.Primitives; -namespace MonoGame.Extended.Particles +namespace MonoGame.Extended.Particles; + +/// +/// Represents a complete particle effect composed of multiple emitters. +/// +/// +/// The class serves as a container for one or more instances, +/// allowing for complex visual effects that combine different types of particle behaviors and appearances. +/// +/// Effects can be positioned, rotated, and scaled as a single unit, with all contained emitters being affected +/// by these transformations. Effects also provide methods for triggering all emitters simultaneously. +/// +public class ParticleEffect : IDisposable { - public class ParticleEffect : Transform2, IDisposable + /// + /// Gets or sets the name of this effect, used for identification and debugging. + /// + public string Name; + + /// + /// Gets or sets the position of this effect in 2D space. + /// + /// + /// This position is used as the reference point for all emitters in the effect. + /// When the effect is updated, this position is passed to each emitter's update method. + /// + public Vector2 Position; + + /// + /// Gets or sets the rotation of this effect, in radians. + /// + /// + /// This property can be used to rotate the entire effect around its position. + /// Note that rotation is not automatically applied to emitters and must be handled by the rendering system. + /// + public float Rotation; + + /// + /// Gets or sets the scale factor of this effect. + /// + /// + /// This property can be used to uniformly or non-uniformly scale the entire effect. + /// Note that scaling is not automatically applied to emitters and must be handled by the rendering system. + /// + public Vector2 Scale; + + /// + /// Gets or sets the collection of emitters that compose this effect. + /// + /// + /// Each emitter in this collection contributes to the overall visual appearance of the effect, + /// potentially with different behaviors, textures, and particle properties. + /// + public List Emitters; + + /// + /// Gets a value indicating whether this has been disposed. + /// + /// if the effect has been disposed; otherwise, . + public bool IsDisposed { get; private set; } + + /// + /// Gets the total number of active particles across all emitters in this effect. + /// + /// The sum of for all emitters in the effect. + public int ActiveParticles { - public ParticleEffect(string name = null) + get { - Name = name; - Emitters = new List(); - } - - ~ParticleEffect() => Dispose(false); - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - private void Dispose(bool disposing) - { - if(IsDisposed) - { - return; - } - - if(disposing) - { - foreach(var emitter in Emitters) - { - emitter.Dispose(); - } - } - - IsDisposed = true; - } - - public string Name { get; set; } - public List Emitters { get; set; } - public int ActiveParticles => Emitters.Sum(t => t.ActiveParticles); - - /// - /// Gets a value that indicates whether this instance of the class has been - /// disposed. - /// - public bool IsDisposed { get; private set; } - - public void FastForward(Vector2 position, float seconds, float triggerPeriod) - { - var time = 0f; - while (time < seconds) - { - Update(triggerPeriod); - Trigger(position); - time += triggerPeriod; - } - } - - public static ParticleEffect FromFile(ITextureRegionService textureRegionService, string path) - { - using (var stream = TitleContainer.OpenStream(path)) - { - return FromStream(textureRegionService, stream); - } - } - - public static ParticleEffect FromStream(ITextureRegionService textureRegionService, Stream stream) - { - var options = ParticleJsonSerializerOptionsProvider.GetOptions(textureRegionService); - return JsonSerializer.Deserialize(stream, options); - } - - public void Update(float elapsedSeconds) - { - ThrowIfDisposed(); - - for (var i = 0; i < Emitters.Count; i++) - Emitters[i].Update(elapsedSeconds, Position); - } - - public void Trigger() - { - Trigger(Position); - } - - public void Trigger(Vector2 position, float layerDepth = 0) - { - ThrowIfDisposed(); - for (var i = 0; i < Emitters.Count; i++) - Emitters[i].Trigger(position, layerDepth); - } - - public void Trigger(LineSegment line, float layerDepth = 0) - { - ThrowIfDisposed(); - for (var i = 0; i < Emitters.Count; i++) - Emitters[i].Trigger(line, layerDepth); - } - - private void ThrowIfDisposed() - { - if(IsDisposed) - { - throw new ObjectDisposedException(nameof(ParticleBuffer)); - } - } - - public override string ToString() - { - return Name; + return Emitters.Sum(t => t.ActiveParticles); } } + + /// + /// Initializes a new instance of the class with the specified name. + /// + /// The name of the effect, used for identification and debugging. + /// + /// This constructor initializes the effect with default position, rotation, and scale, + /// and creates an empty collection of emitters. + /// + public ParticleEffect(string name) + { + Name = name; + Position = Vector2.Zero; + Rotation = 0.0f; + Scale = Vector2.One; + Emitters = new List(); + } + + /// + /// Finalizes an instance of the class. + /// + ~ParticleEffect() + { + Dispose(false); + } + + /// + /// Advances the effect's state rapidly to simulate it having been active for a period of time. + /// + /// The position at which to simulate the effect. + /// The total time, in seconds, to simulate. + /// The time interval, in seconds, between simulated triggers. + /// + /// This method is useful for pre-filling a scene with particles that appear to have been emitted + /// over time, rather than starting with an empty effect. + /// + public void FastForward(Vector2 position, float seconds, float triggerPeriod) + { + float time = 0.0f; + while (time < seconds) + { + Update(triggerPeriod); + Trigger(position); + time += triggerPeriod; + } + } + + public void Update(GameTime gameTime) + { + Update((float)gameTime.ElapsedGameTime.TotalSeconds); + } + + /// + /// Updates the state of all emitters in this effect. + /// + /// The elapsed time, in seconds, since the last update. + /// + /// This method propagates the update call to each emitter in the effect, passing along + /// the elapsed time and the current position of the effect. + /// + /// + /// Thrown if this method is called after the effect has been disposed. + /// + public void Update(float elapsedSeconds) + { + ObjectDisposedException.ThrowIf(IsDisposed, typeof(ParticleBuffer)); + + for (int i = 0; i < Emitters.Count; i++) + { + Emitters[i].Update(elapsedSeconds, Position); + } + } + + /// + /// Triggers all emitters in this effect at the effect's current position. + /// + public void Trigger() + { + Trigger(Position); + } + + /// + /// Triggers all emitters in this effect at the specified position. + /// + /// The position in 2D space at which to trigger the emitters. + /// The layer depth at which to render the emitted particles. + /// + /// Thrown if this method is called after the effect has been disposed. + /// + public void Trigger(Vector2 position, float layerDepth = 0) + { + ObjectDisposedException.ThrowIf(IsDisposed, typeof(ParticleBuffer)); + + for (int i = 0; i < Emitters.Count; i++) + { + Emitters[i].Trigger(position, layerDepth); + } + } + + /// + /// Triggers all emitters in this effect along a line segment. + /// + /// The line segment along which to distribute triggered particles. + /// The layer depth at which to render the emitted particles. + /// + /// Thrown if this method is called after the effect has been disposed. + /// + public void Trigger(LineSegment line, float layerDepth) + { + ObjectDisposedException.ThrowIf(IsDisposed, typeof(ParticleBuffer)); + + for (int i = 0; i < Emitters.Count; i++) + { + Emitters[i].Trigger(line, layerDepth); + } + } + + /// + /// Creates a new instance of the class from a file. + /// + /// The path to the file containing the serialized effect data. + /// A new instance with properties and emitters as defined in the file. + /// + /// This method is not yet implemented. + /// + /// + /// This method is intended to deserialize effect data from a file, but has not been implemented in this version. + /// + public static ParticleEffect FromFile(string path) + { + throw new NotImplementedException(); + } + + /// + /// Creates a new instance of the class from a stream. + /// + /// The stream containing the serialized effect data. + /// A new instance with properties and emitters as defined in the stream. + /// + /// This method is not yet implemented. + /// + /// + /// This method is intended to deserialize effect data from a stream, but has not been implemented in this version. + /// + public static ParticleEffect FromStream(Stream stream) + { + throw new NotImplementedException(); + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases the resources used by the . + /// + /// to release both managed and unmanaged resources; + /// to release only unmanaged resources. + private void Dispose(bool disposing) + { + if (IsDisposed) { return; } + + if (disposing) + { + for (int i = 0; i < Emitters.Count; i++) + { + Emitters[i].Dispose(); + } + } + + IsDisposed = true; + } + + /// + /// Returns a string that represents the current effect. + /// + /// The of this effect. + public override string ToString() + { + return Name; + } } diff --git a/source/MonoGame.Extended/Particles/ParticleEffectReader.cs b/source/MonoGame.Extended/Particles/ParticleEffectReader.cs new file mode 100644 index 00000000..23fd848f --- /dev/null +++ b/source/MonoGame.Extended/Particles/ParticleEffectReader.cs @@ -0,0 +1,635 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using MonoGame.Extended.Graphics; +using MonoGame.Extended.Particles.Data; +using MonoGame.Extended.Particles.Modifiers; +using MonoGame.Extended.Particles.Modifiers.Containers; +using MonoGame.Extended.Particles.Modifiers.Interpolators; +using MonoGame.Extended.Particles.Profiles; +using MonoGame.Extended.Serialization.Xml; + +namespace MonoGame.Extended.Particles; + +/// +/// Represents a reader that deserializes a from an XML configuration. +/// +public sealed class ParticleEffectReader : IDisposable +{ + private readonly XmlReader _reader; + private readonly ContentManager _content; + + /// + /// Gets a value that indicates whether this has been disposed of. + /// + public bool IsDisposed { get; private set; } + + /// + /// Initializes a new instance of the class that reads from a file. + /// + /// The file path to read the XMl from. + /// The to use for loading textures. + /// is + /// is or empty. + public ParticleEffectReader(string fileName, ContentManager content) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentException.ThrowIfNullOrEmpty(fileName); + + XmlReaderSettings settings = new XmlReaderSettings(); + settings.CloseInput = true; + settings.IgnoreComments = true; + settings.IgnoreWhitespace = true; + + _content = content; + _reader = XmlReader.Create(fileName, settings); + } + + /// + /// Initializes a new instance of the class that reads from a stream. + /// + /// The stream to read from. + /// The to use for loading textures. + /// or is + public ParticleEffectReader(Stream stream, ContentManager content) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(content); + + XmlReaderSettings settings = new XmlReaderSettings(); + settings.IgnoreComments = true; + settings.IgnoreWhitespace = true; + settings.CloseInput = false; + + _content = content; + _reader = XmlReader.Create(stream, settings); + } + + /// + ~ParticleEffectReader() + { + Dispose(); + } + + /// + /// Reads a from the XML input. + /// + /// The deserialized . + /// The Xml format is invalid. + public ParticleEffect ReadParticleEffect() + { + _reader.MoveToContent(); + + if (_reader.NodeType != XmlNodeType.Element || _reader.LocalName != nameof(ParticleEffect)) + { + throw new XmlException($"Expected {nameof(ParticleEffect)} root element"); + } + + string name = _reader.GetAttribute(nameof(ParticleEffect.Name)) ?? "Unnamed"; + ParticleEffect effect = new ParticleEffect(name); + + effect.Rotation = _reader.GetAttributeFloat(nameof(ParticleEffect.Rotation)); + effect.Scale = _reader.GetAttributeVector2(nameof(ParticleEffect.Scale)); + + if (_reader.ReadToDescendant(nameof(ParticleEffect.Emitters))) + { + if (_reader.ReadToDescendant(nameof(ParticleEmitter))) + { + do + { + ParticleEmitter emitter = ReadParticleEmitter(); + effect.Emitters.Add(emitter); + } while (_reader.ReadToNextSibling(nameof(ParticleEmitter))); + } + } + + return effect; + } + + private ParticleEmitter ReadParticleEmitter() + { + int capacity = _reader.GetAttributeInt(nameof(ParticleEmitter.Capacity)); + + ParticleEmitter emitter = new ParticleEmitter(capacity); + emitter.Name = _reader.GetAttribute(nameof(ParticleEmitter.Name)) ?? nameof(ParticleEmitter.Name); + emitter.LifeSpan = _reader.GetAttributeFloat(nameof(ParticleEmitter.LifeSpan)); + emitter.Offset = _reader.GetAttributeVector2(nameof(ParticleEmitter.Offset)); + emitter.LayerDepth = _reader.GetAttributeFloat(nameof(ParticleEmitter.LayerDepth)); + emitter.AutoTrigger = _reader.GetAttributeBool(nameof(ParticleEmitter.AutoTrigger)); + emitter.AutoTriggerFrequency = _reader.GetAttributeFloat(nameof(ParticleEmitter.AutoTriggerFrequency)); + emitter.ReclaimFrequency = _reader.GetAttributeFloat(nameof(ParticleEmitter.ReclaimFrequency)); + + string strategy = _reader.GetAttribute(nameof(ParticleEmitter.ModifierExecutionStrategy)); + + if (strategy.Equals(nameof(ModifierExecutionStrategy.Serial))) + { + emitter.ModifierExecutionStrategy = ModifierExecutionStrategy.Serial; + } + else if (strategy.Equals(nameof(ModifierExecutionStrategy.Parallel))) + { + emitter.ModifierExecutionStrategy = ModifierExecutionStrategy.Parallel; + } + else + { + emitter.ModifierExecutionStrategy = ModifierExecutionStrategy.Serial; + } + + emitter.RenderingOrder = _reader.GetAttributeEnum(nameof(ParticleEmitter.RenderingOrder)); + + using XmlReader subtree = _reader.ReadSubtree(); + while (subtree.Read()) + { + if (subtree.NodeType == XmlNodeType.Element) + { + switch (subtree.LocalName) + { + case nameof(ParticleEmitter.TextureRegion): + emitter.TextureRegion = ReadTexture2DRegion(subtree); + break; + + case nameof(ParticleEmitter.Parameters): + emitter.Parameters = ReadParticleReleaseParameters(subtree); + break; + + case nameof(ParticleEmitter.Profile): + emitter.Profile = ReadProfile(subtree); + break; + + case nameof(ParticleEmitter.Modifiers): + ReadModifiers(subtree, emitter.Modifiers); + break; + } + } + } + + return emitter; + } + + private Texture2DRegion ReadTexture2DRegion(XmlReader reader) + { + string name = reader.GetAttribute(nameof(Texture2DRegion.Texture.Name)); + + if (string.IsNullOrEmpty(name)) + { + return null; + } + + Rectangle bounds = _reader.GetAttributeRectangle(nameof(Texture2DRegion.Bounds)); + + try + { + // Try loading directly though the content manager. This should work, but there is a bug + // for relative paths which has been documented at + // https://github.com/MonoGame/MonoGame/issues/8786 + // And a propsed fix at + // https://github.com/MonoGame/MonoGame/pull/8787 + // But until that is merged and released, we'll attempt to load here, then fall back to direct load in + // the catch + Texture2D texture = _content.Load(name); + return new Texture2DRegion(texture, bounds); + } + catch (ContentLoadException) + { + return TryLoadTextureDirectly(name, bounds); + } + } + + private Texture2DRegion TryLoadTextureDirectly(string name, Rectangle bounds) + { + if(_content?.ServiceProvider == null) + { + return null; + } + + IGraphicsDeviceService graphicsDeviceService = _content.ServiceProvider.GetService(typeof(IGraphicsDeviceService)) as IGraphicsDeviceService; + if(graphicsDeviceService?.GraphicsDevice == null) + { + return null; + } + + // Try common image extensions + string[] extensions = { ".png", ".jpg", ".jpeg", ".bmp" }; + string baseDirectory = _content.RootDirectory; + + foreach(string extension in extensions) + { + string filePath = Path.Combine(baseDirectory, name + extension); + + if(File.Exists(filePath)) + { + try + { + Texture2D texture = Texture2D.FromFile(graphicsDeviceService.GraphicsDevice, filePath); + texture.Name = name; + return new Texture2DRegion(texture, bounds); + } + catch + { + // Continue to next extension + continue; + } + } + } + + return null; + } + + private ParticleReleaseParameters ReadParticleReleaseParameters(XmlReader reader) + { + ParticleReleaseParameters parameters = new ParticleReleaseParameters(); + + using XmlReader subtree = reader.ReadSubtree(); + while (subtree.Read()) + { + if (subtree.NodeType == XmlNodeType.Element) + { + switch (subtree.LocalName) + { + case nameof(ParticleReleaseParameters.Quantity): + parameters.Quantity = ReadParticleInt32Parameter(subtree); + break; + + case nameof(ParticleReleaseParameters.Speed): + parameters.Speed = ReadParticleFloatParameter(subtree); + break; + + case nameof(ParticleReleaseParameters.Color): + parameters.Color = ReadParticleColorParameter(subtree); + break; + + case nameof(ParticleReleaseParameters.Opacity): + parameters.Opacity = ReadParticleFloatParameter(subtree); + break; + + case nameof(ParticleReleaseParameters.Scale): + parameters.Scale = ReadParticleFloatParameter(subtree); + break; + + case nameof(ParticleReleaseParameters.Rotation): + parameters.Rotation = ReadParticleFloatParameter(subtree); + break; + + case nameof(ParticleReleaseParameters.Mass): + parameters.Mass = ReadParticleFloatParameter(subtree); + break; + } + } + } + + return parameters; + } + + private ParticleInt32Parameter ReadParticleInt32Parameter(XmlReader reader) + { + ParticleValueKind kind = reader.GetAttributeEnum(nameof(ParticleInt32Parameter.Kind)); + + if (kind == ParticleValueKind.Constant) + { + int value = reader.GetAttributeInt(nameof(ParticleInt32Parameter.Constant)); + return new ParticleInt32Parameter(value); + } + else if (kind == ParticleValueKind.Random) + { + int min = reader.GetAttributeInt(nameof(ParticleInt32Parameter.RandomMin)); + int max = reader.GetAttributeInt(nameof(ParticleInt32Parameter.RandomMax)); + return new ParticleInt32Parameter(min, max); + } + + return new ParticleInt32Parameter(0); + } + + private ParticleFloatParameter ReadParticleFloatParameter(XmlReader reader) + { + ParticleValueKind kind = reader.GetAttributeEnum(nameof(ParticleFloatParameter.Kind)); + + if (kind == ParticleValueKind.Constant) + { + float value = reader.GetAttributeFloat(nameof(ParticleFloatParameter.Constant)); + return new ParticleFloatParameter(value); + } + else if (kind == ParticleValueKind.Random) + { + float min = reader.GetAttributeFloat(nameof(ParticleFloatParameter.RandomMin)); + float max = reader.GetAttributeFloat(nameof(ParticleFloatParameter.RandomMax)); + return new ParticleFloatParameter(min, max); + } + + return new ParticleFloatParameter(0); + } + + private ParticleColorParameter ReadParticleColorParameter(XmlReader reader) + { + ParticleValueKind kind = reader.GetAttributeEnum(nameof(ParticleColorParameter.Kind)); + + if (kind == ParticleValueKind.Constant) + { + Vector3 value = reader.GetAttributeVector3(nameof(ParticleColorParameter.Constant)); + return new ParticleColorParameter(value); + } + else if (kind == ParticleValueKind.Random) + { + Vector3 min = reader.GetAttributeVector3(nameof(ParticleColorParameter.RandomMin)); + Vector3 max = reader.GetAttributeVector3(nameof(ParticleColorParameter.RandomMax)); + return new ParticleColorParameter(min, max); + } + + return new ParticleColorParameter(Vector3.Zero); + } + + private Profile ReadProfile(XmlReader reader) + { + string type = reader.GetAttribute(nameof(Type)); + + switch (type) + { + case nameof(BoxProfile): + BoxProfile boxProfile = new BoxProfile(); + boxProfile.Width = reader.GetAttributeFloat(nameof(BoxProfile.Width)); + boxProfile.Height = reader.GetAttributeFloat(nameof(BoxProfile.Height)); + return boxProfile; + + case nameof(BoxFillProfile): + BoxFillProfile boxFillProfile = new BoxFillProfile(); + boxFillProfile.Width = reader.GetAttributeFloat(nameof(BoxFillProfile.Width)); + boxFillProfile.Height = reader.GetAttributeFloat(nameof(BoxFillProfile.Height)); + return boxFillProfile; + + case nameof(BoxUniformProfile): + BoxUniformProfile boxUniformProfile = new BoxUniformProfile(); + boxUniformProfile.Width = reader.GetAttributeFloat(nameof(BoxUniformProfile.Width)); + boxUniformProfile.Height = reader.GetAttributeFloat(nameof(BoxUniformProfile.Height)); + return boxUniformProfile; + + case nameof(CircleProfile): + CircleProfile circleProfile = new CircleProfile(); + circleProfile.Radius = reader.GetAttributeFloat(nameof(CircleProfile.Radius)); + circleProfile.Radiate = reader.GetAttributeEnum(nameof(CircleProfile.Radiate)); + return circleProfile; + + case nameof(LineProfile): + LineProfile lineProfile = new LineProfile(); + lineProfile.Axis = reader.GetAttributeVector2(nameof(LineProfile.Axis)); + lineProfile.Length = reader.GetAttributeFloat(nameof(LineProfile.Length)); + return lineProfile; + + case nameof(LineUniformProfile): + LineUniformProfile lineUniformProfile = new LineUniformProfile(); + lineUniformProfile.Axis = reader.GetAttributeVector2(nameof(LineUniformProfile.Axis)); + lineUniformProfile.Length = reader.GetAttributeFloat(nameof(LineUniformProfile.Length)); + lineUniformProfile.PerpendicularDirection = reader.GetAttributeVector2(nameof(LineUniformProfile.PerpendicularDirection)); + return lineUniformProfile; + + case nameof(PointProfile): + return Profile.Point(); + + case nameof(RingProfile): + RingProfile ringProfile = new RingProfile(); + ringProfile.Radius = reader.GetAttributeFloat(nameof(RingProfile.Radius)); + ringProfile.Radiate = reader.GetAttributeEnum(nameof(RingProfile.Radiate)); + return ringProfile; + + case nameof(SprayProfile): + SprayProfile sprayProfile = new SprayProfile(); + sprayProfile.Direction = reader.GetAttributeVector2(nameof(SprayProfile.Direction)); + sprayProfile.Spread = reader.GetAttributeFloat(nameof(SprayProfile.Spread)); + return sprayProfile; + + default: + return new PointProfile(); + } + } + + private void ReadModifiers(XmlReader reader, List modifiers) + { + using XmlReader subtree = reader.ReadSubtree(); + while (subtree.Read()) + { + if (subtree.NodeType == XmlNodeType.Element && subtree.LocalName == nameof(Modifier)) + { + Modifier modifier = ReadModifier(subtree); + + if (modifier != null) + { + modifiers.Add(modifier); + } + } + } + } + + private Modifier ReadModifier(XmlReader reader) + { + string type = reader.GetAttribute(nameof(Type)); + string name = reader.GetAttribute(nameof(Modifier.Name)); + float frequency = reader.GetAttributeFloat(nameof(Modifier.Frequency)); + + Modifier modifier = type switch + { + nameof(AgeModifier) => ReadAgeModifier(reader), + nameof(DragModifier) => ReadDragModifier(reader), + nameof(LinearGravityModifier) => ReadLinearGravityModifier(reader), + nameof(OpacityFastFadeModifier) => new OpacityFastFadeModifier(), + nameof(RotationModifier) => ReadRotationModifier(reader), + nameof(VelocityColorModifier) => ReadVelocityColorModifier(reader), + nameof(VelocityModifier) => ReadVelocityModifier(reader), + nameof(VortexModifier) => ReadVortexModifier(reader), + nameof(CircleContainerModifier) => ReadCircleContainerModifier(reader), + nameof(RectangleContainerModifier) => ReadRectangleContainerModifier(reader), + nameof(RectangleLoopContainerModifier) => ReadRectangleLoopContainerModifier(reader), + _ => null + }; + + if (modifier != null) + { + modifier.Name = name; + modifier.Frequency = frequency; + } + + return modifier; + } + + private Modifier ReadAgeModifier(XmlReader reader) + { + AgeModifier modifier = new AgeModifier(); + + using XmlReader subtree = reader.ReadSubtree(); + while (subtree.Read()) + { + if (subtree.NodeType == XmlNodeType.Element && subtree.LocalName == nameof(AgeModifier.Interpolators)) + { + ReadInterpolators(subtree, modifier.Interpolators); + } + } + + return modifier; + } + + private Modifier ReadDragModifier(XmlReader reader) + { + DragModifier modifier = new DragModifier(); + modifier.DragCoefficient = reader.GetAttributeFloat(nameof(DragModifier.DragCoefficient)); + modifier.Density = reader.GetAttributeFloat(nameof(DragModifier.Density)); + return modifier; + } + + private LinearGravityModifier ReadLinearGravityModifier(XmlReader reader) + { + LinearGravityModifier modifier = new LinearGravityModifier(); + modifier.Direction = reader.GetAttributeVector2(nameof(LinearGravityModifier.Direction)); + modifier.Strength = reader.GetAttributeFloat(nameof(LinearGravityModifier.Strength)); + return modifier; + } + + private Modifier ReadRotationModifier(XmlReader reader) + { + RotationModifier modifier = new RotationModifier(); + modifier.RotationRate = reader.GetAttributeFloat(nameof(RotationModifier.RotationRate)); + return modifier; + } + + private Modifier ReadVelocityColorModifier(XmlReader reader) + { + VelocityColorModifier modifier = new VelocityColorModifier(); + modifier.StationaryColor = reader.GetAttributeVector3(nameof(VelocityColorModifier.StationaryColor)); + modifier.VelocityColor = reader.GetAttributeVector3(nameof(VelocityColorModifier.VelocityColor)); + modifier.VelocityThreshold = reader.GetAttributeFloat(nameof(VelocityColorModifier.VelocityThreshold)); + return modifier; + } + + private Modifier ReadVelocityModifier(XmlReader reader) + { + VelocityModifier modifier = new VelocityModifier(); + modifier.VelocityThreshold = reader.GetAttributeFloat(nameof(VelocityModifier.VelocityThreshold)); + + using XmlReader subtree = reader.ReadSubtree(); + while (subtree.Read()) + { + if (subtree.NodeType == XmlNodeType.Element && subtree.LocalName == nameof(VelocityModifier.Interpolators)) + { + ReadInterpolators(subtree, modifier.Interpolators); + } + } + + return modifier; + } + + private Modifier ReadVortexModifier(XmlReader reader) + { + VortexModifier modifier = new VortexModifier(); + modifier.Mass = reader.GetAttributeFloat(nameof(VortexModifier.Mass)); + modifier.MaxSpeed = reader.GetAttributeFloat(nameof(VortexModifier.MaxSpeed)); + return modifier; + } + + private CircleContainerModifier ReadCircleContainerModifier(XmlReader reader) + { + CircleContainerModifier modifier = new CircleContainerModifier(); + modifier.Radius = reader.GetAttributeFloat(nameof(CircleContainerModifier.Radius)); + modifier.Inside = reader.GetAttributeBool(nameof(CircleContainerModifier.Inside)); + modifier.RestitutionCoefficient = reader.GetAttributeFloat(nameof(CircleContainerModifier.RestitutionCoefficient)); + return modifier; + } + + private Modifier ReadRectangleContainerModifier(XmlReader reader) + { + RectangleContainerModifier modifier = new RectangleContainerModifier(); + modifier.Width = reader.GetAttributeInt(nameof(RectangleContainerModifier.Width)); + modifier.Height = reader.GetAttributeInt(nameof(RectangleContainerModifier.Height)); + modifier.RestitutionCoefficient = reader.GetAttributeFloat(nameof(RectangleContainerModifier.RestitutionCoefficient)); + return modifier; + } + + private Modifier ReadRectangleLoopContainerModifier(XmlReader reader) + { + RectangleLoopContainerModifier modifier = new RectangleLoopContainerModifier(); + modifier.Width = reader.GetAttributeInt(nameof(RectangleLoopContainerModifier.Width)); + modifier.Height = reader.GetAttributeInt(nameof(RectangleLoopContainerModifier.Height)); + return modifier; + } + + private void ReadInterpolators(XmlReader reader, List interpolators) + { + using XmlReader subtree = reader.ReadSubtree(); + while (subtree.Read()) + { + if (subtree.NodeType == XmlNodeType.Element && subtree.LocalName == nameof(Interpolator)) + { + Interpolator interpolator = ReadInterpolator(subtree); + + if (interpolator != null) + { + interpolators.Add(interpolator); + } + } + } + } + + private Interpolator ReadInterpolator(XmlReader reader) + { + string type = reader.GetAttribute(nameof(Type)); + string name = reader.GetAttribute(nameof(Interpolator.Name)); + + switch (type) + { + case nameof(ColorInterpolator): + ColorInterpolator colorInterpolator = new ColorInterpolator(); + colorInterpolator.StartValue = reader.GetAttributeVector3(nameof(ColorInterpolator.StartValue)); + colorInterpolator.EndValue = reader.GetAttributeVector3(nameof(ColorInterpolator.EndValue)); + return colorInterpolator; + + case nameof(HueInterpolator): + HueInterpolator hueInterpolator = new HueInterpolator(); + hueInterpolator.StartValue = reader.GetAttributeFloat(nameof(HueInterpolator.StartValue)); + hueInterpolator.EndValue = reader.GetAttributeFloat(nameof(HueInterpolator.EndValue)); + return hueInterpolator; + + case nameof(OpacityInterpolator): + OpacityInterpolator opacityInterpolator = new OpacityInterpolator(); + opacityInterpolator.StartValue = reader.GetAttributeFloat(nameof(OpacityInterpolator.StartValue)); + opacityInterpolator.EndValue = reader.GetAttributeFloat(nameof(OpacityInterpolator.EndValue)); + return opacityInterpolator; + + case nameof(RotationInterpolator): + RotationInterpolator rotationInterpolator = new RotationInterpolator(); + rotationInterpolator.StartValue = reader.GetAttributeFloat(nameof(RotationInterpolator.StartValue)); + rotationInterpolator.EndValue = reader.GetAttributeFloat(nameof(RotationInterpolator.EndValue)); + return rotationInterpolator; + + case nameof(ScaleInterpolator): + ScaleInterpolator scaleInterpolator = new ScaleInterpolator(); + scaleInterpolator.StartValue = reader.GetAttributeFloat(nameof(ScaleInterpolator.StartValue)); + scaleInterpolator.EndValue = reader.GetAttributeFloat(nameof(ScaleInterpolator.EndValue)); + return scaleInterpolator; + + case nameof(VelocityInterpolator): + VelocityInterpolator velocityInterpolator = new VelocityInterpolator(); + velocityInterpolator.StartValue = reader.GetAttributeVector2(nameof(VelocityInterpolator.StartValue)); + velocityInterpolator.EndValue = reader.GetAttributeVector2(nameof(VelocityInterpolator.EndValue)); + return velocityInterpolator; + + default: + return null; + } + } + + /// + public void Dispose() + { + if (IsDisposed) + { + return; + } + + _reader?.Dispose(); + + IsDisposed = true; + GC.SuppressFinalize(this); + } +} diff --git a/source/MonoGame.Extended/Particles/ParticleEffectWriter.cs b/source/MonoGame.Extended/Particles/ParticleEffectWriter.cs new file mode 100644 index 00000000..f950b125 --- /dev/null +++ b/source/MonoGame.Extended/Particles/ParticleEffectWriter.cs @@ -0,0 +1,435 @@ +using System; +using System.IO; +using System.Xml; +using MonoGame.Extended.Graphics; +using MonoGame.Extended.Particles.Data; +using MonoGame.Extended.Particles.Modifiers; +using MonoGame.Extended.Particles.Modifiers.Containers; +using MonoGame.Extended.Particles.Modifiers.Interpolators; +using MonoGame.Extended.Particles.Profiles; +using MonoGame.Extended.Serialization.Xml; + +namespace MonoGame.Extended.Particles; + +/// +/// Represents a writer that serializes a to an XML configuration. +/// +public class ParticleEffectWriter : IDisposable +{ + private readonly XmlWriter _writer; + + /// + /// Gets a value that indicates if this has been disposed of. + /// + public bool IsDisposed { get; private set; } + + /// + /// Initializes a new instance of the class that writers to the writes to a file. + /// + /// The file path to write the XML to. + public ParticleEffectWriter(string fileName) + { + XmlWriterSettings settings = new XmlWriterSettings(); + settings.Indent = true; + settings.IndentChars = " "; + settings.CloseOutput = true; + settings.NewLineChars = "\n"; + + _writer = XmlWriter.Create(fileName, settings); + } + + /// + /// Initializes a new instance of the class that writes to a stream. + /// + /// The stream to write to. + public ParticleEffectWriter(Stream stream) + { + XmlWriterSettings settings = new XmlWriterSettings(); + settings.Indent = true; + settings.IndentChars = " "; + settings.CloseOutput = false; + settings.NewLineChars = "\n"; + + _writer = XmlWriter.Create(stream, settings); + } + + /// + ~ParticleEffectWriter() + { + Dispose(); + } + + /// + /// Write a to the XML output. + /// + /// The to serialize. + /// is . + public void WriteParticleEffect(ParticleEffect effect) + { + ArgumentNullException.ThrowIfNull(effect); + + _writer.WriteStartDocument(); + _writer.WriteStartElement("ParticleEffect"); + + _writer.WriteAttributeString(nameof(ParticleEffect.Name), effect.Name); + _writer.WriteAttributeVector2(nameof(ParticleEffect.Position), effect.Position); + _writer.WriteAttributeFloat(nameof(ParticleEffect.Rotation), effect.Rotation); + _writer.WriteAttributeVector2(nameof(ParticleEffect.Scale), effect.Scale); + + if (effect.Emitters.Count > 0) + { + _writer.WriteStartElement(nameof(ParticleEffect.Emitters)); + foreach (ParticleEmitter emitter in effect.Emitters) + { + _writer.WriteStartElement(nameof(ParticleEmitter)); + WriteParticleEmitter(emitter); + _writer.WriteEndElement(); + } + _writer.WriteEndElement(); + } + + _writer.WriteEndElement(); + _writer.WriteEndDocument(); + + _writer.Flush(); + } + + private void WriteParticleEmitter(ParticleEmitter emitter) + { + _writer.WriteAttributeString(nameof(ParticleEmitter.Name), emitter.Name); + _writer.WriteAttributeFloat(nameof(ParticleEmitter.LifeSpan), emitter.LifeSpan); + _writer.WriteAttributeVector2(nameof(ParticleEmitter.Offset), emitter.Offset); + _writer.WriteAttributeFloat(nameof(ParticleEmitter.LayerDepth), emitter.LayerDepth); + _writer.WriteAttributeBool(nameof(ParticleEmitter.AutoTrigger), emitter.AutoTrigger); + _writer.WriteAttributeFloat(nameof(ParticleEmitter.AutoTriggerFrequency), emitter.AutoTriggerFrequency); + _writer.WriteAttributeFloat(nameof(ParticleEmitter.ReclaimFrequency), emitter.ReclaimFrequency); + _writer.WriteAttributeInt(nameof(ParticleEmitter.Capacity), emitter.Capacity); + _writer.WriteAttributeString(nameof(ParticleEmitter.ModifierExecutionStrategy), emitter.ModifierExecutionStrategy.ToString()); + _writer.WriteAttributeString(nameof(ParticleEmitter.RenderingOrder), emitter.RenderingOrder.ToString()); + + if (emitter.TextureRegion is Texture2DRegion region) + { + _writer.WriteStartElement(nameof(ParticleEmitter.TextureRegion)); + WriteTexture2DRegion(region); + _writer.WriteEndElement(); + } + + _writer.WriteStartElement(nameof(ParticleEmitter.Parameters)); + WriteParticleReleaseParameters(emitter.Parameters); + _writer.WriteEndElement(); + + _writer.WriteStartElement(nameof(ParticleEmitter.Profile)); + WriteProfile(emitter.Profile); + _writer.WriteEndElement(); + + + if (emitter.Modifiers.Count > 0) + { + _writer.WriteStartElement(nameof(ParticleEmitter.Modifiers)); + foreach (Modifier modifier in emitter.Modifiers) + { + _writer.WriteStartElement(nameof(Modifier)); + WriteModifier(modifier); + _writer.WriteEndElement(); + } + _writer.WriteEndElement(); + } + } + + private void WriteTexture2DRegion(Texture2DRegion region) + { + _writer.WriteAttributeString(nameof(Texture2DRegion.Texture.Name), region.Texture.Name); + _writer.WriteAttributeRectangle(nameof(Texture2DRegion.Texture.Bounds), region.Texture.Bounds); + + } + + private void WriteParticleReleaseParameters(ParticleReleaseParameters parameters) + { + WriteParticleInt32Parameter(nameof(ParticleReleaseParameters.Quantity), parameters.Quantity); + WriteParticleFloatParameter(nameof(ParticleReleaseParameters.Speed), parameters.Speed); + WriteParticleColorParameter(nameof(ParticleReleaseParameters.Color), parameters.Color); + WriteParticleFloatParameter(nameof(ParticleReleaseParameters.Opacity), parameters.Opacity); + WriteParticleFloatParameter(nameof(ParticleReleaseParameters.Scale), parameters.Scale); + WriteParticleFloatParameter(nameof(ParticleReleaseParameters.Rotation), parameters.Rotation); + WriteParticleFloatParameter(nameof(ParticleReleaseParameters.Mass), parameters.Mass); + + } + + private void WriteParticleInt32Parameter(string name, ParticleInt32Parameter parameter) + { + _writer.WriteStartElement(name); + _writer.WriteAttributeString(nameof(ParticleInt32Parameter.Kind), parameter.Kind.ToString()); + + if (parameter.Kind == ParticleValueKind.Constant) + { + _writer.WriteAttributeInt(nameof(ParticleInt32Parameter.Constant), parameter.Constant); + } + else + { + _writer.WriteAttributeInt(nameof(ParticleInt32Parameter.RandomMin), parameter.RandomMin); + _writer.WriteAttributeInt(nameof(ParticleInt32Parameter.RandomMax), parameter.RandomMax); + } + + _writer.WriteEndElement(); + } + + private void WriteParticleFloatParameter(string name, ParticleFloatParameter parameter) + { + _writer.WriteStartElement(name); + _writer.WriteAttributeString(nameof(ParticleFloatParameter.Kind), parameter.Kind.ToString()); + + if (parameter.Kind == ParticleValueKind.Constant) + { + _writer.WriteAttributeFloat(nameof(ParticleFloatParameter.Constant), parameter.Constant); + } + else + { + _writer.WriteAttributeFloat(nameof(ParticleFloatParameter.RandomMin), parameter.RandomMin); + _writer.WriteAttributeFloat(nameof(ParticleFloatParameter.RandomMax), parameter.RandomMax); + } + + _writer.WriteEndElement(); + } + + private void WriteParticleColorParameter(string name, ParticleColorParameter parameter) + { + _writer.WriteStartElement(name); + _writer.WriteAttributeString(nameof(ParticleColorParameter.Kind), parameter.Kind.ToString()); + + if (parameter.Kind == ParticleValueKind.Constant) + { + _writer.WriteAttributeVector3(nameof(ParticleColorParameter.Constant), parameter.Constant); + } + else + { + _writer.WriteAttributeVector3(nameof(ParticleColorParameter.RandomMin), parameter.RandomMin); + _writer.WriteAttributeVector3(nameof(ParticleColorParameter.RandomMax), parameter.RandomMax); + } + + _writer.WriteEndElement(); + } + + private void WriteProfile(Profile profile) + { + switch (profile) + { + case BoxFillProfile boxFillProfile: + _writer.WriteAttributeString(nameof(Type), nameof(BoxFillProfile)); + _writer.WriteAttributeFloat(nameof(BoxFillProfile.Width), boxFillProfile.Width); + _writer.WriteAttributeFloat(nameof(BoxFillProfile.Height), boxFillProfile.Height); + break; + + case BoxProfile boxProfile: + _writer.WriteAttributeString(nameof(Type), nameof(BoxProfile)); + _writer.WriteAttributeFloat(nameof(BoxProfile.Width), boxProfile.Width); + _writer.WriteAttributeFloat(nameof(BoxProfile.Height), boxProfile.Height); + break; + + case BoxUniformProfile boxUniformProfile: + _writer.WriteAttributeString(nameof(Type), nameof(BoxUniformProfile)); + _writer.WriteAttributeFloat(nameof(BoxUniformProfile.Width), boxUniformProfile.Width); + _writer.WriteAttributeFloat(nameof(BoxUniformProfile.Height), boxUniformProfile.Height); + break; + + case CircleProfile circleProfile: + _writer.WriteAttributeString(nameof(Type), nameof(CircleProfile)); + _writer.WriteAttributeFloat(nameof(CircleProfile.Radius), circleProfile.Radius); + _writer.WriteAttributeString(nameof(CircleProfile.Radiate), circleProfile.Radiate.ToString()); + break; + + case LineProfile lineProfile: + _writer.WriteAttributeString(nameof(Type), nameof(LineProfile)); + _writer.WriteAttributeVector2(nameof(LineProfile.Axis), lineProfile.Axis); + _writer.WriteAttributeFloat(nameof(LineProfile.Length), lineProfile.Length); + break; + + case LineUniformProfile lineUniformProfile: + _writer.WriteAttributeString(nameof(Type), nameof(LineUniformProfile)); + _writer.WriteAttributeVector2(nameof(LineUniformProfile.Axis), lineUniformProfile.Axis); + _writer.WriteAttributeFloat(nameof(LineUniformProfile.Length), lineUniformProfile.Length); + _writer.WriteAttributeVector2(nameof(LineUniformProfile.PerpendicularDirection), lineUniformProfile.PerpendicularDirection); + break; + + case PointProfile: + _writer.WriteAttributeString(nameof(Type), nameof(PointProfile)); + break; + + case RingProfile ringProfile: + _writer.WriteAttributeString(nameof(Type), nameof(RingProfile)); + _writer.WriteAttributeFloat(nameof(RingProfile.Radius), ringProfile.Radius); + _writer.WriteAttributeString(nameof(RingProfile.Radiate), ringProfile.Radiate.ToString()); + break; + + case SprayProfile sprayProfile: + _writer.WriteAttributeString(nameof(Type), nameof(SprayProfile)); + _writer.WriteAttributeVector2(nameof(SprayProfile.Direction), sprayProfile.Direction); + _writer.WriteAttributeFloat(nameof(SprayProfile.Spread), sprayProfile.Spread); + break; + + default: + _writer.WriteAttributeString(nameof(Type), nameof(PointProfile)); + break; + } + } + + private void WriteModifier(Modifier modifier) + { + _writer.WriteAttributeString(nameof(Modifier.Name), modifier.Name); + _writer.WriteAttributeFloat(nameof(Modifier.Frequency), modifier.Frequency); + + switch (modifier) + { + case AgeModifier ageModifier: + _writer.WriteAttributeString(nameof(Type), nameof(AgeModifier)); + if (ageModifier.Interpolators.Count > 0) + { + _writer.WriteStartElement(nameof(AgeModifier.Interpolators)); + foreach (Interpolator interpolator in ageModifier.Interpolators) + { + _writer.WriteStartElement(nameof(Interpolator)); + WriteInterpolator(interpolator); + _writer.WriteEndElement(); + } + _writer.WriteEndElement(); + } + break; + + case CircleContainerModifier circleContainerModifier: + _writer.WriteAttributeString(nameof(Type), nameof(CircleContainerModifier)); + _writer.WriteAttributeFloat(nameof(CircleContainerModifier.Radius), circleContainerModifier.Radius); + _writer.WriteAttributeBool(nameof(CircleContainerModifier.Inside), circleContainerModifier.Inside); + _writer.WriteAttributeFloat(nameof(CircleContainerModifier.RestitutionCoefficient), circleContainerModifier.RestitutionCoefficient); + break; + + case DragModifier dragModifier: + _writer.WriteAttributeString(nameof(Type), nameof(DragModifier)); + _writer.WriteAttributeFloat(nameof(DragModifier.DragCoefficient), dragModifier.DragCoefficient); + _writer.WriteAttributeFloat(nameof(DragModifier.Density), dragModifier.Density); + break; + + case LinearGravityModifier linearGravityModifier: + _writer.WriteAttributeString(nameof(Type), nameof(LinearGravityModifier)); + _writer.WriteAttributeVector2(nameof(LinearGravityModifier.Direction), linearGravityModifier.Direction); + _writer.WriteAttributeFloat(nameof(LinearGravityModifier.Strength), linearGravityModifier.Strength); + break; + + case OpacityFastFadeModifier: + _writer.WriteAttributeString(nameof(Type), nameof(OpacityFastFadeModifier)); + break; + + case RectangleContainerModifier rectangleContainerModifier: + _writer.WriteAttributeString(nameof(Type), nameof(RectangleContainerModifier)); + _writer.WriteAttributeInt(nameof(RectangleContainerModifier.Width), rectangleContainerModifier.Width); + _writer.WriteAttributeInt(nameof(RectangleContainerModifier.Height), rectangleContainerModifier.Height); + _writer.WriteAttributeFloat(nameof(RectangleContainerModifier.RestitutionCoefficient), rectangleContainerModifier.RestitutionCoefficient); + break; + + case RectangleLoopContainerModifier rectangleLoopContainerModifier: + _writer.WriteAttributeString(nameof(Type), nameof(RectangleLoopContainerModifier)); + _writer.WriteAttributeInt(nameof(RectangleLoopContainerModifier.Width), rectangleLoopContainerModifier.Width); + _writer.WriteAttributeInt(nameof(RectangleLoopContainerModifier.Height), rectangleLoopContainerModifier.Height); + break; + + case RotationModifier rotationModifier: + _writer.WriteAttributeString(nameof(Type), nameof(RotationModifier)); + _writer.WriteAttributeFloat(nameof(RotationModifier.RotationRate), rotationModifier.RotationRate); + break; + + case VelocityColorModifier velocityColorModifier: + _writer.WriteAttributeString(nameof(Type), nameof(VelocityColorModifier)); + _writer.WriteAttributeVector3(nameof(VelocityColorModifier.StationaryColor), velocityColorModifier.StationaryColor); + _writer.WriteAttributeVector3(nameof(VelocityColorModifier.VelocityColor), velocityColorModifier.VelocityColor); + _writer.WriteAttributeFloat(nameof(VelocityColorModifier.VelocityThreshold), velocityColorModifier.VelocityThreshold); + break; + + case VelocityModifier velocityModifier: + _writer.WriteAttributeString(nameof(Type), nameof(VelocityModifier)); + _writer.WriteAttributeFloat(nameof(VelocityModifier.VelocityThreshold), velocityModifier.VelocityThreshold); + if (velocityModifier.Interpolators.Count > 0) + { + _writer.WriteStartElement(nameof(VelocityModifier.Interpolators)); + foreach (Interpolator interpolator in velocityModifier.Interpolators) + { + _writer.WriteStartElement(nameof(Interpolator)); + WriteInterpolator(interpolator); + _writer.WriteEndElement(); + } + _writer.WriteEndElement(); + } + break; + + case VortexModifier vortexModifier: + _writer.WriteAttributeString(nameof(Type), nameof(VortexModifier)); + _writer.WriteAttributeVector2(nameof(VortexModifier.Position), vortexModifier.Position); + _writer.WriteAttributeFloat(nameof(VortexModifier.Mass), vortexModifier.Mass); + _writer.WriteAttributeFloat(nameof(VortexModifier.MaxSpeed), vortexModifier.MaxSpeed); + break; + + default: + _writer.WriteAttributeString(nameof(Type), "Unknown"); + break; + } + } + + private void WriteInterpolator(Interpolator interpolator) + { + _writer.WriteAttributeString(nameof(interpolator.Name), interpolator.Name); + + switch (interpolator) + { + case ColorInterpolator colorInterpolator: + _writer.WriteAttributeString(nameof(Type), nameof(ColorInterpolator)); + _writer.WriteAttributeVector3(nameof(ColorInterpolator.StartValue), colorInterpolator.StartValue); + _writer.WriteAttributeVector3(nameof(ColorInterpolator.EndValue), colorInterpolator.EndValue); + break; + + case HueInterpolator hueInterpolator: + _writer.WriteAttributeString(nameof(Type), nameof(HueInterpolator)); + _writer.WriteAttributeFloat(nameof(HueInterpolator.StartValue), hueInterpolator.StartValue); + _writer.WriteAttributeFloat(nameof(HueInterpolator.EndValue), hueInterpolator.EndValue); + break; + + case OpacityInterpolator opacityInterpolator: + _writer.WriteAttributeString(nameof(Type), nameof(OpacityInterpolator)); + _writer.WriteAttributeFloat(nameof(OpacityInterpolator.StartValue), opacityInterpolator.StartValue); + _writer.WriteAttributeFloat(nameof(OpacityInterpolator.EndValue), opacityInterpolator.EndValue); + break; + + case RotationInterpolator rotationInterpolator: + _writer.WriteAttributeString(nameof(Type), nameof(RotationInterpolator)); + _writer.WriteAttributeFloat(nameof(RotationInterpolator.StartValue), rotationInterpolator.StartValue); + _writer.WriteAttributeFloat(nameof(RotationInterpolator.EndValue), rotationInterpolator.EndValue); + break; + + case ScaleInterpolator scaleInterpolator: + _writer.WriteAttributeString(nameof(Type), nameof(ScaleInterpolator)); + _writer.WriteAttributeFloat(nameof(ScaleInterpolator.StartValue), scaleInterpolator.StartValue); + _writer.WriteAttributeFloat(nameof(ScaleInterpolator.EndValue), scaleInterpolator.EndValue); + break; + + case VelocityInterpolator velocityInterpolator: + _writer.WriteAttributeString(nameof(Type), nameof(VelocityInterpolator)); + _writer.WriteAttributeVector2(nameof(VelocityInterpolator.StartValue), velocityInterpolator.StartValue); + _writer.WriteAttributeVector2(nameof(VelocityInterpolator.EndValue), velocityInterpolator.EndValue); + break; + + default: + _writer.WriteAttributeString(nameof(Type), "Unknown"); + break; + } + } + + /// + public void Dispose() + { + if (IsDisposed) + { + return; + } + + _writer.Dispose(); + GC.SuppressFinalize(this); + + IsDisposed = true; + } +} diff --git a/source/MonoGame.Extended/Particles/ParticleEmitter.cs b/source/MonoGame.Extended/Particles/ParticleEmitter.cs index 211fad77..a9f200fc 100644 --- a/source/MonoGame.Extended/Particles/ParticleEmitter.cs +++ b/source/MonoGame.Extended/Particles/ParticleEmitter.cs @@ -1,258 +1,442 @@ -using System; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; using System.Collections.Generic; -using System.ComponentModel; -using System.Text.Json.Serialization; using Microsoft.Xna.Framework; using MonoGame.Extended.Graphics; +using MonoGame.Extended.Particles.Data; using MonoGame.Extended.Particles.Modifiers; +using MonoGame.Extended.Particles.Primitives; using MonoGame.Extended.Particles.Profiles; -namespace MonoGame.Extended.Particles +namespace MonoGame.Extended.Particles; + +/// +/// Represents a particle emitter that creates, manages, and updates particles within a particle system. +/// +/// +/// The class is the core component of the particle system. It handles particle +/// creation (triggering), lifecycle management, and application of modifiers according to defined profiles +/// and parameters. Each emitter operates independently and can be configured with different behaviors, appearances, +/// and physical properties. +/// +public sealed unsafe class ParticleEmitter : IDisposable { - public unsafe class ParticleEmitter : IDisposable + private float _totalSeconds; + private float _secondsSinceLastReclaim; + private float _nextAutoTrigger; + + /// + /// Gets or sets the buffer that stores and manages the particles for this emitter. + /// + public ParticleBuffer Buffer; + + /// + /// Gets or sets the name of this emitter, used for identification and debugging. + /// + public string Name; + + /// + /// Gets the maximum number of particles that this emitter can manage. + /// + /// The size of the underlying . + public int Capacity { - private readonly FastRandom _random = new FastRandom(Math.Abs(Guid.NewGuid().GetHashCode())); - private float _totalSeconds; - - [JsonConstructor] - public ParticleEmitter(string name, Texture2DRegion textureRegion, int capacity, TimeSpan lifeSpan, Profile profile) + get { - if (profile == null) - throw new ArgumentNullException(nameof(profile)); + return Buffer.Size; + } + } - _lifeSpanSeconds = (float)lifeSpan.TotalSeconds; + /// + /// Gets the current number of active particles in this emitter. + /// + /// The count of particles in the underlying . + public int ActiveParticles + { + get + { + return Buffer.Count; + } + } - Name = name; - TextureRegion = textureRegion; - Buffer = new ParticleBuffer(capacity); - Offset = Vector2.Zero; - Profile = profile; - Modifiers = new List(); - ModifierExecutionStrategy = ParticleModifierExecutionStrategy.Serial; - Parameters = new ParticleReleaseParameters(); + /// + /// Gets or sets the lifespan of particles emitted by this emitter, in seconds. + /// + /// + /// After a particle's age exceeds this value, it will be automatically reclaimed during the next cleanup cycle. + /// + public float LifeSpan; + + /// + /// Gets or sets the position offset applied to this emitter. + /// + /// + /// This offset is applied to the emitter's position when triggering particles, allowing for fine adjustment + /// of the emission point without changing the overall position passed to the method. + /// + public Vector2 Offset; + + /// + /// Gets or sets the default layer depth for particles emitted by this emitter. + /// + /// + /// This value determines the rendering order of particles relative to other sprites and particles. + /// Values range from 0.0 (front) to 1.0 (back). + /// + public float LayerDepth; + + /// + /// Gets or sets a value indicating whether this emitter should automatically trigger particle emissions. + /// + /// + /// When set to , the emitter will periodically emit particles based on the + /// property, without requiring explicit calls to . + /// + public bool AutoTrigger; + + /// + /// Gets or sets the frequency, in seconds, at which this emitter automatically triggers particle emissions. + /// + /// + /// This property only has an effect when is set to . + /// + public float AutoTriggerFrequency; + + /// + /// Gets or sets the frequency, in times per second, at which expired particles are reclaimed. + /// + /// + /// Higher values result in more frequent cleanup of expired particles, potentially improving memory + /// utilization at the cost of slightly increased CPU usage. + /// + public float ReclaimFrequency; + + /// + /// Gets or sets the parameters that control the physical and visual properties of emitted particles. + /// + /// + /// These parameters include properties such as initial speed, color, opacity, scale, rotation, and mass. + /// + public ParticleReleaseParameters Parameters; + + /// + /// Gets or sets the strategy used to execute modifiers on particles. + /// + /// + /// This determines whether modifiers are executed serially (single-threaded) or in parallel (multi-threaded), + /// affecting performance characteristics based on the system's capabilities and the number of particles. + /// + public ModifierExecutionStrategy ModifierExecutionStrategy; + + /// + /// Gets or sets the list of modifiers that affect particles emitted by this emitter. + /// + /// + /// Modifiers alter particle properties over time, creating effects such as gravity, color changes, + /// rotation, and containment within boundaries. + /// + public List Modifiers; + + /// + /// Gets or sets the profile that determines the initial position and heading of emitted particles. + /// + /// + /// Profiles define the emission pattern, such as points, lines, rings, or areas from which particles originate. + /// + public Profile Profile; + + /// + /// The to use when rendering particles from this emitter. + /// + public Texture2DRegion TextureRegion; + + /// + /// Gets or sets the order in which particles are rendered within this emitter. + /// + /// + /// This property determines whether particles are drawn front-to-back or back-to-front, + /// affecting how they visually overlap when using alpha blending. + /// + public ParticleRenderingOrder RenderingOrder; + + /// + /// Gets a value indicating whether this has been disposed. + /// + /// if the emitter has been disposed; otherwise, . + public bool IsDisposed { get; private set; } + + /// + /// Initializes a new instance of the class with default capacity. + /// + /// + /// Creates an emitter with a capacity of 1000 particles and default settings. + /// + public ParticleEmitter() : this(1000) { } + + /// + /// Initializes a new instance of the class with the specified capacity. + /// + /// The maximum number of particles this emitter can manage. + /// + /// This constructor initializes the emitter with default settings but allows for specifying + /// the maximum number of particles it can handle. + /// + public ParticleEmitter(int initialCapacity) + { + LifeSpan = 1.0f; + Name = nameof(ParticleEmitter); + TextureRegion = null; + Buffer = new ParticleBuffer(initialCapacity); + Profile = Profile.Point(); + Modifiers = new List(); + ModifierExecutionStrategy = ModifierExecutionStrategy.Serial; + Parameters = new ParticleReleaseParameters(); + ReclaimFrequency = 60.0f; + Offset = Vector2.Zero; + LayerDepth = 0.0f; + AutoTrigger = true; + AutoTriggerFrequency = 1.0f; + } + + /// + /// Finalizes an instance of the class. + /// + ~ParticleEmitter() + { + Dispose(false); + } + + /// + /// Changes the maximum capacity of this emitter. + /// + /// The new maximum number of particles this emitter can manage. + /// + /// This method disposes the old buffer and creates a new one with the specified capacity. + /// Any existing particles are lost during this operation. + /// + /// + /// Thrown if this method is called after the emitter has been disposed. + /// + public void ChangeCapacity(int size) + { + ObjectDisposedException.ThrowIf(IsDisposed, typeof(ParticleBuffer)); + + if (Capacity == size) + { + return; } - public ParticleEmitter(Texture2DRegion textureRegion, int capacity, TimeSpan lifeSpan, Profile profile) - : this(null, textureRegion, capacity, lifeSpan, profile) + if (Buffer is ParticleBuffer oldBuffer) { + oldBuffer.Dispose(); } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } + Buffer = new ParticleBuffer(size); + } - protected virtual void Dispose(bool disposing) + /// + /// Updates the state of all particles managed by this emitter. + /// + /// The elapsed time, in seconds, since the last update. + /// The current position of the emitter in 2D space. + /// + /// This method handles automatic triggering of particle emissions, updates the positions of all active + /// particles based on their velocities, applies all registered modifiers, and reclaims expired particles. + /// + /// + /// Thrown if this method is called after the emitter has been disposed. + /// + public void Update(float elapsedSeconds, Vector2 position = default) + { + ObjectDisposedException.ThrowIf(IsDisposed, typeof(ParticleBuffer)); + + _totalSeconds += elapsedSeconds; + _secondsSinceLastReclaim += elapsedSeconds; + + if (AutoTrigger) { - if(IsDisposed) + _nextAutoTrigger -= elapsedSeconds; + + if (_nextAutoTrigger <= 0) { - return; - } - - Buffer.Dispose(); - Buffer = null; - IsDisposed = true; - } - - ~ParticleEmitter() - { - Dispose(false); - } - - public string Name { get; set; } - public int ActiveParticles => Buffer.Count; - public Vector2 Offset { get; set; } - public List Modifiers { get; } - public Profile Profile { get; set; } - public float LayerDepth { get; set; } - public ParticleReleaseParameters Parameters { get; set; } - public Texture2DRegion TextureRegion { get; set; } - - /// - /// Gets a value that indicates whether this instance of the class has been - /// disposed. - /// - public bool IsDisposed { get; private set;} - - [EditorBrowsable(EditorBrowsableState.Never)] - public ParticleModifierExecutionStrategy ModifierExecutionStrategy { get; set; } - - internal ParticleBuffer Buffer; - - public int Capacity - { - get - { - ThrowIfDisposed(); - return Buffer.Size; - } - set - { - ThrowIfDisposed(); - - var oldBuffer = Buffer; - oldBuffer.Dispose(); - Buffer = new ParticleBuffer(value); + Trigger(position, LayerDepth); + _nextAutoTrigger = AutoTriggerFrequency; } } - private float _lifeSpanSeconds; - public TimeSpan LifeSpan + if (Buffer.Count == 0) { - get { return TimeSpan.FromSeconds(_lifeSpanSeconds); } - set { _lifeSpanSeconds = (float) value.TotalSeconds; } + return; } - private float _nextAutoTrigger; - - private bool _autoTrigger = true; - public bool AutoTrigger + if (_secondsSinceLastReclaim > (1.0f / ReclaimFrequency)) { - get { return _autoTrigger; } - set - { - _autoTrigger = value; - _nextAutoTrigger = 0; - } - } - - private float _autoTriggerFrequency; - public float AutoTriggerFrequency - { - get { return _autoTriggerFrequency; } - set - { - _autoTriggerFrequency = value; - _nextAutoTrigger = 0; - } - } - - private void ReclaimExpiredParticles() - { - var iterator = Buffer.Iterator; - var expired = 0; - - while (iterator.HasNext) - { - var particle = iterator.Next(); - - if (_totalSeconds - particle->Inception < _lifeSpanSeconds) - break; - - expired++; - } - - if (expired != 0) - Buffer.Reclaim(expired); - } - - public bool Update(float elapsedSeconds, Vector2 position = default(Vector2)) - { - ThrowIfDisposed(); - - _totalSeconds += elapsedSeconds; - - if (_autoTrigger) - { - _nextAutoTrigger -= elapsedSeconds; - - if (_nextAutoTrigger <= 0) - { - Trigger(position, this.LayerDepth); - _nextAutoTrigger = _autoTriggerFrequency; - } - } - - if (Buffer.Count == 0) - return false; - ReclaimExpiredParticles(); + _secondsSinceLastReclaim -= (1.0f / ReclaimFrequency); + } - var iterator = Buffer.Iterator; - + if (Buffer.Count > 0) + { + ParticleIterator iterator = Buffer.Iterator; while (iterator.HasNext) { - var particle = iterator.Next(); - particle->Age = (_totalSeconds - particle->Inception) / _lifeSpanSeconds; - particle->Position = particle->Position + particle->Velocity * elapsedSeconds; + Particle* particle = iterator.Next(); + particle->Age = (_totalSeconds - particle->Inception) / LifeSpan; + particle->Position[0] += particle->Velocity[0] * elapsedSeconds; + particle->Position[1] += particle->Velocity[1] * elapsedSeconds; } ModifierExecutionStrategy.ExecuteModifiers(Modifiers, elapsedSeconds, iterator); - return true; - } - - public void Trigger(Vector2 position, float layerDepth = 0) - { - var numToRelease = _random.Next(Parameters.Quantity); - Release(position + Offset, numToRelease, layerDepth); - } - - public void Trigger(LineSegment line, float layerDepth = 0) - { - var numToRelease = _random.Next(Parameters.Quantity); - var lineVector = line.ToVector(); - - for (var i = 0; i < numToRelease; i++) - { - var offset = lineVector * _random.NextSingle(); - Release(line.Origin + offset, 1, layerDepth); - } - } - - private void Release(Vector2 position, int numToRelease, float layerDepth) - { - ThrowIfDisposed(); - - var iterator = Buffer.Release(numToRelease); - - while (iterator.HasNext) - { - var particle = iterator.Next(); - - Vector2 heading; - Profile.GetOffsetAndHeading(out particle->Position, out heading); - - particle->Age = 0f; - particle->Inception = _totalSeconds; - particle->Position += position; - particle->TriggerPos = position; - - var speed = _random.NextSingle(Parameters.Speed); - - particle->Velocity = heading * speed; - - _random.NextColor(out particle->Color, Parameters.Color); - - particle->Opacity = _random.NextSingle(Parameters.Opacity); - - if(Parameters.MaintainAspectRatioOnScale) - { - var scale = _random.NextSingle(Parameters.Scale); - particle->Scale = new Vector2(scale, scale); - } - else - { - particle->Scale = new Vector2(_random.NextSingle(Parameters.ScaleX), _random.NextSingle(Parameters.ScaleY)); - } - - particle->Rotation = _random.NextSingle(Parameters.Rotation); - particle->Mass = _random.NextSingle(Parameters.Mass); - particle->LayerDepth = layerDepth; - } - } - - private void ThrowIfDisposed() - { - if(IsDisposed) - { - throw new ObjectDisposedException(nameof(ParticleBuffer)); - } - } - - public override string ToString() - { - return Name; } } + + /// + /// Triggers the emission of particles at the specified position. + /// + /// The position in 2D space from which to emit particles. + /// The layer depth at which to render the emitted particles. + /// + /// This method creates a burst of particles according to the configured . + /// The number of particles released is determined by the property. + /// + public void Trigger(Vector2 position, float layerDepth = 0) + { + int numToRelease = Parameters.Quantity.Value; + Release(position, numToRelease, layerDepth); + } + + /// + /// Triggers the emission of particles along a line segment. + /// + /// The line segment along which to distribute emitted particles. + /// The layer depth at which to render the emitted particles. + /// + /// This method creates particles at random positions along the specified line segment. + /// The number of particles released is determined by the property. + /// + public void Trigger(LineSegment line, float layerDepth = 0) + { + int numToRelease = Parameters.Quantity.Value; + Vector2 lineVector = line.ToVector2(); + + for (int i = 0; i < numToRelease; i++) + { + Vector2 offset = lineVector * FastRandom.Shared.NextSingle(); + Release(line.Origin + offset, 1, layerDepth); + } + } + + /// + /// Releases a specified number of particles at the given position. + /// + /// The position in 2D space from which to emit particles. + /// The number of particles to release. + /// The layer depth at which to render the emitted particles. + /// + /// This method initializes newly created particles with properties based on the emitter's + /// and . + /// + private void Release(Vector2 position, int numToRelease, float layerDepth) + { + ParticleIterator iterator = Buffer.Release(numToRelease); + + while (iterator.HasNext) + { + Particle* particle = iterator.Next(); + + Profile.GetOffsetAndHeading((Vector2*)particle->Position, (Vector2*)particle->Velocity); + + particle->Age = 0.0f; + particle->Inception = _totalSeconds; + + particle->Position[0] += position.X; + particle->Position[1] += position.Y; + + particle->TriggeredPos[0] = position.X; + particle->TriggeredPos[1] = position.Y; + + float speed = Parameters.Speed.Value; + + particle->Velocity[0] *= speed; + particle->Velocity[1] *= speed; + + Vector3 color = Parameters.Color.Value; + particle->Color[0] = color.X; + particle->Color[1] = color.Y; + particle->Color[2] = color.Z; + + particle->Opacity = Parameters.Opacity.Value; + particle->Scale = Parameters.Scale.Value; + particle->Rotation = Parameters.Rotation.Value; + particle->Mass = Parameters.Mass.Value; + particle->LayerDepth = layerDepth; + } + } + + /// + /// Reclaims particles that have exceeded their lifespan. + /// + /// + /// This method removes expired particles from the beginning of the buffer and compacts + /// the remaining particles to maintain efficient memory usage. + /// + private void ReclaimExpiredParticles() + { + int expired = 0; + ParticleIterator iterator = Buffer.Iterator; + while (iterator.HasNext) + { + Particle* particle = iterator.Next(); + + if ((_totalSeconds - particle->Inception) < LifeSpan) + { + break; + } + expired++; + } + + if (expired != 0) + { + Buffer.Reclaim(expired); + } + } + + /// + /// Returns a string that represents the current emitter. + /// + /// The of this emitter. + public override string ToString() + { + return Name; + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases the unmanaged resources used by the . + /// + /// to release both managed and unmanaged resources; + /// to release only unmanaged resources. + private void Dispose(bool disposing) + { + if (IsDisposed) { return; } + + if (disposing) + { + // No managed objects + } + + Buffer.Dispose(); + IsDisposed = true; + } } diff --git a/source/MonoGame.Extended/Particles/ParticleExtensions.cs b/source/MonoGame.Extended/Particles/ParticleExtensions.cs deleted file mode 100644 index b5c17e0c..00000000 --- a/source/MonoGame.Extended/Particles/ParticleExtensions.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Graphics; -using MonoGame.Extended.Graphics; - -namespace MonoGame.Extended.Particles -{ - public static class ParticleExtensions - { - public static void Draw(this SpriteBatch spriteBatch, ParticleEffect effect) - { - if(effect.IsDisposed) - { - return; - } - - for (var i = 0; i < effect.Emitters.Count; i++) - UnsafeDraw(spriteBatch, effect.Emitters[i]); - } - - public static void Draw(this SpriteBatch spriteBatch, ParticleEmitter emitter) - { - if(emitter.IsDisposed) - { - return; - } - - UnsafeDraw(spriteBatch, emitter); - } - - private static unsafe void UnsafeDraw(SpriteBatch spriteBatch, ParticleEmitter emitter) - { - if(emitter.TextureRegion == null) - return; - - var textureRegion = emitter.TextureRegion; - var origin = new Vector2(textureRegion.Width/2f, textureRegion.Height/2f); - var iterator = emitter.Buffer.Iterator; - - while (iterator.HasNext) - { - var particle = iterator.Next(); - var color = particle->Color.ToRgb(); - - if (spriteBatch.GraphicsDevice.BlendState == BlendState.AlphaBlend) - color *= particle->Opacity; - else - color.A = (byte) (particle->Opacity*255); - - var position = new Vector2(particle->Position.X, particle->Position.Y); - var scale = particle->Scale; - color.A = (byte)MathHelper.Clamp(particle->Opacity*255, 0, 255); - var rotation = particle->Rotation; - var layerDepth = particle->LayerDepth; - - spriteBatch.Draw(textureRegion, position, color, rotation, origin, scale, SpriteEffects.None, layerDepth); - } - } - } -} diff --git a/source/MonoGame.Extended/Particles/ParticleIterator.cs b/source/MonoGame.Extended/Particles/ParticleIterator.cs new file mode 100644 index 00000000..ac88b051 --- /dev/null +++ b/source/MonoGame.Extended/Particles/ParticleIterator.cs @@ -0,0 +1,101 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles; + +/// +/// Provides functionality for iterating through particles in a circular buffer. +/// +/// +/// The class enables safe traversal of active particles in a +/// , automatically handling the circular nature of the buffer and wrapping around +/// boundaries as needed. +/// +public sealed class ParticleIterator +{ + private readonly ParticleBuffer _buffer; + private unsafe Particle* _current; + + /// + /// Gets the total number of particles that can be iterated over. + /// + public int Total { get; private set; } + + /// + /// Gets a value indicating whether there are more particles to iterate over. + /// + /// + /// if there are more particles available; otherwise, . + /// + public unsafe bool HasNext => _current != _buffer.Tail; + + /// + /// Initializes a new instance of the class. + /// + /// The to iterate over. + /// is . + /// has previously been disposed. + public ParticleIterator(ParticleBuffer buffer) + { + ArgumentNullException.ThrowIfNull(buffer); + ObjectDisposedException.ThrowIf(buffer.IsDisposed, buffer); + _buffer = buffer; + } + + /// + /// Resets the iterator to the beginning of the active particles in the buffer. + /// + /// This instance. + public unsafe ParticleIterator Reset() + { + _current = _buffer.Head; + Total = _buffer.Count; + return this; + } + + /// + /// Resets the iterator to a specific offset position within the active particles. + /// + /// The number of particles to offset from the head position. + /// This instance. + internal unsafe ParticleIterator Reset(int offset) + { + Total = _buffer.Count; + + _current = _buffer.Head + offset; + + if (_current >= _buffer.BufferEnd) + { + _current -= _buffer.Size + 1; + } + + return this; + } + + /// + /// Advances the iterator to the next particle and returns a pointer to the current particle. + /// + /// A pointer to the current particle before advancing the iterator. + public unsafe Particle* Next() + { + Particle* particle = _current; + + _current++; + + if (_current == _buffer.BufferEnd) + { + _current = (Particle*)_buffer.NativePointer; + } + + return particle; + + } + + + + +} diff --git a/source/MonoGame.Extended/Particles/ParticleModifierExecutionStrategy.cs b/source/MonoGame.Extended/Particles/ParticleModifierExecutionStrategy.cs deleted file mode 100644 index 20a4d51c..00000000 --- a/source/MonoGame.Extended/Particles/ParticleModifierExecutionStrategy.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.Collections.Generic; -using MonoGame.Extended.Particles.Modifiers; - -namespace MonoGame.Extended.Particles -{ - using TPL = System.Threading.Tasks; - - public abstract class ParticleModifierExecutionStrategy - { - public static readonly ParticleModifierExecutionStrategy Serial = new SerialModifierExecutionStrategy(); - public static readonly ParticleModifierExecutionStrategy Parallel = new ParallelModifierExecutionStrategy(); - - internal abstract void ExecuteModifiers(List modifiers, float elapsedSeconds, ParticleBuffer.ParticleIterator iterator); - - internal class SerialModifierExecutionStrategy : ParticleModifierExecutionStrategy - { - internal override void ExecuteModifiers(List modifiers, float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) - { - for (var i = 0; i < modifiers.Count; i++) - modifiers[i].Update(elapsedSeconds, iterator.Reset()); - } - - public override string ToString() - { - return nameof(Serial); - } - } - - internal class ParallelModifierExecutionStrategy : ParticleModifierExecutionStrategy - { - internal override void ExecuteModifiers(List modifiers, float elapsedSeconds, ParticleBuffer.ParticleIterator iterator) - { - TPL.Parallel.ForEach(modifiers, modifier => modifier.Update(elapsedSeconds, iterator.Reset())); - } - - public override string ToString() - { - return nameof(Parallel); - } - } - - public static ParticleModifierExecutionStrategy Parse(string value) - { - if (string.Equals(nameof(Parallel), value, StringComparison.OrdinalIgnoreCase)) - return Parallel; - - if (string.Equals(nameof(Serial), value, StringComparison.OrdinalIgnoreCase)) - return Serial; - - throw new InvalidOperationException($"Unknown particle modifier execution strategy '{value}'"); - } - } -} \ No newline at end of file diff --git a/source/MonoGame.Extended/Particles/ParticleReleaseParameters.cs b/source/MonoGame.Extended/Particles/ParticleReleaseParameters.cs deleted file mode 100644 index 195cbdd3..00000000 --- a/source/MonoGame.Extended/Particles/ParticleReleaseParameters.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.Xna.Framework; -using MonoGame.Extended; - -namespace MonoGame.Extended.Particles -{ - public class ParticleReleaseParameters - { - public ParticleReleaseParameters() - { - Quantity = 1; - Speed = new Range(-1f, 1f); - Color = new Range(HslColor.FromRgb(Microsoft.Xna.Framework.Color.Wheat), HslColor.FromRgb(Microsoft.Xna.Framework.Color.White)); - Opacity = new Range(0f, 1f); - Scale = new Range(1f, 1f); - Rotation = new Range(-MathHelper.Pi, MathHelper.Pi); - Mass = 1f; - MaintainAspectRatioOnScale = true; - ScaleX = new Range(1f, 1f); - ScaleY = new Range(1f, 1f); - } - - public Range Quantity { get; set; } - public Range Speed { get; set; } - public Range Color { get; set; } - public Range Opacity { get; set; } - public Range Scale { get; set; } - public Range Rotation { get; set; } - public Range Mass { get; set; } - public bool MaintainAspectRatioOnScale { get; set; } - public Range ScaleX { get; set; } - public Range ScaleY { get; set; } - - } -} diff --git a/source/MonoGame.Extended/Particles/ParticleRenderingOrder.cs b/source/MonoGame.Extended/Particles/ParticleRenderingOrder.cs new file mode 100644 index 00000000..7a564fe4 --- /dev/null +++ b/source/MonoGame.Extended/Particles/ParticleRenderingOrder.cs @@ -0,0 +1,25 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +namespace MonoGame.Extended.Particles; + +/// +/// Specifies the order in which particles are rendered within the particle system. +/// +/// +/// This enumeration defines the rendering order strategies that can be applied to particles to control how they are +/// layered visually. The chosen rendering order affects which particles appear in front of others when they overlap. +/// +public enum ParticleRenderingOrder +{ + /// + /// Particles are rendered from front to back. + /// + FrontToBack, + + /// + /// Particles are rendered from back to front. + /// + BackToFront +} diff --git a/source/MonoGame.Extended/Particles/Primatives/LineSegment.cs b/source/MonoGame.Extended/Particles/Primatives/LineSegment.cs new file mode 100644 index 00000000..8fa2a323 --- /dev/null +++ b/source/MonoGame.Extended/Particles/Primatives/LineSegment.cs @@ -0,0 +1,181 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using System.Runtime.InteropServices; + +namespace MonoGame.Extended.Particles.Primitives; + +/// +/// Represents a line segment defined by two points in 2D space. +/// +[StructLayout(LayoutKind.Sequential)] +public readonly struct LineSegment : IEquatable +{ + /// + /// The first point of the line segment. + /// + internal readonly Vector2 _point1; + + /// + /// The second point of the line segment. + /// + internal readonly Vector2 _point2; + + /// + /// Gets the origin point of the line segment. + /// + /// The first point of the line segment. + public readonly Vector2 Origin + { + get + { + return _point1; + } + } + + /// + /// Gets the direction vector of the line segment. + /// + /// A vector from the first point to the second point. + public readonly Vector2 Direction + { + get + { + return _point2 - _point1; + } + } + + /// + /// Initializes a new instance of the struct with the specified points. + /// + /// The first point of the line segment. + /// The second point of the line segment. + public LineSegment(Vector2 point1, Vector2 point2) + { + _point1 = point1; + _point2 = point2; + } + + /// + /// Returns a new line segment that is a translated version of this line segment. + /// + /// The vector by which to translate the line segment. + /// A new that is offset by the specified vector. + public LineSegment Translate(Vector2 vector) + { + return new LineSegment(_point1 + vector, _point2 + vector); + } + + /// + /// Converts the line segment to a vector representing its direction and magnitude. + /// + /// + /// A representing the direction and length of the line segment, calculated as the second + /// point minus the first point. + /// + public Vector2 ToVector2() + { + return _point2 - _point1; + } + + /// + /// Creates a new line segment from two points. + /// + /// The first point of the line segment. + /// The second point of the line segment. + /// A new defined by the two points. + public static LineSegment FromPoints(Vector2 point1, Vector2 point2) + { + return new LineSegment(point1, point2); + } + + /// + /// Creates a new line segment from an origin point and a direction vector. + /// + /// The starting point of the line segment. + /// The direction and length vector of the line segment. + /// + /// A new starting at the origin and extending by the specified vector. + /// + public static LineSegment FromOrigin(Vector2 origin, Vector2 vector) + { + return new LineSegment(origin, origin + vector); + } + + /// + /// Determines whether the specified object is equal to the current line segment. + /// + /// The object to compare with the current line segment. + /// + /// if the specified object is a and is equal to the + /// current line segment; otherwise, . + /// + public override readonly bool Equals([NotNullWhen(true)] object obj) + { + return obj is LineSegment other && Equals(other); + } + + /// + /// Determines whether the specified line segment is equal to the current line segment. + /// + /// The line segment to compare with the current line segment. + /// + /// if the specified line segment is equal to the current line segment; + /// otherwise, . + /// + public readonly bool Equals(LineSegment other) + { + return _point1.Equals(other._point1) && _point2.Equals(other._point2); + } + + /// + /// Returns the hash code for this line segment. + /// + /// A 32-bit signed integer that is the hash code for this instance. + public override readonly int GetHashCode() + { + return HashCode.Combine(_point1, _point2); + } + + /// + /// Returns a string representation of this line segment. + /// + /// + /// A string containing the coordinates of both points in the format: + /// "(x1:y1,x2:y2)". + /// + public override readonly string ToString() + { + return $"({_point1:x}:{_point1:y},{_point2:x}:{_point2:y})"; + } + + /// + /// Determines whether two line segments are equal. + /// + /// The first line segment to compare. + /// The second line segment to compare. + /// + /// if the line segments are equal; otherwise, . + /// + public static bool operator ==(LineSegment lhs, LineSegment rhs) + { + return lhs.Equals(rhs); + } + + /// + /// Determines whether two line segments are not equal. + /// + /// The first line segment to compare. + /// The second line segment to compare. + /// + /// if the line segments are not equal; otherwise, . + /// + public static bool operator !=(LineSegment lhs, LineSegment rhs) + { + return !lhs.Equals(rhs); + } +} diff --git a/source/MonoGame.Extended/Particles/Profiles/BoxFillProfile.cs b/source/MonoGame.Extended/Particles/Profiles/BoxFillProfile.cs index e3637135..ef81830a 100644 --- a/source/MonoGame.Extended/Particles/Profiles/BoxFillProfile.cs +++ b/source/MonoGame.Extended/Particles/Profiles/BoxFillProfile.cs @@ -1,18 +1,40 @@ -using Microsoft.Xna.Framework; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Profiles +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Particles.Profiles; + +/// +/// A profile that randomly distributes particles throughout a rectangular area. +/// +/// +/// The generates random positions within a rectangle centered the emitter's position, with +/// random unit vector headings. This creates a uniform distribution of particles across the defined area, with +/// particles moving in all directions. +/// +public sealed class BoxFillProfile : Profile { - public class BoxFillProfile : Profile + /// + /// The width of the rectangular area. + /// + public float Width; + + /// + /// The height of the rectangular area. + /// + public float Height; + + /// + /// Computes the offset and heading for a new particle. + /// + /// A pointer to the Vector2 where the offset from the emitter position will be stored. + /// A pointer to the Vector2 where the unit direction vector will be stored. + public override unsafe void GetOffsetAndHeading(Vector2* offset, Vector2* heading) { - public float Width { get; set; } - public float Height { get; set; } - - public override void GetOffsetAndHeading(out Vector2 offset, out Vector2 heading) - { - offset = new Vector2(Random.NextSingle(Width*-0.5f, Width*0.5f), - Random.NextSingle(Height*-0.5f, Height*0.5f)); - - Random.NextUnitVector(out heading); - } + offset->X = FastRandom.Shared.NextSingle(Width * -0.5f, Width * 0.5f); + offset->Y = FastRandom.Shared.NextSingle(Height * -0.5f, Height * 0.5f); + FastRandom.Shared.NextUnitVector(heading); } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Profiles/BoxProfile.cs b/source/MonoGame.Extended/Particles/Profiles/BoxProfile.cs index 7d31984c..92cabc6e 100644 --- a/source/MonoGame.Extended/Particles/Profiles/BoxProfile.cs +++ b/source/MonoGame.Extended/Particles/Profiles/BoxProfile.cs @@ -1,31 +1,61 @@ -using Microsoft.Xna.Framework; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Profiles +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Particles.Profiles; + +/// +/// A profile that distributes particles along the edges of a rectangular boundary. +/// +/// +/// The randomly positions new particles on one of the four sides of a rectangular area +/// centered at the emitter's position. Each side has an equal probability of being selected. Particles are given random +/// unit vector headings, allowing them to move in any direction regardless of their starting edge. +/// +public sealed class BoxProfile : Profile { - public class BoxProfile : Profile + /// + /// The width of the rectangular perimeter. + /// + public float Width; + + /// + /// The height of the rectangular perimeter. + /// + public float Height; + + /// + /// Computes the offset and heading for a new particle. + /// + /// A pointer to the Vector2 where the offset from the emitter position will be stored. + /// A pointer to the Vector2 where the unit direction vector will be stored. + public override unsafe void GetOffsetAndHeading(Vector2* offset, Vector2* heading) { - public float Width { get; set; } - public float Height { get; set; } - - public override void GetOffsetAndHeading(out Vector2 offset, out Vector2 heading) + switch (FastRandom.Shared.Next(4)) { - switch (Random.Next(3)) - { - case 0: // Left - offset = new Vector2(Width*-0.5f, Random.NextSingle(Height*-0.5f, Height*0.5f)); - break; - case 1: // Top - offset = new Vector2(Random.NextSingle(Width*-0.5f, Width*0.5f), Height*-0.5f); - break; - case 2: // Right - offset = new Vector2(Width*0.5f, Random.NextSingle(Height*-0.5f, Height*0.5f)); - break; - default: // Bottom - offset = new Vector2(Random.NextSingle(Width*-0.5f, Width*0.5f), Height*0.5f); - break; - } + case 0: // Left + offset->X = Width * -0.5f; + offset->Y = FastRandom.Shared.NextSingle(Height * -0.5f, Height * 0.5f); + break; - Random.NextUnitVector(out heading); + case 1: // Top + offset->X = FastRandom.Shared.NextSingle(Width * -0.5f, Width * 0.5f); + offset->Y = Height * -0.5f; + break; + + case 2: // Right + offset->X = Width * 0.5f; + offset->Y = FastRandom.Shared.NextSingle(Height * -0.5f, Height * 0.5f); + break; + + default: // Bottom + offset->X = FastRandom.Shared.NextSingle(Width * -0.5f, Width * 0.5f); + offset->Y = Height * 0.5f; + break; } + + FastRandom.Shared.NextUnitVector(heading); } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Profiles/BoxUniformProfile.cs b/source/MonoGame.Extended/Particles/Profiles/BoxUniformProfile.cs index 9f766dcf..3cef853f 100644 --- a/source/MonoGame.Extended/Particles/Profiles/BoxUniformProfile.cs +++ b/source/MonoGame.Extended/Particles/Profiles/BoxUniformProfile.cs @@ -1,32 +1,79 @@ -using Microsoft.Xna.Framework; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Profiles +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Particles.Profiles; + +/// +/// A profile that distributes particles along the edges of a rectangular boundary with uniform density. +/// +/// +/// +/// The positions new particles on the perimeter of a rectangle centered at the +/// emitter's position. Unlike which gives equal probability to each side, this profile +/// allocates probability proportional to the length of each side, ensuring a uniform distribution of particles +/// around the entire perimeter. +/// +/// +/// This means longer sides will receive more particles than shorter sides, creating a visually balanced +/// distribution regardless of the rectangle's dimensions. +/// +/// +/// Particles are given random unit vector headings, allowing them to move in any direction regardless of their +/// starting edge. +/// +/// +public class BoxUniformProfile : Profile { - public class BoxUniformProfile : Profile + /// + /// The width of the rectangular perimeter. + /// + public float Width; + + /// + /// The height of the rectangular perimeter. + /// + public float Height; + + /// + /// Computes the offset and heading for a new particle. + /// + /// A pointer to the Vector2 where the offset from the emitter position will be stored. + /// A pointer to the Vector2 where the unit direction vector will be stored. + public override unsafe void GetOffsetAndHeading(Vector2* offset, Vector2* heading) { - public float Width { get; set; } - public float Height { get; set; } + int perimeter = (int)(2 * Width + 2 * Height); + int value = FastRandom.Shared.Next(perimeter); - public override void GetOffsetAndHeading(out Vector2 offset, out Vector2 heading) + switch (value) { - var value = Random.Next((int) (2*Width + 2*Height)); + // Top + case var _ when value < Width: + offset->X = FastRandom.Shared.NextSingle(Width * -0.5f, Width * 0.5f); + offset->Y = Height * -0.5f; + break; - if (value < Width) // Top - offset = new Vector2(Random.NextSingle(Width*-0.5f, Width*0.5f), Height*-0.5f); - else - { - if (value < 2*Width) // Bottom - offset = new Vector2(Random.NextSingle(Width*-0.5f, Width*0.5f), Height*0.5f); - else - { - if (value < 2*Width + Height) // Left - offset = new Vector2(Width*-0.5f, Random.NextSingle(Height*-0.5f, Height*0.5f)); - else // Right - offset = new Vector2(Width*0.5f, Random.NextSingle(Height*-0.5f, Height*0.5f)); - } - } + // Bottom + case var _ when value < 2 * Width: + offset->X = FastRandom.Shared.NextSingle(Width * -0.5f, Width * 0.5f); + offset->Y = Height * 0.5f; + break; - Random.NextUnitVector(out heading); + // Left + case var _ when value < 2 * Width + Height: + offset->X = Width * -0.5f; + offset->Y = FastRandom.Shared.NextSingle(Height * -0.5f, Height * 0.5f); + break; + + // Right + default: + offset->X = Width * 0.5f; + offset->Y = FastRandom.Shared.NextSingle(Height * -0.5f, Height * 0.5f); + break; } + + FastRandom.Shared.NextUnitVector(heading); } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Profiles/CircleProfile.cs b/source/MonoGame.Extended/Particles/Profiles/CircleProfile.cs index ef7f11e7..141ac99f 100644 --- a/source/MonoGame.Extended/Particles/Profiles/CircleProfile.cs +++ b/source/MonoGame.Extended/Particles/Profiles/CircleProfile.cs @@ -1,24 +1,66 @@ -using Microsoft.Xna.Framework; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Profiles +using System; +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Particles.Profiles; + +/// +/// A profile that distributes particles throughout a circular area with controllable radiation patterns. +/// +/// +/// The randomly positions new particles within a circle centered at the emitter's position. +/// The movement direction (heading) of each particle can be configured to radiate inward toward the center, outward +/// from the center, or in random directions. +/// +public sealed class CircleProfile : Profile { - public class CircleProfile : Profile + /// + /// The radius of the circular area. + /// + public float Radius; + + /// + /// The radiation mode that determines how particle headings are calculated. + /// + public CircleRadiation Radiate; + + /// + /// Computes the offset and heading for a new particle. + /// + /// A pointer to the Vector2 where the offset from the emitter position will be stored. + /// A pointer to the Vector2 where the unit direction vector will be stored. + /// + /// Thrown when contains an unsupported value. + /// + public override unsafe void GetOffsetAndHeading(Vector2* offset, Vector2* heading) { - public float Radius { get; set; } - public CircleRadiation Radiate { get; set; } + float distance = FastRandom.Shared.NextSingle(0f, Radius); - public override void GetOffsetAndHeading(out Vector2 offset, out Vector2 heading) + FastRandom.Shared.NextUnitVector(heading); + + switch (Radiate) { - var dist = Random.NextSingle(0f, Radius); + case CircleRadiation.In: + offset->X = -heading->X * distance; + offset->Y = -heading->Y * distance; + break; - Random.NextUnitVector(out heading); + case CircleRadiation.Out: + offset->X = heading->X * distance; + offset->Y = heading->Y * distance; + break; - offset = Radiate == CircleRadiation.In - ? new Vector2(-heading.X*dist, -heading.Y*dist) - : new Vector2(heading.X*dist, heading.Y*dist); + case CircleRadiation.None: + offset->X = heading->X * distance; + offset->Y = heading->Y * distance; + FastRandom.Shared.NextUnitVector(heading); + break; - if (Radiate == CircleRadiation.None) - Random.NextUnitVector(out heading); + default: + throw new ArgumentOutOfRangeException(nameof(Radiate), Radiate, "Unsupported radiation mode"); } } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Profiles/CircleRadiation.cs b/source/MonoGame.Extended/Particles/Profiles/CircleRadiation.cs new file mode 100644 index 00000000..f42b7a26 --- /dev/null +++ b/source/MonoGame.Extended/Particles/Profiles/CircleRadiation.cs @@ -0,0 +1,44 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +namespace MonoGame.Extended.Particles.Profiles; + +/// +/// Defines the radiation pattern for particles when using a . +/// +/// +/// This enumeration determines how a particle's initial position within a circle affects its movement direction +/// (heading). Different radiation patterns can create varied visual effects such as explosions, implosions, or +/// randomized dispersion. +/// +public enum CircleRadiation +{ + /// + /// Particles move in random directions unrelated to their position. + /// + /// + /// In this mode, the initial heading of particles is completely random and has no relationship to their position + /// within the circle. This creates a chaotic dispersion effect with no discernible pattern of movement. + /// + None, + + /// + /// Particles move toward the center of the circle. + /// + /// + /// In this mode, particles are given initial headings that point directly toward the center of the circle from + /// their starting position. This creates an implosion or suction effect, as if particles are being drawn inward. + /// + In, + + /// + /// Particles move away from the center of the circle. + /// + /// + /// In this mode, particles are given initial headings that point directly away from the center of the circle, + /// extending their starting position outward. This creates an explosion or burst effect, as if particles are + /// emanating from a central point. + /// + Out +} diff --git a/source/MonoGame.Extended/Particles/Profiles/LineProfile.cs b/source/MonoGame.Extended/Particles/Profiles/LineProfile.cs index bea584e9..cf308224 100644 --- a/source/MonoGame.Extended/Particles/Profiles/LineProfile.cs +++ b/source/MonoGame.Extended/Particles/Profiles/LineProfile.cs @@ -1,17 +1,41 @@ -using Microsoft.Xna.Framework; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Profiles +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Particles.Profiles; + +/// +/// A profile that distributes particles uniformly along a line segment with random headings. +/// +/// +/// The positions particles randomly along a line segment centered at the emitter position and +/// defined by an axis direction and length. Unlike , this profile gives each particle a +/// random heading in any direction. +/// +public sealed class LineProfile : Profile { - public class LineProfile : Profile - { - public Vector2 Axis { get; set; } - public float Length { get; set; } + /// + /// The direction vector of the line axis. + /// + public Vector2 Axis; - public override void GetOffsetAndHeading(out Vector2 offset, out Vector2 heading) - { - var vect = Axis*Random.NextSingle(Length*-0.5f, Length*0.5f); - offset = new Vector2(vect.X, vect.Y); - Random.NextUnitVector(out heading); - } + /// + /// The length of the line segment. + /// + public float Length; + + /// + /// Computes the offset and heading for a new particle. + /// + /// A pointer to the Vector2 where the offset from the emitter position will be stored. + /// A pointer to the Vector2 where the unit direction vector will be stored. + public override unsafe void GetOffsetAndHeading(Vector2* offset, Vector2* heading) + { + float value = FastRandom.Shared.NextSingle(Length * -0.5f, Length * 0.5f); + offset->X = Axis.X * value; + offset->Y = Axis.Y * value; + FastRandom.Shared.NextUnitVector(heading); } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Profiles/LineUniformProfile.cs b/source/MonoGame.Extended/Particles/Profiles/LineUniformProfile.cs new file mode 100644 index 00000000..1a585e6f --- /dev/null +++ b/source/MonoGame.Extended/Particles/Profiles/LineUniformProfile.cs @@ -0,0 +1,58 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Particles.Profiles; + +/// +/// A profile that distributes particles uniformly along a line segment with a fixed heading direction. +/// +/// +/// The positions particles randomly along a line segment centered at the emitter +/// position and defined by an axis direction and length. Unlike other profiles, this profile uses a fixed heading +/// direction for all particles, perpendicular to the line. +/// +public sealed class LineUniformProfile : Profile +{ + /// + /// The direction vector of the line axis. + /// + public Vector2 Axis; + + /// + /// The length of the line segment. + /// + public float Length; + + /// + /// The fixed heading direction for all particles spawned from this profile. + /// + public Vector2 PerpendicularDirection; + + /// + /// Computes the offset and heading for a new particle. + /// + /// A pointer to the Vector2 where the offset from the emitter position will be stored. + /// A pointer to the Vector2 where the unit direction vector will be stored. + public override unsafe void GetOffsetAndHeading(Vector2* offset, Vector2* heading) + { + // 1. Spawn the particle at a random point on the line axis + float value = FastRandom.Shared.NextSingle(Length * -0.5f, Length * 0.5f); + offset->X = Axis.X * value; + offset->Y = Axis.Y * value; + + // 2. Set the heading to the perpendicular direction + *heading = PerpendicularDirection; + } + + /// + /// Sets the property to a normalized version of the specified vector. + /// + /// The direction vector to normalize and use as the perpendicular direction. + public void SetPerpendicularDirection(Vector2 direction) + { + PerpendicularDirection = Vector2.Normalize(direction); + } +} diff --git a/source/MonoGame.Extended/Particles/Profiles/PointProfile.cs b/source/MonoGame.Extended/Particles/Profiles/PointProfile.cs index 04456d3f..3dbd8ea7 100644 --- a/source/MonoGame.Extended/Particles/Profiles/PointProfile.cs +++ b/source/MonoGame.Extended/Particles/Profiles/PointProfile.cs @@ -1,14 +1,33 @@ -using Microsoft.Xna.Framework; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Profiles +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Particles.Profiles; + +/// +/// A profile that emits all particles from a single point with random headings. +/// +/// +/// The is the simplest emission profile, where all particles originate exactly at the +/// emitter position with no offset. Each particle is given a random heading in any direction, creating a radial +/// dispersion pattern. +/// +public sealed class PointProfile : Profile { - public class PointProfile : Profile + /// + /// Computes the offset and heading for a new particle. + /// + /// A pointer to the Vector2 where the offset from the emitter position will be stored. + /// A pointer to the Vector2 where the unit direction vector will be stored. + /// + /// The offset is always set to (0,0), meaning particles will spawn exactly at the emitter position. + /// + public override unsafe void GetOffsetAndHeading(Vector2* offset, Vector2* heading) { - public override void GetOffsetAndHeading(out Vector2 offset, out Vector2 heading) - { - offset = Vector2.Zero; - - Random.NextUnitVector(out heading); - } + offset->X = 0.0f; + offset->Y = 0.0f; + FastRandom.Shared.NextUnitVector(heading); } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Profiles/Profile.cs b/source/MonoGame.Extended/Particles/Profiles/Profile.cs index bfde371b..2b0aedc7 100644 --- a/source/MonoGame.Extended/Particles/Profiles/Profile.cs +++ b/source/MonoGame.Extended/Particles/Profiles/Profile.cs @@ -1,68 +1,129 @@ -using Microsoft.Xna.Framework; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. -namespace MonoGame.Extended.Particles.Profiles +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace MonoGame.Extended.Particles.Profiles; + +/// +/// Provides an abstract base class for particle emission profiles. +/// +/// +/// A profile determines how particles are initially positioned and directed when they are emitted. Different profiles +/// create different distribution patterns, such as points, lines, circles, or boxes. +/// +public abstract class Profile { - public abstract class Profile + /// + /// Computes the offset and heading for a new particle. + /// + /// A pointer to the Vector2 where the offset from the emitter position will be stored. + /// A pointer to the Vector2 where the unit direction vector will be stored. + public abstract unsafe void GetOffsetAndHeading(Vector2* offset, Vector2* heading); + + /// + /// Creates a that emits particles from a single point. + /// + /// A new instance. + public static Profile Point() { - public enum CircleRadiation - { - None, - In, - Out - } - - protected FastRandom Random { get; } = new FastRandom(); - - public abstract void GetOffsetAndHeading(out Vector2 offset, out Vector2 heading); - - public object Clone() - { - return MemberwiseClone(); - } - - public static Profile Point() - { - return new PointProfile(); - } - - public static Profile Line(Vector2 axis, float length) - { - return new LineProfile {Axis = axis, Length = length}; - } - - public static Profile Ring(float radius, CircleRadiation radiate) - { - return new RingProfile {Radius = radius, Radiate = radiate}; - } - - public static Profile Box(float width, float height) - { - return new BoxProfile {Width = width, Height = height}; - } - - public static Profile BoxFill(float width, float height) - { - return new BoxFillProfile {Width = width, Height = height}; - } - - public static Profile BoxUniform(float width, float height) - { - return new BoxUniformProfile {Width = width, Height = height}; - } - - public static Profile Circle(float radius, CircleRadiation radiate) - { - return new CircleProfile {Radius = radius, Radiate = radiate}; - } - - public static Profile Spray(Vector2 direction, float spread) - { - return new SprayProfile {Direction = direction, Spread = spread}; - } - - public override string ToString() - { - return GetType().ToString(); - } + return new PointProfile(); } -} \ No newline at end of file + + /// + /// Creates a that emits particles along a line segment. + /// + /// The direction vector of the line. + /// The length of the line segment. + /// A new instance. + public static Profile Line(Vector2 axis, float length) + { + return new LineProfile { Axis = axis, Length = length }; + } + + /// + /// Creates a that emits particles uniformly along a line segment with a fixed heading. + /// + /// The direction vector of the line axis. + /// The length fo the line segment. + /// The fixed heading direction for all particles spawned from the profile. + /// + public static Profile LineUniform(Vector2 axis, float length, Vector2 perpendicularDirection) + { + return new LineUniformProfile { Axis = axis, Length = length, PerpendicularDirection = perpendicularDirection }; + } + + /// + /// Creates a that emits particles from the perimeter of a circle. + /// + /// The radius of the ring. + /// The radiation pattern for particle headings. + /// A new instance. + public static Profile Ring(float radius, CircleRadiation radiate) + { + return new RingProfile { Radius = radius, Radiate = radiate }; + } + + /// + /// Creates a that emits particles from the perimeter of a rectangle. + /// + /// The width of the rectangle. + /// The height of the rectangle. + /// A new instance. + public static Profile Box(float width, float height) + { + return new BoxProfile { Width = width, Height = height }; + } + + /// + /// Creates a that emits particles from within a rectangular area. + /// + /// The width of the rectangle. + /// The height of the rectangle. + /// A new instance. + public static Profile BoxFill(float width, float height) + { + return new BoxFillProfile { Width = width, Height = height }; + } + + /// + /// Creates a that emits particles from the perimeter of a rectangle with uniform density. + /// + /// The width of the rectangle. + /// The height of the rectangle. + /// A new instance. + public static Profile BoxUniform(float width, float height) + { + return new BoxUniformProfile { Width = width, Height = height }; + } + + /// + /// Creates a that emits particles from within a circular area. + /// + /// The radius of the circle. + /// The radiation pattern for particle headings. + /// A new instance. + public static Profile Circle(float radius, CircleRadiation radiate) + { + return new CircleProfile { Radius = radius, Radiate = radiate }; + } + + /// + /// Creates a that emits particles in a directional cone. + /// + /// The central direction of the spray. + /// The angular spread of the spray, in radians. + /// A new instance. + public static Profile Spray(Vector2 direction, float spread) + { + return new SprayProfile { Direction = direction, Spread = spread }; + } + + /// + public override string ToString() + { + return GetType().Name; + } +} diff --git a/source/MonoGame.Extended/Particles/Profiles/RingProfile.cs b/source/MonoGame.Extended/Particles/Profiles/RingProfile.cs index d5ac2f82..2975a507 100644 --- a/source/MonoGame.Extended/Particles/Profiles/RingProfile.cs +++ b/source/MonoGame.Extended/Particles/Profiles/RingProfile.cs @@ -1,32 +1,70 @@ -using System; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; using Microsoft.Xna.Framework; -namespace MonoGame.Extended.Particles.Profiles +namespace MonoGame.Extended.Particles.Profiles; + +/// +/// A profile that distributes particles along the perimeter of a circle with controllable radiation patterns. +/// +/// +/// +/// The positions new particles exclusively on the circumference of a circle centered +/// at the emitter's position. Unlike which distributes particles throughout the +/// circular area, this profile places particles only on the edge. +/// +/// +/// The movement direction (heading) of each particle can be configured to radiate inward toward the center, +/// outward from the center, or in random directions unrelated to their position. +/// +/// +public sealed class RingProfile : Profile { - public class RingProfile : Profile + /// + /// The radius if the ring. + /// + public float Radius; + + /// + /// The radiation mode that determines how particle headings are calculated. + /// + public CircleRadiation Radiate; + + /// + /// Computes the offset and heading for a new particle. + /// + /// A pointer to the Vector2 where the offset from the emitter position will be stored. + /// A pointer to the Vector2 where the unit direction vector will be stored. + /// + /// Thrown when contains an unsupported value. + /// + public override unsafe void GetOffsetAndHeading(Vector2* offset, Vector2* heading) { - public float Radius { get; set; } - public CircleRadiation Radiate { get; set; } + FastRandom.Shared.NextUnitVector(heading); - public override void GetOffsetAndHeading(out Vector2 offset, out Vector2 heading) + switch (Radiate) { - Random.NextUnitVector(out heading); + case CircleRadiation.In: + offset->X = -heading->X * Radius; + offset->Y = -heading->Y * Radius; + break; - switch (Radiate) - { - case CircleRadiation.In: - offset = new Vector2(-heading.X*Radius, -heading.Y*Radius); - break; - case CircleRadiation.Out: - offset = new Vector2(heading.X*Radius, heading.Y*Radius); - break; - case CircleRadiation.None: - offset = new Vector2(heading.X*Radius, heading.Y*Radius); - Random.NextUnitVector(out heading); - break; - default: - throw new ArgumentOutOfRangeException($"{Radiate} is not supported"); - } + case CircleRadiation.Out: + offset->X = heading->X * Radius; + offset->Y = heading->Y * Radius; + break; + + case CircleRadiation.None: + offset->X = heading->X * Radius; + offset->Y = heading->Y * Radius; + FastRandom.Shared.NextUnitVector(heading); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(Radiate), Radiate, "Unsupported radiation mode"); } } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Profiles/SprayProfile.cs b/source/MonoGame.Extended/Particles/Profiles/SprayProfile.cs index 6259210f..0f37cb67 100644 --- a/source/MonoGame.Extended/Particles/Profiles/SprayProfile.cs +++ b/source/MonoGame.Extended/Particles/Profiles/SprayProfile.cs @@ -1,20 +1,57 @@ -using System; +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; using Microsoft.Xna.Framework; -namespace MonoGame.Extended.Particles.Profiles +namespace MonoGame.Extended.Particles.Profiles; + +/// +/// A profile that emits particles from a single point in a directional cone pattern. +/// +/// +/// The positions all particles exactly at the emitter's position, +/// like , but instead of random directions in all directions, it constrains the particle +/// headings to a cone-shaped area defined by a central direction and spread angle. +/// +public sealed class SprayProfile : Profile { - public class SprayProfile : Profile + /// + /// The central direction vector of the spray. + /// + public Vector2 Direction; + + /// + /// The angular spread of the spray cone (in radians). + /// + /// + /// This value determines how wide the spray cone is. For example: + /// + /// + /// A value of 0 will emit all particles in exactly the same direction. + /// A value of π (Pi) will create a 180-degree fan. + /// A value of 2π will emit in all directions (similar to ). + /// + /// + public float Spread; + + /// + /// Computes the offset and heading for a new particle. + /// + /// A pointer to the Vector2 where the offset from the emitter position will be stored. + /// A pointer to the Vector2 where the unit direction vector will be stored. + /// + /// The offset is always set to (0,0), meaning particles will spawn exactly at the emitter position. + /// + public override unsafe void GetOffsetAndHeading(Vector2* offset, Vector2* heading) { - public Vector2 Direction { get; set; } - public float Spread { get; set; } + offset->X = offset->Y = 0.0f; - public override void GetOffsetAndHeading(out Vector2 offset, out Vector2 heading) - { - var angle = (float) Math.Atan2(Direction.Y, Direction.X); + float angle = MathF.Atan2(Direction.Y, Direction.X); + angle = FastRandom.Shared.NextSingle(angle - Spread * 0.5f, angle + Spread * 0.5f); - angle = Random.NextSingle(angle - Spread/2f, angle + Spread/2f); - offset = Vector2.Zero; - heading = new Vector2((float) Math.Cos(angle), (float) Math.Sin(angle)); - } + heading->X = MathF.Cos(angle); + heading->Y = MathF.Sin(angle); } -} \ No newline at end of file +} diff --git a/source/MonoGame.Extended/Particles/Serialization/InterpolatorJsonConverter.cs b/source/MonoGame.Extended/Particles/Serialization/InterpolatorJsonConverter.cs deleted file mode 100644 index 4a884fdc..00000000 --- a/source/MonoGame.Extended/Particles/Serialization/InterpolatorJsonConverter.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using MonoGame.Extended.Particles.Modifiers.Interpolators; -using MonoGame.Extended.Serialization.Json; - -namespace MonoGame.Extended.Particles.Serialization -{ - public class InterpolatorJsonConverter : BaseTypeJsonConverter - { - public InterpolatorJsonConverter() - : base(GetSupportedTypes(), "Interpolator") - { - } - - private static IEnumerable GetSupportedTypes() - { - return typeof(Interpolator) - .GetTypeInfo() - .Assembly - .DefinedTypes - .Where(type => typeof(Interpolator).GetTypeInfo().IsAssignableFrom(type) && !type.IsAbstract); - } - } -} \ No newline at end of file diff --git a/source/MonoGame.Extended/Particles/Serialization/ModifierExecutionStrategyJsonConverter.cs b/source/MonoGame.Extended/Particles/Serialization/ModifierExecutionStrategyJsonConverter.cs deleted file mode 100644 index 8fc894bb..00000000 --- a/source/MonoGame.Extended/Particles/Serialization/ModifierExecutionStrategyJsonConverter.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Reflection; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace MonoGame.Extended.Particles.Serialization; - -/// -/// Converts a value to or from JSON. -/// -public class ModifierExecutionStrategyJsonConverter : JsonConverter -{ - /// - public override bool CanConvert(Type typeToConvert) => - typeToConvert == typeof(ParticleModifierExecutionStrategy) || - typeToConvert.GetTypeInfo().BaseType == typeof(ParticleModifierExecutionStrategy); - - /// - public override ParticleModifierExecutionStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = JsonSerializer.Deserialize(ref reader, options); - return ParticleModifierExecutionStrategy.Parse(value); - } - - /// - /// - /// Throw if is . - /// - public override void Write(Utf8JsonWriter writer, ParticleModifierExecutionStrategy value, JsonSerializerOptions options) - { - ArgumentNullException.ThrowIfNull(writer); - writer.WriteStringValue(value.ToString()); - } -} diff --git a/source/MonoGame.Extended/Particles/Serialization/ModifierJsonConverter.cs b/source/MonoGame.Extended/Particles/Serialization/ModifierJsonConverter.cs deleted file mode 100644 index 20d09984..00000000 --- a/source/MonoGame.Extended/Particles/Serialization/ModifierJsonConverter.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using MonoGame.Extended.Particles.Modifiers; -using MonoGame.Extended.Serialization.Json; - -namespace MonoGame.Extended.Particles.Serialization -{ - public class ModifierJsonConverter : BaseTypeJsonConverter - { - public ModifierJsonConverter() - : base(GetSupportedTypes(), "Modifier") - { - } - - private static IEnumerable GetSupportedTypes() - { - return typeof(Modifier) - .GetTypeInfo() - .Assembly - .DefinedTypes - .Where(type => typeof(Modifier).GetTypeInfo().IsAssignableFrom(type) && !type.IsAbstract); - } - } -} \ No newline at end of file diff --git a/source/MonoGame.Extended/Particles/Serialization/ParticleJsonSerializerOptionsProvider.cs b/source/MonoGame.Extended/Particles/Serialization/ParticleJsonSerializerOptionsProvider.cs deleted file mode 100644 index be4764b5..00000000 --- a/source/MonoGame.Extended/Particles/Serialization/ParticleJsonSerializerOptionsProvider.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using MonoGame.Extended.Serialization; -using MonoGame.Extended.Serialization.Json; - -namespace MonoGame.Extended.Particles.Serialization; - -public static class ParticleJsonSerializerOptionsProvider -{ - public static JsonSerializerOptions GetOptions(ITextureRegionService textureRegionService) - { - var options = new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - - options.Converters.Add(new Vector2JsonConverter()); - options.Converters.Add(new Size2JsonConverter()); - options.Converters.Add(new ColorJsonConverter()); - options.Converters.Add(new TextureRegion2DJsonConverter(textureRegionService)); - options.Converters.Add(new ProfileJsonConverter()); - options.Converters.Add(new ModifierJsonConverter()); - options.Converters.Add(new InterpolatorJsonConverter()); - options.Converters.Add(new TimeSpanJsonConverter()); - options.Converters.Add(new RangeJsonConverter()); - options.Converters.Add(new RangeJsonConverter()); - options.Converters.Add(new RangeJsonConverter()); - options.Converters.Add(new HslColorJsonConverter()); - options.Converters.Add(new ModifierExecutionStrategyJsonConverter()); - - return options; - } -} diff --git a/source/MonoGame.Extended/Particles/Serialization/ProfileJsonConverter.cs b/source/MonoGame.Extended/Particles/Serialization/ProfileJsonConverter.cs deleted file mode 100644 index 8e59f75c..00000000 --- a/source/MonoGame.Extended/Particles/Serialization/ProfileJsonConverter.cs +++ /dev/null @@ -1,306 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Security.Cryptography; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Unicode; -using Microsoft.Xna.Framework; -using MonoGame.Extended.Particles.Profiles; -using MonoGame.Extended.Serialization.Json; - -namespace MonoGame.Extended.Particles.Serialization -{ - public class ProfileJsonConverter : JsonConverter - { - /// - public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(Profile); - - /// - public override Profile Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType != JsonTokenType.StartObject) - { - throw new JsonException($"Expected {nameof(JsonTokenType.StartObject)} token"); - } - - Profile profile = null; - - while (reader.Read()) - { - if (reader.TokenType == JsonTokenType.EndObject) - { - break; - } - - if (reader.TokenType == JsonTokenType.PropertyName) - { - var propertyName = reader.GetString(); - reader.Read(); - - if (propertyName.Equals("type", StringComparison.InvariantCultureIgnoreCase)) - { - var type = reader.GetString(); - profile = type switch - { - nameof(Profile.Point) => Profile.Point(), - nameof(Profile.Line) => ReadLineProfile(ref reader), - nameof(Profile.Ring) => ReadRingProfile(ref reader), - nameof(Profile.Box) => ReadBoxProfile(ref reader), - nameof(Profile.BoxFill) => ReadBoxFillProfile(ref reader), - nameof(Profile.BoxUniform) => ReadBoxUniformProfile(ref reader), - nameof(Profile.Circle) => ReadCircleProfile(ref reader), - nameof(Profile.Spray) => ReadSprayProfile(ref reader), - - _ => throw new NotSupportedException($"The profile type {type} is not supported at this time") - }; - } - } - } - - return profile; - } - - private static Profile ReadLineProfile(ref Utf8JsonReader reader) - { - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("axis", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - string[] split = reader.GetString().Split(" ", StringSplitOptions.RemoveEmptyEntries); - Debug.Assert(split.Length == 2); - Vector2 axis = new Vector2(float.Parse(split[0]), float.Parse(split[1])); - - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("length", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - float length = reader.GetSingle(); - - return Profile.Line(axis, length); - } - - private static Profile ReadRingProfile(ref Utf8JsonReader reader) - { - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("radius", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - float radius = reader.GetSingle(); - - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("radiate", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - int radiate = reader.GetInt32(); - - return Profile.Ring(radius, (Profile.CircleRadiation)radiate); - } - - private static Profile ReadBoxProfile(ref Utf8JsonReader reader) - { - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("width", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - float width = reader.GetSingle(); - - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("height", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - float height = reader.GetSingle(); - - return Profile.Box(width, height); - } - - private static Profile ReadBoxFillProfile(ref Utf8JsonReader reader) - { - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("width", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - float width = reader.GetSingle(); - - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("height", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - float height = reader.GetSingle(); - - return Profile.BoxFill(width, height); - } - - private static Profile ReadBoxUniformProfile(ref Utf8JsonReader reader) - { - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("width", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - float width = reader.GetSingle(); - - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("height", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - float height = reader.GetSingle(); - - return Profile.BoxUniform(width, height); - } - - private static Profile ReadCircleProfile(ref Utf8JsonReader reader) - { - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("radius", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - float radius = reader.GetSingle(); - - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("radiate", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - int radiate = reader.GetInt32(); - - return Profile.Circle(radius, (Profile.CircleRadiation)radiate); - - } - - private static Profile ReadSprayProfile(ref Utf8JsonReader reader) - { - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("direction", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - string[] split = reader.GetString().Split(" ", StringSplitOptions.RemoveEmptyEntries); - Debug.Assert(split.Length == 2); - Vector2 direction = new Vector2(float.Parse(split[0]), float.Parse(split[1])); - - reader.Read(); - Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); - Debug.Assert(reader.GetString().Equals("spread", StringComparison.InvariantCultureIgnoreCase)); - reader.Read(); - float spread = reader.GetSingle(); - - return Profile.Spray(direction, spread); - } - - /// - public override void Write(Utf8JsonWriter writer, Profile value, JsonSerializerOptions options) - { - ArgumentNullException.ThrowIfNull(writer); - var type = value.GetType().ToString(); - switch (type) - { - case nameof(PointProfile): - WritePointProfile(ref writer, (PointProfile)value); - break; - - case nameof(LineProfile): - WriteLineProfile(ref writer, (LineProfile)value); - break; - - case nameof(RingProfile): - WriteRingProfile(ref writer, (RingProfile)value); - break; - - case nameof(BoxProfile): - WriteBoxProfile(ref writer, (BoxProfile)value); - break; - - case nameof(BoxFillProfile): - WriteBoxFillProfile(ref writer, (BoxFillProfile)value); - break; - - case nameof(BoxUniformProfile): - WriteBoxUniformProfile(ref writer, (BoxUniformProfile)value); - break; - - case nameof(CircleProfile): - WriteCircleProfile(ref writer, (CircleProfile)value); - break; - - case nameof(SprayProfile): - WriteSprayProfile(ref writer, (SprayProfile)value); - break; - - default: - throw new InvalidOperationException("Unknown profile type"); - } - } - - private static void WritePointProfile(ref Utf8JsonWriter writer, PointProfile value) - { - writer.WriteStartObject(); - writer.WritePropertyName("type"); - writer.WriteStringValue(value.GetType().ToString()); - writer.WriteEndObject(); - } - - private static void WriteLineProfile(ref Utf8JsonWriter writer, LineProfile value) - { - writer.WriteStartObject(); - writer.WriteString("type", value.GetType().ToString()); - writer.WriteString("axis", $"{value.Axis.X} {value.Axis.Y}"); - writer.WriteNumber("length", value.Length); - writer.WriteEndObject(); - } - - private static void WriteRingProfile(ref Utf8JsonWriter writer, RingProfile value) - { - writer.WriteStartObject(); - writer.WriteString("type", value.GetType().ToString()); - writer.WriteNumber("radius", value.Radius); - writer.WriteNumber("radiate", (int)value.Radiate); - writer.WriteEndObject(); - } - - private static void WriteBoxProfile(ref Utf8JsonWriter writer, BoxProfile value) - { - writer.WriteStartObject(); - writer.WriteString("type", value.GetType().ToString()); - writer.WriteNumber("width", value.Width); - writer.WriteNumber("height", value.Height); - writer.WriteEndObject(); - } - - private static void WriteBoxFillProfile(ref Utf8JsonWriter writer, BoxFillProfile value) - { - writer.WriteStartObject(); - writer.WriteString("type", value.GetType().ToString()); - writer.WriteNumber("width", value.Width); - writer.WriteNumber("height", value.Height); - writer.WriteEndObject(); - } - - private static void WriteBoxUniformProfile(ref Utf8JsonWriter writer, BoxUniformProfile value) - { - writer.WriteStartObject(); - writer.WriteString("type", value.GetType().ToString()); - writer.WriteNumber("width", value.Width); - writer.WriteNumber("height", value.Height); - writer.WriteEndObject(); - } - - private static void WriteCircleProfile(ref Utf8JsonWriter writer, CircleProfile value) - { - writer.WriteStartObject(); - writer.WriteString("type", value.GetType().ToString()); - writer.WriteNumber("radius", value.Radius); - writer.WriteNumber("radiate", (int)value.Radiate); - writer.WriteEndObject(); - - } - - private static void WriteSprayProfile(ref Utf8JsonWriter writer, SprayProfile value) - { - writer.WriteStartObject(); - writer.WriteString("type", value.GetType().ToString()); - writer.WriteString("direction", $"{value.Direction.X} {value.Direction.Y}"); - writer.WriteNumber("spread", value.Spread); - writer.WriteEndObject(); - } - } -} diff --git a/source/MonoGame.Extended/Particles/Serialization/TimeSpanJsonConverter.cs b/source/MonoGame.Extended/Particles/Serialization/TimeSpanJsonConverter.cs deleted file mode 100644 index 0155b083..00000000 --- a/source/MonoGame.Extended/Particles/Serialization/TimeSpanJsonConverter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace MonoGame.Extended.Particles.Serialization; - -public class TimeSpanJsonConverter : JsonConverter -{ - /// - public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(TimeSpan); - - /// - public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.Number) - { - double seconds = reader.GetDouble(); - return TimeSpan.FromSeconds(seconds); - } - - return TimeSpan.Zero; - } - - /// - /// - /// Throw if is . - /// - public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) - { - ArgumentNullException.ThrowIfNull(writer); - writer.WriteNumberValue(value.TotalSeconds); - } -} diff --git a/source/MonoGame.Extended/Particles/SpriteBatchExtensions.ParticleEfffect.cs b/source/MonoGame.Extended/Particles/SpriteBatchExtensions.ParticleEfffect.cs new file mode 100644 index 00000000..eaf21ac7 --- /dev/null +++ b/source/MonoGame.Extended/Particles/SpriteBatchExtensions.ParticleEfffect.cs @@ -0,0 +1,115 @@ +using System; + +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using MonoGame.Extended.Graphics; +using MonoGame.Extended.Particles.Data; + +namespace MonoGame.Extended.Particles; + +public static class SpriteBatchExtensions +{ + public static void Draw(this SpriteBatch spriteBatch, ParticleEffect effect) + { + ArgumentNullException.ThrowIfNull(effect); + ObjectDisposedException.ThrowIf(effect.IsDisposed, effect); + + for (int i = 0; i < effect.Emitters.Count; i++) + { + UnsafeDraw(spriteBatch, effect.Emitters[i]); + } + } + + public static void Draw(this SpriteBatch spriteBatch, ParticleEmitter emitter) + { + ArgumentNullException.ThrowIfNull(emitter); + ObjectDisposedException.ThrowIf(emitter.IsDisposed, emitter); + UnsafeDraw(spriteBatch, emitter); + } + + private static unsafe void UnsafeDraw(SpriteBatch spriteBatch, ParticleEmitter emitter) + { + ArgumentNullException.ThrowIfNull(spriteBatch); + + // Early exit if no texture region assigned + if (emitter.TextureRegion == null) + { + return; + } + + // Early exit if there are no active particles + if (emitter.ActiveParticles == 0) + { + return; + } + + Texture2DRegion region = emitter.TextureRegion; + Texture2D texture = region.Texture; + Rectangle sourceRect = region.Bounds; + Vector2 origin = new Vector2(region.Width, region.Height) * 0.5f; + + if (emitter.RenderingOrder == ParticleRenderingOrder.FrontToBack) + { + int count = emitter.ActiveParticles; + + Span particlePtrs = count <= 1024 ? + stackalloc IntPtr[count] : + new IntPtr[count]; + + ParticleIterator iterator = emitter.Buffer.Iterator; + int index = 0; + + while (iterator.HasNext) + { + particlePtrs[index++] = (IntPtr)iterator.Next(); + } + + for (int i = count - 1; i >= 0; i--) + { + RenderParticle(spriteBatch, (Particle*)particlePtrs[i], texture, sourceRect, origin); + } + } + else + { + ParticleIterator iterator = emitter.Buffer.Iterator; + + while (iterator.HasNext) + { + Particle* particle = iterator.Next(); + RenderParticle(spriteBatch, particle, texture, sourceRect, origin); + } + } + } + + private static unsafe void RenderParticle(SpriteBatch spriteBatch, Particle* particle, Texture2D texture, Rectangle sourceRect, Vector2 origin) + { + HslColor hsl = new HslColor(particle->Color[0], particle->Color[1], particle->Color[2]); + Color color = HslColor.ToRgb(hsl); + + if (spriteBatch.GraphicsDevice.BlendState == BlendState.AlphaBlend) + { + color *= particle->Opacity; + } + else + { + color.A = (byte)MathHelper.Clamp(particle->Opacity * 255, 0, 255); + } + + Vector2 position = new Vector2(particle->Position[0], particle->Position[1]); + float scale = particle->Scale; + float rotation = particle->Rotation; + float layerDepth = particle->LayerDepth; + + spriteBatch.Draw( + texture, + position, + sourceRect, + color, + rotation, + origin, + scale, + SpriteEffects.None, + layerDepth + ); + } +} diff --git a/source/MonoGame.Extended/Serialization/Xml/XmlReaderExtensions.cs b/source/MonoGame.Extended/Serialization/Xml/XmlReaderExtensions.cs new file mode 100644 index 00000000..922cfdd0 --- /dev/null +++ b/source/MonoGame.Extended/Serialization/Xml/XmlReaderExtensions.cs @@ -0,0 +1,267 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using System.Xml; +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Serialization.Xml; + +/// +/// Provides extension methods for to simplify reading and parsing XML Attributes into +/// strong-typed values. +/// +public static class XmlReaderExtensions +{ + /// + /// Reads an XML attribute as an value. + /// + /// The XML reader instance. + /// The name of the attribute to read. + /// The value parsed from the value of the specified attribute. + /// + /// Thrown when the attribute is missing, or when the attribute value cannot be parsed as an . + /// + public static int GetAttributeInt(this XmlReader reader, string attributeName) + { + string value = reader.GetAttribute(attributeName); + + if (value == null) + { + throw new XmlException($"Required attribute '{attributeName}' is missing."); + } + + try + { + return int.Parse(value); + } + catch (Exception ex) when (ex is FormatException || ex is OverflowException) + { + throw new XmlException( + $"Invalid integer format for attribute '{attributeName}'. Expected integer, but got '{value}'", + ex + ); + } + } + + /// + /// Reads an XML attribute as a value. + /// + /// The XML reader instance. + /// The name of the attribute to read. + /// The value parsed from the specified attribute. + /// + /// Thrown when the attribute is missing, or when the attribute value cannot be parsed as a . + /// + public static float GetAttributeFloat(this XmlReader reader, string attributeName) + { + string value = reader.GetAttribute(attributeName); + + if (value == null) + { + throw new XmlException($"Required attribute '{attributeName}' is missing."); + } + + try + { + return float.Parse(value); + } + catch (Exception ex) when (ex is FormatException || ex is OverflowException) + { + throw new XmlException( + $"Invalid float format for attribute '{attributeName}'. Expected float, but got '{value}'", + ex + ); + } + } + + /// + /// Reads an XML attribute as a value. + /// + /// The XML reader instance. + /// The name of the attribute to read. + /// The value parsed from the value of the specified attribute. + /// + /// Thrown when the attribute is missing, or when the attribute value cannot be parsed as a . + /// + public static bool GetAttributeBool(this XmlReader reader, string attributeName) + { + string value = reader.GetAttribute(attributeName); + + if (value == null) + { + throw new XmlException($"Required attribute '{attributeName}' is missing."); + } + + try + { + return bool.Parse(value); + } + catch (Exception ex) when (ex is FormatException) + { + throw new XmlException( + $"Invalid bool format for attribute '{attributeName}'. Expected 'true' or 'false' but got '{value}'", + ex + ); + } + } + + /// + /// Reads an XML attribute as an enumeration value. + /// + /// The enumeration type to parse. + /// The XML reader instance. + /// The name of the attribute to read. + /// The enumeration value parsed from the value of the specified attribute. + /// + /// Thrown when the attribute is missing, or when the attribute value cannot be parsed as the specified enumeration type. + /// + public static T GetAttributeEnum(this XmlReader reader, string attributeName) where T : struct, Enum + { + string value = reader.GetAttribute(attributeName); + + if (value == null) + { + throw new XmlException($"Required attribute '{attributeName}' is missing."); + } + + try + { + return Enum.Parse(value); + } + catch (Exception ex) when (ex is ArgumentException) + { + throw new XmlException( + $"Invalid {typeof(T).Name} format for attribute '{attributeName}'. Expected a {typeof(T).Name} value but got '{value}'", + ex + ); + } + } + + /// + /// Reads an XML attribute as a value. + /// + /// The XML reader instance. + /// The name of the attribute to read. + /// The value parsed from the value of the specified attribute. + /// + /// Thrown when the attribute is missing, or when the attribute value cannot be parsed as a . + /// + public static Rectangle GetAttributeRectangle(this XmlReader reader, string attributeName) + { + string value = reader.GetAttribute(attributeName); + + if (value == null) + { + throw new XmlException($"Required attribute '{attributeName}' is missing."); + } + + string[] split = value.Split(',', StringSplitOptions.RemoveEmptyEntries); + + try + { + if (split.Length != 4) + { + throw new InvalidOperationException($"{nameof(Rectangle)} attribute must contain four integer values separated by a comma"); + } + + int x = int.Parse(split[0]); + int y = int.Parse(split[1]); + int width = int.Parse(split[2]); + int height = int.Parse(split[3]); + + return new Rectangle(x, y, width, height); + } + catch (Exception ex) when (ex is FormatException || ex is OverflowException || ex is InvalidOperationException) + { + throw new XmlException( + $"Invalid {nameof(Rectangle)} format for attribute '{attributeName}'. Expected 'x,y,width,height' but got '{value}'", + ex + ); + } + } + + /// + /// Reads an XML attribute as a value. + /// + /// The XML reader instance. + /// The name of the attribute to read. + /// The value parsed from the value of the specified attribute. + /// + /// Thrown when the attribute is missing, or when the attribute value cannot be parsed as a . + /// + public static Vector2 GetAttributeVector2(this XmlReader reader, string attributeName) + { + string value = reader.GetAttribute(attributeName); + + if (value == null) + { + throw new XmlException($"Required attribute '{attributeName}' is missing."); + } + + string[] split = value.Split(',', StringSplitOptions.RemoveEmptyEntries); + + try + { + if (split.Length != 2) + { + throw new InvalidOperationException($"{nameof(Vector2)} attribute must contain two float values separated by a comma"); + } + + float x = float.Parse(split[0]); + float y = float.Parse(split[1]); + + return new Vector2(x, y); + } + catch (Exception ex) when (ex is FormatException || ex is OverflowException || ex is InvalidOperationException) + { + throw new XmlException( + $"Invalid {nameof(Vector2)} format for attribute '{attributeName}'. Expected 'x,y', but got '{value}'", + ex + ); + } + } + + /// + /// Reads an XML attribute as a value. + /// + /// The XML reader instance. + /// The name of the attribute to read. + /// The value parsed from the value of the specified attribute. + /// + /// Thrown when the attribute is missing, or when the attribute value cannot be parsed as a . + /// + public static Vector3 GetAttributeVector3(this XmlReader reader, string attributeName) + { + string value = reader.GetAttribute(attributeName); + + if (value == null) + { + throw new XmlException($"Required attribute '{attributeName}' is missing."); + } + + string[] split = value.Split(',', StringSplitOptions.RemoveEmptyEntries); + + try + { + if (split.Length != 3) + { + throw new InvalidOperationException($"{nameof(Vector3)} attribute must contain three float values separated by a comma"); + } + + float x = float.Parse(split[0]); + float y = float.Parse(split[1]); + float z = float.Parse(split[2]); + + return new Vector3(x, y, z); + } + catch (Exception ex) when (ex is FormatException || ex is OverflowException || ex is InvalidOperationException) + { + throw new XmlException( + $"Invalid {nameof(Vector3)} format for attribute '{attributeName}'. Expected 'x,y,z', but got '{value}'", + ex + ); + } + } +} diff --git a/source/MonoGame.Extended/Serialization/Xml/XmlWriterExtensions.cs b/source/MonoGame.Extended/Serialization/Xml/XmlWriterExtensions.cs new file mode 100644 index 00000000..e4b13e29 --- /dev/null +++ b/source/MonoGame.Extended/Serialization/Xml/XmlWriterExtensions.cs @@ -0,0 +1,93 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using System.Xml; +using Microsoft.Xna.Framework; + +namespace MonoGame.Extended.Serialization.Xml; + +/// +/// Provides extension methods for to simplify writing XML attributes from strongly-typed values +/// +public static class XmlWriterExtensions +{ + /// + /// Writes an XML attribute from an value. + /// + /// The XML writer instance. + /// The name of the attribute to write. + /// The value to write as the attribute value. + /// The writer state is not valid for this operation. + /// The attribute name is not valid. + public static void WriteAttributeInt(this XmlWriter writer, string attributeName, int value) + { + writer.WriteAttributeString(attributeName, $"{value}"); + } + + /// + /// Writes an XML attribute from a value. + /// + /// The XML writer instance. + /// The name of the attribute to write. + /// The value to write as the attribute value. + /// The writer state is not valid for this operation. + /// The attribute name is not valid. + public static void WriteAttributeFloat(this XmlWriter writer, string attributeName, float value) + { + writer.WriteAttributeString(attributeName, $"{value}"); + } + + /// + /// Writes an XML attribute from a value. + /// + /// The XML writer instance. + /// The name of the attribute to write. + /// The value to write as the attribute value. + /// The writer state is not valid for this operation. + /// The attribute name is not valid. + public static void WriteAttributeBool(this XmlWriter writer, string attributeName, bool value) + { + writer.WriteAttributeString(attributeName, $"{value}"); + } + + /// + /// Writes an XML attribute from a value. + /// + /// The XML writer instance. + /// The name of the attribute to write. + /// The value to write as the attribute value. + /// The writer state is not valid for this operation. + /// The attribute name is not valid. + public static void WriteAttributeRectangle(this XmlWriter writer, string attributeName, Rectangle value) + { + writer.WriteAttributeString(attributeName, $"{value.X},{value.Y},{value.Width},{value.Height}"); + } + + /// + /// Writes an XML attribute from a value. + /// + /// The XML writer instance. + /// The name of the attribute to write. + /// The value to write as the attribute value. + /// The writer state is not valid for this operation. + /// The attribute name is not valid. + public static void WriteAttributeVector2(this XmlWriter writer, string attributeName, Vector2 value) + { + writer.WriteAttributeString(attributeName, $"{value.X},{value.Y}"); + } + + /// + /// Writes an XML attribute from a value. + /// + /// The XML writer instance. + /// The name of the attribute to write. + /// The value to write as the attribute value. + /// The writer state is not valid for this operation. + /// The attribute name is not valid. + public static void WriteAttributeVector3(this XmlWriter writer, string attributeName, Vector3 value) + { + writer.WriteAttributeString(attributeName, $"{value.X},{value.Y},{value.Z}"); + } +} diff --git a/tests/MonoGame.Extended.Tests/MockContentManager.cs b/tests/MonoGame.Extended.Tests/MockContentManager.cs new file mode 100644 index 00000000..c099804a --- /dev/null +++ b/tests/MonoGame.Extended.Tests/MockContentManager.cs @@ -0,0 +1,20 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; + +namespace MonoGame.Extended.Tests; + +public class MockContentManager : ContentManager +{ + public MockContentManager() : base(new GameServiceContainer()) + { + } + + public override T Load(string assetName) + { + return default(T); + } +} diff --git a/tests/MonoGame.Extended.Tests/MonoGame.Extended.Tests.csproj b/tests/MonoGame.Extended.Tests/MonoGame.Extended.Tests.csproj index 00ccb5bf..236a62b1 100644 --- a/tests/MonoGame.Extended.Tests/MonoGame.Extended.Tests.csproj +++ b/tests/MonoGame.Extended.Tests/MonoGame.Extended.Tests.csproj @@ -1,5 +1,9 @@  + + true + + diff --git a/tests/MonoGame.Extended.Tests/Particles/ParticleEffectReaderTests.cs b/tests/MonoGame.Extended.Tests/Particles/ParticleEffectReaderTests.cs new file mode 100644 index 00000000..66090066 --- /dev/null +++ b/tests/MonoGame.Extended.Tests/Particles/ParticleEffectReaderTests.cs @@ -0,0 +1,1172 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using System.IO; +using System.Xml; +using Microsoft.Xna.Framework; +using MonoGame.Extended.Particles; +using MonoGame.Extended.Particles.Modifiers; +using MonoGame.Extended.Particles.Modifiers.Containers; +using MonoGame.Extended.Particles.Modifiers.Interpolators; +using MonoGame.Extended.Particles.Profiles; + +namespace MonoGame.Extended.Tests.Particles; + +public class ParticleEffectReaderTests +{ + private readonly MockContentManager _mockContentManager; + + public ParticleEffectReaderTests() + { + _mockContentManager = new MockContentManager(); + } + + private ParticleEffect ReadParticleEffectFromXml(string xml) + { + using MemoryStream stream = new MemoryStream(); + using StreamWriter writer = new StreamWriter(stream); + writer.Write(xml); + writer.Flush(); + stream.Position = 0; + + using ParticleEffectReader reader = new ParticleEffectReader(stream, _mockContentManager); + return reader.ReadParticleEffect(); + } + + [Fact] + public void ReadParticleEffect_NulLStream_ThrowsArgumentNullException() + { + Assert.Throws(() => new ParticleEffectReader((Stream)null, _mockContentManager)); + } + + [Fact] + public void ReadParticleEffect_NullContentManager_ThrowsArgumentNullException() + { + using MemoryStream stream = new MemoryStream(); + Assert.Throws(() => new ParticleEffectReader(stream, null)); + } + + [Fact] + public void ReadParticleEffect_InvalidXmlRoot_ThrowsXmlException() + { + string xml = ""; + Assert.Throws(() => ReadParticleEffectFromXml(xml)); + } + + [Fact] + public void ReadParticleEffect_EmptyEffect_ReturnsExpected() + { + string xml = + """ + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Equal("EmptyEffect", effect.Name); + Assert.Equal(Vector2.Zero, effect.Position); + Assert.Equal(0.0f, effect.Rotation); + Assert.Equal(Vector2.One, effect.Scale); + Assert.Empty(effect.Emitters); + } + + [Fact] + public void ReadParticleEffect_EmptyModifiers_ReturnsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Empty(emitter.Modifiers); + } + + [Fact] + public void ReadParticleEffect_EmptyInterpolators_ReturnsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + AgeModifier modifier = Assert.IsType(emitter.Modifiers[0]); + Assert.Empty(modifier.Interpolators); + } + + [Fact] + public void ReadParticleEffect_BoxFillProfile_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + BoxFillProfile profile = Assert.IsType(emitter.Profile); + Assert.Equal(1.0f, profile.Height); + Assert.Equal(1.0f, profile.Height); + } + + [Fact] + public void ReadParticleEffect_BoxProfile_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + BoxProfile profile = Assert.IsType(emitter.Profile); + Assert.Equal(1.0f, profile.Height); + Assert.Equal(1.0f, profile.Height); + } + + [Fact] + public void ReadParticleEffect_BoxUniformProfile_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + BoxUniformProfile profile = Assert.IsType(emitter.Profile); + Assert.Equal(1.0f, profile.Height); + Assert.Equal(1.0f, profile.Height); + } + + [Fact] + public void ReadParticleEffect_CircleProfile_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + CircleProfile profile = Assert.IsType(emitter.Profile); + Assert.Equal(1.0f, profile.Radius); + Assert.Equal(CircleRadiation.Out, profile.Radiate); + } + + [Fact] + public void ReadParticleEffect_LineProfile_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + LineProfile profile = Assert.IsType(emitter.Profile); + Assert.Equal(Vector2.One, profile.Axis); + Assert.Equal(1.0f, profile.Length); + } + + [Fact] + public void ReadParticleEffect_LineUniformProfile_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + LineUniformProfile profile = Assert.IsType(emitter.Profile); + Assert.Equal(Vector2.One, profile.Axis); + Assert.Equal(1.0f, profile.Length); + } + + [Fact] + public void ReadParticleEffect_PointProfile_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + PointProfile profile = Assert.IsType(emitter.Profile); + } + + [Fact] + public void ReadParticleEffect_RingProfile_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + RingProfile profile = Assert.IsType(emitter.Profile); + Assert.Equal(1.0f, profile.Radius); + Assert.Equal(CircleRadiation.In, profile.Radiate); + } + + [Fact] + public void ReadParticleEffect_SprayProfile_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + SprayProfile profile = Assert.IsType(emitter.Profile); + Assert.Equal(Vector2.One, profile.Direction); + Assert.Equal(1.0f, profile.Spread); + } + + [Fact] + public void ReadParticleEffect_AgeModifier_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + + AgeModifier modifier = Assert.IsType(effect.Emitters[0].Modifiers[0]); + Assert.Equal(60.0f, modifier.Frequency); + Assert.Equal("AgeModifier", modifier.Name); + } + + [Fact] + public void ReadParticleEffect_CircleContainerModifier_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + + CircleContainerModifier modifier = Assert.IsType(effect.Emitters[0].Modifiers[0]); + Assert.Equal(60.0f, modifier.Frequency); + Assert.Equal("CircleContainerModifier", modifier.Name); + Assert.Equal(0.0f, modifier.Radius); + Assert.True(modifier.Inside); + Assert.Equal(1.0f, modifier.RestitutionCoefficient); + } + + [Fact] + public void ReadParticleEffect_DragModifier_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + + DragModifier modifier = Assert.IsType(effect.Emitters[0].Modifiers[0]); + Assert.Equal(60.0f, modifier.Frequency); + Assert.Equal("DragModifier", modifier.Name); + Assert.Equal(0.47f, modifier.DragCoefficient); + Assert.Equal(0.5f, modifier.Density); + } + + [Fact] + public void ReadParticleEffect_LinearGravityModifier_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + + LinearGravityModifier modifier = Assert.IsType(effect.Emitters[0].Modifiers[0]); + Assert.Equal(60.0f, modifier.Frequency); + Assert.Equal("LinearGravityModifier", modifier.Name); + Assert.Equal(Vector2.Zero, modifier.Direction); + Assert.Equal(0.0f, modifier.Strength); + } + + [Fact] + public void ReadParticleEffect_OpacityFastFadeModifier_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + + OpacityFastFadeModifier modifier = Assert.IsType(effect.Emitters[0].Modifiers[0]); + Assert.Equal(60.0f, modifier.Frequency); + Assert.Equal("OpacityFastFadeModifier", modifier.Name); + } + + [Fact] + public void ReadParticleEffect_RectangleContainerModifier_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + + RectangleContainerModifier modifier = Assert.IsType(effect.Emitters[0].Modifiers[0]); + Assert.Equal(60.0f, modifier.Frequency); + Assert.Equal("RectangleContainerModifier", modifier.Name); + Assert.Equal(0.0f, modifier.Width); + Assert.Equal(0.0f, modifier.Height); + Assert.Equal(1.0f, modifier.RestitutionCoefficient); + } + + [Fact] + public void ReadParticleEffect_RectangleLoopContainerModifier_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + + RectangleLoopContainerModifier modifier = Assert.IsType(effect.Emitters[0].Modifiers[0]); + Assert.Equal(60.0f, modifier.Frequency); + Assert.Equal("RectangleLoopContainerModifier", modifier.Name); + Assert.Equal(0.0f, modifier.Width); + Assert.Equal(0.0f, modifier.Height); + } + + [Fact] + public void ReadParticleEffect_RotationModifier_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + + RotationModifier modifier = Assert.IsType(effect.Emitters[0].Modifiers[0]); + Assert.Equal(60.0f, modifier.Frequency); + Assert.Equal("RotationModifier", modifier.Name); + Assert.Equal(0.0f, modifier.RotationRate); + } + + [Fact] + public void ReadParticleEffect_VelocityColorModifier_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + + VelocityColorModifier modifier = Assert.IsType(effect.Emitters[0].Modifiers[0]); + Assert.Equal(60.0f, modifier.Frequency); + Assert.Equal("VelocityColorModifier", modifier.Name); + Assert.Equal(Vector3.Zero, modifier.StationaryColor); + Assert.Equal(Vector3.Zero, modifier.VelocityColor); + Assert.Equal(0.0f, modifier.VelocityThreshold); + } + + [Fact] + public void ReadParticleEffect_VelocityModifier_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + + VelocityModifier modifier = Assert.IsType(effect.Emitters[0].Modifiers[0]); + Assert.Equal(60.0f, modifier.Frequency); + Assert.Equal("VelocityModifier", modifier.Name); + Assert.Equal(0.0f, modifier.VelocityThreshold); + } + + [Fact] + public void ReadParticleEffect_VortexModifier_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + + VortexModifier modifier = Assert.IsType(effect.Emitters[0].Modifiers[0]); + Assert.Equal(60.0f, modifier.Frequency); + Assert.Equal("VortexModifier", modifier.Name); + Assert.Equal(Vector2.Zero, modifier.Position); + Assert.Equal(0.0f, modifier.Mass); + Assert.Equal(0.0f, modifier.MaxSpeed); + } + + [Fact] + public void ReadParticleEffect_ColorInterpolator_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + AgeModifier modifier = Assert.IsType(emitter.Modifiers[0]); + + Assert.Single(modifier.Interpolators); + + ColorInterpolator interpolator = Assert.IsType(modifier.Interpolators[0]); + Assert.Equal(Vector3.Zero, interpolator.StartValue); + Assert.Equal(Vector3.Zero, interpolator.EndValue); + } + + [Fact] + public void ReadParticleEffect_HueInterpolator_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + AgeModifier modifier = Assert.IsType(emitter.Modifiers[0]); + + Assert.Single(modifier.Interpolators); + + HueInterpolator interpolator = Assert.IsType(modifier.Interpolators[0]); + Assert.Equal(0.0f, interpolator.StartValue); + Assert.Equal(0.0f, interpolator.EndValue); + } + + [Fact] + public void ReadParticleEffect_OpacityInterpolator_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + AgeModifier modifier = Assert.IsType(emitter.Modifiers[0]); + + Assert.Single(modifier.Interpolators); + + OpacityInterpolator interpolator = Assert.IsType(modifier.Interpolators[0]); + Assert.Equal(0.0f, interpolator.StartValue); + Assert.Equal(0.0f, interpolator.EndValue); + } + + [Fact] + public void ReadParticleEffect_RotationInterpolator_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + AgeModifier modifier = Assert.IsType(emitter.Modifiers[0]); + + Assert.Single(modifier.Interpolators); + + RotationInterpolator interpolator = Assert.IsType(modifier.Interpolators[0]); + Assert.Equal(0.0f, interpolator.StartValue); + Assert.Equal(0.0f, interpolator.EndValue); + } + + [Fact] + public void ReadParticleEffect_ScaleInterpolator_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + AgeModifier modifier = Assert.IsType(emitter.Modifiers[0]); + + Assert.Single(modifier.Interpolators); + + ScaleInterpolator interpolator = Assert.IsType(modifier.Interpolators[0]); + Assert.Equal(0.0f, interpolator.StartValue); + Assert.Equal(0.0f, interpolator.EndValue); + } + + [Fact] + public void ReadParticleEffect_VelocityInterpolator_ReadsExpected() + { + string xml = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + ParticleEffect effect = ReadParticleEffectFromXml(xml); + + Assert.Single(effect.Emitters); + ParticleEmitter emitter = effect.Emitters[0]; + + Assert.Single(emitter.Modifiers); + AgeModifier modifier = Assert.IsType(emitter.Modifiers[0]); + + Assert.Single(modifier.Interpolators); + + VelocityInterpolator interpolator = Assert.IsType(modifier.Interpolators[0]); + Assert.Equal(Vector2.Zero, interpolator.StartValue); + Assert.Equal(Vector2.Zero, interpolator.EndValue); + } +} diff --git a/tests/MonoGame.Extended.Tests/Particles/ParticleEffectWriterTests.cs b/tests/MonoGame.Extended.Tests/Particles/ParticleEffectWriterTests.cs new file mode 100644 index 00000000..901b6e59 --- /dev/null +++ b/tests/MonoGame.Extended.Tests/Particles/ParticleEffectWriterTests.cs @@ -0,0 +1,1131 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System; +using System.IO; +using Microsoft.Xna.Framework; +using MonoGame.Extended.Particles; +using MonoGame.Extended.Particles.Modifiers; +using MonoGame.Extended.Particles.Modifiers.Containers; +using MonoGame.Extended.Particles.Modifiers.Interpolators; +using MonoGame.Extended.Particles.Profiles; + +namespace MonoGame.Extended.Tests.Particles; + +public class ParticleEffectWriterTests +{ + [Fact] + public void WriteParticleEffect_NulLEffect_ThrowsArgumentNullException() + { + using MemoryStream stream = new MemoryStream(); + using ParticleEffectWriter writer = new ParticleEffectWriter(stream); + Assert.Throws(() => writer.WriteParticleEffect(null)); + } + + [Fact] + public void WriteParticleEffect_EmptyEffect_WritesMinimalXml() + { + ParticleEffect effect = new ParticleEffect("EmptyEffect"); + + string expected = + $""" + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_EmptyModifiers_WritesMinimalXml() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "EmptyModifiers"; + effect.Emitters.Add(emitter); + + string expected = + $""" + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_EmptyInterpolators_WritesMinimalXml() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + effect.Emitters.Add(emitter); + AgeModifier ageModifier = new AgeModifier(); + emitter.Modifiers.Add(ageModifier); + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_BoxFillProfile_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.BoxFill(1.0f, 2.0f); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_BoxProfile_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Box(1.0f, 2.0f); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_BoxUniformProfile_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.BoxUniform(1.0f, 2.0f); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_CircleProfile_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Circle(1.0f, CircleRadiation.Out); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_LineProfile_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Line(Vector2.One, 1.0f); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_LineUniformProfile_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.LineUniform(Vector2.One, 1.0f, Vector2.One); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_PointProfile_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_RingProfile_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Ring(1.0f, CircleRadiation.In); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_SprayProfile_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Spray(Vector2.One, 1.0f); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_AgeModifier_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + AgeModifier modifier = new AgeModifier(); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_CircleContainerModifier_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + CircleContainerModifier modifier = new CircleContainerModifier(); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_DragModifier_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + DragModifier modifier = new DragModifier(); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_LinearGravityModifier_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + LinearGravityModifier modifier = new LinearGravityModifier(); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_OpacityFastFadeModifier_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + OpacityFastFadeModifier modifier = new OpacityFastFadeModifier(); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_RectangleContainerModifier_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + RectangleContainerModifier modifier = new RectangleContainerModifier(); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_RectangleLoopContainerModifier_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + RectangleLoopContainerModifier modifier = new RectangleLoopContainerModifier(); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_RotationModifier_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + RotationModifier modifier = new RotationModifier(); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_VelocityColorModifier_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + VelocityColorModifier modifier = new VelocityColorModifier(); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_VelocityModifier_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + VelocityModifier modifier = new VelocityModifier(); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_VortexModifier_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + VortexModifier modifier = new VortexModifier(); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_ColorInterpolator_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + AgeModifier modifier = new AgeModifier(); + ColorInterpolator interpolator = new ColorInterpolator(); + modifier.Interpolators.Add(interpolator); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_HueInterpolator_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + AgeModifier modifier = new AgeModifier(); + HueInterpolator interpolator = new HueInterpolator(); + modifier.Interpolators.Add(interpolator); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_OpacityInterpolator_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + AgeModifier modifier = new AgeModifier(); + OpacityInterpolator interpolator = new OpacityInterpolator(); + modifier.Interpolators.Add(interpolator); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_RotationInterpolator_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + AgeModifier modifier = new AgeModifier(); + RotationInterpolator interpolator = new RotationInterpolator(); + modifier.Interpolators.Add(interpolator); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_ScaleInterpolator_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + AgeModifier modifier = new AgeModifier(); + ScaleInterpolator interpolator = new ScaleInterpolator(); + modifier.Interpolators.Add(interpolator); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + [Fact] + public void WriteParticleEffect_VelocityInterpolator_WritesExpected() + { + ParticleEffect effect = new ParticleEffect("TestEffect"); + ParticleEmitter emitter = new ParticleEmitter(1); + emitter.Name = "TestEmitter"; + emitter.Profile = Profile.Point(); + AgeModifier modifier = new AgeModifier(); + VelocityInterpolator interpolator = new VelocityInterpolator(); + modifier.Interpolators.Add(interpolator); + emitter.Modifiers.Add(modifier); + effect.Emitters.Add(emitter); + + + string expected = + $""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + AssertParticleEffect(effect, expected); + } + + private void AssertParticleEffect(ParticleEffect effect, string expected) + { + string actual = string.Empty; + using (MemoryStream stream = new MemoryStream()) + { + using (ParticleEffectWriter writer = new ParticleEffectWriter(stream)) + { + writer.WriteParticleEffect(effect); + stream.Position = 0; + } + + using StreamReader reader = new StreamReader(stream); + actual = reader.ReadToEnd(); + } + + Assert.Equal(expected, actual); + } +} diff --git a/tests/MonoGame.Extended.Tests/Particles/Profiles/PointProfileTests.cs b/tests/MonoGame.Extended.Tests/Particles/Profiles/PointProfileTests.cs index cb22b3bd..56743b47 100644 --- a/tests/MonoGame.Extended.Tests/Particles/Profiles/PointProfileTests.cs +++ b/tests/MonoGame.Extended.Tests/Particles/Profiles/PointProfileTests.cs @@ -1,33 +1,35 @@ using System; +using Microsoft.Xna.Framework; using MonoGame.Extended.Particles.Profiles; -using Xunit; namespace MonoGame.Extended.Tests.Particles.Profiles { - public class PointProfileTests { [Fact] - public void ReturnsZeroOffset() + public unsafe void ReturnsZeroOffset() { - var subject = new PointProfile(); + PointProfile subject = new PointProfile(); - subject.GetOffsetAndHeading(out var offset, out _); + Vector2 offset; + Vector2 heading; + subject.GetOffsetAndHeading(&offset, &heading); Assert.Equal(0f, offset.X); Assert.Equal(0f, offset.Y); } [Fact] - public void ReturnsHeadingAsUnitVector() + public unsafe void ReturnsHeadingAsUnitVector() { - var subject = new PointProfile(); + PointProfile subject = new PointProfile(); - subject.GetOffsetAndHeading(out _, out var heading); + Vector2 offset; + Vector2 heading; + subject.GetOffsetAndHeading(&offset, &heading); - var length = Math.Sqrt(heading.X * heading.X + heading.Y * heading.Y); + double length = Math.Sqrt(heading.X * heading.X + heading.Y * heading.Y); Assert.Equal(1f, length, 6); } - } } diff --git a/tests/MonoGame.Extended.Tests/Particles/Profiles/RingProfileTests.cs b/tests/MonoGame.Extended.Tests/Particles/Profiles/RingProfileTests.cs index a18f5b7b..4f716bdf 100644 --- a/tests/MonoGame.Extended.Tests/Particles/Profiles/RingProfileTests.cs +++ b/tests/MonoGame.Extended.Tests/Particles/Profiles/RingProfileTests.cs @@ -1,38 +1,42 @@ using System; +using Microsoft.Xna.Framework; using MonoGame.Extended.Particles.Profiles; -using Xunit; namespace MonoGame.Extended.Tests.Particles.Profiles { public class RingProfileTests { [Fact] - public void ReturnsOffsetEqualToRadius() + public unsafe void ReturnsOffsetEqualToRadius() { - var subject = new RingProfile + RingProfile subject = new RingProfile { Radius = 10f }; - subject.GetOffsetAndHeading(out var offset, out _); - var length = Math.Sqrt(offset.X * offset.X + offset.Y * offset.Y); - Assert.Equal(10f, length, 6); + Vector2 offset; + Vector2 heading; + subject.GetOffsetAndHeading(&offset, &heading); + + double length = Math.Sqrt(offset.X * offset.X + offset.Y * offset.Y); + Assert.Equal(10.0f, length, precision: 5); } [Fact] - public void WhenRadiateIsTrue_HeadingIsEqualToNormalizedOffset() + public unsafe void WhenRadiateIsTrue_HeadingIsEqualToNormalizedOffset() { - var subject = new RingProfile + RingProfile subject = new RingProfile { Radius = 10f, - Radiate = Profile.CircleRadiation.Out + Radiate = CircleRadiation.Out }; - subject.GetOffsetAndHeading(out var offset, out var heading); - Assert.Equal(heading.X, offset.X / 10, 6); - Assert.Equal(heading.Y, offset.Y / 10, 6); + Vector2 offset; + Vector2 heading; + subject.GetOffsetAndHeading(&offset, &heading); + Assert.Equal(heading.X, offset.X / 10, precision: 5); + Assert.Equal(heading.Y, offset.Y / 10, precision: 5); } - } } diff --git a/tests/MonoGame.Extended.Tests/Serialization/Xml/XmlReaderExtensionsTests.cs b/tests/MonoGame.Extended.Tests/Serialization/Xml/XmlReaderExtensionsTests.cs new file mode 100644 index 00000000..357f7604 --- /dev/null +++ b/tests/MonoGame.Extended.Tests/Serialization/Xml/XmlReaderExtensionsTests.cs @@ -0,0 +1,221 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System.IO; +using System.Xml; +using Microsoft.Xna.Framework; +using MonoGame.Extended.Serialization.Xml; + +namespace MonoGame.Extended.Tests.Serialization.Xml; + +public class XmlReaderExtensionsTests +{ + private static XmlReader CreateXmlReader(string xml) + { + StringReader stringReader = new StringReader(xml); + XmlReader xmlReader = XmlReader.Create(stringReader); + xmlReader.MoveToContent(); + return xmlReader; + } + + [Fact] + public void GetAttributeInt_ValidValue_ReturnsCorrectInt() + { + int expected = 42; + + string xml = $""; + using XmlReader reader = CreateXmlReader(xml); + int actual = reader.GetAttributeInt("testAttr"); + + Assert.Equal(expected, actual); + } + + [Fact] + public void GetAttributeInt_MissingAttribute_ThrowsXmlException() + { + string xml = ""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } + + [Fact] + public void GetAttributeInt_InvalidFormat_ThrowsXmlException() + { + string xml = ""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } + + [Fact] + public void GetAttributeFloat_ValidValue_ReturnsCorrectFloat() + { + float expected = 3.14f; + + string xml = $""; + using XmlReader reader = CreateXmlReader(xml); + float actual = reader.GetAttributeFloat("testAttr"); + + Assert.Equal(expected, actual, precision: 5); + } + + [Fact] + public void GetAttributeFloat_MissingAttribute_ThrowsXmlException() + { + string xml = ""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } + + [Fact] + public void GetAttributeFloat_InvalidFormat_ThrowsXmlException() + { + string xml = ""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } + + [Theory] + [InlineData("true", true)] + [InlineData("false", false)] + [InlineData("True", true)] + [InlineData("False", false)] + public void GetAttributeBool_ValidValues_ReturnsCorrectBool(string value, bool expected) + { + string xml = $""; + using XmlReader reader = CreateXmlReader(xml); + bool actual = reader.GetAttributeBool("testAttr"); + Assert.Equal(expected, actual); + } + + [Fact] + public void GetAttributeBool_InvalidFormat_ThrowsXmlException() + { + string xml = ""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } + + [Theory] + [InlineData(nameof(PlayerIndex.One), PlayerIndex.One)] + [InlineData(nameof(PlayerIndex.Two), PlayerIndex.Two)] + [InlineData(nameof(PlayerIndex.Three), PlayerIndex.Three)] + [InlineData(nameof(PlayerIndex.Four), PlayerIndex.Four)] + public void GetAttributeEnum_ValidValues_ReturnsCorrectEnum(string value, PlayerIndex expected) + { + string xml = $""; + using XmlReader reader = CreateXmlReader(xml); + PlayerIndex actual = reader.GetAttributeEnum("testAttr"); + Assert.Equal(expected, actual); + } + + [Fact] + public void GetAttributeEnum_MissingAttribute_ThrowsXmlException() + { + string xml = ""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } + + [Fact] + public void GetAttributeEnum_InvalidValue_ThrowsXmlException() + { + string xml = ""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } + + [Fact] + public void GetAttributeRectangle_ValidValue_ReturnsCorrectRectangle() + { + Rectangle expected = new Rectangle(1, 2, 3, 4); + + string xml = $""; + using XmlReader reader = CreateXmlReader(xml); + Rectangle actual = reader.GetAttributeRectangle("testAttr"); + + Assert.Equal(expected, actual); + } + + [Fact] + public void GetAttributeRectangle_MissingAttribute_ThrowsXmlException() + { + string xml = ""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } + + [Theory] + [InlineData("1,2,3")] + [InlineData("1,2,3,4,5")] + [InlineData("a,b,c,d")] + [InlineData("1.5,2,3,4")] + public void GetAttributeRectangle_InvalidFormat_ThrowsXmlException(string value) + { + string xml = $""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } + + [Fact] + public void GetAttributeVector2_ValidValue_ReturnsCorrectVector2() + { + Vector2 expected = new Vector2(1.0f, 2.0f); + + string xml = $""; + using XmlReader reader = CreateXmlReader(xml); + Vector2 actual = reader.GetAttributeVector2("testAttr"); + + Assert.Equal(expected, actual); + } + + [Fact] + public void GetAttributeVector2_MissingAttribute_ThrowsXmlException() + { + string xml = ""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } + + [Theory] + [InlineData("3.14")] + [InlineData("1,2,3")] + [InlineData("a,b")] + public void GetAttributeVector2_InvalidFormat_ThrowsXmlException(string value) + { + string xml = $""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } + + [Fact] + public void GetAttributeVector3_ValidValue_ReturnsCorrectVector3() + { + Vector3 expected = new Vector3(1.0f, 2.0f, 3.0f); + + string xml = $""; + using XmlReader reader = CreateXmlReader(xml); + Vector3 actual = reader.GetAttributeVector3("testAttr"); + + Assert.Equal(expected, actual); + } + + [Fact] + public void GetAttributeVector3_MissingAttribute_ThrowsXmlException() + { + string xml = ""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } + + [Theory] + [InlineData("1.0,2.0")] + [InlineData("1,2,3,4")] + [InlineData("a,b,c")] + public void GetAttributeVector3_InvalidFormat_ThrowsXmlException(string value) + { + string xml = $""; + using XmlReader reader = CreateXmlReader(xml); + Assert.Throws(() => reader.GetAttributeInt("testAttr")); + } +} diff --git a/tests/MonoGame.Extended.Tests/Serialization/Xml/XmlWriterExtensionsTests.cs b/tests/MonoGame.Extended.Tests/Serialization/Xml/XmlWriterExtensionsTests.cs new file mode 100644 index 00000000..c20c571d --- /dev/null +++ b/tests/MonoGame.Extended.Tests/Serialization/Xml/XmlWriterExtensionsTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) Craftwork Games. All rights reserved. +// Licensed under the MIT license. +// See LICENSE file in the project root for full license information. + +using System.Text; +using System.Xml; +using Microsoft.Xna.Framework; +using MonoGame.Extended.Serialization.Xml; + +namespace MonoGame.Extended.Tests.Serialization.Xml; + +public class XmlWriterExtensionsTests +{ + private static (XmlWriter writer, StringBuilder output) CreateXmlWriter() + { + StringBuilder output = new StringBuilder(); + + XmlWriterSettings settings = new XmlWriterSettings(); + settings.OmitXmlDeclaration = true; + settings.Indent = true; + + XmlWriter writer = XmlWriter.Create(output, settings); + return (writer, output); + } + + [Fact] + public void WriteAttributeInt_WritesCorrectAttribute() + { + (XmlWriter writer, StringBuilder output) = CreateXmlWriter(); + + writer.WriteStartElement("test"); + writer.WriteAttributeInt("value", 1); + writer.WriteEndElement(); + writer.Flush(); + + Assert.Equal("", output.ToString()); + } + + [Fact] + public void WriteAttributeFloat_WritesCorrectAttribute() + { + (XmlWriter writer, StringBuilder output) = CreateXmlWriter(); + + writer.WriteStartElement("test"); + writer.WriteAttributeFloat("value", 1.2f); + writer.WriteEndElement(); + writer.Flush(); + + Assert.Equal("", output.ToString()); + } + + [Theory] + [InlineData(true, "True")] + [InlineData(false, "False")] + public void WriteAttributeBool_WritesCorrectAttribute(bool value, string expected) + { + (XmlWriter writer, StringBuilder output) = CreateXmlWriter(); + + writer.WriteStartElement("test"); + writer.WriteAttributeBool("value", value); + writer.WriteEndElement(); + writer.Flush(); + + Assert.Equal($"", output.ToString()); + } + + [Fact] + public void WriteAttributeRectangle_WritesCorrectAttribute() + { + (XmlWriter writer, StringBuilder output) = CreateXmlWriter(); + + Rectangle rectangle = new Rectangle(1, 2, 3, 4); + + writer.WriteStartElement("test"); + writer.WriteAttributeRectangle("value", rectangle); + writer.WriteEndElement(); + writer.Flush(); + + Assert.Equal($"", output.ToString()); + } + + [Fact] + public void WriteAttributeVector2_WriteCorrectValue() + { + (XmlWriter writer, StringBuilder output) = CreateXmlWriter(); + + Vector2 vector = new Vector2(1.1f, 2.2f); + + writer.WriteStartElement("test"); + writer.WriteAttributeVector2("value", vector); + writer.WriteEndElement(); + writer.Flush(); + + Assert.Equal($"", output.ToString()); + } + + [Fact] + public void WriteAttributeVector3_WriteCorrectValue() + { + (XmlWriter writer, StringBuilder output) = CreateXmlWriter(); + + Vector3 vector = new Vector3(1.1f, 2.2f, 3.3f); + + writer.WriteStartElement("test"); + writer.WriteAttributeVector3("value", vector); + writer.WriteEndElement(); + writer.Flush(); + + Assert.Equal($"", output.ToString()); + } +}