495 lines
16 KiB
C++
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
|
|
}
|