#include "effects.h" #include "game.h" #include constexpr int AFTERIMAGE_INTERVAL = 2; constexpr size_t EFFECTS_MAX = 1000; constexpr int NUMBER_LENGTH = 16; constexpr float NUMBER_MERGE_TIME = 0.25f; constexpr float AFTERIMAGE_ALPHA = 0.6f; constexpr float DPS_WINDOW = 3; constexpr float FLASH_CROSS = 0.5f; constexpr float FLASH_LENGTH = 16; constexpr float FLASH_LIFE = 0.05f; constexpr float FLASH_WIDTH = 6; constexpr float AFTERIMAGE_LIFE = 0.12f; constexpr float NUMBER_GAP = 8; constexpr float POPUP_FADE = 0.4f; constexpr float POPUP_JITTER_SCALE = 2; constexpr float POPUP_JITTER_START = 4; constexpr float POPUP_GRAVITY = 500; constexpr float POPUP_JUMP = 180; constexpr float POPUP_LIFE = 0.9f; constexpr float POPUP_SCALE = 2; constexpr float POPUP_SCALE_MAX = 5; constexpr float POPUP_SCALE_START = 2.5f; constexpr float POPUP_SCALE_STEP = 1.5f; constexpr float POPUP_SPREAD = 40; constexpr float PARTICLE_OUTLINE = 1; constexpr float PARTICLE_RANDOM_MIN = 0.4f; constexpr float SHAKE_DECAY = 2.5f; constexpr float SHAKE_LIMIT = 6; constexpr float SHAKE_MAX = 5; constexpr float SHAKE_TRAUMA_MAX = 1.5f; // Adds to one of the effect lists, dropping its oldest once it holds EFFECTS_MAX, so a busy fight can't flood the // screen (or the renderer) with leftovers. template static void effect_add(std::vector& list, const T& effect) { if (list.size() >= EFFECTS_MAX) list.erase(list.begin()); list.push_back(effect); } // Sprays particles at position, rotated by angle on top of the burst's own angle. void Effects::burst(Burst burst, Vec2 position, float angle, Color color) { for (int i = 0; i < burst.count; i++) { float direction = angle + burst.angle + random_get(-burst.spread, burst.spread); float speed = burst.speed * random_get(PARTICLE_RANDOM_MIN, 1); float life = burst.life * random_get(PARTICLE_RANDOM_MIN, 1); effect_add(particles, Particle{color, position, vec2_polar(direction, speed), burst.fill, burst.gravity, life, life, burst.size}); } } // Callers may ask every tick. Copies are only kept every few ticks so trails stay spaced out. void Effects::afterimage_spawn(Pose pose, int worn) { if (ticks % AFTERIMAGE_INTERVAL == 0) effect_add(afterimages, Afterimage{pose, AFTERIMAGE_LIFE, worn}); } // Drawn before the player and attacks so trails sit behind what made them. void Effects::afterimage_draw(Game& game) { for (Afterimage& afterimage : afterimages) { Pose pose = afterimage.pose; pose.placement.color = color_fade(pose.placement.color, AFTERIMAGE_ALPHA * afterimage.life / AFTERIMAGE_LIFE); pose.draw(game.renderer); game.sprites.fashion_draw(game.renderer, pose, afterimage.worn); } } // Damage numbers are popups whose whole-number scale and shake both grow with the logarithm of the damage. // Numbers take the attacker's color and start just above the hit (bigger ones higher), so they don't cover it. A hit // on a target whose last number from the same attacker appeared moments ago folds into it, respawning it with the // total. void Effects::number_spawn(Vec2 position, int value, int target, Color color) { for (int i = (int)popups.size() - 1; i >= 0; i--) { Popup& popup = popups[i]; bool isSameColor = popup.color.r == color.r && popup.color.g == color.g && popup.color.b == color.b; if (popup.value <= 0 || popup.target != target || !isSameColor || POPUP_LIFE - popup.life > NUMBER_MERGE_TIME) continue; value += popup.value; popups.erase(popups.begin() + i); break; } // Its bottom edge starts a small gap above the hit, however big the number is drawn. char text[NUMBER_LENGTH]; SDL_snprintf(text, sizeof(text), "%d", value); float magnitude = math_log2((float)std::max(1, value)); float scale = std::clamp(1 + std::floor((magnitude - POPUP_SCALE_START) / POPUP_SCALE_STEP), 1.0f, POPUP_SCALE_MAX); popup_spawn(position - Vec2{0, LINE_HEIGHT * scale / 2 + NUMBER_GAP}, text, color); popups.back().scale = scale; popups.back().jitter = std::max(0.0f, std::floor(magnitude - POPUP_JITTER_START)) * POPUP_JITTER_SCALE; popups.back().target = target; popups.back().value = value; } void Effects::popup_spawn(Vec2 position, const char* text, Color color) { Popup popup = { .color = color, .position = position, .velocity = {random_get(-POPUP_SPREAD, POPUP_SPREAD), -POPUP_JUMP}, .jitter = 0, .life = POPUP_LIFE, .scale = POPUP_SCALE, }; SDL_strlcpy(popup.text, text, sizeof(popup.text)); effect_add(popups, popup); } void Effects::flash_spawn(Flash flash) { flash.life = FLASH_LIFE; effect_add(flashes, flash); } // A particle at position, shrinking and fading as its life runs out: an outline in its color around a core shaded by // its fill. The outline is four strips so the fading core never shows it through. void particle_draw(Renderer& renderer, Particle& particle, Vec2 position) { float fade = particle.life / particle.lifeMax; float size = std::ceil(particle.size * fade); float outer = size + PARTICLE_OUTLINE * 2; Vec2 corner = {std::round(position.x - size / 2), std::round(position.y - size / 2)}; Vec2 edge = corner - Vec2{PARTICLE_OUTLINE, PARTICLE_OUTLINE}; Color color = color_fade(particle.color, fade); renderer.rect_draw({edge.x, edge.y, outer, PARTICLE_OUTLINE}, color); renderer.rect_draw({edge.x, corner.y + size, outer, PARTICLE_OUTLINE}, color); renderer.rect_draw({edge.x, corner.y, PARTICLE_OUTLINE, size}, color); renderer.rect_draw({corner.x + size, corner.y, PARTICLE_OUTLINE, size}, color); renderer.rect_draw({corner.x, corner.y, size, size}, color_fade(color_shade(particle.color, particle.fill), fade)); } void Effects::dps_record(int amount) { hits.push_back({ticks * TICK, amount}); } // Damage dealt over the last few seconds, averaged per second. float Effects::dps_get() { int total = 0; for (Hit& hit : hits) total += hit.amount; return total / DPS_WINDOW; } void Effects::shake_add(float amount) { shake = std::min(SHAKE_TRAUMA_MAX, shake + amount); } // Shake grows with the square of its trauma so small hits stay subtle, up to a limit in pixels. Big trauma // still lasts longer at that limit. Vec2 Effects::shake_get() { float strength = std::min(SHAKE_LIMIT, shake * shake * SHAKE_MAX); return {random_get(-strength, strength), random_get(-strength, strength)}; } void Effects::update() { shake = std::max(0.0f, shake - SHAKE_DECAY * TICK); ticks++; std::erase_if(hits, [&](Hit& hit) { return ticks * TICK - hit.time > DPS_WINDOW; }); for (Flash& flash : flashes) flash.life -= TICK; std::erase_if(flashes, [](Flash& flash) { return flash.life <= 0; }); for (Afterimage& afterimage : afterimages) afterimage.life -= TICK; std::erase_if(afterimages, [](Afterimage& afterimage) { return afterimage.life <= 0; }); for (Particle& particle : particles) { particle.velocity.y += particle.gravity * TICK; particle.position = particle.position + particle.velocity * TICK; particle.life -= TICK; } std::erase_if(particles, [](Particle& particle) { return particle.life <= 0; }); for (Popup& popup : popups) { popup.velocity.y += POPUP_GRAVITY * TICK; popup.position = popup.position + popup.velocity * TICK; popup.life -= TICK; } std::erase_if(popups, [](Popup& popup) { return popup.life <= 0; }); } // Particles and popups have no stored history, so they step back along their velocity // by however much of the tick drawing is behind. void Effects::draw(Game& game) { Renderer& renderer = game.renderer; float behind = (1 - game.alpha) * TICK; // Each flash is a long streak along the shot crossed by a short one, shrinking as it fades. for (Flash& flash : flashes) { game.scene.flash_follow(flash); float fade = flash.life / FLASH_LIFE; float length = FLASH_LENGTH * flash.size * fade; float width = FLASH_WIDTH * flash.size * fade; Vec2 along = vec2_polar(flash.angle, length / 2); Vec2 across = vec2_polar(flash.angle + PI / 2, length * FLASH_CROSS / 2); renderer.line_draw(flash.position - along * 0.2f, flash.position + along * 1.8f, width, flash.color); renderer.line_draw(flash.position - across, flash.position + across, width * FLASH_CROSS, flash.color); } for (Particle& particle : particles) particle_draw(renderer, particle, particle.position - particle.velocity * behind); for (Popup& popup : popups) { Vec2 size = game.text.size_get(popup.text, FONT_SMALL, popup.scale); float jitter = popup.jitter * popup.life / POPUP_LIFE; Vec2 shake = {random_get(-jitter, jitter), random_get(-jitter, jitter)}; Vec2 position = popup.position - popup.velocity * behind + shake; Vec2 corner = {std::round(position.x - size.x / 2), std::round(position.y - size.y / 2)}; float alpha = std::min(1.0f, popup.life / POPUP_FADE); game.text.draw( renderer, popup.text, {.color = color_fade(popup.color, alpha), .position = corner, .scale = {popup.scale, popup.scale}}); } } // Everything showing goes, as when this screen starts showing another game. void Effects::clear() { afterimages.clear(); flashes.clear(); hits.clear(); particles.clear(); popups.clear(); shake = 0; }