-- ============================================================================ -- ParticleEffects: pre-configured named effect definitions -- ============================================================================ ParticleEffects = {} -- Motion type functions: update particle position based on motion style local MotionTypes = {} -- Particles are attracted toward the effect's center function MotionTypes.pull(p, dt, tx, ty) local dx = tx - p.x local dy = ty - p.y local dist = math.sqrt(dx * dx + dy * dy) if dist > 1 then local pull = (p.pullStrength or 80) * dt p.vx = p.vx + (dx / dist) * pull p.vy = p.vy + (dy / dist) * pull end p.vx = p.vx * 0.95 p.vy = p.vy * 0.95 end -- Particles fly outward from origin and fade function MotionTypes.explode(p, dt) p.vx = p.vx * 0.96 p.vy = p.vy * 0.96 end -- Particles drift upward with slight wobble function MotionTypes.float(p, dt) p.vy = p.vy - (p.floatSpeed or 40) * dt p.vx = math.sin(p.time or 0) * (p.wobble or 10) * dt p.time = (p.time or 0) + dt end -- Particles drift upward with slight wobble function MotionTypes.glide(p, dt) p.vx = p.vx + (p.floatSpeed or 40) * dt p.vy = math.sin(p.time or 0) * (p.wobble or 10) * dt p.time = (p.time or 0) + dt end -- Particles orbit around the center point function MotionTypes.orbit(p, dt, tx, ty) local dx = p.x - tx local dy = p.y - ty local dist = math.sqrt(dx * dx + dy * dy) if dist > 1 then local speed = p.orbitSpeed or 2 local nx = -dy / dist local ny = dx / dist p.vx = p.vx + nx * speed * dt * 60 p.vy = p.vy + ny * speed * dt * 60 end p.vx = p.vx * 0.92 p.vy = p.vy * 0.92 end -- Particles shoot upward and fall with gravity function MotionTypes.fountain(p, dt) p.vy = p.vy + (p.gravity or 200) * dt p.vx = p.vx * 0.99 end -- Particles trail behind a horizontal dash with deceleration function MotionTypes.dash(p, dt) p.vx = p.vx * 0.92 p.vy = p.vy * 0.95 p.vy = p.vy + (p.gravity or 40) * dt end function ParticleEffects:define(name, config) self[name] = config end -- Built-in effect presets function ParticleEffects:initPresets() self:define("charge_up", { motion = "pull", spawnRate = 15, spawnRadius = 20, particleCount = 1, life = 1.5, size = 4, pullStrength = 600, color = { 0, 1, 0.1 }, shape = "circle", }) self:define("explosion", { motion = "explode", spawnRate = 1, burstCount = 20, spawnRadius = 4, life = 0.6, size = 4, initVel = function(p) local angle = math.random() * math.pi * 2 local speed = 60 + math.random() * 100 p.vx = math.cos(angle) * speed p.vy = math.sin(angle) * speed end, color = { 1, 0.5, 0.1 }, shape = "circle", }) self:define("magic_orbit", { motion = "orbit", spawnRate = 3, spawnRadius = 25, particleCount = 1, life = 1.5, size = 3, orbitSpeed = 3, color = { 0.5, 0.3, 1 }, shape = "circle", }) self:define("fountain", { motion = "fountain", spawnRate = 8, spawnRadius = 3, particleCount = 1, life = 1, size = 3, gravity = 200, initVel = function(p) p.vx = (math.random() - 0.5) * 30 p.vy = -(80 + math.random() * 60) end, color = { 0.3, 0.6, 1 }, shape = "circle", }) self:define("cast_spell", { motion = "pull", spawnRate = 100, spawnRadius = 4, particleCount = 8, life = 0.8, size = 2, floatSpeed = 80, wobble = 20, color = { 1, 1, 1 }, shape = "square", }) self:define("heal", { motion = "float", spawnRate = 100, spawnRadius = 8, particleCount = 100, life = 0.8, size = 2, floatSpeed = 30, wobble = 100, color = { 1, 1, 0.7 }, shape = "square", initVel = function(p) local angle = -(math.random() * math.pi) local speed = 40 p.vx = math.cos(angle) * speed p.vy = math.sin(angle) * speed end, }) self:define("dash", { motion = "dash", spawnRate = 100, burstCount = 14, spawnRadius = 2, life = 0.45, size = 3, gravity = 30, initVel = function(p) local dir = p.direction or 1 -- Shoot particles opposite to dash direction with a narrow vertical spread local spreadAngle = (math.random() - 0.5) * math.pi * 0.35 local speed = 160 + math.random() * 180 p.vx = -dir * math.cos(spreadAngle) * speed p.vy = math.sin(spreadAngle) * speed end, color = { 0.3, 0.7, 1 }, shape = "square", }) self:define("damage", { motion = "explode", spawnRate = 1, burstCount = 10, spawnRadius = 2, life = 0.4, size = 3, initVel = function(p) local angle = math.random() * math.pi * 2 local speed = 40 + math.random() * 80 p.vx = math.cos(angle) * speed p.vy = math.sin(angle) * speed end, color = { 1, 0.2, 0.2 }, shape = "square", }) self:define("arrow_wall", { motion = "explode", spawnRate = 1, burstCount = 5, spawnRadius = 1, life = 0.4, size = 2, initVel = function(p) local angle = math.random() * math.pi * 2 local speed = 40 + math.random() * 80 p.vx = math.cos(angle) * speed p.vy = math.sin(angle) * speed end, color = { 0.094, 0.23, 0.11 }, shape = "square", }) self:define("shield", { motion = "orbit", spawnRate = 5, spawnRadius = 22, particleCount = 1, life = 1, size = 2.5, orbitSpeed = 4, color = { 0.4, 0.7, 1 }, shape = "circle", }) self:define("poison", { motion = "float", spawnRate = 3, spawnRadius = 14, particleCount = 1, life = 1, size = 3, floatSpeed = 25, wobble = 20, color = { 0.4, 0.9, 0.2 }, shape = "circle", }) self:define("leaves", { motion = "glide", spawnRate = 4, spawnRadius = 60, particleCount = 5, life = 10, size = 4, floatSpeed = 15, wobble = 1025, color = { 0.192, 0.369, 0.282 }, shape = "square", initVel = function(p) p.vx = 20 + math.random() * 100 p.vy = -(10 + math.random() * 20) end, }) end -- ============================================================================ -- ParticlePlayer: global singleton that manages all active effect instances -- ============================================================================ ParticlePlayer = { activeEffects = {}, } -- Create and start playing an effect at (x, y) -- Returns the effect instance handle function ParticlePlayer:play(name, x, y, overrides) local def = ParticleEffects[name] if not def then return nil end local effect = { name = name, x = x, y = y, particles = {}, timer = 0, duration = overrides and overrides.duration or def.duration or 2, spawnAccumulator = 0, spawned = 0, active = true, motionType = MotionTypes[def.motion] or MotionTypes.pull, } -- Spawn initial burst if defined if def.burstCount then for i = 1, def.burstCount do local p = self:createParticle(def, x, y, overrides) table.insert(effect.particles, p) end effect.spawned = def.burstCount end table.insert(self.activeEffects, effect) return effect end -- Stop and remove a specific effect by handle function ParticlePlayer:stop(effect) for i = #self.activeEffects, 1, -1 do if self.activeEffects[i] == effect then table.remove(self.activeEffects, i) return true end end return false end -- Stop all effects (optional filter by name) function ParticlePlayer:stopAll(name) if name then for i = #self.activeEffects, 1, -1 do if self.activeEffects[i].name == name then table.remove(self.activeEffects, i) end end else self.activeEffects = {} end end function ParticlePlayer:createParticle(def, x, y, overrides) local angle = math.random() * math.pi * 2 local dist = (overrides and overrides.spawnRadius) or def.spawnRadius or 30 local color = (overrides and overrides.color) or def.color or { 0.6, 0.7, 1 } local p = { x = x + math.cos(angle) * dist, y = y + math.sin(angle) * dist, vx = 0, vy = 0, life = (overrides and overrides.life) or def.life or 1, maxLife = (overrides and overrides.life) or def.life or 1, size = ((overrides and overrides.size) or def.size or 3) * (0.5 + math.random() * 0.5), color = { color[1], color[2], color[3] }, shape = def.shape or "circle", pullStrength = def.pullStrength, orbitSpeed = def.orbitSpeed, floatSpeed = def.floatSpeed, wobble = def.wobble, gravity = def.gravity, direction = (overrides and overrides.direction) or def.direction or 1, time = 0, } if def.initVel then def.initVel(p) end return p end function ParticlePlayer:update(dt) for i = #self.activeEffects, 1, -1 do local effect = self.activeEffects[i] local def = ParticleEffects[effect.name] if not def then table.remove(self.activeEffects, i) else effect.timer = effect.timer + dt -- Spawn new particles based on spawnRate if not def.burstCount and effect.timer < effect.duration then effect.spawnAccumulator = effect.spawnAccumulator + dt local rate = def.spawnRate or 5 while effect.spawnAccumulator >= 1 / rate do effect.spawnAccumulator = effect.spawnAccumulator - 1 / rate local p = self:createParticle(def, effect.x, effect.y, nil) table.insert(effect.particles, p) effect.spawned = effect.spawned + 1 end end -- Update particles for j = #effect.particles, 1, -1 do local p = effect.particles[j] p.life = p.life - dt if p.life <= 0 then table.remove(effect.particles, j) else effect.motionType(p, dt, effect.x, effect.y) p.x = p.x + p.vx * dt p.y = p.y + p.vy * dt end end -- Remove effect when duration expired and all particles dead if effect.timer >= effect.duration and #effect.particles == 0 then effect.active = false table.remove(self.activeEffects, i) end end end end function ParticlePlayer:draw() for _, effect in ipairs(self.activeEffects) do for _, p in ipairs(effect.particles) do local alpha = p.life / p.maxLife love.graphics.setColor(p.color[1], p.color[2], p.color[3], alpha) if p.shape == "square" then love.graphics.rectangle("fill", p.x - p.size * alpha / 2, p.y - p.size * alpha / 2, p.size * alpha, p.size * alpha) else love.graphics.circle("fill", p.x, p.y, p.size * alpha) end end end love.graphics.setColor(1, 1, 1, 1) end