From f9e984d4c29b76e2098be322a5049051197e09f5 Mon Sep 17 00:00:00 2001 From: Ananim353 Date: Tue, 4 Aug 2026 02:48:54 +0300 Subject: [PATCH 1/6] Implement the GameMaker particle system Nothing under part_* existed, so any game that used particles simply drew nothing where an effect belonged, with no diagnostic. DELTARUNE Chapters 4 and 5 lose their falling leaves, drifting dust, confetti and cherry blossom petals to this; the calls all resolved to "unknown function". Adds src/particles.{c,h} with the three GameMaker resources -- systems own emitters and live particles, types are global and describe appearance and motion -- plus 23 builtins covering system, type and emitter management. Both pools hang off Runner and reuse destroyed ids, the same tombstone convention the ds_* pools already use, because games depend on it. Systems with automatic drawing enter the depth-sorted drawable list as a new DRAWABLE_PARTICLE_SYSTEM entry, so part_system_depth places particles among instances and tiles instead of needing a separate pass. Automatic updates run once at the end of Runner_step, after End Step and before the draw pass. Two deliberate choices worth calling out: * Particles draw from their own random stream rather than rand(). Sharing it would make every particle spawn shift the sequence the game itself sees, so adding an effect to a scene would perturb unrelated randomised behaviour and every seeded screenshot test with it. The cost is that --seed and randomize() do not reach particles. * PARTICLE_SYSTEM_MAX_PARTICLES caps a system at 8192 live particles, warning once when it is hit. GameMaker has no such limit, but an emitter left streaming in a room the player never leaves grows without bound, which the console targets cannot absorb. Ranges are stored exactly as the game passes them, without normalising min against max: GameMaker evaluates "min + random * (max - min)", and Chapter 4 passes part_type_direction(-45, -90), which sweeps downward. Swapping the bounds would flip the spray. Verified on Linux/SDL2 against DELTARUNE Chapter 4 and 5 data: every part_* call now resolves (checked with --print-unknown-functions; vertex_* still reports as unknown, so the check is measuring something). The existing screenshot tests are unaffected -- loritta-and-the-stars at frame 9000 and the deltarune-chapter4 jackenstein path test at frames 9473 and 9474 all still match their expected images byte for byte. --- src/particles.c | 471 ++++++++++++++++++++++++++++++++++++++++++++++ src/particles.h | 128 +++++++++++++ src/runner.c | 29 +++ src/runner.h | 9 +- src/vm_builtins.c | 197 +++++++++++++++++++ 5 files changed, 833 insertions(+), 1 deletion(-) create mode 100644 src/particles.c create mode 100644 src/particles.h diff --git a/src/particles.c b/src/particles.c new file mode 100644 index 000000000..e6bc1b7d9 --- /dev/null +++ b/src/particles.c @@ -0,0 +1,471 @@ +#include "particles.h" + +#include "log.h" +#include "math_compat.h" +#include "renderer.h" +#include "runner.h" +#include "utils.h" + +#include "stb_ds.h" + +#define PARTICLE_DEG2RAD (M_PI / 180.0) + +// Particles draw from their own random stream instead of rand(). Sharing rand() would make every +// particle spawn shift the sequence the game itself sees, so merely adding a particle effect to a +// scene would change unrelated randomised behaviour (and every seeded screenshot test with it). +// The trade-off is that --seed and randomize() do not reach particles. +static uint32_t g_particleRngState = 0x9E3779B9u; + +static uint32_t particleRandomBits(void) { + uint32_t x = g_particleRngState; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + g_particleRngState = x; + return x; +} + +// Uniform in [0, 1). +static GMLReal particleRandom01(void) { + return (GMLReal) (particleRandomBits() >> 8) / (GMLReal) 0x01000000u; +} + +// Uniform between the two bounds. Deliberately NOT normalised to (min <= max): GameMaker computes +// "min + random * (max - min)", and games depend on the reversed form. part_type_direction(-45, -90) +// in DELTARUNE Chapter 4 sweeps downward from -45, and swapping the bounds would flip the spray. +static GMLReal particleRandomRange(GMLReal min, GMLReal max) { + return min + particleRandom01() * (max - min); +} + +// Triangle wave in [-1, 1] driven by the particle's phase counter. GameMaker does not document its +// wiggle period; this approximates the oscillation without a sin() per property per particle per frame. +static GMLReal particleWiggle(uint8_t phase) { + GMLReal t = (GMLReal) phase / 128.0; // 0..2 + return (1.0 > t) ? (t * 2.0 - 1.0) : (3.0 - t * 2.0); +} + +// "number" follows the GML convention shared by part_emitter_stream and part_type_death: a positive +// value is a literal count, a negative value is a 1-in-|number| chance of spawning a single particle. +static int32_t particleResolveCount(int32_t number) { + if (number >= 0) return number; + int32_t chance = -number; + return ((int32_t) (particleRandomBits() % (uint32_t) chance) == 0) ? 1 : 0; +} + +// ===[ Pools ]=== + +ParticleSystem* Particles_systemGet(Runner* runner, int32_t systemId) { + if (0 > systemId || systemId >= (int32_t) arrlen(runner->particleSystemPool)) return nullptr; + ParticleSystem* system = &runner->particleSystemPool[systemId]; + return system->used ? system : nullptr; +} + +ParticleType* Particles_typeGet(Runner* runner, int32_t typeId) { + if (0 > typeId || typeId >= (int32_t) arrlen(runner->particleTypePool)) return nullptr; + ParticleType* type = &runner->particleTypePool[typeId]; + return type->used ? type : nullptr; +} + +int32_t Particles_systemCreate(Runner* runner) { + int32_t poolSize = (int32_t) arrlen(runner->particleSystemPool); + int32_t id = poolSize; + repeat(poolSize, i) { + if (!runner->particleSystemPool[i].used) { id = (int32_t) i; break; } + } + + ParticleSystem system; + ZERO_STRUCT(system); + system.used = true; + system.automaticUpdate = true; + system.automaticDraw = true; + system.depth = 0; + + if (id == poolSize) { + arrput(runner->particleSystemPool, system); + } else { + runner->particleSystemPool[id] = system; + } + + // The system joins the depth-sorted draw list while automaticDraw is set. + runner->drawableListStructureDirty = true; + return id; +} + +void Particles_systemDestroy(Runner* runner, int32_t systemId) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr) return; + + arrfree(system->particles); + arrfree(system->emitters); + ZERO_STRUCT(*system); + runner->drawableListStructureDirty = true; +} + +void Particles_systemSetDepth(Runner* runner, int32_t systemId, int32_t depth) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr || system->depth == depth) return; + system->depth = depth; + runner->drawableListSortDirty = true; +} + +void Particles_systemSetAutomaticDraw(Runner* runner, int32_t systemId, bool automatic) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr || system->automaticDraw == automatic) return; + system->automaticDraw = automatic; + // Entering or leaving the depth list changes the SET of drawables, not just their order. + runner->drawableListStructureDirty = true; +} + +int32_t Particles_typeCreate(Runner* runner) { + int32_t poolSize = (int32_t) arrlen(runner->particleTypePool); + int32_t id = poolSize; + repeat(poolSize, i) { + if (!runner->particleTypePool[i].used) { id = (int32_t) i; break; } + } + + // GameMaker's defaults for a fresh type: a single white pixel-sized particle, no motion, 100 steps. + ParticleType type; + ZERO_STRUCT(type); + type.used = true; + type.sprite = -1; + type.sizeMin = 1.0; + type.sizeMax = 1.0; + type.scaleX = 1.0; + type.scaleY = 1.0; + type.lifeMin = 100; + type.lifeMax = 100; + type.alphaStart = 1.0; + type.alphaMiddle = 1.0; + type.alphaEnd = 1.0; + type.deathType = -1; + + if (id == poolSize) { + arrput(runner->particleTypePool, type); + } else { + runner->particleTypePool[id] = type; + } + return id; +} + +void Particles_typeDestroy(Runner* runner, int32_t typeId) { + ParticleType* type = Particles_typeGet(runner, typeId); + if (type == nullptr) return; + ZERO_STRUCT(*type); + // Particles already alive keep their typeId. Drawing and stepping both resolve the type every + // frame and skip when it is gone, so a destroyed type simply stops its remaining particles. +} + +int32_t Particles_emitterCreate(Runner* runner, int32_t systemId) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr) return -1; + + int32_t count = (int32_t) arrlen(system->emitters); + int32_t id = count; + repeat(count, i) { + if (!system->emitters[i].used) { id = (int32_t) i; break; } + } + + ParticleEmitter emitter; + ZERO_STRUCT(emitter); + emitter.used = true; + emitter.shape = PS_SHAPE_RECTANGLE; + emitter.distribution = PS_DISTR_LINEAR; + emitter.streamType = -1; + + if (id == count) { + arrput(system->emitters, emitter); + } else { + system->emitters[id] = emitter; + } + return id; +} + +ParticleEmitter* Particles_emitterGet(Runner* runner, int32_t systemId, int32_t emitterId) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr) return nullptr; + if (0 > emitterId || emitterId >= (int32_t) arrlen(system->emitters)) return nullptr; + ParticleEmitter* emitter = &system->emitters[emitterId]; + return emitter->used ? emitter : nullptr; +} + +void Particles_emitterDestroy(Runner* runner, int32_t systemId, int32_t emitterId) { + ParticleEmitter* emitter = Particles_emitterGet(runner, systemId, emitterId); + if (emitter == nullptr) return; + ZERO_STRUCT(*emitter); +} + +void Particles_emitterDestroyAll(Runner* runner, int32_t systemId) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr) return; + arrsetlen(system->emitters, 0); +} + +// ===[ Spawning ]=== + +static void particleSpawnAt(Runner* runner, ParticleSystem* system, int32_t typeId, GMLReal x, GMLReal y) { + ParticleType* type = Particles_typeGet(runner, typeId); + if (type == nullptr) return; + + if ((int32_t) arrlen(system->particles) >= PARTICLE_SYSTEM_MAX_PARTICLES) { + if (!system->warnedFull) { + system->warnedFull = true; + logWarn("Particles: system hit the %d particle cap, further spawns are dropped\n", PARTICLE_SYSTEM_MAX_PARTICLES); + } + return; + } + + Particle particle; + ZERO_STRUCT(particle); + particle.typeId = typeId; + particle.x = x; + particle.y = y; + particle.speed = particleRandomRange(type->speedMin, type->speedMax); + particle.direction = particleRandomRange(type->dirMin, type->dirMax); + particle.size = particleRandomRange(type->sizeMin, type->sizeMax); + particle.lifeTotal = (int32_t) particleRandomRange((GMLReal) type->lifeMin, (GMLReal) type->lifeMax); + if (1 > particle.lifeTotal) particle.lifeTotal = 1; + particle.life = particle.lifeTotal; + particle.phase = (uint8_t) (particleRandomBits() & 0xFFu); + + if (type->spriteRandom && type->sprite >= 0 && runner->dataWin != nullptr && (uint32_t) type->sprite < runner->dataWin->sprt.count) { + uint32_t frames = runner->dataWin->sprt.sprites[type->sprite].textureCount; + if (frames > 0) particle.subimgBase = (int32_t) (particleRandomBits() % frames); + } + + arrput(system->particles, particle); +} + +// Picks a point inside the emitter's region. Only the linear distribution is modelled; the gaussian +// ones fall back to it (no game we test against uses them, and guessing at the curve would be worse +// than an honest uniform spread). +static void particleEmitterPoint(ParticleEmitter* emitter, GMLReal* outX, GMLReal* outY) { + GMLReal x = particleRandomRange(emitter->xmin, emitter->xmax); + GMLReal y = particleRandomRange(emitter->ymin, emitter->ymax); + + GMLReal centerX = (emitter->xmin + emitter->xmax) * 0.5; + GMLReal centerY = (emitter->ymin + emitter->ymax) * 0.5; + GMLReal halfW = (emitter->xmax - emitter->xmin) * 0.5; + GMLReal halfH = (emitter->ymax - emitter->ymin) * 0.5; + + if (emitter->shape == PS_SHAPE_ELLIPSE || emitter->shape == PS_SHAPE_DIAMOND) { + // Rejection sampling keeps the spread uniform. The regions are small and the acceptance rate + // is 0.79 (ellipse) / 0.5 (diamond), so the loop is bounded in practice; cap it anyway. + repeat(8, attempt) { + GMLReal nx = (halfW > 0.0) ? (x - centerX) / halfW : 0.0; + GMLReal ny = (halfH > 0.0) ? (y - centerY) / halfH : 0.0; + bool inside = (emitter->shape == PS_SHAPE_ELLIPSE) + ? (nx * nx + ny * ny <= 1.0) + : (GMLReal_fabs(nx) + GMLReal_fabs(ny) <= 1.0); + if (inside) break; + x = particleRandomRange(emitter->xmin, emitter->xmax); + y = particleRandomRange(emitter->ymin, emitter->ymax); + } + } else if (emitter->shape == PS_SHAPE_LINE) { + // A line from (xmin, ymin) to (xmax, ymax), not the rectangle they bound. + GMLReal t = particleRandom01(); + x = emitter->xmin + (emitter->xmax - emitter->xmin) * t; + y = emitter->ymin + (emitter->ymax - emitter->ymin) * t; + } + + *outX = x; + *outY = y; +} + +static void particleEmitterSpawn(Runner* runner, ParticleSystem* system, ParticleEmitter* emitter, int32_t typeId, int32_t count) { + repeat(count, i) { + GMLReal x, y; + particleEmitterPoint(emitter, &x, &y); + particleSpawnAt(runner, system, typeId, x, y); + } +} + +void Particles_emitterBurst(Runner* runner, int32_t systemId, int32_t emitterId, int32_t typeId, int32_t number) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + ParticleEmitter* emitter = Particles_emitterGet(runner, systemId, emitterId); + if (system == nullptr || emitter == nullptr) return; + particleEmitterSpawn(runner, system, emitter, typeId, particleResolveCount(number)); +} + +// ===[ Update ]=== + +void Particles_updateSystem(Runner* runner, int32_t systemId) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr) return; + + // Emitters stream first, so a particle spawned this step also moves this step (as in GameMaker). + int32_t emitterCount = (int32_t) arrlen(system->emitters); + repeat(emitterCount, i) { + ParticleEmitter* emitter = &system->emitters[i]; + if (!emitter->used || 0 > emitter->streamType || emitter->streamNumber == 0) continue; + particleEmitterSpawn(runner, system, emitter, emitter->streamType, particleResolveCount(emitter->streamNumber)); + } + + // Deaths are collected and spawned after the movement pass: spawning mid-loop can realloc the + // array out from under the iteration, and a death particle must not be stepped on its spawn frame. + // Only allocated when a type actually has a death type, which is rare. + typedef struct { int32_t typeId; GMLReal x, y; int32_t count; } PendingDeath; + PendingDeath* deaths = nullptr; + + int32_t index = 0; + while (index < (int32_t) arrlen(system->particles)) { + Particle* particle = &system->particles[index]; + ParticleType* type = Particles_typeGet(runner, particle->typeId); + + if (type == nullptr) { + // The type was destroyed underneath us; drop the particle instead of stepping a dead one. + system->particles[index] = arrlast(system->particles); + arrpop(system->particles); + continue; + } + + GMLReal wiggle = particleWiggle(particle->phase); + GMLReal effectiveSpeed = particle->speed + type->speedWiggle * wiggle; + GMLReal effectiveDirection = particle->direction + type->dirWiggle * wiggle; + + GMLReal radians = effectiveDirection * PARTICLE_DEG2RAD; + particle->x += effectiveSpeed * GMLReal_cos(radians); + particle->y -= effectiveSpeed * GMLReal_sin(radians); // GML's y axis grows downward + + if (type->gravityAmount != 0.0) { + // Gravity folds into the velocity vector permanently, so later speed/direction increments + // apply on top of it. Matches GameMaker, where gravity bends a particle's course for good. + GMLReal baseRadians = particle->direction * PARTICLE_DEG2RAD; + GMLReal gravityRadians = type->gravityDirection * PARTICLE_DEG2RAD; + GMLReal hspeed = particle->speed * GMLReal_cos(baseRadians) + type->gravityAmount * GMLReal_cos(gravityRadians); + GMLReal vspeed = -particle->speed * GMLReal_sin(baseRadians) - type->gravityAmount * GMLReal_sin(gravityRadians); + particle->speed = GMLReal_sqrt(hspeed * hspeed + vspeed * vspeed); + if (hspeed != 0.0 || vspeed != 0.0) + particle->direction = GMLReal_atan2(-vspeed, hspeed) / PARTICLE_DEG2RAD; + } + + particle->speed += type->speedIncr; + if (0.0 > particle->speed) particle->speed = 0.0; // GameMaker never lets a particle reverse + particle->direction += type->dirIncr; + particle->size += type->sizeIncr; + if (0.0 > particle->size) particle->size = 0.0; + + particle->phase = (uint8_t) ((particle->phase + 8u) & 0xFFu); + particle->life--; + + if (particle->life > 0) { + index++; + continue; + } + + if (type->deathType >= 0 && type->deathNumber != 0) { + int32_t count = particleResolveCount(type->deathNumber); + if (count > 0) { + PendingDeath death; + death.typeId = type->deathType; + death.x = particle->x; + death.y = particle->y; + death.count = count; + arrput(deaths, death); + } + } + + // Swap-remove: order within a system does not affect the drawn result, every particle of a + // system is drawn in the same pass at the same depth. + system->particles[index] = arrlast(system->particles); + arrpop(system->particles); + } + + repeat((int32_t) arrlen(deaths), i) { + repeat(deaths[i].count, n) { + particleSpawnAt(runner, system, deaths[i].typeId, deaths[i].x, deaths[i].y); + } + } + arrfree(deaths); +} + +void Particles_updateAutomatic(Runner* runner) { + int32_t count = (int32_t) arrlen(runner->particleSystemPool); + repeat(count, i) { + ParticleSystem* system = &runner->particleSystemPool[i]; + if (!system->used || !system->automaticUpdate) continue; + Particles_updateSystem(runner, (int32_t) i); + } +} + +// ===[ Draw ]=== + +// Alpha follows the three stop points across the particle's life: start -> middle at the halfway +// mark -> end. part_type_alpha1/alpha2 are expressed by collapsing the stops onto each other. +static GMLReal particleAlphaAt(const ParticleType* type, GMLReal ageFraction) { + if (0.5 > ageFraction) { + GMLReal t = ageFraction * 2.0; + return type->alphaStart + (type->alphaMiddle - type->alphaStart) * t; + } + GMLReal t = (ageFraction - 0.5) * 2.0; + return type->alphaMiddle + (type->alphaEnd - type->alphaMiddle) * t; +} + +void Particles_drawSystem(Runner* runner, int32_t systemId) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr || runner->renderer == nullptr) return; + + int32_t count = (int32_t) arrlen(system->particles); + if (count == 0) return; + + Renderer* renderer = runner->renderer; + bool blendChanged = false; + bool additiveActive = false; + + repeat(count, i) { + Particle* particle = &system->particles[i]; + ParticleType* type = Particles_typeGet(runner, particle->typeId); + if (type == nullptr || 0 > type->sprite) continue; + + GMLReal ageFraction = 1.0 - ((GMLReal) particle->life / (GMLReal) particle->lifeTotal); + GMLReal alpha = particleAlphaAt(type, ageFraction); + if (0.0 >= alpha) continue; + if (alpha > 1.0) alpha = 1.0; + + GMLReal size = particle->size + type->sizeWiggle * particleWiggle(particle->phase); + if (0.0 >= size) continue; + + int32_t subimg = particle->subimgBase; + if (type->spriteAnimate) { + if (type->spriteStretch) { + // One full animation cycle stretched over the particle's whole life. + uint32_t frames = ((uint32_t) type->sprite < runner->dataWin->sprt.count) + ? runner->dataWin->sprt.sprites[type->sprite].textureCount : 0; + if (frames > 0) subimg += (int32_t) (ageFraction * (GMLReal) frames); + } else { + subimg += particle->lifeTotal - particle->life; + } + } + + // Only touched when an additive type is actually present, so a system of ordinary particles + // leaves whatever blend mode the caller had set alone. There is no way to read the current + // mode back out of the renderer, so once we do touch it the restore below can only go to + // bm_normal, which is the mode GameMaker itself leaves behind after drawing a system. + if (type->additive != additiveActive) { + renderer->vtable->gpuSetBlendMode(renderer, type->additive ? bm_add : bm_normal); + additiveActive = type->additive; + blendChanged = true; + } + + Renderer_drawSpriteExt(renderer, type->sprite, subimg, + (float) particle->x, (float) particle->y, + (float) (type->scaleX * size), (float) (type->scaleY * size), + 0.0f, 0xFFFFFFu, (float) alpha); + } + + if (blendChanged && additiveActive) + renderer->vtable->gpuSetBlendMode(renderer, bm_normal); +} + +// ===[ Teardown ]=== + +void Particles_freeAll(Runner* runner) { + int32_t count = (int32_t) arrlen(runner->particleSystemPool); + repeat(count, i) { + arrfree(runner->particleSystemPool[i].particles); + arrfree(runner->particleSystemPool[i].emitters); + } + arrfree(runner->particleSystemPool); + runner->particleSystemPool = nullptr; + arrfree(runner->particleTypePool); + runner->particleTypePool = nullptr; +} diff --git a/src/particles.h b/src/particles.h new file mode 100644 index 000000000..00d0ab963 --- /dev/null +++ b/src/particles.h @@ -0,0 +1,128 @@ +#ifndef _BS_PARTICLES_H_ +#define _BS_PARTICLES_H_ + +#include "common.h" +#include "real_type.h" +#include + +// Forward declarations +#ifndef RUNNER_DEFINED +#define RUNNER_DEFINED +typedef struct Runner Runner; +#endif + +// ===[ Particle System ]=== +// GameMaker splits particles into three resources: +// * a SYSTEM owns the live particles and the emitters that spawn them, and decides when they are drawn +// * a TYPE describes how a particle looks and moves; types are global, so any system can stream any type +// * an EMITTER is owned by one system and spawns particles of a given type inside a region +// +// Ids are indices into pools hanging off the Runner, with a "used" tombstone so a destroyed id can be +// handed out again. Same convention as the ds_* pools in vm_builtins.c, and games do rely on it. + +// part_emitter_region() shape constants +#define PS_SHAPE_RECTANGLE 0 +#define PS_SHAPE_ELLIPSE 1 +#define PS_SHAPE_DIAMOND 2 +#define PS_SHAPE_LINE 3 + +// part_emitter_region() distribution constants +#define PS_DISTR_LINEAR 0 +#define PS_DISTR_GAUSSIAN 1 +#define PS_DISTR_INVGAUSS 2 + +// Upper bound on live particles per system. GameMaker itself has no such limit, but an emitter left +// streaming in a room the player never leaves will grow without bound, and the consoles this runner +// targets cannot absorb that. Spawns past the cap are dropped (warned about once per system). +#define PARTICLE_SYSTEM_MAX_PARTICLES 8192 + +typedef struct { + bool used; + + int32_t sprite; // sprite asset index, -1 when the type has no sprite (draws nothing) + bool spriteAnimate; // advance the subimage as the particle ages + bool spriteStretch; // stretch one full animation cycle across the particle's whole life + bool spriteRandom; // start from a random subimage + + // Every "min/max/incr/wiggle" quadruple works the same way: the initial value is picked uniformly + // in [min, max], "incr" is added every step, and "wiggle" oscillates the value used for motion and + // drawing without accumulating into the base. + GMLReal sizeMin, sizeMax, sizeIncr, sizeWiggle; + GMLReal scaleX, scaleY; + GMLReal speedMin, speedMax, speedIncr, speedWiggle; + GMLReal dirMin, dirMax, dirIncr, dirWiggle; + + GMLReal gravityAmount; + GMLReal gravityDirection; + + int32_t lifeMin, lifeMax; + + GMLReal alphaStart, alphaMiddle, alphaEnd; + bool additive; + + int32_t deathType; // type id spawned when a particle of this type dies, -1 when none + int32_t deathNumber; // how many to spawn; negative means a 1-in-|n| chance +} ParticleType; + +typedef struct { + int32_t typeId; + GMLReal x, y; + GMLReal speed; // base speed, before wiggle + GMLReal direction; // base direction in degrees, before wiggle + GMLReal size; // base size, before wiggle + int32_t life; // steps remaining + int32_t lifeTotal; // steps this particle started with, for the alpha/animation curves + int32_t subimgBase; // starting subimage + uint8_t phase; // wiggle phase, advanced every step +} Particle; + +typedef struct { + bool used; + GMLReal xmin, xmax, ymin, ymax; + int32_t shape; + int32_t distribution; + int32_t streamType; // type id streamed every step, -1 when the emitter is idle + int32_t streamNumber; // particles per step; negative means a 1-in-|n| chance +} ParticleEmitter; + +typedef struct { + bool used; + bool automaticUpdate; // step the system at the end of every frame (on by default, as in GML) + bool automaticDraw; // draw the system from the depth list (on by default, as in GML) + int32_t depth; + bool warnedFull; // the "hit PARTICLE_SYSTEM_MAX_PARTICLES" warning fires once per system + Particle* particles; // stb_ds array + ParticleEmitter* emitters; // stb_ds array, index = emitter id within this system +} ParticleSystem; + +// ===[ Systems ]=== +int32_t Particles_systemCreate(Runner* runner); +void Particles_systemDestroy(Runner* runner, int32_t systemId); +ParticleSystem* Particles_systemGet(Runner* runner, int32_t systemId); +void Particles_systemSetDepth(Runner* runner, int32_t systemId, int32_t depth); +void Particles_systemSetAutomaticDraw(Runner* runner, int32_t systemId, bool automatic); + +// ===[ Types ]=== +int32_t Particles_typeCreate(Runner* runner); +void Particles_typeDestroy(Runner* runner, int32_t typeId); +ParticleType* Particles_typeGet(Runner* runner, int32_t typeId); + +// ===[ Emitters ]=== +int32_t Particles_emitterCreate(Runner* runner, int32_t systemId); +ParticleEmitter* Particles_emitterGet(Runner* runner, int32_t systemId, int32_t emitterId); +void Particles_emitterDestroy(Runner* runner, int32_t systemId, int32_t emitterId); +void Particles_emitterDestroyAll(Runner* runner, int32_t systemId); +void Particles_emitterBurst(Runner* runner, int32_t systemId, int32_t emitterId, int32_t typeId, int32_t number); + +// ===[ Frame hooks ]=== +// Steps one system: emitters stream, particles move and age, dead particles run their death spawn. +void Particles_updateSystem(Runner* runner, int32_t systemId); +// Steps every system with automaticUpdate set. Called once at the end of Runner_step. +void Particles_updateAutomatic(Runner* runner); +// Draws one system at the current draw state. Backs part_system_drawit and the depth-list entry. +void Particles_drawSystem(Runner* runner, int32_t systemId); + +// Frees both pools. Called from the Runner's cleanup path. +void Particles_freeAll(Runner* runner); + +#endif /* _BS_PARTICLES_H_ */ diff --git a/src/runner.c b/src/runner.c index 3f0f7d571..3954f3b5b 100644 --- a/src/runner.c +++ b/src/runner.c @@ -600,6 +600,7 @@ static DrawKey drawableKey(const Drawable* d) { case DRAWABLE_TILE: k.order = d->tileIndex; break; case DRAWABLE_INSTANCE: k.order = (int32_t) d->instance->instanceId; break; case DRAWABLE_LAYER: k.order = d->runtimeLayerId; break; + case DRAWABLE_PARTICLE_SYSTEM: k.order = d->particleSystemId; break; } return k; } @@ -726,6 +727,9 @@ static void refreshDrawableDepths(Runner* runner, Drawable* drawables, int32_t c } else if (d->type == DRAWABLE_LAYER) { RuntimeLayer* rl = Runner_findRuntimeLayerById(runner, d->runtimeLayerId); if (rl != nullptr) d->depth = rl->depth; + } else if (d->type == DRAWABLE_PARTICLE_SYSTEM) { + ParticleSystem* ps = Particles_systemGet(runner, d->particleSystemId); + if (ps != nullptr) d->depth = ps->depth; } } } @@ -777,6 +781,19 @@ static void rebuildDrawableCacheIfDirty(Runner* runner) { } } + // Particle systems are not room-scoped: a system created in one room keeps running until the + // game destroys it, so they are re-added on every rebuild rather than tracked per room. + repeat((int32_t) arrlen(runner->particleSystemPool), i) { + ParticleSystem* particleSystem = &runner->particleSystemPool[i]; + if (!particleSystem->used || !particleSystem->automaticDraw) continue; + Drawable d; + ZERO_STRUCT(d); + d.type = DRAWABLE_PARTICLE_SYSTEM; + d.depth = particleSystem->depth; + d.particleSystemId = (int32_t) i; + arrput(runner->cachedDrawables, d); + } + int32_t count = (int32_t) arrlen(runner->cachedDrawables); if (count > 1) { qsort(runner->cachedDrawables, count, sizeof(Drawable), compareDrawables); @@ -909,6 +926,12 @@ void Runner_draw(Runner* runner) { } else if (runner->renderer != nullptr) { Renderer_drawSelf(runner->renderer, inst); } + } else if (d->type == DRAWABLE_PARTICLE_SYSTEM) { + // Filtered at draw time, like instance visibility: part_system_automatic_draw can be + // toggled from a Draw event that already ran this frame. + ParticleSystem* particleSystem = Particles_systemGet(runner, d->particleSystemId); + if (particleSystem == nullptr || !particleSystem->automaticDraw) continue; + Particles_drawSystem(runner, d->particleSystemId); } else if (d->type == DRAWABLE_LAYER) { // Re-resolve every iteration: a previous instance's Draw event may have called layer_create/layer_destroy and reallocated runner->runtimeLayers. RuntimeLayer* runtimeLayer = Runner_findRuntimeLayerById(runner, d->runtimeLayerId); @@ -1806,6 +1829,8 @@ static void cleanupState(Runner* runner) { } runner->savedRoomStates = nullptr; + Particles_freeAll(runner); + // Drain ds_map/ds_list pools BEFORE bulk-freeing struct instances. Their RValue entries may hold RVALUE_STRUCT refs to structs in runner->structInstances, and RValue_free would deref freed memory if the structs are gone. { repeat((int32_t) arrlen(runner->dsMapPool), i) { @@ -3982,6 +4007,10 @@ void Runner_step(Runner* runner) { // Execute End Step for all instances Runner_executeEventForAll(runner, EVENT_STEP, STEP_END); + // Step particle systems left on automatic update. After End Step, so a system whose emitters were + // just reconfigured streams with this frame's settings, and before the draw pass that shows them. + Particles_updateAutomatic(runner); + // Update view following updateViews(runner); diff --git a/src/runner.h b/src/runner.h index 6884f43a2..5d922ac07 100644 --- a/src/runner.h +++ b/src/runner.h @@ -8,6 +8,7 @@ #include "file_system.h" #include "ini.h" #include "instance.h" +#include "particles.h" #include "renderer.h" #include "runner_keyboard.h" #include "spatial_grid.h" @@ -256,7 +257,7 @@ typedef struct { // A single entry in the depth-sorted draw list. Cached on Runner and rebuilt lazily based on Runner.drawableListStructureDirty / drawableListSortDirty. // Filtering on instance->active/visible and runtimeLayer->visible happens at draw time so toggling those does not require invalidating the cache. -typedef enum { DRAWABLE_TILE, DRAWABLE_INSTANCE, DRAWABLE_LAYER } DrawableType; +typedef enum { DRAWABLE_TILE, DRAWABLE_INSTANCE, DRAWABLE_LAYER, DRAWABLE_PARTICLE_SYSTEM } DrawableType; typedef struct { DrawableType type; @@ -266,6 +267,8 @@ typedef struct { int32_t tileIndex; // Stored as an ID (resolved via Runner_findRuntimeLayerById) instead of a pointer, because layer_create can call arrput on runner->runtimeLayers mid-draw and realloc the array, invalidating any cached pointers. int32_t runtimeLayerId; + // Same reasoning as runtimeLayerId: part_system_create during a draw event can realloc the pool. + int32_t particleSystemId; }; } Drawable; @@ -550,6 +553,10 @@ struct Runner { DsGrid* dsGridPool; // stb_ds array of DsGrid GmlBuffer* gmlBufferPool; // stb_ds array of GmlBuffer MpGrid* mpGridPool; // stb_ds array of motion-planning grids + // Particle systems own their emitters and live particles; types are global and can be streamed by + // any system's emitters. Both pools reuse destroyed slots, matching the ds_* id behaviour. + ParticleSystem* particleSystemPool; // stb_ds array of ParticleSystem + ParticleType* particleTypePool; // stb_ds array of ParticleType // Motion planning potential field settings GMLReal mpPotMaxrot; diff --git a/src/vm_builtins.c b/src/vm_builtins.c index 5b7307b26..f709ee5c9 100644 --- a/src/vm_builtins.c +++ b/src/vm_builtins.c @@ -16499,6 +16499,177 @@ static RValue builtin_sprite_get_info(VMContext* ctx, RValue* args, int32_t argC return RValue_makeStructAndIncRef(ret); } +// ===[ PARTICLE FUNCTIONS ]=== +// Thin bindings over particles.c. Everything that can be asked of a dead id returns undefined rather +// than faulting, matching GameMaker, where calling a part_* setter on a destroyed id is a silent no-op. + +static RValue builtin_part_system_create(VMContext* ctx, MAYBE_UNUSED RValue* args, MAYBE_UNUSED int32_t argCount) { + return RValue_makeReal((GMLReal) Particles_systemCreate(ctx->runner)); +} + +static RValue builtin_part_system_destroy(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_systemDestroy(ctx->runner, RValue_toInt32(args[0])); + return RValue_makeUndefined(); +} + +static RValue builtin_part_system_depth(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_systemSetDepth(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])); + return RValue_makeUndefined(); +} + +static RValue builtin_part_system_automatic_draw(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_systemSetAutomaticDraw(ctx->runner, RValue_toInt32(args[0]), RValue_toBool(args[1])); + return RValue_makeUndefined(); +} + +static RValue builtin_part_system_update(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_updateSystem(ctx->runner, RValue_toInt32(args[0])); + return RValue_makeUndefined(); +} + +static RValue builtin_part_system_drawit(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_drawSystem(ctx->runner, RValue_toInt32(args[0])); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_create(VMContext* ctx, MAYBE_UNUSED RValue* args, MAYBE_UNUSED int32_t argCount) { + return RValue_makeReal((GMLReal) Particles_typeCreate(ctx->runner)); +} + +static RValue builtin_part_type_destroy(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_typeDestroy(ctx->runner, RValue_toInt32(args[0])); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_sprite(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->sprite = RValue_toInt32(args[1]); + type->spriteAnimate = RValue_toBool(args[2]); + type->spriteStretch = RValue_toBool(args[3]); + type->spriteRandom = RValue_toBool(args[4]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_size(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->sizeMin = RValue_toReal(args[1]); + type->sizeMax = RValue_toReal(args[2]); + type->sizeIncr = RValue_toReal(args[3]); + type->sizeWiggle = RValue_toReal(args[4]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_scale(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->scaleX = RValue_toReal(args[1]); + type->scaleY = RValue_toReal(args[2]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_speed(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->speedMin = RValue_toReal(args[1]); + type->speedMax = RValue_toReal(args[2]); + type->speedIncr = RValue_toReal(args[3]); + type->speedWiggle = RValue_toReal(args[4]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_direction(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + // Stored unnormalised on purpose: games pass reversed ranges (DELTARUNE Chapter 4 uses -45 to -90) + // and expect GameMaker's "min + random * (max - min)", which sweeps downward. + type->dirMin = RValue_toReal(args[1]); + type->dirMax = RValue_toReal(args[2]); + type->dirIncr = RValue_toReal(args[3]); + type->dirWiggle = RValue_toReal(args[4]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_gravity(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->gravityAmount = RValue_toReal(args[1]); + type->gravityDirection = RValue_toReal(args[2]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_life(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->lifeMin = RValue_toInt32(args[1]); + type->lifeMax = RValue_toInt32(args[2]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_alpha3(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->alphaStart = RValue_toReal(args[1]); + type->alphaMiddle = RValue_toReal(args[2]); + type->alphaEnd = RValue_toReal(args[3]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_blend(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->additive = RValue_toBool(args[1]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_death(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->deathNumber = RValue_toInt32(args[1]); + type->deathType = RValue_toInt32(args[2]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_emitter_create(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + return RValue_makeReal((GMLReal) Particles_emitterCreate(ctx->runner, RValue_toInt32(args[0]))); +} + +static RValue builtin_part_emitter_destroy(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_emitterDestroy(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])); + return RValue_makeUndefined(); +} + +static RValue builtin_part_emitter_destroy_all(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_emitterDestroyAll(ctx->runner, RValue_toInt32(args[0])); + return RValue_makeUndefined(); +} + +static RValue builtin_part_emitter_region(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleEmitter* emitter = Particles_emitterGet(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])); + if (emitter == nullptr) return RValue_makeUndefined(); + emitter->xmin = RValue_toReal(args[2]); + emitter->xmax = RValue_toReal(args[3]); + emitter->ymin = RValue_toReal(args[4]); + emitter->ymax = RValue_toReal(args[5]); + emitter->shape = RValue_toInt32(args[6]); + emitter->distribution = RValue_toInt32(args[7]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_emitter_stream(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleEmitter* emitter = Particles_emitterGet(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])); + if (emitter == nullptr) return RValue_makeUndefined(); + emitter->streamType = RValue_toInt32(args[2]); + emitter->streamNumber = RValue_toInt32(args[3]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_emitter_burst(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_emitterBurst(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1]), RValue_toInt32(args[2]), RValue_toInt32(args[3])); + return RValue_makeUndefined(); +} + // ===[ REGISTRATION ]=== void VMBuiltins_registerAll(VMContext* ctx) { @@ -17466,6 +17637,32 @@ void VMBuiltins_registerAll(VMContext* ctx) { VM_registerBuiltin(ctx, "mp_grid_draw", builtin_mp_grid_draw); VM_registerBuiltin(ctx, "mp_grid_path", builtin_mp_grid_path); + // Particles + VM_registerBuiltin(ctx, "part_system_create", builtin_part_system_create); + VM_registerBuiltin(ctx, "part_system_destroy", builtin_part_system_destroy); + VM_registerBuiltin(ctx, "part_system_depth", builtin_part_system_depth); + VM_registerBuiltin(ctx, "part_system_automatic_draw", builtin_part_system_automatic_draw); + VM_registerBuiltin(ctx, "part_system_update", builtin_part_system_update); + VM_registerBuiltin(ctx, "part_system_drawit", builtin_part_system_drawit); + VM_registerBuiltin(ctx, "part_type_create", builtin_part_type_create); + VM_registerBuiltin(ctx, "part_type_destroy", builtin_part_type_destroy); + VM_registerBuiltin(ctx, "part_type_sprite", builtin_part_type_sprite); + VM_registerBuiltin(ctx, "part_type_size", builtin_part_type_size); + VM_registerBuiltin(ctx, "part_type_scale", builtin_part_type_scale); + VM_registerBuiltin(ctx, "part_type_speed", builtin_part_type_speed); + VM_registerBuiltin(ctx, "part_type_direction", builtin_part_type_direction); + VM_registerBuiltin(ctx, "part_type_gravity", builtin_part_type_gravity); + VM_registerBuiltin(ctx, "part_type_life", builtin_part_type_life); + VM_registerBuiltin(ctx, "part_type_alpha3", builtin_part_type_alpha3); + VM_registerBuiltin(ctx, "part_type_blend", builtin_part_type_blend); + VM_registerBuiltin(ctx, "part_type_death", builtin_part_type_death); + VM_registerBuiltin(ctx, "part_emitter_create", builtin_part_emitter_create); + VM_registerBuiltin(ctx, "part_emitter_destroy", builtin_part_emitter_destroy); + VM_registerBuiltin(ctx, "part_emitter_destroy_all", builtin_part_emitter_destroy_all); + VM_registerBuiltin(ctx, "part_emitter_region", builtin_part_emitter_region); + VM_registerBuiltin(ctx, "part_emitter_stream", builtin_part_emitter_stream); + VM_registerBuiltin(ctx, "part_emitter_burst", builtin_part_emitter_burst); + // Misc VM_registerBuiltin(ctx, "get_timer", builtin_get_timer); if (!isGMS2) { From 03d8c9bf53885fcf32b4d97c7fbf3ce9126b1d58 Mon Sep 17 00:00:00 2001 From: Ananim353 Date: Tue, 4 Aug 2026 03:08:14 +0300 Subject: [PATCH 2/6] Round out the particle API with its remaining setters The previous commit implemented what DELTARUNE actually calls. This adds the functions that fall out of the same machinery, so the system reads as a particle implementation rather than one game's subset. Colour gets the three-stop curve alpha already had, interpolated per byte, which is correct whatever order the channels sit in because GML colours reach the renderer unrepacked. colour1 and colour2 collapse the stops, the same way alpha1 and alpha2 do; a two-stop curve puts its middle on the straight line between the ends so it stays linear. part_type_orientation adds a drawn angle, optionally measured from the direction of travel so a sprite drawn nose-first keeps pointing along its arc as gravity bends it. part_type_step reuses the deferred-spawn list that part_type_death already needed, since spawning inside the movement loop can realloc the array out from under the iteration. Also: part_system_position, part_system_automatic_update, part_system_clear, part_particles_create and its _colour form, part_particles_count/clear, and exists/clear for all three resources. Both the colour and color spellings are registered, matching how draw_ellipse_colour is handled. Kept out deliberately: part_type_shape, which needs the shape textures baked into GameMaker's runtime and so cannot be reproduced exactly; and the GMS2.3 emitter delay/interval calls, which model something the classic stream rate does not express. The existing screenshot tests are still byte-identical (loritta at 9000, deltarune-chapter4 jackenstein at 9473 and 9474), and Chapter 5's cherry blossom system still renders after the drawing path grew colour, rotation and a system origin. --- src/particles.c | 164 ++++++++++++++++++++++++++++++++++++---------- src/particles.h | 28 ++++++++ src/vm_builtins.c | 147 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 306 insertions(+), 33 deletions(-) diff --git a/src/particles.c b/src/particles.c index e6bc1b7d9..b29b6733e 100644 --- a/src/particles.c +++ b/src/particles.c @@ -116,6 +116,54 @@ void Particles_systemSetAutomaticDraw(Runner* runner, int32_t systemId, bool aut runner->drawableListStructureDirty = true; } +void Particles_systemClear(Runner* runner, int32_t systemId) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr) return; + + arrsetlen(system->particles, 0); + arrsetlen(system->emitters, 0); + system->automaticUpdate = true; + system->automaticDraw = true; + system->depth = 0; + system->originX = 0.0; + system->originY = 0.0; + system->warnedFull = false; + // Depth and automatic drawing both just moved, so the cached list has to be rebuilt either way. + runner->drawableListStructureDirty = true; +} + +void Particles_systemClearParticles(Runner* runner, int32_t systemId) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr) return; + arrsetlen(system->particles, 0); +} + +int32_t Particles_systemParticleCount(Runner* runner, int32_t systemId) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + return (system == nullptr) ? 0 : (int32_t) arrlen(system->particles); +} + +// GameMaker's defaults for a fresh type: one white unit-sized particle, no motion, 100 steps. +static void particleTypeSetDefaults(ParticleType* type) { + ZERO_STRUCT(*type); + type->used = true; + type->sprite = -1; + type->sizeMin = 1.0; + type->sizeMax = 1.0; + type->scaleX = 1.0; + type->scaleY = 1.0; + type->lifeMin = 100; + type->lifeMax = 100; + type->alphaStart = 1.0; + type->alphaMiddle = 1.0; + type->alphaEnd = 1.0; + type->colourStart = 0xFFFFFFu; + type->colourMiddle = 0xFFFFFFu; + type->colourEnd = 0xFFFFFFu; + type->deathType = -1; + type->stepType = -1; +} + int32_t Particles_typeCreate(Runner* runner) { int32_t poolSize = (int32_t) arrlen(runner->particleTypePool); int32_t id = poolSize; @@ -123,21 +171,8 @@ int32_t Particles_typeCreate(Runner* runner) { if (!runner->particleTypePool[i].used) { id = (int32_t) i; break; } } - // GameMaker's defaults for a fresh type: a single white pixel-sized particle, no motion, 100 steps. ParticleType type; - ZERO_STRUCT(type); - type.used = true; - type.sprite = -1; - type.sizeMin = 1.0; - type.sizeMax = 1.0; - type.scaleX = 1.0; - type.scaleY = 1.0; - type.lifeMin = 100; - type.lifeMax = 100; - type.alphaStart = 1.0; - type.alphaMiddle = 1.0; - type.alphaEnd = 1.0; - type.deathType = -1; + particleTypeSetDefaults(&type); if (id == poolSize) { arrput(runner->particleTypePool, type); @@ -147,6 +182,12 @@ int32_t Particles_typeCreate(Runner* runner) { return id; } +void Particles_typeClear(Runner* runner, int32_t typeId) { + ParticleType* type = Particles_typeGet(runner, typeId); + if (type == nullptr) return; + particleTypeSetDefaults(type); +} + void Particles_typeDestroy(Runner* runner, int32_t typeId) { ParticleType* type = Particles_typeGet(runner, typeId); if (type == nullptr) return; @@ -202,7 +243,7 @@ void Particles_emitterDestroyAll(Runner* runner, int32_t systemId) { // ===[ Spawning ]=== -static void particleSpawnAt(Runner* runner, ParticleSystem* system, int32_t typeId, GMLReal x, GMLReal y) { +static void particleSpawnAt(Runner* runner, ParticleSystem* system, int32_t typeId, GMLReal x, GMLReal y, uint32_t colour, bool fixedColour) { ParticleType* type = Particles_typeGet(runner, typeId); if (type == nullptr) return; @@ -222,6 +263,9 @@ static void particleSpawnAt(Runner* runner, ParticleSystem* system, int32_t type particle.speed = particleRandomRange(type->speedMin, type->speedMax); particle.direction = particleRandomRange(type->dirMin, type->dirMax); particle.size = particleRandomRange(type->sizeMin, type->sizeMax); + particle.angle = particleRandomRange(type->angMin, type->angMax); + particle.colour = colour; + particle.colourFixed = fixedColour; particle.lifeTotal = (int32_t) particleRandomRange((GMLReal) type->lifeMin, (GMLReal) type->lifeMax); if (1 > particle.lifeTotal) particle.lifeTotal = 1; particle.life = particle.lifeTotal; @@ -275,7 +319,7 @@ static void particleEmitterSpawn(Runner* runner, ParticleSystem* system, Particl repeat(count, i) { GMLReal x, y; particleEmitterPoint(emitter, &x, &y); - particleSpawnAt(runner, system, typeId, x, y); + particleSpawnAt(runner, system, typeId, x, y, 0xFFFFFFu, false); } } @@ -286,6 +330,14 @@ void Particles_emitterBurst(Runner* runner, int32_t systemId, int32_t emitterId, particleEmitterSpawn(runner, system, emitter, typeId, particleResolveCount(number)); } +void Particles_particlesCreate(Runner* runner, int32_t systemId, GMLReal x, GMLReal y, int32_t typeId, int32_t number, uint32_t colour, bool fixedColour) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr) return; + repeat(number, i) { + particleSpawnAt(runner, system, typeId, x, y, colour, fixedColour); + } +} + // ===[ Update ]=== void Particles_updateSystem(Runner* runner, int32_t systemId) { @@ -300,11 +352,11 @@ void Particles_updateSystem(Runner* runner, int32_t systemId) { particleEmitterSpawn(runner, system, emitter, emitter->streamType, particleResolveCount(emitter->streamNumber)); } - // Deaths are collected and spawned after the movement pass: spawning mid-loop can realloc the - // array out from under the iteration, and a death particle must not be stepped on its spawn frame. - // Only allocated when a type actually has a death type, which is rare. - typedef struct { int32_t typeId; GMLReal x, y; int32_t count; } PendingDeath; - PendingDeath* deaths = nullptr; + // part_type_step and part_type_death spawns are collected and run after the movement pass: + // spawning mid-loop can realloc the array out from under the iteration, and the new particles + // must not be stepped on their own spawn frame. Only allocated when a type actually asks for it. + typedef struct { int32_t typeId; GMLReal x, y; int32_t count; } PendingSpawn; + PendingSpawn* pending = nullptr; int32_t index = 0; while (index < (int32_t) arrlen(system->particles)) { @@ -343,10 +395,23 @@ void Particles_updateSystem(Runner* runner, int32_t systemId) { particle->direction += type->dirIncr; particle->size += type->sizeIncr; if (0.0 > particle->size) particle->size = 0.0; + particle->angle += type->angIncr; particle->phase = (uint8_t) ((particle->phase + 8u) & 0xFFu); particle->life--; + if (type->stepType >= 0 && type->stepNumber != 0) { + int32_t count = particleResolveCount(type->stepNumber); + if (count > 0) { + PendingSpawn spawn; + spawn.typeId = type->stepType; + spawn.x = particle->x; + spawn.y = particle->y; + spawn.count = count; + arrput(pending, spawn); + } + } + if (particle->life > 0) { index++; continue; @@ -355,12 +420,12 @@ void Particles_updateSystem(Runner* runner, int32_t systemId) { if (type->deathType >= 0 && type->deathNumber != 0) { int32_t count = particleResolveCount(type->deathNumber); if (count > 0) { - PendingDeath death; - death.typeId = type->deathType; - death.x = particle->x; - death.y = particle->y; - death.count = count; - arrput(deaths, death); + PendingSpawn spawn; + spawn.typeId = type->deathType; + spawn.x = particle->x; + spawn.y = particle->y; + spawn.count = count; + arrput(pending, spawn); } } @@ -370,12 +435,12 @@ void Particles_updateSystem(Runner* runner, int32_t systemId) { arrpop(system->particles); } - repeat((int32_t) arrlen(deaths), i) { - repeat(deaths[i].count, n) { - particleSpawnAt(runner, system, deaths[i].typeId, deaths[i].x, deaths[i].y); + repeat((int32_t) arrlen(pending), i) { + repeat(pending[i].count, n) { + particleSpawnAt(runner, system, pending[i].typeId, pending[i].x, pending[i].y, 0xFFFFFFu, false); } } - arrfree(deaths); + arrfree(pending); } void Particles_updateAutomatic(Runner* runner) { @@ -400,6 +465,32 @@ static GMLReal particleAlphaAt(const ParticleType* type, GMLReal ageFraction) { return type->alphaMiddle + (type->alphaEnd - type->alphaMiddle) * t; } +// Same three stops as the alpha curve. Interpolated per byte, which is correct whatever order the +// channels sit in: GML colours are passed straight through to the renderer without repacking. +static uint32_t particleColourLerp(uint32_t from, uint32_t to, GMLReal t) { + uint32_t out = 0; + repeat(3, shift) { + int32_t bits = (int32_t) shift * 8; + GMLReal a = (GMLReal) ((from >> bits) & 0xFFu); + GMLReal b = (GMLReal) ((to >> bits) & 0xFFu); + int32_t v = (int32_t) (a + (b - a) * t + 0.5); + if (0 > v) v = 0; + if (v > 255) v = 255; + out |= ((uint32_t) v) << bits; + } + return out; +} + +uint32_t Particles_colourMidpoint(uint32_t from, uint32_t to) { + return particleColourLerp(from, to, 0.5); +} + +static uint32_t particleColourAt(const ParticleType* type, GMLReal ageFraction) { + if (0.5 > ageFraction) + return particleColourLerp(type->colourStart, type->colourMiddle, ageFraction * 2.0); + return particleColourLerp(type->colourMiddle, type->colourEnd, (ageFraction - 0.5) * 2.0); +} + void Particles_drawSystem(Runner* runner, int32_t systemId) { ParticleSystem* system = Particles_systemGet(runner, systemId); if (system == nullptr || runner->renderer == nullptr) return; @@ -446,10 +537,17 @@ void Particles_drawSystem(Runner* runner, int32_t systemId) { blendChanged = true; } + uint32_t colour = particle->colourFixed ? particle->colour : particleColourAt(type, ageFraction); + + // A relative orientation is measured from the direction the particle is travelling, so a + // sprite drawn nose-first keeps pointing along its arc as gravity bends it. + GMLReal angle = particle->angle + type->angWiggle * particleWiggle(particle->phase); + if (type->angRelative) angle += particle->direction; + Renderer_drawSpriteExt(renderer, type->sprite, subimg, - (float) particle->x, (float) particle->y, + (float) (system->originX + particle->x), (float) (system->originY + particle->y), (float) (type->scaleX * size), (float) (type->scaleY * size), - 0.0f, 0xFFFFFFu, (float) alpha); + (float) angle, colour, (float) alpha); } if (blendChanged && additiveActive) diff --git a/src/particles.h b/src/particles.h index 00d0ab963..9c2b8208c 100644 --- a/src/particles.h +++ b/src/particles.h @@ -55,13 +55,23 @@ typedef struct { GMLReal gravityAmount; GMLReal gravityDirection; + // Drawn orientation. Independent of the direction of travel unless angRelative is set, in which + // case the angle is measured from it. + GMLReal angMin, angMax, angIncr, angWiggle; + bool angRelative; + int32_t lifeMin, lifeMax; + // Alpha and colour both run through three stops across the particle's life: start, middle at the + // halfway mark, end. part_type_alpha1/alpha2 and part_type_colour1/colour2 collapse the stops. GMLReal alphaStart, alphaMiddle, alphaEnd; + uint32_t colourStart, colourMiddle, colourEnd; // GML packed BGR, as the drawing functions take it bool additive; int32_t deathType; // type id spawned when a particle of this type dies, -1 when none int32_t deathNumber; // how many to spawn; negative means a 1-in-|n| chance + int32_t stepType; // type id spawned every step a particle of this type lives, -1 when none + int32_t stepNumber; // same "negative means a chance" convention as deathNumber } ParticleType; typedef struct { @@ -70,9 +80,12 @@ typedef struct { GMLReal speed; // base speed, before wiggle GMLReal direction; // base direction in degrees, before wiggle GMLReal size; // base size, before wiggle + GMLReal angle; // drawn orientation in degrees, before wiggle int32_t life; // steps remaining int32_t lifeTotal; // steps this particle started with, for the alpha/animation curves int32_t subimgBase; // starting subimage + uint32_t colour; // set by part_particles_create_colour; overrides the type's colour curve + bool colourFixed; // true when "colour" above is in force uint8_t phase; // wiggle phase, advanced every step } Particle; @@ -90,6 +103,7 @@ typedef struct { bool automaticUpdate; // step the system at the end of every frame (on by default, as in GML) bool automaticDraw; // draw the system from the depth list (on by default, as in GML) int32_t depth; + GMLReal originX, originY; // part_system_position: added to every particle when drawing bool warnedFull; // the "hit PARTICLE_SYSTEM_MAX_PARTICLES" warning fires once per system Particle* particles; // stb_ds array ParticleEmitter* emitters; // stb_ds array, index = emitter id within this system @@ -101,11 +115,25 @@ void Particles_systemDestroy(Runner* runner, int32_t systemId); ParticleSystem* Particles_systemGet(Runner* runner, int32_t systemId); void Particles_systemSetDepth(Runner* runner, int32_t systemId, int32_t depth); void Particles_systemSetAutomaticDraw(Runner* runner, int32_t systemId, bool automatic); +// Resets the system to how part_system_create left it: no particles, no emitters, depth 0, both +// automatic flags back on. +void Particles_systemClear(Runner* runner, int32_t systemId); +// Removes every live particle but leaves the emitters and settings in place. +void Particles_systemClearParticles(Runner* runner, int32_t systemId); +int32_t Particles_systemParticleCount(Runner* runner, int32_t systemId); +// Spawns particles directly, bypassing emitters. "colour" is honoured only when fixedColour is set, +// which is what separates part_particles_create_colour from part_particles_create. +void Particles_particlesCreate(Runner* runner, int32_t systemId, GMLReal x, GMLReal y, int32_t typeId, int32_t number, uint32_t colour, bool fixedColour); // ===[ Types ]=== int32_t Particles_typeCreate(Runner* runner); void Particles_typeDestroy(Runner* runner, int32_t typeId); +// Puts a live type back to the defaults a freshly created one has. +void Particles_typeClear(Runner* runner, int32_t typeId); ParticleType* Particles_typeGet(Runner* runner, int32_t typeId); +// Blend of two GML colours, used by part_type_colour2 to place the middle stop of a two-stop curve +// on the straight line between its ends. +uint32_t Particles_colourMidpoint(uint32_t from, uint32_t to); // ===[ Emitters ]=== int32_t Particles_emitterCreate(Runner* runner, int32_t systemId); diff --git a/src/vm_builtins.c b/src/vm_builtins.c index f709ee5c9..41c0135c8 100644 --- a/src/vm_builtins.c +++ b/src/vm_builtins.c @@ -16532,10 +16532,64 @@ static RValue builtin_part_system_drawit(VMContext* ctx, RValue* args, MAYBE_UNU return RValue_makeUndefined(); } +static RValue builtin_part_system_automatic_update(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + if (system == nullptr) return RValue_makeUndefined(); + system->automaticUpdate = RValue_toBool(args[1]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_system_position(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + if (system == nullptr) return RValue_makeUndefined(); + system->originX = RValue_toReal(args[1]); + system->originY = RValue_toReal(args[2]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_system_clear(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_systemClear(ctx->runner, RValue_toInt32(args[0])); + return RValue_makeUndefined(); +} + +static RValue builtin_part_system_exists(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + return RValue_makeReal(Particles_systemGet(ctx->runner, RValue_toInt32(args[0])) != nullptr ? 1.0 : 0.0); +} + +static RValue builtin_part_particles_create(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_particlesCreate(ctx->runner, RValue_toInt32(args[0]), RValue_toReal(args[1]), RValue_toReal(args[2]), + RValue_toInt32(args[3]), RValue_toInt32(args[4]), 0xFFFFFFu, false); + return RValue_makeUndefined(); +} + +static RValue builtin_part_particles_create_colour(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_particlesCreate(ctx->runner, RValue_toInt32(args[0]), RValue_toReal(args[1]), RValue_toReal(args[2]), + RValue_toInt32(args[3]), RValue_toInt32(args[5]), (uint32_t) RValue_toInt32(args[4]), true); + return RValue_makeUndefined(); +} + +static RValue builtin_part_particles_count(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + return RValue_makeReal((GMLReal) Particles_systemParticleCount(ctx->runner, RValue_toInt32(args[0]))); +} + +static RValue builtin_part_particles_clear(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_systemClearParticles(ctx->runner, RValue_toInt32(args[0])); + return RValue_makeUndefined(); +} + static RValue builtin_part_type_create(VMContext* ctx, MAYBE_UNUSED RValue* args, MAYBE_UNUSED int32_t argCount) { return RValue_makeReal((GMLReal) Particles_typeCreate(ctx->runner)); } +static RValue builtin_part_type_clear(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_typeClear(ctx->runner, RValue_toInt32(args[0])); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_exists(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + return RValue_makeReal(Particles_typeGet(ctx->runner, RValue_toInt32(args[0])) != nullptr ? 1.0 : 0.0); +} + static RValue builtin_part_type_destroy(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { Particles_typeDestroy(ctx->runner, RValue_toInt32(args[0])); return RValue_makeUndefined(); @@ -16607,6 +16661,36 @@ static RValue builtin_part_type_life(VMContext* ctx, RValue* args, MAYBE_UNUSED return RValue_makeUndefined(); } +static RValue builtin_part_type_orientation(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->angMin = RValue_toReal(args[1]); + type->angMax = RValue_toReal(args[2]); + type->angIncr = RValue_toReal(args[3]); + type->angWiggle = RValue_toReal(args[4]); + type->angRelative = (argCount > 5) && RValue_toBool(args[5]); + return RValue_makeUndefined(); +} + +// alpha1 and alpha2 are the same curve as alpha3 with the stops collapsed. +static RValue builtin_part_type_alpha1(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->alphaStart = type->alphaMiddle = type->alphaEnd = RValue_toReal(args[1]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_alpha2(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + GMLReal start = RValue_toReal(args[1]); + GMLReal end = RValue_toReal(args[2]); + type->alphaStart = start; + type->alphaMiddle = (start + end) * 0.5; + type->alphaEnd = end; + return RValue_makeUndefined(); +} + static RValue builtin_part_type_alpha3(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); @@ -16616,6 +16700,43 @@ static RValue builtin_part_type_alpha3(VMContext* ctx, RValue* args, MAYBE_UNUSE return RValue_makeUndefined(); } +// Ditto for the colour curve. +static RValue builtin_part_type_colour1(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->colourStart = type->colourMiddle = type->colourEnd = (uint32_t) RValue_toInt32(args[1]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_colour2(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + uint32_t start = (uint32_t) RValue_toInt32(args[1]); + uint32_t end = (uint32_t) RValue_toInt32(args[2]); + type->colourStart = start; + type->colourEnd = end; + // Halfway stop sits on the straight line between the two, so a two-stop curve stays linear. + type->colourMiddle = Particles_colourMidpoint(start, end); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_colour3(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->colourStart = (uint32_t) RValue_toInt32(args[1]); + type->colourMiddle = (uint32_t) RValue_toInt32(args[2]); + type->colourEnd = (uint32_t) RValue_toInt32(args[3]); + return RValue_makeUndefined(); +} + +static RValue builtin_part_type_step(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + type->stepNumber = RValue_toInt32(args[1]); + type->stepType = RValue_toInt32(args[2]); + return RValue_makeUndefined(); +} + static RValue builtin_part_type_blend(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); @@ -16640,6 +16761,10 @@ static RValue builtin_part_emitter_destroy(VMContext* ctx, RValue* args, MAYBE_U return RValue_makeUndefined(); } +static RValue builtin_part_emitter_exists(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + return RValue_makeReal(Particles_emitterGet(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])) != nullptr ? 1.0 : 0.0); +} + static RValue builtin_part_emitter_destroy_all(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { Particles_emitterDestroyAll(ctx->runner, RValue_toInt32(args[0])); return RValue_makeUndefined(); @@ -17642,23 +17767,45 @@ void VMBuiltins_registerAll(VMContext* ctx) { VM_registerBuiltin(ctx, "part_system_destroy", builtin_part_system_destroy); VM_registerBuiltin(ctx, "part_system_depth", builtin_part_system_depth); VM_registerBuiltin(ctx, "part_system_automatic_draw", builtin_part_system_automatic_draw); + VM_registerBuiltin(ctx, "part_system_automatic_update", builtin_part_system_automatic_update); VM_registerBuiltin(ctx, "part_system_update", builtin_part_system_update); VM_registerBuiltin(ctx, "part_system_drawit", builtin_part_system_drawit); + VM_registerBuiltin(ctx, "part_system_position", builtin_part_system_position); + VM_registerBuiltin(ctx, "part_system_clear", builtin_part_system_clear); + VM_registerBuiltin(ctx, "part_system_exists", builtin_part_system_exists); + VM_registerBuiltin(ctx, "part_particles_create", builtin_part_particles_create); + VM_registerBuiltin(ctx, "part_particles_create_colour", builtin_part_particles_create_colour); + VM_registerBuiltin(ctx, "part_particles_create_color", builtin_part_particles_create_colour); + VM_registerBuiltin(ctx, "part_particles_count", builtin_part_particles_count); + VM_registerBuiltin(ctx, "part_particles_clear", builtin_part_particles_clear); VM_registerBuiltin(ctx, "part_type_create", builtin_part_type_create); VM_registerBuiltin(ctx, "part_type_destroy", builtin_part_type_destroy); + VM_registerBuiltin(ctx, "part_type_clear", builtin_part_type_clear); + VM_registerBuiltin(ctx, "part_type_exists", builtin_part_type_exists); VM_registerBuiltin(ctx, "part_type_sprite", builtin_part_type_sprite); VM_registerBuiltin(ctx, "part_type_size", builtin_part_type_size); VM_registerBuiltin(ctx, "part_type_scale", builtin_part_type_scale); VM_registerBuiltin(ctx, "part_type_speed", builtin_part_type_speed); VM_registerBuiltin(ctx, "part_type_direction", builtin_part_type_direction); + VM_registerBuiltin(ctx, "part_type_orientation", builtin_part_type_orientation); VM_registerBuiltin(ctx, "part_type_gravity", builtin_part_type_gravity); VM_registerBuiltin(ctx, "part_type_life", builtin_part_type_life); + VM_registerBuiltin(ctx, "part_type_alpha1", builtin_part_type_alpha1); + VM_registerBuiltin(ctx, "part_type_alpha2", builtin_part_type_alpha2); VM_registerBuiltin(ctx, "part_type_alpha3", builtin_part_type_alpha3); + VM_registerBuiltin(ctx, "part_type_colour1", builtin_part_type_colour1); + VM_registerBuiltin(ctx, "part_type_color1", builtin_part_type_colour1); + VM_registerBuiltin(ctx, "part_type_colour2", builtin_part_type_colour2); + VM_registerBuiltin(ctx, "part_type_color2", builtin_part_type_colour2); + VM_registerBuiltin(ctx, "part_type_colour3", builtin_part_type_colour3); + VM_registerBuiltin(ctx, "part_type_color3", builtin_part_type_colour3); VM_registerBuiltin(ctx, "part_type_blend", builtin_part_type_blend); + VM_registerBuiltin(ctx, "part_type_step", builtin_part_type_step); VM_registerBuiltin(ctx, "part_type_death", builtin_part_type_death); VM_registerBuiltin(ctx, "part_emitter_create", builtin_part_emitter_create); VM_registerBuiltin(ctx, "part_emitter_destroy", builtin_part_emitter_destroy); VM_registerBuiltin(ctx, "part_emitter_destroy_all", builtin_part_emitter_destroy_all); + VM_registerBuiltin(ctx, "part_emitter_exists", builtin_part_emitter_exists); VM_registerBuiltin(ctx, "part_emitter_region", builtin_part_emitter_region); VM_registerBuiltin(ctx, "part_emitter_stream", builtin_part_emitter_stream); VM_registerBuiltin(ctx, "part_emitter_burst", builtin_part_emitter_burst); From 2dea483773a8673aeae1aa79c0f6210e3a49f0a4 Mon Sep 17 00:00:00 2001 From: Ananim353 Date: Tue, 4 Aug 2026 04:07:35 +0300 Subject: [PATCH 3/6] Fix blend-state clobbering and three spawn-path defects found in review A review pass over the two particle commits turned up one real bug and a handful of sharp edges. All of them are in particles.c. The bug: after drawing an additive type, the system forced the blend mode back to bm_normal instead of restoring what the caller had. Blend state in GML is global and sticky, so a game that wraps its scene in gpu_set_blendmode_ext to darken it lost the effect from the particle system onward -- the player would see the scene split, darkened above the system's depth and full-brightness below, and only in scenes that happen to contain an additive effect. The comment claiming the current mode could not be read back was simply wrong: gpuGetBlendMode and gpuGetBlendFactors are both in the vtable and implemented on gl, gl_legacy and ps2. State is now saved on the first change and restored properly, going through the factors when the saved mode reads back as bm_complex, since that is what an _ext call leaves behind. Both getters are optional in the vtable, so a backend without them still gets the old bm_normal fallback. The spawn count from GML was trusted as a loop bound even after the system filled up, so part_emitter_stream(ps, em, ty, 2000000000) on a full system span a two-billion-iteration no-op every frame and hung the game with no diagnostic. particleSpawnAt now reports the cap and its three callers stop. part_type_life picked from a half-open range, so part_type_life(t, 1, 3) never returned 3. Integer ranges are now inclusive at both ends, as GameMaker reads them. Ellipse and diamond emitter regions were sampled by rejection with a bounded attempt count, and on exhaustion accepted whatever point it was holding -- which for a diamond, at half the bounding box, leaked a visible particle into a corner roughly once in five hundred. Both shapes are now sampled directly, which cannot escape the region and is cheaper besides. Also: negating the count in the "negative means a chance" convention was signed overflow at INT32_MIN; the private random stream is reset by Particles_freeAll so a restarted game replays the same particles; and part_system_automatic_draw(ps, false) no longer forces a structural rebuild, since the draw pass already filters on that flag the way it filters instance visibility -- the common automatic_draw(false)/drawit() idiom was dragging a full rebuild and re-sort of every drawable in the room behind it each frame. Screenshot tests unchanged (loritta at 9000, deltarune-chapter4 jackenstein at 9473 and 9474), and Chapter 5's petals still render after the sampling and lifetime changes. --- src/particles.c | 134 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 94 insertions(+), 40 deletions(-) diff --git a/src/particles.c b/src/particles.c index b29b6733e..7f4692521 100644 --- a/src/particles.c +++ b/src/particles.c @@ -14,7 +14,8 @@ // particle spawn shift the sequence the game itself sees, so merely adding a particle effect to a // scene would change unrelated randomised behaviour (and every seeded screenshot test with it). // The trade-off is that --seed and randomize() do not reach particles. -static uint32_t g_particleRngState = 0x9E3779B9u; +#define PARTICLE_RNG_SEED 0x9E3779B9u +static uint32_t g_particleRngState = PARTICLE_RNG_SEED; static uint32_t particleRandomBits(void) { uint32_t x = g_particleRngState; @@ -48,8 +49,17 @@ static GMLReal particleWiggle(uint8_t phase) { // value is a literal count, a negative value is a 1-in-|number| chance of spawning a single particle. static int32_t particleResolveCount(int32_t number) { if (number >= 0) return number; - int32_t chance = -number; - return ((int32_t) (particleRandomBits() % (uint32_t) chance) == 0) ? 1 : 0; + // Negated as unsigned: -INT32_MIN does not fit back into int32_t. + uint32_t chance = (uint32_t) -(int64_t) number; + return (particleRandomBits() % chance == 0) ? 1 : 0; +} + +// Uniform integer in [min, max] with both ends included, which is how GameMaker reads part_type_life. +// Truncating a real drawn from [min, max) would never return max. +static int32_t particleRandomIntRange(int32_t min, int32_t max) { + GMLReal span = (GMLReal) max - (GMLReal) min; + span += (span >= 0.0) ? 1.0 : -1.0; + return min + (int32_t) (particleRandom01() * span); } // ===[ Pools ]=== @@ -112,8 +122,11 @@ void Particles_systemSetAutomaticDraw(Runner* runner, int32_t systemId, bool aut ParticleSystem* system = Particles_systemGet(runner, systemId); if (system == nullptr || system->automaticDraw == automatic) return; system->automaticDraw = automatic; - // Entering or leaving the depth list changes the SET of drawables, not just their order. - runner->drawableListStructureDirty = true; + // Only switching drawing ON has to rebuild, since that is what adds an entry the cache does not + // hold. Switching it off is filtered at draw time, like instance visibility, so the common + // "automatic_draw(false) then drawit()" idiom re-issued every frame does not drag a full rebuild + // and re-sort of every drawable in the room behind it. + if (automatic) runner->drawableListStructureDirty = true; } void Particles_systemClear(Runner* runner, int32_t systemId) { @@ -243,16 +256,18 @@ void Particles_emitterDestroyAll(Runner* runner, int32_t systemId) { // ===[ Spawning ]=== -static void particleSpawnAt(Runner* runner, ParticleSystem* system, int32_t typeId, GMLReal x, GMLReal y, uint32_t colour, bool fixedColour) { +// Returns false once the system is full, so the callers' loops can stop instead of spinning through a +// spawn count that came straight from GML and may be enormous. +static bool particleSpawnAt(Runner* runner, ParticleSystem* system, int32_t typeId, GMLReal x, GMLReal y, uint32_t colour, bool fixedColour) { ParticleType* type = Particles_typeGet(runner, typeId); - if (type == nullptr) return; + if (type == nullptr) return false; if ((int32_t) arrlen(system->particles) >= PARTICLE_SYSTEM_MAX_PARTICLES) { if (!system->warnedFull) { system->warnedFull = true; logWarn("Particles: system hit the %d particle cap, further spawns are dropped\n", PARTICLE_SYSTEM_MAX_PARTICLES); } - return; + return false; } Particle particle; @@ -266,7 +281,7 @@ static void particleSpawnAt(Runner* runner, ParticleSystem* system, int32_t type particle.angle = particleRandomRange(type->angMin, type->angMax); particle.colour = colour; particle.colourFixed = fixedColour; - particle.lifeTotal = (int32_t) particleRandomRange((GMLReal) type->lifeMin, (GMLReal) type->lifeMax); + particle.lifeTotal = particleRandomIntRange(type->lifeMin, type->lifeMax); if (1 > particle.lifeTotal) particle.lifeTotal = 1; particle.life = particle.lifeTotal; particle.phase = (uint8_t) (particleRandomBits() & 0xFFu); @@ -277,49 +292,61 @@ static void particleSpawnAt(Runner* runner, ParticleSystem* system, int32_t type } arrput(system->particles, particle); + return true; } // Picks a point inside the emitter's region. Only the linear distribution is modelled; the gaussian // ones fall back to it (no game we test against uses them, and guessing at the curve would be worse // than an honest uniform spread). static void particleEmitterPoint(ParticleEmitter* emitter, GMLReal* outX, GMLReal* outY) { - GMLReal x = particleRandomRange(emitter->xmin, emitter->xmax); - GMLReal y = particleRandomRange(emitter->ymin, emitter->ymax); - GMLReal centerX = (emitter->xmin + emitter->xmax) * 0.5; GMLReal centerY = (emitter->ymin + emitter->ymax) * 0.5; GMLReal halfW = (emitter->xmax - emitter->xmin) * 0.5; GMLReal halfH = (emitter->ymax - emitter->ymin) * 0.5; - if (emitter->shape == PS_SHAPE_ELLIPSE || emitter->shape == PS_SHAPE_DIAMOND) { - // Rejection sampling keeps the spread uniform. The regions are small and the acceptance rate - // is 0.79 (ellipse) / 0.5 (diamond), so the loop is bounded in practice; cap it anyway. - repeat(8, attempt) { - GMLReal nx = (halfW > 0.0) ? (x - centerX) / halfW : 0.0; - GMLReal ny = (halfH > 0.0) ? (y - centerY) / halfH : 0.0; - bool inside = (emitter->shape == PS_SHAPE_ELLIPSE) - ? (nx * nx + ny * ny <= 1.0) - : (GMLReal_fabs(nx) + GMLReal_fabs(ny) <= 1.0); - if (inside) break; - x = particleRandomRange(emitter->xmin, emitter->xmax); - y = particleRandomRange(emitter->ymin, emitter->ymax); - } - } else if (emitter->shape == PS_SHAPE_LINE) { + // Every shape is sampled directly rather than by rejection. Beyond being cheaper and branch-free, + // it cannot emit outside the region: a bounded rejection loop has to accept whatever it holds when + // the attempts run out, which for a diamond (half the bounding box) leaks a particle into a corner + // often enough to see. + if (emitter->shape == PS_SHAPE_ELLIPSE) { + // sqrt on the radius keeps the spread uniform by area instead of clustering at the centre. + GMLReal radius = GMLReal_sqrt(particleRandom01()); + GMLReal angle = particleRandom01() * 2.0 * M_PI; + *outX = centerX + halfW * radius * GMLReal_cos(angle); + *outY = centerY + halfH * radius * GMLReal_sin(angle); + return; + } + + if (emitter->shape == PS_SHAPE_DIAMOND) { + // Fold the unit square onto the triangle u + v <= 1, then mirror into a random quadrant. + GMLReal u = particleRandom01(); + GMLReal v = particleRandom01(); + if (u + v > 1.0) { u = 1.0 - u; v = 1.0 - v; } + uint32_t quadrant = particleRandomBits(); + if (quadrant & 1u) u = -u; + if (quadrant & 2u) v = -v; + *outX = centerX + halfW * u; + *outY = centerY + halfH * v; + return; + } + + if (emitter->shape == PS_SHAPE_LINE) { // A line from (xmin, ymin) to (xmax, ymax), not the rectangle they bound. GMLReal t = particleRandom01(); - x = emitter->xmin + (emitter->xmax - emitter->xmin) * t; - y = emitter->ymin + (emitter->ymax - emitter->ymin) * t; + *outX = emitter->xmin + (emitter->xmax - emitter->xmin) * t; + *outY = emitter->ymin + (emitter->ymax - emitter->ymin) * t; + return; } - *outX = x; - *outY = y; + *outX = particleRandomRange(emitter->xmin, emitter->xmax); + *outY = particleRandomRange(emitter->ymin, emitter->ymax); } static void particleEmitterSpawn(Runner* runner, ParticleSystem* system, ParticleEmitter* emitter, int32_t typeId, int32_t count) { repeat(count, i) { GMLReal x, y; particleEmitterPoint(emitter, &x, &y); - particleSpawnAt(runner, system, typeId, x, y, 0xFFFFFFu, false); + if (!particleSpawnAt(runner, system, typeId, x, y, 0xFFFFFFu, false)) return; } } @@ -334,7 +361,7 @@ void Particles_particlesCreate(Runner* runner, int32_t systemId, GMLReal x, GMLR ParticleSystem* system = Particles_systemGet(runner, systemId); if (system == nullptr) return; repeat(number, i) { - particleSpawnAt(runner, system, typeId, x, y, colour, fixedColour); + if (!particleSpawnAt(runner, system, typeId, x, y, colour, fixedColour)) return; } } @@ -437,7 +464,7 @@ void Particles_updateSystem(Runner* runner, int32_t systemId) { repeat((int32_t) arrlen(pending), i) { repeat(pending[i].count, n) { - particleSpawnAt(runner, system, pending[i].typeId, pending[i].x, pending[i].y, 0xFFFFFFu, false); + if (!particleSpawnAt(runner, system, pending[i].typeId, pending[i].x, pending[i].y, 0xFFFFFFu, false)) break; } } arrfree(pending); @@ -499,8 +526,16 @@ void Particles_drawSystem(Runner* runner, int32_t systemId) { if (count == 0) return; Renderer* renderer = runner->renderer; - bool blendChanged = false; bool additiveActive = false; + // Blend state is global and sticky in GML, so an additive type has to hand back exactly what the + // caller had rather than assuming bm_normal: a game that darkens the scene with + // gpu_set_blendmode_ext around its draw would otherwise lose the effect from the particle system + // onwards. Captured lazily, so a system of ordinary particles never touches blending at all. + bool blendTouched = false; + bool blendSaved = false; + int32_t savedBlendMode = bm_normal; + BlendFactors savedBlendFactors; + ZERO_STRUCT(savedBlendFactors); repeat(count, i) { Particle* particle = &system->particles[i]; @@ -527,14 +562,20 @@ void Particles_drawSystem(Runner* runner, int32_t systemId) { } } - // Only touched when an additive type is actually present, so a system of ordinary particles - // leaves whatever blend mode the caller had set alone. There is no way to read the current - // mode back out of the renderer, so once we do touch it the restore below can only go to - // bm_normal, which is the mode GameMaker itself leaves behind after drawing a system. if (type->additive != additiveActive) { + if (!blendTouched) { + // The getters are optional in the vtable; without them the best we can do is put + // blending back to bm_normal at the end. + if (renderer->vtable->gpuGetBlendMode != nullptr) { + savedBlendMode = renderer->vtable->gpuGetBlendMode(renderer); + if (renderer->vtable->gpuGetBlendFactors != nullptr) + savedBlendFactors = renderer->vtable->gpuGetBlendFactors(renderer); + blendSaved = true; + } + blendTouched = true; + } renderer->vtable->gpuSetBlendMode(renderer, type->additive ? bm_add : bm_normal); additiveActive = type->additive; - blendChanged = true; } uint32_t colour = particle->colourFixed ? particle->colour : particleColourAt(type, ageFraction); @@ -550,8 +591,18 @@ void Particles_drawSystem(Runner* runner, int32_t systemId) { (float) angle, colour, (float) alpha); } - if (blendChanged && additiveActive) + if (!blendTouched) return; + + if (!blendSaved) { renderer->vtable->gpuSetBlendMode(renderer, bm_normal); + } else if (savedBlendMode == bm_complex && renderer->vtable->gpuSetBlendModeExt != nullptr) { + // gpu_set_blendmode_ext leaves the mode reading back as bm_complex, so the individual + // factors are the only faithful way to put that state back. + renderer->vtable->gpuSetBlendModeExt(renderer, savedBlendFactors.src, savedBlendFactors.dst, + savedBlendFactors.srcAlpha, savedBlendFactors.dstAlpha); + } else { + renderer->vtable->gpuSetBlendMode(renderer, savedBlendMode); + } } // ===[ Teardown ]=== @@ -566,4 +617,7 @@ void Particles_freeAll(Runner* runner) { runner->particleSystemPool = nullptr; arrfree(runner->particleTypePool); runner->particleTypePool = nullptr; + // Reached on game_restart as well as shutdown, so put the stream back to where it started: + // a restarted game should produce the same particles as the first run. + g_particleRngState = PARTICLE_RNG_SEED; } From 6a7e13cbd8b34d731394c56dacbd86b39ef701a4 Mon Sep 17 00:00:00 2001 From: Ananim353 Date: Tue, 4 Aug 2026 18:32:25 +0300 Subject: [PATCH 4/6] Match the HTML5 runtime's particle timing, wiggle and counts YoYo Games publish the HTML5 runtime (github.com/YoYoGames/GameMaker-HTML5, Apache 2.0), and its scripts/yyParticle.js is the closest thing to a specification this API has. Reading it against this implementation turned up four places where the two disagree. Wiggle. GameMaker runs four independent triangle waves off the particle's age: direction on a 24-step period, speed on 20, orientation on 16 and size on 16, each phase-shifted by its own multiple of a random seed drawn per particle. This ran one shared wave on a 32-step period, so a particle's four properties oscillated in lockstep and a spray pulsed instead of shimmering. The particle now carries that seed and each property gets its own wave. Update order. A system is walked in three passes -- age, then movement, then size -- and the emitters spawn only after all three. Two consequences follow that this did not have: a streamed particle does not move on the step it is born, and a step's speed and direction increments land before the particle travels rather than after it, so its very first step already runs at speed + speed increment. Death and step particles still move on their spawn step, matching upstream, because they join the array between the ageing and movement passes rather than at the end of the frame. Step versus death particles. A type emits its death particles instead of its step particles on the step it dies, not as well as them. A type configured with both used to emit a double helping on its last step. Fractional counts. part_emitter_stream and part_emitter_burst keep the count as a real and spend the fractional part as a chance of one more particle, which is how a game asks for fewer than one particle per step. Both truncated to int, so an emitter asked for 0.5 particles per step emitted nothing at all, ever. One divergence is deliberate, and now carries its evidence in the comment above particleRandomRange. MyRandom() returns the low bound whenever the range is not positive, so on HTML5 a reversed pair collapses to a constant. Disassembling all five DELTARUNE chapters finds three reversed calls, all in Chapter 5: part_type_direction with (-1, -165), (-45, -90) and (-40, -90). Each is plainly meant to be a fan, and clamping turns three sprays into single-direction jets, so the Windows runtime these games are built against evidently sweeps them. Chapter 4, which the previous comment credited, has no reversed ranges at all. Verified: loritta at frame 9000 byte-identical; Chapter 5's petals still render and move, and two cold runs of the same recorded session produce byte-identical frames, so the private random stream keeps particles reproducible without touching the sequence the game itself sees. --- src/particles.c | 175 ++++++++++++++++++++++++++-------------------- src/particles.h | 12 ++-- src/vm_builtins.c | 6 +- 3 files changed, 113 insertions(+), 80 deletions(-) diff --git a/src/particles.c b/src/particles.c index 7f4692521..2f5b135d0 100644 --- a/src/particles.c +++ b/src/particles.c @@ -32,30 +32,47 @@ static GMLReal particleRandom01(void) { } // Uniform between the two bounds. Deliberately NOT normalised to (min <= max): GameMaker computes -// "min + random * (max - min)", and games depend on the reversed form. part_type_direction(-45, -90) -// in DELTARUNE Chapter 4 sweeps downward from -45, and swapping the bounds would flip the spray. +// "min + random * (max - min)" and lets a reversed pair sweep downward, which games rely on. +// DELTARUNE Chapter 5 calls part_type_direction with (-1, -165), (-45, -90) and (-40, -90); each is +// a fan that collapses into a single direction the moment the ends are clamped. +// +// Worth spelling out because the HTML5 runtime disagrees: its MyRandom() returns the low bound +// whenever (max - min) <= 0, so those three sprays would come out as straight jets there. The games +// this runner is pointed at are built against the Windows runtime, so that is the one to match. static GMLReal particleRandomRange(GMLReal min, GMLReal max) { return min + particleRandom01() * (max - min); } -// Triangle wave in [-1, 1] driven by the particle's phase counter. GameMaker does not document its -// wiggle period; this approximates the oscillation without a sin() per property per particle per frame. -static GMLReal particleWiggle(uint8_t phase) { - GMLReal t = (GMLReal) phase / 128.0; // 0..2 - return (1.0 > t) ? (t * 2.0 - 1.0) : (3.0 - t * 2.0); +// Triangle wave in [-1, 1] driven by the particle's age. Each wiggling property runs on its own +// period and its own multiple of the particle's seed, so the four oscillations never line up and a +// spray of particles does not pulse in unison. The periods below are GameMaker's. +static GMLReal particleWiggle(int32_t age, int32_t seed, int32_t seedMultiplier, int32_t period) { + GMLReal t = (GMLReal) (4 * ((age + seedMultiplier * seed) % period)) / (GMLReal) period; // 0..4 + if (t > 2.0) t = 4.0 - t; // fold to 0..2 + return t - 1.0; } +static GMLReal particleWiggleDirection(int32_t age, int32_t seed) { return particleWiggle(age, seed, 3, 24); } +static GMLReal particleWiggleSpeed(int32_t age, int32_t seed) { return particleWiggle(age, seed, 4, 20); } +static GMLReal particleWiggleAngle(int32_t age, int32_t seed) { return particleWiggle(age, seed, 2, 16); } +static GMLReal particleWiggleSize(int32_t age, int32_t seed) { return particleWiggle(age, seed, 1, 16); } + // "number" follows the GML convention shared by part_emitter_stream and part_type_death: a positive // value is a literal count, a negative value is a 1-in-|number| chance of spawning a single particle. -static int32_t particleResolveCount(int32_t number) { - if (number >= 0) return number; - // Negated as unsigned: -INT32_MIN does not fit back into int32_t. - uint32_t chance = (uint32_t) -(int64_t) number; - return (particleRandomBits() % chance == 0) ? 1 : 0; +// Emitters take a real count, and GameMaker spends the fraction as a chance of one extra particle, +// so part_emitter_stream(..., 0.5) emits on roughly every other step. +static int32_t particleResolveCount(GMLReal number) { + if (0.0 > number) return (particleRandom01() * -number < 1.0) ? 1 : 0; + + int32_t whole = (int32_t) number; + GMLReal fraction = number - (GMLReal) whole; + if (fraction > 0.0 && particleRandom01() <= fraction) whole++; + return whole; } // Uniform integer in [min, max] with both ends included, which is how GameMaker reads part_type_life. -// Truncating a real drawn from [min, max) would never return max. +// Truncating a real drawn from [min, max) would never return max. Reversed pairs sweep downward for +// the same reason particleRandomRange() lets them. static int32_t particleRandomIntRange(int32_t min, int32_t max) { GMLReal span = (GMLReal) max - (GMLReal) min; span += (span >= 0.0) ? 1.0 : -1.0; @@ -284,7 +301,7 @@ static bool particleSpawnAt(Runner* runner, ParticleSystem* system, int32_t type particle.lifeTotal = particleRandomIntRange(type->lifeMin, type->lifeMax); if (1 > particle.lifeTotal) particle.lifeTotal = 1; particle.life = particle.lifeTotal; - particle.phase = (uint8_t) (particleRandomBits() & 0xFFu); + particle.seed = (int32_t) (particleRandomBits() % 100000u); if (type->spriteRandom && type->sprite >= 0 && runner->dataWin != nullptr && (uint32_t) type->sprite < runner->dataWin->sprt.count) { uint32_t frames = runner->dataWin->sprt.sprites[type->sprite].textureCount; @@ -350,7 +367,7 @@ static void particleEmitterSpawn(Runner* runner, ParticleSystem* system, Particl } } -void Particles_emitterBurst(Runner* runner, int32_t systemId, int32_t emitterId, int32_t typeId, int32_t number) { +void Particles_emitterBurst(Runner* runner, int32_t systemId, int32_t emitterId, int32_t typeId, GMLReal number) { ParticleSystem* system = Particles_systemGet(runner, systemId); ParticleEmitter* emitter = Particles_emitterGet(runner, systemId, emitterId); if (system == nullptr || emitter == nullptr) return; @@ -371,20 +388,16 @@ void Particles_updateSystem(Runner* runner, int32_t systemId) { ParticleSystem* system = Particles_systemGet(runner, systemId); if (system == nullptr) return; - // Emitters stream first, so a particle spawned this step also moves this step (as in GameMaker). - int32_t emitterCount = (int32_t) arrlen(system->emitters); - repeat(emitterCount, i) { - ParticleEmitter* emitter = &system->emitters[i]; - if (!emitter->used || 0 > emitter->streamType || emitter->streamNumber == 0) continue; - particleEmitterSpawn(runner, system, emitter, emitter->streamType, particleResolveCount(emitter->streamNumber)); - } + // GameMaker ages a system, then moves it, and only then lets the emitters spawn into it. The + // order is worth preserving: it is what makes a streamed particle sit still for the step it is + // born on, while a death particle -- which joins between the two passes -- travels immediately. - // part_type_step and part_type_death spawns are collected and run after the movement pass: - // spawning mid-loop can realloc the array out from under the iteration, and the new particles - // must not be stepped on their own spawn frame. Only allocated when a type actually asks for it. + // Collected rather than spawned in place, because arrput() can move the array out from under the + // loop walking it. Only allocated when a type actually asks for step or death particles. typedef struct { int32_t typeId; GMLReal x, y; int32_t count; } PendingSpawn; PendingSpawn* pending = nullptr; + // Pass 1: age everything, note the spawns each particle's type asks for, bury what ran out. int32_t index = 0; while (index < (int32_t) arrlen(system->particles)) { Particle* particle = &system->particles[index]; @@ -397,41 +410,18 @@ void Particles_updateSystem(Runner* runner, int32_t systemId) { continue; } - GMLReal wiggle = particleWiggle(particle->phase); - GMLReal effectiveSpeed = particle->speed + type->speedWiggle * wiggle; - GMLReal effectiveDirection = particle->direction + type->dirWiggle * wiggle; - - GMLReal radians = effectiveDirection * PARTICLE_DEG2RAD; - particle->x += effectiveSpeed * GMLReal_cos(radians); - particle->y -= effectiveSpeed * GMLReal_sin(radians); // GML's y axis grows downward - - if (type->gravityAmount != 0.0) { - // Gravity folds into the velocity vector permanently, so later speed/direction increments - // apply on top of it. Matches GameMaker, where gravity bends a particle's course for good. - GMLReal baseRadians = particle->direction * PARTICLE_DEG2RAD; - GMLReal gravityRadians = type->gravityDirection * PARTICLE_DEG2RAD; - GMLReal hspeed = particle->speed * GMLReal_cos(baseRadians) + type->gravityAmount * GMLReal_cos(gravityRadians); - GMLReal vspeed = -particle->speed * GMLReal_sin(baseRadians) - type->gravityAmount * GMLReal_sin(gravityRadians); - particle->speed = GMLReal_sqrt(hspeed * hspeed + vspeed * vspeed); - if (hspeed != 0.0 || vspeed != 0.0) - particle->direction = GMLReal_atan2(-vspeed, hspeed) / PARTICLE_DEG2RAD; - } - - particle->speed += type->speedIncr; - if (0.0 > particle->speed) particle->speed = 0.0; // GameMaker never lets a particle reverse - particle->direction += type->dirIncr; - particle->size += type->sizeIncr; - if (0.0 > particle->size) particle->size = 0.0; - particle->angle += type->angIncr; - - particle->phase = (uint8_t) ((particle->phase + 8u) & 0xFFu); particle->life--; - - if (type->stepType >= 0 && type->stepNumber != 0) { - int32_t count = particleResolveCount(type->stepNumber); + bool died = (0 >= particle->life); + + // A particle spawns its type's step particles every step it survives, and its death particles + // instead of them on the step it does not. The two never fire on the same step. + int32_t spawnType = died ? type->deathType : type->stepType; + int32_t spawnNumber = died ? type->deathNumber : type->stepNumber; + if (spawnType >= 0 && spawnNumber != 0) { + int32_t count = particleResolveCount((GMLReal) spawnNumber); if (count > 0) { PendingSpawn spawn; - spawn.typeId = type->stepType; + spawn.typeId = spawnType; spawn.x = particle->x; spawn.y = particle->y; spawn.count = count; @@ -439,35 +429,71 @@ void Particles_updateSystem(Runner* runner, int32_t systemId) { } } - if (particle->life > 0) { + if (!died) { index++; continue; } - if (type->deathType >= 0 && type->deathNumber != 0) { - int32_t count = particleResolveCount(type->deathNumber); - if (count > 0) { - PendingSpawn spawn; - spawn.typeId = type->deathType; - spawn.x = particle->x; - spawn.y = particle->y; - spawn.count = count; - arrput(pending, spawn); - } - } - // Swap-remove: order within a system does not affect the drawn result, every particle of a // system is drawn in the same pass at the same depth. system->particles[index] = arrlast(system->particles); arrpop(system->particles); } + // The collected spawns land before the movement pass, so they move on the step they are born. repeat((int32_t) arrlen(pending), i) { repeat(pending[i].count, n) { if (!particleSpawnAt(runner, system, pending[i].typeId, pending[i].x, pending[i].y, 0xFFFFFFu, false)) break; } } arrfree(pending); + + // Pass 2: increments, then gravity, then the move. The increment lands before the particle + // travels, so its very first step already runs at (speed + speed increment). + int32_t particleCount = (int32_t) arrlen(system->particles); + repeat(particleCount, i) { + Particle* particle = &system->particles[i]; + ParticleType* type = Particles_typeGet(runner, particle->typeId); + if (type == nullptr) continue; + + particle->speed += type->speedIncr; + if (0.0 > particle->speed) particle->speed = 0.0; // GameMaker never lets a particle reverse + particle->direction += type->dirIncr; + particle->angle += type->angIncr; + + if (type->gravityAmount != 0.0) { + // Gravity folds into the velocity vector permanently, so later speed/direction increments + // apply on top of it. Matches GameMaker, where gravity bends a particle's course for good. + GMLReal baseRadians = particle->direction * PARTICLE_DEG2RAD; + GMLReal gravityRadians = type->gravityDirection * PARTICLE_DEG2RAD; + GMLReal hspeed = particle->speed * GMLReal_cos(baseRadians) + type->gravityAmount * GMLReal_cos(gravityRadians); + GMLReal vspeed = -particle->speed * GMLReal_sin(baseRadians) - type->gravityAmount * GMLReal_sin(gravityRadians); + particle->speed = GMLReal_sqrt(hspeed * hspeed + vspeed * vspeed); + if (hspeed != 0.0 || vspeed != 0.0) + particle->direction = GMLReal_atan2(-vspeed, hspeed) / PARTICLE_DEG2RAD; + } + + int32_t age = particle->lifeTotal - particle->life; + GMLReal effectiveSpeed = particle->speed + type->speedWiggle * particleWiggleSpeed(age, particle->seed); + GMLReal effectiveDirection = particle->direction + type->dirWiggle * particleWiggleDirection(age, particle->seed); + + GMLReal radians = effectiveDirection * PARTICLE_DEG2RAD; + particle->x += effectiveSpeed * GMLReal_cos(radians); + particle->y -= effectiveSpeed * GMLReal_sin(radians); // GML's y axis grows downward + + // GameMaker's third pass, less the colour and alpha curves: those are functions of the + // particle's age alone, so they are evaluated at draw time rather than stored per particle. + particle->size += type->sizeIncr; + if (0.0 > particle->size) particle->size = 0.0; + } + + // Emitters stream last, into a system where everything already alive has finished moving. + int32_t emitterCount = (int32_t) arrlen(system->emitters); + repeat(emitterCount, i) { + ParticleEmitter* emitter = &system->emitters[i]; + if (!emitter->used || 0 > emitter->streamType || emitter->streamNumber == 0.0) continue; + particleEmitterSpawn(runner, system, emitter, emitter->streamType, particleResolveCount(emitter->streamNumber)); + } } void Particles_updateAutomatic(Runner* runner) { @@ -542,12 +568,13 @@ void Particles_drawSystem(Runner* runner, int32_t systemId) { ParticleType* type = Particles_typeGet(runner, particle->typeId); if (type == nullptr || 0 > type->sprite) continue; - GMLReal ageFraction = 1.0 - ((GMLReal) particle->life / (GMLReal) particle->lifeTotal); + int32_t age = particle->lifeTotal - particle->life; + GMLReal ageFraction = (GMLReal) age / (GMLReal) particle->lifeTotal; GMLReal alpha = particleAlphaAt(type, ageFraction); if (0.0 >= alpha) continue; if (alpha > 1.0) alpha = 1.0; - GMLReal size = particle->size + type->sizeWiggle * particleWiggle(particle->phase); + GMLReal size = particle->size + type->sizeWiggle * particleWiggleSize(age, particle->seed); if (0.0 >= size) continue; int32_t subimg = particle->subimgBase; @@ -558,7 +585,7 @@ void Particles_drawSystem(Runner* runner, int32_t systemId) { ? runner->dataWin->sprt.sprites[type->sprite].textureCount : 0; if (frames > 0) subimg += (int32_t) (ageFraction * (GMLReal) frames); } else { - subimg += particle->lifeTotal - particle->life; + subimg += age; } } @@ -582,7 +609,7 @@ void Particles_drawSystem(Runner* runner, int32_t systemId) { // A relative orientation is measured from the direction the particle is travelling, so a // sprite drawn nose-first keeps pointing along its arc as gravity bends it. - GMLReal angle = particle->angle + type->angWiggle * particleWiggle(particle->phase); + GMLReal angle = particle->angle + type->angWiggle * particleWiggleAngle(age, particle->seed); if (type->angRelative) angle += particle->direction; Renderer_drawSpriteExt(renderer, type->sprite, subimg, diff --git a/src/particles.h b/src/particles.h index 9c2b8208c..f4cd2e97a 100644 --- a/src/particles.h +++ b/src/particles.h @@ -86,7 +86,7 @@ typedef struct { int32_t subimgBase; // starting subimage uint32_t colour; // set by part_particles_create_colour; overrides the type's colour curve bool colourFixed; // true when "colour" above is in force - uint8_t phase; // wiggle phase, advanced every step + int32_t seed; // per-particle random; phase-shifts this particle's wiggle oscillations } Particle; typedef struct { @@ -95,7 +95,9 @@ typedef struct { int32_t shape; int32_t distribution; int32_t streamType; // type id streamed every step, -1 when the emitter is idle - int32_t streamNumber; // particles per step; negative means a 1-in-|n| chance + // Particles per step. Negative means a 1-in-|n| chance; a fraction is a chance of one more, so + // an emitter streaming 0.5 spawns on roughly every other step. + GMLReal streamNumber; } ParticleEmitter; typedef struct { @@ -140,10 +142,12 @@ int32_t Particles_emitterCreate(Runner* runner, int32_t systemId); ParticleEmitter* Particles_emitterGet(Runner* runner, int32_t systemId, int32_t emitterId); void Particles_emitterDestroy(Runner* runner, int32_t systemId, int32_t emitterId); void Particles_emitterDestroyAll(Runner* runner, int32_t systemId); -void Particles_emitterBurst(Runner* runner, int32_t systemId, int32_t emitterId, int32_t typeId, int32_t number); +void Particles_emitterBurst(Runner* runner, int32_t systemId, int32_t emitterId, int32_t typeId, GMLReal number); // ===[ Frame hooks ]=== -// Steps one system: emitters stream, particles move and age, dead particles run their death spawn. +// Steps one system: particles age and run their death spawn, then everything alive moves, and only +// then do the emitters stream. That order is GameMaker's, and it is why a streamed particle stands +// still on the step it is born. void Particles_updateSystem(Runner* runner, int32_t systemId); // Steps every system with automaticUpdate set. Called once at the end of Runner_step. void Particles_updateAutomatic(Runner* runner); diff --git a/src/vm_builtins.c b/src/vm_builtins.c index 41c0135c8..1a51e1d1a 100644 --- a/src/vm_builtins.c +++ b/src/vm_builtins.c @@ -16786,12 +16786,14 @@ static RValue builtin_part_emitter_stream(VMContext* ctx, RValue* args, MAYBE_UN ParticleEmitter* emitter = Particles_emitterGet(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])); if (emitter == nullptr) return RValue_makeUndefined(); emitter->streamType = RValue_toInt32(args[2]); - emitter->streamNumber = RValue_toInt32(args[3]); + // Kept as a real: GameMaker spends the fractional part as a chance of one extra particle, which + // is how a game asks an emitter for fewer than one particle per step. + emitter->streamNumber = RValue_toReal(args[3]); return RValue_makeUndefined(); } static RValue builtin_part_emitter_burst(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_emitterBurst(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1]), RValue_toInt32(args[2]), RValue_toInt32(args[3])); + Particles_emitterBurst(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1]), RValue_toInt32(args[2]), RValue_toReal(args[3])); return RValue_makeUndefined(); } From 4293dbf68e248dcefd6249991d5d12608253a0d2 Mon Sep 17 00:00:00 2001 From: Ananim353 Date: Tue, 4 Aug 2026 19:09:09 +0300 Subject: [PATCH 5/6] Clamp reversed particle ranges, the way the runtime does The previous commit let a reversed pair sweep downward and argued the runtime must behave that way, on the grounds that clamping would turn DELTARUNE's leaf and confetti sprays into straight lines. Trying it in the particle editor settles it the other way round: with gravity, direction increment and wiggle all zeroed, part_type_direction(-45, -90) emits a single line along -45 and no fan at all. GameMaker clamps a non-positive range to its low bound, exactly as MyRandom() does in the HTML5 runtime, so particleRandomRange and particleRandomIntRange now do the same. A straight line turns out to be the right look for both effects, which is presumably why it never looked broken: obj_festival_particles and obj_part_leaves are steady diagonal drifts -- confetti and wind-blown leaves -- and neither applies gravity, so there was never an arc for a fan to fill. That previous commit also named three affected call sites. There are two. The third, obj_part_leaves_2, was a misreading of the bytecode: its particle type lives in an array, and an array-accessor push pops the index and the instance type before pushing the element, so treating those two operands as call arguments shifted the whole argument list. Read properly it asks for (-165, -135), which is not reversed. Verified: loritta at frame 9000 byte-identical, Chapter 5's petals unchanged (obj_sakurafubuki_new uses a constant direction and never took this path). --- src/particles.c | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/particles.c b/src/particles.c index 2f5b135d0..43a5d56bc 100644 --- a/src/particles.c +++ b/src/particles.c @@ -31,16 +31,18 @@ static GMLReal particleRandom01(void) { return (GMLReal) (particleRandomBits() >> 8) / (GMLReal) 0x01000000u; } -// Uniform between the two bounds. Deliberately NOT normalised to (min <= max): GameMaker computes -// "min + random * (max - min)" and lets a reversed pair sweep downward, which games rely on. -// DELTARUNE Chapter 5 calls part_type_direction with (-1, -165), (-45, -90) and (-40, -90); each is -// a fan that collapses into a single direction the moment the ends are clamped. +// Uniform between the two bounds, and NOT normalised to (min <= max): GameMaker gives up on a range +// that is not positive rather than swapping the ends, so part_type_direction(-45, -90) sprays along +// a constant -45 instead of fanning down to -90. That is what MyRandom() does in the HTML5 runtime, +// and the particle editor's preview agrees when gravity, direction increment and wiggle are zeroed. // -// Worth spelling out because the HTML5 runtime disagrees: its MyRandom() returns the low bound -// whenever (max - min) <= 0, so those three sprays would come out as straight jets there. The games -// this runner is pointed at are built against the Windows runtime, so that is the one to match. +// Two effects in DELTARUNE Chapter 5 lean on it -- obj_festival_particles at (-45, -90) and +// obj_part_leaves at (-40, -90). Both are steady diagonal drifts, confetti and wind-blown leaves, +// and neither applies gravity, so sweeping the range would spread them into a visible fan. static GMLReal particleRandomRange(GMLReal min, GMLReal max) { - return min + particleRandom01() * (max - min); + GMLReal range = max - min; + if (0.0 >= range) return min; + return min + particleRandom01() * range; } // Triangle wave in [-1, 1] driven by the particle's age. Each wiggling property runs on its own @@ -71,11 +73,11 @@ static int32_t particleResolveCount(GMLReal number) { } // Uniform integer in [min, max] with both ends included, which is how GameMaker reads part_type_life. -// Truncating a real drawn from [min, max) would never return max. Reversed pairs sweep downward for -// the same reason particleRandomRange() lets them. +// Truncating a real drawn from [min, max) would never return max. A reversed pair collapses to min, +// the same way particleRandomRange() treats one. static int32_t particleRandomIntRange(int32_t min, int32_t max) { - GMLReal span = (GMLReal) max - (GMLReal) min; - span += (span >= 0.0) ? 1.0 : -1.0; + if (min >= max) return min; + GMLReal span = (GMLReal) max - (GMLReal) min + 1.0; return min + (int32_t) (particleRandom01() * span); } From 36fd0f62302a1335901a5fcc2be2ffe8bf825215 Mon Sep 17 00:00:00 2001 From: Ananim353 Date: Sun, 9 Aug 2026 03:31:05 +0300 Subject: [PATCH 6/6] Fold the particle system into vm_builtins.c particles.c and its header are gone. The resources live in runner.h next to the ds_* ones, and every part_* builtin now does its own work instead of calling a one-line wrapper. Only four entry points stay external, for the depth list, the frame step and teardown. Particles also outlive the type they came from, the way the runtime does. A destroyed type answers part_type_exists with false and can no longer be spawned from, but it keeps its pool slot until its last particle dies: those particles age out looking as they did instead of vanishing on the next step, and the id is never handed to a new type under them. Checked against the GameMaker runtime running the same bytecode: it keeps such particles alive, lets them die on schedule, and draws them in their own type's colour. --- src/particles.c | 652 --------------------------------------- src/particles.h | 160 ---------- src/runner.h | 117 ++++++- src/vm_builtins.c | 770 ++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 833 insertions(+), 866 deletions(-) delete mode 100644 src/particles.c delete mode 100644 src/particles.h diff --git a/src/particles.c b/src/particles.c deleted file mode 100644 index 43a5d56bc..000000000 --- a/src/particles.c +++ /dev/null @@ -1,652 +0,0 @@ -#include "particles.h" - -#include "log.h" -#include "math_compat.h" -#include "renderer.h" -#include "runner.h" -#include "utils.h" - -#include "stb_ds.h" - -#define PARTICLE_DEG2RAD (M_PI / 180.0) - -// Particles draw from their own random stream instead of rand(). Sharing rand() would make every -// particle spawn shift the sequence the game itself sees, so merely adding a particle effect to a -// scene would change unrelated randomised behaviour (and every seeded screenshot test with it). -// The trade-off is that --seed and randomize() do not reach particles. -#define PARTICLE_RNG_SEED 0x9E3779B9u -static uint32_t g_particleRngState = PARTICLE_RNG_SEED; - -static uint32_t particleRandomBits(void) { - uint32_t x = g_particleRngState; - x ^= x << 13; - x ^= x >> 17; - x ^= x << 5; - g_particleRngState = x; - return x; -} - -// Uniform in [0, 1). -static GMLReal particleRandom01(void) { - return (GMLReal) (particleRandomBits() >> 8) / (GMLReal) 0x01000000u; -} - -// Uniform between the two bounds, and NOT normalised to (min <= max): GameMaker gives up on a range -// that is not positive rather than swapping the ends, so part_type_direction(-45, -90) sprays along -// a constant -45 instead of fanning down to -90. That is what MyRandom() does in the HTML5 runtime, -// and the particle editor's preview agrees when gravity, direction increment and wiggle are zeroed. -// -// Two effects in DELTARUNE Chapter 5 lean on it -- obj_festival_particles at (-45, -90) and -// obj_part_leaves at (-40, -90). Both are steady diagonal drifts, confetti and wind-blown leaves, -// and neither applies gravity, so sweeping the range would spread them into a visible fan. -static GMLReal particleRandomRange(GMLReal min, GMLReal max) { - GMLReal range = max - min; - if (0.0 >= range) return min; - return min + particleRandom01() * range; -} - -// Triangle wave in [-1, 1] driven by the particle's age. Each wiggling property runs on its own -// period and its own multiple of the particle's seed, so the four oscillations never line up and a -// spray of particles does not pulse in unison. The periods below are GameMaker's. -static GMLReal particleWiggle(int32_t age, int32_t seed, int32_t seedMultiplier, int32_t period) { - GMLReal t = (GMLReal) (4 * ((age + seedMultiplier * seed) % period)) / (GMLReal) period; // 0..4 - if (t > 2.0) t = 4.0 - t; // fold to 0..2 - return t - 1.0; -} - -static GMLReal particleWiggleDirection(int32_t age, int32_t seed) { return particleWiggle(age, seed, 3, 24); } -static GMLReal particleWiggleSpeed(int32_t age, int32_t seed) { return particleWiggle(age, seed, 4, 20); } -static GMLReal particleWiggleAngle(int32_t age, int32_t seed) { return particleWiggle(age, seed, 2, 16); } -static GMLReal particleWiggleSize(int32_t age, int32_t seed) { return particleWiggle(age, seed, 1, 16); } - -// "number" follows the GML convention shared by part_emitter_stream and part_type_death: a positive -// value is a literal count, a negative value is a 1-in-|number| chance of spawning a single particle. -// Emitters take a real count, and GameMaker spends the fraction as a chance of one extra particle, -// so part_emitter_stream(..., 0.5) emits on roughly every other step. -static int32_t particleResolveCount(GMLReal number) { - if (0.0 > number) return (particleRandom01() * -number < 1.0) ? 1 : 0; - - int32_t whole = (int32_t) number; - GMLReal fraction = number - (GMLReal) whole; - if (fraction > 0.0 && particleRandom01() <= fraction) whole++; - return whole; -} - -// Uniform integer in [min, max] with both ends included, which is how GameMaker reads part_type_life. -// Truncating a real drawn from [min, max) would never return max. A reversed pair collapses to min, -// the same way particleRandomRange() treats one. -static int32_t particleRandomIntRange(int32_t min, int32_t max) { - if (min >= max) return min; - GMLReal span = (GMLReal) max - (GMLReal) min + 1.0; - return min + (int32_t) (particleRandom01() * span); -} - -// ===[ Pools ]=== - -ParticleSystem* Particles_systemGet(Runner* runner, int32_t systemId) { - if (0 > systemId || systemId >= (int32_t) arrlen(runner->particleSystemPool)) return nullptr; - ParticleSystem* system = &runner->particleSystemPool[systemId]; - return system->used ? system : nullptr; -} - -ParticleType* Particles_typeGet(Runner* runner, int32_t typeId) { - if (0 > typeId || typeId >= (int32_t) arrlen(runner->particleTypePool)) return nullptr; - ParticleType* type = &runner->particleTypePool[typeId]; - return type->used ? type : nullptr; -} - -int32_t Particles_systemCreate(Runner* runner) { - int32_t poolSize = (int32_t) arrlen(runner->particleSystemPool); - int32_t id = poolSize; - repeat(poolSize, i) { - if (!runner->particleSystemPool[i].used) { id = (int32_t) i; break; } - } - - ParticleSystem system; - ZERO_STRUCT(system); - system.used = true; - system.automaticUpdate = true; - system.automaticDraw = true; - system.depth = 0; - - if (id == poolSize) { - arrput(runner->particleSystemPool, system); - } else { - runner->particleSystemPool[id] = system; - } - - // The system joins the depth-sorted draw list while automaticDraw is set. - runner->drawableListStructureDirty = true; - return id; -} - -void Particles_systemDestroy(Runner* runner, int32_t systemId) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - if (system == nullptr) return; - - arrfree(system->particles); - arrfree(system->emitters); - ZERO_STRUCT(*system); - runner->drawableListStructureDirty = true; -} - -void Particles_systemSetDepth(Runner* runner, int32_t systemId, int32_t depth) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - if (system == nullptr || system->depth == depth) return; - system->depth = depth; - runner->drawableListSortDirty = true; -} - -void Particles_systemSetAutomaticDraw(Runner* runner, int32_t systemId, bool automatic) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - if (system == nullptr || system->automaticDraw == automatic) return; - system->automaticDraw = automatic; - // Only switching drawing ON has to rebuild, since that is what adds an entry the cache does not - // hold. Switching it off is filtered at draw time, like instance visibility, so the common - // "automatic_draw(false) then drawit()" idiom re-issued every frame does not drag a full rebuild - // and re-sort of every drawable in the room behind it. - if (automatic) runner->drawableListStructureDirty = true; -} - -void Particles_systemClear(Runner* runner, int32_t systemId) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - if (system == nullptr) return; - - arrsetlen(system->particles, 0); - arrsetlen(system->emitters, 0); - system->automaticUpdate = true; - system->automaticDraw = true; - system->depth = 0; - system->originX = 0.0; - system->originY = 0.0; - system->warnedFull = false; - // Depth and automatic drawing both just moved, so the cached list has to be rebuilt either way. - runner->drawableListStructureDirty = true; -} - -void Particles_systemClearParticles(Runner* runner, int32_t systemId) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - if (system == nullptr) return; - arrsetlen(system->particles, 0); -} - -int32_t Particles_systemParticleCount(Runner* runner, int32_t systemId) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - return (system == nullptr) ? 0 : (int32_t) arrlen(system->particles); -} - -// GameMaker's defaults for a fresh type: one white unit-sized particle, no motion, 100 steps. -static void particleTypeSetDefaults(ParticleType* type) { - ZERO_STRUCT(*type); - type->used = true; - type->sprite = -1; - type->sizeMin = 1.0; - type->sizeMax = 1.0; - type->scaleX = 1.0; - type->scaleY = 1.0; - type->lifeMin = 100; - type->lifeMax = 100; - type->alphaStart = 1.0; - type->alphaMiddle = 1.0; - type->alphaEnd = 1.0; - type->colourStart = 0xFFFFFFu; - type->colourMiddle = 0xFFFFFFu; - type->colourEnd = 0xFFFFFFu; - type->deathType = -1; - type->stepType = -1; -} - -int32_t Particles_typeCreate(Runner* runner) { - int32_t poolSize = (int32_t) arrlen(runner->particleTypePool); - int32_t id = poolSize; - repeat(poolSize, i) { - if (!runner->particleTypePool[i].used) { id = (int32_t) i; break; } - } - - ParticleType type; - particleTypeSetDefaults(&type); - - if (id == poolSize) { - arrput(runner->particleTypePool, type); - } else { - runner->particleTypePool[id] = type; - } - return id; -} - -void Particles_typeClear(Runner* runner, int32_t typeId) { - ParticleType* type = Particles_typeGet(runner, typeId); - if (type == nullptr) return; - particleTypeSetDefaults(type); -} - -void Particles_typeDestroy(Runner* runner, int32_t typeId) { - ParticleType* type = Particles_typeGet(runner, typeId); - if (type == nullptr) return; - ZERO_STRUCT(*type); - // Particles already alive keep their typeId. Drawing and stepping both resolve the type every - // frame and skip when it is gone, so a destroyed type simply stops its remaining particles. -} - -int32_t Particles_emitterCreate(Runner* runner, int32_t systemId) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - if (system == nullptr) return -1; - - int32_t count = (int32_t) arrlen(system->emitters); - int32_t id = count; - repeat(count, i) { - if (!system->emitters[i].used) { id = (int32_t) i; break; } - } - - ParticleEmitter emitter; - ZERO_STRUCT(emitter); - emitter.used = true; - emitter.shape = PS_SHAPE_RECTANGLE; - emitter.distribution = PS_DISTR_LINEAR; - emitter.streamType = -1; - - if (id == count) { - arrput(system->emitters, emitter); - } else { - system->emitters[id] = emitter; - } - return id; -} - -ParticleEmitter* Particles_emitterGet(Runner* runner, int32_t systemId, int32_t emitterId) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - if (system == nullptr) return nullptr; - if (0 > emitterId || emitterId >= (int32_t) arrlen(system->emitters)) return nullptr; - ParticleEmitter* emitter = &system->emitters[emitterId]; - return emitter->used ? emitter : nullptr; -} - -void Particles_emitterDestroy(Runner* runner, int32_t systemId, int32_t emitterId) { - ParticleEmitter* emitter = Particles_emitterGet(runner, systemId, emitterId); - if (emitter == nullptr) return; - ZERO_STRUCT(*emitter); -} - -void Particles_emitterDestroyAll(Runner* runner, int32_t systemId) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - if (system == nullptr) return; - arrsetlen(system->emitters, 0); -} - -// ===[ Spawning ]=== - -// Returns false once the system is full, so the callers' loops can stop instead of spinning through a -// spawn count that came straight from GML and may be enormous. -static bool particleSpawnAt(Runner* runner, ParticleSystem* system, int32_t typeId, GMLReal x, GMLReal y, uint32_t colour, bool fixedColour) { - ParticleType* type = Particles_typeGet(runner, typeId); - if (type == nullptr) return false; - - if ((int32_t) arrlen(system->particles) >= PARTICLE_SYSTEM_MAX_PARTICLES) { - if (!system->warnedFull) { - system->warnedFull = true; - logWarn("Particles: system hit the %d particle cap, further spawns are dropped\n", PARTICLE_SYSTEM_MAX_PARTICLES); - } - return false; - } - - Particle particle; - ZERO_STRUCT(particle); - particle.typeId = typeId; - particle.x = x; - particle.y = y; - particle.speed = particleRandomRange(type->speedMin, type->speedMax); - particle.direction = particleRandomRange(type->dirMin, type->dirMax); - particle.size = particleRandomRange(type->sizeMin, type->sizeMax); - particle.angle = particleRandomRange(type->angMin, type->angMax); - particle.colour = colour; - particle.colourFixed = fixedColour; - particle.lifeTotal = particleRandomIntRange(type->lifeMin, type->lifeMax); - if (1 > particle.lifeTotal) particle.lifeTotal = 1; - particle.life = particle.lifeTotal; - particle.seed = (int32_t) (particleRandomBits() % 100000u); - - if (type->spriteRandom && type->sprite >= 0 && runner->dataWin != nullptr && (uint32_t) type->sprite < runner->dataWin->sprt.count) { - uint32_t frames = runner->dataWin->sprt.sprites[type->sprite].textureCount; - if (frames > 0) particle.subimgBase = (int32_t) (particleRandomBits() % frames); - } - - arrput(system->particles, particle); - return true; -} - -// Picks a point inside the emitter's region. Only the linear distribution is modelled; the gaussian -// ones fall back to it (no game we test against uses them, and guessing at the curve would be worse -// than an honest uniform spread). -static void particleEmitterPoint(ParticleEmitter* emitter, GMLReal* outX, GMLReal* outY) { - GMLReal centerX = (emitter->xmin + emitter->xmax) * 0.5; - GMLReal centerY = (emitter->ymin + emitter->ymax) * 0.5; - GMLReal halfW = (emitter->xmax - emitter->xmin) * 0.5; - GMLReal halfH = (emitter->ymax - emitter->ymin) * 0.5; - - // Every shape is sampled directly rather than by rejection. Beyond being cheaper and branch-free, - // it cannot emit outside the region: a bounded rejection loop has to accept whatever it holds when - // the attempts run out, which for a diamond (half the bounding box) leaks a particle into a corner - // often enough to see. - if (emitter->shape == PS_SHAPE_ELLIPSE) { - // sqrt on the radius keeps the spread uniform by area instead of clustering at the centre. - GMLReal radius = GMLReal_sqrt(particleRandom01()); - GMLReal angle = particleRandom01() * 2.0 * M_PI; - *outX = centerX + halfW * radius * GMLReal_cos(angle); - *outY = centerY + halfH * radius * GMLReal_sin(angle); - return; - } - - if (emitter->shape == PS_SHAPE_DIAMOND) { - // Fold the unit square onto the triangle u + v <= 1, then mirror into a random quadrant. - GMLReal u = particleRandom01(); - GMLReal v = particleRandom01(); - if (u + v > 1.0) { u = 1.0 - u; v = 1.0 - v; } - uint32_t quadrant = particleRandomBits(); - if (quadrant & 1u) u = -u; - if (quadrant & 2u) v = -v; - *outX = centerX + halfW * u; - *outY = centerY + halfH * v; - return; - } - - if (emitter->shape == PS_SHAPE_LINE) { - // A line from (xmin, ymin) to (xmax, ymax), not the rectangle they bound. - GMLReal t = particleRandom01(); - *outX = emitter->xmin + (emitter->xmax - emitter->xmin) * t; - *outY = emitter->ymin + (emitter->ymax - emitter->ymin) * t; - return; - } - - *outX = particleRandomRange(emitter->xmin, emitter->xmax); - *outY = particleRandomRange(emitter->ymin, emitter->ymax); -} - -static void particleEmitterSpawn(Runner* runner, ParticleSystem* system, ParticleEmitter* emitter, int32_t typeId, int32_t count) { - repeat(count, i) { - GMLReal x, y; - particleEmitterPoint(emitter, &x, &y); - if (!particleSpawnAt(runner, system, typeId, x, y, 0xFFFFFFu, false)) return; - } -} - -void Particles_emitterBurst(Runner* runner, int32_t systemId, int32_t emitterId, int32_t typeId, GMLReal number) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - ParticleEmitter* emitter = Particles_emitterGet(runner, systemId, emitterId); - if (system == nullptr || emitter == nullptr) return; - particleEmitterSpawn(runner, system, emitter, typeId, particleResolveCount(number)); -} - -void Particles_particlesCreate(Runner* runner, int32_t systemId, GMLReal x, GMLReal y, int32_t typeId, int32_t number, uint32_t colour, bool fixedColour) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - if (system == nullptr) return; - repeat(number, i) { - if (!particleSpawnAt(runner, system, typeId, x, y, colour, fixedColour)) return; - } -} - -// ===[ Update ]=== - -void Particles_updateSystem(Runner* runner, int32_t systemId) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - if (system == nullptr) return; - - // GameMaker ages a system, then moves it, and only then lets the emitters spawn into it. The - // order is worth preserving: it is what makes a streamed particle sit still for the step it is - // born on, while a death particle -- which joins between the two passes -- travels immediately. - - // Collected rather than spawned in place, because arrput() can move the array out from under the - // loop walking it. Only allocated when a type actually asks for step or death particles. - typedef struct { int32_t typeId; GMLReal x, y; int32_t count; } PendingSpawn; - PendingSpawn* pending = nullptr; - - // Pass 1: age everything, note the spawns each particle's type asks for, bury what ran out. - int32_t index = 0; - while (index < (int32_t) arrlen(system->particles)) { - Particle* particle = &system->particles[index]; - ParticleType* type = Particles_typeGet(runner, particle->typeId); - - if (type == nullptr) { - // The type was destroyed underneath us; drop the particle instead of stepping a dead one. - system->particles[index] = arrlast(system->particles); - arrpop(system->particles); - continue; - } - - particle->life--; - bool died = (0 >= particle->life); - - // A particle spawns its type's step particles every step it survives, and its death particles - // instead of them on the step it does not. The two never fire on the same step. - int32_t spawnType = died ? type->deathType : type->stepType; - int32_t spawnNumber = died ? type->deathNumber : type->stepNumber; - if (spawnType >= 0 && spawnNumber != 0) { - int32_t count = particleResolveCount((GMLReal) spawnNumber); - if (count > 0) { - PendingSpawn spawn; - spawn.typeId = spawnType; - spawn.x = particle->x; - spawn.y = particle->y; - spawn.count = count; - arrput(pending, spawn); - } - } - - if (!died) { - index++; - continue; - } - - // Swap-remove: order within a system does not affect the drawn result, every particle of a - // system is drawn in the same pass at the same depth. - system->particles[index] = arrlast(system->particles); - arrpop(system->particles); - } - - // The collected spawns land before the movement pass, so they move on the step they are born. - repeat((int32_t) arrlen(pending), i) { - repeat(pending[i].count, n) { - if (!particleSpawnAt(runner, system, pending[i].typeId, pending[i].x, pending[i].y, 0xFFFFFFu, false)) break; - } - } - arrfree(pending); - - // Pass 2: increments, then gravity, then the move. The increment lands before the particle - // travels, so its very first step already runs at (speed + speed increment). - int32_t particleCount = (int32_t) arrlen(system->particles); - repeat(particleCount, i) { - Particle* particle = &system->particles[i]; - ParticleType* type = Particles_typeGet(runner, particle->typeId); - if (type == nullptr) continue; - - particle->speed += type->speedIncr; - if (0.0 > particle->speed) particle->speed = 0.0; // GameMaker never lets a particle reverse - particle->direction += type->dirIncr; - particle->angle += type->angIncr; - - if (type->gravityAmount != 0.0) { - // Gravity folds into the velocity vector permanently, so later speed/direction increments - // apply on top of it. Matches GameMaker, where gravity bends a particle's course for good. - GMLReal baseRadians = particle->direction * PARTICLE_DEG2RAD; - GMLReal gravityRadians = type->gravityDirection * PARTICLE_DEG2RAD; - GMLReal hspeed = particle->speed * GMLReal_cos(baseRadians) + type->gravityAmount * GMLReal_cos(gravityRadians); - GMLReal vspeed = -particle->speed * GMLReal_sin(baseRadians) - type->gravityAmount * GMLReal_sin(gravityRadians); - particle->speed = GMLReal_sqrt(hspeed * hspeed + vspeed * vspeed); - if (hspeed != 0.0 || vspeed != 0.0) - particle->direction = GMLReal_atan2(-vspeed, hspeed) / PARTICLE_DEG2RAD; - } - - int32_t age = particle->lifeTotal - particle->life; - GMLReal effectiveSpeed = particle->speed + type->speedWiggle * particleWiggleSpeed(age, particle->seed); - GMLReal effectiveDirection = particle->direction + type->dirWiggle * particleWiggleDirection(age, particle->seed); - - GMLReal radians = effectiveDirection * PARTICLE_DEG2RAD; - particle->x += effectiveSpeed * GMLReal_cos(radians); - particle->y -= effectiveSpeed * GMLReal_sin(radians); // GML's y axis grows downward - - // GameMaker's third pass, less the colour and alpha curves: those are functions of the - // particle's age alone, so they are evaluated at draw time rather than stored per particle. - particle->size += type->sizeIncr; - if (0.0 > particle->size) particle->size = 0.0; - } - - // Emitters stream last, into a system where everything already alive has finished moving. - int32_t emitterCount = (int32_t) arrlen(system->emitters); - repeat(emitterCount, i) { - ParticleEmitter* emitter = &system->emitters[i]; - if (!emitter->used || 0 > emitter->streamType || emitter->streamNumber == 0.0) continue; - particleEmitterSpawn(runner, system, emitter, emitter->streamType, particleResolveCount(emitter->streamNumber)); - } -} - -void Particles_updateAutomatic(Runner* runner) { - int32_t count = (int32_t) arrlen(runner->particleSystemPool); - repeat(count, i) { - ParticleSystem* system = &runner->particleSystemPool[i]; - if (!system->used || !system->automaticUpdate) continue; - Particles_updateSystem(runner, (int32_t) i); - } -} - -// ===[ Draw ]=== - -// Alpha follows the three stop points across the particle's life: start -> middle at the halfway -// mark -> end. part_type_alpha1/alpha2 are expressed by collapsing the stops onto each other. -static GMLReal particleAlphaAt(const ParticleType* type, GMLReal ageFraction) { - if (0.5 > ageFraction) { - GMLReal t = ageFraction * 2.0; - return type->alphaStart + (type->alphaMiddle - type->alphaStart) * t; - } - GMLReal t = (ageFraction - 0.5) * 2.0; - return type->alphaMiddle + (type->alphaEnd - type->alphaMiddle) * t; -} - -// Same three stops as the alpha curve. Interpolated per byte, which is correct whatever order the -// channels sit in: GML colours are passed straight through to the renderer without repacking. -static uint32_t particleColourLerp(uint32_t from, uint32_t to, GMLReal t) { - uint32_t out = 0; - repeat(3, shift) { - int32_t bits = (int32_t) shift * 8; - GMLReal a = (GMLReal) ((from >> bits) & 0xFFu); - GMLReal b = (GMLReal) ((to >> bits) & 0xFFu); - int32_t v = (int32_t) (a + (b - a) * t + 0.5); - if (0 > v) v = 0; - if (v > 255) v = 255; - out |= ((uint32_t) v) << bits; - } - return out; -} - -uint32_t Particles_colourMidpoint(uint32_t from, uint32_t to) { - return particleColourLerp(from, to, 0.5); -} - -static uint32_t particleColourAt(const ParticleType* type, GMLReal ageFraction) { - if (0.5 > ageFraction) - return particleColourLerp(type->colourStart, type->colourMiddle, ageFraction * 2.0); - return particleColourLerp(type->colourMiddle, type->colourEnd, (ageFraction - 0.5) * 2.0); -} - -void Particles_drawSystem(Runner* runner, int32_t systemId) { - ParticleSystem* system = Particles_systemGet(runner, systemId); - if (system == nullptr || runner->renderer == nullptr) return; - - int32_t count = (int32_t) arrlen(system->particles); - if (count == 0) return; - - Renderer* renderer = runner->renderer; - bool additiveActive = false; - // Blend state is global and sticky in GML, so an additive type has to hand back exactly what the - // caller had rather than assuming bm_normal: a game that darkens the scene with - // gpu_set_blendmode_ext around its draw would otherwise lose the effect from the particle system - // onwards. Captured lazily, so a system of ordinary particles never touches blending at all. - bool blendTouched = false; - bool blendSaved = false; - int32_t savedBlendMode = bm_normal; - BlendFactors savedBlendFactors; - ZERO_STRUCT(savedBlendFactors); - - repeat(count, i) { - Particle* particle = &system->particles[i]; - ParticleType* type = Particles_typeGet(runner, particle->typeId); - if (type == nullptr || 0 > type->sprite) continue; - - int32_t age = particle->lifeTotal - particle->life; - GMLReal ageFraction = (GMLReal) age / (GMLReal) particle->lifeTotal; - GMLReal alpha = particleAlphaAt(type, ageFraction); - if (0.0 >= alpha) continue; - if (alpha > 1.0) alpha = 1.0; - - GMLReal size = particle->size + type->sizeWiggle * particleWiggleSize(age, particle->seed); - if (0.0 >= size) continue; - - int32_t subimg = particle->subimgBase; - if (type->spriteAnimate) { - if (type->spriteStretch) { - // One full animation cycle stretched over the particle's whole life. - uint32_t frames = ((uint32_t) type->sprite < runner->dataWin->sprt.count) - ? runner->dataWin->sprt.sprites[type->sprite].textureCount : 0; - if (frames > 0) subimg += (int32_t) (ageFraction * (GMLReal) frames); - } else { - subimg += age; - } - } - - if (type->additive != additiveActive) { - if (!blendTouched) { - // The getters are optional in the vtable; without them the best we can do is put - // blending back to bm_normal at the end. - if (renderer->vtable->gpuGetBlendMode != nullptr) { - savedBlendMode = renderer->vtable->gpuGetBlendMode(renderer); - if (renderer->vtable->gpuGetBlendFactors != nullptr) - savedBlendFactors = renderer->vtable->gpuGetBlendFactors(renderer); - blendSaved = true; - } - blendTouched = true; - } - renderer->vtable->gpuSetBlendMode(renderer, type->additive ? bm_add : bm_normal); - additiveActive = type->additive; - } - - uint32_t colour = particle->colourFixed ? particle->colour : particleColourAt(type, ageFraction); - - // A relative orientation is measured from the direction the particle is travelling, so a - // sprite drawn nose-first keeps pointing along its arc as gravity bends it. - GMLReal angle = particle->angle + type->angWiggle * particleWiggleAngle(age, particle->seed); - if (type->angRelative) angle += particle->direction; - - Renderer_drawSpriteExt(renderer, type->sprite, subimg, - (float) (system->originX + particle->x), (float) (system->originY + particle->y), - (float) (type->scaleX * size), (float) (type->scaleY * size), - (float) angle, colour, (float) alpha); - } - - if (!blendTouched) return; - - if (!blendSaved) { - renderer->vtable->gpuSetBlendMode(renderer, bm_normal); - } else if (savedBlendMode == bm_complex && renderer->vtable->gpuSetBlendModeExt != nullptr) { - // gpu_set_blendmode_ext leaves the mode reading back as bm_complex, so the individual - // factors are the only faithful way to put that state back. - renderer->vtable->gpuSetBlendModeExt(renderer, savedBlendFactors.src, savedBlendFactors.dst, - savedBlendFactors.srcAlpha, savedBlendFactors.dstAlpha); - } else { - renderer->vtable->gpuSetBlendMode(renderer, savedBlendMode); - } -} - -// ===[ Teardown ]=== - -void Particles_freeAll(Runner* runner) { - int32_t count = (int32_t) arrlen(runner->particleSystemPool); - repeat(count, i) { - arrfree(runner->particleSystemPool[i].particles); - arrfree(runner->particleSystemPool[i].emitters); - } - arrfree(runner->particleSystemPool); - runner->particleSystemPool = nullptr; - arrfree(runner->particleTypePool); - runner->particleTypePool = nullptr; - // Reached on game_restart as well as shutdown, so put the stream back to where it started: - // a restarted game should produce the same particles as the first run. - g_particleRngState = PARTICLE_RNG_SEED; -} diff --git a/src/particles.h b/src/particles.h deleted file mode 100644 index f4cd2e97a..000000000 --- a/src/particles.h +++ /dev/null @@ -1,160 +0,0 @@ -#ifndef _BS_PARTICLES_H_ -#define _BS_PARTICLES_H_ - -#include "common.h" -#include "real_type.h" -#include - -// Forward declarations -#ifndef RUNNER_DEFINED -#define RUNNER_DEFINED -typedef struct Runner Runner; -#endif - -// ===[ Particle System ]=== -// GameMaker splits particles into three resources: -// * a SYSTEM owns the live particles and the emitters that spawn them, and decides when they are drawn -// * a TYPE describes how a particle looks and moves; types are global, so any system can stream any type -// * an EMITTER is owned by one system and spawns particles of a given type inside a region -// -// Ids are indices into pools hanging off the Runner, with a "used" tombstone so a destroyed id can be -// handed out again. Same convention as the ds_* pools in vm_builtins.c, and games do rely on it. - -// part_emitter_region() shape constants -#define PS_SHAPE_RECTANGLE 0 -#define PS_SHAPE_ELLIPSE 1 -#define PS_SHAPE_DIAMOND 2 -#define PS_SHAPE_LINE 3 - -// part_emitter_region() distribution constants -#define PS_DISTR_LINEAR 0 -#define PS_DISTR_GAUSSIAN 1 -#define PS_DISTR_INVGAUSS 2 - -// Upper bound on live particles per system. GameMaker itself has no such limit, but an emitter left -// streaming in a room the player never leaves will grow without bound, and the consoles this runner -// targets cannot absorb that. Spawns past the cap are dropped (warned about once per system). -#define PARTICLE_SYSTEM_MAX_PARTICLES 8192 - -typedef struct { - bool used; - - int32_t sprite; // sprite asset index, -1 when the type has no sprite (draws nothing) - bool spriteAnimate; // advance the subimage as the particle ages - bool spriteStretch; // stretch one full animation cycle across the particle's whole life - bool spriteRandom; // start from a random subimage - - // Every "min/max/incr/wiggle" quadruple works the same way: the initial value is picked uniformly - // in [min, max], "incr" is added every step, and "wiggle" oscillates the value used for motion and - // drawing without accumulating into the base. - GMLReal sizeMin, sizeMax, sizeIncr, sizeWiggle; - GMLReal scaleX, scaleY; - GMLReal speedMin, speedMax, speedIncr, speedWiggle; - GMLReal dirMin, dirMax, dirIncr, dirWiggle; - - GMLReal gravityAmount; - GMLReal gravityDirection; - - // Drawn orientation. Independent of the direction of travel unless angRelative is set, in which - // case the angle is measured from it. - GMLReal angMin, angMax, angIncr, angWiggle; - bool angRelative; - - int32_t lifeMin, lifeMax; - - // Alpha and colour both run through three stops across the particle's life: start, middle at the - // halfway mark, end. part_type_alpha1/alpha2 and part_type_colour1/colour2 collapse the stops. - GMLReal alphaStart, alphaMiddle, alphaEnd; - uint32_t colourStart, colourMiddle, colourEnd; // GML packed BGR, as the drawing functions take it - bool additive; - - int32_t deathType; // type id spawned when a particle of this type dies, -1 when none - int32_t deathNumber; // how many to spawn; negative means a 1-in-|n| chance - int32_t stepType; // type id spawned every step a particle of this type lives, -1 when none - int32_t stepNumber; // same "negative means a chance" convention as deathNumber -} ParticleType; - -typedef struct { - int32_t typeId; - GMLReal x, y; - GMLReal speed; // base speed, before wiggle - GMLReal direction; // base direction in degrees, before wiggle - GMLReal size; // base size, before wiggle - GMLReal angle; // drawn orientation in degrees, before wiggle - int32_t life; // steps remaining - int32_t lifeTotal; // steps this particle started with, for the alpha/animation curves - int32_t subimgBase; // starting subimage - uint32_t colour; // set by part_particles_create_colour; overrides the type's colour curve - bool colourFixed; // true when "colour" above is in force - int32_t seed; // per-particle random; phase-shifts this particle's wiggle oscillations -} Particle; - -typedef struct { - bool used; - GMLReal xmin, xmax, ymin, ymax; - int32_t shape; - int32_t distribution; - int32_t streamType; // type id streamed every step, -1 when the emitter is idle - // Particles per step. Negative means a 1-in-|n| chance; a fraction is a chance of one more, so - // an emitter streaming 0.5 spawns on roughly every other step. - GMLReal streamNumber; -} ParticleEmitter; - -typedef struct { - bool used; - bool automaticUpdate; // step the system at the end of every frame (on by default, as in GML) - bool automaticDraw; // draw the system from the depth list (on by default, as in GML) - int32_t depth; - GMLReal originX, originY; // part_system_position: added to every particle when drawing - bool warnedFull; // the "hit PARTICLE_SYSTEM_MAX_PARTICLES" warning fires once per system - Particle* particles; // stb_ds array - ParticleEmitter* emitters; // stb_ds array, index = emitter id within this system -} ParticleSystem; - -// ===[ Systems ]=== -int32_t Particles_systemCreate(Runner* runner); -void Particles_systemDestroy(Runner* runner, int32_t systemId); -ParticleSystem* Particles_systemGet(Runner* runner, int32_t systemId); -void Particles_systemSetDepth(Runner* runner, int32_t systemId, int32_t depth); -void Particles_systemSetAutomaticDraw(Runner* runner, int32_t systemId, bool automatic); -// Resets the system to how part_system_create left it: no particles, no emitters, depth 0, both -// automatic flags back on. -void Particles_systemClear(Runner* runner, int32_t systemId); -// Removes every live particle but leaves the emitters and settings in place. -void Particles_systemClearParticles(Runner* runner, int32_t systemId); -int32_t Particles_systemParticleCount(Runner* runner, int32_t systemId); -// Spawns particles directly, bypassing emitters. "colour" is honoured only when fixedColour is set, -// which is what separates part_particles_create_colour from part_particles_create. -void Particles_particlesCreate(Runner* runner, int32_t systemId, GMLReal x, GMLReal y, int32_t typeId, int32_t number, uint32_t colour, bool fixedColour); - -// ===[ Types ]=== -int32_t Particles_typeCreate(Runner* runner); -void Particles_typeDestroy(Runner* runner, int32_t typeId); -// Puts a live type back to the defaults a freshly created one has. -void Particles_typeClear(Runner* runner, int32_t typeId); -ParticleType* Particles_typeGet(Runner* runner, int32_t typeId); -// Blend of two GML colours, used by part_type_colour2 to place the middle stop of a two-stop curve -// on the straight line between its ends. -uint32_t Particles_colourMidpoint(uint32_t from, uint32_t to); - -// ===[ Emitters ]=== -int32_t Particles_emitterCreate(Runner* runner, int32_t systemId); -ParticleEmitter* Particles_emitterGet(Runner* runner, int32_t systemId, int32_t emitterId); -void Particles_emitterDestroy(Runner* runner, int32_t systemId, int32_t emitterId); -void Particles_emitterDestroyAll(Runner* runner, int32_t systemId); -void Particles_emitterBurst(Runner* runner, int32_t systemId, int32_t emitterId, int32_t typeId, GMLReal number); - -// ===[ Frame hooks ]=== -// Steps one system: particles age and run their death spawn, then everything alive moves, and only -// then do the emitters stream. That order is GameMaker's, and it is why a streamed particle stands -// still on the step it is born. -void Particles_updateSystem(Runner* runner, int32_t systemId); -// Steps every system with automaticUpdate set. Called once at the end of Runner_step. -void Particles_updateAutomatic(Runner* runner); -// Draws one system at the current draw state. Backs part_system_drawit and the depth-list entry. -void Particles_drawSystem(Runner* runner, int32_t systemId); - -// Frees both pools. Called from the Runner's cleanup path. -void Particles_freeAll(Runner* runner); - -#endif /* _BS_PARTICLES_H_ */ diff --git a/src/runner.h b/src/runner.h index 5d922ac07..6cd9cda82 100644 --- a/src/runner.h +++ b/src/runner.h @@ -8,7 +8,6 @@ #include "file_system.h" #include "ini.h" #include "instance.h" -#include "particles.h" #include "renderer.h" #include "runner_keyboard.h" #include "spatial_grid.h" @@ -381,6 +380,122 @@ typedef struct { uint8_t* cells; } MpGrid; +// ===[ Particle System ]=== +// Backs the part_* builtins, which are implemented in vm_builtins.c. GameMaker splits particles into +// three resources: +// * a SYSTEM owns the live particles and the emitters that spawn them, and decides when they are drawn +// * a TYPE describes how a particle looks and moves; types are global, so any system can stream any type +// * an EMITTER is owned by one system and spawns particles of a given type inside a region +// +// Ids are indices into the pools below, with a "used" tombstone so a destroyed id can be handed out +// again. Same convention as the ds_* pools, and games do rely on it. + +// part_emitter_region() shape constants +#define PS_SHAPE_RECTANGLE 0 +#define PS_SHAPE_ELLIPSE 1 +#define PS_SHAPE_DIAMOND 2 +#define PS_SHAPE_LINE 3 + +// part_emitter_region() distribution constants +#define PS_DISTR_LINEAR 0 +#define PS_DISTR_GAUSSIAN 1 +#define PS_DISTR_INVGAUSSIAN 2 + +// Upper bound on live particles per system. GameMaker itself has no such limit, but an emitter left +// streaming in a room the player never leaves will grow without bound, and the consoles this runner +// targets cannot absorb that. Spawns past the cap are dropped (warned about once per system). +#define PARTICLE_SYSTEM_MAX_PARTICLES 8192 + +typedef struct { + bool used; + + int32_t sprite; // sprite asset index, -1 when the type has no sprite (draws nothing) + bool spriteAnimate; // advance the subimage as the particle ages + bool spriteStretch; // stretch one full animation cycle across the particle's whole life + bool spriteRandom; // start from a random subimage + + // Every "min/max/incr/wiggle" quadruple works the same way: the initial value is picked uniformly + // in [min, max], "incr" is added every step, and "wiggle" oscillates the value used for motion and + // drawing without accumulating into the base. + GMLReal sizeMin, sizeMax, sizeIncr, sizeWiggle; + GMLReal scaleX, scaleY; + GMLReal speedMin, speedMax, speedIncr, speedWiggle; + GMLReal dirMin, dirMax, dirIncr, dirWiggle; + + GMLReal gravityAmount; + GMLReal gravityDirection; + + // Drawn orientation. Independent of the direction of travel unless angRelative is set, in which + // case the angle is measured from it. + GMLReal angMin, angMax, angIncr, angWiggle; + bool angRelative; + + int32_t lifeMin, lifeMax; + + // Alpha and colour both run through three stops across the particle's life: start, middle at the + // halfway mark, end. part_type_alpha1/alpha2 and part_type_colour1/colour2 collapse the stops. + GMLReal alphaStart, alphaMiddle, alphaEnd; + uint32_t colourStart, colourMiddle, colourEnd; // GML packed BGR, as the drawing functions take it + bool additive; + + int32_t deathType; // type id spawned when a particle of this type dies, -1 when none + int32_t deathNumber; // how many to spawn; negative means a 1-in-|n| chance + int32_t stepType; // type id spawned every step a particle of this type lives, -1 when none + int32_t stepNumber; // same "negative means a chance" convention as deathNumber + + // Particles alive that were born from this type. A destroyed type keeps its slot until the + // count reaches zero: in GameMaker a particle outlives the type it came from, and holding the + // slot is far cheaper than copying every type field into every particle. + int32_t refCount; +} ParticleType; + +typedef struct { + int32_t typeId; + GMLReal x, y; + GMLReal speed; // base speed, before wiggle + GMLReal direction; // base direction in degrees, before wiggle + GMLReal size; // base size, before wiggle + GMLReal angle; // drawn orientation in degrees, before wiggle + int32_t life; // steps remaining + int32_t lifeTotal; // steps this particle started with, for the alpha/animation curves + int32_t subimgBase; // starting subimage + uint32_t colour; // set by part_particles_create_colour; overrides the type's colour curve + bool colourFixed; // true when "colour" above is in force + int32_t seed; // per-particle random; phase-shifts this particle's wiggle oscillations +} Particle; + +typedef struct { + bool used; + GMLReal xmin, xmax, ymin, ymax; + int32_t shape; + int32_t distribution; + int32_t streamType; // type id streamed every step, -1 when the emitter is idle + // Particles per step. Negative means a 1-in-|n| chance; a fraction is a chance of one more, so + // an emitter streaming 0.5 spawns on roughly every other step. + GMLReal streamNumber; +} ParticleEmitter; + +typedef struct { + bool used; + bool automaticUpdate; // step the system at the end of every frame (on by default, as in GML) + bool automaticDraw; // draw the system from the depth list (on by default, as in GML) + int32_t depth; + GMLReal originX, originY; // part_system_position: added to every particle when drawing + bool warnedFull; // the "hit PARTICLE_SYSTEM_MAX_PARTICLES" warning fires once per system + Particle* particles; // stb_ds array + ParticleEmitter* emitters; // stb_ds array, index = emitter id within this system +} ParticleSystem; + +// Implemented in vm_builtins.c next to the part_* builtins; these four are the only entry points the +// runner itself needs (depth list, frame step, teardown). +ParticleSystem* Particles_systemGet(Runner* runner, int32_t systemId); +// Steps every system with automaticUpdate set. Called once at the end of Runner_step. +void Particles_updateAutomatic(Runner* runner); +// Draws one system at the current draw state. Backs part_system_drawit and the depth-list entry. +void Particles_drawSystem(Runner* runner, int32_t systemId); +// Frees both pools. Called from the Runner's cleanup path. +void Particles_freeAll(Runner* runner); + // Open text file handle for GML file_text_* functions #define MAX_OPEN_TEXT_FILES 32 typedef struct { diff --git a/src/vm_builtins.c b/src/vm_builtins.c index 1a51e1d1a..8894fd835 100644 --- a/src/vm_builtins.c +++ b/src/vm_builtins.c @@ -16500,42 +16500,607 @@ static RValue builtin_sprite_get_info(VMContext* ctx, RValue* args, int32_t argC } // ===[ PARTICLE FUNCTIONS ]=== -// Thin bindings over particles.c. Everything that can be asked of a dead id returns undefined rather -// than faulting, matching GameMaker, where calling a part_* setter on a destroyed id is a silent no-op. +// GameMaker's particle system. The resources and the two pools they live in are declared in runner.h, +// next to the ds_* ones; everything that drives them is here, with the part_* builtins at the bottom. +// +// Everything that can be asked of a dead id returns undefined rather than faulting, matching +// GameMaker, where calling a part_* setter on a destroyed id is a silent no-op. + +#define PARTICLE_DEG2RAD (M_PI / 180.0) + +// Particles draw from their own random stream instead of rand(). Sharing rand() would make every +// particle spawn shift the sequence the game itself sees, so merely adding a particle effect to a +// scene would change unrelated randomised behaviour (and every seeded screenshot test with it). +// The trade-off is that --seed and randomize() do not reach particles. +#define PARTICLE_RNG_SEED 0x9E3779B9u +static uint32_t g_particleRngState = PARTICLE_RNG_SEED; + +static uint32_t particleRandomBits(void) { + uint32_t x = g_particleRngState; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + g_particleRngState = x; + return x; +} + +// Uniform in [0, 1). +static GMLReal particleRandom01(void) { + return (GMLReal) (particleRandomBits() >> 8) / (GMLReal) 0x01000000u; +} + +// Uniform between the two bounds, and NOT normalised to (min <= max): GameMaker gives up on a range +// that is not positive rather than swapping the ends, so part_type_direction(-45, -90) sprays along +// a constant -45 instead of fanning down to -90. That is what MyRandom() does in the HTML5 runtime, +// and the particle editor's preview agrees when gravity, direction increment and wiggle are zeroed. +// +// Two effects in DELTARUNE Chapter 5 lean on it -- obj_festival_particles at (-45, -90) and +// obj_part_leaves at (-40, -90). Both are steady diagonal drifts, confetti and wind-blown leaves, +// and neither applies gravity, so sweeping the range would spread them into a visible fan. +static GMLReal particleRandomRange(GMLReal min, GMLReal max) { + GMLReal range = max - min; + if (0.0 >= range) return min; + return min + particleRandom01() * range; +} + +// Uniform integer in [min, max] with both ends included, which is how GameMaker reads part_type_life. +// Truncating a real drawn from [min, max) would never return max. A reversed pair collapses to min, +// the same way particleRandomRange() treats one. +static int32_t particleRandomIntRange(int32_t min, int32_t max) { + if (min >= max) return min; + GMLReal span = (GMLReal) max - (GMLReal) min + 1.0; + return min + (int32_t) (particleRandom01() * span); +} + +// Triangle wave in [-1, 1] driven by the particle's age. Each wiggling property runs on its own +// period and its own multiple of the particle's seed, so the four oscillations never line up and a +// spray of particles does not pulse in unison. The periods below are GameMaker's. +static GMLReal particleWiggle(int32_t age, int32_t seed, int32_t seedMultiplier, int32_t period) { + GMLReal t = (GMLReal) (4 * ((age + seedMultiplier * seed) % period)) / (GMLReal) period; // 0..4 + if (t > 2.0) t = 4.0 - t; // fold to 0..2 + return t - 1.0; +} + +static GMLReal particleWiggleDirection(int32_t age, int32_t seed) { return particleWiggle(age, seed, 3, 24); } +static GMLReal particleWiggleSpeed(int32_t age, int32_t seed) { return particleWiggle(age, seed, 4, 20); } +static GMLReal particleWiggleAngle(int32_t age, int32_t seed) { return particleWiggle(age, seed, 2, 16); } +static GMLReal particleWiggleSize(int32_t age, int32_t seed) { return particleWiggle(age, seed, 1, 16); } + +// "number" follows the GML convention shared by part_emitter_stream and part_type_death: a positive +// value is a literal count, a negative value is a 1-in-|number| chance of spawning a single particle. +// Emitters take a real count, and GameMaker spends the fraction as a chance of one extra particle, +// so part_emitter_stream(..., 0.5) emits on roughly every other step. +static int32_t particleResolveCount(GMLReal number) { + if (0.0 > number) return (particleRandom01() * -number < 1.0) ? 1 : 0; + + int32_t whole = (int32_t) number; + GMLReal fraction = number - (GMLReal) whole; + if (fraction > 0.0 && particleRandom01() <= fraction) whole++; + return whole; +} + +// ===[ Pools ]=== + +// Not static: the runner resolves systems by id when it builds the depth-sorted draw list. +ParticleSystem* Particles_systemGet(Runner* runner, int32_t systemId) { + if (0 > systemId || systemId >= (int32_t) arrlen(runner->particleSystemPool)) return nullptr; + ParticleSystem* system = &runner->particleSystemPool[systemId]; + return system->used ? system : nullptr; +} + +static ParticleType* particleTypeGet(Runner* runner, int32_t typeId) { + if (0 > typeId || typeId >= (int32_t) arrlen(runner->particleTypePool)) return nullptr; + ParticleType* type = &runner->particleTypePool[typeId]; + return type->used ? type : nullptr; +} + +// Same, but also answers for a type that part_type_destroy already removed and whose particles are +// still alive. Stepping and drawing go through this one: GameMaker copies a type's settings into +// the particle at birth, so destroying the type leaves the particles looking and moving exactly as +// before, and they simply age out. Resolving a destroyed type instead of dropping its particles is +// what makes that true here. +static ParticleType* particleTypeGetLive(Runner* runner, int32_t typeId) { + if (0 > typeId || typeId >= (int32_t) arrlen(runner->particleTypePool)) return nullptr; + ParticleType* type = &runner->particleTypePool[typeId]; + return (type->used || type->refCount > 0) ? type : nullptr; +} + +// Drops one particle's claim on its type, freeing the slot when a destroyed type loses its last. +static void particleTypeRelease(Runner* runner, int32_t typeId) { + if (0 > typeId || typeId >= (int32_t) arrlen(runner->particleTypePool)) return; + ParticleType* type = &runner->particleTypePool[typeId]; + if (0 >= type->refCount) return; + type->refCount--; + if (!type->used && 0 >= type->refCount) ZERO_STRUCT(*type); +} + +// Removes every particle of a system, releasing the types they held. +static void particleClearParticles(Runner* runner, ParticleSystem* system) { + repeat((int32_t) arrlen(system->particles), i) { + particleTypeRelease(runner, system->particles[i].typeId); + } + arrsetlen(system->particles, 0); +} + +static ParticleEmitter* particleEmitterGet(Runner* runner, int32_t systemId, int32_t emitterId) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr) return nullptr; + if (0 > emitterId || emitterId >= (int32_t) arrlen(system->emitters)) return nullptr; + ParticleEmitter* emitter = &system->emitters[emitterId]; + return emitter->used ? emitter : nullptr; +} + +// GameMaker's defaults for a fresh type: one white unit-sized particle, no motion, 100 steps. +static void particleTypeSetDefaults(ParticleType* type) { + ZERO_STRUCT(*type); + type->used = true; + type->sprite = -1; + type->sizeMin = 1.0; + type->sizeMax = 1.0; + type->scaleX = 1.0; + type->scaleY = 1.0; + type->lifeMin = 100; + type->lifeMax = 100; + type->alphaStart = 1.0; + type->alphaMiddle = 1.0; + type->alphaEnd = 1.0; + type->colourStart = 0xFFFFFFu; + type->colourMiddle = 0xFFFFFFu; + type->colourEnd = 0xFFFFFFu; + type->deathType = -1; + type->stepType = -1; +} + +// ===[ Spawning ]=== + +// Returns false once the system is full, so the callers' loops can stop instead of spinning through a +// spawn count that came straight from GML and may be enormous. +static bool particleSpawnAt(Runner* runner, ParticleSystem* system, int32_t typeId, GMLReal x, GMLReal y, uint32_t colour, bool fixedColour) { + ParticleType* type = particleTypeGet(runner, typeId); + if (type == nullptr) return false; + + if ((int32_t) arrlen(system->particles) >= PARTICLE_SYSTEM_MAX_PARTICLES) { + if (!system->warnedFull) { + system->warnedFull = true; + logWarn("Particles: system hit the %d particle cap, further spawns are dropped\n", PARTICLE_SYSTEM_MAX_PARTICLES); + } + return false; + } + + Particle particle; + ZERO_STRUCT(particle); + particle.typeId = typeId; + particle.x = x; + particle.y = y; + particle.speed = particleRandomRange(type->speedMin, type->speedMax); + particle.direction = particleRandomRange(type->dirMin, type->dirMax); + particle.size = particleRandomRange(type->sizeMin, type->sizeMax); + particle.angle = particleRandomRange(type->angMin, type->angMax); + particle.colour = colour; + particle.colourFixed = fixedColour; + particle.lifeTotal = particleRandomIntRange(type->lifeMin, type->lifeMax); + if (1 > particle.lifeTotal) particle.lifeTotal = 1; + particle.life = particle.lifeTotal; + particle.seed = (int32_t) (particleRandomBits() % 100000u); + + if (type->spriteRandom && type->sprite >= 0 && runner->dataWin != nullptr && (uint32_t) type->sprite < runner->dataWin->sprt.count) { + uint32_t frames = runner->dataWin->sprt.sprites[type->sprite].textureCount; + if (frames > 0) particle.subimgBase = (int32_t) (particleRandomBits() % frames); + } + + arrput(system->particles, particle); + type->refCount++; + return true; +} + +// Picks a point inside the emitter's region. Only the linear distribution is modelled; the gaussian +// ones fall back to it (no game we test against uses them, and guessing at the curve would be worse +// than an honest uniform spread). +static void particleEmitterPoint(ParticleEmitter* emitter, GMLReal* outX, GMLReal* outY) { + GMLReal centerX = (emitter->xmin + emitter->xmax) * 0.5; + GMLReal centerY = (emitter->ymin + emitter->ymax) * 0.5; + GMLReal halfW = (emitter->xmax - emitter->xmin) * 0.5; + GMLReal halfH = (emitter->ymax - emitter->ymin) * 0.5; + + // Every shape is sampled directly rather than by rejection. Beyond being cheaper and branch-free, + // it cannot emit outside the region: a bounded rejection loop has to accept whatever it holds when + // the attempts run out, which for a diamond (half the bounding box) leaks a particle into a corner + // often enough to see. + if (emitter->shape == PS_SHAPE_ELLIPSE) { + // sqrt on the radius keeps the spread uniform by area instead of clustering at the centre. + GMLReal radius = GMLReal_sqrt(particleRandom01()); + GMLReal angle = particleRandom01() * 2.0 * M_PI; + *outX = centerX + halfW * radius * GMLReal_cos(angle); + *outY = centerY + halfH * radius * GMLReal_sin(angle); + return; + } + + if (emitter->shape == PS_SHAPE_DIAMOND) { + // Fold the unit square onto the triangle u + v <= 1, then mirror into a random quadrant. + GMLReal u = particleRandom01(); + GMLReal v = particleRandom01(); + if (u + v > 1.0) { u = 1.0 - u; v = 1.0 - v; } + uint32_t quadrant = particleRandomBits(); + if (quadrant & 1u) u = -u; + if (quadrant & 2u) v = -v; + *outX = centerX + halfW * u; + *outY = centerY + halfH * v; + return; + } + + if (emitter->shape == PS_SHAPE_LINE) { + // A line from (xmin, ymin) to (xmax, ymax), not the rectangle they bound. + GMLReal t = particleRandom01(); + *outX = emitter->xmin + (emitter->xmax - emitter->xmin) * t; + *outY = emitter->ymin + (emitter->ymax - emitter->ymin) * t; + return; + } + + *outX = particleRandomRange(emitter->xmin, emitter->xmax); + *outY = particleRandomRange(emitter->ymin, emitter->ymax); +} + +static void particleEmitterSpawn(Runner* runner, ParticleSystem* system, ParticleEmitter* emitter, int32_t typeId, int32_t count) { + repeat(count, i) { + GMLReal x, y; + particleEmitterPoint(emitter, &x, &y); + if (!particleSpawnAt(runner, system, typeId, x, y, 0xFFFFFFu, false)) return; + } +} + +// ===[ Update ]=== + +// Steps one system: particles age and run their death spawn, then everything alive moves, and only +// then do the emitters stream. That order is GameMaker's, and it is why a streamed particle stands +// still on the step it is born. +static void particleUpdateSystem(Runner* runner, int32_t systemId) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr) return; + + // GameMaker ages a system, then moves it, and only then lets the emitters spawn into it. The + // order is worth preserving: it is what makes a streamed particle sit still for the step it is + // born on, while a death particle -- which joins between the two passes -- travels immediately. + + // Collected rather than spawned in place, because arrput() can move the array out from under the + // loop walking it. Only allocated when a type actually asks for step or death particles. + typedef struct { int32_t typeId; GMLReal x, y; int32_t count; } PendingSpawn; + PendingSpawn* pending = nullptr; + + // Pass 1: age everything, note the spawns each particle's type asks for, bury what ran out. + int32_t index = 0; + while (index < (int32_t) arrlen(system->particles)) { + Particle* particle = &system->particles[index]; + ParticleType* type = particleTypeGetLive(runner, particle->typeId); + + if (type == nullptr) { + // Only reachable if the particle carries an id that was never a type at all; a type + // destroyed under a live particle still answers here. Drop it rather than step a + // particle with nothing to step it by. + particleTypeRelease(runner, particle->typeId); + system->particles[index] = arrlast(system->particles); + arrpop(system->particles); + continue; + } + + particle->life--; + bool died = (0 >= particle->life); + + // A particle spawns its type's step particles every step it survives, and its death particles + // instead of them on the step it does not. The two never fire on the same step. + int32_t spawnType = died ? type->deathType : type->stepType; + int32_t spawnNumber = died ? type->deathNumber : type->stepNumber; + if (spawnType >= 0 && spawnNumber != 0) { + int32_t count = particleResolveCount((GMLReal) spawnNumber); + if (count > 0) { + PendingSpawn spawn; + spawn.typeId = spawnType; + spawn.x = particle->x; + spawn.y = particle->y; + spawn.count = count; + arrput(pending, spawn); + } + } + + if (!died) { + index++; + continue; + } + + // Swap-remove: order within a system does not affect the drawn result, every particle of a + // system is drawn in the same pass at the same depth. + particleTypeRelease(runner, particle->typeId); + system->particles[index] = arrlast(system->particles); + arrpop(system->particles); + } + + // The collected spawns land before the movement pass, so they move on the step they are born. + repeat((int32_t) arrlen(pending), i) { + repeat(pending[i].count, n) { + if (!particleSpawnAt(runner, system, pending[i].typeId, pending[i].x, pending[i].y, 0xFFFFFFu, false)) break; + } + } + arrfree(pending); + + // Pass 2: increments, then gravity, then the move. The increment lands before the particle + // travels, so its very first step already runs at (speed + speed increment). + int32_t particleCount = (int32_t) arrlen(system->particles); + repeat(particleCount, i) { + Particle* particle = &system->particles[i]; + ParticleType* type = particleTypeGetLive(runner, particle->typeId); + if (type == nullptr) continue; + + particle->speed += type->speedIncr; + if (0.0 > particle->speed) particle->speed = 0.0; // GameMaker never lets a particle reverse + particle->direction += type->dirIncr; + particle->angle += type->angIncr; + + if (type->gravityAmount != 0.0) { + // Gravity folds into the velocity vector permanently, so later speed/direction increments + // apply on top of it. Matches GameMaker, where gravity bends a particle's course for good. + GMLReal baseRadians = particle->direction * PARTICLE_DEG2RAD; + GMLReal gravityRadians = type->gravityDirection * PARTICLE_DEG2RAD; + GMLReal hspeed = particle->speed * GMLReal_cos(baseRadians) + type->gravityAmount * GMLReal_cos(gravityRadians); + GMLReal vspeed = -particle->speed * GMLReal_sin(baseRadians) - type->gravityAmount * GMLReal_sin(gravityRadians); + particle->speed = GMLReal_sqrt(hspeed * hspeed + vspeed * vspeed); + if (hspeed != 0.0 || vspeed != 0.0) + particle->direction = GMLReal_atan2(-vspeed, hspeed) / PARTICLE_DEG2RAD; + } + + int32_t age = particle->lifeTotal - particle->life; + GMLReal effectiveSpeed = particle->speed + type->speedWiggle * particleWiggleSpeed(age, particle->seed); + GMLReal effectiveDirection = particle->direction + type->dirWiggle * particleWiggleDirection(age, particle->seed); + + GMLReal radians = effectiveDirection * PARTICLE_DEG2RAD; + particle->x += effectiveSpeed * GMLReal_cos(radians); + particle->y -= effectiveSpeed * GMLReal_sin(radians); // GML's y axis grows downward + + // GameMaker's third pass, less the colour and alpha curves: those are functions of the + // particle's age alone, so they are evaluated at draw time rather than stored per particle. + particle->size += type->sizeIncr; + if (0.0 > particle->size) particle->size = 0.0; + } + + // Emitters stream last, into a system where everything already alive has finished moving. + int32_t emitterCount = (int32_t) arrlen(system->emitters); + repeat(emitterCount, i) { + ParticleEmitter* emitter = &system->emitters[i]; + if (!emitter->used || 0 > emitter->streamType || emitter->streamNumber == 0.0) continue; + particleEmitterSpawn(runner, system, emitter, emitter->streamType, particleResolveCount(emitter->streamNumber)); + } +} + +// Not static: called once at the end of Runner_step. +void Particles_updateAutomatic(Runner* runner) { + int32_t count = (int32_t) arrlen(runner->particleSystemPool); + repeat(count, i) { + ParticleSystem* system = &runner->particleSystemPool[i]; + if (!system->used || !system->automaticUpdate) continue; + particleUpdateSystem(runner, (int32_t) i); + } +} + +// ===[ Draw ]=== + +// Alpha follows the three stop points across the particle's life: start -> middle at the halfway +// mark -> end. part_type_alpha1/alpha2 are expressed by collapsing the stops onto each other. +static GMLReal particleAlphaAt(const ParticleType* type, GMLReal ageFraction) { + if (0.5 > ageFraction) { + GMLReal t = ageFraction * 2.0; + return type->alphaStart + (type->alphaMiddle - type->alphaStart) * t; + } + GMLReal t = (ageFraction - 0.5) * 2.0; + return type->alphaMiddle + (type->alphaEnd - type->alphaMiddle) * t; +} + +// Same three stops as the alpha curve. Interpolated per byte, which is correct whatever order the +// channels sit in: GML colours are passed straight through to the renderer without repacking. +static uint32_t particleColourLerp(uint32_t from, uint32_t to, GMLReal t) { + uint32_t out = 0; + repeat(3, shift) { + int32_t bits = (int32_t) shift * 8; + GMLReal a = (GMLReal) ((from >> bits) & 0xFFu); + GMLReal b = (GMLReal) ((to >> bits) & 0xFFu); + int32_t v = (int32_t) (a + (b - a) * t + 0.5); + if (0 > v) v = 0; + if (v > 255) v = 255; + out |= ((uint32_t) v) << bits; + } + return out; +} + +static uint32_t particleColourAt(const ParticleType* type, GMLReal ageFraction) { + if (0.5 > ageFraction) + return particleColourLerp(type->colourStart, type->colourMiddle, ageFraction * 2.0); + return particleColourLerp(type->colourMiddle, type->colourEnd, (ageFraction - 0.5) * 2.0); +} + +// Not static: backs both part_system_drawit and the system's entry in the depth-sorted draw list. +void Particles_drawSystem(Runner* runner, int32_t systemId) { + ParticleSystem* system = Particles_systemGet(runner, systemId); + if (system == nullptr || runner->renderer == nullptr) return; + + int32_t count = (int32_t) arrlen(system->particles); + if (count == 0) return; + + Renderer* renderer = runner->renderer; + bool additiveActive = false; + // Blend state is global and sticky in GML, so an additive type has to hand back exactly what the + // caller had rather than assuming bm_normal: a game that darkens the scene with + // gpu_set_blendmode_ext around its draw would otherwise lose the effect from the particle system + // onwards. Captured lazily, so a system of ordinary particles never touches blending at all. + bool blendTouched = false; + bool blendSaved = false; + int32_t savedBlendMode = bm_normal; + BlendFactors savedBlendFactors; + ZERO_STRUCT(savedBlendFactors); + + repeat(count, i) { + Particle* particle = &system->particles[i]; + ParticleType* type = particleTypeGetLive(runner, particle->typeId); + if (type == nullptr || 0 > type->sprite) continue; + + int32_t age = particle->lifeTotal - particle->life; + GMLReal ageFraction = (GMLReal) age / (GMLReal) particle->lifeTotal; + GMLReal alpha = particleAlphaAt(type, ageFraction); + if (0.0 >= alpha) continue; + if (alpha > 1.0) alpha = 1.0; + + GMLReal size = particle->size + type->sizeWiggle * particleWiggleSize(age, particle->seed); + if (0.0 >= size) continue; + + int32_t subimg = particle->subimgBase; + if (type->spriteAnimate) { + if (type->spriteStretch) { + // One full animation cycle stretched over the particle's whole life. + uint32_t frames = ((uint32_t) type->sprite < runner->dataWin->sprt.count) + ? runner->dataWin->sprt.sprites[type->sprite].textureCount : 0; + if (frames > 0) subimg += (int32_t) (ageFraction * (GMLReal) frames); + } else { + subimg += age; + } + } + + if (type->additive != additiveActive) { + if (!blendTouched) { + // The getters are optional in the vtable; without them the best we can do is put + // blending back to bm_normal at the end. + if (renderer->vtable->gpuGetBlendMode != nullptr) { + savedBlendMode = renderer->vtable->gpuGetBlendMode(renderer); + if (renderer->vtable->gpuGetBlendFactors != nullptr) + savedBlendFactors = renderer->vtable->gpuGetBlendFactors(renderer); + blendSaved = true; + } + blendTouched = true; + } + renderer->vtable->gpuSetBlendMode(renderer, type->additive ? bm_add : bm_normal); + additiveActive = type->additive; + } + + uint32_t colour = particle->colourFixed ? particle->colour : particleColourAt(type, ageFraction); + + // A relative orientation is measured from the direction the particle is travelling, so a + // sprite drawn nose-first keeps pointing along its arc as gravity bends it. + GMLReal angle = particle->angle + type->angWiggle * particleWiggleAngle(age, particle->seed); + if (type->angRelative) angle += particle->direction; + + Renderer_drawSpriteExt(renderer, type->sprite, subimg, + (float) (system->originX + particle->x), (float) (system->originY + particle->y), + (float) (type->scaleX * size), (float) (type->scaleY * size), + (float) angle, colour, (float) alpha); + } + + if (!blendTouched) return; + + if (!blendSaved) { + renderer->vtable->gpuSetBlendMode(renderer, bm_normal); + } else if (savedBlendMode == bm_complex && renderer->vtable->gpuSetBlendModeExt != nullptr) { + // gpu_set_blendmode_ext leaves the mode reading back as bm_complex, so the individual + // factors are the only faithful way to put that state back. + renderer->vtable->gpuSetBlendModeExt(renderer, savedBlendFactors.src, savedBlendFactors.dst, + savedBlendFactors.srcAlpha, savedBlendFactors.dstAlpha); + } else { + renderer->vtable->gpuSetBlendMode(renderer, savedBlendMode); + } +} + +// ===[ Teardown ]=== + +// Not static: frees both pools from the Runner's cleanup path. +void Particles_freeAll(Runner* runner) { + int32_t count = (int32_t) arrlen(runner->particleSystemPool); + repeat(count, i) { + arrfree(runner->particleSystemPool[i].particles); + arrfree(runner->particleSystemPool[i].emitters); + } + arrfree(runner->particleSystemPool); + runner->particleSystemPool = nullptr; + arrfree(runner->particleTypePool); + runner->particleTypePool = nullptr; + // Reached on game_restart as well as shutdown, so put the stream back to where it started: + // a restarted game should produce the same particles as the first run. + g_particleRngState = PARTICLE_RNG_SEED; +} + +// ===[ Systems ]=== static RValue builtin_part_system_create(VMContext* ctx, MAYBE_UNUSED RValue* args, MAYBE_UNUSED int32_t argCount) { - return RValue_makeReal((GMLReal) Particles_systemCreate(ctx->runner)); + Runner* runner = ctx->runner; + int32_t poolSize = (int32_t) arrlen(runner->particleSystemPool); + int32_t id = poolSize; + repeat(poolSize, i) { + if (!runner->particleSystemPool[i].used) { id = (int32_t) i; break; } + } + + ParticleSystem system; + ZERO_STRUCT(system); + system.used = true; + system.automaticUpdate = true; + system.automaticDraw = true; + system.depth = 0; + + if (id == poolSize) { + arrput(runner->particleSystemPool, system); + } else { + runner->particleSystemPool[id] = system; + } + + // The system joins the depth-sorted draw list while automaticDraw is set. + runner->drawableListStructureDirty = true; + return RValue_makeReal((GMLReal) id); } static RValue builtin_part_system_destroy(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_systemDestroy(ctx->runner, RValue_toInt32(args[0])); + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + if (system == nullptr) return RValue_makeUndefined(); + + particleClearParticles(ctx->runner, system); + arrfree(system->particles); + arrfree(system->emitters); + ZERO_STRUCT(*system); + ctx->runner->drawableListStructureDirty = true; return RValue_makeUndefined(); } static RValue builtin_part_system_depth(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_systemSetDepth(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])); + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + if (system == nullptr) return RValue_makeUndefined(); + + int32_t depth = RValue_toInt32(args[1]); + if (system->depth == depth) return RValue_makeUndefined(); + system->depth = depth; + ctx->runner->drawableListSortDirty = true; return RValue_makeUndefined(); } static RValue builtin_part_system_automatic_draw(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_systemSetAutomaticDraw(ctx->runner, RValue_toInt32(args[0]), RValue_toBool(args[1])); + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + if (system == nullptr) return RValue_makeUndefined(); + + bool automatic = RValue_toBool(args[1]); + if (system->automaticDraw == automatic) return RValue_makeUndefined(); + system->automaticDraw = automatic; + // Only switching drawing ON has to rebuild, since that is what adds an entry the cache does not + // hold. Switching it off is filtered at draw time, like instance visibility, so the common + // "automatic_draw(false) then drawit()" idiom re-issued every frame does not drag a full rebuild + // and re-sort of every drawable in the room behind it. + if (automatic) ctx->runner->drawableListStructureDirty = true; return RValue_makeUndefined(); } -static RValue builtin_part_system_update(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_updateSystem(ctx->runner, RValue_toInt32(args[0])); +static RValue builtin_part_system_automatic_update(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + if (system == nullptr) return RValue_makeUndefined(); + system->automaticUpdate = RValue_toBool(args[1]); return RValue_makeUndefined(); } -static RValue builtin_part_system_drawit(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_drawSystem(ctx->runner, RValue_toInt32(args[0])); +static RValue builtin_part_system_update(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + particleUpdateSystem(ctx->runner, RValue_toInt32(args[0])); return RValue_makeUndefined(); } -static RValue builtin_part_system_automatic_update(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); - if (system == nullptr) return RValue_makeUndefined(); - system->automaticUpdate = RValue_toBool(args[1]); +static RValue builtin_part_system_drawit(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + Particles_drawSystem(ctx->runner, RValue_toInt32(args[0])); return RValue_makeUndefined(); } @@ -16547,8 +17112,22 @@ static RValue builtin_part_system_position(VMContext* ctx, RValue* args, MAYBE_U return RValue_makeUndefined(); } +// Resets the system to how part_system_create left it: no particles, no emitters, depth 0, both +// automatic flags back on. static RValue builtin_part_system_clear(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_systemClear(ctx->runner, RValue_toInt32(args[0])); + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + if (system == nullptr) return RValue_makeUndefined(); + + particleClearParticles(ctx->runner, system); + arrsetlen(system->emitters, 0); + system->automaticUpdate = true; + system->automaticDraw = true; + system->depth = 0; + system->originX = 0.0; + system->originY = 0.0; + system->warnedFull = false; + // Depth and automatic drawing both just moved, so the cached list has to be rebuilt either way. + ctx->runner->drawableListStructureDirty = true; return RValue_makeUndefined(); } @@ -16556,47 +17135,101 @@ static RValue builtin_part_system_exists(VMContext* ctx, RValue* args, MAYBE_UNU return RValue_makeReal(Particles_systemGet(ctx->runner, RValue_toInt32(args[0])) != nullptr ? 1.0 : 0.0); } +// Spawns particles directly, bypassing emitters. static RValue builtin_part_particles_create(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_particlesCreate(ctx->runner, RValue_toInt32(args[0]), RValue_toReal(args[1]), RValue_toReal(args[2]), - RValue_toInt32(args[3]), RValue_toInt32(args[4]), 0xFFFFFFu, false); + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + if (system == nullptr) return RValue_makeUndefined(); + + GMLReal x = RValue_toReal(args[1]); + GMLReal y = RValue_toReal(args[2]); + int32_t typeId = RValue_toInt32(args[3]); + repeat(RValue_toInt32(args[4]), i) { + if (!particleSpawnAt(ctx->runner, system, typeId, x, y, 0xFFFFFFu, false)) break; + } return RValue_makeUndefined(); } +// Same, with a colour that overrides the type's colour curve for these particles only. Note the +// argument order: the colour sits before the count. static RValue builtin_part_particles_create_colour(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_particlesCreate(ctx->runner, RValue_toInt32(args[0]), RValue_toReal(args[1]), RValue_toReal(args[2]), - RValue_toInt32(args[3]), RValue_toInt32(args[5]), (uint32_t) RValue_toInt32(args[4]), true); + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + if (system == nullptr) return RValue_makeUndefined(); + + GMLReal x = RValue_toReal(args[1]); + GMLReal y = RValue_toReal(args[2]); + int32_t typeId = RValue_toInt32(args[3]); + uint32_t colour = (uint32_t) RValue_toInt32(args[4]); + repeat(RValue_toInt32(args[5]), i) { + if (!particleSpawnAt(ctx->runner, system, typeId, x, y, colour, true)) break; + } return RValue_makeUndefined(); } static RValue builtin_part_particles_count(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - return RValue_makeReal((GMLReal) Particles_systemParticleCount(ctx->runner, RValue_toInt32(args[0]))); + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + return RValue_makeReal((system == nullptr) ? 0.0 : (GMLReal) arrlen(system->particles)); } +// Removes every live particle but leaves the emitters and settings in place. static RValue builtin_part_particles_clear(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_systemClearParticles(ctx->runner, RValue_toInt32(args[0])); + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + if (system != nullptr) particleClearParticles(ctx->runner, system); return RValue_makeUndefined(); } +// ===[ Types ]=== + static RValue builtin_part_type_create(VMContext* ctx, MAYBE_UNUSED RValue* args, MAYBE_UNUSED int32_t argCount) { - return RValue_makeReal((GMLReal) Particles_typeCreate(ctx->runner)); + Runner* runner = ctx->runner; + int32_t poolSize = (int32_t) arrlen(runner->particleTypePool); + int32_t id = poolSize; + repeat(poolSize, i) { + // A destroyed type whose particles are still alive keeps its slot: handing the id out again + // would silently re-point those particles at whatever the new type looks like. + if (!runner->particleTypePool[i].used && 0 >= runner->particleTypePool[i].refCount) { id = (int32_t) i; break; } + } + + ParticleType type; + particleTypeSetDefaults(&type); + + if (id == poolSize) { + arrput(runner->particleTypePool, type); + } else { + runner->particleTypePool[id] = type; + } + return RValue_makeReal((GMLReal) id); } +// Puts a live type back to the defaults a freshly created one has. static RValue builtin_part_type_clear(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_typeClear(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + // Particles already born from this type keep pointing at the slot, so their claims survive the + // reset -- otherwise the count would drop to zero and the slot could be handed out from under them. + int32_t claims = type->refCount; + particleTypeSetDefaults(type); + type->refCount = claims; return RValue_makeUndefined(); } static RValue builtin_part_type_exists(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - return RValue_makeReal(Particles_typeGet(ctx->runner, RValue_toInt32(args[0])) != nullptr ? 1.0 : 0.0); + return RValue_makeReal(particleTypeGet(ctx->runner, RValue_toInt32(args[0])) != nullptr ? 1.0 : 0.0); } static RValue builtin_part_type_destroy(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_typeDestroy(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); + if (type == nullptr) return RValue_makeUndefined(); + // part_type_exists answers false from here on and nothing new can be spawned from it, but the + // particles already alive go on looking and moving as before and simply age out -- that is what + // GameMaker does, and the settings they need are still in this slot. It is freed for reuse once + // the last of them dies. + type->used = false; + if (0 >= type->refCount) ZERO_STRUCT(*type); return RValue_makeUndefined(); } static RValue builtin_part_type_sprite(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->sprite = RValue_toInt32(args[1]); type->spriteAnimate = RValue_toBool(args[2]); @@ -16606,7 +17239,7 @@ static RValue builtin_part_type_sprite(VMContext* ctx, RValue* args, MAYBE_UNUSE } static RValue builtin_part_type_size(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->sizeMin = RValue_toReal(args[1]); type->sizeMax = RValue_toReal(args[2]); @@ -16616,7 +17249,7 @@ static RValue builtin_part_type_size(VMContext* ctx, RValue* args, MAYBE_UNUSED } static RValue builtin_part_type_scale(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->scaleX = RValue_toReal(args[1]); type->scaleY = RValue_toReal(args[2]); @@ -16624,7 +17257,7 @@ static RValue builtin_part_type_scale(VMContext* ctx, RValue* args, MAYBE_UNUSED } static RValue builtin_part_type_speed(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->speedMin = RValue_toReal(args[1]); type->speedMax = RValue_toReal(args[2]); @@ -16634,7 +17267,7 @@ static RValue builtin_part_type_speed(VMContext* ctx, RValue* args, MAYBE_UNUSED } static RValue builtin_part_type_direction(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); // Stored unnormalised on purpose: games pass reversed ranges (DELTARUNE Chapter 4 uses -45 to -90) // and expect GameMaker's "min + random * (max - min)", which sweeps downward. @@ -16646,7 +17279,7 @@ static RValue builtin_part_type_direction(VMContext* ctx, RValue* args, MAYBE_UN } static RValue builtin_part_type_gravity(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->gravityAmount = RValue_toReal(args[1]); type->gravityDirection = RValue_toReal(args[2]); @@ -16654,7 +17287,7 @@ static RValue builtin_part_type_gravity(VMContext* ctx, RValue* args, MAYBE_UNUS } static RValue builtin_part_type_life(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->lifeMin = RValue_toInt32(args[1]); type->lifeMax = RValue_toInt32(args[2]); @@ -16662,7 +17295,7 @@ static RValue builtin_part_type_life(VMContext* ctx, RValue* args, MAYBE_UNUSED } static RValue builtin_part_type_orientation(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->angMin = RValue_toReal(args[1]); type->angMax = RValue_toReal(args[2]); @@ -16674,14 +17307,14 @@ static RValue builtin_part_type_orientation(VMContext* ctx, RValue* args, MAYBE_ // alpha1 and alpha2 are the same curve as alpha3 with the stops collapsed. static RValue builtin_part_type_alpha1(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->alphaStart = type->alphaMiddle = type->alphaEnd = RValue_toReal(args[1]); return RValue_makeUndefined(); } static RValue builtin_part_type_alpha2(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); GMLReal start = RValue_toReal(args[1]); GMLReal end = RValue_toReal(args[2]); @@ -16692,7 +17325,7 @@ static RValue builtin_part_type_alpha2(VMContext* ctx, RValue* args, MAYBE_UNUSE } static RValue builtin_part_type_alpha3(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->alphaStart = RValue_toReal(args[1]); type->alphaMiddle = RValue_toReal(args[2]); @@ -16702,26 +17335,26 @@ static RValue builtin_part_type_alpha3(VMContext* ctx, RValue* args, MAYBE_UNUSE // Ditto for the colour curve. static RValue builtin_part_type_colour1(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->colourStart = type->colourMiddle = type->colourEnd = (uint32_t) RValue_toInt32(args[1]); return RValue_makeUndefined(); } static RValue builtin_part_type_colour2(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); uint32_t start = (uint32_t) RValue_toInt32(args[1]); uint32_t end = (uint32_t) RValue_toInt32(args[2]); type->colourStart = start; type->colourEnd = end; // Halfway stop sits on the straight line between the two, so a two-stop curve stays linear. - type->colourMiddle = Particles_colourMidpoint(start, end); + type->colourMiddle = particleColourLerp(start, end, 0.5); return RValue_makeUndefined(); } static RValue builtin_part_type_colour3(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->colourStart = (uint32_t) RValue_toInt32(args[1]); type->colourMiddle = (uint32_t) RValue_toInt32(args[2]); @@ -16730,7 +17363,7 @@ static RValue builtin_part_type_colour3(VMContext* ctx, RValue* args, MAYBE_UNUS } static RValue builtin_part_type_step(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->stepNumber = RValue_toInt32(args[1]); type->stepType = RValue_toInt32(args[2]); @@ -16738,40 +17371,65 @@ static RValue builtin_part_type_step(VMContext* ctx, RValue* args, MAYBE_UNUSED } static RValue builtin_part_type_blend(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->additive = RValue_toBool(args[1]); return RValue_makeUndefined(); } static RValue builtin_part_type_death(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleType* type = Particles_typeGet(ctx->runner, RValue_toInt32(args[0])); + ParticleType* type = particleTypeGet(ctx->runner, RValue_toInt32(args[0])); if (type == nullptr) return RValue_makeUndefined(); type->deathNumber = RValue_toInt32(args[1]); type->deathType = RValue_toInt32(args[2]); return RValue_makeUndefined(); } +// ===[ Emitters ]=== + static RValue builtin_part_emitter_create(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - return RValue_makeReal((GMLReal) Particles_emitterCreate(ctx->runner, RValue_toInt32(args[0]))); + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + if (system == nullptr) return RValue_makeReal(-1.0); + + int32_t count = (int32_t) arrlen(system->emitters); + int32_t id = count; + repeat(count, i) { + if (!system->emitters[i].used) { id = (int32_t) i; break; } + } + + ParticleEmitter emitter; + ZERO_STRUCT(emitter); + emitter.used = true; + emitter.shape = PS_SHAPE_RECTANGLE; + emitter.distribution = PS_DISTR_LINEAR; + emitter.streamType = -1; + + if (id == count) { + arrput(system->emitters, emitter); + } else { + system->emitters[id] = emitter; + } + return RValue_makeReal((GMLReal) id); } static RValue builtin_part_emitter_destroy(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_emitterDestroy(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])); + ParticleEmitter* emitter = particleEmitterGet(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])); + if (emitter != nullptr) ZERO_STRUCT(*emitter); return RValue_makeUndefined(); } -static RValue builtin_part_emitter_exists(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - return RValue_makeReal(Particles_emitterGet(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])) != nullptr ? 1.0 : 0.0); -} - static RValue builtin_part_emitter_destroy_all(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_emitterDestroyAll(ctx->runner, RValue_toInt32(args[0])); + ParticleSystem* system = Particles_systemGet(ctx->runner, RValue_toInt32(args[0])); + if (system != nullptr) arrsetlen(system->emitters, 0); return RValue_makeUndefined(); } +static RValue builtin_part_emitter_exists(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { + return RValue_makeReal(particleEmitterGet(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])) != nullptr ? 1.0 : 0.0); +} + static RValue builtin_part_emitter_region(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleEmitter* emitter = Particles_emitterGet(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])); + ParticleEmitter* emitter = particleEmitterGet(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])); if (emitter == nullptr) return RValue_makeUndefined(); emitter->xmin = RValue_toReal(args[2]); emitter->xmax = RValue_toReal(args[3]); @@ -16783,7 +17441,7 @@ static RValue builtin_part_emitter_region(VMContext* ctx, RValue* args, MAYBE_UN } static RValue builtin_part_emitter_stream(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - ParticleEmitter* emitter = Particles_emitterGet(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])); + ParticleEmitter* emitter = particleEmitterGet(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1])); if (emitter == nullptr) return RValue_makeUndefined(); emitter->streamType = RValue_toInt32(args[2]); // Kept as a real: GameMaker spends the fractional part as a chance of one extra particle, which @@ -16793,7 +17451,13 @@ static RValue builtin_part_emitter_stream(VMContext* ctx, RValue* args, MAYBE_UN } static RValue builtin_part_emitter_burst(VMContext* ctx, RValue* args, MAYBE_UNUSED int32_t argCount) { - Particles_emitterBurst(ctx->runner, RValue_toInt32(args[0]), RValue_toInt32(args[1]), RValue_toInt32(args[2]), RValue_toReal(args[3])); + Runner* runner = ctx->runner; + int32_t systemId = RValue_toInt32(args[0]); + ParticleSystem* system = Particles_systemGet(runner, systemId); + ParticleEmitter* emitter = particleEmitterGet(runner, systemId, RValue_toInt32(args[1])); + if (system == nullptr || emitter == nullptr) return RValue_makeUndefined(); + + particleEmitterSpawn(runner, system, emitter, RValue_toInt32(args[2]), particleResolveCount(RValue_toReal(args[3]))); return RValue_makeUndefined(); }