Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shweetandClaude Opus 5.5 committed 2026-11-08 01:30:33 -05:00
commit 2007fb7edc
200 files changed
+27710

No files matched your search

+256
View File
@@ -0,0 +1,256 @@
#include "effects.h"
#include "game.h"
#include <algorithm>
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 = 12;
constexpr float SHAKE_MAX = 10;
constexpr float SHAKE_TRAUMA_MAX = 3;
// 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 <typename T> static void effect_add(std::vector<T>& 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)
{
if (isMuted)
return;
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)
{
if (isMuted)
return;
if (ticks % AFTERIMAGE_INTERVAL == 0)
effect_add(afterimages, Afterimage{pose, AFTERIMAGE_LIFE});
}
// 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);
}
}
// Damage numbers are popups whose whole-number scale and shake both grow with the logarithm of the damage.
// Numbers take the attacker's colour 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)
{
if (isMuted)
return;
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)
{
if (isMuted)
return;
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(Vec2 position, float angle, float size, Color color)
{
if (isMuted)
return;
effect_add(flashes, Flash{color, position, angle, FLASH_LIFE, size});
}
void Effects::dps_record(int amount)
{
if (isMuted)
return;
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)
{
if (isMuted)
return;
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)
{
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);
}
// Each particle shrinks and fades out. The outline is four strips so the fading core never shows it through.
for (Particle& particle : particles)
{
float fade = particle.life / particle.lifeMax;
float size = std::ceil(particle.size * fade);
float outer = size + PARTICLE_OUTLINE * 2;
Vec2 position = particle.position - particle.velocity * behind;
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));
}
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;
}