feat: implement textured line clear effects and refine UI alignment

- **Visual Effects**: Upgraded line clear particles to use the game's block texture instead of simple circles, matching the reference web game's aesthetic.
- **Particle Physics**: Tuned particle velocity, gravity, and fade rates for a more dynamic explosion effect.
- **Rendering Integration**: Updated [main.cpp](cci:7://file:///d:/Sites/Work/tetris/src/main.cpp:0:0-0:0) and `GameRenderer` to pass the block texture to the effect system and correctly trigger animations upon line completion.
- **Menu UI**: Fixed [MenuState](cci:1://file:///d:/Sites/Work/tetris/src/states/MenuState.cpp:19:0-19:55) layout calculations to use fixed logical dimensions (1200x1000), ensuring consistent centering and alignment of the logo, buttons, and settings icon across different window sizes.
- **Code Cleanup**: Refactored `PlayingState` to delegate effect triggering to the rendering layer where correct screen coordinates are available.
This commit is contained in:
2025-11-21 21:19:14 +01:00
parent b5ef9172b3
commit 66099809e0
47 changed files with 5547 additions and 267 deletions

379
src/gameplay/core/Game.cpp Normal file
View File

@ -0,0 +1,379 @@
// Game.cpp - Implementation of core Tetris game logic
#include "Game.h"
#include <algorithm>
#include <cmath>
#include <SDL3/SDL.h>
// Piece rotation bitmasks (row-major 4x4). Bit 0 = (0,0).
static const std::array<Shape, PIECE_COUNT> SHAPES = {{
Shape{ 0x0F00, 0x2222, 0x00F0, 0x4444 }, // I
Shape{ 0x0660, 0x0660, 0x0660, 0x0660 }, // O
Shape{ 0x0E40, 0x4C40, 0x4E00, 0x4640 }, // T
Shape{ 0x06C0, 0x4620, 0x06C0, 0x4620 }, // S
Shape{ 0x0C60, 0x2640, 0x0C60, 0x2640 }, // Z
Shape{ 0x08E0, 0x6440, 0x0E20, 0x44C0 }, // J
Shape{ 0x02E0, 0x4460, 0x0E80, 0xC440 }, // L
}};
// NES (NTSC) gravity table: frames per grid cell for each level.
// Based on: 0-9, 10-12: 5, 13-15: 4, 16-18: 3, 19-28: 2, 29+: 1
namespace {
constexpr double NES_FPS = 60.0988;
constexpr double FRAME_MS = 1000.0 / NES_FPS;
struct LevelGravity { int framesPerCell; double levelMultiplier; };
// Default table following NES values; levelMultiplier starts at 1.0 and can be tuned per-level
LevelGravity LEVEL_TABLE[30] = {
{48,1.0}, {43,1.0}, {38,1.0}, {33,1.0}, {28,1.0}, {23,1.0}, {18,1.0}, {13,1.0}, {8,1.0}, {6,1.0},
{5,1.0}, {5,1.0}, {5,1.0}, {4,1.0}, {4,1.0}, {4,1.0}, {3,1.0}, {3,1.0}, {3,1.0}, {2,1.0},
{2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {2,1.0}, {1,1.0}
};
inline double gravityMsForLevel(int level, double globalMultiplier)
{
int idx = level < 0 ? 0 : (level >= 29 ? 29 : level);
const LevelGravity &lg = LEVEL_TABLE[idx];
double frames = lg.framesPerCell * lg.levelMultiplier;
double result = frames * FRAME_MS * globalMultiplier;
static int debug_calls = 0;
/*
if (debug_calls < 3) {
printf("Level %d: %d frames per cell (mult %.2f) = %.1f ms per cell (global x%.2f)\\n",
level, lg.framesPerCell, lg.levelMultiplier, result, globalMultiplier);
debug_calls++;
}
*/
return result;
}
}
void Game::reset(int startLevel_) {
std::fill(board.begin(), board.end(), 0);
std::fill(blockCounts.begin(), blockCounts.end(), 0);
bag.clear();
_score = 0; _lines = 0; _level = startLevel_; startLevel = startLevel_;
// Initialize gravity using NES timing table (ms per cell by level)
gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier);
fallAcc = 0; _elapsedSec = 0; gameOver=false; paused=false;
hold = Piece{}; hold.type = PIECE_COUNT; canHold=true;
refillBag(); spawn();
}
void Game::refillBag() {
bag.clear();
for (int i=0;i<PIECE_COUNT;++i) bag.push_back(static_cast<PieceType>(i));
std::shuffle(bag.begin(), bag.end(), rng);
}
double Game::getGravityGlobalMultiplier() const { return gravityGlobalMultiplier; }
double Game::getGravityMs() const { return gravityMs; }
void Game::setLevelGravityMultiplier(int level, double m) {
if (level < 0) return;
int idx = level >= 29 ? 29 : level;
LEVEL_TABLE[idx].levelMultiplier = m;
// If current level changed, refresh gravityMs
if (_level == idx) gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier);
}
void Game::spawn() {
if (bag.empty()) refillBag();
PieceType pieceType = bag.back();
// I-piece needs to start one row higher due to its height when vertical
int spawnY = (pieceType == I) ? -2 : -1;
cur = Piece{ pieceType, 0, 3, spawnY };
// Check if the newly spawned piece collides with existing blocks
if (collides(cur)) {
gameOver = true;
return; // Don't proceed with spawning if it causes a collision
}
bag.pop_back();
blockCounts[cur.type]++; // Increment count for this piece type
canHold = true;
// Prepare next piece
if (bag.empty()) refillBag();
PieceType nextType = bag.back();
int nextSpawnY = (nextType == I) ? -2 : -1;
nextPiece = Piece{ nextType, 0, 3, nextSpawnY };
}
bool Game::cellFilled(const Piece& p, int cx, int cy) {
if (p.type == PIECE_COUNT) return false;
const uint16_t mask = SHAPES[p.type][p.rot];
const int bit = cy*4 + cx;
return (mask >> bit) & 1;
}
bool Game::collides(const Piece& p) const {
for (int cy=0; cy<4; ++cy) {
for (int cx=0; cx<4; ++cx) if (cellFilled(p,cx,cy)) {
int gx = p.x + cx; int gy = p.y + cy;
if (gx < 0 || gx >= COLS || gy >= ROWS) return true;
if (gy >= 0 && board[gy*COLS + gx] != 0) return true;
}
}
return false;
}
void Game::lockPiece() {
for (int cy=0; cy<4; ++cy) {
for (int cx=0; cx<4; ++cx) if (cellFilled(cur,cx,cy)) {
int gx = cur.x + cx; int gy = cur.y + cy;
if (gy >= 0 && gy < ROWS) board[gy*COLS + gx] = static_cast<int>(cur.type)+1;
if (gy < 0) gameOver = true;
}
}
// Check for completed lines but don't clear them yet - let the effect system handle it
int cleared = checkLines();
if (cleared > 0) {
// JS scoring system: base points per clear, multiplied by (level+1) in JS.
// Our _level is 1-based (JS level + 1), so multiplier == _level.
int base = 0;
switch (cleared) {
case 1: base = 40; break; // SINGLE
case 2: base = 100; break; // DOUBLE
case 3: base = 300; break; // TRIPLE
case 4: base = 1200; break; // TETRIS
default: base = 0; break;
}
// multiplier is level+1 to match original scoring where level 0 => x1
_score += base * (_level + 1);
// Update total lines
_lines += cleared;
// JS level progression (NES-like) using starting level rules
// Both startLevel and _level are 0-based now.
int targetLevel = startLevel;
int firstThreshold = (startLevel + 1) * 10;
if (_lines >= firstThreshold) {
targetLevel = startLevel + 1 + (_lines - firstThreshold) / 10;
}
// If we haven't reached the first threshold yet, we are still at startLevel.
// The above logic handles this (targetLevel initialized to startLevel).
if (targetLevel > _level) {
_level = targetLevel;
// Update gravity to exact NES speed for the new level
gravityMs = gravityMsForLevel(_level, gravityGlobalMultiplier);
if (levelUpCallback) levelUpCallback(_level);
}
// Trigger sound effect callback for line clears
if (soundCallback) {
soundCallback(cleared);
}
}
if (!gameOver) spawn();
}
int Game::checkLines() {
completedLines.clear();
// Check each row from bottom to top
for (int y = ROWS - 1; y >= 0; --y) {
bool full = true;
for (int x = 0; x < COLS; ++x) {
if (board[y*COLS + x] == 0) {
full = false;
break;
}
}
if (full) {
completedLines.push_back(y);
}
}
return static_cast<int>(completedLines.size());
}
void Game::clearCompletedLines() {
if (completedLines.empty()) return;
actualClearLines();
completedLines.clear();
}
void Game::actualClearLines() {
if (completedLines.empty()) return;
int write = ROWS - 1;
for (int y = ROWS - 1; y >= 0; --y) {
// Check if this row should be cleared
bool shouldClear = std::find(completedLines.begin(), completedLines.end(), y) != completedLines.end();
if (!shouldClear) {
// Keep this row, move it down if necessary
if (write != y) {
for (int x = 0; x < COLS; ++x) {
board[write*COLS + x] = board[y*COLS + x];
}
}
--write;
}
// If shouldClear is true, we skip this row (effectively removing it)
}
// Clear the top rows that are now empty
for (int y = write; y >= 0; --y) {
for (int x = 0; x < COLS; ++x) {
board[y*COLS + x] = 0;
}
}
}
bool Game::tryMoveDown() {
Piece p = cur; p.y += 1; if (!collides(p)) { cur = p; return true; } return false;
}
void Game::tickGravity(double frameMs) {
if (paused) return; // Don't tick gravity when paused
fallAcc += frameMs;
while (fallAcc >= gravityMs) {
// Attempt to move down by one row
if (tryMoveDown()) {
// Award soft drop points only if player is actively holding Down
// JS: POINTS.SOFT_DROP = 1 per cell for soft drop
if (softDropping) {
_score += 1;
}
} else {
// Can't move down further, lock piece
lockPiece();
if (gameOver) break;
}
fallAcc -= gravityMs;
}
}
void Game::softDropBoost(double frameMs) {
// Reduce soft drop speed multiplier from 10.0 to 3.0 to make it less aggressive
if (!paused) fallAcc += frameMs * 3.0;
}
void Game::hardDrop() {
if (paused) return;
// Count how many rows we drop for scoring parity with JS
int rows = 0;
while (tryMoveDown()) { rows++; }
// JS: POINTS.HARD_DROP = 1 per cell
if (rows > 0) {
_score += rows * 1;
}
lockPiece();
}
void Game::rotate(int dir) {
if (paused) return;
Piece p = cur;
p.rot = (p.rot + dir + 4) % 4;
// Try rotation at current position first
if (!collides(p)) {
cur = p;
return;
}
// Standard SRS Wall Kicks
// See: https://tetris.wiki/Super_Rotation_System#Wall_kicks
// JLSTZ Wall Kicks (0->R, R->2, 2->L, L->0)
// We only implement the clockwise (0->1, 1->2, 2->3, 3->0) and counter-clockwise (0->3, 3->2, 2->1, 1->0)
// For simplicity in this codebase, we'll use a unified set of tests that covers most cases
// or we can implement the full table.
// Let's use a robust set of kicks that covers most standard situations
std::vector<std::pair<int,int>> kicks;
if (p.type == I) {
// I-piece kicks
kicks = {
{0, 0}, // Basic rotation
{-2, 0}, {1, 0}, {-2, -1}, {1, 2}, // 0->1 (R)
{2, 0}, {-1, 0}, {2, 1}, {-1, -2}, // 1->0 (L)
{-1, 0}, {2, 0}, {-1, 2}, {2, -1}, // 1->2 (R)
{1, 0}, {-2, 0}, {1, -2}, {-2, 1}, // 2->1 (L)
{2, 0}, {-1, 0}, {2, 1}, {-1, -2}, // 2->3 (R)
{-2, 0}, {1, 0}, {-2, -1}, {1, 2}, // 3->2 (L)
{1, 0}, {-2, 0}, {1, -2}, {-2, 1}, // 3->0 (R)
{-1, 0}, {2, 0}, {-1, 2}, {2, -1} // 0->3 (L)
};
// The above is a superset; for a specific rotation state transition we should pick the right row.
// However, since we don't track "last rotation state" easily here (we just have p.rot),
// we'll try a generally permissive set of kicks that works for I-piece.
// A simplified "try everything" approach for I-piece:
kicks = {
{0, 0},
{-2, 0}, { 2, 0},
{-1, 0}, { 1, 0},
{ 0,-1}, { 0, 1}, // Up/Down
{-2,-1}, { 2,-1}, // Diagonal up
{ 1, 2}, {-1, 2}, // Diagonal down
{-2, 1}, { 2, 1}
};
} else {
// JLSTZ kicks
kicks = {
{0, 0},
{-1, 0}, { 1, 0}, // Left/Right
{ 0,-1}, // Up (floor kick)
{-1,-1}, { 1,-1}, // Diagonal up
{ 0, 1} // Down (rare but possible)
};
}
for (auto kick : kicks) {
Piece test = p;
test.x = cur.x + kick.first;
test.y = cur.y + kick.second;
if (!collides(test)) {
cur = test;
return;
}
}
}
void Game::move(int dx) {
if (paused) return;
Piece p = cur; p.x += dx; if (!collides(p)) cur = p;
}
void Game::holdCurrent() {
if (paused || !canHold) return;
if (hold.type == PIECE_COUNT) {
// First hold - just store current piece and spawn new one
hold = cur;
// I-piece needs to start one row higher due to its height when vertical
int holdSpawnY = (hold.type == I) ? -2 : -1;
hold.x = 3; hold.y = holdSpawnY; hold.rot = 0;
spawn();
} else {
// Swap current with held piece
Piece temp = hold;
hold = cur;
// I-piece needs to start one row higher due to its height when vertical
int holdSpawnY = (hold.type == I) ? -2 : -1;
int currentSpawnY = (temp.type == I) ? -2 : -1;
hold.x = 3; hold.y = holdSpawnY; hold.rot = 0;
cur = temp;
cur.x = 3; cur.y = currentSpawnY; cur.rot = 0;
}
canHold = false; // Can only hold once per piece spawn
}