Files
smack-a-brother/src/effects.cpp
T
shweet 93cdf46d5a Room and lobby chat, /host, /fashion, player-colored lines, context tips, and American spelling
- Chat goes to your room (the sandbox, or your game) by default, and to the whole lobby with /all or a leading !.
  Lines from elsewhere say where they came from ([Sandbox], [Game 2]), lobby-wide ones from your room say [All].
  Typing shows only to your room. Protocol 8.
- Hosting: everyone is told who hosts now, and the host can hand it over with /host "player".
- /fashion <item> wears a fashion locally, like /color.
- Lines that mention a player show in that player's color, looked up as they draw.
- Tips are tagged with the item and situations they fit, and those that fit are 1.5x as likely. They turn over every
  10 seconds on one continuous run, show under the menu too, and can be turned off with Tips in HUD / Gameplay. The
  HUD hotbars hide while the menu is up (except on its item tabs).
- American spelling throughout: color, center, gray, mustache, and so on.
2026-11-10 19:37:19 -05:00

248 lines
9.4 KiB
C++

#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 = 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 <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)
{
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;
}