// Game.cpp - Implementation of core Tetris game logic #include "Game.h" #include #include #include // Piece rotation bitmasks (row-major 4x4). Bit 0 = (0,0). static const std::array 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(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(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(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> 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 }