From 1a60ce164e31c5cf3706e25d4b7e42aa70f359da Mon Sep 17 00:00:00 2001 From: Opelkuh <25430283+Opelkuh@users.noreply.github.com> Date: Tue, 24 Aug 2021 00:15:16 +0200 Subject: [PATCH] Add `ParticleJet` --- .../Visual/Gameplay/TestSceneParticleJet.cs | 61 +++++++ osu.Game/Graphics/Particles/ParticleJet.cs | 49 +++++ osu.Game/Graphics/Particles/ParticleSpewer.cs | 172 ++++++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 osu.Game.Tests/Visual/Gameplay/TestSceneParticleJet.cs create mode 100644 osu.Game/Graphics/Particles/ParticleJet.cs create mode 100644 osu.Game/Graphics/Particles/ParticleSpewer.cs diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneParticleJet.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleJet.cs new file mode 100644 index 0000000000..6438ba0b22 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneParticleJet.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Graphics.Particles; +using osu.Game.Skinning; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [TestFixture] + public class TestSceneParticleJet : OsuTestScene + { + private ParticleJet jet; + + [Resolved] + private SkinManager skinManager { get; set; } + + public TestSceneParticleJet() + { + AddStep("create", () => + { + Child = jet = createJet(); + }); + + AddToggleStep("toggle spawning", value => jet.Active = value); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create jet", () => Child = jet = createJet()); + } + + [Test] + public void TestPresence() + { + AddStep("start jet", () => jet.Active = true); + AddAssert("is present", () => jet.IsPresent); + + AddWaitStep("wait for some particles", 3); + AddStep("stop jet", () => jet.Active = false); + + AddWaitStep("wait for clean screen", 5); + AddAssert("is not present", () => !jet.IsPresent); + } + + private ParticleJet createJet() + { + return new ParticleJet(skinManager.DefaultLegacySkin.GetTexture("star2"), 180) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativePositionAxes = Axes.Y, + Y = -0.1f, + }; + } + } +} diff --git a/osu.Game/Graphics/Particles/ParticleJet.cs b/osu.Game/Graphics/Particles/ParticleJet.cs new file mode 100644 index 0000000000..039dd36ddc --- /dev/null +++ b/osu.Game/Graphics/Particles/ParticleJet.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osuTK; + +namespace osu.Game.Graphics.Particles +{ + public class ParticleJet : ParticleSpewer + { + private const int particles_per_second = 80; + private const double particle_lifetime = 500; + private const float angular_velocity = 3f; + private const int angle_spread = 10; + private const float velocity_min = 1.3f; + private const float velocity_max = 1.5f; + + private readonly int angle; + + protected override float ParticleGravity => 0.25f; + + public ParticleJet(Texture texture, int angle) + : base(texture, particles_per_second, particle_lifetime) + { + this.angle = angle; + } + + protected override FallingParticle SpawnParticle() + { + var directionRads = MathUtils.DegreesToRadians( + RNG.NextSingle(angle - angle_spread / 2, angle + angle_spread / 2) + ); + var direction = new Vector2(MathF.Sin(directionRads), MathF.Cos(directionRads)); + + return new FallingParticle + { + StartTime = (float)Time.Current, + Position = OriginPosition, + Duration = RNG.NextSingle((float)particle_lifetime * 0.8f, (float)particle_lifetime), + Velocity = direction * new Vector2(RNG.NextSingle(velocity_min, velocity_max)), + AngularVelocity = RNG.NextSingle(-angular_velocity, angular_velocity), + StartScale = 1f, + EndScale = 2f, + }; + } + } +} diff --git a/osu.Game/Graphics/Particles/ParticleSpewer.cs b/osu.Game/Graphics/Particles/ParticleSpewer.cs new file mode 100644 index 0000000000..7196727ca1 --- /dev/null +++ b/osu.Game/Graphics/Particles/ParticleSpewer.cs @@ -0,0 +1,172 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Vertices; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osuTK; + +namespace osu.Game.Graphics.Particles +{ + public abstract class ParticleSpewer : Sprite + { + private readonly FallingParticle[] particles; + private int currentIndex; + private double lastParticleAdded; + + private readonly double cooldown; + private readonly double maxLifetime; + + /// + /// Determines whether particles are being spawned. + /// + public bool Active { get; set; } + + public bool HasActiveParticles => Active || (lastParticleAdded + maxLifetime) > Time.Current; + public override bool IsPresent => base.IsPresent && HasActiveParticles; + + protected virtual float ParticleGravity => 0.5f; + + protected ParticleSpewer(Texture texture, int perSecond, double maxLifetime) + { + Texture = texture; + Blending = BlendingParameters.Additive; + + particles = new FallingParticle[perSecond * (int)Math.Ceiling(maxLifetime / 1000)]; + + cooldown = 1000f / perSecond; + this.maxLifetime = maxLifetime; + } + + protected override void Update() + { + base.Update(); + + if (Active && Time.Current > lastParticleAdded + cooldown) + { + addParticle(SpawnParticle()); + } + + Invalidate(Invalidation.DrawNode); + } + + /// + /// Called each time a new particle should be spawned. + /// + protected abstract FallingParticle SpawnParticle(); + + private void addParticle(FallingParticle fallingParticle) + { + particles[currentIndex] = fallingParticle; + + currentIndex = (currentIndex + 1) % particles.Length; + lastParticleAdded = Time.Current; + } + + protected override DrawNode CreateDrawNode() => new ParticleSpewerDrawNode(this); + + private class ParticleSpewerDrawNode : SpriteDrawNode + { + private readonly FallingParticle[] particles; + + protected new ParticleSpewer Source => (ParticleSpewer)base.Source; + + private float currentTime; + private float gravity; + + public ParticleSpewerDrawNode(Sprite source) + : base(source) + { + particles = new FallingParticle[Source.particles.Length]; + } + + public override void ApplyState() + { + base.ApplyState(); + + Source.particles.CopyTo(particles, 0); + + currentTime = (float)Source.Time.Current; + gravity = Source.ParticleGravity; + } + + protected override void Blit(Action vertexAction) + { + foreach (var p in particles) + { + // ignore particles that weren't initialized. + if (p.StartTime <= 0) continue; + + var timeSinceStart = currentTime - p.StartTime; + + var alpha = p.AlphaAtTime(timeSinceStart); + if (alpha <= 0) continue; + + var scale = p.ScaleAtTime(timeSinceStart); + var pos = p.PositionAtTime(timeSinceStart, gravity); + var angle = p.AngleAtTime(timeSinceStart); + + var rect = new RectangleF( + pos.X - Texture.DisplayWidth * scale / 2, + pos.Y - Texture.DisplayHeight * scale / 2, + Texture.DisplayWidth * scale, + Texture.DisplayHeight * scale); + + var quad = new Quad( + transformPosition(rect.TopLeft, rect.Centre, angle), + transformPosition(rect.TopRight, rect.Centre, angle), + transformPosition(rect.BottomLeft, rect.Centre, angle), + transformPosition(rect.BottomRight, rect.Centre, angle) + ); + + DrawQuad(Texture, quad, DrawColourInfo.Colour.MultiplyAlpha(alpha), null, vertexAction, + new Vector2(InflationAmount.X / DrawRectangle.Width, InflationAmount.Y / DrawRectangle.Height), + null, TextureCoords); + } + } + + private Vector2 transformPosition(Vector2 pos, Vector2 centre, float angle) + { + // rotate point around centre. + float cos = MathF.Cos(angle); + float sin = MathF.Sin(angle); + + float x = centre.X + (pos.X - centre.X) * cos + (pos.Y - centre.Y) * sin; + float y = centre.Y + (pos.Y - centre.Y) * cos - (pos.X - centre.X) * sin; + + // convert to screen space. + return Vector2Extensions.Transform(new Vector2(x, y), DrawInfo.Matrix); + } + } + + protected struct FallingParticle + { + public float StartTime; + public Vector2 Position; + public Vector2 Velocity; + public float Duration; + public float AngularVelocity; + public float StartScale; + public float EndScale; + + public float AlphaAtTime(float timeSinceStart) => 1 - progressAtTime(timeSinceStart); + + public float ScaleAtTime(float timeSinceStart) => StartScale + (EndScale - StartScale) * progressAtTime(timeSinceStart); + + public float AngleAtTime(float timeSinceStart) => AngularVelocity / 1000 * timeSinceStart; + + public Vector2 PositionAtTime(float timeSinceStart, float gravity) + { + var progress = progressAtTime(timeSinceStart); + var grav = new Vector2(0, -gravity) * progress; + + return Position + (Velocity - grav) * timeSinceStart; + } + + private float progressAtTime(float timeSinceStart) => Math.Clamp(timeSinceStart / Duration, 0, 1); + } + } +}