Particle Manager ideas and api

Started by eri0o, Sat 12/10/2024 20:45:12

Previous topic - Next topic

eri0o

I decided to play around with the idea of a particle system just for fun, first, here is the demo


Here is the sketch of the module header and script

particle.ash
Spoiler
Code: ags
// new module header

managed struct ParticleDefinition {
    int offsetX; // Offset from the emitter position
    int offsetY; // Offset from the emitter position
    int life;    // Lifetime of the particle
    int vx;      // mili Velocity in x direction
    int vy;      // mili Velocity in y direction
    int gravity; // mili Gravity effect on the particle
    int initialTransparency; // Initial transparency
    int finalTransparency; // Final transparency
    int initialWidth; // Initial width
    int finalWidth; // Final width
    int initialHeight; // Initial height
    int finalHeight; // Final height
    bool groundHitBounces;
    int groundY;
    int groundX;
    int groundWidth;
};

managed struct Particle {
    float x;
    float y;
    int life;
    int initialLife;
    int overlayIndex; // This refers to the overlay in the overlay pool
    float vx; // x velocity
    float vy; // y velocity
    float gravity; // this is vertical acceleration downwards
    int transparency;
    int width;
    int height;
    int initialTransparency; 
    int finalTransparency; 
    int initialWidth; 
    int finalWidth; 
    int initialHeight; 
    int finalHeight;
    bool bounces;
    int groundY;
    int groundX;
    int groundWidth;

    // Initialize the particle with its position, life, velocity, and transparency
    import void Init(ParticleDefinition* def, int x, int y, int overlayIndex);
    import void Update(); // Update particle position and overlay
    import bool IsAlive(); // Check if particle is still alive
};

struct Emitter {
  protected int x;
  protected int y;
  protected int particleLife;
  protected int emitParticleCount;
  protected int particleCount;
  protected int sprite; // The sprite slot to use for particles
  protected int gravity;
  protected Particle* particles[]; // Pool of particles
  protected ParticleDefinition* definitions[]; // Array of particle definitions
  protected int definitionsCount; // Count of particle definitions
  
  /// Set sprite
  import void SetSprite(int graphic);  
  /// Set emitter possible particle definitions
  import void SetParticleDefinitions(ParticleDefinition* definitions[], int definitionsCount);
  /// Update emitter position
  import void SetPosition(int x, int y);

  /// Initialize the emitter
  import void Init(int x, int y, ParticleDefinition* definitions[], int definitionsCount, int emitParticleCount = 10, int particleCount = 50, int sprite = 0, int gravity = 0);
  /// Emit a single particle
  import void EmitSingleParticle();  
  /// Emit particles
  import void Emit(); 
  /// Update all particles
  import void Update();
};

/// Global Particle Emitter
import Emitter GPE;
[close]

particle.asc
Spoiler
Code: ags
// new module script

#define MAX_OVERLAYS 512
Overlay* overlayPool[MAX_OVERLAYS];
bool overlayUsed[MAX_OVERLAYS];
int lastUsed;
Emitter GPE;
export GPE;

#define MAX_LERP 1024

// lerp from percent 0 to 1024
int _Lerp(int start, int end, int percent) {
  if (percent < 0) percent = 0;
  if (percent > MAX_LERP) percent = MAX_LERP;

  // Calculate the interpolated value
  return start + ((end - start) * percent) / MAX_LERP;
}

int InvalidateOverlay(int index)
{
  if(overlayPool[index] != null)
    overlayPool[index].Remove();
  overlayPool[index] = null; // Clear the reference to the overlay
  overlayUsed[index] = false; // Mark the overlay slot as free
  return -1;
}

// Find an available overlay slot in the pool
function GetAvailableOverlayIndex() {
  for (int i = lastUsed; i < MAX_OVERLAYS; i++) {
    if (!overlayUsed[i]) {
      InvalidateOverlay(i);
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  for (int i = 0; (i < lastUsed) && (i < MAX_OVERLAYS); i++) {
    if (!overlayUsed[i]) {
      InvalidateOverlay(i);
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  return -1; // No available overlay
}

void UpdateOverlayFromParticle(Overlay* ovr, Particle* p)
{
  ovr.X = FloatToInt(p.x);
  ovr.Y = FloatToInt(p.y);
  ovr.Transparency = p.transparency;
  ovr.Width = p.width;
  ovr.Height = p.height;
}

void Particle::Init(ParticleDefinition* def, int x, int y,  int overlayIndex) {
  if(this.overlayIndex >= 0 && this.overlayIndex < MAX_OVERLAYS) {
    InvalidateOverlay(this.overlayIndex);
  }

  this.x = IntToFloat(x + def.offsetX); // Offset based on emitter
  this.y = IntToFloat(y + def.offsetY); // Offset based on emitter
  this.life = def.life;
  this.vx = IntToFloat(def.vx)/1000.0;
  this.vy = IntToFloat(def.vy)/1000.0;
  this.gravity = IntToFloat(def.gravity)/1000.0;
  this.transparency = def.initialTransparency;
  this.initialTransparency = def.initialTransparency;
  this.finalTransparency = def.finalTransparency;
  this.width = def.initialWidth;
  this.initialWidth = def.initialWidth;
  this.finalWidth = def.finalWidth;
  this.height = def.initialHeight;
  this.initialHeight = def.initialHeight;
  this.finalHeight = def.finalHeight;
  this.overlayIndex = overlayIndex;
  this.initialLife = def.life; // Store initial life for transitions
  this.bounces = def.groundHitBounces;
  this.groundY = def.groundY;
  this.groundX = def.groundX;
  this.groundWidth = def.groundWidth;
  if(this.groundY > 0) {
    if(this.groundWidth <= 0 ) {
      this.groundWidth = 8192;
      this.groundX = -1024;
    }
  } else {
    this.groundY = 0;
    this.groundX = 0;
    this.groundWidth = 0;
  }

  if (overlayIndex >= 0 && overlayPool[overlayIndex] != null) {
    UpdateOverlayFromParticle(overlayPool[overlayIndex], this);
  }
}

bool Particle::IsAlive() {
  return (this.life > 0);
}

// Update the particle state and sync with overlay
void Particle::Update() {
  if (this.IsAlive()) {
    this.x += this.vx;
    this.y += this.vy;
    this.vy += this.gravity; // Apply gravity
    this.life--;
    int px = FloatToInt(this.x);
    int py = FloatToInt(this.y);

    // Calculate the scaling and transparency transitions based on life
    int lifeRatio =  MAX_LERP - ((this.life * MAX_LERP) / this.initialLife); // 0 to 1024
    this.transparency = _Lerp(this.initialTransparency, this.finalTransparency, lifeRatio);
    this.width = _Lerp(this.initialWidth, this.finalWidth, lifeRatio);
    this.height = _Lerp(this.initialHeight, this.finalHeight, lifeRatio);

    if (this.overlayIndex >= 0 && overlayPool[this.overlayIndex] != null) {
      UpdateOverlayFromParticle(overlayPool[this.overlayIndex], this);
    }    
    
    if ((py >= this.groundY) && (px >= this.groundX) && (px <= (this.groundX + this.groundWidth))) {
      if (this.bounces) {
        this.vy = -this.vy * 0.7; // Invert velocity, reduce it to simulate energy loss
      } else {
        this.life = 0; // Mark particle as dead
      }
    }
  } else {
    // Remove overlay if life is over
    if (this.overlayIndex >= 0) {
      this.overlayIndex = InvalidateOverlay(this.overlayIndex); // Invalidate the overlay index
    }
  }
}

void Emitter::SetPosition(int x, int y)
{
  this.x = x;
  this.y = y;
}

void Emitter::SetSprite(int graphic)
{
  this.sprite = graphic;
}

void Emitter::SetParticleDefinitions(ParticleDefinition* definitions[], int definitionsCount)
{
  this.definitions = definitions;
  this.definitionsCount = definitionsCount;
}

// Initialize the emitter with position, particle definitions, and specific parameters
void Emitter::Init(int x, int y, ParticleDefinition* definitions[], int definitionsCount, int emitParticleCount, int particleCount, int sprite, int gravity) {
  this.SetPosition(x, y);
  this.particleCount = particleCount;
  this.SetSprite(sprite);
  this.gravity = gravity;
  this.SetParticleDefinitions(definitions, definitionsCount);
  this.emitParticleCount = emitParticleCount;
  this.particles = new Particle[particleCount];
  for (int i = 0; i < particleCount; i++) {
    this.particles[i] = new Particle;
  }
}

// Emit a single particle from the emitter
void Emitter::EmitSingleParticle() {
  for (int i = 0; i < this.particleCount; i++) {
    if (this.particles[i].IsAlive())
    {
      continue;
    }
    
    // Reuse dead particle if it's not alive anymore
    int overlayIndex = GetAvailableOverlayIndex();
    if (overlayIndex >= 0) {
      Overlay* ovr = Overlay.CreateGraphical(this.x, this.y, this.sprite);
      overlayPool[overlayIndex] = ovr;

      // Randomly select a particle definition from the available definitions
      int defIndex = 0;
      if(this.definitionsCount > 0) {
        defIndex = Random(this.definitionsCount-1);
      }
      ParticleDefinition* def = this.definitions[defIndex];

      this.particles[i].Init(def, this.x, this.y, overlayIndex);
    }
    return;
  }
}

// Emit particles from the emitter
void Emitter::Emit() {
  for (int i = 0; i < this.emitParticleCount; i++) {
    this.EmitSingleParticle();
  }
}

// Update all particles
void Emitter::Update() {
  for (int i = 0; i < this.particleCount; i++) {
    if (this.particles[i].IsAlive()) {
      this.particles[i].Update();
    } else if (this.particles[i].overlayIndex >= 0) {
      // Ensure overlays are freed for dead particles
      this.particles[i].overlayIndex = InvalidateOverlay(this.particles[i].overlayIndex); // Invalidate the overlay index
    }
  }
}

void game_start()
{
  ParticleDefinition* d[];
  GPE.Init(Screen.Width/2, Screen.Height/2, d, 0, MAX_OVERLAYS/14, MAX_OVERLAYS/2, 0, 0);
}

void repeatedly_execute_always()
{
  GPE.Update();
}
[close]

And here is the room script for the demo above
room1.asc
Spoiler
Code: ags
// room script file

//Emitter emt;

function hGlowingOrb_Look(Hotspot *thisHotspot, CursorMode mode)
{
  player.Say("It is the second best glowing orb that I've seen today.");
}

ParticleDefinition* GetFireworksParticle()
{
  ParticleDefinition* fireworksParticle = new ParticleDefinition;
  fireworksParticle.life = 40;
  fireworksParticle.vx = Random(4000) - 2000; // Random outward velocity
  fireworksParticle.vy = Random(4000) - 2000;
  fireworksParticle.gravity = 0; // No gravity
  fireworksParticle.initialTransparency = 0;
  fireworksParticle.finalTransparency = 100;
  fireworksParticle.initialWidth = 2;
  fireworksParticle.finalWidth = 20; // Expanding outward
  fireworksParticle.initialHeight = 2;
  fireworksParticle.finalHeight = 20;
  return fireworksParticle;
}

ParticleDefinition* GetSparkleParticle()
{
  ParticleDefinition* sparkleParticle = new ParticleDefinition;
  sparkleParticle.life = 50;
  sparkleParticle.vx = Random(3000) - 1000;
  sparkleParticle.vy = Random(3000) - 1000;
  sparkleParticle.initialTransparency = 0;
  sparkleParticle.finalTransparency = 100;
  sparkleParticle.initialWidth = 3;
  sparkleParticle.finalWidth = 8;
  sparkleParticle.initialHeight = 3;
  sparkleParticle.finalHeight = 8;
  sparkleParticle.gravity = 100;
  sparkleParticle.groundY = 154;
  sparkleParticle.groundHitBounces = true;
  return sparkleParticle;
}

ParticleDefinition* GetExplosionParticle()
{
  ParticleDefinition* explosionParticle = new ParticleDefinition;
  explosionParticle.life = 30;
  explosionParticle.vx = Random(6000) - 3000;
  explosionParticle.vy = Random(6000) - 3000;
  explosionParticle.gravity =  -1000;
  explosionParticle.initialTransparency = 15;
  explosionParticle.finalTransparency = 100;
  explosionParticle.initialWidth = 15;
  explosionParticle.finalWidth = 30;
  explosionParticle.initialHeight = 15;
  explosionParticle.finalHeight = 30;
  return explosionParticle;
}

ParticleDefinition* GetSmokeParticle()
{
  ParticleDefinition* smokeParticle = new ParticleDefinition;
  smokeParticle.life = 40+Random(14);
  smokeParticle.vy = -1000-Random(1000);
  smokeParticle.initialTransparency = 0;
  smokeParticle.finalTransparency = 100;
  smokeParticle.initialWidth = 10+Random(2);
  smokeParticle.finalWidth = 20+Random(2);
  smokeParticle.initialHeight = 20+Random(2);
  smokeParticle.finalHeight = 10+Random(2);
  return smokeParticle;
}

ParticleDefinition* GetBubbleParticle()
{
  ParticleDefinition* bubbleParticle = new ParticleDefinition;
  bubbleParticle.life = 60;
  bubbleParticle.vx = Random(500) - 250; // Small horizontal drift
  bubbleParticle.vy = -1000 - Random(500); // Rising upwards
  bubbleParticle.gravity = -200; // Rising effect
  bubbleParticle.initialTransparency = 30;
  bubbleParticle.finalTransparency = 100;
  bubbleParticle.initialWidth = 5;
  bubbleParticle.finalWidth = 15; // Expands as it rises
  bubbleParticle.initialHeight = 5;
  bubbleParticle.finalHeight = 15;
  return bubbleParticle;
}

ParticleDefinition* GetRainParticle()
{
  ParticleDefinition* rainParticle = new ParticleDefinition;
  rainParticle.offsetX = Random(Screen.Width) - (Screen.Width/2);
  rainParticle.offsetY = -Random(30);
  rainParticle.life = 50;
  rainParticle.vx = Random(500) - 250; // Slight horizontal movement
  rainParticle.vy = 3000; // Falling down quickly
  rainParticle.gravity = 200; // Light gravity effect
  rainParticle.initialTransparency = 30;
  rainParticle.finalTransparency = 80;
  rainParticle.initialWidth = 2;
  rainParticle.finalWidth = 2;
  rainParticle.initialHeight = 10;
  rainParticle.finalHeight = 15; // Lengthening as it falls
  return rainParticle;
}

ParticleDefinition* GetFireParticle()
{
  ParticleDefinition* fireParticle = new ParticleDefinition;
  fireParticle.life = 35;
  fireParticle.vx = Random(1000) - 500; // Small horizontal variance
  fireParticle.vy = -1500 - Random(500); // Rising upward
  fireParticle.gravity = -50; // Slow upward pull
  fireParticle.initialTransparency = 50;
  fireParticle.finalTransparency = 100; // Disappears as it rises
  fireParticle.initialWidth = 10;
  fireParticle.finalWidth = 20; // Expands as it rises
  fireParticle.initialHeight = 10;
  fireParticle.finalHeight = 15;
  return fireParticle;
}

ParticleDefinition* GetSnowParticle()
{
  ParticleDefinition* snowParticle = new ParticleDefinition;
  snowParticle.offsetX = Random(Screen.Width) - (Screen.Width/2);
  snowParticle.offsetY = -Random(30);
  snowParticle.life = 150;
  snowParticle.vx = Random(300) - 150; // Slight horizontal drift
  snowParticle.vy = Random(300) + 300; // Slow downward movement
  snowParticle.gravity = 15; // Minimal gravity effect
  snowParticle.initialTransparency = 50;
  snowParticle.finalTransparency = 80;
  snowParticle.initialWidth = 4;
  snowParticle.finalWidth = 6; // Slight expansion as it falls
  snowParticle.initialHeight = 4;
  snowParticle.finalHeight = 6;
  return snowParticle;
}

enum PresetParticleType {
  ePPT_Fireworks, 
  ePPT_Sparkle, 
  ePPT_Explosion, 
  ePPT_Smoke, 
  ePPT_Bubble, 
  ePPT_Rain, 
  ePPT_Fire, 
  ePPT_Snow
};

#define ePPT_Last ePPT_Snow

ParticleDefinition* [] GetParticleDefinitionsArrayByType(PresetParticleType type, int count)
{
  ParticleDefinition* definitions[] = new ParticleDefinition[count];
  int i;
  switch(type) {
    case ePPT_Fireworks:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetFireworksParticle();
      }
    break;
    case ePPT_Sparkle:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetSparkleParticle();
      }
    break;
    case ePPT_Explosion:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetExplosionParticle();
      }
    break;
    case ePPT_Smoke:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetSmokeParticle();
      }
    break;
    case ePPT_Bubble:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetBubbleParticle();
      }
    break;
    case ePPT_Rain:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetRainParticle();
      }
    break;
    case ePPT_Fire:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetFireParticle();
      }
    break;
    case ePPT_Snow:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetSnowParticle();
      }
    break;
  }  
  return definitions;
}

String GetTypeName(PresetParticleType type) {  
  switch(type) {
    case ePPT_Fireworks:
      return "Fireworks";
    case ePPT_Sparkle:
      return "Sparkle";
    case ePPT_Explosion:
      return "Explosion";
    case ePPT_Smoke:
      return "Smoke";
    case ePPT_Bubble:
      return "Bubble";
    case ePPT_Rain:
      return "Rain";
    case ePPT_Fire:
      return "Fire";
    case ePPT_Snow:
      return "Snow";
    default:
      return "Unknown";
  }  
}

void SetEmitterToType(PresetParticleType type)
{
  int definitions_count = 2048;
  ParticleDefinition* definitions[] = GetParticleDefinitionsArrayByType(type, definitions_count);
  GPE.SetParticleDefinitions(definitions, definitions_count);
  lbl_particle_selected.Text = GetTypeName(type);
}

int particle_type;
void on_call (int value)
{
  if(value == 1) {
    particle_type++;
    if(particle_type> ePPT_Last) {
      particle_type = 1;
    }
  }
  SetEmitterToType(particle_type);
}

function room_Load()
{  
  SetEmitterToType(ePPT_Fireworks);
}

void on_mouse_click(MouseButton button)
{
  int mx = mouse.x;
  int my = mouse.y;
  GPE.SetPosition(mx, my);
  GPE.Emit();
}
[close]

OK, so I started to play with this thing by sheer poking around, and there is a lot that is unfinished, but I kinda was trying to think on what does Particle management system are expected to have. I used some lerp there that has both initial and final state so one can see this could easily put on top the curves used by the tween module, but what else could be done here?

I want to keep at minimal Overlay manipulation. Other thing is I kind have no idea in a way that would be nice to actually setup things, I did a weird way that you have a particle definition, then you make an array of those and pass to the emitter, and then the emitter select at random from the definitions, the idea here is to avoid to have complex random functions per attribute, but I probably still need to have some modifier aspect to each attribute.

Anyway, just wanted to kick around the idea of a particle module and see ideas on this.

Edit: I had an idea that the emitter could have types like point or rectangle (lacking of other ideas here) and if it's a rectangle it would spawn a particle randomly in a point inside the rectangle. My idea is that perhaps doing that I could make things like rain and other ideas. Edit2: I actually can hack around this using the offsetX and offsetY in the particle itself!

Other idea is besides having the Emit functionality, to also have a RepeatedlyEmit that if set would emit a set number of times in the Emitter Update call.

eri0o

#1
I did an improvement in the Sparkle particle and added some small physics, so you can set some ground where the particle may hit and either "die" or bounce. I am unsure yet if the particle should get life go to 0 if it's bounce is not set or just stay there and let it's life end. I may instead replace ground hit with an enum and then enable one of the three for selection. But I also prefer to simplify if possible to keep the update loop tight.

I kinda want to experiment with ideas and later simplify things.

Edit:ok, decided to play a bit more and made an improvement and added a new ContinuousEmitter! It can be run with ContinuousUpdate instead of calling Update on rep exec. I made the GlobalParticleEmitter be a ContinuousEmitter instead.



particles.ash
Spoiler
Code: ags
// new module header

managed struct ParticleDefinition {
    int offsetX; // Offset from the emitter position
    int offsetY; // Offset from the emitter position
    int life;    // Lifetime of the particle
    int vx;      // mili Velocity in x direction
    int vy;      // mili Velocity in y direction
    int gravity; // mili Gravity effect on the particle
    int initialTransparency; // Initial transparency
    int finalTransparency; // Final transparency
    int initialWidth; // Initial width
    int finalWidth; // Final width
    int initialHeight; // Initial height
    int finalHeight; // Final height
    bool groundHitBounces;
    int groundY;
    int groundX;
    int groundWidth;
};

managed struct Particle {
    float x;
    float y;
    int life;
    int initialLife;
    int overlayIndex; // This refers to the overlay in the overlay pool
    float vx; // x velocity
    float vy; // y velocity
    float gravity; // this is vertical acceleration downwards
    int transparency;
    int width;
    int height;
    int initialTransparency; 
    int finalTransparency; 
    int initialWidth; 
    int finalWidth; 
    int initialHeight; 
    int finalHeight;
    bool bounces;
    int groundY;
    int groundX;
    int groundWidth;

    // Initialize the particle with its position, life, velocity, and transparency
    import void Init(ParticleDefinition* def, int x, int y, int overlayIndex);
    import void Update(); // Update particle position and overlay
    import bool IsAlive(); // Check if particle is still alive
};

struct Emitter {
  protected int x;
  protected int y;
  protected int particleLife;
  protected int emitParticleCount;
  protected int particleCount;
  protected int sprite; // The sprite slot to use for particles
  protected int gravity;
  protected Particle* particles[]; // Pool of particles
  protected ParticleDefinition* definitions[]; // Array of particle definitions
  protected int definitionsCount; // Count of particle definitions
  
  /// Set sprite
  import void SetSprite(int graphic);  
  /// Set emitter possible particle definitions
  import void SetParticleDefinitions(ParticleDefinition* definitions[], int definitionsCount);
  /// Update emitter position
  import void SetPosition(int x, int y);

  /// Initialize the emitter
  import void Init(int x, int y, ParticleDefinition* definitions[], int definitionsCount, int emitParticleCount = 10, int particleCount = 50, int sprite = 0, int gravity = 0);
  /// Emit a single particle
  import void EmitSingleParticle();  
  /// Emit particles
  import void Emit(); 
  /// Update all particles
  import void Update();
};

struct ContinuousEmitter extends Emitter {
  protected int emitRate;
  protected int _emitRateFrame;
  protected bool isEmitting;

  import void StartEmitting(int emitRate = 11);
  import void StopEmitting();
  import void UpdateContinuous();
};

/// Global Particle Emitter
import ContinuousEmitter GPE;
[close]

particles.asc
Spoiler
Code: ags
// new module script

#define MAX_OVERLAYS 512
Overlay* overlayPool[MAX_OVERLAYS];
bool overlayUsed[MAX_OVERLAYS];
int lastUsed;

#define MAX_LERP 1024

// lerp from percent 0 to 1024
int _Lerp(int start, int end, int percent) {
  if (percent < 0) percent = 0;
  if (percent > MAX_LERP) percent = MAX_LERP;

  // Calculate the interpolated value
  return start + ((end - start) * percent) / MAX_LERP;
}

int InvalidateOverlay(int index)
{
  if(overlayPool[index] != null)
    overlayPool[index].Remove();
  overlayPool[index] = null; // Clear the reference to the overlay
  overlayUsed[index] = false; // Mark the overlay slot as free
  return -1;
}

// Find an available overlay slot in the pool
function GetAvailableOverlayIndex() {
  for (int i = lastUsed; i < MAX_OVERLAYS; i++) {
    if (!overlayUsed[i]) {
      InvalidateOverlay(i);
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  for (int i = 0; (i < lastUsed) && (i < MAX_OVERLAYS); i++) {
    if (!overlayUsed[i]) {
      InvalidateOverlay(i);
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  return -1; // No available overlay
}

void UpdateOverlayFromParticle(Overlay* ovr, Particle* p)
{
  ovr.X = FloatToInt(p.x);
  ovr.Y = FloatToInt(p.y);
  ovr.Transparency = p.transparency;
  ovr.Width = p.width;
  ovr.Height = p.height;
}

void Particle::Init(ParticleDefinition* def, int x, int y,  int overlayIndex) {
  if(this.overlayIndex >= 0 && this.overlayIndex < MAX_OVERLAYS) {
    InvalidateOverlay(this.overlayIndex);
  }

  this.x = IntToFloat(x + def.offsetX); // Offset based on emitter
  this.y = IntToFloat(y + def.offsetY); // Offset based on emitter
  this.life = def.life;
  this.vx = IntToFloat(def.vx)/1000.0;
  this.vy = IntToFloat(def.vy)/1000.0;
  this.gravity = IntToFloat(def.gravity)/1000.0;
  this.transparency = def.initialTransparency;
  this.initialTransparency = def.initialTransparency;
  this.finalTransparency = def.finalTransparency;
  this.width = def.initialWidth;
  this.initialWidth = def.initialWidth;
  this.finalWidth = def.finalWidth;
  this.height = def.initialHeight;
  this.initialHeight = def.initialHeight;
  this.finalHeight = def.finalHeight;
  this.overlayIndex = overlayIndex;
  this.initialLife = def.life; // Store initial life for transitions
  this.bounces = def.groundHitBounces;
  this.groundY = def.groundY;
  this.groundX = def.groundX;
  this.groundWidth = def.groundWidth;
  if(this.groundY > 0) {
    if(this.groundWidth <= 0 ) {
      this.groundWidth = 8192;
      this.groundX = -1024;
    }
  } else {
    this.groundY = 0;
    this.groundX = 0;
    this.groundWidth = 0;
  }

  if (overlayIndex >= 0 && overlayPool[overlayIndex] != null) {
    UpdateOverlayFromParticle(overlayPool[overlayIndex], this);
  }
}

bool Particle::IsAlive() {
  return (this.life > 0);
}

// Update the particle state and sync with overlay
void Particle::Update() {
  if (this.IsAlive()) {
    this.x += this.vx;
    this.y += this.vy;
    this.vy += this.gravity; // Apply gravity
    this.life--;
    int px = FloatToInt(this.x);
    int py = FloatToInt(this.y);

    // Calculate the scaling and transparency transitions based on life
    int lifeRatio =  MAX_LERP - ((this.life * MAX_LERP) / this.initialLife); // 0 to 1024
    this.transparency = _Lerp(this.initialTransparency, this.finalTransparency, lifeRatio);
    this.width = _Lerp(this.initialWidth, this.finalWidth, lifeRatio);
    this.height = _Lerp(this.initialHeight, this.finalHeight, lifeRatio);

    if (this.overlayIndex >= 0 && overlayPool[this.overlayIndex] != null) {
      UpdateOverlayFromParticle(overlayPool[this.overlayIndex], this);
    }    
    
    if ((py >= this.groundY) && (px >= this.groundX) && (px <= (this.groundX + this.groundWidth))) {
      if (this.bounces) {
        this.vy = -this.vy * 0.7; // Invert velocity, reduce it to simulate energy loss
      } else {
        this.life = 0; // Mark particle as dead
      }
    }
  } else {
    // Remove overlay if life is over
    if (this.overlayIndex >= 0) {
      this.overlayIndex = InvalidateOverlay(this.overlayIndex); // Invalidate the overlay index
    }
  }
}

void Emitter::SetPosition(int x, int y)
{
  this.x = x;
  this.y = y;
}

void Emitter::SetSprite(int graphic)
{
  this.sprite = graphic;
}

void Emitter::SetParticleDefinitions(ParticleDefinition* definitions[], int definitionsCount)
{
  this.definitions = definitions;
  this.definitionsCount = definitionsCount;
}

// Initialize the emitter with position, particle definitions, and specific parameters
void Emitter::Init(int x, int y, ParticleDefinition* definitions[], int definitionsCount, int emitParticleCount, int particleCount, int sprite, int gravity) {
  this.SetPosition(x, y);
  this.particleCount = particleCount;
  this.SetSprite(sprite);
  this.gravity = gravity;
  this.SetParticleDefinitions(definitions, definitionsCount);
  this.emitParticleCount = emitParticleCount;
  this.particles = new Particle[particleCount];
  for (int i = 0; i < particleCount; i++) {
    this.particles[i] = new Particle;
  }
}

// Emit a single particle from the emitter
void Emitter::EmitSingleParticle() {
  for (int i = 0; i < this.particleCount; i++) {
    if (this.particles[i].IsAlive())
    {
      continue;
    }
    
    // Reuse dead particle if it's not alive anymore
    int overlayIndex = GetAvailableOverlayIndex();
    if (overlayIndex >= 0) {
      Overlay* ovr = Overlay.CreateGraphical(this.x, this.y, this.sprite);
      overlayPool[overlayIndex] = ovr;

      // Randomly select a particle definition from the available definitions
      int defIndex = 0;
      if(this.definitionsCount > 0) {
        defIndex = Random(this.definitionsCount-1);
      }
      ParticleDefinition* def = this.definitions[defIndex];

      this.particles[i].Init(def, this.x, this.y, overlayIndex);
    }
    return;
  }
}

// Emit particles from the emitter
void Emitter::Emit() {
  for (int i = 0; i < this.emitParticleCount; i++) {
    this.EmitSingleParticle();
  }
}

// Update all particles
void Emitter::Update() {
  for (int i = 0; i < this.particleCount; i++) {
    if (this.particles[i].IsAlive()) {
      this.particles[i].Update();
    } else if (this.particles[i].overlayIndex >= 0) {
      // Ensure overlays are freed for dead particles
      this.particles[i].overlayIndex = InvalidateOverlay(this.particles[i].overlayIndex); // Invalidate the overlay index
    }
  }
}

void ContinuousEmitter::StartEmitting(int emitRate)
{
  if(emitRate < 4) emitRate = 4;
  this.emitRate = emitRate;
  this.isEmitting = true;
}

void ContinuousEmitter::StopEmitting()
{
  this.isEmitting = false; 
}

void ContinuousEmitter::UpdateContinuous()
{
  this.Update();
  if(this.isEmitting) {
    this._emitRateFrame--;
    
    if(this._emitRateFrame <= 0) {
      this._emitRateFrame = this.emitRate;
      this.Emit();
    }
  } 
}
  
ContinuousEmitter GPE;
export GPE;

void game_start()
{
  ParticleDefinition* d[];
  GPE.Init(Screen.Width/2, Screen.Height/2, d, 0, MAX_OVERLAYS/14, MAX_OVERLAYS, 0, 0);
}

void repeatedly_execute_always()
{
  GPE.UpdateContinuous();
}
[close]

room1.asc
Spoiler
Code: ags
// room script file

//Emitter emt;

function hGlowingOrb_Look(Hotspot *thisHotspot, CursorMode mode)
{
  player.Say("It is the second best glowing orb that I've seen today.");
}

ParticleDefinition* GetFireworksParticle()
{
  ParticleDefinition* fireworksParticle = new ParticleDefinition;
  fireworksParticle.life = 40;
  fireworksParticle.vx = Random(4000) - 2000; // Random outward velocity
  fireworksParticle.vy = Random(4000) - 2000;
  fireworksParticle.gravity = 0; // No gravity
  fireworksParticle.initialTransparency = 0;
  fireworksParticle.finalTransparency = 100;
  fireworksParticle.initialWidth = 2;
  fireworksParticle.finalWidth = 20; // Expanding outward
  fireworksParticle.initialHeight = 2;
  fireworksParticle.finalHeight = 20;
  return fireworksParticle;
}

ParticleDefinition* GetSparkleParticle()
{
  ParticleDefinition* sparkleParticle = new ParticleDefinition;
  sparkleParticle.life = 50;
  sparkleParticle.vx = Random(3000) - 1000;
  sparkleParticle.vy = Random(3000) - 1000;
  sparkleParticle.initialTransparency = 0;
  sparkleParticle.finalTransparency = 100;
  sparkleParticle.initialWidth = 3;
  sparkleParticle.finalWidth = 8;
  sparkleParticle.initialHeight = 3;
  sparkleParticle.finalHeight = 8;
  sparkleParticle.gravity = 100;
  sparkleParticle.groundY = 154;
  sparkleParticle.groundHitBounces = true;
  return sparkleParticle;
}

ParticleDefinition* GetExplosionParticle()
{
  ParticleDefinition* explosionParticle = new ParticleDefinition;
  explosionParticle.life = 30;
  explosionParticle.vx = Random(6000) - 3000;
  explosionParticle.vy = Random(6000) - 3000;
  explosionParticle.gravity =  -1000;
  explosionParticle.initialTransparency = 15;
  explosionParticle.finalTransparency = 100;
  explosionParticle.initialWidth = 15;
  explosionParticle.finalWidth = 30;
  explosionParticle.initialHeight = 15;
  explosionParticle.finalHeight = 30;
  return explosionParticle;
}

ParticleDefinition* GetSmokeParticle()
{
  ParticleDefinition* smokeParticle = new ParticleDefinition;
  smokeParticle.life = 40+Random(14);
  smokeParticle.vy = -1000-Random(1000);
  smokeParticle.initialTransparency = 0;
  smokeParticle.finalTransparency = 100;
  smokeParticle.initialWidth = 10+Random(2);
  smokeParticle.finalWidth = 20+Random(2);
  smokeParticle.initialHeight = 20+Random(2);
  smokeParticle.finalHeight = 10+Random(2);
  return smokeParticle;
}

ParticleDefinition* GetBubbleParticle()
{
  ParticleDefinition* bubbleParticle = new ParticleDefinition;
  bubbleParticle.life = 60;
  bubbleParticle.vx = Random(500) - 250; // Small horizontal drift
  bubbleParticle.vy = -1000 - Random(500); // Rising upwards
  bubbleParticle.gravity = -200; // Rising effect
  bubbleParticle.initialTransparency = 30;
  bubbleParticle.finalTransparency = 100;
  bubbleParticle.initialWidth = 5;
  bubbleParticle.finalWidth = 15; // Expands as it rises
  bubbleParticle.initialHeight = 5;
  bubbleParticle.finalHeight = 15;
  return bubbleParticle;
}

ParticleDefinition* GetRainParticle()
{
  ParticleDefinition* rainParticle = new ParticleDefinition;
  rainParticle.offsetX = Random(Screen.Width) - (Screen.Width/2);
  rainParticle.offsetY = -Random(30);
  rainParticle.life = 50;
  rainParticle.vx = Random(500) - 250; // Slight horizontal movement
  rainParticle.vy = 3000; // Falling down quickly
  rainParticle.gravity = 180; // Light gravity effect
  rainParticle.initialTransparency = 30;
  rainParticle.finalTransparency = 80;
  rainParticle.initialWidth = 2;
  rainParticle.finalWidth = 2;
  rainParticle.initialHeight = 10;
  rainParticle.finalHeight = 15; // Lengthening as it falls
  return rainParticle;
}

ParticleDefinition* GetFireParticle()
{
  ParticleDefinition* fireParticle = new ParticleDefinition;
  fireParticle.life = 35;
  fireParticle.vx = Random(1000) - 500; // Small horizontal variance
  fireParticle.vy = -1200 - Random(500); // Rising upward
  fireParticle.gravity = -50; // Slow upward pull
  fireParticle.initialTransparency = 50;
  fireParticle.finalTransparency = 100; // Disappears as it rises
  fireParticle.initialWidth = 10;
  fireParticle.finalWidth = 20; // Expands as it rises
  fireParticle.initialHeight = 10;
  fireParticle.finalHeight = 15;
  return fireParticle;
}

ParticleDefinition* GetSnowParticle()
{
  ParticleDefinition* snowParticle = new ParticleDefinition;
  snowParticle.offsetX = Random(Screen.Width) - (Screen.Width/2);
  snowParticle.offsetY = -Random(30);
  snowParticle.life = 160;
  snowParticle.vx = Random(300) - 150; // Slight horizontal drift
  snowParticle.vy = Random(300) + 220; // Slow downward movement
  snowParticle.gravity = 10; // Minimal gravity effect
  snowParticle.initialTransparency = 50;
  snowParticle.finalTransparency = 75;
  snowParticle.initialWidth = 4;
  snowParticle.finalWidth = 6; // Slight expansion as it falls
  snowParticle.initialHeight = 4;
  snowParticle.finalHeight = 6;
  return snowParticle;
}

enum PresetParticleType {
  ePPT_Fireworks, 
  ePPT_Sparkle, 
  ePPT_Explosion, 
  ePPT_Smoke, 
  ePPT_Bubble, 
  ePPT_Rain, 
  ePPT_Fire, 
  ePPT_Snow
};

#define ePPT_Last ePPT_Snow

ParticleDefinition* [] GetParticleDefinitionsArrayByType(PresetParticleType type, int count)
{
  ParticleDefinition* definitions[] = new ParticleDefinition[count];
  int i;
  switch(type) {
    case ePPT_Fireworks:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetFireworksParticle();
      }
    break;
    case ePPT_Sparkle:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetSparkleParticle();
      }
    break;
    case ePPT_Explosion:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetExplosionParticle();
      }
    break;
    case ePPT_Smoke:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetSmokeParticle();
      }
    break;
    case ePPT_Bubble:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetBubbleParticle();
      }
    break;
    case ePPT_Rain:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetRainParticle();
      }
    break;
    case ePPT_Fire:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetFireParticle();
      }
    break;
    case ePPT_Snow:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetSnowParticle();
      }
    break;
  }  
  return definitions;
}

String GetTypeName(PresetParticleType type) {  
  switch(type) {
    case ePPT_Fireworks:
      return "Fireworks";
    case ePPT_Sparkle:
      return "Sparkle";
    case ePPT_Explosion:
      return "Explosion";
    case ePPT_Smoke:
      return "Smoke";
    case ePPT_Bubble:
      return "Bubble";
    case ePPT_Rain:
      return "Rain";
    case ePPT_Fire:
      return "Fire";
    case ePPT_Snow:
      return "Snow";
    default:
      return "Unknown";
  }  
}

void SetEmitterToType(PresetParticleType type)
{
  int definitions_count = 2048;
  ParticleDefinition* definitions[] = GetParticleDefinitionsArrayByType(type, definitions_count);
  GPE.SetParticleDefinitions(definitions, definitions_count);
  lbl_particle_selected.Text = GetTypeName(type);
}

int particle_type;
void on_call (int value)
{
  if(value == 1) {
    particle_type++;
    if(particle_type> ePPT_Last) {
      particle_type = 1;
    }
  }
  SetEmitterToType(particle_type);
  if(particle_type == ePPT_Rain || particle_type == ePPT_Snow) {
    GPE.SetPosition(Screen.Width/2, 0);
    GPE.StartEmitting();
  } else {
    GPE.StopEmitting();
  }
}

function room_Load()
{  
  SetEmitterToType(ePPT_Fireworks);
}

void on_mouse_click(MouseButton button)
{
  if(particle_type == ePPT_Rain || particle_type == ePPT_Snow)
    return;
  GPE.SetPosition(mouse.x, mouse.y);
  GPE.Emit();
}

int mb_press;
function room_RepExec()
{
  if(particle_type == ePPT_Rain || particle_type == ePPT_Snow)
    return;
    
  if(mouse.IsButtonDown(eMouseLeft))
    mb_press++;
  else
    mb_press = 0;
  
  if(mb_press > 10) {
    GPE.SetPosition(mouse.x, mouse.y);
    GPE.StartEmitting(5);
  } else {
    GPE.StopEmitting();
  }
}
[close]

ThreeOhFour

Nice! I think your examples are great. I've been starting to play around with making something similar for myself too. I posted a quick example here.

Some stuff that I use a lot in making VFX that might be worth thinking about:

-particle rotation
-tint of each particle over time
-effect angle (so if you want a stream of water to shoot upwards, or at a 45 degree angle, etc
-variation properties for things like life, spin, speed, size, etc

eri0o

#3
Tint for something like fire to have the yellow to reddish transition is not hard to add, the only issue is Overlay tint is ags4 only. I think maybe it's not that much to backport to ags3 but from memory I don't remember much.

Rotation of particles is ags4 too - and It's a lot of stuff so it will be for sure ags4 only.

One hack that I can add to support these fakely is to be able to set the sprite of each particle and also being able to set a "final sprite", so it would increment the sprite number until the final one, which would enable to make the sprite change along with it's life. This would require a bit of care of the person using the module but it could enable some fun effects.

I think these aren't hard to add, so I will add these things to a list.

I also want to add some sort of collision check so you could use the particles for something like coins that drops from a monster you kill and then you would be able to collect - perhaps would need to have some gravity well support too if you would just be able to attract these.

Overall the balance is mostly in trying things and see how fast things can run. I will probably remove the floating points I am using internally and rewrite using only int to improve performance slightly.

Edit: ah the video wasn't working before, I watched it now, that is pretty cool, is this in ags4 with blending modes? I can't figure other approach.

One thing that one can do with these that I had the idea just now is to record mouse positions and throw them in a script array for playback if you want to make some "forest spirit" that points to things and want to keep the movement organical but don't want to have to note down all positions manually to enable this.

ThreeOhFour

#4
Quote from: eri0o on Mon 14/10/2024 11:12:26Rotation of particles is ags4 too - and It's a lot of stuff so it will be forOne hack that I can add to support these fakely is to be able to set the sprite of each particle and also being able to set a "final sprite", so it would increment the sprite number until the final one, which would enable to make the sprite change along with it's life. This would require a bit of care of the person using the module but it could enable some fun effects.

Yes I've done this too in the past and it works reasonably well. Especially if you treat it as modular so that it can loop back to the start. Then the user can just set increment or decrement to change the direction of rotation, and step value to change the speed.

Yes, I used blending modes. One of many good additions to AGS4!

I'm curious about performance too. I've no idea how performant 100 overlays running at once would be but I'm assuming in low resolution it's less of an issue.

QuoteOne thing that one can do with these that I had the idea just now is to record mouse positions and throw them in a script array for playback if you want to make some "forest spirit" that points to things and want to keep the movement organical but don't want to have to note down all positions manually to enable this.
I'm doing this myself and am using custom properties rather than an array to store this so it's easier to tell which coordinates link to which hotspot. Each hotspot has an X and y value and then a property which checks if this should be pointed to or not, and then the effect measures the distance and angle and points to the closest correct one. I'd be curious to see your vision for more organic movement!

eri0o

This example I made has a cap of 512 Overlays by 512 Particles, but I believe it should be able to run up to 2048 without many issues - I made a sprite scaler 3D like engine using overlays and managed to get to 4096 on screen things going before slow down.

Regarding the resolution, you pay for it twice, once to read from the sprite file to the sprite cache and another time to read the sprite from the sprite cache to the texture cache. So once these are cached they are reused, and the resolution doesn't matter much, as long they all fit in memory.

For now I am mostly about playing with the physics of the particle, I think I will look into the visuals of the individual particles and only latter look into the emitter (for things like movement of it), but for now I am "outsourcing" their movement at the discretion of the user  (laugh)

ThreeOhFour


eri0o

particles.ash
Spoiler
Code: ags
// new module header

managed struct ParticleDefinition {
  int sprite;
  int offsetX; // Offset from the emitter position
  int offsetY; // Offset from the emitter position
  int life;    // Lifetime of the particle
  int vx;      // mili Velocity in x direction
  int vy;      // mili Velocity in y direction
  int gravity; // mili Gravity effect on the particle
  int initialTransparency; // Initial transparency
  int finalTransparency; // Final transparency
  int initialWidth; // Initial width
  int finalWidth; // Final width
  int initialHeight; // Initial height
  int finalHeight; // Final height
  bool groundHitBounces;
  int groundY;
};

managed struct Particle {
  int sprite;
  int x;
  int y;
  int life;
  int initialLife;
  int overlayIndex; // This refers to the overlay in the overlay pool
  int vx; // x velocity
  int vy; // y velocity
  int gravity; // this is vertical acceleration downwards
  int transparency;
  int width;
  int height;
  int initialTransparency;
  int deltaTransparency;
  int initialWidth;
  int deltaWidth;
  int initialHeight;
  int deltaHeight;
  bool bounces;
  int groundY;

  // Initialize the particle with its position, life, velocity, and transparency
  import void Init(ParticleDefinition* def, int x, int y, int overlayIndex);
  import void Update(); // Update particle position and overlay
  import bool IsAlive(); // Check if particle is still alive
};

struct Emitter {
  protected int x;
  protected int y;
  protected int emitParticleCount;
  protected int particleCount;
  protected Particle* particles[]; // Pool of particles
  protected ParticleDefinition* definitions[]; // Array of particle definitions
  protected int definitionsCount; // Count of particle definitions
  protected int lastEmittedParticle;
  
  /// Set emitter possible particle definitions
  import void SetParticleDefinitions(ParticleDefinition* definitions[], int definitionsCount);
  /// Update emitter position
  import void SetPosition(int x, int y);

  /// Initialize the emitter
  import void Init(int x, int y, ParticleDefinition* definitions[], int definitionsCount, int emitParticleCount = 10, int particleCount = 50);
  /// Emit a specific particle
  import protected bool EmitParticleIndex(int i);
  /// Emit a single particle
  import protected bool EmitSingleParticle();
  /// Emit particles
  import void Emit();
  /// Update all particles
  import void Update();
};

struct ContinuousEmitter extends Emitter {
  protected int emitRate;
  protected int _emitRateFrame;
  protected bool isEmitting;

  import void StartEmitting(int emitRate = 11);
  import void StopEmitting();
  import void UpdateContinuous();
};

/// Global Particle Emitter
import ContinuousEmitter GPE;
[close]

particles.asc
Spoiler
Code: ags
// new module script

#define MAX_OVERLAYS 2048
Overlay* overlayPool[MAX_OVERLAYS];
bool overlayUsed[MAX_OVERLAYS];
int lastUsed;

#define MAX_LERP 1024
#define INT_FRAC 1024

// lerp from percent 0 to 1024
int _Lerp(int start, int end, int percent) {
  if (percent < 0) percent = 0;
  if (percent > MAX_LERP) percent = MAX_LERP;
  return start + ((end - start) * percent) / MAX_LERP;
}

int InvalidateOverlay(int index)
{
  if(overlayPool[index] != null)
  {
    overlayPool[index].Transparency = 100;
  }
  overlayUsed[index] = false; // Mark the overlay slot as free
  return -1;
}

// Find an available overlay slot in the pool
function GetAvailableOverlayIndex() {
  for (int i = lastUsed; i < MAX_OVERLAYS; i++) {
    if (!overlayUsed[i]) {
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  for (int i = 0; i < lastUsed; i++) {
    if (!overlayUsed[i]) {
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  return -1; // No available overlay
}

void UpdateOverlayFromParticle(Overlay* ovr, Particle* p)
{
  ovr.Transparency = p.transparency;
  ovr.Graphic = p.sprite;
#ifdef SCRIPT_API_v362
  ovr.SetPosition(p.x/INT_FRAC, p.y/INT_FRAC, p.width, p.height);
#else
  ovr.X = p.x/INT_FRAC;
  ovr.Y = p.y/INT_FRAC;
  ovr.Width = p.width;
  ovr.Height = p.height;
#endif
}

void Particle::Init(ParticleDefinition* def, int x, int y,  int overlayIndex) {
  if(this.overlayIndex >= 0 && this.overlayIndex < MAX_OVERLAYS) {
    InvalidateOverlay(this.overlayIndex);
  }

  this.x = INT_FRAC*(x + def.offsetX); // Offset based on emitter
  this.y = INT_FRAC*(y + def.offsetY); // Offset based on emitter
  this.life = def.life;
  this.vx = (def.vx*INT_FRAC)/1000;
  this.vy = (def.vy*INT_FRAC)/1000;
  this.gravity = (def.gravity*INT_FRAC)/1000;
  this.transparency = def.initialTransparency;
  this.initialTransparency = def.initialTransparency;
  this.deltaTransparency = def.finalTransparency - def.initialTransparency;
  this.width = def.initialWidth;
  this.initialWidth = def.initialWidth;
  this.deltaWidth = def.finalWidth - def.initialWidth;
  this.height = def.initialHeight;
  this.initialHeight = def.initialHeight;
  this.deltaHeight = def.finalHeight - def.initialHeight;
  this.overlayIndex = overlayIndex;
  this.initialLife = def.life; // Store initial life for transitions
  this.bounces = def.groundHitBounces;
  this.groundY = def.groundY*INT_FRAC;
  if(this.groundY <= 0) {
    this.groundY = 16777216; // a big number so it is not reached
  }

  if (overlayIndex >= 0 && overlayPool[overlayIndex] != null) {
    UpdateOverlayFromParticle(overlayPool[overlayIndex], this);
  }
}

bool Particle::IsAlive() {
  return (this.life > 0);
}

// Update the particle state and sync with overlay
void Particle::Update() {
  // alive check is done before calling this function
  this.x += this.vx;
  this.y += this.vy;
  this.vy += this.gravity; // Apply gravity
  int px = this.x/INT_FRAC;
  int py = this.y/INT_FRAC;

  // Calculate the scaling and transparency transitions based on life
  int percent =  MAX_LERP - ((this.life * MAX_LERP) / this.initialLife); // 0 to 1024  
  this.transparency = this.initialTransparency + ((this.deltaTransparency) * percent) / MAX_LERP;  
  this.width = this.initialWidth + ((this.deltaWidth) * percent) / MAX_LERP;
  this.height = this.initialHeight + ((this.deltaHeight) * percent) / MAX_LERP;
  
  int oidx = this.overlayIndex;
  if (oidx >= 0 && overlayPool[oidx] != null) {
    // UpdateOverlayFromParticle(overlayPool[this.overlayIndex], this);
    Overlay* ovr = overlayPool[oidx];
    ovr.Transparency = this.transparency;
    ovr.Graphic = this.sprite;
#ifdef SCRIPT_API_v362
    ovr.SetPosition(px, py, this.width, this.height);
#else
    ovr.X = px;
    ovr.Y = py;
    ovr.Width = this.width;
    ovr.Height = this.height;
#endif
  }
  
  this.life--;
  
  if (this.y >= this.groundY) {
    if (this.bounces) {
      this.vy = -(this.vy * 700)/INT_FRAC; // Invert velocity, reduce it to simulate energy loss
    } else {
      this.life = 0; // Mark particle as dead (cheaper than other things...)
    }
  }
}

void Emitter::SetPosition(int x, int y)
{
  this.x = x;
  this.y = y;
}

void Emitter::SetParticleDefinitions(ParticleDefinition* definitions[], int definitionsCount)
{
  this.definitions = definitions;
  this.definitionsCount = definitionsCount;
}

// Initialize the emitter with position, particle definitions, and specific parameters
void Emitter::Init(int x, int y, ParticleDefinition* definitions[], int definitionsCount, int emitParticleCount, int particleCount) {
  this.SetPosition(x, y);
  this.particleCount = particleCount;
  this.SetParticleDefinitions(definitions, definitionsCount);
  this.emitParticleCount = emitParticleCount;
  this.particles = new Particle[particleCount];
  for (int i = 0; i < particleCount; i++) {
    this.particles[i] = new Particle;
  }
}

protected bool Emitter::EmitParticleIndex(int i)
{
  if(this.particles[i].IsAlive())
    return false;

  this.lastEmittedParticle = i;
  
  // Reuse dead particle if it's not alive anymore
  int overlayIndex = GetAvailableOverlayIndex();
  if (overlayIndex >= 0) {      
    // Randomly select a particle definition from the available definitions
    int defIndex = 0;
    if(this.definitionsCount > 0) {
      defIndex = Random(this.definitionsCount-1);
    }
    ParticleDefinition* def = this.definitions[defIndex];
    
    if(overlayPool[overlayIndex] == null) {
      Overlay* ovr = Overlay.CreateGraphical(this.x, this.y, def.sprite);
      overlayPool[overlayIndex] = ovr;
    }
    this.particles[i].Init(def, this.x, this.y, overlayIndex);
  }
  return true;
}

// Emit a single particle from the emitter
protected bool Emitter::EmitSingleParticle() {
  //System.Log(eLogInfo, ">>begin sp");
  int i = (this.lastEmittedParticle + 1) % this.particleCount;
  
  if(this.EmitParticleIndex(i))
    return false;
  
  //System.Log(eLogInfo, "loop1 sp");
  int loop_at = i;
  for (; i < this.particleCount; i++) {
    if (!this.particles[i].IsAlive())
    {
      this.EmitParticleIndex(i);
      return false;
    }
  }
  
  //System.Log(eLogInfo, "loop2 sp");
  for (i=0; i < loop_at; i++) {
    if (!this.particles[i].IsAlive())
    {
      this.EmitParticleIndex(i);
      return false;
    }
  }
  
  // FIX-ME: if we get here we need to do some sort of cooldown 
  // the issue is we are emitting too many particles per total particle count
  // along with particles that have a too big life
  return true; // indicates something is wrong
}

// Emit particles from the emitter
void Emitter::Emit() {
  for (int i = 0; i < this.emitParticleCount; i++) {
    if(this.EmitSingleParticle())
      return;
  }
}

// Update all particles
void Emitter::Update() {
  for (int i = 0; i < this.particleCount; i++) {
    if (this.particles[i].life > 0) {
      this.particles[i].Update();
    } 
    else if (this.particles[i].overlayIndex >= 0) 
    {
      this.particles[i].overlayIndex = InvalidateOverlay(this.particles[i].overlayIndex);
    }
  }
}

void ContinuousEmitter::StartEmitting(int emitRate)
{
  if(emitRate < 4) emitRate = 4;
  this.emitRate = emitRate;
  this.isEmitting = true;
}

void ContinuousEmitter::StopEmitting()
{
  this.isEmitting = false; 
}

void ContinuousEmitter::UpdateContinuous()
{
  this.Update();
  if(this.isEmitting) {
    this._emitRateFrame--;    
    if(this._emitRateFrame <= 0) {
      this._emitRateFrame = this.emitRate;
      this.Emit();
    }
  } 
}
  
ContinuousEmitter GPE;
export GPE;

void game_start()
{
  ParticleDefinition* d[];
  int max_particles = MAX_OVERLAYS;
  GPE.Init(Screen.Width/2, Screen.Height/2, d, 0, max_particles/16, max_particles);
}

void repeatedly_execute_always()
{
  GPE.UpdateContinuous();
}
[close]

I haven't actually changed room1 here, but I have played with changing the module code to try to improve a bit of performance... In the above version I upped the amount of particles to 2048.


I had one idea for additional performance which was to run the "particle simulation" for what would be 5 frames and interpolate the in-between, but I didn't figure a way to do this that actually improved the perceived performance - plus ended up adding a few bugs, so I scrapped. The performance improvements I did here ended up being the most obvious in just focusing in reducing the amount of code ran. Also internally I no longer use floats anywhere - to avoid conversion between int and floats.

In the next few days I will probably do a few polishes to this and try to actually add some more visually interesting stuff here and hopefully actually put a module for release.

Spoiler
I wanted to be able to think if there was some API change to be done in Overlays in the engine that could unlock some faster way to do something, just to improve performance for particles beyond the current one, but short of throwing a few int arrays as input parameters I think I am out of ideas.
[close]

RootBound

Looks fantastic. Really great work! Would it be possible to add a splash effect where particles go up and then down, or would that need the tween module?
They/them. Here are some of my games:

eri0o

#9
Uhm, it depends, not sure on the splash meaning, if it's going up and then down, you make some upwards initial velocity set in the particle and additionally give it some "gravity" (acceleration downwards) that can win from such velocity and it should present such movement.

There's a way to fake this too using some easing specific function, for now I haven't added such thing because I am not convinced that the performance payment is worth it, I would need to extend the particle and emitter for something specifically for handling this in a way that it doesn't affect the performance of the "non-extra eased" interpolation, need to think about it.

Other possibility is having a particle emit another particle when they die. So a drop of water could fall, reach ground (particle gets life set to 0 at this point), and then emit particles. I could not think in some proper way to do this in ags3, in ags4 it's easy because I can have a managed struct hold pointers to its own type, so the particle definition could just hold an array of particle definitions, and then it would be easy to script such systems. To do it in ags3 I would need to linearize this somehow in the same array but the ergonomics are not good. I guess I may add this feature behind a version check for ags4.

Edit:

The big idea in this module is that the emitter holds an array of particle definitions, that describes the intended behavior of a particular particle and the emitter randomly samples and chooses one of these when emitting.

The idea here is that you can great a big number of definitions that may have each property created with whatever probability distribution you want at creation time and the systems is made so you don't pay for this randomness at emit time and neither require the particle definition to have many different properties to describe such randomness. It's expected that the small sample of particles that get emitted hold a similar distribution of all your particle definitions.

Edit2: did an upgrade adding sprite interpolation

Particles.ash
Spoiler
Code: ags
// new module header

managed struct ParticleDefinition {
  int sprite;
  int offsetX; // Offset from the emitter position
  int offsetY; // Offset from the emitter position
  int life;    // Lifetime of the particle
  int vx;      // mili Velocity in x direction
  int vy;      // mili Velocity in y direction
  int gravity; // mili Gravity effect on the particle
  int initialSprite;
  int finalSprite;
  int initialTransparency; // Initial transparency
  int finalTransparency; // Final transparency
  int initialWidth; // Initial width
  int finalWidth; // Final width
  int initialHeight; // Initial height
  int finalHeight; // Final height
  bool groundHitBounces;
  int groundY;
};

managed struct Particle {
  int sprite;
  int x;
  int y;
  int life;
  int initialLife;
  int overlayIndex; // This refers to the overlay in the overlay pool
  int vx; // x velocity
  int vy; // y velocity
  int gravity; // this is vertical acceleration downwards
  int transparency;
  int width;
  int height;
  int initialSprite;
  int rollSprite;
  int deltaSprite;
  int initialTransparency;
  int deltaTransparency;
  int initialWidth;
  int deltaWidth;
  int initialHeight;
  int deltaHeight;
  bool bounces;
  int groundY;

  // Initialize the particle with its position, life, velocity, and transparency
  import void Init(ParticleDefinition* def, int x, int y, int overlayIndex);
  import void Update(); // Update particle position and overlay
  import bool IsAlive(); // Check if particle is still alive
};

struct Emitter {
  protected int x;
  protected int y;
  protected int emitParticleCount;
  protected int particleCount;
  protected Particle* particles[]; // Pool of particles
  protected ParticleDefinition* definitions[]; // Array of particle definitions
  protected int definitionsCount; // Count of particle definitions
  protected int lastEmittedParticle;
  
  /// Set emitter possible particle definitions
  import void SetParticleDefinitions(ParticleDefinition* definitions[], int definitionsCount);
  /// Update emitter position
  import void SetPosition(int x, int y);

  /// Initialize the emitter
  import void Init(int x, int y, ParticleDefinition* definitions[], int definitionsCount, int emitParticleCount = 10, int particleCount = 50);
  /// Emit a specific particle
  import protected bool EmitParticleIndex(int i);
  /// Emit a single particle
  import protected bool EmitSingleParticle();
  /// Emit particles
  import void Emit();
  /// Update all particles
  import void Update();
};

struct ContinuousEmitter extends Emitter {
  protected int emitRate;
  protected int _emitRateFrame;
  protected bool isEmitting;

  import void StartEmitting(int emitRate = 11);
  import void StopEmitting();
  import void UpdateContinuous();
};

/// Global Particle Emitter
import ContinuousEmitter GPE;
[close]

Particles.asc
Spoiler
Code: ags
// new module script

#define MAX_OVERLAYS 2048
Overlay* overlayPool[MAX_OVERLAYS];
bool overlayUsed[MAX_OVERLAYS];
int lastUsed;

#define MAX_LERP 1024
#define INT_FRAC 1024

// lerp from percent 0 to 1024
int _Lerp(int start, int end, int percent) {
  if (percent < 0) percent = 0;
  if (percent > MAX_LERP) percent = MAX_LERP;
  return start + ((end - start) * percent) / MAX_LERP;
}

int InvalidateOverlay(int index)
{
  if(overlayPool[index] != null)
  {
    overlayPool[index].Transparency = 100;
  }
  overlayUsed[index] = false; // Mark the overlay slot as free
  return -1;
}

// Find an available overlay slot in the pool
function GetAvailableOverlayIndex() {
  for (int i = lastUsed; i < MAX_OVERLAYS; i++) {
    if (!overlayUsed[i]) {
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  for (int i = 0; i < lastUsed; i++) {
    if (!overlayUsed[i]) {
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  return -1; // No available overlay
}

void UpdateOverlayFromParticle(Overlay* ovr, Particle* p)
{
  ovr.Transparency = p.transparency;
  ovr.Graphic = p.sprite;
#ifdef SCRIPT_API_v362
  ovr.SetPosition(p.x/INT_FRAC, p.y/INT_FRAC, p.width, p.height);
#else
  ovr.X = p.x/INT_FRAC;
  ovr.Y = p.y/INT_FRAC;
  ovr.Width = p.width;
  ovr.Height = p.height;
#endif
}

void Particle::Init(ParticleDefinition* def, int x, int y,  int overlayIndex) {
  if(this.overlayIndex >= 0 && this.overlayIndex < MAX_OVERLAYS) {
    InvalidateOverlay(this.overlayIndex);
  }

  this.x = INT_FRAC*(x + def.offsetX); // Offset based on emitter
  this.y = INT_FRAC*(y + def.offsetY); // Offset based on emitter
  this.life = def.life;
  this.vx = (def.vx*INT_FRAC)/1000;
  this.vy = (def.vy*INT_FRAC)/1000;
  this.gravity = (def.gravity*INT_FRAC)/1000;
  
  this.transparency = def.initialTransparency;
  this.initialTransparency = def.initialTransparency;
  this.deltaTransparency = def.finalTransparency - def.initialTransparency;
  
  this.width = def.initialWidth;
  this.initialWidth = def.initialWidth;
  this.deltaWidth = def.finalWidth - def.initialWidth;
  
  this.height = def.initialHeight;
  this.initialHeight = def.initialHeight;
  this.deltaHeight = def.finalHeight - def.initialHeight;
  
  this.rollSprite = def.sprite - def.initialSprite;
  this.sprite = def.sprite;
  this.initialSprite = def.initialSprite;
  this.deltaSprite = def.finalSprite - def.initialSprite;
  
  this.overlayIndex = overlayIndex;
  this.initialLife = def.life; // Store initial life for transitions
  this.bounces = def.groundHitBounces;
  this.groundY = def.groundY*INT_FRAC;
  if(this.groundY <= 0) {
    this.groundY = 16777216; // a big number so it is not reached
  }

  if (overlayIndex >= 0 && overlayPool[overlayIndex] != null) {
    UpdateOverlayFromParticle(overlayPool[overlayIndex], this);
  }
}

bool Particle::IsAlive() {
  return (this.life > 0);
}

// Update the particle state and sync with overlay
void Particle::Update() {
  // alive check is done before calling this function
  this.x += this.vx;
  this.y += this.vy;
  this.vy += this.gravity; // Apply gravity
  int px = this.x/INT_FRAC;
  int py = this.y/INT_FRAC;

  // Calculate the scaling and transparency transitions based on life
  int percent =  MAX_LERP - ((this.life * MAX_LERP) / this.initialLife); // 0 to 1024  
  this.transparency = this.initialTransparency + ((this.deltaTransparency) * percent) / MAX_LERP;  
  this.width = this.initialWidth + ((this.deltaWidth) * percent) / MAX_LERP;
  this.height = this.initialHeight + ((this.deltaHeight) * percent) / MAX_LERP;
  if(this.deltaSprite > 0) {
    this.sprite = this.initialSprite + (this.rollSprite + ((this.deltaSprite) * percent) / MAX_LERP) % this.deltaSprite;
  }
  
  int oidx = this.overlayIndex;
  if (oidx >= 0 && overlayPool[oidx] != null) {
    // UpdateOverlayFromParticle(overlayPool[this.overlayIndex], this);
    Overlay* ovr = overlayPool[oidx];
    ovr.Transparency = this.transparency;
    ovr.Graphic = this.sprite;
#ifdef SCRIPT_API_v362
    ovr.SetPosition(px, py, this.width, this.height);
#else
    ovr.X = px;
    ovr.Y = py;
    ovr.Width = this.width;
    ovr.Height = this.height;
#endif
  }
  
  this.life--;
  
  if (this.y >= this.groundY) {
    if (this.bounces) {
      this.vy = -(this.vy * 700)/INT_FRAC; // Invert velocity, reduce it to simulate energy loss
    } else {
      this.life = 0; // Mark particle as dead (cheaper than other things...)
    }
  }
}

void Emitter::SetPosition(int x, int y)
{
  this.x = x;
  this.y = y;
}

void Emitter::SetParticleDefinitions(ParticleDefinition* definitions[], int definitionsCount)
{
  for(int i=0; i<definitionsCount; i++) {
    ParticleDefinition* def = definitions[i];
    if(def.initialSprite == 0 && def.finalSprite == 0) {
      def.initialSprite = def.sprite;
      def.finalSprite = def.sprite;
    }
  }
  
  this.definitions = definitions;
  this.definitionsCount = definitionsCount;
}

// Initialize the emitter with position, particle definitions, and specific parameters
void Emitter::Init(int x, int y, ParticleDefinition* definitions[], int definitionsCount, int emitParticleCount, int particleCount) {
  this.SetPosition(x, y);
  this.particleCount = particleCount;
  this.SetParticleDefinitions(definitions, definitionsCount);
  this.emitParticleCount = emitParticleCount;
  this.particles = new Particle[particleCount];
  for (int i = 0; i < particleCount; i++) {
    this.particles[i] = new Particle;
  }
}

protected bool Emitter::EmitParticleIndex(int i)
{
  if(this.particles[i].IsAlive())
    return false;

  this.lastEmittedParticle = i;
  
  // Reuse dead particle if it's not alive anymore
  int overlayIndex = GetAvailableOverlayIndex();
  if (overlayIndex >= 0) {      
    // Randomly select a particle definition from the available definitions
    int defIndex = 0;
    if(this.definitionsCount > 0) {
      defIndex = Random(this.definitionsCount-1);
    }
    ParticleDefinition* def = this.definitions[defIndex];
    
    if(overlayPool[overlayIndex] == null) {
      Overlay* ovr = Overlay.CreateGraphical(this.x, this.y, def.sprite);
      overlayPool[overlayIndex] = ovr;
    }
    this.particles[i].Init(def, this.x, this.y, overlayIndex);
  }
  return true;
}

// Emit a single particle from the emitter
protected bool Emitter::EmitSingleParticle() {
  //System.Log(eLogInfo, ">>begin sp");
  int i = (this.lastEmittedParticle + 1) % this.particleCount;
  
  if(this.EmitParticleIndex(i))
    return false;
  
  //System.Log(eLogInfo, "loop1 sp");
  int loop_at = i;
  for (; i < this.particleCount; i++) {
    if (!this.particles[i].IsAlive())
    {
      this.EmitParticleIndex(i);
      return false;
    }
  }
  
  //System.Log(eLogInfo, "loop2 sp");
  for (i=0; i < loop_at; i++) {
    if (!this.particles[i].IsAlive())
    {
      this.EmitParticleIndex(i);
      return false;
    }
  }
  
  // FIX-ME: if we get here we need to do some sort of cooldown 
  // the issue is we are emitting too many particles per total particle count
  // along with particles that have a too big life
  return true; // indicates something is wrong
}

// Emit particles from the emitter
void Emitter::Emit() {
  for (int i = 0; i < this.emitParticleCount; i++) {
    if(this.EmitSingleParticle())
      return;
  }
}

// Update all particles
void Emitter::Update() {
  for (int i = 0; i < this.particleCount; i++) {
    if (this.particles[i].life > 0) {
      this.particles[i].Update();
    } 
    else if (this.particles[i].overlayIndex >= 0) 
    {
      this.particles[i].overlayIndex = InvalidateOverlay(this.particles[i].overlayIndex);
    }
  }
}

void ContinuousEmitter::StartEmitting(int emitRate)
{
  if(emitRate < 4) emitRate = 4;
  this.emitRate = emitRate;
  this.isEmitting = true;
}

void ContinuousEmitter::StopEmitting()
{
  this.isEmitting = false; 
}

void ContinuousEmitter::UpdateContinuous()
{
  this.Update();
  if(this.isEmitting) {
    this._emitRateFrame--;    
    if(this._emitRateFrame <= 0) {
      this._emitRateFrame = this.emitRate;
      this.Emit();
    }
  } 
}
  
ContinuousEmitter GPE;
export GPE;

void game_start()
{
  ParticleDefinition* d[];
  int max_particles = (MAX_OVERLAYS*4)/5;
  GPE.Init(Screen.Width/2, Screen.Height/2, d, 0, max_particles/16, max_particles);
}

void repeatedly_execute_always()
{
  GPE.UpdateContinuous();
}
[close]

>>particle test web demo v4<<

The explosion particle now looks like this

Code: ags
ParticleDefinition* GetExplosionParticle()
{
  ParticleDefinition* explosionParticle = new ParticleDefinition;  
  explosionParticle.sprite = 1+ Random(60);
  explosionParticle.initialSprite = 1;
  explosionParticle.finalSprite = 61;  
  explosionParticle.life = 40;
  explosionParticle.vx = Random(3000) - 1500;
  explosionParticle.vy = Random(3000) - 1500;
  explosionParticle.gravity =  -90;
  explosionParticle.initialTransparency = 15;
  explosionParticle.finalTransparency = 100;
  explosionParticle.initialWidth = 12;
  explosionParticle.finalWidth = 50;
  explosionParticle.initialHeight = 12;
  explosionParticle.finalHeight = 50;
  return explosionParticle;
}

The sprite property of the particle definition is treated like if it was the actually starting sprite, and the initial and final are used to set the range of sprites, this is to be able to add some noise for sprite specifically to let things roll more nicely.

If a particle definition didn't set the initial and final sprite, it will instead use only the sprite set in the sprite property.

RootBound

I just meant something like this where the particles start in one place, spread out upwards, and then fall down. It would be similar to the sparkle option you have currently except they would fly up before falling, I guess. It sounds like the upward velocity plus gravity factors you mentioned would make this work.

They/them. Here are some of my games:

eri0o

#11
Ah, that should be possible already @RootBound , the way it would work is similar to the sparks but having the velocity y be more negative so it goes upwards, the sparks distribution is very simple and some particles do go upwards, but just a little, and you have to set gravity (downwards acceleration) in a way it would push down.

I want to try later to add subparticles, so it would be possible to have the splashing be triggered by the rain particle hitting the ground, but I still need to think a bit on how to do it.

The trick is always how to get the most interesting/fun/diverse effects with the least amount of code running, and a lot of care has to be put in the particle update function so it can still perform well.

Edit: ok, figured the ergonomics of gravity well require that the well be in the emitter and not in the particle itself, if I want to have the player have such gravity - think of being able to atract coin particles as you walk near them.

Edit2: also found a fix to ensure the fireworks goes outward in a way it expands as a circle

Code: ags
ParticleDefinition* GetFireworksParticle()
{
  ParticleDefinition* fireworksParticle = new ParticleDefinition;
  fireworksParticle.life = 40;
  // make the velocities circular <<-- this is the new thing!
  float r = 2000.0*Maths.Sqrt(IntToFloat(Random(8192))/8192.0);
  float theta =  2.0 * Maths.Pi*(IntToFloat(Random(8192))/8192.0);
  fireworksParticle.vx = FloatToInt(r * Maths.Cos(theta)); // Random outward velocity
  fireworksParticle.vy = FloatToInt(r * Maths.Sin(theta));
  
  fireworksParticle.gravity = 0; // No gravity
  fireworksParticle.initialTransparency = 0;
  fireworksParticle.finalTransparency = 100;
  fireworksParticle.initialWidth = 2;
  fireworksParticle.finalWidth = 20; // Expanding outward
  fireworksParticle.initialHeight = 2;
  fireworksParticle.finalHeight = 20;
  return fireworksParticle;
}

I had previously had to figure this proper way of sampling a circle in this other post here!

eri0o

#12
I had an idea to pass the overlays to be "owned" by the emitter, this would take out one for loop out because I currently need to find an available overlay from the pool everytime I try to initialize a particle, the issue though is I need the overlay at the Update loop, so the way I thought to solve that is having the particle update take one parameter with the overlay so that the emitter can pass it down.

Other idea is have an API in the emitter to retrieve a null terminated array of particles that are colliding with a specific point (going by AABB collision test).

What I can't figure it out is if the ergonomics of using particle definition is good or bad, I like it a lot but I don't know if people will have a bad time figuring it out, and I can't figure any helper functions to make it easier to generate them.

RootBound

Quote from: eri0o on Wed 16/10/2024 11:56:07What I can't figure it out is if the ergonomics of using particle definition is good or bad, I like it a lot but I don't know if people will have a bad time figuring it out, and I can't figure any helper functions to make it easier to generate them.

Do you mean reducing the number of code lines to generate each particle set? I'm wondering if the two height parameters could be called in one line, the two transparency in one differline, and so on, something like
Code: ags
FireworksParticleTransparencyGradient(0,100);
FireworksParticleHeightGradient(2,20);
Don't know if that's possible to implement, though.
They/them. Here are some of my games:

eri0o

Something like that can be an approach, but I think for now I will leave these for the user to figure it out.

Did one more addition here with "collision", added a GetParticlesCollidingPoint and GetParticlesCollidingRect, which can test the particle with is always a Rect (AABB, defined by x, y, width and height) with something else. I will probably also add a circle later too as the target to test collision with.


The collision is just an overlap test, and I made the example in the room in the ParticlePlayerCollision function, which what it does is cause the life of the particle to be reduced when in collision with the player.


header
Particles.ash
Spoiler
Code: ags
// new module header

managed struct ParticleDefinition {
  int sprite;
  int offsetX; // Offset from the emitter position
  int offsetY; // Offset from the emitter position
  int life;    // Lifetime of the particle
  int vx;      // mili Velocity in x direction
  int vy;      // mili Velocity in y direction
  int gravity; // mili Gravity effect on the particle
  int initialSprite;
  int finalSprite;
  int initialTransparency; // Initial transparency
  int finalTransparency; // Final transparency
  int initialWidth; // Initial width
  int finalWidth; // Final width
  int initialHeight; // Initial height
  int finalHeight; // Final height
  bool groundHitBounces;
  int groundY;
};

managed struct Particle {
  int sprite;
  int x;
  int y;
  int mx;
  int my;
  int life;
  int initialLife;
  int overlayIndex; // This refers to the overlay in the overlay pool
  int vx; // x velocity
  int vy; // y velocity
  int gravity; // this is vertical acceleration downwards
  int transparency;
  int width;
  int height;
  int initialSprite;
  int rollSprite;
  int deltaSprite;
  int initialTransparency;
  int deltaTransparency;
  int initialWidth;
  int deltaWidth;
  int initialHeight;
  int deltaHeight;
  bool bounces;
  int groundY;

  // Initialize the particle with its position, life, velocity, and transparency
  import void Init(ParticleDefinition* def, int x, int y, int overlayIndex);
  import void Update(); // Update particle position and overlay
  import bool IsAlive(); // Check if particle is still alive
  import bool IsCollidingPoint(int x, int y);
  import bool IsCollidingRect(int x, int y, int width, int height);
};

struct Emitter {
  protected int x;
  protected int y;
  protected int emitParticleCount;
  protected int particleCount;
  protected Particle* particles[]; // Pool of particles
  protected ParticleDefinition* definitions[]; // Array of particle definitions
  protected int definitionsCount; // Count of particle definitions
  protected int lastEmittedParticle;
  
  /// Set emitter possible particle definitions
  import void SetParticleDefinitions(ParticleDefinition* definitions[], int definitionsCount);
  /// Update emitter position
  import void SetPosition(int x, int y);
  /// Get null terminated array of particles that have collision with the point
  import Particle* [] GetParticlesCollidingPoint(int x, int y);
  
  import Particle* [] GetParticlesCollidingRect(int x, int y, int width, int height);

  /// Initialize the emitter
  import void Init(int x, int y, ParticleDefinition* definitions[], int definitionsCount, int emitParticleCount = 10, int particleCount = 50);
  /// Emit a specific particle
  import protected bool EmitParticleIndex(int i);
  /// Emit a single particle
  import protected bool EmitSingleParticle();
  /// Emit particles
  import void Emit();
  /// Update all particles
  import void Update();
};

struct ContinuousEmitter extends Emitter {
  protected int emitRate;
  protected int _emitRateFrame;
  protected bool isEmitting;

  import void StartEmitting(int emitRate = 11);
  import void StopEmitting();
  import void UpdateContinuous();
};

/// Global Particle Emitter
import ContinuousEmitter GPE;
[close]

script
Particles.asc
Spoiler
Code: ags
// new module script

#define MAX_OVERLAYS 2048
Overlay* overlayPool[MAX_OVERLAYS];
bool overlayUsed[MAX_OVERLAYS];
int lastUsed;

#define MAX_LERP 1024
#define INT_FRAC 1024

// lerp from percent 0 to 1024
int _Lerp(int start, int end, int percent) {
  if (percent < 0) percent = 0;
  if (percent > MAX_LERP) percent = MAX_LERP;
  return start + ((end - start) * percent) / MAX_LERP;
}

int InvalidateOverlay(int index)
{
  if(overlayPool[index] != null)
  {
    overlayPool[index].Transparency = 100;
  }
  overlayUsed[index] = false; // Mark the overlay slot as free
  return -1;
}

// Find an available overlay slot in the pool
function GetAvailableOverlayIndex() {
  for (int i = lastUsed; i < MAX_OVERLAYS; i++) {
    if (!overlayUsed[i]) {
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  for (int i = 0; i < lastUsed; i++) {
    if (!overlayUsed[i]) {
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  return -1; // No available overlay
}

void UpdateOverlayFromParticle(Overlay* ovr, Particle* p)
{
  ovr.Transparency = p.transparency;
  ovr.Graphic = p.sprite;
#ifdef SCRIPT_API_v362
  ovr.SetPosition(p.x, p.y, p.width, p.height);
#else
  ovr.X = p.x;
  ovr.Y = p.y;
  ovr.Width = p.width;
  ovr.Height = p.height;
#endif
}

void Particle::Init(ParticleDefinition* def, int x, int y,  int overlayIndex) {
  if(this.overlayIndex >= 0 && this.overlayIndex < MAX_OVERLAYS) {
    InvalidateOverlay(this.overlayIndex);
  }

  this.mx = INT_FRAC*(x + def.offsetX); // Offset based on emitter
  this.my = INT_FRAC*(y + def.offsetY); // Offset based on emitter
  this.x = this.mx/INT_FRAC;
  this.y = this.my/INT_FRAC;
  this.life = def.life;
  this.vx = (def.vx*INT_FRAC)/1000;
  this.vy = (def.vy*INT_FRAC)/1000;
  this.gravity = (def.gravity*INT_FRAC)/1000;
  
  this.transparency = def.initialTransparency;
  this.initialTransparency = def.initialTransparency;
  this.deltaTransparency = def.finalTransparency - def.initialTransparency;
  
  this.width = def.initialWidth;
  this.initialWidth = def.initialWidth;
  this.deltaWidth = def.finalWidth - def.initialWidth;
  
  this.height = def.initialHeight;
  this.initialHeight = def.initialHeight;
  this.deltaHeight = def.finalHeight - def.initialHeight;
  
  this.rollSprite = def.sprite - def.initialSprite;
  this.sprite = def.sprite;
  this.initialSprite = def.initialSprite;
  this.deltaSprite = def.finalSprite - def.initialSprite;
  
  this.overlayIndex = overlayIndex;
  this.initialLife = def.life; // Store initial life for transitions
  this.bounces = def.groundHitBounces;
  this.groundY = def.groundY;
  if(this.groundY <= 0) {
    this.groundY = 16777216; // a big number so it is not reached
  }

  if (overlayIndex >= 0 && overlayPool[overlayIndex] != null) {
    UpdateOverlayFromParticle(overlayPool[overlayIndex], this);
  }
}

bool Particle::IsAlive() {
  return (this.life > 0);
}

bool Particle::IsCollidingPoint(int x, int y) {
  return (x >= this.x) && (y >= this.y) && (x <= this.x + this.width) && (y <= this.y + this.height);
}

// TODO: check if this correct
bool Particle::IsCollidingRect(int x, int y, int width, int height) {
  return (x + width >= this.x) && (y + height >= this.y) && (x <= this.x + this.width) && (y <= this.y + this.height);
}

// Update the particle state and sync with overlay
void Particle::Update() {
  // alive check is done before calling this function
  this.mx += this.vx;
  this.my += this.vy;
  this.vy += this.gravity; // Apply gravity
  this.x = this.mx/INT_FRAC;
  this.y = this.my/INT_FRAC;

  // Calculate the scaling and transparency transitions based on life
  int percent =  MAX_LERP - ((this.life * MAX_LERP) / this.initialLife); // 0 to 1024  
  this.transparency = this.initialTransparency + ((this.deltaTransparency) * percent) / MAX_LERP;  
  this.width = this.initialWidth + ((this.deltaWidth) * percent) / MAX_LERP;
  this.height = this.initialHeight + ((this.deltaHeight) * percent) / MAX_LERP;
  if(this.deltaSprite > 0) {
    this.sprite = this.initialSprite + (this.rollSprite + ((this.deltaSprite) * percent) / MAX_LERP) % this.deltaSprite;
  }
  
  int oidx = this.overlayIndex;
  if (oidx >= 0 && overlayPool[oidx] != null) {
    // UpdateOverlayFromParticle(overlayPool[this.overlayIndex], this);
    Overlay* ovr = overlayPool[oidx];
    ovr.Transparency = this.transparency;
    ovr.Graphic = this.sprite;
#ifdef SCRIPT_API_v362
    ovr.SetPosition(this.x, this.y, this.width, this.height);
#else
    ovr.X = this.x;
    ovr.Y = this.y;
    ovr.Width = this.width;
    ovr.Height = this.height;
#endif
  }
  
  this.life--;
  
  if (this.y >= this.groundY) {
    if (this.bounces) {
      this.vy = -(this.vy * 700)/INT_FRAC; // Invert velocity, reduce it to simulate energy loss
    } else {
      this.life = 0; // Mark particle as dead (cheaper than other things...)
    }
  }
}

void Emitter::SetPosition(int x, int y)
{
  this.x = x;
  this.y = y;
}

void Emitter::SetParticleDefinitions(ParticleDefinition* definitions[], int definitionsCount)
{
  for(int i=0; i<definitionsCount; i++) {
    ParticleDefinition* def = definitions[i];
    if(def.initialSprite == 0 && def.finalSprite == 0) {
      def.initialSprite = def.sprite;
      def.finalSprite = def.sprite;
    }
  }
  
  this.definitions = definitions;
  this.definitionsCount = definitionsCount;
}

// Initialize the emitter with position, particle definitions, and specific parameters
void Emitter::Init(int x, int y, ParticleDefinition* definitions[], int definitionsCount, int emitParticleCount, int particleCount) {
  this.SetPosition(x, y);
  this.particleCount = particleCount;
  this.SetParticleDefinitions(definitions, definitionsCount);
  this.emitParticleCount = emitParticleCount;
  this.particles = new Particle[particleCount];
  for (int i = 0; i < particleCount; i++) {
    this.particles[i] = new Particle;
  }
}

protected bool Emitter::EmitParticleIndex(int i)
{
  if(this.particles[i].IsAlive())
    return false;

  this.lastEmittedParticle = i;
  
  // Reuse dead particle if it's not alive anymore
  int overlayIndex = GetAvailableOverlayIndex();
  if (overlayIndex >= 0) {      
    // Randomly select a particle definition from the available definitions
    int defIndex = 0;
    if(this.definitionsCount > 0) {
      defIndex = Random(this.definitionsCount-1);
    }
    ParticleDefinition* def = this.definitions[defIndex];
    
    if(overlayPool[overlayIndex] == null) {
      Overlay* ovr = Overlay.CreateGraphical(this.x, this.y, def.sprite);
      overlayPool[overlayIndex] = ovr;
    }
    this.particles[i].Init(def, this.x, this.y, overlayIndex);
  }
  return true;
}

// Emit a single particle from the emitter
protected bool Emitter::EmitSingleParticle() {
  //System.Log(eLogInfo, ">>begin sp");
  int i = (this.lastEmittedParticle + 1) % this.particleCount;
  
  if(this.EmitParticleIndex(i))
    return false;
  
  //System.Log(eLogInfo, "loop1 sp");
  int loop_at = i;
  for (; i < this.particleCount; i++) {
    if (!this.particles[i].IsAlive())
    {
      this.EmitParticleIndex(i);
      return false;
    }
  }
  
  //System.Log(eLogInfo, "loop2 sp");
  for (i=0; i < loop_at; i++) {
    if (!this.particles[i].IsAlive())
    {
      this.EmitParticleIndex(i);
      return false;
    }
  }
  
  // FIX-ME: if we get here we need to do some sort of cooldown 
  // the issue is we are emitting too many particles per total particle count
  // along with particles that have a too big life
  return true; // indicates something is wrong
}

// Emit particles from the emitter
void Emitter::Emit() {
  for (int i = 0; i < this.emitParticleCount; i++) {
    if(this.EmitSingleParticle())
      return;
  }
}

// Update all particles
void Emitter::Update() {
  for (int i = 0; i < this.particleCount; i++) {
    if (this.particles[i].life > 0) {
      this.particles[i].Update();
    } 
    else if (this.particles[i].overlayIndex >= 0) 
    {
      this.particles[i].overlayIndex = InvalidateOverlay(this.particles[i].overlayIndex);
    }
  }
}

Particle* [] Emitter::GetParticlesCollidingPoint(int x, int y)
{
  int c[] = new int[this.particleCount];
  int c_count;
  for (int i = 0; i < this.particleCount; i++) {
    Particle* p = this.particles[i];
    if(p.IsAlive() && p.IsCollidingPoint(x, y)) {
      c[c_count] = i;
      c_count++;
    }
  }
  Particle* ps [] = new Particle[c_count + 1];
  for (int i = 0; i < c_count; i++) {
    ps[i] = this.particles[c[i]];
  }
  return ps;
}

Particle* [] Emitter::GetParticlesCollidingRect(int x, int y, int width, int height)
{  
  int c[] = new int[this.particleCount];
  int c_count = 0;
  for (int i = 0; i < this.particleCount; i++) {
    Particle* p = this.particles[i];
    if(p.IsAlive() && p.IsCollidingRect(x, y, width, height)) {
      c[c_count] = i;
      c_count++;
    }
  }
  Particle* ps [] = new Particle[c_count + 1];
  for (int i = 0; i < c_count; i++) {
    ps[i] = this.particles[c[i]];
  }
  return ps;  
}

void ContinuousEmitter::StartEmitting(int emitRate)
{
  if(emitRate < 4) emitRate = 4;
  this.emitRate = emitRate;
  this.isEmitting = true;
}

void ContinuousEmitter::StopEmitting()
{
  this.isEmitting = false; 
}

void ContinuousEmitter::UpdateContinuous()
{
  this.Update();
  if(this.isEmitting) {
    this._emitRateFrame--;    
    if(this._emitRateFrame <= 0) {
      this._emitRateFrame = this.emitRate;
      this.Emit();
    }
  } 
}
  
ContinuousEmitter GPE;
export GPE;

void game_start()
{
  ParticleDefinition* d[];
  int max_particles = (MAX_OVERLAYS*4)/5;
  GPE.Init(Screen.Width/2, Screen.Height/2, d, 0, max_particles/16, max_particles);
}

void repeatedly_execute_always()
{
  GPE.UpdateContinuous();
}
[close]

example room
room1.asc
Spoiler
Code: ags
// room script file

//Emitter emt;

function hGlowingOrb_Look(Hotspot *thisHotspot, CursorMode mode)
{
  player.Say("It is the second best glowing orb that I've seen today.");
}

ParticleDefinition* GetFireworksParticle()
{
  ParticleDefinition* fireworksParticle = new ParticleDefinition;
  fireworksParticle.life = 40;
  // make the velocities circular
  float r = 2000.0*Maths.Sqrt(IntToFloat(Random(8192))/8192.0);
  float theta =  2.0 * Maths.Pi*(IntToFloat(Random(8192))/8192.0);
  fireworksParticle.vx = FloatToInt(r * Maths.Cos(theta)); // Random outward velocity
  fireworksParticle.vy = FloatToInt(r * Maths.Sin(theta));
  
  fireworksParticle.gravity = 0; // No gravity
  fireworksParticle.initialTransparency = 0;
  fireworksParticle.finalTransparency = 100;
  fireworksParticle.initialWidth = 2;
  fireworksParticle.finalWidth = 20; // Expanding outward
  fireworksParticle.initialHeight = 2;
  fireworksParticle.finalHeight = 20;
  return fireworksParticle;
}

ParticleDefinition* GetSparkleParticle()
{
  ParticleDefinition* sparkleParticle = new ParticleDefinition;
  sparkleParticle.life = 50;
  sparkleParticle.vx = Random(3000) - 1000;
  sparkleParticle.vy = Random(3000) - 1000;
  sparkleParticle.initialTransparency = 0;
  sparkleParticle.finalTransparency = 100;
  sparkleParticle.initialWidth = 3;
  sparkleParticle.finalWidth = 8;
  sparkleParticle.initialHeight = 3;
  sparkleParticle.finalHeight = 8;
  sparkleParticle.gravity = 100;
  sparkleParticle.groundY = 154;
  sparkleParticle.groundHitBounces = true;
  return sparkleParticle;
}

ParticleDefinition* GetExplosionParticle()
{
  ParticleDefinition* explosionParticle = new ParticleDefinition;  
  explosionParticle.sprite = 1+ Random(60);
  explosionParticle.initialSprite = 1;
  explosionParticle.finalSprite = 61;  
  explosionParticle.life = 40;
  explosionParticle.vx = Random(3000) - 1500;
  explosionParticle.vy = Random(3000) - 1500;
  explosionParticle.gravity =  -90;
  explosionParticle.initialTransparency = 15;
  explosionParticle.finalTransparency = 100;
  explosionParticle.initialWidth = 12;
  explosionParticle.finalWidth = 50;
  explosionParticle.initialHeight = 12;
  explosionParticle.finalHeight = 50;
  return explosionParticle;
}

ParticleDefinition* GetSmokeParticle()
{
  ParticleDefinition* smokeParticle = new ParticleDefinition;
  smokeParticle.life = 40+Random(14);
  smokeParticle.vy = -1000-Random(1000);
  smokeParticle.initialTransparency = 0;
  smokeParticle.finalTransparency = 100;
  smokeParticle.initialWidth = 10+Random(2);
  smokeParticle.finalWidth = 20+Random(2);
  smokeParticle.initialHeight = 20+Random(2);
  smokeParticle.finalHeight = 10+Random(2);
  return smokeParticle;
}

ParticleDefinition* GetBubbleParticle()
{
  ParticleDefinition* bubbleParticle = new ParticleDefinition;
  bubbleParticle.life = 60;
  bubbleParticle.vx = Random(500) - 250; // Small horizontal drift
  bubbleParticle.vy = -1000 - Random(500); // Rising upwards
  bubbleParticle.gravity = -200; // Rising effect
  bubbleParticle.initialTransparency = 30;
  bubbleParticle.finalTransparency = 100;
  bubbleParticle.initialWidth = 5;
  bubbleParticle.finalWidth = 15; // Expands as it rises
  bubbleParticle.initialHeight = 5;
  bubbleParticle.finalHeight = 15;
  return bubbleParticle;
}

ParticleDefinition* GetRainParticle()
{
  ParticleDefinition* rainParticle = new ParticleDefinition;
  rainParticle.offsetX = Random(Screen.Width) - (Screen.Width/2);
  rainParticle.offsetY = -Random(30);
  rainParticle.life = 50;
  rainParticle.vx = Random(500) - 250; // Slight horizontal movement
  rainParticle.vy = 3000; // Falling down quickly
  rainParticle.gravity = 180; // Light gravity effect
  rainParticle.initialTransparency = 30;
  rainParticle.finalTransparency = 80;
  rainParticle.initialWidth = 2;
  rainParticle.finalWidth = 2;
  rainParticle.initialHeight = 10;
  rainParticle.finalHeight = 15; // Lengthening as it falls
  return rainParticle;
}

ParticleDefinition* GetFireParticle()
{
  ParticleDefinition* fireParticle = new ParticleDefinition;
  fireParticle.life = 35;
  fireParticle.vx = Random(1000) - 500; // Small horizontal variance
  fireParticle.vy = -1200 - Random(500); // Rising upward
  fireParticle.gravity = -50; // Slow upward pull
  fireParticle.initialTransparency = 50;
  fireParticle.finalTransparency = 100; // Disappears as it rises
  fireParticle.initialWidth = 10;
  fireParticle.finalWidth = 20; // Expands as it rises
  fireParticle.initialHeight = 10;
  fireParticle.finalHeight = 15;
  return fireParticle;
}

ParticleDefinition* GetSnowParticle()
{
  ParticleDefinition* snowParticle = new ParticleDefinition;
  snowParticle.offsetX = Random(Screen.Width) - (Screen.Width/2);
  snowParticle.offsetY = -Random(30);
  snowParticle.life = 160;
  snowParticle.vx = Random(300) - 150; // Slight horizontal drift
  snowParticle.vy = Random(300) + 220; // Slow downward movement
  snowParticle.gravity = 10; // Minimal gravity effect
  snowParticle.initialTransparency = 50;
  snowParticle.finalTransparency = 75;
  snowParticle.initialWidth = 4;
  snowParticle.finalWidth = 6; // Slight expansion as it falls
  snowParticle.initialHeight = 4;
  snowParticle.finalHeight = 6;
  return snowParticle;
}

ParticleDefinition* GetSplashParticle()
{
  ParticleDefinition* p = new ParticleDefinition;
  p.life = 96;  
  // squished circle
  float r = 2000.0*Maths.Sqrt(IntToFloat(Random(8192))/8192.0);
  float theta =  2.0 * Maths.Pi*(IntToFloat(Random(8192))/8192.0);
  p.vx = FloatToInt(r * Maths.Cos(theta))/3;
  p.vy = FloatToInt(r * Maths.Sin(theta))/2-2000; // vy shots upwards
  p.initialTransparency = 20;
  p.finalTransparency = 100;
  p.initialWidth = 2;
  p.finalWidth = 8;
  p.initialHeight = 8;
  p.finalHeight = 2;
  p.gravity = 100;
  p.groundY = 150 + Random(9);
  p.groundHitBounces = true;
  return p;
}

enum PresetParticleType {
  ePPT_Fireworks, 
  ePPT_Sparkle, 
  ePPT_Explosion, 
  ePPT_Smoke, 
  ePPT_Bubble, 
  ePPT_Rain, 
  ePPT_Fire, 
  ePPT_Snow, 
  ePPT_Splash
};

#define ePPT_Last ePPT_Splash

ParticleDefinition* [] GetParticleDefinitionsArrayByType(PresetParticleType type, int count)
{
  ParticleDefinition* definitions[] = new ParticleDefinition[count];
  int i;
  switch(type) {
    case ePPT_Fireworks:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetFireworksParticle();
      }
    break;
    case ePPT_Sparkle:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetSparkleParticle();
      }
    break;
    case ePPT_Explosion:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetExplosionParticle();
      }
    break;
    case ePPT_Smoke:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetSmokeParticle();
      }
    break;
    case ePPT_Bubble:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetBubbleParticle();
      }
    break;
    case ePPT_Rain:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetRainParticle();
      }
    break;
    case ePPT_Fire:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetFireParticle();
      }
    break;
    case ePPT_Snow:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetSnowParticle();
      }
    break;
    case ePPT_Splash:
      for(i=0; i<count; i++)
      {
        definitions[i] = GetSplashParticle();
      }
    break;
  }  
  return definitions;
}

String GetTypeName(PresetParticleType type) {  
  switch(type) {
    case ePPT_Fireworks:
      return "Fireworks";
    case ePPT_Sparkle:
      return "Sparkle";
    case ePPT_Explosion:
      return "Explosion";
    case ePPT_Smoke:
      return "Smoke";
    case ePPT_Bubble:
      return "Bubble";
    case ePPT_Rain:
      return "Rain";
    case ePPT_Fire:
      return "Fire";
    case ePPT_Snow:
      return "Snow";
    case ePPT_Splash:
      return "Splash";
    default:
      return "Unknown";
  }  
}

void SetEmitterToType(PresetParticleType type)
{
  int definitions_count = 2048;
  ParticleDefinition* definitions[] = GetParticleDefinitionsArrayByType(type, definitions_count);
  GPE.SetParticleDefinitions(definitions, definitions_count);
  lbl_particle_selected.Text = GetTypeName(type);
  
  if(type == ePPT_Rain || type == ePPT_Snow) {
    GPE.SetPosition(Screen.Width/2, 0);
    GPE.StartEmitting();
  } else {
    GPE.StopEmitting();
  }
}

void ParticlePlayerCollision()
{  
  ViewFrame* c_vf = Game.GetViewFrame(player.NormalView, 0, 0);
  float scaling = IntToFloat(player.Scaling) / 100.00;
  int p_rect_width = FloatToInt(IntToFloat(Game.SpriteWidth[c_vf.Graphic]) * scaling);
  int p_rect_height = FloatToInt(IntToFloat(Game.SpriteHeight[c_vf.Graphic]) * scaling);
  int p_rect_x = player.x - p_rect_width/2;
  int p_rect_y = player.y - p_rect_height;
  
  //DrawingSurface* surf = Room.GetDrawingSurfaceForBackground();
  //surf.DrawingColor = Random(65535);
  //surf.DrawRectangle(p_rect_x, p_rect_y, p_rect_x + p_rect_width, p_rect_y + p_rect_height);
  //surf.Release();
  
  Particle* pcp [] = GPE.GetParticlesCollidingRect(p_rect_x, p_rect_y, p_rect_width, p_rect_height);
  for(int i=0; pcp[i] != null; i++)
    pcp[i].life-=2;
}

int particle_type;
void on_call (int value)
{
  if(value == 1) {
    particle_type++;
    if(particle_type> ePPT_Last) {
      particle_type = 1;
    }
  }
  SetEmitterToType(particle_type);
}

function room_Load()
{  
  particle_type = ePPT_Splash;
  SetEmitterToType(particle_type);
}

void on_mouse_click(MouseButton button)
{
  if(particle_type == ePPT_Rain || particle_type == ePPT_Snow)
    return;
  GPE.SetPosition(mouse.x, mouse.y);
  GPE.Emit();
}

int mb_press;
function room_RepExec()
{
  ParticlePlayerCollision();
  
  if(particle_type == ePPT_Rain || particle_type == ePPT_Snow)
    return;
    
  if(mouse.IsButtonDown(eMouseLeft))
    mb_press++;
  else
    mb_press = 0;
  
  if(mb_press > 10) {
    GPE.SetPosition(mouse.x, mouse.y);
    GPE.StartEmitting(5);
  } else {
    GPE.StopEmitting();
  }
}

function room_AfterFadeIn()
{

}
[close]

eri0o

#15
OK, just minor update of the code itself. Did some clean up, removed some code paths that weren't necessary (Threw in a few AbortGame calls and noticed some things never happened!). Other than this not much. Verified it works fine in AGS 3.6.1, 3.6.2 and 4.0, and in 4.0 it gets additional BlendModo and Rotation support for the particles.

I think I will move the properties to use PascalCase instead of the current camelCase, and use camelCase only in the function parameters and local variables.

Other than this trying to short the name of things when possible - switched from using collision to hit as one of the concepts, and also since the type ParticleDefinition is already there I can use only "def" to refer to it as a parameter.

If someone has some criticism on the code it would be nice to get it, I think I will try to encapsulate this all as a module and throw a simple demo together.

From top of my head the only feature I still wish to get in is some sort of deceleration, so that I could do something like the Splash particle but then have the particles still lay there for some time before disappearing.

module header
Particles.ash
Spoiler
Code: ags
// new module header

managed struct ParticleDefinition {
  int sprite;
  int offsetX; // Offset from the emitter position
  int offsetY; // Offset from the emitter position
  int life; // Lifetime of the particle
  int vx; // mili Velocity in x direction
  int vy; // mili Velocity in y direction
  int gravity; // mili Gravity effect on the particle
  int initialSprite;
  int finalSprite;
  int initialTransparency; // Initial transparency
  int finalTransparency; // Final transparency
  int initialWidth; // Initial width
  int finalWidth; // Final width
  int initialHeight; // Initial height
  int finalHeight; // Final height
  bool groundHitBounces;
  int groundY;
  #ifdef SCRIPT_API_v400
  BlendMode BlendMode;
  float rotationSpeed;
  float angle;
  #endif
};

managed struct Particle {
  int sprite;
  int x;
  int y;
  protected int mx; // mili x (~1000 times x)
  protected int my; // mili y (~1000 times y)
  int life;
  int initialLife;
  int overlayIndex; // This refers to the overlay in the overlay pool
  int vx; // x velocity
  int vy; // y velocity
  int gravity; // this is vertical acceleration downwards
  int transparency;
  int width;
  int height;
  int initialSprite;
  int rollSprite;
  int deltaSprite;
  int initialTransparency;
  int deltaTransparency;
  int initialWidth;
  int deltaWidth;
  int initialHeight;
  int deltaHeight;
  bool bounces;
  int groundY;
  #ifdef SCRIPT_API_v400
  float rotationSpeed;
  float angle;
  #endif
  /// returns true if particle is alive
  import bool IsAlive();
  /// returns true if particle rect overlaps point
  import bool HitsPoint(int x, int y);
  /// returns true if particle rect overlaps rect
  import bool HitsRect(int x, int y, int width, int height);
  // private stuff
  import void _Init(ParticleDefinition * def, int x, int y, Overlay* ovr); // $AUTOCOMPLETEIGNORE$
  import void _Update(); // $AUTOCOMPLETEIGNORE$
};

struct Emitter {
  /// Set emitter possible particle definitions
  import void SetParticleDefinitions(ParticleDefinition * definitions[], int definitionsCount);
  /// Update emitter position
  import void SetPosition(int x, int y);
  /// Get null terminated array of particles that overlaps with the point
  import Particle * [] ParticlesHitPoint(int x, int y);
  /// Get null terminated array of particles that overlaps with the rect
  import Particle * [] ParticlesHitRect(int x, int y, int width, int height);

  /// Initialize the emitter
  import void Init(int x, int y, ParticleDefinition * defs[], int defCount, int emitCount = 10, int maxParticles = 50);
  /// Emit particles
  import void Emit();
  /// Update all particles
  import void Update();
  
  import protected bool EmitParticleIndex(int i);
  import protected bool EmitSingleParticle();
  protected int x;
  protected int y;
  protected int emitCount;
  protected int maxParticles;
  protected Particle * particles[]; // Pool of particles
  protected ParticleDefinition * definitions[]; // Array of particle definitions
  protected int definitionsCount; // Count of particle definitions
  protected int lastEmittedParticle;
};

struct ContinuousEmitter extends Emitter {
  protected int emitRate;
  protected int _emitRateFrame;
  protected bool isEmitting;

  import void StartEmitting(int emitRate = 11);
  import void StopEmitting();
  import void UpdateContinuous();
};

/// Global Particle Emitter
import ContinuousEmitter GPE;
[close]

module script
Particles.asc
Spoiler
Code: ags
// new module script

#define MAX_LERP 1024
#define INT_FRAC 1024
#define MAX_OVERLAYS 2048
Overlay *overlayPool[MAX_OVERLAYS];
bool overlayUsed[MAX_OVERLAYS];
int lastUsed;

int InvalidateOverlay (int index)
{
  if (overlayPool[index] != null)
  {
    overlayPool[index].Transparency = 100;
  }
  overlayUsed[index] = false; // Mark the overlay slot as free
  return -1;
}

// Find an available overlay slot in the pool
int GetAvailableOverlayIndex ()
{
  for (int i = lastUsed; i < MAX_OVERLAYS; i++)
  {
    if (!overlayUsed[i])
    {
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  for (int i = 0; i < lastUsed; i++)
  {
    if (!overlayUsed[i])
    {
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  return -1; // No available overlay
}

void UpdateOverlayFromParticle (Overlay *ovr, Particle *p)
{
  ovr.Transparency = p.transparency;
  ovr.Graphic = p.sprite;
#ifdef SCRIPT_API_v362
  ovr.SetPosition (p.x, p.y, p.width, p.height);
#else
  ovr.X = p.x;
  ovr.Y = p.y;
  ovr.Width = p.width;
  ovr.Height = p.height;
#endif
#ifdef SCRIPT_API_v400
  ovr.Rotation = p.angle;
#endif
}

// ---------------------- Particle methods ----------------------------

bool Particle::IsAlive ()
{
  return (this.life > 0);
}

bool Particle::HitsPoint (int x, int y)
{
  return (x >= this.x) && (y >= this.y)
         && (x <= this.x + this.width) && (y <= this.y + this.height);
}

bool Particle::HitsRect (int x, int y, int width, int height)
{
  return (x + width >= this.x) && (y + height >= this.y)
         && (x <= this.x + this.width) && (y <= this.y + this.height);
}

void Particle::_Init (ParticleDefinition *def, int x, int y, Overlay* ovr)
{
  this.mx = INT_FRAC * (x + def.offsetX); // Offset based on emitter
  this.my = INT_FRAC * (y + def.offsetY); // Offset based on emitter
  this.x = this.mx / INT_FRAC;
  this.y = this.my / INT_FRAC;
  this.life = def.life;
  this.vx = (def.vx * INT_FRAC) / 1000;
  this.vy = (def.vy * INT_FRAC) / 1000;
  this.gravity = (def.gravity * INT_FRAC) / 1000;

  this.transparency = def.initialTransparency;
  this.initialTransparency = def.initialTransparency;
  this.deltaTransparency = def.finalTransparency - def.initialTransparency;

  this.width = def.initialWidth;
  this.initialWidth = def.initialWidth;
  this.deltaWidth = def.finalWidth - def.initialWidth;

  this.height = def.initialHeight;
  this.initialHeight = def.initialHeight;
  this.deltaHeight = def.finalHeight - def.initialHeight;

  this.rollSprite = def.sprite - def.initialSprite;
  this.sprite = def.sprite;
  this.initialSprite = def.initialSprite;
  this.deltaSprite = def.finalSprite - def.initialSprite;
  
#ifdef SCRIPT_API_v400
  this.rotationSpeed = def.rotationSpeed;
  this.angle = def.angle;
#endif

  this.initialLife = def.life; // Store initial life for transitions
  this.bounces = def.groundHitBounces;
  this.groundY = def.groundY;
  if (this.groundY <= 0)
  {
    this.groundY = 16777216; // a big number so it is not reached
  }
  
#ifdef SCRIPT_API_v400
  ovr.BlendMode = def.BlendMode;
#endif
  UpdateOverlayFromParticle (ovr, this);
}

// Update the particle state and sync with overlay
void Particle::_Update ()
{
  // alive check is done before calling this function
  this.mx += this.vx;
  this.my += this.vy;
  this.vy += this.gravity; // Apply gravity
  this.x = this.mx / INT_FRAC;
  this.y = this.my / INT_FRAC;

  // Calculate the scaling and transparency transitions based on life
  int percent = MAX_LERP - ((this.life * MAX_LERP) / this.initialLife); // 0 to 1024
  this.transparency = this.initialTransparency + ((this.deltaTransparency) * percent) / MAX_LERP;
  this.width = this.initialWidth + ((this.deltaWidth) * percent) / MAX_LERP;
  this.height = this.initialHeight + ((this.deltaHeight) * percent) / MAX_LERP;
  if (this.deltaSprite > 0)
  {
    this.sprite
        = this.initialSprite
          + (this.rollSprite + ((this.deltaSprite) * percent) / MAX_LERP)
                % this.deltaSprite;
  }
  
#ifdef SCRIPT_API_v400
  this.angle += this.rotationSpeed;
  if (this.angle >= 360.0)
    this.angle -= 360.0;
  if (this.angle < 0.0)
    this.angle += 360.0;
#endif

  int oidx = this.overlayIndex;
  if (oidx >= 0 && overlayPool[oidx] != null)
  {
    Overlay *ovr = overlayPool[oidx];
    ovr.Transparency = this.transparency;
    ovr.Graphic = this.sprite;
#ifdef SCRIPT_API_v362
    ovr.SetPosition (this.x, this.y, this.width, this.height);
#else
    ovr.X = this.x;
    ovr.Y = this.y;
    ovr.Width = this.width;
    ovr.Height = this.height;
#endif
#ifdef SCRIPT_API_v400
    ovr.Rotation = this.angle;
#endif
  }

  this.life--;

  if (this.y >= this.groundY)
  {
    if (this.bounces)
    {
      this.vy = -(this.vy * 700) / INT_FRAC; // Invert velocity and simulate energy loss
    }
    else
    {
      this.life = 0; // Mark particle as dead (cheaper than other things...)
    }
  }
}


// ---------------------- Emitter methods ----------------------------

void Emitter::SetPosition (int x, int y)
{
  this.x = x;
  this.y = y;
}

void Emitter::SetParticleDefinitions (ParticleDefinition *defs[], int defCount)
{
  for (int i = 0; i < defCount; i++)
  {
    ParticleDefinition *def = defs[i];
    if (def.initialSprite == 0 && def.finalSprite == 0)
    {
      def.initialSprite = def.sprite;
      def.finalSprite = def.sprite;
    }
  }

  this.definitions = defs;
  this.definitionsCount = defCount;
}

// Initialize the emitter with position, particle definitions, and other parameters
void Emitter::Init (int x, int y, ParticleDefinition *defs[], int defCount, int emitCount, int maxParticles)
{
  this.SetPosition (x, y);
  this.maxParticles = maxParticles;
  this.SetParticleDefinitions (defs, defCount);
  this.emitCount = emitCount;
  this.particles = new Particle[maxParticles];
  for (int i = 0; i < maxParticles; i++)
  {
    this.particles[i] = new Particle;
  }
}

// returns TRUE if it emitted the particle SUCCESSFULLY
protected bool Emitter::EmitParticleIndex (int i)
{
  Particle *p = this.particles[i];
  if (p.IsAlive ())
    return false;

  this.lastEmittedParticle = i;

  // Reuse dead particle if it's not alive anymore
  int overlayIndex = GetAvailableOverlayIndex ();
  if (overlayIndex >= 0)
  {
    // Randomly select a particle definition from the available definitions
    int defIndex = 0;
    if (this.definitionsCount > 0)
    {
      defIndex = Random (this.definitionsCount - 1);
    }
    ParticleDefinition *def = this.definitions[defIndex];

    if (overlayPool[overlayIndex] == null)
    {
      Overlay *ovr = Overlay.CreateGraphical (this.x, this.y, def.sprite);
      overlayPool[overlayIndex] = ovr;
    }
    p._Init (def, this.x, this.y, overlayPool[overlayIndex]);
    p.overlayIndex = overlayIndex;
    return true;
  }
  return false;
}

// Emit a single particle from the emitter, returns TRUE for FAILURE
protected bool Emitter::EmitSingleParticle ()
{
  int i = (this.lastEmittedParticle + 1) % this.maxParticles;

  if (this.EmitParticleIndex (i)) // if it fail to emit, try to find a dead particle to reuse
    return false;

  int loop_at = i;
  for (; i < this.maxParticles; i++)
  {
    if (!this.particles[i].IsAlive ())
    {
      return !this.EmitParticleIndex (i);
    }
  }

  for (i = 0; i < loop_at; i++)
  {
    if (!this.particles[i].IsAlive ())
    {
      return !this.EmitParticleIndex (i);
    }
  }

  // FIX-ME: if we get here we need to do some sort of cooldown
  // the issue is we are emitting too many particles per total particle count
  // along with particles that have a too big life
  return true; // indicates something is wrong
}

// Emit particles from the emitter
void Emitter::Emit ()
{
  for (int i = 0; i < this.emitCount; i++)
  {
    if (this.EmitSingleParticle ())
      return;
  }
}

// Update all particles
void Emitter::Update ()
{
  for (int i = 0; i < this.maxParticles; i++)
  {
    if (this.particles[i].life > 0)
    {
      this.particles[i]._Update ();
    }
    else if (this.particles[i].overlayIndex >= 0)
    {
      this.particles[i].overlayIndex
          = InvalidateOverlay (this.particles[i].overlayIndex);
    }
  }
}

Particle *[] Emitter::ParticlesHitPoint (int x, int y)
{
  int c[] = new int[this.maxParticles];
  int c_count;
  for (int i = 0; i < this.maxParticles; i++)
  {
    Particle *p = this.particles[i];
    if (p.IsAlive () && p.HitsPoint (x, y))
    {
      c[c_count] = i;
      c_count++;
    }
  }
  Particle *ps[] = new Particle[c_count + 1];
  for (int i = 0; i < c_count; i++)
  {
    ps[i] = this.particles[c[i]];
  }
  return ps;
}

Particle *[] Emitter::ParticlesHitRect (int x, int y, int width, int height) 
{
  int c[] = new int[this.maxParticles];
  int c_count = 0;
  for (int i = 0; i < this.maxParticles; i++)
  {
    Particle *p = this.particles[i];
    if (p.IsAlive () && p.HitsRect (x, y, width, height))
    {
      c[c_count] = i;
      c_count++;
    }
  }
  Particle *ps[] = new Particle[c_count + 1];
  for (int i = 0; i < c_count; i++)
  {
    ps[i] = this.particles[c[i]];
  }
  return ps;
}

void ContinuousEmitter::StartEmitting (int emitRate)
{
  if (emitRate < 4)
    emitRate = 4;
  this.emitRate = emitRate;
  this.isEmitting = true;
}

void ContinuousEmitter::StopEmitting ()
{
  this.isEmitting = false;
}

void ContinuousEmitter::UpdateContinuous ()
{
  this.Update ();
  if (this.isEmitting)
  {
    this._emitRateFrame--;
    if (this._emitRateFrame <= 0)
    {
      this._emitRateFrame = this.emitRate;
      this.Emit ();
    }
  }
}

// --- Global Particle Emitter for easy quick particle emission

ContinuousEmitter GPE;
export GPE;

void game_start ()
{
  ParticleDefinition *d[];
  int max_particles =  MAX_OVERLAYS; // (MAX_OVERLAYS * 4) / 5;
  int emit_count = max_particles / 16;
  GPE.Init (Screen.Width / 2, Screen.Height / 2, d, 0, emit_count,  max_particles);
}

void repeatedly_execute_always ()
{
  GPE.UpdateContinuous ();
}
[close]

Edit: reading my code again, made a few more notes about it

Spoiler
A few things to rename

emitRate, replace with emitInterval
InvalidateOverlay, replace with FreeOverlay
GetAvailableOverlayIndex, replace with FindFreeOverlaySlot
UpdateOverlayFromParticle, replace with SyncParticleOverlay
These should help a little bit with making things easier to understand. Need to read the code a bit more to see if there are more opportunities for renaming things.

Oh, probably also better to rename initialX and finalX to instead be xStart and xEnd, as it may work better in the autocomplete. Maybe xBegin and xEnd, sounds worse but makes the initial state show before in the autocomplete.

Reading again, lastEmittedParticle is a bit long and we only emit particles, and that is an index, so maybe just only lastEmitted or lastEmittedIndex, that it's a particle is implied by the emitted.

Ah, right, similarly SetParticleDefinitions can be simply SetDefinitions since we don't support other type of definition other than particles
[close]

Overall I few I am approaching to have some version 0.1.0 that could make into release, I think I will do the changes I thought, write up some docs, make up a few demo rooms and see how it goes.

eri0o

#16
OK, one more iteration of it, I think the header is shaping nicely. If someone can, please take a look and tell me what makes sense or not of it.

header
ParticleAccelerator.ash
Code: ags
// Particle Accelerator module header
// Module to manage particles effects based on Overlays
//
//    +-------------------------+
//    |        Emitter          |
//    |-------------------------|      +--------------------+
//    |  Array of Definitions  |<------| ParticleDefinition |
//    |  Pool of Particles      |      +--------------------+
//    +-------------------------+        (sprite, velocity, size, etc.)
//                |
//                |    Emits based on a random
//                |  selection from definitions
//                v
//    +--------------------+
//    |      Particle      |
//    +--------------------+
// 
// The Emitter is how we will manage particles in this module.
// It's responsible for a few things
// 1. When emitting it randomly selects a ParticleDefinition and Spawns it;
// 2. Updates the particle;
// 3. Renders the particle as overlay until it's life ends.

managed struct ParticleDefinition {
  /// The particle sprite. If SpriteBegin/End are set, it's the initial frame. 
  int Sprite;
  /// Horizontal Offset from the emitter position
  int OffsetX;
  /// Vertical Offset from the emitter position
  int OffsetY;
  /// The initial life of the particle, it's Lifetime in update loops
  int life;
  /// Horizontal mili velocity. It's in thousandths of a pixel per update, in X direction (e.g., 1000 moves 1 pixel to right per update).
  int VelX; 
  /// Vertical mili velocity. It's in thousandths of a pixel per update, in Y direction (e.g., -2000 moves 2 pixel upwards per update).
  int VelY;
  /// Mili Gravity (vertical acceleration).
  int Gravity;
  /// The initial sprite of a sequential Sprite range (see Sprite for initial frame).
  int SpriteBegin;
  /// The final sprite of a sequential Sprite range.
  int SpriteEnd;
  /// Initial transparency, the Transparency when emitted.
  int TransparencyBegin;
  /// Final transparency, the Transparency when Particle life is zero.
  int TransparencyEnd;
  /// Initial width, the Width when emitted.
  int WidthBegin;
  /// Final width, the Width when Particle life is zero.
  int WidthEnd;
  /// Initial height, the Height when emitted.
  int HeightBegin;
  /// Final height, the Height when Particle life is zero.
  int HeightEnd;
  /// If the particle should bounce when it hits ground
  bool Bounces;
  /// The ground level position for the particle (if unset assumes no ground exists)
  int GroundY;
  #ifdef SCRIPT_API_v400
  /// The blend mode the particle should use
  BlendMode BlendMode;
  /// The angle in degrees (0.0 to 360.0) the particle should be
  float Angle;
  /// The speed that will increase the angle in degrees per update loop
  float RotationSpeed;
  #endif
};

managed struct Particle {
  /// The particle life, it decrements on each update. It dies when life is equal or below zero.
  import attribute int Life;
  /// returns true if particle is alive
  import bool IsAlive();
  /// returns true if particle overlaps the given point. Particle is assumed a rectangle.
  import bool HitsPoint(int x, int y);
  /// returns true if particle overlaps the given rectangle. Particles is assumed a rectangle.
  import bool HitsRect(int x, int y, int width, int height);

  // private internals
  protected int X;
  protected int Y;
  protected int MiliX; // mili x (~1000 times x)
  protected int MiliY; // mili y (~1000 times y)
  protected int Sprite;
  protected int InitialLife;
  protected int VelX; // x velocity
  protected int VelY; // y velocity
  protected int Gravity; // this is vertical acceleration downwards
  protected int Transparency;
  protected int Width;
  protected int Height;
  protected int SpriteBegin;
  protected int SpriteCycleOffset;
  protected int SpriteDelta;
  protected int TransparencyBegin;
  protected int TransparencyDelta;
  protected int WidthBegin;
  protected int WidthDelta;
  protected int HeightBegin;
  protected int HeightDelta;
  protected bool Bounces;
  protected int GroundY;
  #ifdef SCRIPT_API_v400
  protected float RotationSpeed;
  protected float Angle;
  #endif
  // not actual public interface and should not be used or relied upon
  int _Life; // $AUTOCOMPLETEIGNORE$
  int _OverlayIdx; // $AUTOCOMPLETEIGNORE$
};

struct Emitter {
  /// Initialize the emitter
  import void Init(int x, int y, ParticleDefinition * defs[], int defCount, int emitAmount, int maxParticles);
  /// Emit particles set in emitBurst, returns true if succeed emitting all particles
  import bool Emit();
  /// Update all particles
  import void Update();
  /// Set emitter possible particle definitions
  import void SetParticleDefinitions(ParticleDefinition * definitions[], int definitionsCount);
  /// Update emitter position
  import void SetPosition(int x, int y);
  /// Get null terminated array of particles that overlaps with the given point.
  import Particle * [] ParticlesHitPoint(int x, int y);
  /// Get null terminated array of particles that overlaps with the given rectangle.
  import Particle * [] ParticlesHitRect(int x, int y, int width, int height);

  // private internals
  protected int X;
  protected int Y;
  protected int EmitAmount;
  protected int maxParticles;
  protected Particle * particles[]; // Pool of particles
  protected ParticleDefinition * definitions[]; // Array of particle definitions
  protected int definitionsCount; // Count of particle definitions
  protected int lastEmittedParticle;
  import protected bool _EmitParticleIndex(int i);
  import protected bool _EmitSingleParticle();
};

struct ContinuousEmitter extends Emitter {
  protected int emitInterval;
  protected int _emitCooldown;
  protected bool isEmitting;

  /// Starts the emission of particles at regular intervals. Interval unit is in update loops.
  import void StartEmitting(int emitInterval = 11);
  /// Stops the emission of particles.
  import void StopEmitting();
  import void UpdateContinuous();
};

/// Global Particle Emitter
import ContinuousEmitter GPE;

script
ParticleAccelerator.asc
Spoiler
Code: ags
// Particle Accelerator module script

#define LERP_SCALE 1024
#define INT_FRAC 1024
#define MAX_OVERLAYS 2048
Overlay *overlayPool[MAX_OVERLAYS];
bool overlayUsed[MAX_OVERLAYS];
int lastUsed;

int FreeOverlay (int index)
{
  if (overlayPool[index] != null)
  {
    overlayPool[index].Transparency = 100;
  }
  overlayUsed[index] = false; // Mark the overlay slot as free
  return -1;
}

// Find an available overlay slot in the pool, remember the slot to speed up search
int FindFreeOverlaySlot ()
{
  for (int i = lastUsed + 1; i < MAX_OVERLAYS; i++)
  {
    if (!overlayUsed[i])
    {
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  for (int i = 0; i < lastUsed; i++)
  {
    if (!overlayUsed[i])
    {
      overlayUsed[i] = true;
      lastUsed = i;
      return i;
    }
  }
  return -1; // No available overlay
}

// ---------------------- Particle methods ----------------------------

// we will use extenders in internal methods
void _SyncOverlay (this Particle*, Overlay* ovr)
{
  ovr.Transparency = this.Transparency;
  ovr.Graphic = this.Sprite;
#ifdef SCRIPT_API_v362
  ovr.SetPosition (this.X, this.Y, this.Width, this.Height);
#else
  ovr.X = this.X;
  ovr.Y = this.Y;
  ovr.Width = this.Width;
  ovr.Height = this.Height;
#endif
#ifdef SCRIPT_API_v400
  ovr.Rotation = this.Angle;
#endif
}

void _Init (this Particle*, ParticleDefinition *def, int x, int y, Overlay* ovr)
{
  this.MiliX = INT_FRAC * (x + def.OffsetX); // Offset based on emitter
  this.MiliY = INT_FRAC * (y + def.OffsetY); // Offset based on emitter
  this.X = this.MiliX / INT_FRAC;
  this.Y = this.MiliY / INT_FRAC;
  this._Life = def.life;
  this.VelX = (def.VelX * INT_FRAC) / 1000;
  this.VelY = (def.VelY * INT_FRAC) / 1000;
  this.Gravity = (def.Gravity * INT_FRAC) / 1000;

  this.Transparency = def.TransparencyBegin;
  this.TransparencyBegin = def.TransparencyBegin;
  this.TransparencyDelta = def.TransparencyEnd - def.TransparencyBegin;

  this.Width = def.WidthBegin;
  this.WidthBegin = def.WidthBegin;
  this.WidthDelta = def.WidthEnd - def.WidthBegin;

  this.Height = def.HeightBegin;
  this.HeightBegin = def.HeightBegin;
  this.HeightDelta = def.HeightEnd - def.HeightBegin;

  this.SpriteCycleOffset = def.Sprite - def.SpriteBegin;
  this.Sprite = def.Sprite;
  this.SpriteBegin = def.SpriteBegin;
  this.SpriteDelta = def.SpriteEnd - def.SpriteBegin;
  
#ifdef SCRIPT_API_v400
  this.RotationSpeed = def.RotationSpeed;
  this.Angle = def.Angle;
#endif

  this.InitialLife = def.life; // Store initial life for transitions
  this.Bounces = def.Bounces;
  this.GroundY = def.GroundY;
  if (this.GroundY <= 0)
  {
    this.GroundY = 16777216; // a big number so it is not reached
  }
  
#ifdef SCRIPT_API_v400
  ovr.BlendMode = def.BlendMode;
#endif
  this._SyncOverlay(ovr);
}

// Update the particle state and sync with overlay
void _Update (this Particle*)
{
  // alive check is done before calling this function
  this.MiliX += this.VelX;
  this.MiliY += this.VelY;
  this.VelY += this.Gravity; // Apply Gravity
  this.X = this.MiliX / INT_FRAC;
  this.Y = this.MiliY / INT_FRAC;

  // Calculate the scaling and Transparency transitions based on life
  int percent = LERP_SCALE - ((this._Life * LERP_SCALE) / this.InitialLife); // 0 to 1024
  this.Transparency = this.TransparencyBegin + ((this.TransparencyDelta) * percent) / LERP_SCALE;
  this.Width = this.WidthBegin + ((this.WidthDelta) * percent) / LERP_SCALE;
  this.Height = this.HeightBegin + ((this.HeightDelta) * percent) / LERP_SCALE;
  if (this.SpriteDelta > 0)
  {
    this.Sprite
        = this.SpriteBegin
          + (this.SpriteCycleOffset + ((this.SpriteDelta) * percent) / LERP_SCALE)
                % this.SpriteDelta;
  }
  
#ifdef SCRIPT_API_v400
  this.Angle += this.RotationSpeed;
  if (this.Angle >= 360.0)
    this.Angle -= 360.0;
  if (this.Angle < 0.0)
    this.Angle += 360.0;
#endif

  int oidx = this._OverlayIdx;
  if (oidx >= 0 && overlayPool[oidx] != null)
  {
    Overlay *ovr = overlayPool[oidx];
    ovr.Transparency = this.Transparency;
    ovr.Graphic = this.Sprite;
#ifdef SCRIPT_API_v362
    ovr.SetPosition (this.X, this.Y, this.Width, this.Height);
#else
    ovr.X = this.X;
    ovr.Y = this.Y;
    ovr.Width = this.Width;
    ovr.Height = this.Height;
#endif
#ifdef SCRIPT_API_v400
    ovr.Rotation = this.Angle;
#endif
  }

  this._Life--;

  if (this.Y >= this.GroundY)
  {
    if (this.Bounces)
    {
      this.VelY = -(this.VelY * 700) / INT_FRAC; // Invert velocity and simulate energy loss
    }
    else
    {
      this._Life = 0; // Mark particle as dead (cheaper than other things...)
    }
  }
}

int get_Life(this Particle*)
{
  return this._Life;
}

void set_Life(this Particle*, int value)
{
  this._Life = value;
}

bool Particle::IsAlive ()
{
  return (this._Life > 0);
}

bool Particle::HitsPoint (int x, int y)
{
  return (x >= this.X) && (y >= this.Y)
        && (x <= this.X + this.Width) && (y <= this.Y + this.Height);
}

bool Particle::HitsRect (int x, int y, int width, int height)
{
  return (x + width >= this.X) && (y + height >= this.Y)
        && (x <= this.X + this.Width) && (y <= this.Y + this.Height);
}



// ---------------------- Emitter methods ----------------------------

void Emitter::SetPosition (int x, int y)
{
  this.X = x;
  this.Y = y;
}

void Emitter::SetParticleDefinitions (ParticleDefinition *defs[], int defCount)
{
  for (int i = 0; i < defCount; i++)
  {
    ParticleDefinition *def = defs[i];
    if (def.SpriteBegin == 0 && def.SpriteEnd == 0)
    {
      def.SpriteBegin = def.Sprite;
      def.SpriteEnd = def.Sprite;
    }
  }

  this.definitions = defs;
  this.definitionsCount = defCount;
}

// Initialize the emitter with position, particle definitions, and other parameters
void Emitter::Init (int x, int y, ParticleDefinition *defs[], int defCount, int emitAmount, int maxParticles)
{
  this.SetPosition (x, y);
  this.maxParticles = maxParticles;
  this.SetParticleDefinitions (defs, defCount);
  this.EmitAmount = emitAmount;
  this.particles = new Particle[maxParticles];
  for (int i = 0; i < maxParticles; i++)
  {
    this.particles[i] = new Particle;
  }
}

// returns TRUE if it emitted the particle SUCCESSFULLY
protected bool Emitter::_EmitParticleIndex (int i)
{
  Particle *p = this.particles[i];
  if (p.IsAlive ())
    return false;
    
  // Reuse dead particle if it's not alive anymore

  this.lastEmittedParticle = i; // remember to speed up loop for looking new particles
  int overlayIndex = FindFreeOverlaySlot ();
  if (overlayIndex >= 0)
  {
    // Randomly select a particle definition from the available definitions
    int defIndex = 0;
    if (this.definitionsCount > 0)
    {
      defIndex = Random (this.definitionsCount - 1);
    }
    ParticleDefinition *def = this.definitions[defIndex];

    if(p._OverlayIdx >= 0) {
      FreeOverlay(p._OverlayIdx);
    }
    if (overlayPool[overlayIndex] == null)
    {
      Overlay *ovr = Overlay.CreateGraphical (this.X, this.Y, def.Sprite);
      overlayPool[overlayIndex] = ovr;
    }
    p._Init (def, this.X, this.Y, overlayPool[overlayIndex]);
    p._OverlayIdx = overlayIndex;
    return true;
  }
  return false;
}

// Emit a single particle from the emitter, returns TRUE for FAILURE
protected bool Emitter::_EmitSingleParticle ()
{
  int i = (this.lastEmittedParticle + 1) % this.maxParticles;

  if (this._EmitParticleIndex (i)) // if it fail to emit, try to find a dead particle to reuse
    return false;

  int loop_at = i;
  for (; i < this.maxParticles; i++)
  {
    if (!this.particles[i].IsAlive ())
    {
      return !this._EmitParticleIndex (i);
    }
  }

  for (i = 0; i < loop_at; i++)
  {
    if (!this.particles[i].IsAlive ())
    {
      return !this._EmitParticleIndex (i);
    }
  }

  return true; // indicates something is wrong
}

// Emit particles from the emitter, returns true on success
bool Emitter::Emit ()
{
  for (int i = 0; i < this.EmitAmount; i++)
  {
    // if we either don't have more overlays or particles, return failure
    if (this._EmitSingleParticle ())
      return false;
  }
  return true;
}

// Update all particles
void Emitter::Update ()
{
  for (int i = 0; i < this.maxParticles; i++)
  {
    if (this.particles[i]._Life > 0)
    {
      this.particles[i]._Update ();
    }
    else if (this.particles[i]._OverlayIdx >= 0)
    {
      this.particles[i]._OverlayIdx = FreeOverlay (this.particles[i]._OverlayIdx);
    }
  }
}

Particle *[] Emitter::ParticlesHitPoint (int x, int y)
{
  int c[] = new int[this.maxParticles];
  int c_count;
  for (int i = 0; i < this.maxParticles; i++)
  {
    Particle *p = this.particles[i];
    if (p.IsAlive () && p.HitsPoint (x, y))
    {
      c[c_count] = i;
      c_count++;
    }
  }
  Particle *ps[] = new Particle[c_count + 1];
  for (int i = 0; i < c_count; i++)
  {
    ps[i] = this.particles[c[i]];
  }
  return ps;
}

Particle *[] Emitter::ParticlesHitRect (int x, int y, int width, int height) 
{
  int c[] = new int[this.maxParticles];
  int c_count = 0;
  for (int i = 0; i < this.maxParticles; i++)
  {
    Particle *p = this.particles[i];
    if (p.IsAlive () && p.HitsRect (x, y, width, height))
    {
      c[c_count] = i;
      c_count++;
    }
  }
  Particle *ps[] = new Particle[c_count + 1];
  for (int i = 0; i < c_count; i++)
  {
    ps[i] = this.particles[c[i]];
  }
  return ps;
}

void ContinuousEmitter::StartEmitting (int emitInterval)
{
  if (emitInterval < 4)
    emitInterval = 4;
  this.emitInterval = emitInterval;
  this.isEmitting = true;
}

void ContinuousEmitter::StopEmitting ()
{
  this.isEmitting = false;
}

void ContinuousEmitter::UpdateContinuous ()
{
  this.Update ();
  
  if (!this.isEmitting) return;
  
  this._emitCooldown--;
  if (this._emitCooldown <= 0)
  {
    bool success = this.Emit ();
    this._emitCooldown = this.emitInterval;
    
    // if we fail to emit we need to increase cooldown
    // we are emitting too many long lived particles per total particle count
    // TODO: test and actually evaluate if this does anything to help
    if(!success) // failed to emit,  increase interval
      this._emitCooldown+=2;
  }
}

// --- Global Particle Emitter for easy quick particle emission

ContinuousEmitter GPE;
export GPE;

void game_start ()
{
  ParticleDefinition *d[];
  int max_particles =  MAX_OVERLAYS; // (MAX_OVERLAYS * 4) / 5;
  int emit_count = max_particles / 16;
  GPE.Init (Screen.Width / 2, Screen.Height / 2, d, 0, emit_count,  max_particles);
}

void repeatedly_execute_always ()
{
  GPE.UpdateContinuous ();
}
[close]

I tried to hide more things in the script to not make the header too daunting and also to keep hidden some interfaces that may change in the future. There is still a lot of stuff leaking, most notably the Particle itself contains a ton of properties I would rather hide completely.

I think modifying the particles, other than their life, should be reserved to the Emitter - if at all. Anyway, if someone has any comments, ideas/suggestions in how to hide more stuff from the header, welcome any suggestions. Also if you catch a code comment in the header that is hard to understand, please tell me.

Edit: remembered the Overlays die when exiting a room, so need to do something to workaround this somehow. Essentially all emitters need to somehow clean their particles on room exit or some other strategy that enable going out of a room and then going back to not have invalid overlay indexes pointed by the particles.

I think I can't clear the particles of all emitters unless the Emitter is like a fake interface to a global emitter that exists behind the scenes. Other approach I can think is to keep both the current internal arrays to particles but also a global one and then I can clear all the particles individually after room fadeout event and then all the particles the emitters points to will be ok in a dead state, even though I don't have access to the emitters directly. Have no idea how to properly do this yet.

eri0o

#17
Code: ags
struct Emitter {
  ...
  String address; // <<-- added this!
  ...
};

OK, came up with a really ugly solution that works. I discovered I can get the address of the current struct using String.Format("%p", this) in the Init method, so I do it and note down in the emitter address property. This works even if the struct is not managed!

I put a global Set, and then when I run the Update of an emitter, I check if it exists in this global set by looking if the set contains it's address, if it doesn't I do what I have to do assuming the room has changed and finally add the emitter address to the Set - so this only actually runs once per room.

Finally, whenever there is a room change, I clear the emitter Set entirely. A single additional if in an Update call is ok performance wise, and tracking only emitters is much lighter than tracking all particles. Next time the update will run for that emitter it will know a room change has happened and deal with it.

Crimson Wizard

#18
Quote from: eri0o on Mon 21/10/2024 04:06:41OK, came up with a really ugly solution that works. I discovered I can get the address of the current struct using String.Format("%p", this) in the Init method, so I do it and note down in the emitter address property. This works even if the struct is not managed!

This will also likely break on save reload, because there's no guarantee that memory address of a object will be the same next time. So this has to be fully reinitialized upon a save restore too (in on_event).


EDIT: hmm, this may accidentally work so long as Emitter instance is a global script variable, because currently script's data arrays are normally not getting reallocated on save restore, so anything in global script data may happen to retain its address between reloads. The memory layout may be different next time a game is launched.

Then, if we take AGS 4, it already allows to have nested structs of any combination, and dynamic arrays of regular structs.
This means that Emitter, not being a managed struct, still may be a part of managed object, and gets removed and recreated in mem on restoring a save.

Anyway, this is just a analysis of possible situations, TLDR is that this value must be treated as invalid after restoring a save.

EDIT2: hmm, speaking of dynamic objects, note that if Emitter instance is a part of a managed struct or dynamic array, then it may be deleted (also, accidentally a new emitter may get same address as a previously deleted one). So there has to be a "Dispose" method which removes Emitter from this set too.

eri0o

Thanks CW, those are very good remarks! Switched the hacked address to instead using a simple int as uID, and now on init of the emitter if it's not set yet, the global uID is incremented and the value is assigned to the emitter.

The emitter is not a managed struct, it's a simple struct, and it's alright to linger a little in the global set, since the global set is cleared on every room transition.

It seems that I finally got the code to be bug free, I will mostly do a few additional tests to ensure I am not doing anything stupid and I think I will publish a beta version of the module sometime this week.

I ended up stealing your object pool design to the management of overlays, and I think at some point I will also do it for the management of particles, since both are intended to be handled as pools - but in the case of the particles I will probably leave this part of refactoring to a bit in the future since it's mostly meant to improve performance and avoid unnecessary loops.

SMF spam blocked by CleanTalk