Compare commits

...

14 Commits

22 changed files with 1232 additions and 293 deletions

3
.gitignore vendored
View File

@ -70,4 +70,7 @@ dist_package/
# Local environment files (if any)
.env
# Ignore local settings file
settings.ini
# End of .gitignore

Binary file not shown.

Binary file not shown.

BIN
assets/music/new_level.mp3 Normal file

Binary file not shown.

View File

@ -6,10 +6,13 @@ Fullscreen=1
[Audio]
Music=1
Sound=0
Sound=1
[Gameplay]
SmoothScroll=1
[Player]
Name=Player
Name=GREGOR
[Debug]
Enabled=0
Enabled=1

View File

@ -3,6 +3,55 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <random>
#include <cmath>
namespace {
constexpr float PI_F = 3.14159265358979323846f;
float randRange(float minVal, float maxVal) {
return minVal + (static_cast<float>(rand()) / static_cast<float>(RAND_MAX)) * (maxVal - minVal);
}
SDL_Color randomFireworkColor() {
static const SDL_Color palette[] = {
{255, 120, 80, 255},
{255, 190, 60, 255},
{120, 210, 255, 255},
{170, 120, 255, 255},
{255, 90, 180, 255},
{120, 255, 170, 255},
{255, 255, 180, 255}
};
size_t idx = static_cast<size_t>(rand() % (sizeof(palette) / sizeof(palette[0])));
return palette[idx];
}
SDL_Color scaleColor(SDL_Color color, float factor, Uint8 alphaOverride = 0) {
auto clampChannel = [](float value) -> Uint8 {
return static_cast<Uint8>(std::max(0.0f, std::min(255.0f, std::round(value))));
};
SDL_Color result;
result.r = clampChannel(color.r * factor);
result.g = clampChannel(color.g * factor);
result.b = clampChannel(color.b * factor);
result.a = alphaOverride ? alphaOverride : color.a;
return result;
}
SDL_Color mixColors(SDL_Color a, SDL_Color b, float t) {
t = std::clamp(t, 0.0f, 1.0f);
auto lerpChannel = [t](Uint8 ca, Uint8 cb) -> Uint8 {
float blended = ca + (cb - ca) * t;
return static_cast<Uint8>(std::max(0.0f, std::min(255.0f, blended)));
};
SDL_Color result;
result.r = lerpChannel(a.r, b.r);
result.g = lerpChannel(a.g, b.g);
result.b = lerpChannel(a.b, b.b);
result.a = lerpChannel(a.a, b.a);
return result;
}
}
GlobalState& GlobalState::instance() {
static GlobalState instance;
@ -41,88 +90,163 @@ void GlobalState::shutdown() {
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "[GlobalState] Shutdown complete");
}
namespace {
using Firework = GlobalState::TetrisFirework;
using BlockParticle = GlobalState::BlockParticle;
using SparkParticle = GlobalState::SparkParticle;
void spawnSparks(Firework& firework, float cx, float cy, SDL_Color baseColor, float speedBase) {
int sparkCount = 10 + (rand() % 10);
for (int i = 0; i < sparkCount; ++i) {
SparkParticle spark;
spark.x = cx;
spark.y = cy;
float angle = randRange(0.0f, PI_F * 2.0f);
float speed = speedBase * randRange(1.05f, 1.6f);
spark.vx = std::cos(angle) * speed;
spark.vy = std::sin(angle) * speed - randRange(30.0f, 90.0f);
spark.life = 0.0f;
spark.maxLife = 260.0f + randRange(0.0f, 200.0f);
spark.thickness = randRange(0.8f, 2.2f);
spark.color = scaleColor(baseColor, randRange(0.85f, 1.2f), 255);
firework.sparks.push_back(spark);
}
}
void triggerFireworkBurst(Firework& firework, int burstIndex) {
SDL_Color burstColor = firework.burstColors[burstIndex % firework.burstColors.size()];
float centerX = firework.originX + randRange(-30.0f, 30.0f);
float centerY = firework.originY - burstIndex * randRange(14.0f, 24.0f) + randRange(-10.0f, 10.0f);
int particleCount = 22 + (rand() % 16);
float speedBase = 90.0f + burstIndex * 40.0f;
for (int i = 0; i < particleCount; ++i) {
BlockParticle particle;
particle.x = centerX;
particle.y = centerY;
float angle = randRange(0.0f, PI_F * 2.0f);
float speed = speedBase + randRange(-20.0f, 70.0f);
particle.vx = std::cos(angle) * speed;
particle.vy = std::sin(angle) * speed - randRange(35.0f, 95.0f);
particle.maxLife = 950.0f + randRange(0.0f, 420.0f) + burstIndex * 220.0f;
particle.life = particle.maxLife;
particle.crackle = (rand() % 100) < 75;
particle.flickerSeed = randRange(0.0f, PI_F * 2.0f);
if (particle.crackle) {
particle.size = randRange(1.4f, 3.2f);
} else {
particle.size = 3.2f + randRange(0.0f, 2.6f) + burstIndex * 0.6f;
}
particle.color = scaleColor(burstColor, randRange(0.85f, 1.2f));
particle.dualColor = (rand() % 100) < 55;
if (particle.dualColor) {
SDL_Color alt = randomFireworkColor();
float luminanceDiff = std::abs(static_cast<float>(alt.r + alt.g + alt.b) - (particle.color.r + particle.color.g + particle.color.b));
if (luminanceDiff < 40.0f) {
alt = scaleColor(particle.color, randRange(0.6f, 1.4f));
}
particle.accentColor = alt;
particle.colorBlendSpeed = randRange(0.6f, 1.4f);
} else {
particle.accentColor = particle.color;
particle.colorBlendSpeed = 1.0f;
}
firework.particles.push_back(particle);
}
spawnSparks(firework, centerX, centerY, burstColor, speedBase);
}
}
void GlobalState::updateFireworks(double frameMs) {
if (frameMs <= 0.0) {
frameMs = 16.0;
}
const Uint64 currentTime = SDL_GetTicks();
// Check if we have any active fireworks
bool hasActiveFirework = false;
size_t activeCount = 0;
for (const auto& fw : fireworks) {
if (fw.active) {
hasActiveFirework = true;
break;
++activeCount;
}
}
// Only create new firework if no active ones exist
if (!hasActiveFirework && currentTime - lastFireworkTime > 1500 + (rand() % 2000)) {
float x = Config::Logical::WIDTH * (0.15f + (rand() % 70) / 100.0f);
float y = Config::Logical::HEIGHT * (0.20f + (rand() % 60) / 100.0f);
constexpr size_t MAX_SIMULTANEOUS_FIREWORKS = 2;
bool canSpawnNew = activeCount < MAX_SIMULTANEOUS_FIREWORKS;
bool spawnedFirework = false;
if (canSpawnNew) {
Uint64 interval = 1300 + static_cast<Uint64>(rand() % 1400);
if (currentTime - lastFireworkTime > interval) {
float x = Config::Logical::WIDTH * (0.15f + randRange(0.0f, 0.70f));
float y = Config::Logical::HEIGHT * (0.18f + randRange(0.0f, 0.40f));
createFirework(x, y);
lastFireworkTime = currentTime;
lastFireworkX = x;
lastFireworkY = y;
pendingStaggerFirework = (rand() % 100) < 65;
if (pendingStaggerFirework) {
nextStaggerFireworkTime = currentTime + 250 + static_cast<Uint64>(rand() % 420);
}
spawnedFirework = true;
}
}
if (!spawnedFirework && pendingStaggerFirework && canSpawnNew && currentTime >= nextStaggerFireworkTime) {
float x = lastFireworkX + randRange(-140.0f, 140.0f);
float y = lastFireworkY + randRange(-80.0f, 50.0f);
x = std::clamp(x, Config::Logical::WIDTH * 0.10f, Config::Logical::WIDTH * 0.90f);
y = std::clamp(y, Config::Logical::HEIGHT * 0.15f, Config::Logical::HEIGHT * 0.70f);
createFirework(x, y);
lastFireworkTime = currentTime;
lastFireworkX = x;
lastFireworkY = y;
pendingStaggerFirework = false;
spawnedFirework = true;
}
// Update existing fireworks
const float dtSeconds = static_cast<float>(frameMs / 1000.0);
const float deltaMs = static_cast<float>(frameMs);
for (auto& firework : fireworks) {
if (!firework.active) continue;
bool hasActiveParticles = false;
std::vector<BlockParticle> newParticles;
newParticles.reserve(20); // Pre-allocate to avoid reallocation
for (auto& particle : firework.particles) {
if (particle.life <= 0) continue;
// Update physics
float dt = float(frameMs / 1000.0f);
particle.x += particle.vx * dt;
particle.y += particle.vy * dt;
particle.vx *= (1.0f - 0.5f * dt);
particle.vy = particle.vy * (1.0f - 0.2f * dt) + 80.0f * dt;
particle.life -= frameMs;
// Update size
float lifeRatio = particle.life / particle.maxLife;
particle.size = (6.0f - particle.generation * 2.0f) + (4.0f - particle.generation) * lifeRatio;
// Only primary particles create secondary explosions (single cascade level)
if (!particle.hasExploded && particle.generation == 0 && lifeRatio < 0.5f) {
particle.hasExploded = true;
// Spawn only 3-4 secondary particles
int secondaryCount = 3 + (rand() % 2);
for (int i = 0; i < secondaryCount; ++i) {
BlockParticle secondary;
secondary.x = particle.x;
secondary.y = particle.y;
secondary.generation = 1; // Only one level of cascade
secondary.hasExploded = true; // Don't cascade further
float angle = (float)(rand() % 360) * 3.14159f / 180.0f;
float speed = 40.0f + (rand() % 40);
secondary.vx = cos(angle) * speed;
secondary.vy = sin(angle) * speed - 20.0f;
secondary.type = 1 + (rand() % 7);
secondary.maxLife = 700.0f + (rand() % 400);
secondary.life = secondary.maxLife;
secondary.size = 3.0f + (rand() % 2);
newParticles.push_back(secondary);
}
}
if (particle.life > 0) {
hasActiveParticles = true;
}
}
// Add secondary particles
for (auto& newParticle : newParticles) {
firework.particles.push_back(newParticle);
hasActiveParticles = true;
if (!firework.active) {
continue;
}
firework.active = hasActiveParticles;
firework.elapsedMs += deltaMs;
while (firework.nextBurst < static_cast<int>(firework.burstSchedule.size()) &&
firework.elapsedMs >= firework.burstSchedule[firework.nextBurst]) {
triggerFireworkBurst(firework, firework.nextBurst);
firework.nextBurst++;
}
for (auto it = firework.particles.begin(); it != firework.particles.end();) {
it->life -= deltaMs;
if (it->life <= 0.0f) {
it = firework.particles.erase(it);
continue;
}
it->x += it->vx * dtSeconds;
it->y += it->vy * dtSeconds;
it->vx *= 0.986f;
it->vy = it->vy * 0.972f + 70.0f * dtSeconds;
++it;
}
for (auto it = firework.sparks.begin(); it != firework.sparks.end();) {
it->life += deltaMs;
if (it->life >= it->maxLife) {
it = firework.sparks.erase(it);
continue;
}
it->x += it->vx * dtSeconds;
it->y += it->vy * dtSeconds;
it->vx *= 0.992f;
it->vy = it->vy * 0.965f + 120.0f * dtSeconds;
++it;
}
bool pendingBursts = firework.nextBurst < static_cast<int>(firework.burstSchedule.size());
firework.active = pendingBursts || !firework.particles.empty() || !firework.sparks.empty();
}
}
@ -142,78 +266,103 @@ void GlobalState::createFirework(float x, float y) {
firework = &fireworks.back();
}
// Initialize firework
firework->active = true;
firework->particles.clear();
firework->sparks.clear();
firework->originX = x;
firework->originY = y;
firework->elapsedMs = 0.0f;
firework->nextBurst = 0;
firework->burstSchedule = {
0.0f,
220.0f + randRange(0.0f, 160.0f),
420.0f + randRange(0.0f, 260.0f)
};
// Create fewer particles for subtle background effect
const int particleCount = 12 + (rand() % 8); // 12-20 particles per explosion
for (int i = 0; i < particleCount; ++i) {
BlockParticle particle;
particle.x = x;
particle.y = y;
particle.generation = 0; // Primary explosion
particle.hasExploded = false;
// Random velocity in all directions
float angle = (float)(rand() % 360) * 3.14159f / 180.0f;
float speed = 80.0f + (rand() % 100); // Moderate speed
particle.vx = cos(angle) * speed;
particle.vy = sin(angle) * speed - 50.0f; // Slight upward bias
particle.type = 1 + (rand() % 7); // Random tetris piece color
particle.maxLife = 1500.0f + (rand() % 1000); // Medium life: ~1.5-2.5 seconds
particle.life = particle.maxLife;
particle.size = 6.0f + (rand() % 5); // Smaller particles
firework->particles.push_back(particle);
SDL_Color baseColor = randomFireworkColor();
for (int i = 0; i < 3; ++i) {
float wobble = randRange(-0.08f, 0.08f);
firework->burstColors[i] = scaleColor(baseColor, 1.0f - i * 0.12f + wobble, 255);
}
}
void GlobalState::drawFireworks(SDL_Renderer* renderer, SDL_Texture* blocksTex) {
(void)blocksTex; // Not using texture anymore
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
(void)blocksTex;
auto renderCircle = [renderer](float cx, float cy, float radius) {
int ir = static_cast<int>(std::ceil(radius));
for (int dy = -ir; dy <= ir; ++dy) {
float row = std::sqrt(std::max(0.0f, radius * radius - static_cast<float>(dy * dy)));
SDL_FRect line{cx - row, cy + dy, row * 2.0f, 1.0f};
SDL_RenderFillRect(renderer, &line);
}
};
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
for (const auto& firework : fireworks) {
if (!firework.active) continue;
for (const auto& spark : firework.sparks) {
if (spark.life >= spark.maxLife) continue;
float progress = spark.life / spark.maxLife;
Uint8 alpha = static_cast<Uint8>((1.0f - progress) * 255.0f);
if (alpha == 0) continue;
SDL_SetRenderDrawColor(renderer, spark.color.r, spark.color.g, spark.color.b, alpha);
float trailScale = 0.015f * spark.thickness;
float tailX = spark.x - spark.vx * trailScale;
float tailY = spark.y - spark.vy * trailScale;
SDL_RenderLine(renderer,
static_cast<int>(spark.x),
static_cast<int>(spark.y),
static_cast<int>(tailX),
static_cast<int>(tailY));
}
}
auto sampleParticleColor = [](const BlockParticle& particle) -> SDL_Color {
if (!particle.dualColor) {
return particle.color;
}
float elapsed = particle.maxLife - particle.life;
float phase = particle.flickerSeed * 1.8f + elapsed * 0.0025f * particle.colorBlendSpeed;
float mixFactor = 0.5f + 0.5f * std::sin(phase);
return mixColors(particle.color, particle.accentColor, mixFactor);
};
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
for (const auto& firework : fireworks) {
if (!firework.active) continue;
for (const auto& particle : firework.particles) {
if (particle.life <= 0) continue;
// Calculate alpha based on remaining life
if (particle.life <= 0.0f) continue;
float lifeRatio = particle.life / particle.maxLife;
Uint8 alpha = (Uint8)(128 * std::min(1.0f, lifeRatio * 1.6f));
// Color based on particle type
SDL_Color colors[] = {
{0, 240, 240, alpha}, // Cyan
{240, 160, 0, alpha}, // Orange
{0, 0, 240, alpha}, // Blue
{240, 240, 0, alpha}, // Yellow
{0, 240, 0, alpha}, // Green
{160, 0, 240, alpha}, // Purple
{240, 0, 0, alpha} // Red
};
SDL_Color color = colors[(particle.type - 1) % 7];
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a);
// For small particles, just draw a filled rect (much faster)
if (particle.size <= 4.0f) {
SDL_FRect rect{particle.x - particle.size/2, particle.y - particle.size/2, particle.size, particle.size};
SDL_RenderFillRect(renderer, &rect);
float alphaF = std::pow(std::max(0.0f, lifeRatio), 0.75f);
if (particle.crackle) {
SDL_Color dynamicColor = sampleParticleColor(particle);
float flicker = 0.55f + 0.45f * std::sin(particle.flickerSeed + particle.life * 0.018f);
Uint8 alpha = static_cast<Uint8>(alphaF * flicker * 255.0f);
if (alpha == 0) continue;
SDL_SetRenderDrawColor(renderer, dynamicColor.r, dynamicColor.g, dynamicColor.b, alpha);
float stretch = particle.size * (2.5f + (1.0f - lifeRatio) * 1.3f);
float angle = particle.flickerSeed * 3.0f + particle.life * 0.004f;
float dx = std::cos(angle) * stretch;
float dy = std::sin(angle) * stretch * 0.7f;
SDL_RenderLine(renderer,
static_cast<int>(particle.x - dx),
static_cast<int>(particle.y - dy),
static_cast<int>(particle.x + dx),
static_cast<int>(particle.y + dy));
SDL_RenderLine(renderer,
static_cast<int>(particle.x - dy * 0.45f),
static_cast<int>(particle.y + dx * 0.45f),
static_cast<int>(particle.x + dy * 0.45f),
static_cast<int>(particle.y - dx * 0.45f));
} else {
// For larger particles, draw a simple circle approximation
float radius = particle.size / 2.0f;
int r = (int)radius;
for (int dy = -r; dy <= r; ++dy) {
int width = (int)sqrt(radius*radius - dy*dy) * 2;
if (width > 0) {
SDL_FRect line{particle.x - width/2.0f, particle.y + dy, (float)width, 1.0f};
SDL_RenderFillRect(renderer, &line);
}
}
SDL_Color dynamicColor = sampleParticleColor(particle);
Uint8 alpha = static_cast<Uint8>(alphaF * 255.0f);
SDL_SetRenderDrawColor(renderer, dynamicColor.r, dynamicColor.g, dynamicColor.b, alpha);
float radius = particle.size * (0.5f + 0.3f * lifeRatio);
renderCircle(particle.x, particle.y, radius);
}
}
}

View File

@ -1,6 +1,7 @@
#pragma once
#include <vector>
#include <array>
#include <memory>
#include <SDL3/SDL.h>
@ -73,21 +74,52 @@ public:
// Fireworks system (for menu animation)
struct BlockParticle {
float x, y, vx, vy;
int type;
float life, maxLife;
float size;
int generation = 0; // 0 = primary, 1 = secondary, 2 = tertiary
bool hasExploded = false; // Track if this particle has spawned children
float x = 0.0f;
float y = 0.0f;
float vx = 0.0f;
float vy = 0.0f;
float size = 0.0f;
float life = 0.0f;
float maxLife = 0.0f;
SDL_Color color{255, 255, 255, 255};
SDL_Color accentColor{255, 255, 255, 255};
int generation = 0;
bool hasExploded = false;
bool crackle = false;
float flickerSeed = 0.0f;
bool dualColor = false;
float colorBlendSpeed = 0.0f;
};
struct SparkParticle {
float x = 0.0f;
float y = 0.0f;
float vx = 0.0f;
float vy = 0.0f;
float life = 0.0f;
float maxLife = 0.0f;
float thickness = 1.0f;
SDL_Color color{255, 255, 255, 255};
};
struct TetrisFirework {
std::vector<BlockParticle> particles;
std::vector<SparkParticle> sparks;
bool active = false;
float originX = 0.0f;
float originY = 0.0f;
float elapsedMs = 0.0f;
int nextBurst = 0;
std::array<float, 3> burstSchedule{0.0f, 250.0f, 520.0f};
std::array<SDL_Color, 3> burstColors{};
};
std::vector<TetrisFirework> fireworks;
Uint64 lastFireworkTime = 0;
bool pendingStaggerFirework = false;
Uint64 nextStaggerFireworkTime = 0;
float lastFireworkX = 0.0f;
float lastFireworkY = 0.0f;
// Fireworks management methods
void updateFireworks(double frameMs);

View File

@ -66,6 +66,10 @@ bool Settings::load() {
} else if (key == "Sound") {
m_soundEnabled = (value == "1" || value == "true" || value == "True");
}
} else if (currentSection == "Gameplay") {
if (key == "SmoothScroll") {
m_smoothScrollEnabled = (value == "1" || value == "true" || value == "True");
}
} else if (currentSection == "Player") {
if (key == "Name") {
m_playerName = value;
@ -100,6 +104,9 @@ bool Settings::save() {
file << "Music=" << (m_musicEnabled ? "1" : "0") << "\n";
file << "Sound=" << (m_soundEnabled ? "1" : "0") << "\n\n";
file << "[Gameplay]\n";
file << "SmoothScroll=" << (m_smoothScrollEnabled ? "1" : "0") << "\n\n";
file << "[Player]\n";
file << "Name=" << m_playerName << "\n\n";

View File

@ -28,6 +28,9 @@ public:
bool isDebugEnabled() const { return m_debugEnabled; }
void setDebugEnabled(bool value) { m_debugEnabled = value; }
bool isSmoothScrollEnabled() const { return m_smoothScrollEnabled; }
void setSmoothScrollEnabled(bool value) { m_smoothScrollEnabled = value; }
const std::string& getPlayerName() const { return m_playerName; }
void setPlayerName(const std::string& name) { m_playerName = name; }
@ -45,5 +48,6 @@ private:
bool m_musicEnabled = true;
bool m_soundEnabled = true;
bool m_debugEnabled = false;
bool m_smoothScrollEnabled = true;
std::string m_playerName = "Player";
};

View File

@ -57,6 +57,9 @@ void Game::reset(int startLevel_) {
// Initialize gravity using NES timing table (ms per cell by level)
gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier);
fallAcc = 0; gameOver=false; paused=false;
hardDropShakeTimerMs = 0.0;
hardDropCells.clear();
hardDropFxId = 0;
_startTime = SDL_GetPerformanceCounter();
_pausedTime = 0;
_lastPauseStart = 0;
@ -103,6 +106,24 @@ void Game::setPaused(bool p) {
paused = p;
}
void Game::setSoftDropping(bool on) {
if (softDropping == on) {
return;
}
double oldStep = softDropping ? (gravityMs / 5.0) : gravityMs;
softDropping = on;
double newStep = softDropping ? (gravityMs / 5.0) : gravityMs;
if (oldStep <= 0.0 || newStep <= 0.0) {
return;
}
double progress = fallAcc / oldStep;
progress = std::clamp(progress, 0.0, 1.0);
fallAcc = progress * newStep;
}
void Game::refillBag() {
bag.clear();
for (int i=0;i<PIECE_COUNT;++i) bag.push_back(static_cast<PieceType>(i));
@ -310,6 +331,25 @@ void Game::softDropBoost(double frameMs) {
(void)frameMs;
}
void Game::updateVisualEffects(double frameMs) {
if (frameMs <= 0.0) {
return;
}
if (hardDropShakeTimerMs <= 0.0) {
hardDropShakeTimerMs = 0.0;
if (!hardDropCells.empty()) {
hardDropCells.clear();
}
return;
}
hardDropShakeTimerMs = std::max(0.0, hardDropShakeTimerMs - frameMs);
if (hardDropShakeTimerMs <= 0.0 && !hardDropCells.empty()) {
hardDropCells.clear();
}
}
void Game::hardDrop() {
if (paused) return;
// Count how many rows we drop for scoring parity with JS
@ -319,7 +359,34 @@ void Game::hardDrop() {
if (rows > 0) {
_score += rows * 1;
}
lockPiece();
hardDropCells.clear();
hardDropCells.reserve(8);
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!cellFilled(cur, cx, cy)) {
continue;
}
int gx = cur.x + cx;
int gy = cur.y + cy;
if (gx < 0 || gx >= COLS || gy >= ROWS) {
continue;
}
if (gy >= 0) {
hardDropCells.push_back(SDL_Point{gx, gy});
}
}
}
++hardDropFxId;
lockPiece();
hardDropShakeTimerMs = HARD_DROP_SHAKE_DURATION_MS;
}
double Game::hardDropShakeStrength() const {
if (hardDropShakeTimerMs <= 0.0) {
return 0.0;
}
return std::clamp(hardDropShakeTimerMs / HARD_DROP_SHAKE_DURATION_MS, 0.0, 1.0);
}
void Game::rotate(int dir) {

View File

@ -28,7 +28,7 @@ public:
void tickGravity(double frameMs); // advance gravity accumulator & drop
void softDropBoost(double frameMs); // accelerate fall while held
void hardDrop(); // instant drop & lock
void setSoftDropping(bool on) { softDropping = on; } // mark if player holds Down
void setSoftDropping(bool on); // mark if player holds Down
void move(int dx); // horizontal move
void rotate(int dir); // +1 cw, -1 ccw (simple wall-kick)
void holdCurrent(); // swap with hold (once per spawn)
@ -74,6 +74,13 @@ public:
double getFallAccumulator() const { return fallAcc; } // Debug: time accumulated toward next drop
void setLevelGravityMultiplier(int level, double m);
// Visual effect hooks
void updateVisualEffects(double frameMs);
bool hasHardDropShake() const { return hardDropShakeTimerMs > 0.0; }
double hardDropShakeStrength() const;
const std::vector<SDL_Point>& getHardDropCells() const { return hardDropCells; }
uint32_t getHardDropFxId() const { return hardDropFxId; }
private:
std::array<int, COLS*ROWS> board{}; // 0 empty else color index
Piece cur{}, hold{}, nextPiece{}; // current, held & next piece
@ -109,6 +116,12 @@ private:
// Backwards-compatible accessors (delegate to gravityMgr)
double computeGravityMsForLevel(int level) const;
// Impact FX timers
double hardDropShakeTimerMs{0.0};
static constexpr double HARD_DROP_SHAKE_DURATION_MS = 320.0;
std::vector<SDL_Point> hardDropCells;
uint32_t hardDropFxId{0};
// Internal helpers ----------------------------------------------------
void refillBag();
void spawn();

View File

@ -3,82 +3,144 @@
#include <algorithm>
#include <cmath>
#include "audio/Audio.h"
#include "gameplay/core/Game.h"
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
LineEffect::Particle::Particle(float px, float py)
: x(px), y(py), size(6.0f + static_cast<float>(rand()) / RAND_MAX * 12.0f), alpha(1.0f) {
// Random velocity for explosive effect
float angle = static_cast<float>(rand()) / RAND_MAX * 2.0f * M_PI;
float speed = 80.0f + static_cast<float>(rand()) / RAND_MAX * 150.0f;
vx = std::cos(angle) * speed;
vy = std::sin(angle) * speed - 30.0f;
// Random block type for texture
blockType = rand() % 7;
// Fallback colors if texture not available
switch (blockType % 4) {
case 0: color = {255, 140, 30, 255}; break;
case 1: color = {255, 255, 100, 255}; break;
case 2: color = {255, 255, 255, 255}; break;
case 3: color = {255, 100, 100, 255}; break;
}
namespace {
float randRange(float min, float max) {
return min + (static_cast<float>(rand()) / static_cast<float>(RAND_MAX)) * (max - min);
}
}
void LineEffect::Particle::update() {
x += vx * 0.016f;
y += vy * 0.016f;
vy += 250.0f * 0.016f;
vx *= 0.98f;
alpha -= 0.025f; // Slower fade for blocks (longer visibility)
if (alpha < 0.0f) alpha = 0.0f;
if (size > 2.0f) size -= 0.05f;
LineEffect::Particle::Particle(float px, float py, Style particleStyle, SDL_Color tint)
: x(px), y(py), size(0.0f), alpha(1.0f), life(0.0f), lifeSpan(0.0f),
blockType(rand() % 7), color(tint), style(particleStyle) {
float angle = randRange(0.0f, static_cast<float>(M_PI) * 2.0f);
float speed = (style == Style::Shard) ? randRange(140.0f, 260.0f) : randRange(70.0f, 140.0f);
vx = std::cos(angle) * speed;
vy = std::sin(angle) * speed - ((style == Style::Shard) ? randRange(40.0f, 110.0f) : randRange(10.0f, 40.0f));
size = (style == Style::Shard) ? randRange(8.0f, 16.0f) : randRange(5.0f, 10.0f);
lifeSpan = (style == Style::Shard) ? randRange(0.70f, 1.20f) : randRange(1.00f, 1.50f);
}
void LineEffect::Particle::update(float dt) {
life += dt;
if (life >= lifeSpan) {
alpha = 0.0f;
return;
}
const float progress = life / lifeSpan;
x += vx * dt;
y += vy * dt;
float gravity = (style == Style::Shard) ? 520.0f : 240.0f;
vy += gravity * dt;
vx *= (style == Style::Shard) ? 0.96f : 0.985f;
float shrinkRate = (style == Style::Shard) ? 24.0f : 10.0f;
size = std::max(2.0f, size - shrinkRate * dt);
alpha = 1.0f - progress;
}
void LineEffect::Particle::render(SDL_Renderer* renderer, SDL_Texture* blocksTex) {
if (alpha <= 0.0f) return;
if (blocksTex) {
// Render textured block fragment
Uint8 prevA = 255;
SDL_GetTextureAlphaMod(blocksTex, &prevA);
SDL_SetTextureAlphaMod(blocksTex, static_cast<Uint8>(alpha * 255.0f));
const int SPRITE_SIZE = 90;
float srcX = blockType * SPRITE_SIZE + 2;
float srcY = 2;
float srcW = SPRITE_SIZE - 4;
float srcH = SPRITE_SIZE - 4;
SDL_FRect srcRect = {srcX, srcY, srcW, srcH};
SDL_FRect dstRect = {x - size/2, y - size/2, size, size};
SDL_FRect dstRect = {x - size/2.0f, y - size/2.0f, size, size};
SDL_RenderTexture(renderer, blocksTex, &srcRect, &dstRect);
SDL_SetTextureAlphaMod(blocksTex, prevA);
} else {
// Fallback to circle rendering
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
Uint8 adjustedAlpha = static_cast<Uint8>(alpha * 255.0f);
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, adjustedAlpha);
for (int i = 0; i < static_cast<int>(size); ++i) {
for (int j = 0; j < static_cast<int>(size); ++j) {
float dx = i - size/2.0f;
float dy = j - size/2.0f;
if (dx*dx + dy*dy <= (size/2.0f)*(size/2.0f)) {
SDL_RenderPoint(renderer, x + dx, y + dy);
float radius = size * 0.5f;
for (int iy = -static_cast<int>(radius); iy <= static_cast<int>(radius); ++iy) {
for (int ix = -static_cast<int>(radius); ix <= static_cast<int>(radius); ++ix) {
float dist2 = float(ix * ix + iy * iy);
if (dist2 <= radius * radius) {
SDL_RenderPoint(renderer, x + ix, y + iy);
}
}
}
}
}
LineEffect::Spark::Spark(float px, float py, SDL_Color tint)
: x(px), y(py), vx(0.0f), vy(0.0f), life(0.0f), maxLife(randRange(0.40f, 0.80f)),
thickness(randRange(1.0f, 2.0f)), color(tint) {
float angle = randRange(0.0f, static_cast<float>(M_PI) * 2.0f);
float speed = randRange(240.0f, 520.0f);
vx = std::cos(angle) * speed;
vy = std::sin(angle) * speed - randRange(80.0f, 150.0f);
}
void LineEffect::Spark::update(float dt) {
life += dt;
x += vx * dt;
y += vy * dt;
vy += 420.0f * dt;
vx *= 0.99f;
}
void LineEffect::Spark::render(SDL_Renderer* renderer) const {
if (life >= maxLife) return;
float progress = life / maxLife;
float alpha = (1.0f - progress) * 255.0f;
if (alpha <= 0.0f) return;
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, static_cast<Uint8>(alpha));
float trail = 0.018f * (1.2f - progress) * thickness;
SDL_FPoint tail{ x - vx * trail, y - vy * trail };
SDL_RenderLine(renderer, x, y, tail.x, tail.y);
}
LineEffect::GlowPulse::GlowPulse(float px, float py, float baseR, float maxR, SDL_Color tint)
: x(px), y(py), baseRadius(baseR), maxRadius(maxR), life(0.0f),
maxLife(randRange(0.45f, 0.70f)), color(tint) {
if (color.a == 0) color.a = 200;
}
void LineEffect::GlowPulse::update(float dt) {
life += dt;
if (life > maxLife) life = maxLife;
}
void LineEffect::GlowPulse::render(SDL_Renderer* renderer) const {
if (life >= maxLife) return;
float progress = life / maxLife;
float radius = baseRadius + (maxRadius - baseRadius) * progress;
float baseAlpha = (1.0f - progress) * (color.a / 255.0f);
int intRadius = static_cast<int>(std::ceil(radius));
for (int iy = -intRadius; iy <= intRadius; ++iy) {
float dy = static_cast<float>(iy);
float rowWidth = std::sqrt(std::max(0.0f, radius * radius - dy * dy));
float falloff = std::max(0.0f, 1.0f - std::abs(dy) / radius);
Uint8 alpha = static_cast<Uint8>(baseAlpha * falloff * 255.0f);
if (alpha == 0) continue;
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, alpha);
SDL_FRect segment{
x - rowWidth,
y + dy,
rowWidth * 2.0f,
1.4f
};
SDL_RenderFillRect(renderer, &segment);
}
}
LineEffect::LineEffect() : renderer(nullptr), state(AnimationState::IDLE), timer(0.0f),
rng(std::random_device{}()), audioStream(nullptr) {
}
@ -132,7 +194,28 @@ void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gri
clearingRows = rows;
state = AnimationState::FLASH_WHITE;
timer = 0.0f;
dropProgress = 0.0f;
dropBlockSize = blockSize;
rowDropTargets.fill(0.0f);
particles.clear();
sparks.clear();
glowPulses.clear();
std::array<bool, Game::ROWS> rowClearing{};
for (int row : rows) {
if (row >= 0 && row < Game::ROWS) {
rowClearing[row] = true;
}
}
int clearedBelow = 0;
for (int row = Game::ROWS - 1; row >= 0; --row) {
if (rowClearing[row]) {
++clearedBelow;
} else if (clearedBelow > 0) {
rowDropTargets[row] = static_cast<float>(clearedBelow * blockSize);
}
}
// Create particles for each clearing row
for (int row : rows) {
@ -144,22 +227,38 @@ void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gri
}
void LineEffect::createParticles(int row, int gridX, int gridY, int blockSize) {
// Create particles spread across the row with explosive pattern
int particlesPerRow = 60; // More particles for dramatic explosion effect
for (int i = 0; i < particlesPerRow; ++i) {
// Create particles along the entire row width
float x = gridX + (static_cast<float>(i) / (particlesPerRow - 1)) * (10 * blockSize);
float y = gridY + row * blockSize + blockSize / 2.0f;
// Add some randomness to position
x += (static_cast<float>(rand()) / RAND_MAX - 0.5f) * blockSize * 0.8f;
y += (static_cast<float>(rand()) / RAND_MAX - 0.5f) * blockSize * 0.6f;
particles.emplace_back(x, y);
const float centerY = gridY + row * blockSize + blockSize * 0.5f;
for (int col = 0; col < Game::COLS; ++col) {
float centerX = gridX + col * blockSize + blockSize * 0.5f;
SDL_Color tint = pickFireColor();
spawnGlowPulse(centerX, centerY, static_cast<float>(blockSize), tint);
spawnShardBurst(centerX, centerY, tint);
spawnSparkBurst(centerX, centerY, tint);
}
}
void LineEffect::spawnShardBurst(float x, float y, SDL_Color tint) {
int shardCount = 3 + rand() % 4;
for (int i = 0; i < shardCount; ++i) {
particles.emplace_back(x, y, Particle::Style::Shard, tint);
}
int emberCount = 2 + rand() % 3;
for (int i = 0; i < emberCount; ++i) {
particles.emplace_back(x, y, Particle::Style::Ember, tint);
}
}
void LineEffect::spawnSparkBurst(float x, float y, SDL_Color tint) {
int sparkCount = 4 + rand() % 5;
for (int i = 0; i < sparkCount; ++i) {
sparks.emplace_back(x, y, tint);
}
}
void LineEffect::spawnGlowPulse(float x, float y, float blockSize, SDL_Color tint) {
glowPulses.emplace_back(x, y, blockSize * 0.45f, blockSize * 2.3f, tint);
}
bool LineEffect::update(float deltaTime) {
if (state == AnimationState::IDLE) return true;
@ -174,7 +273,9 @@ bool LineEffect::update(float deltaTime) {
break;
case AnimationState::EXPLODE_BLOCKS:
updateParticles();
updateParticles(deltaTime);
updateSparks(deltaTime);
updateGlowPulses(deltaTime);
if (timer >= EXPLODE_DURATION) {
state = AnimationState::BLOCKS_DROP;
timer = 0.0f;
@ -182,11 +283,19 @@ bool LineEffect::update(float deltaTime) {
break;
case AnimationState::BLOCKS_DROP:
updateParticles();
updateParticles(deltaTime);
updateSparks(deltaTime);
updateGlowPulses(deltaTime);
dropProgress = std::min(1.0f, timer / DROP_DURATION);
if (timer >= DROP_DURATION) {
state = AnimationState::IDLE;
clearingRows.clear();
particles.clear();
sparks.clear();
glowPulses.clear();
rowDropTargets.fill(0.0f);
dropProgress = 0.0f;
dropBlockSize = 0;
return true; // Effect complete
}
break;
@ -198,18 +307,34 @@ bool LineEffect::update(float deltaTime) {
return false; // Effect still running
}
void LineEffect::updateParticles() {
// Update all particles
void LineEffect::updateParticles(float dt) {
for (auto& particle : particles) {
particle.update();
particle.update(dt);
}
// Remove dead particles
particles.erase(
std::remove_if(particles.begin(), particles.end(),
[](const Particle& p) { return !p.isAlive(); }),
particles.end()
);
particles.end());
}
void LineEffect::updateSparks(float dt) {
for (auto& spark : sparks) {
spark.update(dt);
}
sparks.erase(
std::remove_if(sparks.begin(), sparks.end(),
[](const Spark& s) { return !s.isAlive(); }),
sparks.end());
}
void LineEffect::updateGlowPulses(float dt) {
for (auto& pulse : glowPulses) {
pulse.update(dt);
}
glowPulses.erase(
std::remove_if(glowPulses.begin(), glowPulses.end(),
[](const GlowPulse& p) { return !p.isAlive(); }),
glowPulses.end());
}
void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize) {
@ -233,6 +358,21 @@ void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int grid
}
}
float LineEffect::getRowDropOffset(int row) const {
if (state != AnimationState::BLOCKS_DROP) {
return 0.0f;
}
if (row < 0 || row >= Game::ROWS) {
return 0.0f;
}
float target = rowDropTargets[row];
if (target <= 0.0f) {
return 0.0f;
}
float eased = dropProgress * dropProgress * dropProgress;
return target * eased;
}
void LineEffect::renderFlash(int gridX, int gridY, int blockSize) {
// Create a flashing white effect with varying opacity
float progress = timer / FLASH_DURATION;
@ -265,11 +405,46 @@ void LineEffect::renderFlash(int gridX, int gridY, int blockSize) {
}
void LineEffect::renderExplosion(SDL_Texture* blocksTex) {
renderGlowPulses();
renderSparks();
renderParticleGlows();
for (auto& particle : particles) {
particle.render(renderer, blocksTex);
}
}
void LineEffect::renderGlowPulses() {
if (glowPulses.empty()) return;
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
for (const auto& pulse : glowPulses) {
pulse.render(renderer);
}
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
}
void LineEffect::renderSparks() {
if (sparks.empty()) return;
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
for (const auto& spark : sparks) {
spark.render(renderer);
}
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
}
void LineEffect::renderParticleGlows() {
if (particles.empty()) return;
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);
for (const auto& particle : particles) {
if (particle.alpha <= 0.0f) continue;
float radius = particle.size * 0.6f;
SDL_SetRenderDrawColor(renderer, particle.color.r, particle.color.g, particle.color.b,
static_cast<Uint8>(particle.alpha * 150.0f));
SDL_FRect glowRect{ particle.x - radius, particle.y - radius, radius * 2.0f, radius * 2.0f };
SDL_RenderFillRect(renderer, &glowRect);
}
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
}
void LineEffect::playLineClearSound(int lineCount) {
// Choose appropriate sound based on line count
const std::vector<int16_t>* sample = (lineCount == 4) ? &tetrisSample : &lineClearSample;
@ -278,3 +453,15 @@ void LineEffect::playLineClearSound(int lineCount) {
Audio::instance().playSfx(*sample, 2, 44100, (lineCount == 4) ? 0.9f : 0.7f);
}
}
SDL_Color LineEffect::pickFireColor() const {
static const SDL_Color palette[] = {
{255, 200, 120, 210},
{255, 150, 90, 220},
{255, 100, 160, 200},
{120, 200, 255, 200},
{255, 255, 200, 210}
};
const size_t count = sizeof(palette) / sizeof(palette[0]);
return palette[rand() % count];
}

View File

@ -3,21 +3,56 @@
#include <SDL3/SDL.h>
#include <vector>
#include <random>
#include <array>
#include "../core/Game.h"
class LineEffect {
public:
struct Particle {
enum class Style { Shard, Ember };
float x, y;
float vx, vy;
float size;
float alpha;
int blockType; // Added for textured particles
float life;
float lifeSpan;
int blockType;
SDL_Color color;
Particle(float px, float py);
void update();
Style style;
Particle(float px, float py, Style particleStyle, SDL_Color tint);
void update(float dt);
void render(SDL_Renderer* renderer, SDL_Texture* blocksTex);
bool isAlive() const { return alpha > 0.0f; }
bool isAlive() const { return life < lifeSpan && alpha > 0.01f; }
};
struct Spark {
float x, y;
float vx, vy;
float life;
float maxLife;
float thickness;
SDL_Color color;
Spark(float px, float py, SDL_Color tint);
void update(float dt);
void render(SDL_Renderer* renderer) const;
bool isAlive() const { return life < maxLife; }
};
struct GlowPulse {
float x, y;
float baseRadius;
float maxRadius;
float life;
float maxLife;
SDL_Color color;
GlowPulse(float px, float py, float baseR, float maxR, SDL_Color tint);
void update(float dt);
void render(SDL_Renderer* renderer) const;
bool isAlive() const { return life < maxLife; }
};
enum class AnimationState {
@ -39,6 +74,7 @@ public:
// Update and render the effect
bool update(float deltaTime); // Returns true if effect is complete
void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize);
float getRowDropOffset(int row) const;
// Audio
void playLineClearSound(int lineCount);
@ -51,6 +87,8 @@ private:
float timer{0.0f};
std::vector<int> clearingRows;
std::vector<Particle> particles;
std::vector<Spark> sparks;
std::vector<GlowPulse> glowPulses;
std::mt19937 rng{std::random_device{}()};
// Audio resources
@ -59,14 +97,27 @@ private:
std::vector<int16_t> tetrisSample;
// Animation timing - Flash then immediate explosion effect
static constexpr float FLASH_DURATION = 0.12f; // Very brief white flash
static constexpr float EXPLODE_DURATION = 0.6f; // Longer explosive effect
static constexpr float DROP_DURATION = 0.05f; // Almost instant block drop
static constexpr float FLASH_DURATION = 0.18f; // Slightly longer flash for anticipation
static constexpr float EXPLODE_DURATION = 0.9f; // Extended fireworks time
static constexpr float DROP_DURATION = 0.35f; // Allow lingering sparks before collapse
void createParticles(int row, int gridX, int gridY, int blockSize);
void updateParticles();
void spawnShardBurst(float x, float y, SDL_Color tint);
void spawnSparkBurst(float x, float y, SDL_Color tint);
void spawnGlowPulse(float x, float y, float blockSize, SDL_Color tint);
void updateParticles(float dt);
void updateSparks(float dt);
void updateGlowPulses(float dt);
void renderFlash(int gridX, int gridY, int blockSize);
void renderExplosion(SDL_Texture* blocksTex);
void renderGlowPulses();
void renderSparks();
void renderParticleGlows();
bool loadAudioSample(const std::string& path, std::vector<int16_t>& sample);
void initAudio();
SDL_Color pickFireColor() const;
std::array<float, Game::ROWS> rowDropTargets{};
float dropProgress = 0.0f;
int dropBlockSize = 0;
};

View File

@ -6,8 +6,23 @@
#include <array>
#include <cmath>
#include <cstdio>
#include <random>
#include <vector>
#include "../../core/Settings.h"
namespace {
struct ImpactSpark {
float x = 0.0f;
float y = 0.0f;
float vx = 0.0f;
float vy = 0.0f;
float lifeMs = 0.0f;
float maxLifeMs = 0.0f;
float size = 0.0f;
SDL_Color color{255, 255, 255, 255};
};
}
// Color constants (copied from main.cpp)
static const SDL_Color COLORS[] = {
{0, 0, 0, 255}, // 0: BLACK (empty)
@ -47,14 +62,14 @@ void GameRenderer::drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksT
SDL_RenderTexture(renderer, blocksTex, &srcRect, &dstRect);
}
void GameRenderer::drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, const Game::Piece& piece, float ox, float oy, float tileSize, bool isGhost) {
void GameRenderer::drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, const Game::Piece& piece, float ox, float oy, float tileSize, bool isGhost, float pixelOffsetY) {
if (piece.type >= PIECE_COUNT) return;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (Game::cellFilled(piece, cx, cy)) {
float px = ox + (piece.x + cx) * tileSize;
float py = oy + (piece.y + cy) * tileSize;
float py = oy + (piece.y + cy) * tileSize + pixelOffsetY;
if (isGhost) {
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
@ -126,6 +141,18 @@ void GameRenderer::renderPlayingState(
float winH
) {
if (!game || !pixelFont) return;
static std::vector<ImpactSpark> s_impactSparks;
static uint32_t s_lastImpactFxId = 0;
static Uint32 s_lastImpactTick = SDL_GetTicks();
static std::mt19937 s_impactRng{ std::random_device{}() };
Uint32 nowTicks = SDL_GetTicks();
float sparkDeltaMs = static_cast<float>(nowTicks - s_lastImpactTick);
s_lastImpactTick = nowTicks;
if (sparkDeltaMs < 0.0f || sparkDeltaMs > 100.0f) {
sparkDeltaMs = 16.0f;
}
// Calculate actual content area (centered within the window)
float contentScale = logicalScale;
@ -223,20 +250,188 @@ void GameRenderer::renderPlayingState(
drawRectWithOffset(nextX - 3 - contentOffsetX, nextY - 3 - contentOffsetY, nextW + 6, nextH + 6, {100, 120, 200, 255});
drawRectWithOffset(nextX - contentOffsetX, nextY - contentOffsetY, nextW, nextH, {30, 35, 50, 255});
// Precompute row drop offsets (line collapse effect)
std::array<float, Game::ROWS> rowDropOffsets{};
for (int y = 0; y < Game::ROWS; ++y) {
rowDropOffsets[y] = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f);
}
// Draw the game board
const auto &board = game->boardRef();
float impactStrength = 0.0f;
float impactEased = 0.0f;
std::array<uint8_t, Game::COLS * Game::ROWS> impactMask{};
std::array<float, Game::COLS * Game::ROWS> impactWeight{};
if (game->hasHardDropShake()) {
impactStrength = static_cast<float>(game->hardDropShakeStrength());
impactStrength = std::clamp(impactStrength, 0.0f, 1.0f);
impactEased = impactStrength * impactStrength;
const auto& impactCells = game->getHardDropCells();
for (const auto& cell : impactCells) {
if (cell.x < 0 || cell.x >= Game::COLS || cell.y < 0 || cell.y >= Game::ROWS) {
continue;
}
int idx = cell.y * Game::COLS + cell.x;
impactMask[idx] = 1;
impactWeight[idx] = 1.0f;
int depth = 0;
for (int ny = cell.y + 1; ny < Game::ROWS && depth < 4; ++ny) {
if (board[ny * Game::COLS + cell.x] == 0) {
break;
}
++depth;
int nidx = ny * Game::COLS + cell.x;
impactMask[nidx] = 1;
float weight = std::max(0.15f, 1.0f - depth * 0.35f);
impactWeight[nidx] = std::max(impactWeight[nidx], weight);
}
}
}
bool shouldSpawnCrackles = game->hasHardDropShake() && !game->getHardDropCells().empty() && game->getHardDropFxId() != s_lastImpactFxId;
if (shouldSpawnCrackles) {
s_lastImpactFxId = game->getHardDropFxId();
std::uniform_real_distribution<float> jitter(-finalBlockSize * 0.2f, finalBlockSize * 0.2f);
std::uniform_real_distribution<float> velX(-0.04f, 0.04f);
std::uniform_real_distribution<float> velY(0.035f, 0.07f);
std::uniform_real_distribution<float> lifespan(210.0f, 320.0f);
std::uniform_real_distribution<float> sizeDist(finalBlockSize * 0.08f, finalBlockSize * 0.14f);
const auto& impactCells = game->getHardDropCells();
for (const auto& cell : impactCells) {
if (cell.x < 0 || cell.x >= Game::COLS || cell.y < 0 || cell.y >= Game::ROWS) {
continue;
}
int idx = cell.y * Game::COLS + cell.x;
int v = (cell.y >= 0) ? board[idx] : 0;
SDL_Color baseColor = (v > 0 && v < PIECE_COUNT + 1) ? COLORS[v] : SDL_Color{255, 220, 180, 255};
float cellX = gridX + (cell.x + 0.5f) * finalBlockSize;
float cellY = gridY + (cell.y + 0.85f) * finalBlockSize + rowDropOffsets[cell.y];
for (int i = 0; i < 4; ++i) {
ImpactSpark spark;
spark.x = cellX + jitter(s_impactRng);
spark.y = cellY + jitter(s_impactRng) * 0.25f;
spark.vx = velX(s_impactRng);
spark.vy = velY(s_impactRng);
spark.lifeMs = lifespan(s_impactRng);
spark.maxLifeMs = spark.lifeMs;
spark.size = sizeDist(s_impactRng);
spark.color = SDL_Color{
static_cast<Uint8>(std::min(255, baseColor.r + 30)),
static_cast<Uint8>(std::min(255, baseColor.g + 30)),
static_cast<Uint8>(std::min(255, baseColor.b + 30)),
255
};
s_impactSparks.push_back(spark);
}
}
}
for (int y = 0; y < Game::ROWS; ++y) {
float dropOffset = rowDropOffsets[y];
for (int x = 0; x < Game::COLS; ++x) {
int v = board[y * Game::COLS + x];
if (v > 0) {
float bx = gridX + x * finalBlockSize;
float by = gridY + y * finalBlockSize;
float by = gridY + y * finalBlockSize + dropOffset;
const int cellIdx = y * Game::COLS + x;
float weight = impactWeight[cellIdx];
if (impactStrength > 0.0f && weight > 0.0f && impactMask[cellIdx]) {
float cellSeed = static_cast<float>((x * 37 + y * 61) % 113);
float t = static_cast<float>(nowTicks % 10000) * 0.018f + cellSeed;
float amplitude = 6.0f * impactEased * weight;
float freq = 2.0f + weight * 1.3f;
bx += amplitude * std::sin(t * freq);
by += amplitude * 0.75f * std::cos(t * (freq + 1.1f));
}
drawBlockTexture(renderer, blocksTex, bx, by, finalBlockSize, v - 1);
}
}
}
if (!s_impactSparks.empty()) {
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
auto it = s_impactSparks.begin();
while (it != s_impactSparks.end()) {
ImpactSpark& spark = *it;
spark.vy += 0.00045f * sparkDeltaMs;
spark.x += spark.vx * sparkDeltaMs;
spark.y += spark.vy * sparkDeltaMs;
spark.lifeMs -= sparkDeltaMs;
if (spark.lifeMs <= 0.0f) {
it = s_impactSparks.erase(it);
continue;
}
float lifeRatio = spark.lifeMs / spark.maxLifeMs;
Uint8 alpha = static_cast<Uint8>(std::clamp(lifeRatio, 0.0f, 1.0f) * 160.0f);
SDL_SetRenderDrawColor(renderer, spark.color.r, spark.color.g, spark.color.b, alpha);
SDL_FRect sparkRect{
spark.x - spark.size * 0.5f,
spark.y - spark.size * 0.5f,
spark.size,
spark.size * 1.4f
};
SDL_RenderFillRect(renderer, &sparkRect);
++it;
}
}
bool allowActivePieceRender = true;
const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled();
auto computeFallOffset = [&]() -> float {
if (game->isPaused()) {
return 0.0f;
}
double gravityMs = game->getGravityMs();
if (gravityMs <= 0.0) {
return 0.0f;
}
double effectiveMs = game->isSoftDropping() ? std::max(5.0, gravityMs / 5.0) : gravityMs;
double accumulator = std::clamp(game->getFallAccumulator(), 0.0, effectiveMs);
if (effectiveMs <= 0.0) {
return 0.0f;
}
float progress = static_cast<float>(accumulator / effectiveMs);
progress = std::clamp(progress, 0.0f, 1.0f);
return progress * finalBlockSize;
};
float activePieceOffset = (!game->isPaused() && smoothScrollEnabled) ? computeFallOffset() : 0.0f;
if (activePieceOffset > 0.0f) {
const auto& boardRef = game->boardRef();
const Game::Piece& piece = game->current();
float maxAllowed = finalBlockSize;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!Game::cellFilled(piece, cx, cy)) {
continue;
}
int gx = piece.x + cx;
int gy = piece.y + cy;
if (gx < 0 || gx >= Game::COLS) {
continue;
}
int testY = gy + 1;
int emptyRows = 0;
if (testY < 0) {
emptyRows -= testY; // number of rows until we reach row 0
testY = 0;
}
while (testY >= 0 && testY < Game::ROWS) {
if (boardRef[testY * Game::COLS + gx] != 0) {
break;
}
++emptyRows;
++testY;
}
float cellLimit = (emptyRows > 0) ? finalBlockSize : 0.0f;
maxAllowed = std::min(maxAllowed, cellLimit);
}
}
activePieceOffset = std::min(activePieceOffset, maxAllowed);
}
// Draw ghost piece (where current piece will land)
if (allowActivePieceRender) {
@ -273,7 +468,7 @@ void GameRenderer::renderPlayingState(
// Draw the falling piece
if (allowActivePieceRender) {
drawPiece(renderer, blocksTex, game->current(), gridX, gridY, finalBlockSize, false);
drawPiece(renderer, blocksTex, game->current(), gridX, gridY, finalBlockSize, false, activePieceOffset);
}
// Draw line clearing effects

View File

@ -50,7 +50,7 @@ public:
private:
// Helper functions for drawing game elements
static void drawBlockTexture(SDL_Renderer* renderer, SDL_Texture* blocksTex, float x, float y, float size, int blockType);
static void drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, const Game::Piece& piece, float ox, float oy, float tileSize, bool isGhost = false);
static void drawPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, const Game::Piece& piece, float ox, float oy, float tileSize, bool isGhost = false, float pixelOffsetY = 0.0f);
static void drawSmallPiece(SDL_Renderer* renderer, SDL_Texture* blocksTex, PieceType pieceType, float x, float y, float tileSize);
// Helper function for drawing rectangles

View File

@ -108,15 +108,35 @@ static SDL_Texture* loadTextureFromImage(SDL_Renderer* renderer, const std::stri
return texture;
}
enum class LevelBackgroundPhase { Idle, ZoomOut, ZoomIn };
struct LevelBackgroundFader {
SDL_Texture* currentTex = nullptr;
SDL_Texture* nextTex = nullptr;
int currentLevel = -1;
int queuedLevel = -1;
float fadeElapsedMs = 0.0f;
float phaseElapsedMs = 0.0f;
float phaseDurationMs = 0.0f;
float fadeDurationMs = Config::Gameplay::LEVEL_FADE_DURATION;
LevelBackgroundPhase phase = LevelBackgroundPhase::Idle;
};
static float getPhaseDurationMs(const LevelBackgroundFader& fader, LevelBackgroundPhase phase) {
const float total = std::max(1200.0f, fader.fadeDurationMs);
switch (phase) {
case LevelBackgroundPhase::ZoomOut: return total * 0.45f;
case LevelBackgroundPhase::ZoomIn: return total * 0.45f;
case LevelBackgroundPhase::Idle:
default: return 0.0f;
}
}
static void setPhase(LevelBackgroundFader& fader, LevelBackgroundPhase nextPhase) {
fader.phase = nextPhase;
fader.phaseDurationMs = getPhaseDurationMs(fader, nextPhase);
fader.phaseElapsedMs = 0.0f;
}
static void destroyTexture(SDL_Texture*& tex) {
if (tex) {
SDL_DestroyTexture(tex);
@ -146,32 +166,90 @@ static bool queueLevelBackground(LevelBackgroundFader& fader, SDL_Renderer* rend
destroyTexture(fader.nextTex);
fader.nextTex = newTexture;
fader.queuedLevel = level;
fader.fadeElapsedMs = 0.0f;
if (!fader.currentTex) {
// First background load happens instantly.
fader.currentTex = fader.nextTex;
fader.currentLevel = fader.queuedLevel;
fader.nextTex = nullptr;
fader.queuedLevel = -1;
fader.phase = LevelBackgroundPhase::Idle;
fader.phaseElapsedMs = 0.0f;
fader.phaseDurationMs = 0.0f;
} else if (fader.phase == LevelBackgroundPhase::Idle) {
// Kick off fancy transition.
setPhase(fader, LevelBackgroundPhase::ZoomOut);
}
return true;
}
static void updateLevelBackgroundFade(LevelBackgroundFader& fader, float frameMs) {
if (!fader.currentTex || !fader.nextTex) {
if (fader.phase == LevelBackgroundPhase::Idle) {
return;
}
fader.fadeElapsedMs += frameMs;
if (fader.fadeElapsedMs >= fader.fadeDurationMs) {
destroyTexture(fader.currentTex);
fader.currentTex = fader.nextTex;
fader.currentLevel = fader.queuedLevel;
fader.nextTex = nullptr;
fader.queuedLevel = -1;
fader.fadeElapsedMs = 0.0f;
// Guard against missing textures
if (!fader.currentTex && !fader.nextTex) {
fader.phase = LevelBackgroundPhase::Idle;
return;
}
fader.phaseElapsedMs += frameMs;
if (fader.phaseElapsedMs < std::max(1.0f, fader.phaseDurationMs)) {
return;
}
switch (fader.phase) {
case LevelBackgroundPhase::ZoomOut:
// After zoom-out, swap textures then start zoom-in.
if (fader.nextTex) {
destroyTexture(fader.currentTex);
fader.currentTex = fader.nextTex;
fader.currentLevel = fader.queuedLevel;
fader.nextTex = nullptr;
fader.queuedLevel = -1;
}
setPhase(fader, LevelBackgroundPhase::ZoomIn);
break;
case LevelBackgroundPhase::ZoomIn:
fader.phase = LevelBackgroundPhase::Idle;
fader.phaseElapsedMs = 0.0f;
fader.phaseDurationMs = 0.0f;
break;
case LevelBackgroundPhase::Idle:
default:
fader.phase = LevelBackgroundPhase::Idle;
break;
}
}
static void renderScaledBackground(SDL_Renderer* renderer, SDL_Texture* tex, int winW, int winH, float scale, Uint8 alpha = 255) {
if (!renderer || !tex) {
return;
}
scale = std::max(0.5f, scale);
SDL_FRect dest{
(winW - winW * scale) * 0.5f,
(winH - winH * scale) * 0.5f,
winW * scale,
winH * scale
};
SDL_SetTextureAlphaMod(tex, alpha);
SDL_RenderTexture(renderer, tex, nullptr, &dest);
SDL_SetTextureAlphaMod(tex, 255);
}
static void drawOverlay(SDL_Renderer* renderer, const SDL_FRect& rect, SDL_Color color, Uint8 alpha) {
if (!renderer || alpha == 0) {
return;
}
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, alpha);
SDL_RenderFillRect(renderer, &rect);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
}
static void renderLevelBackgrounds(const LevelBackgroundFader& fader, SDL_Renderer* renderer, int winW, int winH) {
@ -180,22 +258,36 @@ static void renderLevelBackgrounds(const LevelBackgroundFader& fader, SDL_Render
}
SDL_FRect fullRect{0.f, 0.f, static_cast<float>(winW), static_cast<float>(winH)};
const float duration = std::max(1.0f, fader.phaseDurationMs);
const float progress = (fader.phase == LevelBackgroundPhase::Idle) ? 0.0f : std::clamp(fader.phaseElapsedMs / duration, 0.0f, 1.0f);
if (fader.currentTex && fader.nextTex) {
const float duration = std::max(1.0f, fader.fadeDurationMs);
const float alpha = std::clamp(fader.fadeElapsedMs / duration, 0.0f, 1.0f);
SDL_SetTextureAlphaMod(fader.currentTex, Uint8((1.0f - alpha) * 255.0f));
SDL_RenderTexture(renderer, fader.currentTex, nullptr, &fullRect);
SDL_SetTextureAlphaMod(fader.currentTex, 255);
SDL_SetTextureAlphaMod(fader.nextTex, Uint8(alpha * 255.0f));
SDL_RenderTexture(renderer, fader.nextTex, nullptr, &fullRect);
SDL_SetTextureAlphaMod(fader.nextTex, 255);
} else if (fader.currentTex) {
SDL_RenderTexture(renderer, fader.currentTex, nullptr, &fullRect);
} else if (fader.nextTex) {
SDL_RenderTexture(renderer, fader.nextTex, nullptr, &fullRect);
switch (fader.phase) {
case LevelBackgroundPhase::ZoomOut: {
const float scale = 1.0f + progress * 0.15f;
if (fader.currentTex) {
renderScaledBackground(renderer, fader.currentTex, winW, winH, scale, Uint8((1.0f - progress * 0.4f) * 255.0f));
drawOverlay(renderer, fullRect, SDL_Color{0, 0, 0, 255}, Uint8(progress * 200.0f));
}
break;
}
case LevelBackgroundPhase::ZoomIn: {
const float scale = 1.10f - progress * 0.10f;
const Uint8 alpha = Uint8((0.4f + progress * 0.6f) * 255.0f);
if (fader.currentTex) {
renderScaledBackground(renderer, fader.currentTex, winW, winH, scale, alpha);
}
break;
}
case LevelBackgroundPhase::Idle:
default:
if (fader.currentTex) {
renderScaledBackground(renderer, fader.currentTex, winW, winH, 1.0f, 255);
} else if (fader.nextTex) {
renderScaledBackground(renderer, fader.nextTex, winW, winH, 1.0f, 255);
} else {
drawOverlay(renderer, fullRect, SDL_Color{0, 0, 0, 255}, 255);
}
break;
}
}
@ -204,7 +296,9 @@ static void resetLevelBackgrounds(LevelBackgroundFader& fader) {
destroyTexture(fader.nextTex);
fader.currentLevel = -1;
fader.queuedLevel = -1;
fader.fadeElapsedMs = 0.0f;
fader.phaseElapsedMs = 0.0f;
fader.phaseDurationMs = 0.0f;
fader.phase = LevelBackgroundPhase::Idle;
}
// Hover state for level popup ( -1 = none, 0..19 = hovered level )
@ -659,9 +753,18 @@ int main(int, char **)
SoundEffectManager::instance().loadSound("clear_line", "assets/music/clear_line.wav");
// Load voice lines for line clears using WAV files (with MP3 fallback)
std::vector<std::string> doubleSounds = {"nice_combo", "you_fire", "well_played", "keep_that_ryhtm"};
std::vector<std::string> tripleSounds = {"great_move", "smooth_clear", "impressive", "triple_strike"};
std::vector<std::string> singleSounds = {"well_played", "smooth_clear", "great_move"};
std::vector<std::string> doubleSounds = {"nice_combo", "you_fire", "keep_that_ryhtm"};
std::vector<std::string> tripleSounds = {"impressive", "triple_strike"};
std::vector<std::string> tetrisSounds = {"amazing", "you_re_unstoppable", "boom_tetris", "wonderful"};
std::vector<std::string> allVoiceSounds;
auto appendVoices = [&allVoiceSounds](const std::vector<std::string>& src) {
allVoiceSounds.insert(allVoiceSounds.end(), src.begin(), src.end());
};
appendVoices(singleSounds);
appendVoices(doubleSounds);
appendVoices(tripleSounds);
appendVoices(tetrisSounds);
// Helper function to load sound with WAV/MP3 fallback and file existence check
auto loadSoundWithFallback = [&](const std::string& id, const std::string& baseName) {
@ -704,26 +807,48 @@ int main(int, char **)
loadSoundWithFallback("boom_tetris", "boom_tetris");
loadSoundWithFallback("wonderful", "wonderful");
loadSoundWithFallback("lets_go", "lets_go"); // For level up
loadSoundWithFallback("hard_drop", "hard_drop_001");
loadSoundWithFallback("new_level", "new_level");
// Set up sound effect callbacks
game.setSoundCallback([&](int linesCleared) {
// Play basic line clear sound first
SoundEffectManager::instance().playSound("clear_line", 1.0f); // Increased volume
// Then play voice line based on number of lines cleared
if (linesCleared == 2) {
SoundEffectManager::instance().playRandomSound(doubleSounds, 1.0f); // Increased volume
} else if (linesCleared == 3) {
SoundEffectManager::instance().playRandomSound(tripleSounds, 1.0f); // Increased volume
} else if (linesCleared == 4) {
SoundEffectManager::instance().playRandomSound(tetrisSounds, 1.0f); // Increased volume
bool suppressLineVoiceForLevelUp = false;
auto playVoiceCue = [&](int linesCleared) {
const std::vector<std::string>* bank = nullptr;
switch (linesCleared) {
case 1: bank = &singleSounds; break;
case 2: bank = &doubleSounds; break;
case 3: bank = &tripleSounds; break;
default:
if (linesCleared >= 4) {
bank = &tetrisSounds;
}
break;
}
// Single line clears just play the basic clear sound (no voice in JS version)
if (bank && !bank->empty()) {
SoundEffectManager::instance().playRandomSound(*bank, 1.0f);
}
};
// Set up sound effect callbacks
game.setSoundCallback([&, playVoiceCue](int linesCleared) {
if (linesCleared <= 0) {
return;
}
// Always play the core line-clear sound for consistency
SoundEffectManager::instance().playSound("clear_line", 1.0f);
// Layer a voiced callout based on the number of cleared lines
if (!suppressLineVoiceForLevelUp) {
playVoiceCue(linesCleared);
}
suppressLineVoiceForLevelUp = false;
});
game.setLevelUpCallback([&](int newLevel) {
// Play level up sound
SoundEffectManager::instance().playSound("lets_go", 1.0f); // Increased volume
SoundEffectManager::instance().playSound("new_level", 1.0f);
SoundEffectManager::instance().playSound("lets_go", 1.0f); // Existing voice line
suppressLineVoiceForLevelUp = true;
});
AppState state = AppState::Loading;
@ -796,6 +921,31 @@ int main(int, char **)
running = false;
};
auto beginStateFade = [&](AppState targetState, bool armGameplayCountdown) {
if (!ctx.stateManager) {
return;
}
if (state == targetState) {
return;
}
if (menuFadePhase != MenuFadePhase::None) {
return;
}
menuFadePhase = MenuFadePhase::FadeOut;
menuFadeClockMs = 0.0;
menuFadeAlpha = 0.0f;
menuFadeTarget = targetState;
menuPlayCountdownArmed = armGameplayCountdown;
gameplayCountdownActive = false;
gameplayCountdownIndex = 0;
gameplayCountdownElapsed = 0.0;
if (!armGameplayCountdown) {
game.setPaused(false);
}
};
auto startMenuPlayTransition = [&]() {
if (!ctx.stateManager) {
return;
@ -805,20 +955,22 @@ int main(int, char **)
ctx.stateManager->setState(state);
return;
}
if (menuFadePhase != MenuFadePhase::None) {
return;
}
menuFadePhase = MenuFadePhase::FadeOut;
menuFadeClockMs = 0.0;
menuFadeAlpha = 0.0f;
menuFadeTarget = AppState::Playing;
menuPlayCountdownArmed = true;
gameplayCountdownActive = false;
gameplayCountdownIndex = 0;
gameplayCountdownElapsed = 0.0;
beginStateFade(AppState::Playing, true);
};
ctx.startPlayTransition = startMenuPlayTransition;
auto requestStateFade = [&](AppState targetState) {
if (!ctx.stateManager) {
return;
}
if (targetState == AppState::Playing) {
startMenuPlayTransition();
return;
}
beginStateFade(targetState, false);
};
ctx.requestFadeTransition = requestStateFade;
// Instantiate state objects
auto loadingState = std::make_unique<LoadingState>(ctx);
auto menuState = std::make_unique<MenuState>(ctx);
@ -898,8 +1050,10 @@ int main(int, char **)
}
if (e.key.scancode == SDL_SCANCODE_N)
{
// Test sound effects - play lets_go.wav specifically
SoundEffectManager::instance().playSound("lets_go", 1.0f);
// Manually trigger a random voice line for quick testing
if (!allVoiceSounds.empty()) {
SoundEffectManager::instance().playRandomSound(allVoiceSounds, 1.0f);
}
}
if (e.key.key == SDLK_F11 || (e.key.key == SDLK_RETURN && (e.key.mod & SDL_KMOD_ALT)))
{
@ -981,11 +1135,9 @@ int main(int, char **)
if (pointInRect(buttonRects[0])) {
startMenuPlayTransition();
} else if (pointInRect(buttonRects[1])) {
state = AppState::LevelSelector;
stateMgr.setState(state);
requestStateFade(AppState::LevelSelector);
} else if (pointInRect(buttonRects[2])) {
state = AppState::Options;
stateMgr.setState(state);
requestStateFade(AppState::Options);
} else if (pointInRect(buttonRects[3])) {
showExitConfirmPopup = true;
exitPopupSelectedButton = 1;
@ -1342,14 +1494,23 @@ int main(int, char **)
menuFadeClockMs += frameMs;
menuFadeAlpha = std::min(1.0f, float(menuFadeClockMs / MENU_PLAY_FADE_DURATION_MS));
if (menuFadeClockMs >= MENU_PLAY_FADE_DURATION_MS) {
if (menuFadeTarget == AppState::Playing) {
if (state != menuFadeTarget) {
state = menuFadeTarget;
stateMgr.setState(state);
}
if (menuFadeTarget == AppState::Playing) {
menuPlayCountdownArmed = true;
gameplayCountdownActive = false;
gameplayCountdownIndex = 0;
gameplayCountdownElapsed = 0.0;
game.setPaused(true);
} else {
menuPlayCountdownArmed = false;
gameplayCountdownActive = false;
gameplayCountdownIndex = 0;
gameplayCountdownElapsed = 0.0;
game.setPaused(false);
}
menuFadePhase = MenuFadePhase::FadeIn;
menuFadeClockMs = MENU_PLAY_FADE_DURATION_MS;
@ -1627,12 +1788,37 @@ int main(int, char **)
SDL_SetRenderDrawColor(renderer, 255, 220, 0, 255);
SDL_RenderRect(renderer, &inputRect);
// Player Name (with cursor)
std::string display = playerName;
if ((SDL_GetTicks() / 500) % 2 == 0) display += "_"; // Blink cursor
int nW=0, nH=0; pixelFont.measure(display, 1.2f, nW, nH);
pixelFont.draw(renderer, inputX + (inputW - nW) * 0.5f + contentOffsetX, inputY + (inputH - nH) * 0.5f + contentOffsetY, display, 1.2f, {255, 255, 255, 255});
// Player Name (blink cursor without shifting text)
const float nameScale = 1.2f;
const bool showCursor = ((SDL_GetTicks() / 500) % 2) == 0;
int metricsW = 0, metricsH = 0;
pixelFont.measure("A", nameScale, metricsW, metricsH);
if (metricsH == 0) metricsH = 24; // fallback height
int nameW = 0, nameH = 0;
if (!playerName.empty()) {
pixelFont.measure(playerName, nameScale, nameW, nameH);
} else {
nameH = metricsH;
}
float textX = inputX + (inputW - static_cast<float>(nameW)) * 0.5f + contentOffsetX;
float textY = inputY + (inputH - static_cast<float>(metricsH)) * 0.5f + contentOffsetY;
if (!playerName.empty()) {
pixelFont.draw(renderer, textX, textY, playerName, nameScale, {255, 255, 255, 255});
}
if (showCursor) {
int cursorW = 0, cursorH = 0;
pixelFont.measure("_", nameScale, cursorW, cursorH);
float cursorX = playerName.empty()
? inputX + (inputW - static_cast<float>(cursorW)) * 0.5f + contentOffsetX
: textX + static_cast<float>(nameW);
float cursorY = inputY + (inputH - static_cast<float>(cursorH)) * 0.5f + contentOffsetY;
pixelFont.draw(renderer, cursorX, cursorY, "_", nameScale, {255, 255, 255, 255});
}
// Hint
const char* hint = "PRESS ENTER TO SUBMIT";

View File

@ -326,14 +326,18 @@ void LevelSelectorState::selectLevel(int level) {
*ctx.startLevelSelection = level;
}
// Transition back to menu
if (ctx.stateManager) {
if (ctx.requestFadeTransition) {
ctx.requestFadeTransition(AppState::Menu);
} else if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Menu);
}
}
void LevelSelectorState::closePopup() {
// Transition back to menu without changing level
if (ctx.stateManager) {
if (ctx.requestFadeTransition) {
ctx.requestFadeTransition(AppState::Menu);
} else if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Menu);
}
}

View File

@ -126,10 +126,18 @@ void MenuState::handleEvent(const SDL_Event& e) {
triggerPlay();
break;
case 1:
ctx.stateManager->setState(AppState::LevelSelector);
if (ctx.requestFadeTransition) {
ctx.requestFadeTransition(AppState::LevelSelector);
} else if (ctx.stateManager) {
ctx.stateManager->setState(AppState::LevelSelector);
}
break;
case 2:
ctx.stateManager->setState(AppState::Options);
if (ctx.requestFadeTransition) {
ctx.requestFadeTransition(AppState::Options);
} else if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Options);
}
break;
case 3:
setExitPrompt(true);

View File

@ -61,6 +61,10 @@ void OptionsState::handleEvent(const SDL_Event& e) {
toggleSoundFx();
return;
}
if (m_selectedField == Field::SmoothScroll) {
toggleSmoothScroll();
return;
}
break;
default:
break;
@ -151,7 +155,7 @@ void OptionsState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
SDL_SetRenderDrawColor(renderer, 40, 80, 140, 240);
SDL_RenderRect(renderer, &inner);
constexpr int rowCount = 4;
constexpr int rowCount = 5;
const float rowHeight = 60.0f;
const float spacing = std::max(0.0f, (inner.h - rowHeight * rowCount) / (rowCount + 1));
@ -180,7 +184,7 @@ void OptionsState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
int valueW = 0, valueH = 0;
float valueScale = (field == Field::Back) ? 1.3f : 1.5f;
retroFont->measure(value, valueScale, valueW, valueH);
bool rightAlign = (field == Field::Fullscreen || field == Field::Music || field == Field::SoundFx);
bool rightAlign = (field == Field::Fullscreen || field == Field::Music || field == Field::SoundFx || field == Field::SmoothScroll);
float valX = rightAlign
? (row.x + row.w - static_cast<float>(valueW) - 16.0f)
: (row.x + (row.w - static_cast<float>(valueW)) * 0.5f);
@ -197,6 +201,8 @@ void OptionsState::render(SDL_Renderer* renderer, float logicalScale, SDL_Rect l
rowY += rowHeight + spacing;
drawField(Field::SoundFx, rowY, "SOUND FX", isSoundFxEnabled() ? "ON" : "OFF");
rowY += rowHeight + spacing;
drawField(Field::SmoothScroll, rowY, "SMOOTH SCROLL", isSmoothScrollEnabled() ? "ON" : "OFF");
rowY += rowHeight + spacing;
drawField(Field::Back, rowY, "", "RETURN TO MENU");
(void)retroFont; // footer removed for cleaner layout
@ -220,6 +226,9 @@ void OptionsState::activateSelection() {
case Field::SoundFx:
toggleSoundFx();
break;
case Field::SmoothScroll:
toggleSmoothScroll();
break;
case Field::Back:
exitToMenu();
break;
@ -266,8 +275,16 @@ void OptionsState::toggleSoundFx() {
Settings::instance().save();
}
void OptionsState::toggleSmoothScroll() {
bool next = !Settings::instance().isSmoothScrollEnabled();
Settings::instance().setSmoothScrollEnabled(next);
Settings::instance().save();
}
void OptionsState::exitToMenu() {
if (ctx.stateManager) {
if (ctx.requestFadeTransition) {
ctx.requestFadeTransition(AppState::Menu);
} else if (ctx.stateManager) {
ctx.stateManager->setState(AppState::Menu);
}
}
@ -289,3 +306,7 @@ bool OptionsState::isMusicEnabled() const {
bool OptionsState::isSoundFxEnabled() const {
return SoundEffectManager::instance().isEnabled();
}
bool OptionsState::isSmoothScrollEnabled() const {
return Settings::instance().isSmoothScrollEnabled();
}

View File

@ -16,7 +16,8 @@ private:
Fullscreen = 0,
Music = 1,
SoundFx = 2,
Back = 3
SmoothScroll = 3,
Back = 4
};
static constexpr int MAX_NAME_LENGTH = 12;
@ -29,8 +30,10 @@ private:
void toggleFullscreen();
void toggleMusic();
void toggleSoundFx();
void toggleSmoothScroll();
void exitToMenu();
bool isFullscreen() const;
bool isMusicEnabled() const;
bool isSoundFxEnabled() const;
bool isSmoothScrollEnabled() const;
};

View File

@ -4,6 +4,7 @@
#include "../gameplay/effects/LineEffect.h"
#include "../persistence/Scores.h"
#include "../audio/Audio.h"
#include "../audio/SoundEffect.h"
#include "../graphics/renderers/GameRenderer.h"
#include "../core/Config.h"
#include <SDL3/SDL.h>
@ -115,6 +116,7 @@ void PlayingState::handleEvent(const SDL_Event& e) {
// Hard drop (space)
if (e.key.scancode == SDL_SCANCODE_SPACE) {
SoundEffectManager::instance().playSound("hard_drop", 0.7f);
ctx.game->hardDrop();
return;
}
@ -128,6 +130,8 @@ void PlayingState::handleEvent(const SDL_Event& e) {
void PlayingState::update(double frameMs) {
if (!ctx.game) return;
ctx.game->updateVisualEffects(frameMs);
// forward per-frame gameplay updates (gravity, line effects)
if (!ctx.game->isPaused()) {
ctx.game->tickGravity(frameMs);

View File

@ -13,6 +13,7 @@ class Starfield;
class Starfield3D;
class FontAtlas;
class LineEffect;
enum class AppState;
// Forward declare StateManager so StateContext can hold a pointer without
// including the StateManager header here.
@ -58,6 +59,7 @@ struct StateContext {
std::function<bool()> queryFullscreen; // Optional callback if fullscreenFlag is not reliable
std::function<void()> requestQuit; // Allows menu/option states to close the app gracefully
std::function<void()> startPlayTransition; // Optional fade hook when transitioning from menu to gameplay
std::function<void(AppState)> requestFadeTransition; // Generic state fade requests (menu/options/level)
// Pointer to the application's StateManager so states can request transitions
StateManager* stateManager = nullptr;
};