Files
spacetris/src/gameplay/core/Game.cpp

495 lines
16 KiB
C++

// 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; gameOver=false; paused=false;
hardDropShakeTimerMs = 0.0;
hardDropCells.clear();
hardDropFxId = 0;
_startTime = SDL_GetPerformanceCounter();
_pausedTime = 0;
_lastPauseStart = 0;
hold = Piece{}; hold.type = PIECE_COUNT; canHold=true;
refillBag();
pieceSequence = 0;
spawn();
}
double Game::elapsed() const {
if (!_startTime) return 0.0;
Uint64 currentTime = SDL_GetPerformanceCounter();
Uint64 totalPausedTime = _pausedTime;
// If currently paused, add time since pause started
if (paused && _lastPauseStart > 0) {
totalPausedTime += (currentTime - _lastPauseStart);
}
Uint64 activeTime = currentTime - _startTime - totalPausedTime;
double seconds = (double)activeTime / (double)SDL_GetPerformanceFrequency();
return seconds;
}
void Game::updateElapsedTime() {
// This method is now just for API compatibility
// Actual elapsed time is calculated on-demand in elapsed()
}
void Game::setPaused(bool p) {
if (p == paused) return; // No change
if (p) {
// Pausing - record when pause started
_lastPauseStart = SDL_GetPerformanceCounter();
} else {
// Unpausing - add elapsed pause time to total
if (_lastPauseStart > 0) {
Uint64 currentTime = SDL_GetPerformanceCounter();
_pausedTime += (currentTime - _lastPauseStart);
_lastPauseStart = 0;
}
}
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));
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 };
++pieceSequence;
}
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
// Soft drop: 20x faster for rapid continuous dropping
double effectiveGravityMs = softDropping ? (gravityMs / 5.0) : gravityMs;
fallAcc += frameMs;
while (fallAcc >= effectiveGravityMs) {
// Attempt to move down by one row
if (tryMoveDown()) {
// Award soft drop points only if player is actively holding Down
if (softDropping) {
_score += 1;
}
} else {
// Can't move down further, lock piece
lockPiece();
if (gameOver) break;
}
fallAcc -= effectiveGravityMs;
}
}
void Game::softDropBoost(double frameMs) {
// This method is now deprecated - soft drop is handled in tickGravity
// Kept for API compatibility but does nothing
(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
int rows = 0;
while (tryMoveDown()) { rows++; }
// JS: POINTS.HARD_DROP = 1 per cell
if (rows > 0) {
_score += rows * 1;
}
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) {
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
}