|
#!/usr/bin/env php |
|
<?php |
|
/** |
|
* Tetris - A Terminal-Based Implementation |
|
* |
|
* @author Alireza Bashiri |
|
* @version 1.0 |
|
* @license MIT |
|
* |
|
* Created after watching the Tetris movie (2023), which motivated me to both learn |
|
* something fun and explore how optimized we can make a 30 FPS terminal game. |
|
*/ |
|
|
|
class Tetris { |
|
private $board; |
|
private $width = 10; |
|
private $height = 20; |
|
private $currentPiece; |
|
private $currentX; |
|
private $currentY; |
|
private $currentRotation = 0; |
|
private $score = 0; |
|
private $gameOver = false; |
|
private $renderBuffer = ''; |
|
private $pieceCache = []; |
|
|
|
private $pieces = [ |
|
// I piece - all 4 rotations pre-calculated |
|
[ |
|
[[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]], |
|
[[0,0,1,0],[0,0,1,0],[0,0,1,0],[0,0,1,0]], |
|
[[0,0,0,0],[0,0,0,0],[1,1,1,1],[0,0,0,0]], |
|
[[0,1,0,0],[0,1,0,0],[0,1,0,0],[0,1,0,0]] |
|
], |
|
// O piece |
|
[ |
|
[[1,1],[1,1]], |
|
[[1,1],[1,1]], |
|
[[1,1],[1,1]], |
|
[[1,1],[1,1]] |
|
], |
|
// T piece |
|
[ |
|
[[0,1,0],[1,1,1],[0,0,0]], |
|
[[0,1,0],[0,1,1],[0,1,0]], |
|
[[0,0,0],[1,1,1],[0,1,0]], |
|
[[0,1,0],[1,1,0],[0,1,0]] |
|
], |
|
// S piece |
|
[ |
|
[[0,1,1],[1,1,0],[0,0,0]], |
|
[[0,1,0],[0,1,1],[0,0,1]], |
|
[[0,0,0],[0,1,1],[1,1,0]], |
|
[[1,0,0],[1,1,0],[0,1,0]] |
|
], |
|
// Z piece |
|
[ |
|
[[1,1,0],[0,1,1],[0,0,0]], |
|
[[0,0,1],[0,1,1],[0,1,0]], |
|
[[0,0,0],[1,1,0],[0,1,1]], |
|
[[0,1,0],[1,1,0],[1,0,0]] |
|
], |
|
// J piece |
|
[ |
|
[[1,0,0],[1,1,1],[0,0,0]], |
|
[[0,1,1],[0,1,0],[0,1,0]], |
|
[[0,0,0],[1,1,1],[0,0,1]], |
|
[[0,1,0],[0,1,0],[1,1,0]] |
|
], |
|
// L piece |
|
[ |
|
[[0,0,1],[1,1,1],[0,0,0]], |
|
[[0,1,0],[0,1,0],[0,1,1]], |
|
[[0,0,0],[1,1,1],[1,0,0]], |
|
[[1,1,0],[0,1,0],[0,1,0]] |
|
] |
|
]; |
|
|
|
private $pieceIndex; |
|
|
|
public function __construct() { |
|
$this->initBoard(); |
|
$this->spawnPiece(); |
|
} |
|
|
|
private function initBoard() { |
|
$this->board = array_fill(0, $this->height, 0); |
|
for ($y = 0; $y < $this->height; $y++) { |
|
$this->board[$y] = 0; |
|
} |
|
} |
|
|
|
private function spawnPiece() { |
|
$this->pieceIndex = mt_rand(0, count($this->pieces) - 1); |
|
$this->currentRotation = 0; |
|
$this->currentPiece = $this->pieces[$this->pieceIndex][$this->currentRotation]; |
|
$this->currentX = floor(($this->width - count($this->currentPiece[0])) / 2); |
|
$this->currentY = 0; |
|
|
|
if (!$this->isValidPosition($this->currentPiece, $this->currentX, $this->currentY)) { |
|
$this->gameOver = true; |
|
} |
|
} |
|
|
|
private function isValidPosition($piece, $x, $y) { |
|
$pieceHeight = count($piece); |
|
$pieceWidth = count($piece[0]); |
|
|
|
for ($py = 0; $py < $pieceHeight; $py++) { |
|
for ($px = 0; $px < $pieceWidth; $px++) { |
|
if (!$piece[$py][$px]) continue; |
|
|
|
$boardX = $x + $px; |
|
$boardY = $y + $py; |
|
|
|
if ($boardX < 0 || $boardX >= $this->width || $boardY >= $this->height) { |
|
return false; |
|
} |
|
|
|
if ($boardY >= 0 && ($this->board[$boardY] & (1 << $boardX))) { |
|
return false; |
|
} |
|
} |
|
} |
|
return true; |
|
} |
|
|
|
private function lockPiece() { |
|
$pieceHeight = count($this->currentPiece); |
|
$pieceWidth = count($this->currentPiece[0]); |
|
|
|
for ($py = 0; $py < $pieceHeight; $py++) { |
|
for ($px = 0; $px < $pieceWidth; $px++) { |
|
if (!$this->currentPiece[$py][$px]) continue; |
|
|
|
$boardY = $this->currentY + $py; |
|
$boardX = $this->currentX + $px; |
|
if ($boardY >= 0) { |
|
$this->board[$boardY] |= (1 << $boardX); |
|
} |
|
} |
|
} |
|
$this->clearLines(); |
|
$this->spawnPiece(); |
|
} |
|
|
|
private function clearLines() { |
|
$linesCleared = 0; |
|
$fullLine = (1 << $this->width) - 1; |
|
|
|
for ($y = $this->height - 1; $y >= 0; $y--) { |
|
if ($this->board[$y] === $fullLine) { |
|
array_splice($this->board, $y, 1); |
|
array_unshift($this->board, 0); |
|
$linesCleared++; |
|
$y++; |
|
} |
|
} |
|
|
|
if ($linesCleared > 0) { |
|
$points = [0, 100, 300, 500, 800]; |
|
$this->score += $points[min($linesCleared, 4)]; |
|
} |
|
} |
|
|
|
public function moveLeft() { |
|
if ($this->isValidPosition($this->currentPiece, $this->currentX - 1, $this->currentY)) { |
|
$this->currentX--; |
|
} |
|
} |
|
|
|
public function moveRight() { |
|
if ($this->isValidPosition($this->currentPiece, $this->currentX + 1, $this->currentY)) { |
|
$this->currentX++; |
|
} |
|
} |
|
|
|
public function moveDown() { |
|
if ($this->isValidPosition($this->currentPiece, $this->currentX, $this->currentY + 1)) { |
|
$this->currentY++; |
|
return true; |
|
} |
|
$this->lockPiece(); |
|
return false; |
|
} |
|
|
|
public function hardDrop() { |
|
$dropDistance = 0; |
|
while ($this->isValidPosition($this->currentPiece, $this->currentX, $this->currentY + 1)) { |
|
$this->currentY++; |
|
$dropDistance++; |
|
} |
|
$this->score += $dropDistance * 2; |
|
$this->lockPiece(); |
|
} |
|
|
|
public function rotate() { |
|
$newRotation = ($this->currentRotation + 1) % 4; |
|
$rotatedPiece = $this->pieces[$this->pieceIndex][$newRotation]; |
|
|
|
if ($this->isValidPosition($rotatedPiece, $this->currentX, $this->currentY)) { |
|
$this->currentRotation = $newRotation; |
|
$this->currentPiece = $rotatedPiece; |
|
return; |
|
} |
|
|
|
// Wall kick attempts |
|
$kicks = [[-1, 0], [1, 0], [0, -1], [-2, 0], [2, 0]]; |
|
foreach ($kicks as $kick) { |
|
if ($this->isValidPosition($rotatedPiece, $this->currentX + $kick[0], $this->currentY + $kick[1])) { |
|
$this->currentX += $kick[0]; |
|
$this->currentY += $kick[1]; |
|
$this->currentRotation = $newRotation; |
|
$this->currentPiece = $rotatedPiece; |
|
return; |
|
} |
|
} |
|
} |
|
|
|
public function render() { |
|
$this->renderBuffer = "\033[H\033[J"; |
|
|
|
// Create display bitmap |
|
$display = $this->board; |
|
|
|
// Add current piece to display |
|
$pieceHeight = count($this->currentPiece); |
|
$pieceWidth = count($this->currentPiece[0]); |
|
|
|
for ($py = 0; $py < $pieceHeight; $py++) { |
|
for ($px = 0; $px < $pieceWidth; $px++) { |
|
if (!$this->currentPiece[$py][$px]) continue; |
|
|
|
$boardY = $this->currentY + $py; |
|
$boardX = $this->currentX + $px; |
|
if ($boardY >= 0 && $boardY < $this->height && $boardX >= 0 && $boardX < $this->width) { |
|
$display[$boardY] |= (1 << $boardX); |
|
} |
|
} |
|
} |
|
|
|
// Build render buffer |
|
$this->renderBuffer .= "<!"; |
|
for ($x = 0; $x < $this->width; $x++) { |
|
$this->renderBuffer .= "=="; |
|
} |
|
$this->renderBuffer .= "!>\n"; |
|
|
|
for ($y = 0; $y < $this->height; $y++) { |
|
$this->renderBuffer .= "<!"; |
|
for ($x = 0; $x < $this->width; $x++) { |
|
$this->renderBuffer .= ($display[$y] & (1 << $x)) ? "[]" : ". "; |
|
} |
|
$this->renderBuffer .= "!>\n"; |
|
} |
|
|
|
$this->renderBuffer .= "<!"; |
|
for ($x = 0; $x < $this->width; $x++) { |
|
$this->renderBuffer .= "=="; |
|
} |
|
$this->renderBuffer .= "!>\n"; |
|
|
|
$this->renderBuffer .= "\nScore: " . $this->score . "\n"; |
|
$this->renderBuffer .= "\nControls: a/d:move s:drop w:rotate space:hard q:quit\n"; |
|
|
|
if ($this->gameOver) { |
|
$this->renderBuffer .= "\n*** GAME OVER ***\nFinal Score: " . $this->score . "\n"; |
|
} |
|
|
|
echo $this->renderBuffer; |
|
} |
|
|
|
public function isGameOver() { |
|
return $this->gameOver; |
|
} |
|
} |
|
|
|
// Terminal handling |
|
if (!function_exists('readline_callback_handler_install')) { |
|
function enableRawMode() { |
|
system('stty -icanon -echo min 0 time 0'); |
|
} |
|
|
|
function disableRawMode() { |
|
system('stty sane'); |
|
} |
|
} else { |
|
function enableRawMode() { |
|
readline_callback_handler_install('', function() {}); |
|
stream_set_blocking(STDIN, false); |
|
} |
|
|
|
function disableRawMode() { |
|
readline_callback_handler_remove(); |
|
stream_set_blocking(STDIN, true); |
|
} |
|
} |
|
|
|
function getChar() { |
|
return fgetc(STDIN); |
|
} |
|
|
|
// Main game loop |
|
$game = new Tetris(); |
|
enableRawMode(); |
|
register_shutdown_function('disableRawMode'); |
|
|
|
$lastDrop = microtime(true); |
|
$dropInterval = 0.5; |
|
$lastRender = 0; |
|
$renderInterval = 0.03333; // 30 FPS |
|
|
|
pcntl_signal(SIGINT, function() { |
|
disableRawMode(); |
|
echo "\nThanks for playing!\n"; |
|
exit(0); |
|
}); |
|
|
|
while (!$game->isGameOver()) { |
|
$currentTime = microtime(true); |
|
|
|
// Auto drop |
|
if ($currentTime - $lastDrop > $dropInterval) { |
|
$game->moveDown(); |
|
$lastDrop = $currentTime; |
|
} |
|
|
|
// Render at 30 FPS |
|
if ($currentTime - $lastRender > $renderInterval) { |
|
$game->render(); |
|
$lastRender = $currentTime; |
|
} |
|
|
|
// Handle input |
|
$input = getChar(); |
|
if ($input !== false && $input !== '') { |
|
switch (strtolower($input)) { |
|
case 'a': $game->moveLeft(); break; |
|
case 'd': $game->moveRight(); break; |
|
case 's': |
|
if ($game->moveDown()) { |
|
$lastDrop = $currentTime; |
|
} |
|
break; |
|
case 'w': $game->rotate(); break; |
|
case ' ': |
|
$game->hardDrop(); |
|
$lastDrop = $currentTime; |
|
break; |
|
case 'q': |
|
disableRawMode(); |
|
echo "\nThanks for playing!\n"; |
|
exit(0); |
|
} |
|
} |
|
|
|
pcntl_signal_dispatch(); |
|
usleep(10000); // 10ms |
|
} |
|
|
|
$game->render(); |
|
disableRawMode(); |
|
echo "\nPress any key to exit...\n"; |
|
fgetc(STDIN); |